@lythos/skill-deck 0.1.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 +13 -0
- package/src/cli.ts +15 -0
- package/src/link.ts +348 -0
- package/src/schema.ts +54 -0
package/package.json
ADDED
package/src/cli.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { linkDeck } from './link.js'
|
|
3
|
+
|
|
4
|
+
const command = process.argv[2]
|
|
5
|
+
const deckFlagIdx = process.argv.indexOf('--deck')
|
|
6
|
+
const deckPath = deckFlagIdx >= 0 ? process.argv[deckFlagIdx + 1] : undefined
|
|
7
|
+
|
|
8
|
+
switch (command) {
|
|
9
|
+
case 'link':
|
|
10
|
+
linkDeck(deckPath)
|
|
11
|
+
break
|
|
12
|
+
default:
|
|
13
|
+
console.error('Usage: lythoskill-deck link [--deck <path>]')
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-link.ts — Skill Deck reconciler
|
|
4
|
+
*
|
|
5
|
+
* 读取 skill-deck.toml → 计算期望状态 → 收束 working set → 写 lock。
|
|
6
|
+
* 职责:ln -s、预算检查、过期检查、managed_dirs 重叠检测。
|
|
7
|
+
* 不做:语义分析、智能推荐、niche 冲突仲裁。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parse as parseToml } from "@iarna/toml";
|
|
11
|
+
import { createHash } from "crypto";
|
|
12
|
+
import {
|
|
13
|
+
existsSync, mkdirSync, readFileSync, readdirSync,
|
|
14
|
+
symlinkSync, lstatSync, rmSync, writeFileSync,
|
|
15
|
+
} from "fs";
|
|
16
|
+
import { resolve, dirname, join } from "path";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import {
|
|
19
|
+
SkillDeckLockSchema,
|
|
20
|
+
type SkillDeckLock, type LinkedSkill, type ConstraintReport,
|
|
21
|
+
} from "./schema.js";
|
|
22
|
+
|
|
23
|
+
// ── 路径工具 ────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function findDeckToml(from: string): string | null {
|
|
26
|
+
let dir = from;
|
|
27
|
+
for (let i = 0; i < 10; i++) {
|
|
28
|
+
const p = join(dir, "skill-deck.toml");
|
|
29
|
+
if (existsSync(p)) return p;
|
|
30
|
+
const parent = dirname(dir);
|
|
31
|
+
if (parent === dir) break;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expandHome(p: string, base: string): string {
|
|
38
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
39
|
+
return resolve(base, p);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hashContent(content: string): string {
|
|
43
|
+
return createHash("sha256").update(content).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Front matter 提取 ───────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function getFrontMatter(skillMdPath: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const c = readFileSync(skillMdPath, "utf-8");
|
|
51
|
+
if (!c.startsWith("---")) return "";
|
|
52
|
+
const parts = c.split("---");
|
|
53
|
+
return parts.length >= 3 ? parts[1] : "";
|
|
54
|
+
} catch { return ""; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractField(fm: string, field: string): string {
|
|
58
|
+
const m = fm.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
|
|
59
|
+
return m ? m[1].trim() : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractArrayField(fm: string, field: string): string[] {
|
|
63
|
+
const lines = fm.split("\n");
|
|
64
|
+
const results: string[] = [];
|
|
65
|
+
let collecting = false;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (line.match(new RegExp(`^${field}:\\s*$`))) {
|
|
68
|
+
collecting = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (line.match(new RegExp(`^${field}:\\s*\\[`))) {
|
|
72
|
+
const inline = line.match(/\[(.+)\]/);
|
|
73
|
+
if (inline) return inline[1].split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
|
|
74
|
+
collecting = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (collecting) {
|
|
78
|
+
const item = line.match(/^\s+-\s+(.+)/);
|
|
79
|
+
if (item) {
|
|
80
|
+
results.push(item[1].trim().replace(/^["']|["']$/g, ""));
|
|
81
|
+
} else if (line.trim() !== "" && !line.match(/^\s*#/)) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 冷池查找 ────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function findSource(name: string, coldPool: string, projectDir: string): string | null {
|
|
92
|
+
// 1. 直接路径
|
|
93
|
+
const direct = resolve(coldPool, name);
|
|
94
|
+
if (existsSync(join(direct, "SKILL.md"))) return direct;
|
|
95
|
+
|
|
96
|
+
// 2. Monorepo: repo/skill → cold_pool/repo/skills/skill
|
|
97
|
+
if (name.includes("/")) {
|
|
98
|
+
const [repo, ...rest] = name.split("/");
|
|
99
|
+
const mono = join(coldPool, repo, "skills", rest.join("/"));
|
|
100
|
+
if (existsSync(join(mono, "SKILL.md"))) return mono;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. 扁平扫描: cold_pool/<any-repo>/<name> 或 <any-repo>/skills/<name>
|
|
104
|
+
try {
|
|
105
|
+
for (const entry of readdirSync(coldPool, { withFileTypes: true })) {
|
|
106
|
+
if (!entry.isDirectory()) continue;
|
|
107
|
+
const base = join(coldPool, entry.name);
|
|
108
|
+
for (const sub of [join(base, name), join(base, "skills", name)]) {
|
|
109
|
+
if (existsSync(join(sub, "SKILL.md"))) return sub;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {}
|
|
113
|
+
|
|
114
|
+
// 4. 项目本地: <project>/skills/<name>
|
|
115
|
+
const local = resolve(projectDir, "skills", name);
|
|
116
|
+
if (existsSync(join(local, "SKILL.md"))) return local;
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 主流程 ──────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export function linkDeck(cliDeckPath?: string): void {
|
|
124
|
+
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
125
|
+
const DECK_PATH = cliDeck
|
|
126
|
+
? resolve(cliDeck)
|
|
127
|
+
: findDeckToml(process.cwd()) || resolve("skill-deck.toml");
|
|
128
|
+
|
|
129
|
+
if (!existsSync(DECK_PATH)) {
|
|
130
|
+
console.error(`❌ ${DECK_PATH} 不存在`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PROJECT_DIR = dirname(DECK_PATH);
|
|
135
|
+
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
136
|
+
const deckHash = hashContent(deckRaw);
|
|
137
|
+
const deck = parseToml(deckRaw) as any;
|
|
138
|
+
|
|
139
|
+
const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
|
|
140
|
+
const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
|
|
141
|
+
const MAX_CARDS = Number(deck.deck?.max_cards || 10);
|
|
142
|
+
|
|
143
|
+
// ── 收集声明 ────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
interface DeclaredSkill {
|
|
146
|
+
name: string;
|
|
147
|
+
type: "innate" | "tool" | "combo" | "transient";
|
|
148
|
+
sourcePath: string;
|
|
149
|
+
expires?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const declared: DeclaredSkill[] = [];
|
|
153
|
+
const errors: string[] = [];
|
|
154
|
+
|
|
155
|
+
for (const section of ["innate", "tool", "combo"] as const) {
|
|
156
|
+
for (const name of (deck[section]?.skills || [])) {
|
|
157
|
+
if (!name || typeof name !== "string") continue;
|
|
158
|
+
const src = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
159
|
+
if (!src) {
|
|
160
|
+
errors.push(`skill 未找到: ${name}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
declared.push({ name, type: section, sourcePath: src });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// transient: sub-tables with path field
|
|
168
|
+
for (const [key, value] of Object.entries(deck.transient || {})) {
|
|
169
|
+
const t = value as any;
|
|
170
|
+
if (!t?.path) continue;
|
|
171
|
+
const src = resolve(PROJECT_DIR, t.path);
|
|
172
|
+
if (!existsSync(src)) {
|
|
173
|
+
errors.push(`transient 路径不存在: ${key} → ${src}`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (errors.length > 0) {
|
|
180
|
+
for (const e of errors) console.error(`❌ ${e}`);
|
|
181
|
+
// 继续执行已找到的 skill,不因个别缺失中断全部
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── 预算检查(硬约束,链接前检查)──────────────────────────
|
|
185
|
+
|
|
186
|
+
if (declared.length > MAX_CARDS) {
|
|
187
|
+
console.error(`❌ 超出预算: 声明 ${declared.length} 个,上限 ${MAX_CARDS}`);
|
|
188
|
+
console.error(` 减少 skill-deck.toml 中的声明,或调整 max_cards`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 收束 working set ────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
mkdirSync(WORKING_SET, { recursive: true });
|
|
195
|
+
|
|
196
|
+
// 清理未声明的条目
|
|
197
|
+
const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
|
|
198
|
+
try {
|
|
199
|
+
for (const entry of readdirSync(WORKING_SET)) {
|
|
200
|
+
if (entry.startsWith("_")) continue;
|
|
201
|
+
if (!declaredNames.has(entry)) {
|
|
202
|
+
rmSync(join(WORKING_SET, entry), { recursive: true, force: true });
|
|
203
|
+
console.log(` 🗑️ 移除: ${entry}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {}
|
|
207
|
+
|
|
208
|
+
// 创建 symlink
|
|
209
|
+
const linkedSkills: LinkedSkill[] = [];
|
|
210
|
+
|
|
211
|
+
for (const item of declared) {
|
|
212
|
+
const dest = join(WORKING_SET, item.name);
|
|
213
|
+
|
|
214
|
+
// 幂等:已存在则删除重建
|
|
215
|
+
if (existsSync(dest)) {
|
|
216
|
+
rmSync(dest, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
221
|
+
symlinkSync(item.sourcePath, dest);
|
|
222
|
+
} catch (err: any) {
|
|
223
|
+
console.error(`❌ 链接失败: ${item.name}: ${err.message}`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 提取元数据
|
|
228
|
+
const skillMdPath = join(item.sourcePath, "SKILL.md");
|
|
229
|
+
const fm = getFrontMatter(skillMdPath);
|
|
230
|
+
const niche = extractField(fm, "deck_niche");
|
|
231
|
+
const managedDirs = extractArrayField(fm, "deck_managed_dirs");
|
|
232
|
+
let contentHash: string | undefined;
|
|
233
|
+
try {
|
|
234
|
+
contentHash = hashContent(readFileSync(skillMdPath, "utf-8"));
|
|
235
|
+
} catch {}
|
|
236
|
+
|
|
237
|
+
linkedSkills.push({
|
|
238
|
+
name: item.name,
|
|
239
|
+
deck_niche: niche,
|
|
240
|
+
type: item.type,
|
|
241
|
+
source: item.sourcePath,
|
|
242
|
+
dest,
|
|
243
|
+
content_hash: contentHash,
|
|
244
|
+
linked_at: new Date().toISOString(),
|
|
245
|
+
...(item.expires ? { expires: item.expires } : {}),
|
|
246
|
+
deck_managed_dirs: managedDirs,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
console.log(` 🔗 ${item.name}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Transient 过期检查 ──────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const transientWarnings: { name: string; expires: string; days_remaining: number }[] = [];
|
|
256
|
+
|
|
257
|
+
for (const s of linkedSkills) {
|
|
258
|
+
if (s.type !== "transient" || !s.expires) continue;
|
|
259
|
+
const exp = new Date(s.expires).getTime();
|
|
260
|
+
const days = Math.ceil((exp - now) / 86400000);
|
|
261
|
+
transientWarnings.push({ name: s.name, expires: s.expires, days_remaining: days });
|
|
262
|
+
if (days <= 0) {
|
|
263
|
+
console.warn(`⚠️ 过期: ${s.name}(到期 ${s.expires})— 评估是否仍需要`);
|
|
264
|
+
} else if (days <= 14) {
|
|
265
|
+
console.warn(`⏰ 即将过期: ${s.name}(剩余 ${days} 天)`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── managed_dirs 重叠检测 ───────────────────────────────────
|
|
270
|
+
|
|
271
|
+
const dirOwners = new Map<string, string[]>();
|
|
272
|
+
for (const s of linkedSkills) {
|
|
273
|
+
for (const d of s.deck_managed_dirs) {
|
|
274
|
+
const norm = d.replace(/\/+$/, ""); // 去尾斜杠
|
|
275
|
+
const owners = dirOwners.get(norm) || [];
|
|
276
|
+
owners.push(s.name);
|
|
277
|
+
dirOwners.set(norm, owners);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const dirOverlaps: { dir: string; skills: string[] }[] = [];
|
|
282
|
+
for (const [dir, owners] of dirOwners) {
|
|
283
|
+
if (owners.length > 1) {
|
|
284
|
+
dirOverlaps.push({ dir, skills: owners });
|
|
285
|
+
console.warn(`⚠️ 目录重叠: ${dir} ← ${owners.join(", ")}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 父子目录重叠检测
|
|
290
|
+
const allDirs = [...dirOwners.keys()].sort();
|
|
291
|
+
for (let i = 0; i < allDirs.length; i++) {
|
|
292
|
+
for (let j = i + 1; j < allDirs.length; j++) {
|
|
293
|
+
if (allDirs[j].startsWith(allDirs[i] + "/")) {
|
|
294
|
+
const parentOwners = dirOwners.get(allDirs[i]) || [];
|
|
295
|
+
const childOwners = dirOwners.get(allDirs[j]) || [];
|
|
296
|
+
// 只在不同 skill 之间报告
|
|
297
|
+
const cross = parentOwners.filter(o => !childOwners.includes(o));
|
|
298
|
+
if (cross.length > 0) {
|
|
299
|
+
const msg = `${allDirs[i]} (${parentOwners.join(",")}) 包含 ${allDirs[j]} (${childOwners.join(",")})`;
|
|
300
|
+
console.warn(`⚠️ 目录包含关系: ${msg}`);
|
|
301
|
+
dirOverlaps.push({ dir: `${allDirs[i]} ⊃ ${allDirs[j]}`, skills: [...new Set([...parentOwners, ...childOwners])] });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── 生成 lock ───────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
const constraints: ConstraintReport = {
|
|
310
|
+
total_cards: linkedSkills.length,
|
|
311
|
+
max_cards: MAX_CARDS,
|
|
312
|
+
within_budget: linkedSkills.length <= MAX_CARDS,
|
|
313
|
+
transient_warnings: transientWarnings,
|
|
314
|
+
dir_overlaps: dirOverlaps,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const lock: SkillDeckLock = {
|
|
318
|
+
version: "1.0.0",
|
|
319
|
+
generated_at: new Date().toISOString(),
|
|
320
|
+
deck_source: { path: DECK_PATH, content_hash: deckHash },
|
|
321
|
+
working_set: WORKING_SET,
|
|
322
|
+
cold_pool: COLD_POOL,
|
|
323
|
+
skills: linkedSkills,
|
|
324
|
+
constraints,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const parsed = SkillDeckLockSchema.safeParse(lock);
|
|
328
|
+
if (!parsed.success) {
|
|
329
|
+
console.error("❌ Lock schema 校验失败:", JSON.stringify(parsed.error.format(), null, 2));
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const LOCK_PATH = resolve(PROJECT_DIR, "skill-deck.lock");
|
|
334
|
+
writeFileSync(LOCK_PATH, JSON.stringify(parsed.data, null, 2) + "\n");
|
|
335
|
+
|
|
336
|
+
// ── 报告 ────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
console.log("");
|
|
339
|
+
console.log(`✅ 同步完成: ${linkedSkills.length}/${MAX_CARDS} skill`);
|
|
340
|
+
console.log(` lock: ${LOCK_PATH}`);
|
|
341
|
+
if (dirOverlaps.length > 0) {
|
|
342
|
+
console.log(` ⚠️ ${dirOverlaps.length} 个目录重叠(详见上方警告)`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (import.meta.main) {
|
|
347
|
+
linkDeck();
|
|
348
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ── 单个已链接 Skill ────────────────────────────────────────
|
|
4
|
+
export const LinkedSkillSchema = z.object({
|
|
5
|
+
name: z.string(),
|
|
6
|
+
deck_niche: z.string(),
|
|
7
|
+
type: z.enum(["innate", "tool", "combo", "transient"]),
|
|
8
|
+
source: z.string(),
|
|
9
|
+
dest: z.string(),
|
|
10
|
+
content_hash: z.string().optional(),
|
|
11
|
+
linked_at: z.string().datetime(),
|
|
12
|
+
expires: z.string().optional(),
|
|
13
|
+
/** 该 skill 声明管理的目录列表 */
|
|
14
|
+
deck_managed_dirs: z.array(z.string()).default([]),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ── 约束校验 ────────────────────────────────────────────────
|
|
18
|
+
export const ConstraintReportSchema = z.object({
|
|
19
|
+
total_cards: z.number().int().min(0),
|
|
20
|
+
max_cards: z.number().int().min(0),
|
|
21
|
+
within_budget: z.boolean(),
|
|
22
|
+
transient_warnings: z.array(
|
|
23
|
+
z.object({
|
|
24
|
+
name: z.string(),
|
|
25
|
+
expires: z.string(),
|
|
26
|
+
days_remaining: z.number().int(),
|
|
27
|
+
})
|
|
28
|
+
),
|
|
29
|
+
/** 两个以上 skill 声明管理同一目录 */
|
|
30
|
+
dir_overlaps: z.array(
|
|
31
|
+
z.object({
|
|
32
|
+
dir: z.string(),
|
|
33
|
+
skills: z.array(z.string()),
|
|
34
|
+
})
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── 完整 lock 文件 ──────────────────────────────────────────
|
|
39
|
+
export const SkillDeckLockSchema = z.object({
|
|
40
|
+
version: z.literal("1.0.0"),
|
|
41
|
+
generated_at: z.string().datetime(),
|
|
42
|
+
deck_source: z.object({
|
|
43
|
+
path: z.string(),
|
|
44
|
+
content_hash: z.string(),
|
|
45
|
+
}),
|
|
46
|
+
working_set: z.string(),
|
|
47
|
+
cold_pool: z.string(),
|
|
48
|
+
skills: z.array(LinkedSkillSchema),
|
|
49
|
+
constraints: ConstraintReportSchema,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type LinkedSkill = z.infer<typeof LinkedSkillSchema>;
|
|
53
|
+
export type ConstraintReport = z.infer<typeof ConstraintReportSchema>;
|
|
54
|
+
export type SkillDeckLock = z.infer<typeof SkillDeckLockSchema>;
|