@lythos/skill-deck 0.9.2 → 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 +1 -1
- package/src/prune-plan.test.ts +103 -0
- package/src/prune-plan.ts +188 -0
- package/src/prune.ts +40 -89
- package/src/refresh-plan.test.ts +118 -0
- package/src/refresh-plan.ts +212 -0
- package/src/refresh.ts +47 -160
package/package.json
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
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
|
|
15
|
-
import {
|
|
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
|
|
107
|
-
const
|
|
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
|
-
|
|
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(
|
|
143
|
-
console.
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
187
|
-
process.exit(0)
|
|
149
|
+
console.log('❎ Prune cancelled.')
|
|
150
|
+
process.exit(0)
|
|
188
151
|
}
|
|
189
152
|
|
|
190
|
-
// ──
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
14
|
-
import { findDeckToml,
|
|
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
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
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(
|
|
73
|
+
console.warn('⚠️ Deprecation: string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.')
|
|
102
74
|
}
|
|
103
75
|
|
|
104
|
-
|
|
105
|
-
|
|
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 (
|
|
109
|
-
console.
|
|
110
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
}
|