@lythos/skill-deck 0.9.28 → 0.9.30

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
@@ -9,7 +9,7 @@
9
9
  This package exposes a **CLI**. Invoke via:
10
10
 
11
11
  ```bash
12
- bunx @lythos/skill-deck@0.9.28 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.30 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -55,14 +55,14 @@ prompt = "Search for latest info, then generate professional document with diagr
55
55
 
56
56
  | Situation | Command |
57
57
  |-----------|---------|
58
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.28 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.28 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.28 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.28 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.28 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.28 remove tdd` |
64
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.28 prune` |
65
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.28 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.30 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.30 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.30 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.30 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.30 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.30 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.30 prune` |
65
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.30 link --deck ./my-deck.toml --workdir /path/to/project` |
66
66
 
67
67
  ### Commands
68
68
 
@@ -119,7 +119,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
119
119
  EOF
120
120
 
121
121
  # 2. Link — creates symlinks in .claude/skills/
122
- bunx @lythos/skill-deck@0.9.28 link
122
+ bunx @lythos/skill-deck@0.9.30 link
123
123
  ```
124
124
 
125
125
  ### Key Concepts
@@ -148,7 +148,7 @@ Different agents look for skills in different directories. `skill-deck.toml` con
148
148
 
149
149
  | Symptom | Cause | Fix |
150
150
  |---------|-------|-----|
151
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.28 add github.com/owner/repo/skill` or clone manually into cold pool |
151
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.30 add github.com/owner/repo/skill` or clone manually into cold pool |
152
152
  | `link` skips entries with warnings | Real files/directories exist in working set (not symlinks) | Delete the real directories in `working_set` and re-run `link`. Never create directories manually there |
