@lythos/skill-deck 0.9.2 → 0.9.13
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 +28 -4
- package/package.json +1 -1
- package/src/add.test.ts +1 -1
- package/src/add.ts +8 -48
- package/src/cli.ts +5 -7
- package/src/link.ts +1 -1
- package/src/prune-plan.test.ts +103 -0
- package/src/prune-plan.ts +188 -0
- package/src/prune.ts +40 -89
- package/src/refresh-plan.test.ts +118 -0
- package/src/refresh-plan.ts +212 -0
- package/src/refresh.ts +47 -160
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @lythos/skill-deck
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+
   
|
|
4
4
|
|
|
5
5
|
> Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
|
|
6
6
|
|
|
@@ -68,7 +68,7 @@ expires = "2026-05-01" # ISO date; warns at ≤14 days
|
|
|
68
68
|
|---------|------|-------------|
|
|
69
69
|
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
|
|
70
70
|
| `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
|
|
71
|
-
| `add` | `<locator> [--
|
|
71
|
+
| `add` | `<locator> [--alias <alias>] [--type <type>] [--deck <path>]` | Git clone skill to cold pool and append to skill-deck.toml. |
|
|
72
72
|
| `refresh` | `[<fq|alias>] [--deck <path>]` | Pull latest versions of declared skills from upstream git repos. Pass a name to refresh one skill. |
|
|
73
73
|
| `remove` | `<fq|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
|
|
74
74
|
| `prune` | `[--yes] [--deck <path>]` | GC cold pool repos no longer referenced. Interactive confirm (skip with `--yes`). |
|
|
@@ -79,8 +79,8 @@ expires = "2026-05-01" # ISO date; warns at ≤14 days
|
|
|
79
79
|
|------|-------------|---------|
|
|
80
80
|
| `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
|
|
81
81
|
| `--workdir <dir>` | Working directory | cwd |
|
|
82
|
-
|
|
83
|
-
| `--
|
|
82
|
+
|
|
83
|
+
| `--alias <alias>` | Explicit alias for the skill (default: basename of path) | — |
|
|
84
84
|
| `--type <type>` | Target section for `add`: `innate`, `tool`, or `combo` | `tool` |
|
|
85
85
|
|
|
86
86
|
### Safety guards
|
|
@@ -157,6 +157,30 @@ Different agents look for skills in different directories. `skill-deck.toml` con
|
|
|
157
157
|
| `deck add` fails with 404 | Locator format wrong or repo doesn't exist | Format: `github.com/owner/repo/skill-name` (path to skill directory inside repo) |
|
|
158
158
|
| `skill-deck.toml not found` | Running `link` outside project tree | Run from project root, or use `--deck ./path/to/skill-deck.toml` |
|
|
159
159
|
|
|
160
|
+
## Architecture: Intent / Plan / Execute
|
|
161
|
+
|
|
162
|
+
Deck commands separate pure logic from IO:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
deck.toml → RefreshPlan / PrunePlan (pure) → execute with injectable IO
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **Plan**: `buildRefreshPlan()`, `buildPrunePlan()` — pure functions, unit-testable
|
|
169
|
+
- **Execute**: `executeRefreshPlan(plan, io)`, `executePrunePlan(plan, io)` — IO injected (`gitPull`, `delete`, `log`)
|
|
170
|
+
- **Config**: `workdir`, `coldPool`, `deckPath` all accept explicit overrides, defaults are fallback
|
|
171
|
+
|
|
172
|
+
This enables testing without real git operations — inject mock `gitPull`, capture `log` output, assert expected behavior.
|
|
173
|
+
|
|
174
|
+
## Test Coverage
|
|
175
|
+
|
|
176
|
+
| Layer | Count | CI | Notes |
|
|
177
|
+
|-------|-------|----|-------|
|
|
178
|
+
| Unit tests | 71 | ✅ | Plan generation, link, add, remove, schema |
|
|
179
|
+
| CLI BDD | 21 | ✅ | End-to-end via real CLI invocations in tmpdir |
|
|
180
|
+
| Agent BDD | 5 | ❌ | Requires `claude -p` CLI; `.agent.test.ts` convention |
|
|
181
|
+
|
|
182
|
+
Coverage is honest — no gate, no inflation. Agent BDD scenarios run locally only.
|
|
183
|
+
|
|
160
184
|
## More Documentation
|
|
161
185
|
|
|
162
186
|
- **Skill layer** (agent-facing instructions):
|
package/package.json
CHANGED
package/src/add.test.ts
CHANGED
|
@@ -137,7 +137,7 @@ describe('addSkill', () => {
|
|
|
137
137
|
|
|
138
138
|
try {
|
|
139
139
|
const { addSkill } = await import('./add.ts')
|
|
140
|
-
await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath,
|
|
140
|
+
await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath, alias: 'foo' })
|
|
141
141
|
expect(false).toBe(true) // should not reach here
|
|
142
142
|
} catch (err: any) {
|
|
143
143
|
expect(exitCode).toBe(1)
|
package/src/add.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* deck-add.ts — Skill acquisition command
|
|
4
4
|
*
|
|
5
5
|
* Downloads a skill to the cold pool, updates skill-deck.toml, and links.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Single backend: git clone. For feed-based discovery with decision tracking,
|
|
7
|
+
* use curator add instead.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
|
|
@@ -16,7 +16,6 @@ import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
|
|
|
16
16
|
import { findDeckToml, expandHome } from './link.js'
|
|
17
17
|
import { parseDeck } from './parse-deck.js'
|
|
18
18
|
|
|
19
|
-
const CLAUDE_SKILLS_DIR = join(homedir(), '.claude', 'skills')
|
|
20
19
|
|
|
21
20
|
interface ParsedLocator {
|
|
22
21
|
host: string
|
|
@@ -76,7 +75,7 @@ function resolvePath(p: string): string {
|
|
|
76
75
|
return resolve(p)
|
|
77
76
|
}
|
|
78
77
|
|
|
79
|
-
export async function addSkill(locator: string, options: {
|
|
78
|
+
export async function addSkill(locator: string, options: { deck?: string; workdir?: string; alias?: string; type?: string }) {
|
|
80
79
|
const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
|
|
81
80
|
const deckPath = options.deck
|
|
82
81
|
? resolvePath(options.deck)
|
|
@@ -89,8 +88,6 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
89
88
|
process.exit(1)
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
const backend = options.via || 'git'
|
|
93
|
-
|
|
94
91
|
let coldPool = join(homedir(), '.agents', 'skill-repos')
|
|
95
92
|
if (existsSync(deckPath)) {
|
|
96
93
|
try {
|
|
@@ -117,47 +114,10 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
117
114
|
const tmpRepo = join(tmpDir, 'repo')
|
|
118
115
|
|
|
119
116
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
console.log(`📦 Downloading via skills.sh: ${skillsShLocator}`)
|
|
125
|
-
|
|
126
|
-
// Snapshot existing directories in ~/.claude/skills/
|
|
127
|
-
const beforeDirs = existsSync(CLAUDE_SKILLS_DIR)
|
|
128
|
-
? new Set(readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })
|
|
129
|
-
.filter(e => e.isDirectory())
|
|
130
|
-
.map(e => e.name))
|
|
131
|
-
: new Set<string>()
|
|
132
|
-
|
|
133
|
-
execFileSync('npx', ['skills', 'add', skillsShLocator, '-g'], { cwd: tmpDir, stdio: 'inherit' })
|
|
134
|
-
|
|
135
|
-
// Detect the newly installed directory
|
|
136
|
-
const afterDirs = existsSync(CLAUDE_SKILLS_DIR)
|
|
137
|
-
? readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })
|
|
138
|
-
.filter(e => e.isDirectory())
|
|
139
|
-
.map(e => e.name)
|
|
140
|
-
: []
|
|
141
|
-
const newDirs = afterDirs.filter(d => !beforeDirs.has(d))
|
|
142
|
-
|
|
143
|
-
if (newDirs.length === 0) {
|
|
144
|
-
console.error(`❌ skills.sh installed nothing new to ~/.claude/skills/`)
|
|
145
|
-
console.error(` The skill may already be installed, or the install failed.`)
|
|
146
|
-
process.exit(1)
|
|
147
|
-
}
|
|
148
|
-
if (newDirs.length > 1) {
|
|
149
|
-
console.warn(`⚠️ Multiple new directories detected in ~/.claude/skills/`)
|
|
150
|
-
console.warn(` Using the first one: ${newDirs[0]}`)
|
|
151
|
-
}
|
|
152
|
-
const installedName = newDirs[0]
|
|
153
|
-
skillSourceDir = join(CLAUDE_SKILLS_DIR, installedName)
|
|
154
|
-
console.log(` Detected install: ${installedName}`)
|
|
155
|
-
} else {
|
|
156
|
-
const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
|
|
157
|
-
console.log(`📦 Cloning: ${gitUrl}`)
|
|
158
|
-
execFileSync('git', ['clone', '--depth', '1', gitUrl, tmpRepo], { stdio: 'inherit' })
|
|
159
|
-
skillSourceDir = tmpRepo
|
|
160
|
-
}
|
|
117
|
+
const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
|
|
118
|
+
console.log(`📦 Cloning: ${gitUrl}`)
|
|
119
|
+
execFileSync('git', ['clone', '--depth', '1', gitUrl, tmpRepo], { stdio: 'inherit' })
|
|
120
|
+
let skillSourceDir = tmpRepo
|
|
161
121
|
|
|
162
122
|
if (!existsSync(skillSourceDir)) {
|
|
163
123
|
console.error(`❌ Download failed: expected output not found at ${skillSourceDir}`)
|
|
@@ -175,7 +135,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
175
135
|
}
|
|
176
136
|
|
|
177
137
|
const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
|
|
178
|
-
const alias = options.
|
|
138
|
+
const alias = options.alias || skillName
|
|
179
139
|
const skillType = (options.type || 'tool').toLowerCase()
|
|
180
140
|
|
|
181
141
|
if (!['innate', 'tool', 'combo'].includes(skillType)) {
|
package/src/cli.ts
CHANGED
|
@@ -14,14 +14,12 @@ const command = args[0]
|
|
|
14
14
|
|
|
15
15
|
const deckFlagIdx = args.indexOf('--deck')
|
|
16
16
|
const workdirFlagIdx = args.indexOf('--workdir')
|
|
17
|
-
const
|
|
18
|
-
const asFlagIdx = args.indexOf('--as')
|
|
17
|
+
const aliasFlagIdx = args.indexOf('--alias')
|
|
19
18
|
const typeFlagIdx = args.indexOf('--type')
|
|
20
19
|
|
|
21
20
|
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
22
21
|
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
23
|
-
const
|
|
24
|
-
const as = asFlagIdx >= 0 ? args[asFlagIdx + 1] : undefined
|
|
22
|
+
const alias = aliasFlagIdx >= 0 ? args[aliasFlagIdx + 1] : undefined
|
|
25
23
|
const type = typeFlagIdx >= 0 ? args[typeFlagIdx + 1] : undefined
|
|
26
24
|
const noBackup = args.includes('--no-backup')
|
|
27
25
|
const yes = args.includes('--yes')
|
|
@@ -42,8 +40,8 @@ const HELP_CONFIG = {
|
|
|
42
40
|
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
|
|
43
41
|
{ flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
|
|
44
42
|
{ flag: '--no-backup', description: 'Skip tar backup when removing non-symlink entries' },
|
|
45
|
-
|
|
46
|
-
{ flag: '--
|
|
43
|
+
|
|
44
|
+
{ flag: '--alias <name>', description: 'Explicit alias for the skill (default: basename of path)' },
|
|
47
45
|
{ flag: '--type <type>', description: 'Target section: innate | tool | combo (default: tool)' },
|
|
48
46
|
{ flag: '--yes', description: 'Skip interactive confirmation (for prune)' },
|
|
49
47
|
],
|
|
@@ -63,7 +61,7 @@ switch (command) {
|
|
|
63
61
|
console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
|
|
64
62
|
process.exit(1)
|
|
65
63
|
}
|
|
66
|
-
await addSkill(locator, {
|
|
64
|
+
await addSkill(locator, { deck: deckPath, workdir, alias, type })
|
|
67
65
|
break
|
|
68
66
|
}
|
|
69
67
|
case 'refresh': {
|
package/src/link.ts
CHANGED
|
@@ -244,7 +244,7 @@ for (const d of declared) {
|
|
|
244
244
|
for (const [alias, types] of aliasToTypes) {
|
|
245
245
|
if (types.length > 1) {
|
|
246
246
|
errors.push(
|
|
247
|
-
`Alias collision: "${alias}" appears in [${types.join('], [')}]. Use --
|
|
247
|
+
`Alias collision: "${alias}" appears in [${types.join('], [')}]. Use --alias to specify different aliases.`
|
|
248
248
|
);
|
|
249
249
|
}
|
|
250
250
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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/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', '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
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
export function scanColdPool(coldPool: string): string[] {
|
|
48
|
+
const repos: string[] = []
|
|
49
|
+
if (!existsSync(coldPool)) return repos
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
for (const host of readdirSync(coldPool, { withFileTypes: true })) {
|
|
53
|
+
if (!host.isDirectory() || host.name.startsWith('.')) continue
|
|
54
|
+
const hostPath = join(coldPool, host.name)
|
|
55
|
+
|
|
56
|
+
// Flat skill: cold-pool/skill-name/SKILL.md (localhost style)
|
|
57
|
+
if (existsSync(join(hostPath, 'SKILL.md'))) {
|
|
58
|
+
repos.push(hostPath)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Nested: cold-pool/github.com/owner/repo/
|
|
63
|
+
for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
|
|
64
|
+
if (!owner.isDirectory() || owner.name.startsWith('.')) continue
|
|
65
|
+
const ownerPath = join(hostPath, owner.name)
|
|
66
|
+
for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
|
|
67
|
+
if (!repo.isDirectory() || repo.name.startsWith('.')) continue
|
|
68
|
+
repos.push(join(ownerPath, repo.name))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
return repos
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Size calculation (pure helper) ─────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export function calculateDirSize(dir: string): number {
|
|
80
|
+
let total = 0
|
|
81
|
+
try {
|
|
82
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
+
const p = join(dir, entry.name)
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
total += calculateDirSize(p)
|
|
86
|
+
} else if (entry.isFile()) {
|
|
87
|
+
total += statSync(p).size
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
return total
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Plan builder (pure: no deletion, no mutation) ──────────────────────────
|
|
95
|
+
|
|
96
|
+
export function buildPrunePlan(
|
|
97
|
+
deckRaw: string,
|
|
98
|
+
opts?: { deckPath?: string; workdir?: string; coldPool?: string }
|
|
99
|
+
): PrunePlan {
|
|
100
|
+
const { deckPath, workdir, coldPool: configuredColdPool } = resolvePruneConfig(opts)
|
|
101
|
+
|
|
102
|
+
// Read cold_pool from deck.toml if not explicitly overridden
|
|
103
|
+
let coldPool = configuredColdPool
|
|
104
|
+
if (!opts?.coldPool) {
|
|
105
|
+
const deckMatch = deckRaw.match(/cold_pool\s*=\s*"([^"]+)"/)
|
|
106
|
+
if (deckMatch) {
|
|
107
|
+
coldPool = expandHome(deckMatch[1], workdir)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Get declared skill paths from deck
|
|
112
|
+
const { entries: declared } = parseDeck(deckRaw)
|
|
113
|
+
const declaredPaths = new Set(declared.map(d => d.path))
|
|
114
|
+
|
|
115
|
+
// Scan cold pool for all repos
|
|
116
|
+
const allRepos = scanColdPool(coldPool)
|
|
117
|
+
|
|
118
|
+
// Find unreferenced: repos not declared in deck
|
|
119
|
+
const candidates: PruneCandidate[] = []
|
|
120
|
+
for (const repoPath of allRepos) {
|
|
121
|
+
// A repo is referenced if any declared skill path starts with its cold-pool-relative path
|
|
122
|
+
const repoRel = repoPath.slice(coldPool.length + 1) // relative to cold pool
|
|
123
|
+
const isReferenced = [...declaredPaths].some(d => d.startsWith(repoRel) || repoRel.startsWith(d))
|
|
124
|
+
|
|
125
|
+
if (!isReferenced) {
|
|
126
|
+
candidates.push({
|
|
127
|
+
repoPath,
|
|
128
|
+
repoRel,
|
|
129
|
+
size: calculateDirSize(repoPath),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const totalSize = candidates.reduce((sum, c) => sum + c.size, 0)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
deckPath,
|
|
138
|
+
workdir,
|
|
139
|
+
coldPool,
|
|
140
|
+
candidates,
|
|
141
|
+
declared: declared.map(d => d.path),
|
|
142
|
+
totalSize,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Execution (IO layer, injectable for testing) ───────────────────────────
|
|
147
|
+
|
|
148
|
+
export interface PruneResult {
|
|
149
|
+
repoRel: string
|
|
150
|
+
deleted: boolean
|
|
151
|
+
error?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface PruneIO {
|
|
155
|
+
delete?: (path: string) => void
|
|
156
|
+
log?: (msg: string) => void
|
|
157
|
+
formatSize?: (bytes: number) => string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function executePrunePlan(plan: PrunePlan, io?: PruneIO): PruneResult[] {
|
|
161
|
+
const deleteFn = io?.delete ?? ((_path: string) => { throw new Error('delete not injected') })
|
|
162
|
+
const log = io?.log ?? (() => {})
|
|
163
|
+
const fmtSize = io?.formatSize ?? ((b: number) => b < 1024 ? `${b}B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / (1024 * 1024)).toFixed(1)}MB`)
|
|
164
|
+
|
|
165
|
+
log(`\n🧹 Prune candidates — ${plan.candidates.length} repo(s), ${fmtSize(plan.totalSize)} total:\n`)
|
|
166
|
+
for (const c of plan.candidates) {
|
|
167
|
+
log(` ${c.repoRel} (${fmtSize(c.size)})`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const results: PruneResult[] = []
|
|
171
|
+
let deleted = 0, failed = 0
|
|
172
|
+
|
|
173
|
+
for (const c of plan.candidates) {
|
|
174
|
+
try {
|
|
175
|
+
deleteFn(c.repoPath)
|
|
176
|
+
log(` 🗑️ Deleted: ${c.repoRel}`)
|
|
177
|
+
results.push({ repoRel: c.repoRel, deleted: true })
|
|
178
|
+
deleted++
|
|
179
|
+
} catch (err: any) {
|
|
180
|
+
log(` ❌ Failed to delete ${c.repoRel}: ${err.message}`)
|
|
181
|
+
results.push({ repoRel: c.repoRel, deleted: false, error: err.message })
|
|
182
|
+
failed++
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
log(`\n📦 Prune complete: ${deleted} deleted, ${failed} failed`)
|
|
187
|
+
return results
|
|
188
|
+
}
|
package/src/prune.ts
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
* Does NOT modify deck.toml or the working set.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { resolve, dirname, join, relative } from "node:path";
|
|
10
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
13
12
|
import { createInterface } from "node:readline";
|
|
14
|
-
import { findDeckToml
|
|
15
|
-
import {
|
|
13
|
+
import { findDeckToml } from "./link.js";
|
|
14
|
+
import { buildPrunePlan, executePrunePlan } from "./prune-plan.js";
|
|
16
15
|
|
|
17
16
|
interface PruneCandidate {
|
|
18
17
|
repoPath: string;
|
|
@@ -103,108 +102,60 @@ async function confirm(message: string): Promise<boolean> {
|
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
export async function pruneDeck(cliDeckPath?: string, cliWorkdir?: string, yes?: boolean): Promise<void> {
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
? resolve(cliDeck)
|
|
109
|
-
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
105
|
+
const deckPath = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
106
|
+
const workdir = cliWorkdir
|
|
110
107
|
|
|
111
|
-
|
|
112
|
-
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
113
|
-
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
114
|
-
process.exit(1);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
118
|
-
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
119
|
-
const deck = parseToml(deckRaw) as any;
|
|
120
|
-
|
|
121
|
-
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
122
|
-
|
|
123
|
-
// ── 收集声明 ────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
const { entries: parsedEntries } = parseDeck(deckRaw);
|
|
126
|
-
const declaredPaths = parsedEntries.map(e => e.path);
|
|
127
|
-
|
|
128
|
-
// Legacy string-array fallback
|
|
129
|
-
for (const section of ["innate", "tool", "combo"] as const) {
|
|
130
|
-
const skills = deck[section]?.skills;
|
|
131
|
-
if (Array.isArray(skills)) {
|
|
132
|
-
for (const name of skills) {
|
|
133
|
-
if (name && typeof name === "string" && !declaredPaths.includes(name)) {
|
|
134
|
-
declaredPaths.push(name);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ── 扫描 cold pool ──────────────────────────────────────────
|
|
108
|
+
const DECK_PATH = deckPath ? resolve(deckPath) : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
|
|
141
109
|
|
|
142
|
-
if (!existsSync(
|
|
143
|
-
console.
|
|
144
|
-
|
|
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)
|
|
145
114
|
}
|
|
146
115
|
|
|
147
|
-
const
|
|
148
|
-
if (allRepos.length === 0) {
|
|
149
|
-
console.log("📭 Cold pool is empty. Nothing to prune.");
|
|
150
|
-
process.exit(0);
|
|
151
|
-
}
|
|
116
|
+
const deckRaw = readFileSync(DECK_PATH, 'utf-8')
|
|
152
117
|
|
|
153
|
-
// ──
|
|
118
|
+
// ── Plan: pure unreferenced detection ──────────────────────────────
|
|
119
|
+
const plan = buildPrunePlan(deckRaw, {
|
|
120
|
+
deckPath: DECK_PATH,
|
|
121
|
+
workdir: workdir ? resolve(workdir) : undefined,
|
|
122
|
+
})
|
|
154
123
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const size = calculateDirSize(repoPath);
|
|
159
|
-
candidates.push({ repoPath, repoRel: relative(COLD_POOL, repoPath), size });
|
|
124
|
+
if (!existsSync(plan.coldPool)) {
|
|
125
|
+
console.log('📭 Cold pool does not exist. Nothing to prune.')
|
|
126
|
+
process.exit(0)
|
|
160
127
|
}
|
|
161
128
|
|
|
162
|
-
if (candidates.length === 0) {
|
|
163
|
-
console.log(
|
|
164
|
-
process.exit(0)
|
|
129
|
+
if (plan.candidates.length === 0 && plan.declared.length === 0) {
|
|
130
|
+
console.log('📭 Cold pool is empty. Nothing to prune.')
|
|
131
|
+
process.exit(0)
|
|
165
132
|
}
|
|
166
133
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
console.log(`\n🧹 Prune candidates — ${candidates.length} repo(s), ${formatSize(totalSize)} total:\n`);
|
|
171
|
-
for (const c of candidates) {
|
|
172
|
-
console.log(` ${c.repoRel} (${formatSize(c.size)})`);
|
|
134
|
+
if (plan.candidates.length === 0) {
|
|
135
|
+
console.log('✅ All cold pool repositories are referenced. Nothing to prune.')
|
|
136
|
+
process.exit(0)
|
|
173
137
|
}
|
|
174
138
|
|
|
175
|
-
// ──
|
|
176
|
-
|
|
177
|
-
let shouldDelete = false;
|
|
139
|
+
// ── Confirm ──────────────────────────────────────────────────────
|
|
140
|
+
let shouldDelete = false
|
|
178
141
|
if (yes) {
|
|
179
|
-
shouldDelete = true
|
|
180
|
-
console.log(
|
|
142
|
+
shouldDelete = true
|
|
143
|
+
console.log('\n⚠️ --yes flag set: deleting without confirmation.')
|
|
181
144
|
} else {
|
|
182
|
-
shouldDelete = await confirm(`\nDelete ${candidates.length} unreferenced repo(s)?`)
|
|
145
|
+
shouldDelete = await confirm(`\nDelete ${plan.candidates.length} unreferenced repo(s)?`)
|
|
183
146
|
}
|
|
184
147
|
|
|
185
148
|
if (!shouldDelete) {
|
|
186
|
-
console.log(
|
|
187
|
-
process.exit(0)
|
|
149
|
+
console.log('❎ Prune cancelled.')
|
|
150
|
+
process.exit(0)
|
|
188
151
|
}
|
|
189
152
|
|
|
190
|
-
// ──
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
rmSync(c.repoPath, { recursive: true, force: true });
|
|
197
|
-
console.log(` 🗑️ Deleted: ${c.repoRel}`);
|
|
198
|
-
deleted++;
|
|
199
|
-
} catch (err: any) {
|
|
200
|
-
console.error(` ❌ Failed to delete ${c.repoRel}: ${err.message}`);
|
|
201
|
-
failed++;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
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
|
+
})
|
|
204
159
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (failed > 0) {
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
160
|
+
if (results.some(r => !r.deleted)) process.exit(1)
|
|
210
161
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { resolveRefreshConfig, detectGitRoot, buildRefreshPlan } from './refresh-plan'
|
|
5
|
+
|
|
6
|
+
const deckAliasDict = `[deck]
|
|
7
|
+
max_cards = 10
|
|
8
|
+
cold_pool = "./cold-pool"
|
|
9
|
+
|
|
10
|
+
[tool.skills.skill-a]
|
|
11
|
+
path = "github.com/foo/bar/skill-a"
|
|
12
|
+
|
|
13
|
+
[tool.skills.skill-b]
|
|
14
|
+
path = "localhost/skill-b"
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
describe('resolveRefreshConfig', () => {
|
|
18
|
+
test('returns strings without throwing when no opts', () => {
|
|
19
|
+
const cfg = resolveRefreshConfig()
|
|
20
|
+
expect(typeof cfg.deckPath).toBe('string')
|
|
21
|
+
expect(typeof cfg.workdir).toBe('string')
|
|
22
|
+
expect(typeof cfg.coldPool).toBe('string')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('resolves explicit deckPath', () => {
|
|
26
|
+
const cfg = resolveRefreshConfig({ deckPath: '/tmp/test-deck.toml' })
|
|
27
|
+
expect(cfg.deckPath).toBe('/tmp/test-deck.toml')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('workdir falls back to deckPath dirname', () => {
|
|
31
|
+
const cfg = resolveRefreshConfig({ deckPath: '/tmp/my-deck.toml' })
|
|
32
|
+
expect(cfg.workdir).toBe('/tmp')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('explicit workdir overrides fallback', () => {
|
|
36
|
+
const cfg = resolveRefreshConfig({ deckPath: '/tmp/my-deck.toml', workdir: '/custom/workdir' })
|
|
37
|
+
expect(cfg.workdir).toBe('/custom/workdir')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('explicit coldPool resolved', () => {
|
|
41
|
+
const cfg = resolveRefreshConfig({ coldPool: '/custom/cold-pool' })
|
|
42
|
+
expect(cfg.coldPool).toBe('/custom/cold-pool')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('detectGitRoot', () => {
|
|
47
|
+
test('localhost skill → localhost type', () => {
|
|
48
|
+
const result = detectGitRoot('/pool/localhost/skill-a', '/pool')
|
|
49
|
+
expect(result.type).toBe('localhost')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('localhost as root → localhost type', () => {
|
|
53
|
+
const result = detectGitRoot('/pool/localhost', '/pool')
|
|
54
|
+
expect(result.type).toBe('localhost')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('not-git: directory without .git', () => {
|
|
58
|
+
const dir = join('/tmp', 'refresh-test-no-git-' + Date.now())
|
|
59
|
+
mkdirSync(dir, { recursive: true })
|
|
60
|
+
const result = detectGitRoot(dir, '/tmp')
|
|
61
|
+
expect(result.type).toBe('not-git')
|
|
62
|
+
rmSync(dir, { recursive: true, force: true })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('buildRefreshPlan', () => {
|
|
67
|
+
test('builds plan from alias-dict deck', () => {
|
|
68
|
+
const plan = buildRefreshPlan(deckAliasDict, { coldPool: '/tmp/test-cold-pool' })
|
|
69
|
+
expect(plan.targets).toHaveLength(2)
|
|
70
|
+
expect(plan.allDeclared).toHaveLength(2)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('filters by alias when target specified', () => {
|
|
74
|
+
const plan = buildRefreshPlan(deckAliasDict, {
|
|
75
|
+
coldPool: '/tmp/test-cold-pool',
|
|
76
|
+
target: 'skill-a',
|
|
77
|
+
})
|
|
78
|
+
expect(plan.targets).toHaveLength(1)
|
|
79
|
+
expect(plan.targets[0].alias).toBe('skill-a')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('filters by path when target specified', () => {
|
|
83
|
+
const plan = buildRefreshPlan(deckAliasDict, {
|
|
84
|
+
coldPool: '/tmp/test-cold-pool',
|
|
85
|
+
target: 'github.com/foo/bar/skill-a',
|
|
86
|
+
})
|
|
87
|
+
expect(plan.targets).toHaveLength(1)
|
|
88
|
+
expect(plan.targets[0].path).toBe('github.com/foo/bar/skill-a')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('unknown target → empty plan', () => {
|
|
92
|
+
const plan = buildRefreshPlan(deckAliasDict, {
|
|
93
|
+
coldPool: '/tmp/test-cold-pool',
|
|
94
|
+
target: 'nonexistent',
|
|
95
|
+
})
|
|
96
|
+
expect(plan.targets).toHaveLength(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('localhost skill is in plan as declared', () => {
|
|
100
|
+
const plan = buildRefreshPlan(deckAliasDict, { coldPool: '/tmp/test-cold-pool' })
|
|
101
|
+
const localhost = plan.targets.find(t => t.alias === 'skill-b')
|
|
102
|
+
// Without a real cold pool, source resolution may fail → 'missing'
|
|
103
|
+
// Plan structure is what matters; type depends on actual filesystem
|
|
104
|
+
expect(localhost).toBeDefined()
|
|
105
|
+
expect(localhost!.path).toBe('localhost/skill-b')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('paths are resolved through config', () => {
|
|
109
|
+
const plan = buildRefreshPlan(deckAliasDict, {
|
|
110
|
+
deckPath: '/custom/deck.toml',
|
|
111
|
+
workdir: '/custom/work',
|
|
112
|
+
coldPool: '/custom/pool',
|
|
113
|
+
})
|
|
114
|
+
expect(plan.deckPath).toBe('/custom/deck.toml')
|
|
115
|
+
expect(plan.workdir).toBe('/custom/work')
|
|
116
|
+
expect(plan.coldPool).toBe('/custom/pool')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { resolve, dirname, relative } from 'node:path'
|
|
3
|
+
import { realpathSync } from 'node:fs'
|
|
4
|
+
import { execSync } from 'node:child_process'
|
|
5
|
+
import { findDeckToml, expandHome, findSource } from './link'
|
|
6
|
+
import { parseDeck, type ParsedSkillEntry } from './parse-deck'
|
|
7
|
+
|
|
8
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface RefreshTarget {
|
|
11
|
+
alias: string
|
|
12
|
+
path: string // FQ path
|
|
13
|
+
sourcePath: string // absolute path in cold pool
|
|
14
|
+
sourceRel: string // relative to cold pool
|
|
15
|
+
type: 'git' | 'localhost' | 'missing' | 'not-git'
|
|
16
|
+
gitRoot?: string // populated for 'git' type
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RefreshPlan {
|
|
20
|
+
deckPath: string
|
|
21
|
+
workdir: string
|
|
22
|
+
coldPool: string
|
|
23
|
+
targets: RefreshTarget[]
|
|
24
|
+
allDeclared: ParsedSkillEntry[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Config resolution (pure, defaults via params) ──────────────────────────
|
|
28
|
+
|
|
29
|
+
export function resolveRefreshConfig(opts?: {
|
|
30
|
+
deckPath?: string
|
|
31
|
+
workdir?: string
|
|
32
|
+
coldPool?: string
|
|
33
|
+
}) {
|
|
34
|
+
const deckPath = opts?.deckPath
|
|
35
|
+
? resolve(opts.deckPath)
|
|
36
|
+
: (findDeckToml(process.cwd()) || resolve('skill-deck.toml'))
|
|
37
|
+
|
|
38
|
+
const workdir = opts?.workdir
|
|
39
|
+
? resolve(opts.workdir)
|
|
40
|
+
: dirname(deckPath)
|
|
41
|
+
|
|
42
|
+
const coldPool = opts?.coldPool
|
|
43
|
+
? resolve(opts.coldPool)
|
|
44
|
+
: expandHome('~/.agents/skill-repos', workdir)
|
|
45
|
+
|
|
46
|
+
return { deckPath, workdir, coldPool }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Git detection (pure: only checks directory structure, no mutation) ─────
|
|
50
|
+
|
|
51
|
+
export function detectGitRoot(skillDir: string, coldPool: string): { gitRoot?: string; type: RefreshTarget['type'] } {
|
|
52
|
+
// localhost skills are user-managed
|
|
53
|
+
const rel = relative(coldPool, skillDir)
|
|
54
|
+
if (rel.startsWith('localhost') || rel === 'localhost') {
|
|
55
|
+
return { type: 'localhost' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Standalone skill: .git directly in skill dir
|
|
59
|
+
if (existsSync(resolve(skillDir, '.git'))) {
|
|
60
|
+
return { gitRoot: skillDir, type: 'git' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const out = execSync('git rev-parse --show-toplevel', {
|
|
65
|
+
cwd: skillDir,
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
|
+
}).trim()
|
|
69
|
+
|
|
70
|
+
// Normalize paths (macOS /tmp → /private/tmp)
|
|
71
|
+
const resolvedRoot = realpathSync(out)
|
|
72
|
+
const resolvedDir = realpathSync(skillDir)
|
|
73
|
+
const resolvedPool = realpathSync(coldPool)
|
|
74
|
+
|
|
75
|
+
// Must be ancestor of skillDir and within coldPool
|
|
76
|
+
if (resolvedDir.startsWith(resolvedRoot + '/') &&
|
|
77
|
+
(resolvedRoot === resolvedPool || resolvedRoot.startsWith(resolvedPool + '/'))) {
|
|
78
|
+
return { gitRoot: out, type: 'git' }
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
|
|
82
|
+
return { type: 'not-git' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Plan builder (pure: no git pull, no mutation) ──────────────────────────
|
|
86
|
+
|
|
87
|
+
export function buildRefreshPlan(
|
|
88
|
+
deckRaw: string,
|
|
89
|
+
opts?: { deckPath?: string; workdir?: string; coldPool?: string; target?: string }
|
|
90
|
+
): RefreshPlan {
|
|
91
|
+
const { deckPath, workdir, coldPool: configuredColdPool } = resolveRefreshConfig(opts)
|
|
92
|
+
|
|
93
|
+
// Read cold_pool from deck.toml [deck] section if not explicitly overridden
|
|
94
|
+
let coldPool = configuredColdPool
|
|
95
|
+
if (!opts?.coldPool) {
|
|
96
|
+
const deckMatch = deckRaw.match(/cold_pool\s*=\s*"([^"]+)"/)
|
|
97
|
+
if (deckMatch) {
|
|
98
|
+
coldPool = expandHome(deckMatch[1], workdir)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { entries: allDeclared } = parseDeck(deckRaw)
|
|
103
|
+
|
|
104
|
+
// Filter to target (by alias or path) if specified
|
|
105
|
+
let declared = allDeclared
|
|
106
|
+
if (opts?.target) {
|
|
107
|
+
const byAlias = allDeclared.find(d => d.alias === opts.target)
|
|
108
|
+
if (byAlias) {
|
|
109
|
+
declared = [byAlias]
|
|
110
|
+
} else {
|
|
111
|
+
const byPath = allDeclared.find(d => d.path === opts.target)
|
|
112
|
+
if (byPath) {
|
|
113
|
+
declared = [byPath]
|
|
114
|
+
} else {
|
|
115
|
+
declared = [] // target not found → empty plan
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const targets: RefreshTarget[] = []
|
|
121
|
+
|
|
122
|
+
for (const entry of declared) {
|
|
123
|
+
const source = findSource(entry.path, coldPool, workdir)
|
|
124
|
+
|
|
125
|
+
if (source.error || !source.path) {
|
|
126
|
+
targets.push({ alias: entry.alias, path: entry.path, sourcePath: '', sourceRel: '', type: 'missing' })
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { gitRoot, type } = detectGitRoot(source.path, coldPool)
|
|
131
|
+
const sourceRel = relative(coldPool, source.path)
|
|
132
|
+
|
|
133
|
+
targets.push({
|
|
134
|
+
alias: entry.alias,
|
|
135
|
+
path: entry.path,
|
|
136
|
+
sourcePath: source.path,
|
|
137
|
+
sourceRel,
|
|
138
|
+
type,
|
|
139
|
+
gitRoot,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { deckPath, workdir, coldPool, targets, allDeclared }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Execution (IO layer, injectable for testing) ───────────────────────────
|
|
147
|
+
|
|
148
|
+
export interface RefreshResult {
|
|
149
|
+
alias: string
|
|
150
|
+
path: string
|
|
151
|
+
status: 'updated' | 'up-to-date' | 'skipped' | 'failed' | 'not-git'
|
|
152
|
+
message?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface RefreshIO {
|
|
156
|
+
gitPull?: (dir: string) => { status: 'updated' | 'up-to-date' | 'failed'; message: string }
|
|
157
|
+
log?: (msg: string) => void
|
|
158
|
+
linkDeck?: (deckPath?: string, workdir?: string) => void
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function executeRefreshPlan(plan: RefreshPlan, io?: RefreshIO): RefreshResult[] {
|
|
162
|
+
const gitPull = io?.gitPull ?? (() => ({ status: 'failed' as const, message: 'gitPull not injected' }))
|
|
163
|
+
const log = io?.log ?? (() => {})
|
|
164
|
+
|
|
165
|
+
const results: RefreshResult[] = []
|
|
166
|
+
let updated = 0, upToDate = 0, skipped = 0, failed = 0
|
|
167
|
+
|
|
168
|
+
for (const t of plan.targets) {
|
|
169
|
+
switch (t.type) {
|
|
170
|
+
case 'missing':
|
|
171
|
+
results.push({ alias: t.alias, path: '', status: 'failed', message: 'Skill not found in cold pool' })
|
|
172
|
+
failed++
|
|
173
|
+
break
|
|
174
|
+
case 'localhost':
|
|
175
|
+
results.push({ alias: t.alias, path: t.sourceRel, status: 'skipped', message: 'localhost skill — user-managed' })
|
|
176
|
+
skipped++
|
|
177
|
+
break
|
|
178
|
+
case 'not-git':
|
|
179
|
+
results.push({ alias: t.alias, path: t.sourceRel, status: 'not-git', message: 'skipped: not a git repository' })
|
|
180
|
+
skipped++
|
|
181
|
+
break
|
|
182
|
+
case 'git': {
|
|
183
|
+
const pullResult = gitPull(t.gitRoot!)
|
|
184
|
+
results.push({ alias: t.alias, path: t.sourceRel, status: pullResult.status, message: pullResult.message })
|
|
185
|
+
if (pullResult.status === 'updated') updated++
|
|
186
|
+
else if (pullResult.status === 'up-to-date') upToDate++
|
|
187
|
+
else failed++
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Report phase
|
|
194
|
+
const scope = plan.targets.length === plan.allDeclared.length
|
|
195
|
+
? `${plan.allDeclared.length} skill(s)`
|
|
196
|
+
: 'single skill'
|
|
197
|
+
log(`\n📦 Skill Refresh Report — ${scope} checked`)
|
|
198
|
+
log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`)
|
|
199
|
+
|
|
200
|
+
for (const r of results) {
|
|
201
|
+
const icon = r.status === 'updated' ? '🔄' : r.status === 'up-to-date' ? '✅' :
|
|
202
|
+
r.status === 'skipped' ? '⏭️' : r.status === 'not-git' ? '📁' : '❌'
|
|
203
|
+
log(`${icon} ${r.alias}`)
|
|
204
|
+
if (r.message) log(` ${r.message}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (updated > 0) {
|
|
208
|
+
io?.linkDeck?.()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return results
|
|
212
|
+
}
|
package/src/refresh.ts
CHANGED
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
* Never modifies deck.toml.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
11
|
import { execSync } from "node:child_process";
|
|
13
|
-
import { resolve
|
|
14
|
-
import { findDeckToml,
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { findDeckToml, linkDeck } from "./link.js";
|
|
15
14
|
import { parseDeck } from "./parse-deck.js";
|
|
15
|
+
import { buildRefreshPlan, detectGitRoot, executeRefreshPlan } from "./refresh-plan.js";
|
|
16
|
+
|
|
17
|
+
// Backward compat: old findGitRoot returns string|null
|
|
18
|
+
export function findGitRoot(dir: string, coldPool: string): string | null {
|
|
19
|
+
const result = detectGitRoot(dir, coldPool)
|
|
20
|
+
return result.gitRoot ?? null
|
|
21
|
+
}
|
|
16
22
|
|
|
17
23
|
interface RefreshResult {
|
|
18
24
|
name: string;
|
|
@@ -21,39 +27,6 @@ interface RefreshResult {
|
|
|
21
27
|
message?: string;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
export function findGitRoot(dir: string, coldPool: string): string | null {
|
|
25
|
-
// Standalone skill: .git directly in skill dir
|
|
26
|
-
if (existsSync(join(dir, ".git"))) {
|
|
27
|
-
return dir;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
const out = execSync("git rev-parse --show-toplevel", {
|
|
32
|
-
cwd: dir,
|
|
33
|
-
encoding: "utf-8",
|
|
34
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
-
}).trim();
|
|
36
|
-
|
|
37
|
-
const resolvedRoot = realpathSync(out);
|
|
38
|
-
const resolvedDir = realpathSync(dir);
|
|
39
|
-
const resolvedColdPool = realpathSync(coldPool);
|
|
40
|
-
|
|
41
|
-
// Must be an ancestor of dir (standalone case handled above)
|
|
42
|
-
if (!resolvedDir.startsWith(resolvedRoot + "/")) {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Must be within cold_pool — prevents finding an unrelated git repo outside
|
|
47
|
-
if (resolvedRoot === resolvedColdPool || resolvedRoot.startsWith(resolvedColdPool + "/")) {
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return null;
|
|
52
|
-
} catch {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
30
|
function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
|
|
58
31
|
try {
|
|
59
32
|
const output = execSync("git pull", {
|
|
@@ -74,140 +47,54 @@ function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; me
|
|
|
74
47
|
}
|
|
75
48
|
|
|
76
49
|
export function refreshDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
50
|
+
const deckPath = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
51
|
+
const workdir = cliWorkdir
|
|
52
|
+
|
|
53
|
+
const DECK_PATH = deckPath ? resolve(deckPath) : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
|
|
81
54
|
|
|
82
55
|
if (!existsSync(DECK_PATH)) {
|
|
83
|
-
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`)
|
|
84
|
-
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`)
|
|
85
|
-
process.exit(1)
|
|
56
|
+
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`)
|
|
57
|
+
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`)
|
|
58
|
+
process.exit(1)
|
|
86
59
|
}
|
|
87
60
|
|
|
88
|
-
const
|
|
89
|
-
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
90
|
-
const deck = parseToml(deckRaw) as any;
|
|
91
|
-
|
|
92
|
-
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
61
|
+
const deckRaw = readFileSync(DECK_PATH, 'utf-8')
|
|
93
62
|
|
|
94
|
-
// ──
|
|
63
|
+
// ── Plan: pure target collection + type classification ─────────────
|
|
64
|
+
const plan = buildRefreshPlan(deckRaw, {
|
|
65
|
+
deckPath: DECK_PATH,
|
|
66
|
+
workdir: workdir ? resolve(workdir) : undefined,
|
|
67
|
+
coldPool: undefined, // derive from deck
|
|
68
|
+
target,
|
|
69
|
+
})
|
|
95
70
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
// Use parseDeck for alias-dict compatibility
|
|
99
|
-
const { entries: parsedEntries, deprecated: isDeprecated } = parseDeck(deckRaw);
|
|
71
|
+
const { entries: parsedEntries, deprecated: isDeprecated } = parseDeck(deckRaw)
|
|
100
72
|
if (isDeprecated) {
|
|
101
|
-
console.warn(
|
|
73
|
+
console.warn('⚠️ Deprecation: string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.')
|
|
102
74
|
}
|
|
103
75
|
|
|
104
|
-
|
|
105
|
-
|
|
76
|
+
if (parsedEntries.length === 0) {
|
|
77
|
+
console.log('📭 No skills declared in deck. Nothing to refresh.')
|
|
78
|
+
process.exit(0)
|
|
106
79
|
}
|
|
107
80
|
|
|
108
|
-
if (
|
|
109
|
-
console.
|
|
110
|
-
|
|
81
|
+
if (target && plan.targets.length === 0) {
|
|
82
|
+
console.error(`❌ Skill not found in deck: ${target}`)
|
|
83
|
+
const { entries } = parseDeck(deckRaw)
|
|
84
|
+
console.error(` Declared aliases: ${entries.map(d => d.alias).join(', ')}`)
|
|
85
|
+
process.exit(1)
|
|
111
86
|
}
|
|
112
87
|
|
|
113
|
-
// ──
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
targets = [byPath];
|
|
126
|
-
} else {
|
|
127
|
-
console.error(`❌ Skill not found in deck: ${target}`);
|
|
128
|
-
console.error(` Declared aliases: ${declared.map(d => d.alias).join(", ")}`);
|
|
129
|
-
process.exit(1);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
targets = declared;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── 执行刷新 ────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
const results: RefreshResult[] = [];
|
|
139
|
-
let updated = 0;
|
|
140
|
-
let upToDate = 0;
|
|
141
|
-
let skipped = 0;
|
|
142
|
-
let failed = 0;
|
|
143
|
-
|
|
144
|
-
for (const item of targets) {
|
|
145
|
-
const result = findSource(item.path, COLD_POOL, PROJECT_DIR);
|
|
146
|
-
|
|
147
|
-
if (result.error || !result.path) {
|
|
148
|
-
results.push({ name: item.alias, path: "", status: "failed", message: result.error || "Skill not found in cold pool" });
|
|
149
|
-
failed++;
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const path = result.path;
|
|
154
|
-
|
|
155
|
-
// localhost skills are user-managed; skip
|
|
156
|
-
const relativePath = relative(COLD_POOL, path);
|
|
157
|
-
if (relativePath.startsWith("localhost")) {
|
|
158
|
-
results.push({ name: item.alias, path: relativePath, status: "skipped", message: "localhost skill — user-managed" });
|
|
159
|
-
skipped++;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const gitRoot = findGitRoot(path, COLD_POOL);
|
|
164
|
-
if (!gitRoot) {
|
|
165
|
-
results.push({ name: item.alias, path: relativePath, status: "not-git", message: "skipped: not a git repository" });
|
|
166
|
-
skipped++;
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const pullResult = gitPull(gitRoot);
|
|
171
|
-
results.push({ name: item.alias, path: relativePath, status: pullResult.status, message: pullResult.message });
|
|
172
|
-
|
|
173
|
-
if (pullResult.status === "updated") updated++;
|
|
174
|
-
else if (pullResult.status === "up-to-date") upToDate++;
|
|
175
|
-
else failed++;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ── 报告 ────────────────────────────────────────────────────
|
|
179
|
-
|
|
180
|
-
const scope = target ? `single skill` : `${declared.length} skill(s)`;
|
|
181
|
-
console.log(`\n📦 Skill Refresh Report — ${scope} checked`);
|
|
182
|
-
console.log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`);
|
|
183
|
-
console.log();
|
|
184
|
-
|
|
185
|
-
for (const r of results) {
|
|
186
|
-
const icon =
|
|
187
|
-
r.status === "updated" ? "🔄" :
|
|
188
|
-
r.status === "up-to-date" ? "✅" :
|
|
189
|
-
r.status === "skipped" ? "⏭️" :
|
|
190
|
-
r.status === "not-git" ? "📁" :
|
|
191
|
-
"❌";
|
|
192
|
-
console.log(`${icon} ${r.name}`);
|
|
193
|
-
if (r.message && r.status !== "up-to-date") {
|
|
194
|
-
const lines = r.message.split("\n").filter(l => l.trim());
|
|
195
|
-
for (const line of lines.slice(0, 3)) {
|
|
196
|
-
console.log(` ${line.trim()}`);
|
|
197
|
-
}
|
|
198
|
-
if (lines.length > 3) {
|
|
199
|
-
console.log(` ... (${lines.length - 3} more lines)`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (updated > 0) {
|
|
205
|
-
console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync refreshed skills to working set.`);
|
|
206
|
-
console.log("🔗 Running deck link...");
|
|
207
|
-
linkDeck(cliDeckPath, cliWorkdir);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (failed > 0) {
|
|
211
|
-
process.exit(1);
|
|
212
|
-
}
|
|
88
|
+
// ── Execute with real IO ────────────────────────────────────
|
|
89
|
+
const results = executeRefreshPlan(plan, {
|
|
90
|
+
gitPull,
|
|
91
|
+
log: console.log,
|
|
92
|
+
linkDeck: () => {
|
|
93
|
+
console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync refreshed skills to working set.`)
|
|
94
|
+
console.log('🔗 Running deck link...')
|
|
95
|
+
linkDeck(cliDeckPath, cliWorkdir)
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (results.some(r => r.status === 'failed')) process.exit(1)
|
|
213
100
|
}
|