@lythos/skill-deck 0.6.2 → 0.7.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 CHANGED
@@ -19,7 +19,7 @@ No installation required. `bunx` auto-downloads the package.
19
19
  max_cards = 10
20
20
 
21
21
  [tool]
22
- skills = ["github.com/lythos-labs/lythoskill/lythoskill-deck"]
22
+ skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
23
23
  ```
24
24
 
25
25
  ### skill-deck.toml (full reference)
@@ -31,13 +31,16 @@ working_set = ".claude/skills" # Where symlinks are created
31
31
  cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
32
32
 
33
33
  [innate] # Always-loaded skills
34
- skills = ["github.com/lythos-labs/lythoskill/lythoskill-deck"]
34
+ skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
35
35
 
36
36
  [tool] # Auto-triggered skills
37
- skills = ["skill-a", "skill-b"]
37
+ skills = [
38
+ "github.com/mattpocock/skills/skills/engineering/tdd",
39
+ "github.com/garrytan/gstack",
40
+ ]
38
41
 
39
42
  [combo] # Multi-skill bundles
40
- skills = ["report-generation-combo"]
43
+ skills = ["github.com/anthropics/skills/skills/pdf"]
41
44
 
42
45
  [transient] # Temporary skills with expiry
43
46
  [transient.handoff]
@@ -100,7 +103,7 @@ cat > skill-deck.toml << 'EOF'
100
103
  max_cards = 10
101
104
 
102
105
  [tool]
103
- skills = ["github.com/lythos-labs/lythoskill/lythoskill-deck"]
106
+ skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
104
107
  EOF
105
108
 
106
109
  # 2. Link — creates symlinks in .claude/skills/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.6.2",
4
- "description": "Declarative skill deck governance \u2014 cold pool, working set, deny-by-default",
3
+ "version": "0.7.1",
4
+ "description": "Declarative skill deck governance cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
7
7
  "skill",
package/src/add.ts CHANGED
@@ -15,6 +15,8 @@ import { execSync } from 'node:child_process'
15
15
  import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
16
16
  import { findDeckToml, expandHome } from './link.js'
17
17
 
18
+ const CLAUDE_SKILLS_DIR = join(homedir(), '.claude', 'skills')
19
+
18
20
  interface ParsedLocator {
19
21
  host: string
20
22
  owner: string
@@ -114,29 +116,55 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
114
116
  const tmpRepo = join(tmpDir, 'repo')
115
117
 
116
118
  try {
119
+ let skillSourceDir: string
120
+
117
121
  if (backend === 'skills.sh' || backend === 'vercel') {
118
122
  const skillsShLocator = `${parsed.owner}/${parsed.repo}`
119
123
  console.log(`📦 Downloading via skills.sh: ${skillsShLocator}`)
124
+
125
+ // Snapshot existing directories in ~/.claude/skills/
126
+ const beforeDirs = existsSync(CLAUDE_SKILLS_DIR)
127
+ ? new Set(readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })
128
+ .filter(e => e.isDirectory())
129
+ .map(e => e.name))
130
+ : new Set<string>()
131
+
120
132
  execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
133
+
134
+ // Detect the newly installed directory
135
+ const afterDirs = existsSync(CLAUDE_SKILLS_DIR)
136
+ ? readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })
137
+ .filter(e => e.isDirectory())
138
+ .map(e => e.name)
139
+ : []
140
+ const newDirs = afterDirs.filter(d => !beforeDirs.has(d))
141
+
142
+ if (newDirs.length === 0) {
143
+ console.error(`❌ skills.sh installed nothing new to ~/.claude/skills/`)
144
+ console.error(` The skill may already be installed, or the install failed.`)
145
+ process.exit(1)
146
+ }
147
+ if (newDirs.length > 1) {
148
+ console.warn(`⚠️ Multiple new directories detected in ~/.claude/skills/`)
149
+ console.warn(` Using the first one: ${newDirs[0]}`)
150
+ }
151
+ const installedName = newDirs[0]
152
+ skillSourceDir = join(CLAUDE_SKILLS_DIR, installedName)
153
+ console.log(` Detected install: ${installedName}`)
121
154
  } else {
122
155
  const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
123
156
  console.log(`📦 Cloning: ${gitUrl}`)
124
157
  execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
158
+ skillSourceDir = tmpRepo
125
159
  }
126
160
 
127
- if (!existsSync(tmpRepo)) {
128
- if (backend === 'skills.sh' || backend === 'vercel') {
129
- console.error(`❌ skills.sh backend installs globally, not to cold pool.`)
130
- console.error(` Please manually place the skill at: ${targetDir}`)
131
- console.error(` Or use: deck add ${locator} --via git`)
132
- process.exit(1)
133
- }
134
- console.error(`❌ Download failed: expected output not found at ${tmpRepo}`)
161
+ if (!existsSync(skillSourceDir)) {
162
+ console.error(`❌ Download failed: expected output not found at ${skillSourceDir}`)
135
163
  process.exit(1)
136
164
  }
137
165
 
138
166
  mkdirSync(dirname(targetDir), { recursive: true })
139
- renameSync(tmpRepo, targetDir)
167
+ renameSync(skillSourceDir, targetDir)
140
168
 
141
169
  const skillDir = findSkillDir(targetDir, parsed.skill)
142
170
  if (!skillDir) {
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { linkDeck } from './link.js'
3
3
  import { validateDeck } from './validate.js'
4
4
  import { addSkill } from './add.js'
5
+ import { updateDeck } from './update.js'
5
6
  import { formatHelp } from './help.js'
6
7
 
7
8
  const HELP_CONFIG = {
@@ -10,11 +11,13 @@ const HELP_CONFIG = {
10
11
  commands: [
11
12
  { name: 'link', description: 'Sync working set with skill-deck.toml' },
12
13
  { name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
14
+ { name: 'update', description: 'Pull latest versions of declared skills from upstream' },
13
15
  { name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
14
16
  ],
15
17
  options: [
16
18
  { flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
17
19
  { flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
20
+ { flag: '--no-backup', description: 'Skip tar backup when removing non-symlink entries' },
18
21
  { flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
19
22
  ],
20
23
  }
@@ -29,6 +32,7 @@ const viaFlagIdx = args.indexOf('--via')
29
32
  const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
30
33
  const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
31
34
  const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
35
+ const noBackup = args.includes('--no-backup')
32
36
 
33
37
  switch (command) {
34
38
  case '--help':
@@ -36,7 +40,7 @@ switch (command) {
36
40
  console.log(formatHelp(HELP_CONFIG))
37
41
  process.exit(0)
38
42
  case 'link':
39
- linkDeck(deckPath, workdir)
43
+ linkDeck(deckPath, workdir, noBackup)
40
44
  break
41
45
  case 'add': {
42
46
  const locator = args[1]
@@ -47,6 +51,9 @@ switch (command) {
47
51
  await addSkill(locator, { via, deck: deckPath, workdir })
48
52
  break
49
53
  }
54
+ case 'update':
55
+ updateDeck(deckPath, workdir)
56
+ break
50
57
  case 'validate':
51
58
  validateDeck(deckPath, workdir)
52
59
  break
package/src/link.ts CHANGED
@@ -9,13 +9,14 @@
9
9
 
10
10
  import { parse as parseToml } from "@iarna/toml";
11
11
  import YAML from "yaml";
12
- import { createHash } from "crypto";
12
+ import { createHash } from "node:crypto";
13
13
  import {
14
14
  existsSync, mkdirSync, readFileSync, readdirSync,
15
- symlinkSync, lstatSync, rmSync, writeFileSync,
16
- } from "fs";
17
- import { resolve, dirname, join, basename, relative } from "path";
18
- import { homedir } from "os";
15
+ symlinkSync, lstatSync, rmSync, statSync, writeFileSync,
16
+ } from "node:fs";
17
+ import { execSync } from "node:child_process";
18
+ import { resolve, dirname, join, basename, relative } from "node:path";
19
+ import { homedir } from "node:os";
19
20
  import {
20
21
  SkillDeckLockSchema,
21
22
  type SkillDeckLock, type LinkedSkill, type ConstraintReport,
@@ -51,7 +52,12 @@ function parseSkillFrontmatter(skillMdPath: string): Record<string, any> {
51
52
 
52
53
  // ── 冷池查找 ────────────────────────────────────────────────
53
54
 
54
- export function findSource(name: string, coldPool: string, projectDir: string): string | null {
55
+ export interface FindSourceResult {
56
+ path: string | null;
57
+ error?: string;
58
+ }
59
+
60
+ export function findSource(name: string, coldPool: string, projectDir: string): FindSourceResult {
55
61
  // 0. Fully-qualified path: host.tld/owner/repo/skill
56
62
  // → cold_pool/host.tld/owner/repo/skills/skill
57
63
  // Also handles host.tld/owner/repo (standalone skill without skills/ subdir)
@@ -65,31 +71,32 @@ export function findSource(name: string, coldPool: string, projectDir: string):
65
71
 
66
72
  if (skill) {
67
73
  const fqPath = join(coldPool, host, owner, repo, "skills", skill);
68
- if (existsSync(join(fqPath, "SKILL.md"))) return fqPath;
74
+ if (existsSync(join(fqPath, "SKILL.md"))) return { path: fqPath };
69
75
  }
70
76
  // fallback: standalone skill at repo root
71
77
  const directPath = join(coldPool, host, owner, repo);
72
- if (existsSync(join(directPath, "SKILL.md"))) return directPath;
78
+ if (existsSync(join(directPath, "SKILL.md"))) return { path: directPath };
73
79
  }
74
80
 
75
81
  // 1. 直接路径
76
82
  const direct = resolve(coldPool, name);
77
- if (existsSync(join(direct, "SKILL.md"))) return direct;
83
+ if (existsSync(join(direct, "SKILL.md"))) return { path: direct };
78
84
 
79
85
  // 2. Monorepo: repo/skill → cold_pool/repo/skills/skill
80
86
  if (name.includes("/")) {
81
87
  const [repo, ...rest] = name.split("/");
82
88
  const mono = join(coldPool, repo, "skills", rest.join("/"));
83
- if (existsSync(join(mono, "SKILL.md"))) return mono;
89
+ if (existsSync(join(mono, "SKILL.md"))) return { path: mono };
84
90
  }
85
91
 
86
92
  // 3. 项目本地: <project>/skills/<name>(build 输出目录,优先级高于扁平扫描)
87
93
  const local = resolve(projectDir, "skills", name);
88
- if (existsSync(join(local, "SKILL.md"))) return local;
94
+ if (existsSync(join(local, "SKILL.md"))) return { path: local };
89
95
 
90
96
  // 4. 扁平扫描: cold_pool/<any-repo>/<name> 或 <any-repo>/skills/<name>
91
97
  // 跳过隐藏目录(agent working set、git、配置等)和 node_modules,
92
98
  // 避免把 .claude/skills/ 里的 symlink 误判为有效 cold-pool 源
99
+ const matches: string[] = [];
93
100
  try {
94
101
  for (const entry of readdirSync(coldPool, { withFileTypes: true })) {
95
102
  if (!entry.isDirectory()) continue;
@@ -97,17 +104,54 @@ export function findSource(name: string, coldPool: string, projectDir: string):
97
104
  if (entry.name === 'node_modules') continue;
98
105
  const base = join(coldPool, entry.name);
99
106
  for (const sub of [join(base, name), join(base, "skills", name)]) {
100
- if (existsSync(join(sub, "SKILL.md"))) return sub;
107
+ if (existsSync(join(sub, "SKILL.md"))) {
108
+ matches.push(sub);
109
+ }
101
110
  }
102
111
  }
103
112
  } catch {}
104
113
 
105
- return null;
114
+ if (matches.length === 1) {
115
+ return { path: matches[0] };
116
+ }
117
+ if (matches.length > 1) {
118
+ const candidates = matches.map(m => relative(coldPool, m)).join(', ');
119
+ return {
120
+ path: null,
121
+ error: `Ambiguous skill name "${name}": found ${matches.length} matches (${candidates}). Use fully-qualified name (e.g., github.com/owner/repo/${name})`,
122
+ };
123
+ }
124
+
125
+ return { path: null };
106
126
  }
107
127
 
128
+ // ── 备份工具 ────────────────────────────────────────────────
129
+
130
+ function calculateDirSize(dir: string): number {
131
+ let total = 0;
132
+ try {
133
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
134
+ const p = join(dir, entry.name);
135
+ if (entry.isDirectory()) {
136
+ total += calculateDirSize(p);
137
+ } else if (entry.isFile()) {
138
+ total += statSync(p).size;
139
+ }
140
+ }
141
+ } catch {}
142
+ return total;
143
+ }
144
+
145
+ function formatBackupDate(d: Date): string {
146
+ const pad = (n: number) => String(n).padStart(2, "0");
147
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
148
+ }
149
+
150
+ const BACKUP_SIZE_THRESHOLD = 100 * 1024 * 1024; // 100MB
151
+
108
152
  // ── 主流程 ──────────────────────────────────────────────────
109
153
 
110
- export function linkDeck(cliDeckPath?: string, cliWorkdir?: string): void {
154
+ export function linkDeck(cliDeckPath?: string, cliWorkdir?: string, noBackup?: boolean): void {
111
155
  const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
112
156
  const DECK_PATH = cliDeck
113
157
  ? resolve(cliDeck)
@@ -150,12 +194,16 @@ const errors: string[] = [];
150
194
  for (const section of ["innate", "tool", "combo"] as const) {
151
195
  for (const name of (deck[section]?.skills || [])) {
152
196
  if (!name || typeof name !== "string") continue;
153
- const src = findSource(name, COLD_POOL, PROJECT_DIR);
154
- if (!src) {
197
+ const result = findSource(name, COLD_POOL, PROJECT_DIR);
198
+ if (result.error) {
199
+ errors.push(result.error);
200
+ continue;
201
+ }
202
+ if (!result.path) {
155
203
  errors.push(`Skill not found: ${name}`);
156
204
  continue;
157
205
  }
158
- declared.push({ name, type: section, sourcePath: src });
206
+ declared.push({ name, type: section, sourcePath: result.path });
159
207
  }
160
208
  }
161
209
 
@@ -239,23 +287,74 @@ if (
239
287
 
240
288
  mkdirSync(WORKING_SET, { recursive: true });
241
289
 
242
- // 清理未声明的条目(只删 symlink,防呆)
290
+ // Pre-flight: 备份并清理非 symlink 实体(真实目录/文件)
291
+ const nonSymlinks: string[] = [];
292
+ try {
293
+ for (const entry of readdirSync(WORKING_SET)) {
294
+ if (entry.startsWith("_") || entry.startsWith(".")) continue;
295
+ const entryPath = join(WORKING_SET, entry);
296
+ try {
297
+ const st = lstatSync(entryPath);
298
+ if (!st.isSymbolicLink()) {
299
+ nonSymlinks.push(entry);
300
+ }
301
+ } catch { continue; }
302
+ }
303
+ } catch {}
304
+
305
+ if (nonSymlinks.length > 0) {
306
+ // 计算总大小
307
+ let totalSize = 0;
308
+ for (const e of nonSymlinks) {
309
+ totalSize += calculateDirSize(join(WORKING_SET, e));
310
+ }
311
+
312
+ if (!noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
313
+ console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, WORKING_SET)} (> 100MB total).`);
314
+ console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
315
+ console.error(` Use --no-backup to skip backup (removes without saving), or clean up manually.`);
316
+ process.exit(1);
317
+ }
318
+
319
+ if (!noBackup) {
320
+ const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
321
+ const bakPath = join(PROJECT_DIR, ".claude", bakName);
322
+ mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
323
+
324
+ const tarArgs = [
325
+ "czf", bakPath,
326
+ ...nonSymlinks.map(e => relative(PROJECT_DIR, join(WORKING_SET, e))),
327
+ ];
328
+ try {
329
+ execSync("tar " + tarArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" "), {
330
+ cwd: PROJECT_DIR,
331
+ stdio: "pipe",
332
+ });
333
+ console.log(`📦 Backed up ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} to .claude/${bakName}`);
334
+ } catch (err: any) {
335
+ console.error(`❌ Backup failed: ${err.message || err}`);
336
+ console.error(` Use --no-backup to skip backup, or fix the issue and retry.`);
337
+ process.exit(1);
338
+ }
339
+ } else {
340
+ console.log(`⚠️ --no-backup: removing ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} without backup`);
341
+ }
342
+
343
+ for (const e of nonSymlinks) {
344
+ rmSync(join(WORKING_SET, e), { recursive: true, force: true });
345
+ }
346
+ }
347
+
348
+ // 清理未声明的 symlink
243
349
  const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