153
153
  | `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone with `git clone` or use `deck add` which clones by default |
154
154
  | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.28",
3
+ "version": "0.9.30",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/add.ts CHANGED
@@ -91,7 +91,7 @@ function exitInvalidLocator(locator: string): never {
91
91
 
92
92
  export async function addSkill(
93
93
  locator: string,
94
- options: { deck?: string; workdir?: string; alias?: string; type?: string; dryRun?: boolean },
94
+ options: { deck?: string; workdir?: string; alias?: string; type?: string; dryRun?: boolean; mode?: 'symlink' | 'snapshot' },
95
95
  ) {
96
96
  const dryRun = options.dryRun || false
97
97
  const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
@@ -245,15 +245,32 @@ export async function addSkill(
245
245
  writeFileSync(deckPath, stringifyToml(deck))
246
246
  console.log(`📝 Added "${alias}" to [${skillType}.skills] in ${deckPath}`)
247
247
  } else {
248
- const minimal: Record<string, any> = { deck: { max_cards: 10 } }
248
+ const header = [
249
+ '# Skill Deck — generated by lythoskill-deck',
250
+ '# Edit working_set for your agent platform (uncomment one):',
251
+ '# working_set = ".claude/skills" # Claude Code (also read by Cursor, Copilot)',
252
+ '# working_set = ".agents/skills" # Codex CLI, OpenClaw',
253
+ '# working_set = ".cursor/skills" # Cursor-native',
254
+ '# working_set = ".github/skills" # GitHub Copilot',
255
+ '# working_set = ".windsurf/skills" # Windsurf',
256
+ '# After editing, run: bunx @lythos/skill-deck@latest link',
257
+ '',
258
+ ].join('\n')
259
+ const minimal: Record<string, any> = {
260
+ deck: {
261
+ max_cards: 10,
262
+ cold_pool: '~/.agents/skill-repos',
263
+ working_set: '.claude/skills',
264
+ },
265
+ }
249
266
  minimal[skillType] = { skills: { [alias]: { path: fqPath } } }
250
- writeFileSync(deckPath, stringifyToml(minimal))
267
+ writeFileSync(deckPath, header + stringifyToml(minimal))
251
268
  console.log(`📝 Created ${deckPath} with "${alias}"`)
252
269
  }
253
270
 
254
271
  console.log('🔗 Running deck link...')
255
272
  const { linkDeck } = await import('./link.js')
256
- linkDeck(deckPath, workdir)
273
+ await linkDeck(deckPath, workdir, { mode: options.mode })
257
274
 
258
275
  // ── Metadata recording (content-level only; deck refs reconciled by link) ─
259
276
 
package/src/cli.ts CHANGED
@@ -29,6 +29,7 @@ const noBackup = args.includes('--no-backup')
29
29
  const yes = args.includes('--yes')
30
30
  const dryRun = args.includes('--dry-run')
31
31
  const remote = args.includes('--remote')
32
+ const mode = flagValue('--mode') as 'symlink' | 'snapshot' | undefined
32
33
 
33
34
  const HELP_CONFIG = {
34
35
  binName: 'lythoskill-deck',
@@ -45,6 +46,7 @@ const HELP_CONFIG = {
45
46
  options: [
46
47
  { flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
47
48
  { flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
49
+ { flag: '--mode <symlink|snapshot>', description: 'Link mode: symlink (default) or snapshot (cp)' },
48
50
  { flag: '--no-backup', description: 'Skip tar backup when removing non-symlink entries' },
49
51
 
50
52
  { flag: '--alias <name>', description: 'Explicit alias for the skill (default: basename of path)' },
@@ -62,7 +64,7 @@ switch (command) {
62
64
  console.log(formatHelp(HELP_CONFIG))
63
65
  process.exit(0)
64
66
  case 'link':
65
- linkDeck(deckPath, workdir, noBackup)
67
+ await linkDeck(deckPath, workdir, { noBackup, mode })
66
68
  break
67
69
  case 'add': {
68
70
  const locator = args[1]
@@ -70,7 +72,7 @@ switch (command) {
70
72
  console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
71
73
  process.exit(1)
72
74
  }
73
- await addSkill(locator, { deck: deckPath, workdir, alias, type, dryRun })
75
+ await addSkill(locator, { deck: deckPath, workdir, alias, type, dryRun, mode })
74
76
  break
75
77
  }
76
78
  case 'refresh': {
package/src/link.test.ts CHANGED
@@ -129,7 +129,7 @@ describe('linkDeck reconciler', () => {
129
129
  const deckPath = join(projectDir, 'skill-deck.toml')
130
130
  writeFileSync(deckPath, deckContent)
131
131
 
132
- linkDeck(deckPath, projectDir, true)
132
+ linkDeck(deckPath, projectDir, { noBackup: true })
133
133
 
134
134
  const workingSet = join(projectDir, '.claude', 'skills')
135
135
  expect(existsSync(workingSet)).toBe(true)
@@ -167,7 +167,7 @@ describe('linkDeck reconciler', () => {
167
167
  const deckPath = join(projectDir, 'skill-deck.toml')
168
168
  writeFileSync(deckPath, deckContent)
169
169
 
170
- linkDeck(deckPath, projectDir, true)
170
+ linkDeck(deckPath, projectDir, { noBackup: true })
171
171
 
172
172
  const workingSet = join(projectDir, '.claude', 'skills')
173
173
  const symlinkPath = join(workingSet, 'my-alias')
@@ -204,7 +204,7 @@ describe('linkDeck reconciler', () => {
204
204
  const deckPath = join(projectDir, 'skill-deck.toml')
205
205
  writeFileSync(deckPath, deckContent)
206
206
 
207
- linkDeck(deckPath, projectDir, true)
207
+ linkDeck(deckPath, projectDir, { noBackup: true })
208
208
 
209
209
  const lock1 = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
210
210
  const symlinkPath = join(projectDir, '.claude', 'skills', 'my-alias')
@@ -212,7 +212,7 @@ describe('linkDeck reconciler', () => {
212
212
 
213
213
  await new Promise(r => setTimeout(r, 50))
214
214
 
215
- linkDeck(deckPath, projectDir, true)
215
+ linkDeck(deckPath, projectDir, { noBackup: true })
216
216
 
217
217
  const lock2 = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
218
218
  const target2 = readlinkSync(symlinkPath)
@@ -249,7 +249,7 @@ describe('linkDeck reconciler', () => {
249
249
  symlinkSync(skillADir, join(workingSet, 'skill-a'))
250
250
  symlinkSync(skillBDir, join(workingSet, 'skill-b'))
251
251
 
252
- linkDeck(deckPath, projectDir, true)
252
+ linkDeck(deckPath, projectDir, { noBackup: true })
253
253
 
254
254
  // skill-a should remain
255
255
  expect(existsSync(join(workingSet, 'skill-a'))).toBe(true)
@@ -263,4 +263,26 @@ describe('linkDeck reconciler', () => {
263
263
  expect(lock.skills[0].alias).toBe('skill-a')
264
264
  })
265
265
 
266
+ it('snapshot mode: cp instead of symlink', () => {
267
+ const projectDir = makeTmp()
268
+ const coldPoolRel = 'cold-pool'
269
+ const coldPool = join(projectDir, coldPoolRel)
270
+
271
+ placeSkill(coldPool, 'github.com/owner/repo-a')
272
+
273
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo-a"\n`
274
+ const deckPath = join(projectDir, 'skill-deck.toml')
275
+ writeFileSync(deckPath, deckContent)
276
+
277
+ linkDeck(deckPath, projectDir, { noBackup: true, mode: 'snapshot' })
278
+
279
+ const dest = join(projectDir, '.claude', 'skills', 'my-alias')
280
+ expect(existsSync(dest)).toBe(true)
281
+ // Snapshot: real directory, not a symlink
282
+ expect(lstatSync(dest).isDirectory()).toBe(true)
283
+ expect(lstatSync(dest).isSymbolicLink()).toBe(false)
284
+ // Verify content was copied
285
+ expect(existsSync(join(dest, 'SKILL.md'))).toBe(true)
286
+ })
287
+
266
288
  })
