@lythos/skill-deck 0.9.2 → 0.9.13

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