@lythos/skill-deck 0.7.2 → 0.9.1

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.
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * parse-deck.test.ts — unit tests for parse-deck.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/parse-deck.test.ts
6
+ */
7
+
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { parseDeck } from './parse-deck.ts'
10
+
11
+ describe('parseDeck', () => {
12
+ it('parses dict format entries', () => {
13
+ const raw = `[tool.skills.foo]\npath = "github.com/owner/repo"\n`
14
+ const result = parseDeck(raw)
15
+ expect(result.entries).toHaveLength(1)
16
+ expect(result.entries[0].alias).toBe('foo')
17
+ expect(result.entries[0].path).toBe('github.com/owner/repo')
18
+ expect(result.entries[0].type).toBe('tool')
19
+ expect(result.deprecated).toBe(false)
20
+ expect(result.errors).toHaveLength(0)
21
+ })
22
+
23
+ it('errors when dict entry lacks path', () => {
24
+ const raw = `[tool.skills.foo]\nname = "foo"\n`
25
+ const result = parseDeck(raw)
26
+ expect(result.errors.length).toBeGreaterThan(0)
27
+ expect(result.errors[0]).toContain('Missing path')
28
+ })
29
+
30
+ it('errors when dict entry has invalid schema', () => {
31
+ const raw = `[tool.skills.foo]\npath = ""\n`
32
+ const result = parseDeck(raw)
33
+ expect(result.errors.length).toBeGreaterThan(0)
34
+ })
35
+
36
+ it('parses legacy string-array format and marks deprecated', () => {
37
+ const raw = `[tool]\nskills = ["github.com/owner/repo"]\n`
38
+ const result = parseDeck(raw)
39
+ expect(result.deprecated).toBe(true)
40
+ expect(result.entries).toHaveLength(1)
41
+ expect(result.entries[0].alias).toBe('repo')
42
+ expect(result.entries[0].path).toBe('github.com/owner/repo')
43
+ expect(result.entries[0].type).toBe('tool')
44
+ })
45
+
46
+ it('returns empty for deck with no skill sections', () => {
47
+ const raw = `[deck]\nmax_cards = 10\n`
48
+ const result = parseDeck(raw)
49
+ expect(result.entries).toHaveLength(0)
50
+ expect(result.deprecated).toBe(false)
51
+ expect(result.errors).toHaveLength(0)
52
+ })
53
+ })
@@ -0,0 +1,78 @@
1
+ import { parse as parseToml } from "@iarna/toml";
2
+ import { SkillEntrySchema } from "./schema.js";
3
+
4
+ export type SkillType = "innate" | "tool" | "combo";
5
+
6
+ export interface ParsedSkillEntry {
7
+ alias: string; // working-set flat symlink name = role identity
8
+ path: string; // FQ locator or local path
9
+ type: SkillType;
10
+ role?: string;
11
+ why_in_deck?: string;
12
+ [key: string]: unknown; // forward-compat: unknown fields pass through
13
+ }
14
+
15
+ export interface ParsedDeck {
16
+ entries: ParsedSkillEntry[];
17
+ deprecated: boolean; // true if any section used legacy string-array
18
+ errors: string[];
19
+ }
20
+
21
+ export function parseDeck(raw: string): ParsedDeck {
22
+ const parsed = parseToml(raw) as any;
23
+ const entries: ParsedSkillEntry[] = [];
24
+ const errors: string[] = [];
25
+ let deprecated = false;
26
+
27
+ for (const section of ["innate", "tool", "combo"] as const) {
28
+ const sectionData = parsed[section];
29
+ if (!sectionData) continue;
30
+
31
+ // ── New format: [<type>.skills.<alias>] with path in body ──
32
+ if (
33
+ sectionData.skills &&
34
+ typeof sectionData.skills === "object" &&
35
+ !Array.isArray(sectionData.skills)
36
+ ) {
37
+ for (const [alias, entry] of Object.entries(sectionData.skills)) {
38
+ const e = entry as Record<string, unknown>;
39
+ if (!e?.path || typeof e.path !== "string") {
40
+ errors.push(
41
+ `Missing path for skill "${alias}" in [${section}.skills.${alias}]`
42
+ );
43
+ continue;
44
+ }
45
+ const parsedEntry = SkillEntrySchema.safeParse(e);
46
+ if (!parsedEntry.success) {
47
+ errors.push(
48
+ `Invalid entry "${alias}" in [${section}.skills.${alias}]: ${parsedEntry.error.message}`
49
+ );
50
+ continue;
51
+ }
52
+ entries.push({
53
+ alias,
54
+ path: e.path as string,
55
+ type: section,
56
+ ...parsedEntry.data,
57
+ });
58
+ }
59
+ continue;
60
+ }
61
+
62
+ // ── Legacy format: [<type>] with skills = ["...", ...] ──
63
+ const skillsArray = sectionData?.skills;
64
+ if (Array.isArray(skillsArray)) {
65
+ deprecated = true;
66
+ for (const name of skillsArray) {
67
+ if (!name || typeof name !== "string") continue;
68
+ entries.push({
69
+ alias: name.split("/").pop() || name,
70
+ path: name,
71
+ type: section,
72
+ });
73
+ }
74
+ }
75
+ }
76
+
77
+ return { entries, deprecated, errors };
78
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * prune.test.ts — unit tests for prune.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/prune.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach, spyOn } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+
13
+ let cleanup: string[] = []
14
+
15
+ afterEach(() => {
16
+ for (const dir of cleanup) {
17
+ rmSync(dir, { recursive: true, force: true })
18
+ }
19
+ cleanup = []
20
+ })
21
+
22
+ function makeTmp(): string {
23
+ const dir = mkdtempSync(join(tmpdir(), 'deck-prune-'))
24
+ cleanup.push(dir)
25
+ return dir
26
+ }
27
+
28
+ function placeRepo(coldPool: string, host: string, owner: string, repo: string): string {
29
+ const repoDir = join(coldPool, host, owner, repo)
30
+ mkdirSync(repoDir, { recursive: true })
31
+ return repoDir
32
+ }
33
+
34
+ function placeSkillInRepo(repoDir: string, skillName: string): string {
35
+ const skillDir = join(repoDir, 'skills', skillName)
36
+ mkdirSync(skillDir, { recursive: true })
37
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
38
+ return skillDir
39
+ }
40
+
41
+ describe('pruneDeck', () => {
42
+ it('C15: prune with unreferenced repos deletes them when --yes is set', async () => {
43
+ const projectDir = makeTmp()
44
+ const coldPoolRel = 'cold-pool'
45
+ const coldPool = join(projectDir, coldPoolRel)
46
+
47
+ const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
48
+ placeSkillInRepo(repoA, 'skill-a')
49
+
50
+ const repoB = placeRepo(coldPool, 'github.com', 'owner', 'repo-b')
51
+ placeSkillInRepo(repoB, 'skill-b')
52
+
53
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skill-a"\n`
54
+ const deckPath = join(projectDir, 'skill-deck.toml')
55
+ writeFileSync(deckPath, deckContent)
56
+
57
+ const { pruneDeck } = await import('./prune.ts')
58
+ await pruneDeck(deckPath, projectDir, true)
59
+
60
+ expect(existsSync(repoA)).toBe(true)
61
+ expect(existsSync(join(repoA, 'skills', 'skill-a', 'SKILL.md'))).toBe(true)
62
+
63
+ expect(existsSync(repoB)).toBe(false)
64
+ })
65
+
66
+ it('C16: prune with all referenced repos is a no-op', async () => {
67
+ const projectDir = makeTmp()
68
+ const coldPoolRel = 'cold-pool'
69
+ const coldPool = join(projectDir, coldPoolRel)
70
+
71
+ const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
72
+ placeSkillInRepo(repoA, 'skill-a')
73
+
74
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skill-a"\n`
75
+ const deckPath = join(projectDir, 'skill-deck.toml')
76
+ writeFileSync(deckPath, deckContent)
77
+
78
+ const logs: string[] = []
79
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
80
+ logs.push(String(msg))
81
+ })
82
+
83
+ const originalExit = process.exit
84
+ let exitCode: number | undefined
85
+ process.exit = ((code?: number) => {
86
+ exitCode = code ?? 0
87
+ throw new Error(`EXIT:${code}`)
88
+ }) as typeof process.exit
89
+
90
+ try {
91
+ const { pruneDeck } = await import('./prune.ts')
92
+ await pruneDeck(deckPath, projectDir, true)
93
+ expect(false).toBe(true)
94
+ } catch (err: any) {
95
+ expect(exitCode).toBe(0)
96
+ expect(logs.some(l => l.includes('Nothing to prune'))).toBe(true)
97
+ } finally {
98
+ process.exit = originalExit
99
+ logSpy.mockRestore()
100
+ }
101
+ })
102
+
103
+ it('C17: prune with empty cold pool reports nothing to prune', async () => {
104
+ const projectDir = makeTmp()
105
+ const coldPoolRel = 'cold-pool'
106
+ const coldPool = join(projectDir, coldPoolRel)
107
+ mkdirSync(coldPool, { recursive: true })
108
+
109
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n`
110
+ const deckPath = join(projectDir, 'skill-deck.toml')
111
+ writeFileSync(deckPath, deckContent)
112
+
113
+ const logs: string[] = []
114
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
115
+ logs.push(String(msg))
116
+ })
117
+
118
+ const originalExit = process.exit
119
+ let exitCode: number | undefined
120
+ process.exit = ((code?: number) => {
121
+ exitCode = code ?? 0
122
+ throw new Error(`EXIT:${code}`)
123
+ }) as typeof process.exit
124
+
125
+ try {
126
+ const { pruneDeck } = await import('./prune.ts')
127
+ await pruneDeck(deckPath, projectDir, true)
128
+ expect(false).toBe(true)
129
+ } catch (err: any) {
130
+ expect(exitCode).toBe(0)
131
+ expect(logs.some(l => l.includes('empty'))).toBe(true)
132
+ } finally {
133
+ process.exit = originalExit
134
+ logSpy.mockRestore()
135
+ }
136
+ })
137
+ })
package/src/prune.ts ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-prune.ts — Cold pool garbage collection
4
+ *
5
+ * Scans the cold pool for repositories no longer referenced by any
6
+ * skill-deck.toml declaration and offers to delete them.
7
+ * Does NOT modify deck.toml or the working set.
8
+ */
9
+
10
+ import { parse as parseToml } from "@iarna/toml";
11
+ import { existsSync, readFileSync, readdirSync, statSync, rmSync } from "node:fs";
12
+ import { resolve, dirname, join, relative } from "node:path";
13
+ import { createInterface } from "node:readline";
14
+ import { findDeckToml, expandHome, findSource } from "./link.js";
15
+ import { parseDeck } from "./parse-deck.js";
16
+
17
+ interface PruneCandidate {
18
+ repoPath: string;
19
+ repoRel: string;
20
+ size: number;
21
+ }
22
+
23
+ function formatSize(bytes: number): string {
24
+ if (bytes < 1024) return `${bytes}B`;
25
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
26
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
27
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
28
+ }
29
+
30
+ function calculateDirSize(dir: string): number {
31
+ let total = 0;
32
+ try {
33
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
34
+ const p = join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ total += calculateDirSize(p);
37
+ } else if (entry.isFile()) {
38
+ total += statSync(p).size;
39
+ }
40
+ }
41
+ } catch {}
42
+ return total;
43
+ }
44
+
45
+ function scanColdPoolRepos(coldPool: string): string[] {
46
+ const repos: string[] = [];
47
+ try {
48
+ for (const host of readdirSync(coldPool, { withFileTypes: true })) {
49
+ if (!host.isDirectory() || host.name.startsWith(".")) continue;
50
+ const hostPath = join(coldPool, host.name);
51
+ for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
52
+ if (!owner.isDirectory() || owner.name.startsWith(".")) continue;
53
+ const ownerPath = join(hostPath, owner.name);
54
+ for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
55
+ if (!repo.isDirectory() || repo.name.startsWith(".")) continue;
56
+ repos.push(join(ownerPath, repo.name));
57
+ }
58
+ }
59
+ }
60
+ } catch {}
61
+ return repos;
62
+ }
63
+
64
+ function isRepoReferenced(repoPath: string, declaredPaths: string[], coldPool: string, projectDir: string): boolean {
65
+ for (const path of declaredPaths) {
66
+ const result = findSource(path, coldPool, projectDir);
67
+ if (result.path) {
68
+ // Check if the resolved skill path is inside this repo
69
+ const rel = relative(repoPath, result.path);
70
+ if (!rel.startsWith("..") && rel !== "") {
71
+ return true;
72
+ }
73
+ if (result.path === repoPath) {
74
+ return true;
75
+ }
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+
81
+ async function confirm(message: string): Promise<boolean> {
82
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
83
+ return new Promise((resolve) => {
84
+ rl.question(`${message} (y/N) `, (answer) => {
85
+ rl.close();
86
+ resolve(answer.trim().toLowerCase() === "y");
87
+ });
88
+ });
89
+ }
90
+
91
+ export async function pruneDeck(cliDeckPath?: string, cliWorkdir?: string, yes?: boolean): Promise<void> {
92
+ const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
93
+ const DECK_PATH = cliDeck
94
+ ? resolve(cliDeck)
95
+ : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
96
+
97
+ if (!existsSync(DECK_PATH)) {
98
+ console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
99
+ console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
100
+ process.exit(1);
101
+ }
102
+
103
+ const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
104
+ const deckRaw = readFileSync(DECK_PATH, "utf-8");
105
+ const deck = parseToml(deckRaw) as any;
106
+
107
+ const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
108
+
109
+ // ── 收集声明 ────────────────────────────────────────────────
110
+
111
+ const { entries: parsedEntries } = parseDeck(deckRaw);
112
+ const declaredPaths = parsedEntries.map(e => e.path);
113
+
114
+ // Legacy string-array fallback
115
+ for (const section of ["innate", "tool", "combo"] as const) {
116
+ const skills = deck[section]?.skills;
117
+ if (Array.isArray(skills)) {
118
+ for (const name of skills) {
119
+ if (name && typeof name === "string" && !declaredPaths.includes(name)) {
120
+ declaredPaths.push(name);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // ── 扫描 cold pool ──────────────────────────────────────────
127
+
128
+ if (!existsSync(COLD_POOL)) {
129
+ console.log("📭 Cold pool does not exist. Nothing to prune.");
130
+ process.exit(0);
131
+ }
132
+
133
+ const allRepos = scanColdPoolRepos(COLD_POOL);
134
+ if (allRepos.length === 0) {
135
+ console.log("📭 Cold pool is empty. Nothing to prune.");
136
+ process.exit(0);
137
+ }
138
+
139
+ // ── 求差集 ──────────────────────────────────────────────────
140
+
141
+ const candidates: PruneCandidate[] = [];
142
+ for (const repoPath of allRepos) {
143
+ if (isRepoReferenced(repoPath, declaredPaths, COLD_POOL, PROJECT_DIR)) continue;
144
+ const size = calculateDirSize(repoPath);
145
+ candidates.push({ repoPath, repoRel: relative(COLD_POOL, repoPath), size });
146
+ }
147
+
148
+ if (candidates.length === 0) {
149
+ console.log("✅ All cold pool repositories are referenced. Nothing to prune.");
150
+ process.exit(0);
151
+ }
152
+
153
+ // ── 报告 ────────────────────────────────────────────────────
154
+
155
+ const totalSize = candidates.reduce((sum, c) => sum + c.size, 0);
156
+ console.log(`\n🧹 Prune candidates — ${candidates.length} repo(s), ${formatSize(totalSize)} total:\n`);
157
+ for (const c of candidates) {
158
+ console.log(` ${c.repoRel} (${formatSize(c.size)})`);
159
+ }
160
+
161
+ // ── 确认 ────────────────────────────────────────────────────
162
+
163
+ let shouldDelete = false;
164
+ if (yes) {
165
+ shouldDelete = true;
166
+ console.log("\n⚠️ --yes flag set: deleting without confirmation.");
167
+ } else {
168
+ shouldDelete = await confirm(`\nDelete ${candidates.length} unreferenced repo(s)?`);
169
+ }
170
+
171
+ if (!shouldDelete) {
172
+ console.log("❎ Prune cancelled.");
173
+ process.exit(0);
174
+ }
175
+
176
+ // ── 执行删除 ────────────────────────────────────────────────
177
+
178
+ let deleted = 0;
179
+ let failed = 0;
180
+ for (const c of candidates) {
181
+ try {
182
+ rmSync(c.repoPath, { recursive: true, force: true });
183
+ console.log(` 🗑️ Deleted: ${c.repoRel}`);
184
+ deleted++;
185
+ } catch (err: any) {
186
+ console.error(` ❌ Failed to delete ${c.repoRel}: ${err.message}`);
187
+ failed++;
188
+ }
189
+ }
190
+
191
+ console.log(`\n📦 Prune complete: ${deleted} deleted, ${failed} failed`);
192
+
193
+ if (failed > 0) {
194
+ process.exit(1);
195
+ }
196
+ }