@lythos/skill-deck 0.1.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.1.9",
3
+ "version": "0.3.0",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "license": "MIT",
6
6
  "type": "module",
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('Usage: lythoskill-deck link [--deck <path>] [--workdir <dir>]')
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 parsedToml = parseToml(deckRaw) as any;
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.working_set, PROJECT_DIR);
194
- const COLD_POOL = expandHome(deck.deck.cold_pool, PROJECT_DIR);
195
- const MAX_CARDS = deck.deck.max_cards;
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; // 完整声明名(如 github.com/.../lythoskill-deck)
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
- const baseName = name.split("/").pop() || name;
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, baseName: key, type: "transient", sourcePath: src, expires: t.expires });
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 declaredBaseNames = new Set(declared.map(d => d.baseName));
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 (!declaredBaseNames.has(entry)) {
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.baseName);
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.baseName}: ${err.message}`);
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.baseName}`);
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(),
@@ -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
+ }