@lythos/skill-deck 0.1.8 → 0.3.0
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/cli.ts +23 -1
- package/src/help.ts +68 -0
- package/src/link.ts +16 -45
- package/src/schema.ts +0 -25
- package/src/validate.ts +130 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { linkDeck } from './link.js'
|
|
3
|
+
import { validateDeck } from './validate.js'
|
|
4
|
+
import { formatHelp } from './help.js'
|
|
5
|
+
|
|
6
|
+
const HELP_CONFIG = {
|
|
7
|
+
binName: 'lythoskill-deck',
|
|
8
|
+
description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
|
|
9
|
+
commands: [
|
|
10
|
+
{ name: 'link', description: 'Sync working set with skill-deck.toml' },
|
|
11
|
+
{ name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
|
|
12
|
+
],
|
|
13
|
+
options: [
|
|
14
|
+
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path' },
|
|
15
|
+
{ flag: '--workdir <dir>', description: 'Specify working directory' },
|
|
16
|
+
],
|
|
17
|
+
}
|
|
3
18
|
|
|
4
19
|
const command = process.argv[2]
|
|
5
20
|
const deckFlagIdx = process.argv.indexOf('--deck')
|
|
@@ -8,10 +23,17 @@ const deckPath = deckFlagIdx >= 0 ? process.argv[deckFlagIdx + 1] : undefined
|
|
|
8
23
|
const workdir = workdirFlagIdx >= 0 ? process.argv[workdirFlagIdx + 1] : undefined
|
|
9
24
|
|
|
10
25
|
switch (command) {
|
|
26
|
+
case '--help':
|
|
27
|
+
case '-h':
|
|
28
|
+
console.log(formatHelp(HELP_CONFIG))
|
|
29
|
+
process.exit(0)
|
|
11
30
|
case 'link':
|
|
12
31
|
linkDeck(deckPath, workdir)
|
|
13
32
|
break
|
|
33
|
+
case 'validate':
|
|
34
|
+
validateDeck(deckPath, workdir)
|
|
35
|
+
break
|
|
14
36
|
default:
|
|
15
|
-
console.error(
|
|
37
|
+
console.error(formatHelp(HELP_CONFIG))
|
|
16
38
|
process.exit(1)
|
|
17
39
|
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* help.ts — Declarative help text formatter
|
|
4
|
+
*
|
|
5
|
+
* Commands and options are defined as data (HelpConfig).
|
|
6
|
+
* formatHelp() produces aligned usage output.
|
|
7
|
+
* This is the SSOT: build pipeline captures `bun cli.ts --help`
|
|
8
|
+
* and writes it to skill/references/COMMANDS.md.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CommandDef {
|
|
12
|
+
name: string
|
|
13
|
+
description: string
|
|
14
|
+
args?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OptionDef {
|
|
18
|
+
flag: string
|
|
19
|
+
description: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HelpConfig {
|
|
23
|
+
binName: string
|
|
24
|
+
description?: string
|
|
25
|
+
commands: CommandDef[]
|
|
26
|
+
options?: OptionDef[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatHelp(config: HelpConfig): string {
|
|
30
|
+
const lines: string[] = []
|
|
31
|
+
|
|
32
|
+
if (config.description) {
|
|
33
|
+
lines.push(`${config.binName} -- ${config.description}`)
|
|
34
|
+
lines.push('')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Usage line
|
|
38
|
+
const usages = config.commands.map(
|
|
39
|
+
(c) => `${config.binName} ${c.name}${c.args ? ' ' + c.args : ''}`
|
|
40
|
+
)
|
|
41
|
+
lines.push(`Usage: ${usages.join(' | ')}`)
|
|
42
|
+
lines.push('')
|
|
43
|
+
|
|
44
|
+
// Commands
|
|
45
|
+
lines.push('Commands:')
|
|
46
|
+
const cmdWidth = Math.max(
|
|
47
|
+
...config.commands.map((c) => c.name.length + (c.args ? c.args.length + 1 : 0))
|
|
48
|
+
)
|
|
49
|
+
for (const cmd of config.commands) {
|
|
50
|
+
const left = ` ${cmd.name}${cmd.args ? ' ' + cmd.args : ''}`
|
|
51
|
+
const pad = ' '.repeat(Math.max(cmdWidth - left.length + 4, 2))
|
|
52
|
+
lines.push(`${left}${pad}${cmd.description}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Options
|
|
56
|
+
if (config.options && config.options.length > 0) {
|
|
57
|
+
lines.push('')
|
|
58
|
+
lines.push('Options:')
|
|
59
|
+
const optWidth = Math.max(...config.options.map((o) => o.flag.length))
|
|
60
|
+
for (const opt of config.options) {
|
|
61
|
+
const left = ` ${opt.flag}`
|
|
62
|
+
const pad = ' '.repeat(Math.max(optWidth - left.length + 4, 2))
|
|
63
|
+
lines.push(`${left}${pad}${opt.description}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return lines.join('\n')
|
|
68
|
+
}
|
package/src/link.ts
CHANGED
|
@@ -17,19 +17,18 @@ import { resolve, dirname, join } from "path";
|
|
|
17
17
|
import { homedir } from "os";
|
|
18
18
|
import {
|
|
19
19
|
SkillDeckLockSchema,
|
|
20
|
-
SkillDeckTomlSchema,
|
|
21
20
|
type SkillDeckLock, type LinkedSkill, type ConstraintReport,
|
|
22
21
|
} from "./schema.js";
|
|
23
22
|
|
|
24
23
|
// ── 路径工具 ────────────────────────────────────────────────
|
|
25
24
|
|
|
26
|
-
function findDeckToml(from: string): string | null {
|
|
25
|
+
export function findDeckToml(from: string): string | null {
|
|
27
26
|
const p = join(from, "skill-deck.toml");
|
|
28
27
|
if (existsSync(p)) return p;
|
|
29
28
|
return null;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
function expandHome(p: string, base: string): string {
|
|
31
|
+
export function expandHome(p: string, base: string): string {
|
|
33
32
|
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
34
33
|
return resolve(base, p);
|
|
35
34
|
}
|
|
@@ -83,7 +82,7 @@ function extractArrayField(fm: string, field: string): string[] {
|
|
|
83
82
|
|
|
84
83
|
// ── 冷池查找 ────────────────────────────────────────────────
|
|
85
84
|
|
|
86
|
-
function findSource(name: string, coldPool: string, projectDir: string): string | null {
|
|
85
|
+
export function findSource(name: string, coldPool: string, projectDir: string): string | null {
|
|
87
86
|
// 0. Fully-qualified path: host.tld/owner/repo/skill
|
|
88
87
|
// → cold_pool/host.tld/owner/repo/skills/skill
|
|
89
88
|
// Also handles host.tld/owner/repo (standalone skill without skills/ subdir)
|
|
@@ -161,44 +160,16 @@ if (!existsSync(DECK_PATH)) {
|
|
|
161
160
|
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
162
161
|
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
163
162
|
const deckHash = hashContent(deckRaw);
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
// 预处理:TOML 解析器可能在空表上生成 Symbol 键,先清理
|
|
167
|
-
function stripSymbols(obj: any): any {
|
|
168
|
-
if (obj == null) return obj;
|
|
169
|
-
if (Array.isArray(obj)) return obj.map(stripSymbols);
|
|
170
|
-
if (typeof obj === 'object') {
|
|
171
|
-
const clean: Record<string, any> = {};
|
|
172
|
-
for (const key of Object.keys(obj)) {
|
|
173
|
-
clean[key] = stripSymbols(obj[key]);
|
|
174
|
-
}
|
|
175
|
-
return clean;
|
|
176
|
-
}
|
|
177
|
-
return obj;
|
|
178
|
-
}
|
|
179
|
-
const cleanToml = stripSymbols(parsedToml);
|
|
180
|
-
|
|
181
|
-
// Schema 校验:防止模板与运行时解析漂移
|
|
182
|
-
const schemaResult = SkillDeckTomlSchema.safeParse(cleanToml);
|
|
183
|
-
if (!schemaResult.success) {
|
|
184
|
-
console.error("❌ skill-deck.toml 格式错误:");
|
|
185
|
-
for (const issue of schemaResult.error.issues) {
|
|
186
|
-
const path = issue.path.map(String).filter(p => p !== "undefined").join(".") || "<root>";
|
|
187
|
-
console.error(` ${path}: ${issue.message}`);
|
|
188
|
-
}
|
|
189
|
-
process.exit(1);
|
|
190
|
-
}
|
|
191
|
-
const deck = schemaResult.data;
|
|
163
|
+
const deck = parseToml(deckRaw) as any;
|
|
192
164
|
|
|
193
|
-
const WORKING_SET = expandHome(deck.deck.
|
|
194
|
-
const COLD_POOL = expandHome(deck.deck
|
|
195
|
-
const MAX_CARDS = deck.deck
|
|
165
|
+
const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
|
|
166
|
+
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
167
|
+
const MAX_CARDS = Number(deck.deck?.max_cards || 10);
|
|
196
168
|
|
|
197
169
|
// ── 收集声明 ────────────────────────────────────────────────
|
|
198
170
|
|
|
199
171
|
interface DeclaredSkill {
|
|
200
|
-
name: string;
|
|
201
|
-
baseName: string; // working set 中的目录名(如 lythoskill-deck)
|
|
172
|
+
name: string;
|
|
202
173
|
type: "innate" | "tool" | "combo" | "transient";
|
|
203
174
|
sourcePath: string;
|
|
204
175
|
expires?: string;
|
|
@@ -215,8 +186,7 @@ for (const section of ["innate", "tool", "combo"] as const) {
|
|
|
215
186
|
errors.push(`skill 未找到: ${name}`);
|
|
216
187
|
continue;
|
|
217
188
|
}
|
|
218
|
-
|
|
219
|
-
declared.push({ name, baseName, type: section, sourcePath: src });
|
|
189
|
+
declared.push({ name, type: section, sourcePath: src });
|
|
220
190
|
}
|
|
221
191
|
}
|
|
222
192
|
|
|
@@ -229,7 +199,7 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
|
|
|
229
199
|
errors.push(`transient 路径不存在: ${key} → ${src}`);
|
|
230
200
|
continue;
|
|
231
201
|
}
|
|
232
|
-
declared.push({ name: key,
|
|
202
|
+
declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
233
203
|
}
|
|
234
204
|
|
|
235
205
|
if (errors.length > 0) {
|
|
@@ -250,11 +220,11 @@ if (declared.length > MAX_CARDS) {
|
|
|
250
220
|
mkdirSync(WORKING_SET, { recursive: true });
|
|
251
221
|
|
|
252
222
|
// 清理未声明的条目
|
|
253
|
-
const
|
|
223
|
+
const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
|
|
254
224
|
try {
|
|
255
225
|
for (const entry of readdirSync(WORKING_SET)) {
|
|
256
226
|
if (entry.startsWith("_")) continue;
|
|
257
|
-
if (!
|
|
227
|
+
if (!declaredNames.has(entry)) {
|
|
258
228
|
rmSync(join(WORKING_SET, entry), { recursive: true, force: true });
|
|
259
229
|
console.log(` 🗑️ 移除: ${entry}`);
|
|
260
230
|
}
|
|
@@ -265,7 +235,7 @@ try {
|
|
|
265
235
|
const linkedSkills: LinkedSkill[] = [];
|
|
266
236
|
|
|
267
237
|
for (const item of declared) {
|
|
268
|
-
const dest = join(WORKING_SET, item.
|
|
238
|
+
const dest = join(WORKING_SET, item.name);
|
|
269
239
|
|
|
270
240
|
// 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
|
|
271
241
|
try {
|
|
@@ -274,9 +244,10 @@ for (const item of declared) {
|
|
|
274
244
|
} catch {}
|
|
275
245
|
|
|
276
246
|
try {
|
|
247
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
277
248
|
symlinkSync(item.sourcePath, dest);
|
|
278
249
|
} catch (err: any) {
|
|
279
|
-
console.error(`❌ 链接失败: ${item.
|
|
250
|
+
console.error(`❌ 链接失败: ${item.name}: ${err.message}`);
|
|
280
251
|
continue;
|
|
281
252
|
}
|
|
282
253
|
|
|
@@ -302,7 +273,7 @@ for (const item of declared) {
|
|
|
302
273
|
deck_managed_dirs: managedDirs,
|
|
303
274
|
});
|
|
304
275
|
|
|
305
|
-
console.log(` 🔗 ${item.
|
|
276
|
+
console.log(` 🔗 ${item.name}`);
|
|
306
277
|
}
|
|
307
278
|
|
|
308
279
|
// ── Transient 过期检查 ──────────────────────────────────────
|
package/src/schema.ts
CHANGED
|
@@ -1,30 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
// ── skill-deck.toml 声明文件 Schema ─────────────────────────
|
|
4
|
-
// 防止模板与运行时解析漂移(template drift)
|
|
5
|
-
|
|
6
|
-
export const SkillDeckTomlSchema = z.object({
|
|
7
|
-
deck: z.object({
|
|
8
|
-
working_set: z.string().default(".claude/skills"),
|
|
9
|
-
cold_pool: z.string().default("~/.agents/skill-repos"),
|
|
10
|
-
max_cards: z.number().int().min(1).default(10),
|
|
11
|
-
}),
|
|
12
|
-
innate: z.object({
|
|
13
|
-
skills: z.array(z.string()).default([]),
|
|
14
|
-
}).optional(),
|
|
15
|
-
tool: z.object({
|
|
16
|
-
skills: z.array(z.string()).default([]),
|
|
17
|
-
}).optional(),
|
|
18
|
-
combo: z.object({
|
|
19
|
-
skills: z.array(z.string()).default([]),
|
|
20
|
-
}).optional(),
|
|
21
|
-
// transient: 动态子表,key 是 skill 名,value 是任意配置
|
|
22
|
-
// 用 z.any() 避免空 [transient] 导致 record key 验证失败
|
|
23
|
-
transient: z.record(z.string(), z.any()).optional(),
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
export type SkillDeckToml = z.infer<typeof SkillDeckTomlSchema>;
|
|
27
|
-
|
|
28
3
|
// ── 单个已链接 Skill ────────────────────────────────────────
|
|
29
4
|
export const LinkedSkillSchema = z.object({
|
|
30
5
|
name: z.string(),
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-validate.ts — Skill Deck configuration validator
|
|
4
|
+
*
|
|
5
|
+
* 读取 skill-deck.toml → 校验 schema、引用有效性、约束合规性。
|
|
6
|
+
* 不做:创建 symlink、修改文件系统。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseToml } from "@iarna/toml";
|
|
10
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
import { findDeckToml, expandHome, findSource } from "./link.js";
|
|
13
|
+
|
|
14
|
+
export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
|
|
15
|
+
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
|
|
16
|
+
const DECK_PATH = cliDeckPath
|
|
17
|
+
? resolve(cliDeckPath)
|
|
18
|
+
: findDeckToml(PROJECT_DIR) || resolve(PROJECT_DIR, "skill-deck.toml");
|
|
19
|
+
|
|
20
|
+
if (!existsSync(DECK_PATH)) {
|
|
21
|
+
console.error(`❌ skill-deck.toml not found: ${DECK_PATH}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
26
|
+
let deck: any;
|
|
27
|
+
try {
|
|
28
|
+
deck = parseToml(deckRaw);
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
console.error(`❌ TOML parse error: ${err.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const errors: string[] = [];
|
|
35
|
+
const warnings: string[] = [];
|
|
36
|
+
|
|
37
|
+
// ── Validate deck section ──────────────────────────────────
|
|
38
|
+
|
|
39
|
+
if (!deck.deck || typeof deck.deck !== "object") {
|
|
40
|
+
errors.push("[deck] section is required");
|
|
41
|
+
} else {
|
|
42
|
+
const maxCards = deck.deck.max_cards;
|
|
43
|
+
if (maxCards === undefined) {
|
|
44
|
+
errors.push("deck.max_cards is required");
|
|
45
|
+
} else if (!Number.isInteger(maxCards) || maxCards < 1) {
|
|
46
|
+
errors.push(`deck.max_cards must be a positive integer, got ${maxCards}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
51
|
+
const MAX_CARDS = Number(deck.deck?.max_cards || 10);
|
|
52
|
+
|
|
53
|
+
// ── Validate skill declarations ────────────────────────────
|
|
54
|
+
|
|
55
|
+
const declaredNames = new Set<string>();
|
|
56
|
+
let declaredCount = 0;
|
|
57
|
+
|
|
58
|
+
for (const section of ["innate", "tool", "combo"] as const) {
|
|
59
|
+
const skills = deck[section]?.skills;
|
|
60
|
+
if (skills === undefined) continue;
|
|
61
|
+
if (!Array.isArray(skills)) {
|
|
62
|
+
errors.push(`[${section}].skills must be an array`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const name of skills) {
|
|
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);
|
|
75
|
+
|
|
76
|
+
const src = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
77
|
+
if (!src) {
|
|
78
|
+
errors.push(`Skill not found: ${name} (${section})`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Validate transient section ─────────────────────────────
|
|
84
|
+
|
|
85
|
+
const transientCount = Object.keys(deck.transient || {}).length;
|
|
86
|
+
if (deck.transient) {
|
|
87
|
+
for (const [key, value] of Object.entries(deck.transient)) {
|
|
88
|
+
const t = value as any;
|
|
89
|
+
if (!t || typeof t !== "object") {
|
|
90
|
+
errors.push(`transient.${key} must be a table`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!t.path) {
|
|
94
|
+
errors.push(`transient.${key} missing required field: path`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const src = resolve(PROJECT_DIR, t.path);
|
|
98
|
+
if (!existsSync(src)) {
|
|
99
|
+
errors.push(`transient path does not exist: ${key} → ${src}`);
|
|
100
|
+
}
|
|
101
|
+
if (t.expires) {
|
|
102
|
+
const d = new Date(t.expires);
|
|
103
|
+
if (isNaN(d.getTime())) {
|
|
104
|
+
errors.push(`transient.${key} has invalid expires format: ${t.expires}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Budget check ───────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const total = declaredCount + transientCount;
|
|
113
|
+
if (total > MAX_CARDS) {
|
|
114
|
+
errors.push(`Budget exceeded: declared ${total} skill(s), max_cards = ${MAX_CARDS}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Report ─────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
if (warnings.length > 0) {
|
|
120
|
+
for (const w of warnings) console.warn(`⚠️ ${w}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (errors.length > 0) {
|
|
124
|
+
for (const e of errors) console.error(`❌ ${e}`);
|
|
125
|
+
console.error(`\n❌ Validation failed: ${errors.length} error(s)`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`✅ Validation passed: ${total} skill(s), max_cards = ${MAX_CARDS}`);
|
|
130
|
+
}
|