@lythos/skill-deck 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -21
- package/package.json +1 -1
- package/src/add.ts +70 -16
- package/src/cli.ts +65 -16
- package/src/link.ts +55 -29
- package/src/migrate-schema.ts +58 -0
- package/src/parse-deck.ts +78 -0
- package/src/prune.ts +196 -0
- package/src/refresh.ts +183 -0
- package/src/remove.ts +91 -0
- package/src/schema.ts +10 -0
- package/src/update.ts +6 -147
package/README.md
CHANGED
|
@@ -18,8 +18,8 @@ No installation required. `bunx` auto-downloads the package.
|
|
|
18
18
|
[deck]
|
|
19
19
|
max_cards = 10
|
|
20
20
|
|
|
21
|
-
[tool]
|
|
22
|
-
|
|
21
|
+
[tool.skills.lythoskill-deck]
|
|
22
|
+
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
### skill-deck.toml (full reference)
|
|
@@ -30,22 +30,21 @@ max_cards = 10 # Hard limit on total skills
|
|
|
30
30
|
working_set = ".claude/skills" # Where symlinks are created
|
|
31
31
|
cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
|
|
32
32
|
|
|
33
|
-
[innate]
|
|
34
|
-
|
|
33
|
+
[innate.skills.lythoskill-deck] # Always-loaded skills
|
|
34
|
+
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
35
35
|
|
|
36
|
-
[tool]
|
|
37
|
-
|
|
38
|
-
"github.com/mattpocock/skills/skills/engineering/tdd",
|
|
39
|
-
"github.com/garrytan/gstack",
|
|
40
|
-
]
|
|
36
|
+
[tool.skills.tdd] # Auto-triggered skills
|
|
37
|
+
path = "github.com/mattpocock/skills/skills/engineering/tdd"
|
|
41
38
|
|
|
42
|
-
[
|
|
43
|
-
|
|
39
|
+
[tool.skills.gstack]
|
|
40
|
+
path = "github.com/garrytan/gstack"
|
|
44
41
|
|
|
45
|
-
[
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
[combo.skills.pdf] # Multi-skill bundles
|
|
43
|
+
path = "github.com/anthropics/skills/skills/pdf"
|
|
44
|
+
|
|
45
|
+
[transient.handoff] # Temporary skills with expiry
|
|
46
|
+
path = "./skills/handoff" # Local path (not cold pool)
|
|
47
|
+
expires = "2026-05-01" # ISO date; warns at ≤14 days
|
|
49
48
|
```
|
|
50
49
|
|
|
51
50
|
### When to invoke
|
|
@@ -55,7 +54,10 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
|
|
|
55
54
|
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck link` |
|
|
56
55
|
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck validate` |
|
|
57
56
|
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck add owner/repo` |
|
|
58
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck
|
|
57
|
+
| Pull latest versions of declared skills | `bunx @lythos/skill-deck refresh` |
|
|
58
|
+
| Refresh a single skill by alias | `bunx @lythos/skill-deck refresh tdd` |
|
|
59
|
+
| Remove a skill from deck and working set | `bunx @lythos/skill-deck remove tdd` |
|
|
60
|
+
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck prune` |
|
|
59
61
|
| Use a custom deck file or working dir | `bunx @lythos/skill-deck link --deck ./my-deck.toml --workdir /path/to/project` |
|
|
60
62
|
|
|
61
63
|
### Commands
|
|
@@ -64,8 +66,10 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
|
|
|
64
66
|
|---------|------|-------------|
|
|
65
67
|
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
|
|
66
68
|
| `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
|
|
67
|
-
| `add` | `<locator> [--via <backend>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
|
|
68
|
-
| `
|
|
69
|
+
| `add` | `<locator> [--via <backend>] [--as <alias>] [--type <type>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
|
|
70
|
+
| `refresh` | `[<fq|alias>] [--deck <path>]` | Pull latest versions of declared skills from upstream git repos. Pass a name to refresh one skill. |
|
|
71
|
+
| `remove` | `<fq|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
|
|
72
|
+
| `prune` | `[--yes] [--deck <path>]` | GC cold pool repos no longer referenced. Interactive confirm (skip with `--yes`). |
|
|
69
73
|
|
|
70
74
|
### Options
|
|
71
75
|
|
|
@@ -74,6 +78,8 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
|
|
|
74
78
|
| `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
|
|
75
79
|
| `--workdir <dir>` | Working directory | cwd |
|
|
76
80
|
| `--via <backend>` | Download backend for `add`: `git` or `skills.sh` | `git` |
|
|
81
|
+
| `--as <alias>` | Explicit alias for the skill (default: basename of path) | — |
|
|
82
|
+
| `--type <type>` | Target section for `add`: `innate`, `tool`, or `combo` | `tool` |
|
|
77
83
|
|
|
78
84
|
### Safety guards
|
|
79
85
|
|
|
@@ -104,8 +110,8 @@ cat > skill-deck.toml << 'EOF'
|
|
|
104
110
|
[deck]
|
|
105
111
|
max_cards = 10
|
|
106
112
|
|
|
107
|
-
[tool]
|
|
108
|
-
|
|
113
|
+
[tool.skills.lythoskill-deck]
|
|
114
|
+
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
109
115
|
EOF
|
|
110
116
|
|
|
111
117
|
# 2. Link — creates symlinks in .claude/skills/
|
|
@@ -140,7 +146,8 @@ Different agents look for skills in different directories. `skill-deck.toml` con
|
|
|
140
146
|
|---------|-------|-----|
|
|
141
147
|
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck add github.com/owner/repo/skill` or clone manually into cold pool |
|
|
142
148
|
| `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 |
|
|
143
|
-
| `
|
|
149
|
+
| `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 |
|
|
150
|
+
| `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
|
|
144
151
|
| `link` refuses with "budget exceeded" | Declared skills > `max_cards` | Increase `max_cards` in `skill-deck.toml` or remove unused skills |
|
|
145
152
|
| `link` refuses with "unsafe working_set" | `working_set` resolves to `~` or `/` | Check `skill-deck.toml` has correct relative path (e.g. `.claude/skills/`) |
|
|
146
153
|
| Agent doesn't see skills after `link` | `working_set` path doesn't match agent's scan location | Claude Code: `.claude/skills/`; Cursor: `.cursor/skills/`; Kimi: check your platform docs. Set `working_set` correctly |
|
package/package.json
CHANGED
package/src/add.ts
CHANGED
|
@@ -11,9 +11,10 @@ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync,
|
|
|
11
11
|
import { mkdtempSync } from 'node:fs'
|
|
12
12
|
import { tmpdir, homedir } from 'node:os'
|
|
13
13
|
import { join, basename, dirname, resolve } from 'node:path'
|
|
14
|
-
import {
|
|
14
|
+
import { execFileSync } from 'node:child_process'
|
|
15
15
|
import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
|
|
16
16
|
import { findDeckToml, expandHome } from './link.js'
|
|
17
|
+
import { parseDeck } from './parse-deck.js'
|
|
17
18
|
|
|
18
19
|
const CLAUDE_SKILLS_DIR = join(homedir(), '.claude', 'skills')
|
|
19
20
|
|
|
@@ -75,7 +76,7 @@ function resolvePath(p: string): string {
|
|
|
75
76
|
return resolve(p)
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string }) {
|
|
79
|
+
export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string; as?: string; type?: string }) {
|
|
79
80
|
const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
|
|
80
81
|
const deckPath = options.deck
|
|
81
82
|
? resolvePath(options.deck)
|
|
@@ -129,7 +130,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
129
130
|
.map(e => e.name))
|
|
130
131
|
: new Set<string>()
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
execFileSync('npx', ['skills', 'add', skillsShLocator, '-g'], { cwd: tmpDir, stdio: 'inherit' })
|
|
133
134
|
|
|
134
135
|
// Detect the newly installed directory
|
|
135
136
|
const afterDirs = existsSync(CLAUDE_SKILLS_DIR)
|
|
@@ -154,7 +155,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
154
155
|
} else {
|
|
155
156
|
const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
|
|
156
157
|
console.log(`📦 Cloning: ${gitUrl}`)
|
|
157
|
-
|
|
158
|
+
execFileSync('git', ['clone', '--depth', '1', gitUrl, tmpRepo], { stdio: 'inherit' })
|
|
158
159
|
skillSourceDir = tmpRepo
|
|
159
160
|
}
|
|
160
161
|
|
|
@@ -174,26 +175,79 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
|
|
177
|
-
|
|
178
|
+
const alias = options.as || skillName
|
|
179
|
+
const skillType = (options.type || 'tool').toLowerCase()
|
|
180
|
+
|
|
181
|
+
if (!['innate', 'tool', 'combo'].includes(skillType)) {
|
|
182
|
+
console.error(`❌ Invalid type: ${skillType}. Must be innate, tool, or combo.`)
|
|
183
|
+
process.exit(1)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fqPath = parsed.skill
|
|
187
|
+
? `${parsed.host}/${parsed.owner}/${parsed.repo}/${parsed.skill}`
|
|
188
|
+
: `${parsed.host}/${parsed.owner}/${parsed.repo}`
|
|
189
|
+
|
|
190
|
+
console.log(`✅ Skill ready: ${skillName} (alias: ${alias})`)
|
|
178
191
|
console.log(` Location: ${skillDir}`)
|
|
179
192
|
|
|
193
|
+
// ── 写 deck.toml ────────────────────────────────────────────
|
|
194
|
+
|
|
180
195
|
if (existsSync(deckPath)) {
|
|
181
196
|
const deckRaw = readFileSync(deckPath, 'utf-8')
|
|
182
197
|
const deck = parseToml(deckRaw) as any
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
deck
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
|
|
199
|
+
// Alias collision check across all sections
|
|
200
|
+
const allAliases = new Set<string>()
|
|
201
|
+
for (const section of ['innate', 'tool', 'combo'] as const) {
|
|
202
|
+
const skills = deck[section]?.skills
|
|
203
|
+
if (skills && typeof skills === 'object' && !Array.isArray(skills)) {
|
|
204
|
+
for (const key of Object.keys(skills)) allAliases.add(key)
|
|
205
|
+
} else if (Array.isArray(skills)) {
|
|
206
|
+
for (const name of skills) allAliases.add(name.split('/').pop() || name)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const key of Object.keys(deck.transient || {})) {
|
|
210
|
+
allAliases.add(key)
|
|
192
211
|
}
|
|
212
|
+
if (allAliases.has(alias)) {
|
|
213
|
+
console.error(`❌ Alias "${alias}" already exists in deck`)
|
|
214
|
+
process.exit(1)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Auto-migrate old string-array format to dict
|
|
218
|
+
for (const section of ['innate', 'tool', 'combo'] as const) {
|
|
219
|
+
const sectionData = deck[section]
|
|
220
|
+
if (sectionData && Array.isArray(sectionData.skills)) {
|
|
221
|
+
const dict: Record<string, { path: string }> = {}
|
|
222
|
+
for (const name of sectionData.skills) {
|
|
223
|
+
const a = name.split('/').pop() || name
|
|
224
|
+
dict[a] = { path: name }
|
|
225
|
+
}
|
|
226
|
+
deck[section].skills = dict
|
|
227
|
+
console.log(`📝 Auto-migrated [${section}] from string-array to dict format`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Ensure target section exists and is dict format
|
|
232
|
+
if (!deck[skillType]) deck[skillType] = {}
|
|
233
|
+
if (!deck[skillType].skills) deck[skillType].skills = {}
|
|
234
|
+
if (Array.isArray(deck[skillType].skills)) {
|
|
235
|
+
const dict: Record<string, { path: string }> = {}
|
|
236
|
+
for (const name of deck[skillType].skills) {
|
|
237
|
+
const a = name.split('/').pop() || name
|
|
238
|
+
dict[a] = { path: name }
|
|
239
|
+
}
|
|
240
|
+
deck[skillType].skills = dict
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
deck[skillType].skills[alias] = { path: fqPath }
|
|
244
|
+
writeFileSync(deckPath, stringifyToml(deck))
|
|
245
|
+
console.log(`📝 Added "${alias}" to [${skillType}.skills] in ${deckPath}`)
|
|
193
246
|
} else {
|
|
194
|
-
const minimal = { deck: { max_cards: 10 }
|
|
247
|
+
const minimal: any = { deck: { max_cards: 10 } }
|
|
248
|
+
minimal[skillType] = { skills: { [alias]: { path: fqPath } } }
|
|
195
249
|
writeFileSync(deckPath, stringifyToml(minimal))
|
|
196
|
-
console.log(`📝 Created ${deckPath} with "${
|
|
250
|
+
console.log(`📝 Created ${deckPath} with "${alias}"`)
|
|
197
251
|
}
|
|
198
252
|
|
|
199
253
|
console.log('🔗 Running deck link...')
|
package/src/cli.ts
CHANGED
|
@@ -2,38 +2,53 @@
|
|
|
2
2
|
import { linkDeck } from './link.js'
|
|
3
3
|
import { validateDeck } from './validate.js'
|
|
4
4
|
import { addSkill } from './add.js'
|
|
5
|
+
import { refreshDeck } from './refresh.js'
|
|
5
6
|
import { updateDeck } from './update.js'
|
|
7
|
+
import { migrateSchema } from './migrate-schema.js'
|
|
8
|
+
import { removeSkill } from './remove.js'
|
|
9
|
+
import { pruneDeck } from './prune.js'
|
|
6
10
|
import { formatHelp } from './help.js'
|
|
7
11
|
|
|
12
|
+
const args = process.argv.slice(2)
|
|
13
|
+
const command = args[0]
|
|
14
|
+
|
|
15
|
+
const deckFlagIdx = args.indexOf('--deck')
|
|
16
|
+
const workdirFlagIdx = args.indexOf('--workdir')
|
|
17
|
+
const viaFlagIdx = args.indexOf('--via')
|
|
18
|
+
const asFlagIdx = args.indexOf('--as')
|
|
19
|
+
const typeFlagIdx = args.indexOf('--type')
|
|
20
|
+
|
|
21
|
+
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
22
|
+
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
23
|
+
const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
|
|
24
|
+
const as = asFlagIdx >= 0 ? args[asFlagIdx + 1] : undefined
|
|
25
|
+
const type = typeFlagIdx >= 0 ? args[typeFlagIdx + 1] : undefined
|
|
26
|
+
const noBackup = args.includes('--no-backup')
|
|
27
|
+
const yes = args.includes('--yes')
|
|
28
|
+
|
|
8
29
|
const HELP_CONFIG = {
|
|
9
30
|
binName: 'lythoskill-deck',
|
|
10
31
|
description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
|
|
11
32
|
commands: [
|
|
12
33
|
{ name: 'link', description: 'Sync working set with skill-deck.toml' },
|
|
13
34
|
{ name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
|
|
14
|
-
{ name: '
|
|
35
|
+
{ name: 'refresh', description: 'Pull latest versions of declared skills from upstream', args: '[<fq|alias>]' },
|
|
15
36
|
{ name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
|
|
37
|
+
{ name: 'remove', description: 'Remove a skill from deck.toml and working set', args: '<fq|alias>' },
|
|
38
|
+
{ name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
|
|
39
|
+
{ name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
|
|
16
40
|
],
|
|
17
41
|
options: [
|
|
18
42
|
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
|
|
19
43
|
{ flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
|
|
20
44
|
{ flag: '--no-backup', description: 'Skip tar backup when removing non-symlink entries' },
|
|
21
45
|
{ flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
|
|
46
|
+
{ flag: '--as <alias>', description: 'Explicit alias for the skill (default: basename of path)' },
|
|
47
|
+
{ flag: '--type <type>', description: 'Target section: innate | tool | combo (default: tool)' },
|
|
48
|
+
{ flag: '--yes', description: 'Skip interactive confirmation (for prune)' },
|
|
22
49
|
],
|
|
23
50
|
}
|
|
24
51
|
|
|
25
|
-
const args = process.argv.slice(2)
|
|
26
|
-
const command = args[0]
|
|
27
|
-
|
|
28
|
-
const deckFlagIdx = args.indexOf('--deck')
|
|
29
|
-
const workdirFlagIdx = args.indexOf('--workdir')
|
|
30
|
-
const viaFlagIdx = args.indexOf('--via')
|
|
31
|
-
|
|
32
|
-
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
33
|
-
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
34
|
-
const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
|
|
35
|
-
const noBackup = args.includes('--no-backup')
|
|
36
|
-
|
|
37
52
|
switch (command) {
|
|
38
53
|
case '--help':
|
|
39
54
|
case '-h':
|
|
@@ -48,15 +63,49 @@ switch (command) {
|
|
|
48
63
|
console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
|
|
49
64
|
process.exit(1)
|
|
50
65
|
}
|
|
51
|
-
await addSkill(locator, { via, deck: deckPath, workdir })
|
|
66
|
+
await addSkill(locator, { via, deck: deckPath, workdir, as, type })
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
case 'refresh': {
|
|
70
|
+
const refreshTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
71
|
+
refreshDeck(deckPath, workdir, refreshTarget)
|
|
52
72
|
break
|
|
53
73
|
}
|
|
54
|
-
case 'update':
|
|
55
|
-
|
|
74
|
+
case 'update': {
|
|
75
|
+
const updateTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
76
|
+
updateDeck(deckPath, workdir, updateTarget)
|
|
56
77
|
break
|
|
78
|
+
}
|
|
57
79
|
case 'validate':
|
|
58
80
|
validateDeck(deckPath, workdir)
|
|
59
81
|
break
|
|
82
|
+
case 'remove': {
|
|
83
|
+
const removeTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
84
|
+
if (!removeTarget) {
|
|
85
|
+
console.error('❌ Missing target. Usage: deck remove <fq|alias>')
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
removeSkill(removeTarget, deckPath, workdir)
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case 'prune': {
|
|
92
|
+
await pruneDeck(deckPath, workdir, yes)
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
case 'migrate-schema': {
|
|
96
|
+
const dryRun = args.includes('--dry-run')
|
|
97
|
+
const targetPath = deckPath || 'skill-deck.toml'
|
|
98
|
+
const result = migrateSchema(targetPath, dryRun)
|
|
99
|
+
if (result.diff) {
|
|
100
|
+
console.log(result.message)
|
|
101
|
+
console.log('---')
|
|
102
|
+
console.log(result.diff)
|
|
103
|
+
} else {
|
|
104
|
+
console.log(result.message)
|
|
105
|
+
}
|
|
106
|
+
if (!result.migrated) process.exit(0)
|
|
107
|
+
break
|
|
108
|
+
}
|
|
60
109
|
default:
|
|
61
110
|
console.error(formatHelp(HELP_CONFIG))
|
|
62
111
|
process.exit(1)
|
package/src/link.ts
CHANGED
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
existsSync, mkdirSync, readFileSync, readdirSync,
|
|
15
15
|
symlinkSync, lstatSync, rmSync, statSync, writeFileSync,
|
|
16
16
|
} from "node:fs";
|
|
17
|
-
import {
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
18
|
import { resolve, dirname, join, basename, relative } from "node:path";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import {
|
|
21
21
|
SkillDeckLockSchema,
|
|
22
22
|
type SkillDeckLock, type LinkedSkill, type ConstraintReport,
|
|
23
23
|
} from "./schema.js";
|
|
24
|
+
import { parseDeck } from "./parse-deck.js";
|
|
24
25
|
|
|
25
26
|
// ── 路径工具 ────────────────────────────────────────────────
|
|
26
27
|
|
|
@@ -173,44 +174,47 @@ if (!existsSync(DECK_PATH)) {
|
|
|
173
174
|
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
174
175
|
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
175
176
|
const deckHash = hashContent(deckRaw);
|
|
176
|
-
const deck = parseToml(deckRaw) as any;
|
|
177
177
|
|
|
178
|
-
const
|
|
179
|
-
|
|
178
|
+
const { entries: parsedEntries, deprecated: isDeprecated, errors: parseErrors } = parseDeck(deckRaw);
|
|
179
|
+
if (isDeprecated) {
|
|
180
|
+
console.warn("⚠️ Deprecation: string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsedToml = parseToml(deckRaw) as any;
|
|
184
|
+
const WORKING_SET_RAW = parsedToml.deck?.working_set || ".claude/skills";
|
|
185
|
+
const COLD_POOL_RAW = parsedToml.deck?.cold_pool || "~/.agents/skill-repos";
|
|
180
186
|
const WORKING_SET = expandHome(WORKING_SET_RAW, PROJECT_DIR);
|
|
181
187
|
const COLD_POOL = expandHome(COLD_POOL_RAW, PROJECT_DIR);
|
|
182
|
-
const MAX_CARDS = Number(
|
|
188
|
+
const MAX_CARDS = Number(parsedToml.deck?.max_cards || 10);
|
|
183
189
|
|
|
184
190
|
// ── 收集声明 ────────────────────────────────────────────────
|
|
185
191
|
|
|
186
192
|
interface DeclaredSkill {
|
|
187
|
-
name: string;
|
|
193
|
+
name: string; // original path/name (for lock.source backward-compat)
|
|
194
|
+
alias: string; // working-set flat symlink name
|
|
188
195
|
type: "innate" | "tool" | "combo" | "transient";
|
|
189
196
|
sourcePath: string;
|
|
190
197
|
expires?: string;
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
const declared: DeclaredSkill[] = [];
|
|
194
|
-
const errors: string[] = [];
|
|
195
|
-
|
|
196
|
-
for (const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
errors.push(`Skill not found: ${name}`);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
declared.push({ name, type: section, sourcePath: result.path });
|
|
201
|
+
const errors: string[] = [...parseErrors];
|
|
202
|
+
|
|
203
|
+
for (const entry of parsedEntries) {
|
|
204
|
+
const result = findSource(entry.path, COLD_POOL, PROJECT_DIR);
|
|
205
|
+
if (result.error) {
|
|
206
|
+
errors.push(result.error);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!result.path) {
|
|
210
|
+
errors.push(`Skill not found: ${entry.path}`);
|
|
211
|
+
continue;
|
|
209
212
|
}
|
|
213
|
+
declared.push({ name: entry.path, alias: entry.alias, type: entry.type, sourcePath: result.path });
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
// transient: sub-tables with path field
|
|
213
|
-
for (const [key, value] of Object.entries(
|
|
216
|
+
// transient: sub-tables with path field (kept backward-compat; future ADR may unify)
|
|
217
|
+
for (const [key, value] of Object.entries(parsedToml.transient || {})) {
|
|
214
218
|
const t = value as any;
|
|
215
219
|
if (!t?.path) continue;
|
|
216
220
|
const src = resolve(PROJECT_DIR, t.path);
|
|
@@ -218,7 +222,22 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
|
|
|
218
222
|
errors.push(`Transient path does not exist: ${key} → ${src}`);
|
|
219
223
|
continue;
|
|
220
224
|
}
|
|
221
|
-
declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
225
|
+
declared.push({ name: key, alias: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 跨 type alias collision 检测 ──────────────────────────────
|
|
229
|
+
const aliasToTypes = new Map<string, string[]>();
|
|
230
|
+
for (const d of declared) {
|
|
231
|
+
const types = aliasToTypes.get(d.alias) || [];
|
|
232
|
+
types.push(d.type);
|
|
233
|
+
aliasToTypes.set(d.alias, types);
|
|
234
|
+
}
|
|
235
|
+
for (const [alias, types] of aliasToTypes) {
|
|
236
|
+
if (types.length > 1) {
|
|
237
|
+
errors.push(
|
|
238
|
+
`Alias collision: "${alias}" appears in [${types.join('], [')}]. Use --as to specify different aliases.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
if (errors.length > 0) {
|
|
@@ -242,6 +261,12 @@ if (errors.length > 0) {
|
|
|
242
261
|
}
|
|
243
262
|
// 继续执行已找到的 skill,不因个别缺失中断全部
|
|
244
263
|
|
|
264
|
+
// fatal errors: alias collision must block
|
|
265
|
+
const fatalErrors = errors.filter(e => e.includes('Alias collision'));
|
|
266
|
+
if (fatalErrors.length > 0) {
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
// 引导:如果 cold pool 为空,给出更明确的指引
|
|
246
271
|
const hasSkills = existsSync(COLD_POOL) && readdirSync(COLD_POOL).filter(e => !e.startsWith('.')).length > 0;
|
|
247
272
|
if (!hasSkills) {
|
|
@@ -328,7 +353,7 @@ if (nonSymlinks.length > 0) {
|
|
|
328
353
|
...nonSymlinks.map(e => relative(PROJECT_DIR, join(WORKING_SET, e))),
|
|
329
354
|
];
|
|
330
355
|
try {
|
|
331
|
-
|
|
356
|
+
execFileSync("tar", tarArgs, {
|
|
332
357
|
cwd: PROJECT_DIR,
|
|
333
358
|
stdio: "pipe",
|
|
334
359
|
});
|
|
@@ -348,7 +373,7 @@ if (nonSymlinks.length > 0) {
|
|
|
348
373
|
}
|
|
349
374
|
|
|
350
375
|
// 清理未声明的 symlink
|
|
351
|
-
const declaredNames = new Set(declared.map(d => d.
|
|
376
|
+
const declaredNames = new Set(declared.map(d => d.alias));
|
|
352
377
|
try {
|
|
353
378
|
for (const entry of readdirSync(WORKING_SET)) {
|
|
354
379
|
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
@@ -368,7 +393,7 @@ try {
|
|
|
368
393
|
const linkedSkills: LinkedSkill[] = [];
|
|
369
394
|
|
|
370
395
|
for (const item of declared) {
|
|
371
|
-
const dest = join(WORKING_SET, item.
|
|
396
|
+
const dest = join(WORKING_SET, item.alias);
|
|
372
397
|
|
|
373
398
|
// 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
|
|
374
399
|
try {
|
|
@@ -380,7 +405,7 @@ for (const item of declared) {
|
|
|
380
405
|
mkdirSync(dirname(dest), { recursive: true });
|
|
381
406
|
symlinkSync(item.sourcePath, dest);
|
|
382
407
|
} catch (err: any) {
|
|
383
|
-
console.error(`❌ Link failed: ${item.
|
|
408
|
+
console.error(`❌ Link failed: ${item.alias}: ${err.message}`);
|
|
384
409
|
continue;
|
|
385
410
|
}
|
|
386
411
|
|
|
@@ -405,6 +430,7 @@ for (const item of declared) {
|
|
|
405
430
|
|
|
406
431
|
linkedSkills.push({
|
|
407
432
|
name: item.name,
|
|
433
|
+
alias: item.alias,
|
|
408
434
|
deck_niche: niche,
|
|
409
435
|
type: item.type,
|
|
410
436
|
source: sourceRel,
|
|
@@ -415,7 +441,7 @@ for (const item of declared) {
|
|
|
415
441
|
deck_managed_dirs: managedDirs,
|
|
416
442
|
});
|
|
417
443
|
|
|
418
|
-
console.log(` 🔗 ${item.
|
|
444
|
+
console.log(` 🔗 ${item.alias}`);
|
|
419
445
|
}
|
|
420
446
|
|
|
421
447
|
// ── Transient 过期检查 ──────────────────────────────────────
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parse as parseToml, stringify } from "@iarna/toml";
|
|
3
|
+
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
4
|
+
import { parseDeck } from "./parse-deck.js";
|
|
5
|
+
|
|
6
|
+
export interface MigrateResult {
|
|
7
|
+
migrated: boolean;
|
|
8
|
+
message: string;
|
|
9
|
+
diff?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function migrateSchema(deckPath: string, dryRun: boolean): MigrateResult {
|
|
13
|
+
const raw = readFileSync(deckPath, "utf-8");
|
|
14
|
+
const { deprecated } = parseDeck(raw);
|
|
15
|
+
|
|
16
|
+
if (!deprecated) {
|
|
17
|
+
return {
|
|
18
|
+
migrated: false,
|
|
19
|
+
message: "No migration needed: deck.toml already uses alias-as-key dict schema.",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parsed = parseToml(raw) as any;
|
|
24
|
+
|
|
25
|
+
for (const section of ["innate", "tool", "combo"] as const) {
|
|
26
|
+
const skills = parsed[section]?.skills;
|
|
27
|
+
if (!Array.isArray(skills)) continue;
|
|
28
|
+
|
|
29
|
+
delete parsed[section].skills;
|
|
30
|
+
const entries: Record<string, { path: string }> = {};
|
|
31
|
+
for (const name of skills) {
|
|
32
|
+
if (!name || typeof name !== "string") continue;
|
|
33
|
+
const alias = name.split("/").pop() || name;
|
|
34
|
+
entries[alias] = { path: name };
|
|
35
|
+
}
|
|
36
|
+
parsed[section] = { skills: entries };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const newToml = stringify(parsed);
|
|
40
|
+
|
|
41
|
+
if (dryRun) {
|
|
42
|
+
return {
|
|
43
|
+
migrated: true,
|
|
44
|
+
message: `Dry run — would migrate ${deckPath} to alias-as-key dict schema:`,
|
|
45
|
+
diff: newToml,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
50
|
+
const backupPath = `${deckPath}.bak.${ts}`;
|
|
51
|
+
renameSync(deckPath, backupPath);
|
|
52
|
+
writeFileSync(deckPath, newToml);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
migrated: true,
|
|
56
|
+
message: `Migrated ${deckPath} → alias-as-key dict schema. Backup: ${backupPath}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parse as parseToml } from "@iarna/toml";
|
|
2
|
+
import { SkillEntrySchema } from "./schema.js";
|
|
3
|
+
|
|
4
|
+
export type SkillType = "innate" | "tool" | "combo";
|
|
5
|
+
|
|
6
|
+
export interface ParsedSkillEntry {
|
|
7
|
+
alias: string; // working-set flat symlink name = role identity
|
|
8
|
+
path: string; // FQ locator or local path
|
|
9
|
+
type: SkillType;
|
|
10
|
+
role?: string;
|
|
11
|
+
why_in_deck?: string;
|
|
12
|
+
[key: string]: unknown; // forward-compat: unknown fields pass through
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ParsedDeck {
|
|
16
|
+
entries: ParsedSkillEntry[];
|
|
17
|
+
deprecated: boolean; // true if any section used legacy string-array
|
|
18
|
+
errors: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseDeck(raw: string): ParsedDeck {
|
|
22
|
+
const parsed = parseToml(raw) as any;
|
|
23
|
+
const entries: ParsedSkillEntry[] = [];
|
|
24
|
+
const errors: string[] = [];
|
|
25
|
+
let deprecated = false;
|
|
26
|
+
|
|
27
|
+
for (const section of ["innate", "tool", "combo"] as const) {
|
|
28
|
+
const sectionData = parsed[section];
|
|
29
|
+
if (!sectionData) continue;
|
|
30
|
+
|
|
31
|
+
// ── New format: [<type>.skills.<alias>] with path in body ──
|
|
32
|
+
if (
|
|
33
|
+
sectionData.skills &&
|
|
34
|
+
typeof sectionData.skills === "object" &&
|
|
35
|
+
!Array.isArray(sectionData.skills)
|
|
36
|
+
) {
|
|
37
|
+
for (const [alias, entry] of Object.entries(sectionData.skills)) {
|
|
38
|
+
const e = entry as Record<string, unknown>;
|
|
39
|
+
if (!e?.path || typeof e.path !== "string") {
|
|
40
|
+
errors.push(
|
|
41
|
+
`Missing path for skill "${alias}" in [${section}.skills.${alias}]`
|
|
42
|
+
);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const parsedEntry = SkillEntrySchema.safeParse(e);
|
|
46
|
+
if (!parsedEntry.success) {
|
|
47
|
+
errors.push(
|
|
48
|
+
`Invalid entry "${alias}" in [${section}.skills.${alias}]: ${parsedEntry.error.message}`
|
|
49
|
+
);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
entries.push({
|
|
53
|
+
alias,
|
|
54
|
+
path: e.path as string,
|
|
55
|
+
type: section,
|
|
56
|
+
...parsedEntry.data,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Legacy format: [<type>] with skills = ["...", ...] ──
|
|
63
|
+
const skillsArray = sectionData?.skills;
|
|
64
|
+
if (Array.isArray(skillsArray)) {
|
|
65
|
+
deprecated = true;
|
|
66
|
+
for (const name of skillsArray) {
|
|
67
|
+
if (!name || typeof name !== "string") continue;
|
|
68
|
+
entries.push({
|
|
69
|
+
alias: name.split("/").pop() || name,
|
|
70
|
+
path: name,
|
|
71
|
+
type: section,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { entries, deprecated, errors };
|
|
78
|
+
}
|
package/src/prune.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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 { parse as parseToml } from "@iarna/toml";
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
12
|
+
import { resolve, dirname, join, relative } from "node:path";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
import { findDeckToml, expandHome, findSource } from "./link.js";
|
|
15
|
+
import { parseDeck } from "./parse-deck.js";
|
|
16
|
+
|
|
17
|
+
interface PruneCandidate {
|
|
18
|
+
repoPath: string;
|
|
19
|
+
repoRel: string;
|
|
20
|
+
size: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatSize(bytes: number): string {
|
|
24
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
25
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
26
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
27
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function calculateDirSize(dir: string): number {
|
|
31
|
+
let total = 0;
|
|
32
|
+
try {
|
|
33
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
const p = join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
total += calculateDirSize(p);
|
|
37
|
+
} else if (entry.isFile()) {
|
|
38
|
+
total += statSync(p).size;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
return total;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function scanColdPoolRepos(coldPool: string): string[] {
|
|
46
|
+
const repos: string[] = [];
|
|
47
|
+
try {
|
|
48
|
+
for (const host of readdirSync(coldPool, { withFileTypes: true })) {
|
|
49
|
+
if (!host.isDirectory() || host.name.startsWith(".")) continue;
|
|
50
|
+
const hostPath = join(coldPool, host.name);
|
|
51
|
+
for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
|
|
52
|
+
if (!owner.isDirectory() || owner.name.startsWith(".")) continue;
|
|
53
|
+
const ownerPath = join(hostPath, owner.name);
|
|
54
|
+
for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
|
|
55
|
+
if (!repo.isDirectory() || repo.name.startsWith(".")) continue;
|
|
56
|
+
repos.push(join(ownerPath, repo.name));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
return repos;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRepoReferenced(repoPath: string, declaredPaths: string[], coldPool: string, projectDir: string): boolean {
|
|
65
|
+
for (const path of declaredPaths) {
|
|
66
|
+
const result = findSource(path, coldPool, projectDir);
|
|
67
|
+
if (result.path) {
|
|
68
|
+
// Check if the resolved skill path is inside this repo
|
|
69
|
+
const rel = relative(repoPath, result.path);
|
|
70
|
+
if (!rel.startsWith("..") && rel !== "") {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (result.path === repoPath) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function confirm(message: string): Promise<boolean> {
|
|
82
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
rl.question(`${message} (y/N) `, (answer) => {
|
|
85
|
+
rl.close();
|
|
86
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function pruneDeck(cliDeckPath?: string, cliWorkdir?: string, yes?: boolean): Promise<void> {
|
|
92
|
+
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
93
|
+
const DECK_PATH = cliDeck
|
|
94
|
+
? resolve(cliDeck)
|
|
95
|
+
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
96
|
+
|
|
97
|
+
if (!existsSync(DECK_PATH)) {
|
|
98
|
+
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
99
|
+
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
104
|
+
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
105
|
+
const deck = parseToml(deckRaw) as any;
|
|
106
|
+
|
|
107
|
+
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
108
|
+
|
|
109
|
+
// ── 收集声明 ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const { entries: parsedEntries } = parseDeck(deckRaw);
|
|
112
|
+
const declaredPaths = parsedEntries.map(e => e.path);
|
|
113
|
+
|
|
114
|
+
// Legacy string-array fallback
|
|
115
|
+
for (const section of ["innate", "tool", "combo"] as const) {
|
|
116
|
+
const skills = deck[section]?.skills;
|
|
117
|
+
if (Array.isArray(skills)) {
|
|
118
|
+
for (const name of skills) {
|
|
119
|
+
if (name && typeof name === "string" && !declaredPaths.includes(name)) {
|
|
120
|
+
declaredPaths.push(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 扫描 cold pool ──────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
if (!existsSync(COLD_POOL)) {
|
|
129
|
+
console.log("📭 Cold pool does not exist. Nothing to prune.");
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const allRepos = scanColdPoolRepos(COLD_POOL);
|
|
134
|
+
if (allRepos.length === 0) {
|
|
135
|
+
console.log("📭 Cold pool is empty. Nothing to prune.");
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 求差集 ──────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const candidates: PruneCandidate[] = [];
|
|
142
|
+
for (const repoPath of allRepos) {
|
|
143
|
+
if (isRepoReferenced(repoPath, declaredPaths, COLD_POOL, PROJECT_DIR)) continue;
|
|
144
|
+
const size = calculateDirSize(repoPath);
|
|
145
|
+
candidates.push({ repoPath, repoRel: relative(COLD_POOL, repoPath), size });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (candidates.length === 0) {
|
|
149
|
+
console.log("✅ All cold pool repositories are referenced. Nothing to prune.");
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 报告 ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const totalSize = candidates.reduce((sum, c) => sum + c.size, 0);
|
|
156
|
+
console.log(`\n🧹 Prune candidates — ${candidates.length} repo(s), ${formatSize(totalSize)} total:\n`);
|
|
157
|
+
for (const c of candidates) {
|
|
158
|
+
console.log(` ${c.repoRel} (${formatSize(c.size)})`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 确认 ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
let shouldDelete = false;
|
|
164
|
+
if (yes) {
|
|
165
|
+
shouldDelete = true;
|
|
166
|
+
console.log("\n⚠️ --yes flag set: deleting without confirmation.");
|
|
167
|
+
} else {
|
|
168
|
+
shouldDelete = await confirm(`\nDelete ${candidates.length} unreferenced repo(s)?`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!shouldDelete) {
|
|
172
|
+
console.log("❎ Prune cancelled.");
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── 执行删除 ────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
let deleted = 0;
|
|
179
|
+
let failed = 0;
|
|
180
|
+
for (const c of candidates) {
|
|
181
|
+
try {
|
|
182
|
+
rmSync(c.repoPath, { recursive: true, force: true });
|
|
183
|
+
console.log(` 🗑️ Deleted: ${c.repoRel}`);
|
|
184
|
+
deleted++;
|
|
185
|
+
} catch (err: any) {
|
|
186
|
+
console.error(` ❌ Failed to delete ${c.repoRel}: ${err.message}`);
|
|
187
|
+
failed++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log(`\n📦 Prune complete: ${deleted} deleted, ${failed} failed`);
|
|
192
|
+
|
|
193
|
+
if (failed > 0) {
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/refresh.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-refresh.ts — Refresh declared skills from their upstream sources
|
|
4
|
+
*
|
|
5
|
+
* Reads skill-deck.toml → traverses declared skills → git pull.
|
|
6
|
+
* Supports single-skill (by FQ path or alias) or all skills.
|
|
7
|
+
* Never modifies deck.toml.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parse as parseToml } from "@iarna/toml";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { resolve, dirname, join, relative } from "node:path";
|
|
14
|
+
import { findDeckToml, expandHome, findSource, linkDeck } from "./link.js";
|
|
15
|
+
import { parseDeck } from "./parse-deck.js";
|
|
16
|
+
|
|
17
|
+
interface RefreshResult {
|
|
18
|
+
name: string;
|
|
19
|
+
path: string;
|
|
20
|
+
status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
|
|
21
|
+
message?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isGitRepo(dir: string): boolean {
|
|
25
|
+
return existsSync(join(dir, ".git"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
|
|
29
|
+
try {
|
|
30
|
+
const output = execSync("git pull", {
|
|
31
|
+
cwd: dir,
|
|
32
|
+
encoding: "utf-8",
|
|
33
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
34
|
+
timeout: 30000,
|
|
35
|
+
}).trim();
|
|
36
|
+
|
|
37
|
+
if (output.includes("Already up to date") || output.includes("Already up-to-date")) {
|
|
38
|
+
return { status: "up-to-date", message: output };
|
|
39
|
+
}
|
|
40
|
+
return { status: "updated", message: output };
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
const stderr = err.stderr?.toString() || err.message || "";
|
|
43
|
+
return { status: "failed", message: stderr.trim() };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function refreshDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
|
|
48
|
+
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
49
|
+
const DECK_PATH = cliDeck
|
|
50
|
+
? resolve(cliDeck)
|
|
51
|
+
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
52
|
+
|
|
53
|
+
if (!existsSync(DECK_PATH)) {
|
|
54
|
+
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
55
|
+
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
60
|
+
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
61
|
+
const deck = parseToml(deckRaw) as any;
|
|
62
|
+
|
|
63
|
+
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
64
|
+
|
|
65
|
+
// ── 收集声明 ────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const declared: { name: string; alias: string; path: string; type: string }[] = [];
|
|
68
|
+
|
|
69
|
+
// Use parseDeck for alias-dict compatibility
|
|
70
|
+
const { entries: parsedEntries, deprecated: isDeprecated } = parseDeck(deckRaw);
|
|
71
|
+
if (isDeprecated) {
|
|
72
|
+
console.warn("⚠️ Deprecation: string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const entry of parsedEntries) {
|
|
76
|
+
declared.push({ name: entry.path, alias: entry.alias, path: entry.path, type: entry.type });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (declared.length === 0) {
|
|
80
|
+
console.log("📭 No skills declared in deck. Nothing to refresh.");
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 确定目标 ────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
let targets: { name: string; alias: string; path: string; type: string }[];
|
|
87
|
+
|
|
88
|
+
if (target) {
|
|
89
|
+
// Try resolve as alias first, then as FQ path
|
|
90
|
+
const byAlias = declared.find(d => d.alias === target);
|
|
91
|
+
if (byAlias) {
|
|
92
|
+
targets = [byAlias];
|
|
93
|
+
} else {
|
|
94
|
+
const byPath = declared.find(d => d.path === target);
|
|
95
|
+
if (byPath) {
|
|
96
|
+
targets = [byPath];
|
|
97
|
+
} else {
|
|
98
|
+
console.error(`❌ Skill not found in deck: ${target}`);
|
|
99
|
+
console.error(` Declared aliases: ${declared.map(d => d.alias).join(", ")}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
targets = declared;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── 执行刷新 ────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const results: RefreshResult[] = [];
|
|
110
|
+
let updated = 0;
|
|
111
|
+
let upToDate = 0;
|
|
112
|
+
let skipped = 0;
|
|
113
|
+
let failed = 0;
|
|
114
|
+
|
|
115
|
+
for (const item of targets) {
|
|
116
|
+
const result = findSource(item.path, COLD_POOL, PROJECT_DIR);
|
|
117
|
+
|
|
118
|
+
if (result.error || !result.path) {
|
|
119
|
+
results.push({ name: item.alias, path: "", status: "failed", message: result.error || "Skill not found in cold pool" });
|
|
120
|
+
failed++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const path = result.path;
|
|
125
|
+
|
|
126
|
+
// localhost skills are user-managed; skip
|
|
127
|
+
const relativePath = relative(COLD_POOL, path);
|
|
128
|
+
if (relativePath.startsWith("localhost")) {
|
|
129
|
+
results.push({ name: item.alias, path: relativePath, status: "skipped", message: "localhost skill — user-managed" });
|
|
130
|
+
skipped++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isGitRepo(path)) {
|
|
135
|
+
results.push({ name: item.alias, path: relativePath, status: "not-git", message: "Not a git repository" });
|
|
136
|
+
skipped++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pullResult = gitPull(path);
|
|
141
|
+
results.push({ name: item.alias, path: relativePath, status: pullResult.status, message: pullResult.message });
|
|
142
|
+
|
|
143
|
+
if (pullResult.status === "updated") updated++;
|
|
144
|
+
else if (pullResult.status === "up-to-date") upToDate++;
|
|
145
|
+
else failed++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── 报告 ────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const scope = target ? `single skill` : `${declared.length} skill(s)`;
|
|
151
|
+
console.log(`\n📦 Skill Refresh Report — ${scope} checked`);
|
|
152
|
+
console.log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`);
|
|
153
|
+
console.log();
|
|
154
|
+
|
|
155
|
+
for (const r of results) {
|
|
156
|
+
const icon =
|
|
157
|
+
r.status === "updated" ? "🔄" :
|
|
158
|
+
r.status === "up-to-date" ? "✅" :
|
|
159
|
+
r.status === "skipped" ? "⏭️" :
|
|
160
|
+
r.status === "not-git" ? "📁" :
|
|
161
|
+
"❌";
|
|
162
|
+
console.log(`${icon} ${r.name}`);
|
|
163
|
+
if (r.message && r.status !== "up-to-date") {
|
|
164
|
+
const lines = r.message.split("\n").filter(l => l.trim());
|
|
165
|
+
for (const line of lines.slice(0, 3)) {
|
|
166
|
+
console.log(` ${line.trim()}`);
|
|
167
|
+
}
|
|
168
|
+
if (lines.length > 3) {
|
|
169
|
+
console.log(` ... (${lines.length - 3} more lines)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (updated > 0) {
|
|
175
|
+
console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync refreshed skills to working set.`);
|
|
176
|
+
console.log("🔗 Running deck link...");
|
|
177
|
+
linkDeck(cliDeckPath, cliWorkdir);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (failed > 0) {
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/remove.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-remove.ts — Remove a skill from the declaration layer
|
|
4
|
+
*
|
|
5
|
+
* Deletes the entry from skill-deck.toml and removes the working-set symlink.
|
|
6
|
+
* Does NOT touch the cold pool (use `deck prune` for material-layer GC).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseToml, stringify as stringifyToml } from "@iarna/toml";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { resolve, dirname, join } from "node:path";
|
|
12
|
+
import { findDeckToml, expandHome } from "./link.js";
|
|
13
|
+
import { parseDeck } from "./parse-deck.js";
|
|
14
|
+
|
|
15
|
+
export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
|
|
16
|
+
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
17
|
+
const DECK_PATH = cliDeck
|
|
18
|
+
? resolve(cliDeck)
|
|
19
|
+
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
20
|
+
|
|
21
|
+
if (!existsSync(DECK_PATH)) {
|
|
22
|
+
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
23
|
+
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
28
|
+
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
29
|
+
const deck = parseToml(deckRaw) as any;
|
|
30
|
+
|
|
31
|
+
const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
|
|
32
|
+
|
|
33
|
+
// ── 定位目标 ────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const { entries: parsedEntries } = parseDeck(deckRaw);
|
|
36
|
+
|
|
37
|
+
// Match by alias first, then by path
|
|
38
|
+
const match = parsedEntries.find(e => e.alias === target || e.path === target);
|
|
39
|
+
|
|
40
|
+
if (!match) {
|
|
41
|
+
console.error(`❌ Skill not found in deck: ${target}`);
|
|
42
|
+
const aliases = parsedEntries.map(e => e.alias);
|
|
43
|
+
if (aliases.length > 0) {
|
|
44
|
+
console.error(` Declared aliases: ${aliases.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 删 deck.toml 条目 ───────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const section = match.type;
|
|
52
|
+
const alias = match.alias;
|
|
53
|
+
|
|
54
|
+
if (deck[section]?.skills) {
|
|
55
|
+
if (Array.isArray(deck[section].skills)) {
|
|
56
|
+
// Legacy string-array format
|
|
57
|
+
deck[section].skills = deck[section].skills.filter((name: string) => {
|
|
58
|
+
const a = name.split("/").pop() || name;
|
|
59
|
+
return a !== alias;
|
|
60
|
+
});
|
|
61
|
+
if (deck[section].skills.length === 0) {
|
|
62
|
+
delete deck[section].skills;
|
|
63
|
+
}
|
|
64
|
+
} else if (typeof deck[section].skills === "object") {
|
|
65
|
+
// Dict format
|
|
66
|
+
delete deck[section].skills[alias];
|
|
67
|
+
if (Object.keys(deck[section].skills).length === 0) {
|
|
68
|
+
delete deck[section].skills;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Clean up empty section
|
|
72
|
+
if (Object.keys(deck[section] || {}).length === 0) {
|
|
73
|
+
delete deck[section];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeFileSync(DECK_PATH, stringifyToml(deck));
|
|
78
|
+
console.log(`📝 Removed "${alias}" from [${section}.skills] in ${DECK_PATH}`);
|
|
79
|
+
|
|
80
|
+
// ── 删 working set symlink ──────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const symlinkPath = join(WORKING_SET, alias);
|
|
83
|
+
if (existsSync(symlinkPath)) {
|
|
84
|
+
rmSync(symlinkPath, { recursive: true, force: true });
|
|
85
|
+
console.log(` 🗑️ Removed symlink: ${symlinkPath}`);
|
|
86
|
+
} else {
|
|
87
|
+
console.log(` ⚠️ Symlink not found: ${symlinkPath}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/skill-deck prune' to GC unreferenced repos.`);
|
|
91
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
// ── 单个已链接 Skill ────────────────────────────────────────
|
|
4
4
|
export const LinkedSkillSchema = z.object({
|
|
5
5
|
name: z.string(),
|
|
6
|
+
alias: z.string(),
|
|
6
7
|
deck_niche: z.string(),
|
|
7
8
|
type: z.enum(["innate", "tool", "combo", "transient"]),
|
|
8
9
|
source: z.string(),
|
|
@@ -49,6 +50,15 @@ export const SkillDeckLockSchema = z.object({
|
|
|
49
50
|
constraints: ConstraintReportSchema,
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
// ── Skill entry (alias-as-key dict body) ──────────────────────
|
|
54
|
+
export const SkillEntrySchema = z.object({
|
|
55
|
+
path: z.string().min(1),
|
|
56
|
+
role: z.string().optional(),
|
|
57
|
+
why_in_deck: z.string().optional(),
|
|
58
|
+
}).passthrough();
|
|
59
|
+
|
|
60
|
+
export type SkillEntry = z.infer<typeof SkillEntrySchema>;
|
|
61
|
+
|
|
52
62
|
export type LinkedSkill = z.infer<typeof LinkedSkillSchema>;
|
|
53
63
|
export type ConstraintReport = z.infer<typeof ConstraintReportSchema>;
|
|
54
64
|
export type SkillDeckLock = z.infer<typeof SkillDeckLockSchema>;
|
package/src/update.ts
CHANGED
|
@@ -1,154 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* deck-update.ts —
|
|
3
|
+
* deck-update.ts — Deprecated alias for refresh
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* 职责:让冷池跟上上游版本。
|
|
7
|
-
* 不做:下载新 skill(那是 add 的职责)、修改 deck.toml、同步 working set。
|
|
5
|
+
* Kept for backward compatibility. Will be removed in v1.0.0.
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
|
-
import {
|
|
11
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
-
import { execSync } from "node:child_process";
|
|
13
|
-
import { resolve, dirname, join, relative } from "node:path";
|
|
14
|
-
import { findDeckToml, expandHome, findSource } from "./link.js";
|
|
8
|
+
import { refreshDeck } from "./refresh.js";
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
|
|
20
|
-
message?: string;
|
|
10
|
+
export function updateDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
|
|
11
|
+
console.warn("⚠️ `deck update` is deprecated. Use `deck refresh` instead. (Removed in v1.0.0)");
|
|
12
|
+
refreshDeck(cliDeckPath, cliWorkdir, target);
|
|
21
13
|
}
|
|
22
|
-
|
|
23
|
-
function isGitRepo(dir: string): boolean {
|
|
24
|
-
return existsSync(join(dir, ".git"));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
|
|
28
|
-
try {
|
|
29
|
-
const output = execSync("git pull", {
|
|
30
|
-
cwd: dir,
|
|
31
|
-
encoding: "utf-8",
|
|
32
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
-
timeout: 30000,
|
|
34
|
-
}).trim();
|
|
35
|
-
|
|
36
|
-
if (output.includes("Already up to date") || output.includes("Already up-to-date")) {
|
|
37
|
-
return { status: "up-to-date", message: output };
|
|
38
|
-
}
|
|
39
|
-
return { status: "updated", message: output };
|
|
40
|
-
} catch (err: any) {
|
|
41
|
-
const stderr = err.stderr?.toString() || err.message || "";
|
|
42
|
-
return { status: "failed", message: stderr.trim() };
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function updateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
47
|
-
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
48
|
-
const DECK_PATH = cliDeck
|
|
49
|
-
? resolve(cliDeck)
|
|
50
|
-
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
51
|
-
|
|
52
|
-
if (!existsSync(DECK_PATH)) {
|
|
53
|
-
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
54
|
-
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
59
|
-
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
60
|
-
const deck = parseToml(deckRaw) as any;
|
|
61
|
-
|
|
62
|
-
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
63
|
-
|
|
64
|
-
// ── 收集声明 ────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
const declared: { name: string; type: string }[] = [];
|
|
67
|
-
|
|
68
|
-
for (const section of ["innate", "tool", "combo"] as const) {
|
|
69
|
-
for (const name of (deck[section]?.skills || [])) {
|
|
70
|
-
if (!name || typeof name !== "string") continue;
|
|
71
|
-
declared.push({ name, type: section });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (declared.length === 0) {
|
|
76
|
-
console.log("📭 No skills declared in deck. Nothing to update.");
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── 执行更新 ────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
const results: UpdateResult[] = [];
|
|
83
|
-
let updated = 0;
|
|
84
|
-
let upToDate = 0;
|
|
85
|
-
let skipped = 0;
|
|
86
|
-
let failed = 0;
|
|
87
|
-
|
|
88
|
-
for (const { name, type } of declared) {
|
|
89
|
-
const result = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
90
|
-
|
|
91
|
-
if (result.error || !result.path) {
|
|
92
|
-
results.push({ name, path: "", status: "failed", message: result.error || "Skill not found" });
|
|
93
|
-
failed++;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const path = result.path;
|
|
98
|
-
|
|
99
|
-
// localhost skills are user-managed; skip
|
|
100
|
-
const relativePath = relative(COLD_POOL, path);
|
|
101
|
-
if (relativePath.startsWith("localhost")) {
|
|
102
|
-
results.push({ name, path: relativePath, status: "skipped", message: "localhost skill — user-managed" });
|
|
103
|
-
skipped++;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (!isGitRepo(path)) {
|
|
108
|
-
results.push({ name, path: relativePath, status: "not-git", message: "Not a git repository" });
|
|
109
|
-
skipped++;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const pullResult = gitPull(path);
|
|
114
|
-
results.push({ name, path: relativePath, status: pullResult.status, message: pullResult.message });
|
|
115
|
-
|
|
116
|
-
if (pullResult.status === "updated") updated++;
|
|
117
|
-
else if (pullResult.status === "up-to-date") upToDate++;
|
|
118
|
-
else failed++;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── 报告 ────────────────────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
console.log(`\n📦 Skill Update Report — ${declared.length} skill(s) checked`);
|
|
124
|
-
console.log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`);
|
|
125
|
-
console.log();
|
|
126
|
-
|
|
127
|
-
for (const r of results) {
|
|
128
|
-
const icon =
|
|
129
|
-
r.status === "updated" ? "🔄" :
|
|
130
|
-
r.status === "up-to-date" ? "✅" :
|
|
131
|
-
r.status === "skipped" ? "⏭️" :
|
|
132
|
-
r.status === "not-git" ? "📁" :
|
|
133
|
-
"❌";
|
|
134
|
-
console.log(`${icon} ${r.name}`);
|
|
135
|
-
if (r.message && r.status !== "up-to-date") {
|
|
136
|
-
const lines = r.message.split("\n").filter(l => l.trim());
|
|
137
|
-
for (const line of lines.slice(0, 3)) {
|
|
138
|
-
console.log(` ${line.trim()}`);
|
|
139
|
-
}
|
|
140
|
-
if (lines.length > 3) {
|
|
141
|
-
console.log(` ... (${lines.length - 3} more lines)`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (updated > 0) {
|
|
147
|
-
console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync updated skills to working set.`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (failed > 0) {
|
|
151
|
-
process.exit(1);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|