@lythos/skill-deck 0.9.28 → 0.9.29

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.29 <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.29 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.29 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.29 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.29 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.29 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.29 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.29 prune` |
65
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.29 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.29 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.29 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.29",
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
+ 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
+ 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,7 +125,8 @@ 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 function linkDeck(cliDeckPath?: string, cliWorkdir?: string, opts?: { noBackup?: boolean; mode?: 'symlink' | 'snapshot' }): void {
129
+ const MODE = opts?.mode ?? 'symlink'
129
130
  const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
130
131
  const DECK_PATH = cliDeck
131
132
  ? resolve(cliDeck)
@@ -144,7 +145,14 @@ if (!existsSync(DECK_PATH)) {
144
145
  process.exit(1);
145
146
  }
146
147
 
147
- const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
148
+ // --workdir always wins. --deck (explicit) defaults to cwd: user expects
149
+ // "work here" when pointing to a deck outside the current directory.
150
+ // Default (no flags): deck file's directory (99% of cases = project root).
151
+ const PROJECT_DIR = cliWorkdir
152
+ ? resolve(cliWorkdir)
153
+ : cliDeck
154
+ ? process.cwd()
155
+ : dirname(DECK_PATH);
148
156
  const deckRaw = readFileSync(DECK_PATH, "utf-8");
149
157
  const deckHash = hashContent(deckRaw);
150
158
 
@@ -333,14 +341,14 @@ if (nonSymlinks.length > 0) {
333
341
  totalSize += calculateDirSize(join(WORKING_SET, e));
334
342
  }
335
343
 
336
- if (!noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
344
+ if (!opts?.noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
337
345
  console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, WORKING_SET)} (> 100MB total).`);
338
346
  console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
339
347
  console.error(` Use --no-backup to skip backup (removes without saving), or clean up manually.`);
340
348
  process.exit(1);
341
349
  }
342
350
 
343
- if (!noBackup) {
351
+ if (!opts?.noBackup) {
344
352
  const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
345
353
  const bakPath = join(PROJECT_DIR, ".claude", bakName);
346
354
  mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
@@ -400,7 +408,11 @@ for (const item of declared) {
400
408
 
401
409
  try {
402
410
  mkdirSync(dirname(dest), { recursive: true });
403
- symlinkSync(item.sourcePath, dest);
411
+ if (MODE === 'snapshot') {
412
+ cpSync(item.sourcePath, dest, { recursive: true });
413
+ } else {
414
+ symlinkSync(item.sourcePath, dest);
415
+ }
404
416
  } catch (err: any) {
405
417
  console.error(`❌ Link failed: ${item.alias}: ${err.message}`);
406
418
  continue;
@@ -540,6 +552,12 @@ try {
540
552
  // ── 报告 ────────────────────────────────────────────────────
541
553
 
542
554
  console.log("");
555
+ console.log(`📋 deck: ${DECK_PATH}`);
556
+ console.log(`📁 working_set: ${resolvedWorkingSet}`);
557
+ console.log(`🗄️ cold_pool: ${COLD_POOL}`);
558
+ if (!cliWorkdir && cliDeck && dirname(DECK_PATH) !== process.cwd()) {
559
+ console.log(`💡 working_set 相对于当前目录。若期望跟随 deck 文件位置,使用 --workdir <dir>`);
560
+ }
543
561
  console.log(`✅ Sync complete: ${linkedSkills.length} skill(s) linked (max_cards: ${MAX_CARDS})`);
544
562
  console.log(` lock: ${LOCK_PATH}`);
545
563
  if (dirOverlaps.length > 0) {