@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 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
- skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
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] # Always-loaded skills
34
- skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
33
+ [innate.skills.lythoskill-deck] # Always-loaded skills
34
+ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
35
35
 
36
- [tool] # Auto-triggered skills
37
- skills = [
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
- [combo] # Multi-skill bundles
43
- skills = ["github.com/anthropics/skills/skills/pdf"]
39
+ [tool.skills.gstack]
40
+ path = "github.com/garrytan/gstack"
44
41
 
45
- [transient] # Temporary skills with expiry
46
- [transient.handoff]
47
- path = "./skills/handoff" # Local path (not cold pool)
48
- expires = "2026-05-01" # ISO date; warns at ≤14 days
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
- skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
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 { execSync } from 'node:child_process'
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
- execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
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
- execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
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
- console.log(`✅ Skill ready: ${skillName}`)
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
- const toolSkills = deck.tool?.skills || []
184
- if (!toolSkills.includes(skillName)) {
185
- if (!deck.tool) deck.tool = {}
186
- if (!deck.tool.skills) deck.tool.skills = []
187
- deck.tool.skills.push(skillName)
188
- writeFileSync(deckPath, stringifyToml(deck))
189
- console.log(`📝 Added "${skillName}" to ${deckPath}`)
190
- } else {
191
- console.log(`📝 "${skillName}" already declared in ${deckPath}`)
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 }, tool: { skills: [skillName] } }
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 "${skillName}"`)
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: 'update', description: 'Pull latest versions of declared skills from upstream' },
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
- updateDeck(deckPath, workdir)
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 { execSync } from "node:child_process";
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 WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
179
- const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
180
- const MAX_CARDS = Number(deck.deck?.max_cards || 10);
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 section of ["innate", "tool", "combo"] as const) {
195
- for (const name of (deck[section]?.skills || [])) {
196
- if (!name || typeof name !== "string") continue;
197
- const result = findSource(name, COLD_POOL, PROJECT_DIR);
198
- if (result.error) {
199
- errors.push(result.error);
200
- continue;
201
- }
202
- if (!result.path) {
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(deck.transient || {})) {
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
- execSync("tar " + tarArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" "), {
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.name.split("/")[0]));
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.name);
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.name}: ${err.message}`);
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: item.sourcePath,
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.name}`);
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: WORKING_SET,
484
- cold_pool: 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 — Update declared skills from their upstream sources
3
+ * deck-update.ts — Deprecated alias for refresh
4
4
  *
5
- * 读取 skill-deck.toml 遍历声明的 skill git 来源执行 pull。
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 { parse as parseToml } from "@iarna/toml";
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
- interface UpdateResult {
17
- name: string;
18
- path: string;
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
-