package/src/link.ts CHANGED
@@ -12,7 +12,7 @@ import YAML from "yaml";
12
12
  import { createHash } from "node:crypto";
13
13
  import {
14
14
  existsSync, mkdirSync, readFileSync, readdirSync,
15
- symlinkSync, lstatSync, rmSync, statSync, writeFileSync,
15
+ symlinkSync, cpSync, lstatSync, rmSync, statSync, writeFileSync,
16
16
  } from "node:fs";
17
17
  import { execFileSync } from "node:child_process";
18
18
  import { resolve, dirname, join, basename, relative } from "node:path";
@@ -125,11 +125,37 @@ const BACKUP_SIZE_THRESHOLD = 100 * 1024 * 1024; // 100MB
125
125
 
126
126
  // ── 主流程 ──────────────────────────────────────────────────
127
127
 
128
- export function linkDeck(cliDeckPath?: string, cliWorkdir?: string, noBackup?: boolean): void {
128
+ export async function linkDeck(cliDeckPath?: string, cliWorkdir?: string, opts?: { noBackup?: boolean; mode?: 'symlink' | 'snapshot' }): Promise<void> {
129
+ const MODE = opts?.mode ?? 'symlink'
129
130
  const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
130
- const DECK_PATH = cliDeck
131
- ? resolve(cliDeck)
132
- : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
131
+
132
+ // If --deck is a URL, fetch it first (discovered via quick-agent.sh dogfooding)
133
+ let DECK_PATH: string
134
+ if (cliDeck && (cliDeck.startsWith('http://') || cliDeck.startsWith('https://'))) {
135
+ let url = cliDeck
136
+ if (url.includes('github.com/') && url.includes('/blob/')) {
137
+ url = url.replace('github.com/', 'raw.githubusercontent.com/').replace('/blob/', '/')
138
+ }
139
+ const dest = resolve(process.cwd(), 'skill-deck.toml')
140
+ console.log(`📥 Fetching deck: ${url}`)
141
+ try {
142
+ const res = await fetch(url, { signal: AbortSignal.timeout(30_000) })
143
+ if (!res.ok) {
144
+ console.error(`❌ Failed to fetch deck (HTTP ${res.status}): ${url}`)
145
+ process.exit(1)
146
+ }
147
+ writeFileSync(dest, await res.text())
148
+ console.log(` → saved to ${dest}`)
149
+ DECK_PATH = dest
150
+ } catch (e: any) {
151
+ console.error(`❌ Failed to fetch deck: ${e.message || e}`)
152
+ process.exit(1)
153
+ }
154
+ } else {
155
+ DECK_PATH = cliDeck
156
+ ? resolve(cliDeck)
157
+ : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
158
+ }
133
159
 
134
160
  if (!existsSync(DECK_PATH)) {
135
161
  console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
@@ -144,7 +170,14 @@ if (!existsSync(DECK_PATH)) {
144
170
  process.exit(1);
145
171
  }
146
172
 
147
- const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
173
+ // --workdir always wins. --deck (explicit) defaults to cwd: user expects
174
+ // "work here" when pointing to a deck outside the current directory.
175
+ // Default (no flags): deck file's directory (99% of cases = project root).
176
+ const PROJECT_DIR = cliWorkdir
177
+ ? resolve(cliWorkdir)
178
+ : cliDeck
179
+ ? process.cwd()
180
+ : dirname(DECK_PATH);
148
181
  const deckRaw = readFileSync(DECK_PATH, "utf-8");
149
182
  const deckHash = hashContent(deckRaw);
150
183
 
@@ -333,14 +366,14 @@ if (nonSymlinks.length > 0) {
333
366
  totalSize += calculateDirSize(join(WORKING_SET, e));
334
367
  }
335
368
 
336
- if (!noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
369
+ if (!opts?.noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
337
370
  console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, WORKING_SET)} (> 100MB total).`);
338
371
  console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
339
372
  console.error(` Use --no-backup to skip backup (removes without saving), or clean up manually.`);
340
373
  process.exit(1);
341
374
  }
342
375
 
343
- if (!noBackup) {
376
+ if (!opts?.noBackup) {
344
377
  const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
345
378
  const bakPath = join(PROJECT_DIR, ".claude", bakName);
346
379
  mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
@@ -400,7 +433,11 @@ for (const item of declared) {
400
433
 
401
434
  try {
402
435
  mkdirSync(dirname(dest), { recursive: true });
403
- symlinkSync(item.sourcePath, dest);
436
+ if (MODE === 'snapshot') {
437
+ cpSync(item.sourcePath, dest, { recursive: true });
438
+ } else {
439
+ symlinkSync(item.sourcePath, dest);
440
+ }
404
441
  } catch (err: any) {
405
442
  console.error(`❌ Link failed: ${item.alias}: ${err.message}`);
406
443
  continue;
@@ -540,6 +577,12 @@ try {
540
577
  // ── 报告 ────────────────────────────────────────────────────
541
578
 
542
579
  console.log("");
580
+ console.log(`📋 deck: ${DECK_PATH}`);
581
+ console.log(`📁 working_set: ${resolvedWorkingSet}`);
582
+ console.log(`🗄️ cold_pool: ${COLD_POOL}`);
583
+ if (!cliWorkdir && cliDeck && dirname(DECK_PATH) !== process.cwd()) {
584
+ console.log(`💡 working_set 相对于当前目录。若期望跟随 deck 文件位置,使用 --workdir <dir>`);
585
+ }
543
586
  console.log(`✅ Sync complete: ${linkedSkills.length} skill(s) linked (max_cards: ${MAX_CARDS})`);
544
587
  console.log(` lock: ${LOCK_PATH}`);
545
588
  if (dirOverlaps.length > 0) {
package/src/refresh.ts CHANGED
@@ -66,7 +66,7 @@ export function refreshDeck(cliDeckPath?: string, cliWorkdir?: string, target?:
66
66
  linkDeck: () => {
67
67
  console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync refreshed skills to working set.`)
68
68
  console.log('🔗 Running deck link...')
69
- linkDeck(cliDeckPath, cliWorkdir)
69
+ await linkDeck(cliDeckPath, cliWorkdir)
70
70
  },
71
71
  })
72
72