@lythos/skill-deck 0.7.1 → 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 -18
- package/package.json +1 -1
- package/src/add.ts +70 -16
- package/src/cli.ts +65 -16
- package/src/link.ts +67 -34
- 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,6 +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` |
|
|
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` |
|
|
58
61
|
| Use a custom deck file or working dir | `bunx @lythos/skill-deck link --deck ./my-deck.toml --workdir /path/to/project` |
|
|
59
62
|
|
|
60
63
|
### Commands
|
|
@@ -63,7 +66,10 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
|
|
|
63
66
|
|---------|------|-------------|
|
|
64
67
|
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
|
|
65
68
|
| `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
|
|
66
|
-
| `add` | `<locator> [--via <backend>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
|
|
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`). |
|
|
67
73
|
|
|
68
74
|
### Options
|
|
69
75
|
|
|
@@ -72,6 +78,8 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
|
|
|
72
78
|
| `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
|
|
73
79
|
| `--workdir <dir>` | Working directory | cwd |
|
|
74
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` |
|
|
75
83
|
|
|
76
84
|
### Safety guards
|
|
77
85
|
|
|
@@ -102,8 +110,8 @@ cat > skill-deck.toml << 'EOF'
|
|
|
102
110
|
[deck]
|
|
103
111
|
max_cards = 10
|
|
104
112
|
|
|
105
|
-
[tool]
|
|
106
|
-
|
|
113
|
+
[tool.skills.lythoskill-deck]
|
|
114
|
+
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
107
115
|
EOF
|
|
108
116
|
|
|
109
117
|
# 2. Link — creates symlinks in .claude/skills/
|
|
@@ -138,6 +146,8 @@ Different agents look for skills in different directories. `skill-deck.toml` con
|
|
|
138
146
|
|---------|-------|-----|
|
|
139
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 |
|
|
140
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 |
|
|
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 |
|
|
141
151
|
| `link` refuses with "budget exceeded" | Declared skills > `max_cards` | Increase `max_cards` in `skill-deck.toml` or remove unused skills |
|
|
142
152
|
| `link` refuses with "unsafe working_set" | `working_set` resolves to `~` or `/` | Check `skill-deck.toml` has correct relative path (e.g. `.claude/skills/`) |
|
|
143
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,42 +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
|
-
|
|
180
|
-
|
|
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";
|
|
186
|
+
const WORKING_SET = expandHome(WORKING_SET_RAW, PROJECT_DIR);
|
|
187
|
+
const COLD_POOL = expandHome(COLD_POOL_RAW, PROJECT_DIR);
|
|
188
|
+
const MAX_CARDS = Number(parsedToml.deck?.max_cards || 10);
|
|
181
189
|
|
|
182
190
|
// ── 收集声明 ────────────────────────────────────────────────
|
|
183
191
|
|
|
184
192
|
interface DeclaredSkill {
|
|
185
|
-
name: string;
|
|
193
|
+
name: string; // original path/name (for lock.source backward-compat)
|
|
194
|
+
alias: string; // working-set flat symlink name
|
|
186
195
|
type: "innate" | "tool" | "combo" | "transient";
|
|
187
196
|
sourcePath: string;
|
|
188
197
|
expires?: string;
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
const declared: DeclaredSkill[] = [];
|
|
192
|
-
const errors: string[] = [];
|
|
193
|
-
|
|
194
|
-
for (const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
errors.push(`Skill not found: ${name}`);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
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;
|
|
207
212
|
}
|
|
213
|
+
declared.push({ name: entry.path, alias: entry.alias, type: entry.type, sourcePath: result.path });
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
// transient: sub-tables with path field
|
|
211
|
-
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 || {})) {
|
|
212
218
|
const t = value as any;
|
|
213
219
|
if (!t?.path) continue;
|
|
214
220
|
const src = resolve(PROJECT_DIR, t.path);
|
|
@@ -216,7 +222,22 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
|
|
|
216
222
|
errors.push(`Transient path does not exist: ${key} → ${src}`);
|
|
217
223
|
continue;
|
|
218
224
|
}
|
|
219
|
-
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
|
+
}
|
|
220
241
|
}
|
|
221
242
|
|
|
222
243
|
if (errors.length > 0) {
|
|
@@ -240,6 +261,12 @@ if (errors.length > 0) {
|
|
|
240
261
|
}
|
|
241
262
|
// 继续执行已找到的 skill,不因个别缺失中断全部
|
|
242
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
|
+
|
|
243
270
|
// 引导:如果 cold pool 为空,给出更明确的指引
|
|
244
271
|
const hasSkills = existsSync(COLD_POOL) && readdirSync(COLD_POOL).filter(e => !e.startsWith('.')).length > 0;
|
|
245
272
|
if (!hasSkills) {
|
|
@@ -326,7 +353,7 @@ if (nonSymlinks.length > 0) {
|
|
|
326
353
|
...nonSymlinks.map(e => relative(PROJECT_DIR, join(WORKING_SET, e))),
|
|
327
354
|
];
|
|
328
355
|
try {
|
|
329
|
-
|
|
356
|
+
execFileSync("tar", tarArgs, {
|
|
330
357
|
cwd: PROJECT_DIR,
|
|
331
358
|
stdio: "pipe",
|
|
332
359
|
});
|
|
@@ -346,7 +373,7 @@ if (nonSymlinks.length > 0) {
|
|
|
346
373
|
}
|
|
347
374
|
|
|
348
375
|
// 清理未声明的 symlink
|
|
349
|
-
const declaredNames = new Set(declared.map(d => d.
|
|
376
|
+
const declaredNames = new Set(declared.map(d => d.alias));
|
|
350
377
|
try {
|
|
351
378
|
for (const entry of readdirSync(WORKING_SET)) {
|
|
352
379
|
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
@@ -366,7 +393,7 @@ try {
|
|
|
366
393
|
const linkedSkills: LinkedSkill[] = [];
|
|
367
394
|
|
|
368
395
|
for (const item of declared) {
|
|
369
|
-
const dest = join(WORKING_SET, item.
|
|
396
|
+
const dest = join(WORKING_SET, item.alias);
|
|
370
397
|
|
|
371
398
|
// 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
|
|
372
399
|
try {
|
|
@@ -378,7 +405,7 @@ for (const item of declared) {
|
|
|
378
405
|
mkdirSync(dirname(dest), { recursive: true });
|
|
379
406
|
symlinkSync(item.sourcePath, dest);
|
|
380
407
|
} catch (err: any) {
|
|
381
|
-
console.error(`❌ Link failed: ${item.
|
|
408
|
+
console.error(`❌ Link failed: ${item.alias}: ${err.message}`);
|
|
382
409
|
continue;
|
|
383
410
|
}
|
|
384
411
|
|
|
@@ -396,19 +423,25 @@ for (const item of declared) {
|
|
|
396
423
|
contentHash = hashContent(readFileSync(skillMdPath, "utf-8"));
|
|
397
424
|
} catch {}
|
|
398
425
|
|
|
426
|
+
// source: relative to cold_pool (non-transient) or project dir (transient)
|
|
427
|
+
const sourceRel = item.type === "transient"
|
|
428
|
+
? relative(PROJECT_DIR, item.sourcePath)
|
|
429
|
+
: relative(COLD_POOL, item.sourcePath);
|
|
430
|
+
|
|
399
431
|
linkedSkills.push({
|
|
400
432
|
name: item.name,
|
|
433
|
+
alias: item.alias,
|
|
401
434
|
deck_niche: niche,
|
|
402
435
|
type: item.type,
|
|
403
|
-
source:
|
|
404
|
-
dest,
|
|
436
|
+
source: sourceRel,
|
|
437
|
+
dest: relative(PROJECT_DIR, dest),
|
|
405
438
|
content_hash: contentHash,
|
|
406
439
|
linked_at: new Date().toISOString(),
|
|
407
440
|
...(item.expires ? { expires: item.expires } : {}),
|
|
408
441
|
deck_managed_dirs: managedDirs,
|
|
409
442
|
});
|
|
410
443
|
|
|
411
|
-
console.log(` 🔗 ${item.
|
|
444
|
+
console.log(` 🔗 ${item.alias}`);
|
|
412
445
|
}
|
|
413
446
|
|
|
414
447
|
// ── Transient 过期检查 ──────────────────────────────────────
|
|
@@ -479,9 +512,9 @@ const constraints: ConstraintReport = {
|
|
|
479
512
|
const lock: SkillDeckLock = {
|
|
480
513
|
version: "1.0.0",
|
|
481
514
|
generated_at: new Date().toISOString(),
|
|
482
|
-
deck_source: { path: DECK_PATH, content_hash: deckHash },
|
|
483
|
-
working_set:
|
|
484
|
-
cold_pool:
|
|
515
|
+
deck_source: { path: relative(PROJECT_DIR, DECK_PATH), content_hash: deckHash },
|
|
516
|
+
working_set: WORKING_SET_RAW,
|
|
517
|
+
cold_pool: COLD_POOL_RAW,
|
|
485
518
|
skills: linkedSkills,
|
|
486
519
|
constraints,
|
|
487
520
|
};
|
|
@@ -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
|
-
|