@lythos/skill-deck 0.9.1 → 0.9.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/link.ts CHANGED
@@ -79,6 +79,15 @@ export function findSource(name: string, coldPool: string, projectDir: string):
79
79
  if (existsSync(join(directPath, "SKILL.md"))) return { path: directPath };
80
80
  }
81
81
 
82
+ // 0.5 localhost skills: localhost/skill → cold_pool/skill
83
+ if (name.startsWith('localhost/')) {
84
+ const skill = name.slice('localhost/'.length);
85
+ if (skill) {
86
+ const localPath = join(coldPool, skill);
87
+ if (existsSync(join(localPath, "SKILL.md"))) return { path: localPath };
88
+ }
89
+ }
90
+
82
91
  // 1. 直接路径
83
92
  const direct = resolve(coldPool, name);
84
93
  if (existsSync(join(direct, "SKILL.md"))) return { path: direct };
@@ -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;
@@ -48,9 +47,23 @@ function scanColdPoolRepos(coldPool: string): string[] {
48
47
  for (const host of readdirSync(coldPool, { withFileTypes: true })) {
49
48
  if (!host.isDirectory() || host.name.startsWith(".")) continue;
50
49
  const hostPath = join(coldPool, host.name);
50
+
51
+ // Flat skill: cold-pool/skill-name/ (localhost style)
52
+ if (existsSync(join(hostPath, "SKILL.md"))) {
53
+ repos.push(hostPath);
54
+ continue;
55
+ }
56
+
51
57
  for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
52
58
  if (!owner.isDirectory() || owner.name.startsWith(".")) continue;
53
59
  const ownerPath = join(hostPath, owner.name);
60
+
61
+ // Standalone repo: cold-pool/host.tld/owner/repo/
62
+ if (existsSync(join(ownerPath, "SKILL.md"))) {
63
+ repos.push(ownerPath);
64
+ continue;
65
+ }
66
+
54
67
  for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
55
68
  if (!repo.isDirectory() || repo.name.startsWith(".")) continue;
56
69
  repos.push(join(ownerPath, repo.name));
@@ -89,108 +102,60 @@ async function confirm(message: string): Promise<boolean> {
89
102
  }
90
103
 
91
104
  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);
105
+ const deckPath = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
106
+ const workdir = cliWorkdir
113
107
 
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
- }
108
+ const DECK_PATH = deckPath ? resolve(deckPath) : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
125
109
 
126
- // ── 扫描 cold pool ──────────────────────────────────────────
127
-
128
- if (!existsSync(COLD_POOL)) {
129
- console.log("📭 Cold pool does not exist. Nothing to prune.");
130
- 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)
131
114
  }
132
115
 
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
- }
116
+ const deckRaw = readFileSync(DECK_PATH, 'utf-8')
138
117
 
139
- // ── 求差集 ──────────────────────────────────────────────────
118
+ // ── Plan: pure unreferenced detection ──────────────────────────────
119
+ const plan = buildPrunePlan(deckRaw, {
120
+ deckPath: DECK_PATH,
121
+ workdir: workdir ? resolve(workdir) : undefined,
122
+ })
140
123
 
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 });
124
+ if (!existsSync(plan.coldPool)) {
125
+ console.log('📭 Cold pool does not exist. Nothing to prune.')
126
+ process.exit(0)
146
127
  }
147
128
 
148
- if (candidates.length === 0) {
149
- console.log("✅ All cold pool repositories are referenced. Nothing to prune.");
150
- 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)
151
132
  }
152
133
 
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)})`);
134
+ if (plan.candidates.length === 0) {
135
+ console.log('✅ All cold pool repositories are referenced. Nothing to prune.')
136
+ process.exit(0)
159
137
  }
160
138
 
161
- // ── 确认 ────────────────────────────────────────────────────
162
-
163
- let shouldDelete = false;
139
+ // ── Confirm ──────────────────────────────────────────────────────
140
+ let shouldDelete = false
164
141
  if (yes) {
165
- shouldDelete = true;
166
- console.log("\n⚠️ --yes flag set: deleting without confirmation.");
142
+ shouldDelete = true
143
+ console.log('\n⚠️ --yes flag set: deleting without confirmation.')
167
144
  } else {
168
- shouldDelete = await confirm(`\nDelete ${candidates.length} unreferenced repo(s)?`);
145
+ shouldDelete = await confirm(`\nDelete ${plan.candidates.length} unreferenced repo(s)?`)
169
146
  }
170
147
 
171
148
  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
- }
149
+ console.log('❎ Prune cancelled.')
150
+ process.exit(0)
189
151
  }
190
152
 
191
- console.log(`\n📦 Prune complete: ${deleted} deleted, ${failed} failed`);
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
+ })
192
159
 
193
- if (failed > 0) {
194
- process.exit(1);
195
- }
160
+ if (results.some(r => !r.deleted)) process.exit(1)
196
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
  }