@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.
- package/README.md +30 -21
- package/package.json +1 -1
- package/src/COVERAGE-GAPS.md +117 -0
- package/src/add.test.ts +195 -0
- package/src/add.ts +71 -17
- package/src/cli.ts +65 -16
- package/src/link.test.ts +320 -0
- package/src/link.ts +55 -29
- package/src/migrate-schema.ts +58 -0
- package/src/parse-deck.test.ts +53 -0
- package/src/parse-deck.ts +78 -0
- package/src/prune.test.ts +137 -0
- package/src/prune.ts +196 -0
- package/src/refresh.test.ts +266 -0
- package/src/refresh.ts +213 -0
- package/src/remove.test.ts +145 -0
- package/src/remove.ts +91 -0
- package/src/schema.ts +10 -0
- package/src/update.ts +6 -147
- package/src/validate.test.ts +160 -0
- package/src/validate.ts +17 -22
package/src/remove.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-remove.ts — Remove a skill from the declaration layer
|
|
4
|
+
*
|
|
5
|
+
* Deletes the entry from skill-deck.toml and removes the working-set symlink.
|
|
6
|
+
* Does NOT touch the cold pool (use `deck prune` for material-layer GC).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseToml, stringify as stringifyToml } from "@iarna/toml";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { resolve, dirname, join } from "node:path";
|
|
12
|
+
import { findDeckToml, expandHome } from "./link.js";
|
|
13
|
+
import { parseDeck } from "./parse-deck.js";
|
|
14
|
+
|
|
15
|
+
export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
|
|
16
|
+
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
17
|
+
const DECK_PATH = cliDeck
|
|
18
|
+
? resolve(cliDeck)
|
|
19
|
+
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
20
|
+
|
|
21
|
+
if (!existsSync(DECK_PATH)) {
|
|
22
|
+
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
23
|
+
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
28
|
+
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
29
|
+
const deck = parseToml(deckRaw) as any;
|
|
30
|
+
|
|
31
|
+
const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
|
|
32
|
+
|
|
33
|
+
// ── 定位目标 ────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const { entries: parsedEntries } = parseDeck(deckRaw);
|
|
36
|
+
|
|
37
|
+
// Match by alias first, then by path
|
|
38
|
+
const match = parsedEntries.find(e => e.alias === target || e.path === target);
|
|
39
|
+
|
|
40
|
+
if (!match) {
|
|
41
|
+
console.error(`❌ Skill not found in deck: ${target}`);
|
|
42
|
+
const aliases = parsedEntries.map(e => e.alias);
|
|
43
|
+
if (aliases.length > 0) {
|
|
44
|
+
console.error(` Declared aliases: ${aliases.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 删 deck.toml 条目 ───────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const section = match.type;
|
|
52
|
+
const alias = match.alias;
|
|
53
|
+
|
|
54
|
+
if (deck[section]?.skills) {
|
|
55
|
+
if (Array.isArray(deck[section].skills)) {
|
|
56
|
+
// Legacy string-array format
|
|
57
|
+
deck[section].skills = deck[section].skills.filter((name: string) => {
|
|
58
|
+
const a = name.split("/").pop() || name;
|
|
59
|
+
return a !== alias;
|
|
60
|
+
});
|
|
61
|
+
if (deck[section].skills.length === 0) {
|
|
62
|
+
delete deck[section].skills;
|
|
63
|
+
}
|
|
64
|
+
} else if (typeof deck[section].skills === "object") {
|
|
65
|
+
// Dict format
|
|
66
|
+
delete deck[section].skills[alias];
|
|
67
|
+
if (Object.keys(deck[section].skills).length === 0) {
|
|
68
|
+
delete deck[section].skills;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Clean up empty section
|
|
72
|
+
if (Object.keys(deck[section] || {}).length === 0) {
|
|
73
|
+
delete deck[section];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeFileSync(DECK_PATH, stringifyToml(deck));
|
|
78
|
+
console.log(`📝 Removed "${alias}" from [${section}.skills] in ${DECK_PATH}`);
|
|
79
|
+
|
|
80
|
+
// ── 删 working set symlink ──────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const symlinkPath = join(WORKING_SET, alias);
|
|
83
|
+
if (existsSync(symlinkPath)) {
|
|
84
|
+
rmSync(symlinkPath, { recursive: true, force: true });
|
|
85
|
+
console.log(` 🗑️ Removed symlink: ${symlinkPath}`);
|
|
86
|
+
} else {
|
|
87
|
+
console.log(` ⚠️ Symlink not found: ${symlinkPath}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/skill-deck prune' to GC unreferenced repos.`);
|
|
91
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
// ── 单个已链接 Skill ────────────────────────────────────────
|
|
4
4
|
export const LinkedSkillSchema = z.object({
|
|
5
5
|
name: z.string(),
|
|
6
|
+
alias: z.string(),
|
|
6
7
|
deck_niche: z.string(),
|
|
7
8
|
type: z.enum(["innate", "tool", "combo", "transient"]),
|
|
8
9
|
source: z.string(),
|
|
@@ -49,6 +50,15 @@ export const SkillDeckLockSchema = z.object({
|
|
|
49
50
|
constraints: ConstraintReportSchema,
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
// ── Skill entry (alias-as-key dict body) ──────────────────────
|
|
54
|
+
export const SkillEntrySchema = z.object({
|
|
55
|
+
path: z.string().min(1),
|
|
56
|
+
role: z.string().optional(),
|
|
57
|
+
why_in_deck: z.string().optional(),
|
|
58
|
+
}).passthrough();
|
|
59
|
+
|
|
60
|
+
export type SkillEntry = z.infer<typeof SkillEntrySchema>;
|
|
61
|
+
|
|
52
62
|
export type LinkedSkill = z.infer<typeof LinkedSkillSchema>;
|
|
53
63
|
export type ConstraintReport = z.infer<typeof ConstraintReportSchema>;
|
|
54
64
|
export type SkillDeckLock = z.infer<typeof SkillDeckLockSchema>;
|
package/src/update.ts
CHANGED
|
@@ -1,154 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* deck-update.ts —
|
|
3
|
+
* deck-update.ts — Deprecated alias for refresh
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* 职责:让冷池跟上上游版本。
|
|
7
|
-
* 不做:下载新 skill(那是 add 的职责)、修改 deck.toml、同步 working set。
|
|
5
|
+
* Kept for backward compatibility. Will be removed in v1.0.0.
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
|
-
import {
|
|
11
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
-
import { execSync } from "node:child_process";
|
|
13
|
-
import { resolve, dirname, join, relative } from "node:path";
|
|
14
|
-
import { findDeckToml, expandHome, findSource } from "./link.js";
|
|
8
|
+
import { refreshDeck } from "./refresh.js";
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
|
|
20
|
-
message?: string;
|
|
10
|
+
export function updateDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
|
|
11
|
+
console.warn("⚠️ `deck update` is deprecated. Use `deck refresh` instead. (Removed in v1.0.0)");
|
|
12
|
+
refreshDeck(cliDeckPath, cliWorkdir, target);
|
|
21
13
|
}
|
|
22
|
-
|
|
23
|
-
function isGitRepo(dir: string): boolean {
|
|
24
|
-
return existsSync(join(dir, ".git"));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
|
|
28
|
-
try {
|
|
29
|
-
const output = execSync("git pull", {
|
|
30
|
-
cwd: dir,
|
|
31
|
-
encoding: "utf-8",
|
|
32
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
-
timeout: 30000,
|
|
34
|
-
}).trim();
|
|
35
|
-
|
|
36
|
-
if (output.includes("Already up to date") || output.includes("Already up-to-date")) {
|
|
37
|
-
return { status: "up-to-date", message: output };
|
|
38
|
-
}
|
|
39
|
-
return { status: "updated", message: output };
|
|
40
|
-
} catch (err: any) {
|
|
41
|
-
const stderr = err.stderr?.toString() || err.message || "";
|
|
42
|
-
return { status: "failed", message: stderr.trim() };
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function updateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
47
|
-
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
48
|
-
const DECK_PATH = cliDeck
|
|
49
|
-
? resolve(cliDeck)
|
|
50
|
-
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
51
|
-
|
|
52
|
-
if (!existsSync(DECK_PATH)) {
|
|
53
|
-
console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
|
|
54
|
-
console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
59
|
-
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
60
|
-
const deck = parseToml(deckRaw) as any;
|
|
61
|
-
|
|
62
|
-
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
63
|
-
|
|
64
|
-
// ── 收集声明 ────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
const declared: { name: string; type: string }[] = [];
|
|
67
|
-
|
|
68
|
-
for (const section of ["innate", "tool", "combo"] as const) {
|
|
69
|
-
for (const name of (deck[section]?.skills || [])) {
|
|
70
|
-
if (!name || typeof name !== "string") continue;
|
|
71
|
-
declared.push({ name, type: section });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (declared.length === 0) {
|
|
76
|
-
console.log("📭 No skills declared in deck. Nothing to update.");
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── 执行更新 ────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
const results: UpdateResult[] = [];
|
|
83
|
-
let updated = 0;
|
|
84
|
-
let upToDate = 0;
|
|
85
|
-
let skipped = 0;
|
|
86
|
-
let failed = 0;
|
|
87
|
-
|
|
88
|
-
for (const { name, type } of declared) {
|
|
89
|
-
const result = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
90
|
-
|
|
91
|
-
if (result.error || !result.path) {
|
|
92
|
-
results.push({ name, path: "", status: "failed", message: result.error || "Skill not found" });
|
|
93
|
-
failed++;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const path = result.path;
|
|
98
|
-
|
|
99
|
-
// localhost skills are user-managed; skip
|
|
100
|
-
const relativePath = relative(COLD_POOL, path);
|
|
101
|
-
if (relativePath.startsWith("localhost")) {
|
|
102
|
-
results.push({ name, path: relativePath, status: "skipped", message: "localhost skill — user-managed" });
|
|
103
|
-
skipped++;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (!isGitRepo(path)) {
|
|
108
|
-
results.push({ name, path: relativePath, status: "not-git", message: "Not a git repository" });
|
|
109
|
-
skipped++;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const pullResult = gitPull(path);
|
|
114
|
-
results.push({ name, path: relativePath, status: pullResult.status, message: pullResult.message });
|
|
115
|
-
|
|
116
|
-
if (pullResult.status === "updated") updated++;
|
|
117
|
-
else if (pullResult.status === "up-to-date") upToDate++;
|
|
118
|
-
else failed++;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── 报告 ────────────────────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
console.log(`\n📦 Skill Update Report — ${declared.length} skill(s) checked`);
|
|
124
|
-
console.log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`);
|
|
125
|
-
console.log();
|
|
126
|
-
|
|
127
|
-
for (const r of results) {
|
|
128
|
-
const icon =
|
|
129
|
-
r.status === "updated" ? "🔄" :
|
|
130
|
-
r.status === "up-to-date" ? "✅" :
|
|
131
|
-
r.status === "skipped" ? "⏭️" :
|
|
132
|
-
r.status === "not-git" ? "📁" :
|
|
133
|
-
"❌";
|
|
134
|
-
console.log(`${icon} ${r.name}`);
|
|
135
|
-
if (r.message && r.status !== "up-to-date") {
|
|
136
|
-
const lines = r.message.split("\n").filter(l => l.trim());
|
|
137
|
-
for (const line of lines.slice(0, 3)) {
|
|
138
|
-
console.log(` ${line.trim()}`);
|
|
139
|
-
}
|
|
140
|
-
if (lines.length > 3) {
|
|
141
|
-
console.log(` ... (${lines.length - 3} more lines)`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (updated > 0) {
|
|
147
|
-
console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync updated skills to working set.`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (failed > 0) {
|
|
151
|
-
process.exit(1);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* validate.test.ts — unit tests for validate.ts
|
|
4
|
+
*
|
|
5
|
+
* Run: bun test packages/lythoskill-deck/src/validate.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, afterEach } from 'bun:test'
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
|
|
14
|
+
let cleanup: string[] = []
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const dir of cleanup) {
|
|
18
|
+
rmSync(dir, { recursive: true, force: true })
|
|
19
|
+
}
|
|
20
|
+
cleanup = []
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function makeTmp(): string {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), 'deck-validate-'))
|
|
25
|
+
cleanup.push(dir)
|
|
26
|
+
return dir
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function placeSkill(coldPool: string, relPath: string): string {
|
|
30
|
+
const skillDir = join(coldPool, relPath)
|
|
31
|
+
mkdirSync(skillDir, { recursive: true })
|
|
32
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
|
|
33
|
+
return skillDir
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runValidate(deckPath: string, workdir: string) {
|
|
37
|
+
return spawnSync('bun', [join(import.meta.dir, 'cli.ts'), 'validate', '--deck', deckPath, '--workdir', workdir], {
|
|
38
|
+
cwd: workdir,
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('validateDeck', () => {
|
|
44
|
+
it('C1: valid deck passes validation', () => {
|
|
45
|
+
const projectDir = makeTmp()
|
|
46
|
+
const coldPoolRel = 'cold-pool'
|
|
47
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
48
|
+
|
|
49
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill')
|
|
50
|
+
|
|
51
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
|
|
52
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
53
|
+
writeFileSync(deckPath, deckContent)
|
|
54
|
+
|
|
55
|
+
const result = runValidate(deckPath, projectDir)
|
|
56
|
+
|
|
57
|
+
expect(result.status).toBe(0)
|
|
58
|
+
expect(result.stdout).toContain('Validation passed')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('C2: missing [deck] section errors', () => {
|
|
62
|
+
const projectDir = makeTmp()
|
|
63
|
+
const deckContent = `[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
|
|
64
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
65
|
+
writeFileSync(deckPath, deckContent)
|
|
66
|
+
|
|
67
|
+
const result = runValidate(deckPath, projectDir)
|
|
68
|
+
|
|
69
|
+
expect(result.status).toBe(1)
|
|
70
|
+
expect(result.stderr).toContain('[deck] section is required')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('C3: invalid max_cards errors', () => {
|
|
74
|
+
const projectDir = makeTmp()
|
|
75
|
+
const coldPoolRel = 'cold-pool'
|
|
76
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
77
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill')
|
|
78
|
+
|
|
79
|
+
const deckContent = `[deck]\nmax_cards = -1\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
|
|
80
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
81
|
+
writeFileSync(deckPath, deckContent)
|
|
82
|
+
|
|
83
|
+
const result = runValidate(deckPath, projectDir)
|
|
84
|
+
|
|
85
|
+
expect(result.status).toBe(1)
|
|
86
|
+
expect(result.stderr).toContain('deck.max_cards must be a positive integer')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('C4: skill not found in cold pool errors', () => {
|
|
90
|
+
const projectDir = makeTmp()
|
|
91
|
+
const coldPoolRel = 'cold-pool'
|
|
92
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
93
|
+
mkdirSync(coldPool, { recursive: true })
|
|
94
|
+
// do NOT place the skill
|
|
95
|
+
|
|
96
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo/nonexistent"\n`
|
|
97
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
98
|
+
writeFileSync(deckPath, deckContent)
|
|
99
|
+
|
|
100
|
+
const result = runValidate(deckPath, projectDir)
|
|
101
|
+
|
|
102
|
+
expect(result.status).toBe(1)
|
|
103
|
+
expect(result.stderr).toContain('Skill not found')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('C5: budget exceeded errors', () => {
|
|
107
|
+
const projectDir = makeTmp()
|
|
108
|
+
const coldPoolRel = 'cold-pool'
|
|
109
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
110
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill-a')
|
|
111
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill-b')
|
|
112
|
+
|
|
113
|
+
const deckContent = `[deck]\nmax_cards = 1\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n\n[tool.skills.skill-b]\npath = "github.com/owner/repo/skill-b"\n`
|
|
114
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
115
|
+
writeFileSync(deckPath, deckContent)
|
|
116
|
+
|
|
117
|
+
const result = runValidate(deckPath, projectDir)
|
|
118
|
+
|
|
119
|
+
expect(result.status).toBe(1)
|
|
120
|
+
expect(result.stderr).toContain('Budget exceeded')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('C6: toml parse error exits', () => {
|
|
124
|
+
const projectDir = makeTmp()
|
|
125
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
126
|
+
writeFileSync(deckPath, '[invalid toml\n')
|
|
127
|
+
|
|
128
|
+
const result = runValidate(deckPath, projectDir)
|
|
129
|
+
|
|
130
|
+
expect(result.status).toBe(1)
|
|
131
|
+
expect(result.stderr).toContain('TOML parse error')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('C7: deprecated string-array format warns', () => {
|
|
135
|
+
const projectDir = makeTmp()
|
|
136
|
+
const coldPoolRel = 'cold-pool'
|
|
137
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
138
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill')
|
|
139
|
+
|
|
140
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner/repo/skill"]\n`
|
|
141
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
142
|
+
writeFileSync(deckPath, deckContent)
|
|
143
|
+
|
|
144
|
+
const result = runValidate(deckPath, projectDir)
|
|
145
|
+
|
|
146
|
+
expect(result.status).toBe(0)
|
|
147
|
+
expect(result.stderr).toContain('deprecated')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('C8: invalid transient expires errors', () => {
|
|
151
|
+
const projectDir = makeTmp()
|
|
152
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
153
|
+
writeFileSync(deckPath, `[deck]\nmax_cards = 10\n\n[transient.foo]\npath = "./nonexistent"\nexpires = "not-a-date"\n`)
|
|
154
|
+
|
|
155
|
+
const result = runValidate(deckPath, projectDir)
|
|
156
|
+
|
|
157
|
+
expect(result.status).toBe(1)
|
|
158
|
+
expect(result.stderr).toContain('invalid expires')
|
|
159
|
+
})
|
|
160
|
+
})
|
package/src/validate.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { parse as parseToml } from "@iarna/toml";
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { findDeckToml, expandHome, findSource } from "./link.js";
|
|
13
|
+
import { parseDeck } from "./parse-deck.js";
|
|
13
14
|
|
|
14
15
|
export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
15
16
|
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
|
|
@@ -52,33 +53,27 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
|
52
53
|
|
|
53
54
|
// ── Validate skill declarations ────────────────────────────
|
|
54
55
|
|
|
56
|
+
const { entries: parsedEntries, deprecated: isDeprecated, errors: parseErrors } = parseDeck(deckRaw);
|
|
57
|
+
if (isDeprecated) {
|
|
58
|
+
warnings.push("string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
|
|
59
|
+
}
|
|
60
|
+
errors.push(...parseErrors);
|
|
61
|
+
|
|
55
62
|
const declaredNames = new Set<string>();
|
|
56
63
|
let declaredCount = 0;
|
|
57
64
|
|
|
58
|
-
for (const
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
errors.push(`[${section}].skills must be an array`);
|
|
63
|
-
continue;
|
|
65
|
+
for (const entry of parsedEntries) {
|
|
66
|
+
declaredCount++;
|
|
67
|
+
if (declaredNames.has(entry.path)) {
|
|
68
|
+
warnings.push(`Skill "${entry.path}" is declared in multiple sections`);
|
|
64
69
|
}
|
|
65
|
-
|
|
66
|
-
if (!name || typeof name !== "string") {
|
|
67
|
-
errors.push(`[${section}] contains invalid skill name`);
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
declaredCount++;
|
|
71
|
-
if (declaredNames.has(name)) {
|
|
72
|
-
warnings.push(`Skill "${name}" is declared in multiple sections`);
|
|
73
|
-
}
|
|
74
|
-
declaredNames.add(name);
|
|
70
|
+
declaredNames.add(entry.path);
|
|
75
71
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
72
|
+
const result = findSource(entry.path, COLD_POOL, PROJECT_DIR);
|
|
73
|
+
if (result.error) {
|
|
74
|
+
errors.push(result.error);
|
|
75
|
+
} else if (!result.path) {
|
|
76
|
+
errors.push(`Skill not found: ${entry.path} (${entry.type})`);
|
|
82
77
|
}
|
|
83
78
|
}
|
|
84
79
|
|