244
350
  try {
245
351
  for (const entry of readdirSync(WORKING_SET)) {
246
- if (entry.startsWith("_")) continue;
352
+ if (entry.startsWith("_") || entry.startsWith(".")) continue;
247
353
  if (!declaredNames.has(entry)) {
248
354
  const entryPath = join(WORKING_SET, entry);
249
355
  try {
250
356
  const st = lstatSync(entryPath);
251
- if (!st.isSymbolicLink()) {
252
- console.warn(`⚠️ Skipping non-symlink entry: ${entry}`);
253
- console.warn(` → ${entry} is a real directory, not a symlink. Deck only manages symlinks.`);
254
- const cpRel2 = relative(PROJECT_DIR, COLD_POOL);
255
- const cpHint2 = cpRel2 === "" ? `skills/${entry}` : `${cpRel2}/${entry}`;
256
- console.warn(` Move it to your cold pool (${cpHint2}) and run link again.`);
257
- continue;
258
- }
357
+ if (!st.isSymbolicLink()) continue; // 已在上文处理
259
358
  } catch { continue; }
260
359
  rmSync(entryPath, { recursive: true, force: true });
261
360
  console.log(` 🗑️ Removed: ${entry}`);
@@ -399,7 +498,7 @@ writeFileSync(LOCK_PATH, JSON.stringify(parsed.data, null, 2) + "\n");
399
498
  // ── 报告 ────────────────────────────────────────────────────
400
499
 
401
500
  console.log("");
402
- console.log(`✅ Sync complete: ${linkedSkills.length}/${MAX_CARDS} skills`);
501
+ console.log(`✅ Sync complete: ${linkedSkills.length} skill(s) linked (max_cards: ${MAX_CARDS})`);
403
502
  console.log(` lock: ${LOCK_PATH}`);
404
503
  if (dirOverlaps.length > 0) {
405
504
  console.log(` ⚠️ ${dirOverlaps.length} directory overlap(s) (see warnings above)`);
package/src/update.ts ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-update.ts — Update declared skills from their upstream sources
4
+ *
5
+ * 读取 skill-deck.toml → 遍历声明的 skill → 对 git 来源执行 pull。
6
+ * 职责:让冷池跟上上游版本。
7
+ * 不做:下载新 skill(那是 add 的职责)、修改 deck.toml、同步 working set。
8
+ */
9
+
10
+ import { parse as parseToml } from "@iarna/toml";
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";
15
+
16
+ interface UpdateResult {
17
+ name: string;
18
+ path: string;
19
+ status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
20
+ message?: string;
21
+ }
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
+
package/src/validate.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  */
8
8
 
9
9
  import { parse as parseToml } from "@iarna/toml";
10
- import { existsSync, readFileSync } from "fs";
11
- import { resolve } from "path";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
12
  import { findDeckToml, expandHome, findSource } from "./link.js";
13
13
 
14
14
  export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
@@ -73,8 +73,10 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
73
73
  }
74
74
  declaredNames.add(name);
75
75
 
76
- const src = findSource(name, COLD_POOL, PROJECT_DIR);
77
- if (!src) {
76
+ const result = findSource(name, COLD_POOL, PROJECT_DIR);
77
+ if (result.error) {
78
+ errors.push(result.error);
79
+ } else if (!result.path) {
78
80
  errors.push(`Skill not found: ${name} (${section})`);
79
81
  }
80
82
  }