@lythos/skill-deck 0.9.31 → 0.9.33

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.31 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.33 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -55,14 +55,17 @@ 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.31 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.31 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.31 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.31 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.31 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.31 remove tdd` |
64
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.31 prune` |
65
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.31 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.33 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.33 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.33 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.33 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.33 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.33 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.33 prune` |
65
+ | Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.33 sync tdd` |
66
+ | Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.33 freeze tdd` |
67
+ | Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.33 reconcile` |
68
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.33 link --deck ./my-deck.toml --workdir /path/to/project` |
66
69
 
67
70
  ### Commands
68
71
 
@@ -74,6 +77,9 @@ prompt = "Search for latest info, then generate professional document with diagr
74
77
  | `refresh` | `[<fq|alias>] [--deck <path>]` | Pull latest versions of declared skills from upstream git repos. Pass a name to refresh one skill. |
75
78
  | `remove` | `<fq|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
76
79
  | `prune` | `[--yes] [--deck <path>]` | GC cold pool repos no longer referenced. Interactive confirm (skip with `--yes`). |
80
+ | `sync` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch skill from snapshot (real dir) to sync (symlink) — live mode. |
81
+ | `freeze` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch skill from sync (symlink) to snapshot (real dir) — pin current HEAD. |
82
+ | `reconcile` | `[--apply] [--deck <path>] [--workdir <dir>]` | Compare lock vs cold pool, report drift (missing/behind/extra). Plan-first. |
77
83
 
78
84
  ### Options
79
85
 
@@ -84,10 +90,13 @@ prompt = "Search for latest info, then generate professional document with diagr
84
90
 
85
91
  | `--alias <alias>` | Explicit alias for the skill (default: basename of path) | — |
86
92
  | `--type <type>` | Target section for `add`: `innate`, `tool`, or `transient` | `tool` |
93
+ | `--mode <mode>` | Link mode for `add`/`link`: `symlink` (default) or `snapshot` (copy, for Codex compat) | `symlink` |
87
94
 
88
95
  ### Safety guards
89
96
 
90
- `link` refuses to operate if `working_set` resolves to your home directory or root (`/`). It also only removes **symlinks** from the working set — real files or directories are skipped with a warning.
97
+ `link` refuses to operate if `working_set` resolves to your home directory or root (`/`).
98
+
99
+ **Snapshot mode** (`--mode snapshot` or `link --mode snapshot`): copies the source directory into the working set instead of symlinking. This is needed for agents that don't support symlinks (e.g. Codex #11314). Snapshots are pinned to the cold pool version at link time. Use `deck sync <alias>` to switch back to live symlink mode, or `deck freeze <alias>` to pin a symlink as a snapshot.
91
100
 
92
101
  ### Exit codes
93
102
 
@@ -119,7 +128,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
119
128
  EOF
120
129
 
121
130
  # 2. Link — creates symlinks in .claude/skills/
122
- bunx @lythos/skill-deck@0.9.31 link
131
+ bunx @lythos/skill-deck@0.9.33 link
123
132
  ```
124
133
 
125
134
  ### Key Concepts
@@ -129,7 +138,7 @@ bunx @lythos/skill-deck@0.9.31 link
129
138
  | **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
130
139
  | **skill-deck.toml** | Declares desired state: "this project uses these skills." |
131
140
  | **`deck link`** | Reconciler. Makes the working set match the declaration. |
132
- | **Working Set** | Symlinks only. Default: `.claude/skills/` — where agents scan for skills. |
141
+ | **Working Set** | Per-skill mode: symlink (live) or snapshot (pinned copy). Default: `.claude/skills/`. |
133
142
  | **deny-by-default** | Undeclared skills are physically absent from the working set. |
134
143
 
135
144
  ### Agent skill scan locations
@@ -148,7 +157,7 @@ Different agents look for skills in different directories. `skill-deck.toml` con
148
157
 
149
158
  | Symptom | Cause | Fix |
150
159
  |---------|-------|-----|
151
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.31 add github.com/owner/repo/skill` or clone manually into cold pool |
160
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.33 add github.com/owner/repo/skill` or clone manually into cold pool |
152
161
  | `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
162
  | `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
163
  | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
@@ -213,7 +222,7 @@ If you already have skills installed (in working set, globally, or mixed), deck
213
222
  3. BACKUP Always. `link` creates tar backups for non-symlink entries before removal.
214
223
  Use `--no-backup` only if you're certain.
215
224
 
216
- 4. EXECUTE deck link — creates symlinks, removes undeclared, leaves real files untouched.
225
+ 4. EXECUTE deck link — creates symlinks (default) or snapshots (--mode snapshot), removes undeclared entries.
217
226
 
218
227
  5. VERIFY Agent checks: all declared skills resolve? Working set clean?
219
228
  If unhappy: tar xf backup → rollback to pre-migration state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/cli.ts CHANGED
@@ -7,6 +7,9 @@ import { updateDeck } from './update.js'
7
7
  import { migrateSchema } from './migrate-schema.js'
8
8
  import { removeSkill } from './remove.js'
9
9
  import { pruneDeck } from './prune.js'
10
+ import { syncSkill, freezeSkill } from './sync-freeze.js'
11
+ import { reconcileDeck } from './reconcile.js'
12
+ import { resolveDeckPathSync, fetchDeckUrl, isUrl } from './resolve-deck.js'
10
13
  import { formatHelp } from './help.js'
11
14
 
12
15
  const args = process.argv.slice(2)
@@ -20,7 +23,22 @@ function flagValue(name: string): string | undefined {
20
23
  return idx >= 0 ? args[idx + 1] : undefined
21
24
  }
22
25
 
23
- const deckPath = flagValue('--deck')
26
+ const cliDeck = flagValue('--deck')
27
+ // URL deck: fetch first at the CLI dispatch layer, then pass local path to commands.
28
+ // Commands stay sync; URL I/O is handled once here.
29
+ let deckPath: string | undefined
30
+ if (cliDeck && isUrl(cliDeck)) {
31
+ try {
32
+ deckPath = await fetchDeckUrl(cliDeck)
33
+ } catch (e: any) {
34
+ console.error(`❌ ${e.message}`)
35
+ process.exit(1)
36
+ }
37
+ } else if (cliDeck) {
38
+ deckPath = resolveDeckPathSync(cliDeck).path
39
+ } else {
40
+ deckPath = undefined
41
+ }
24
42
  const workdir = flagValue('--workdir')
25
43
  const alias = flagValue('--alias')
26
44
  const type = flagValue('--type')
@@ -41,6 +59,9 @@ const HELP_CONFIG = {
41
59
  { name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
42
60
  { name: 'remove', description: 'Remove a skill from deck.toml and working set', args: '<fq|alias>' },
43
61
  { name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
62
+ { name: 'sync', description: 'Switch skill from snapshot (cp) to sync (symlink)', args: '<alias>' },
63
+ { name: 'freeze', description: 'Switch skill from sync (symlink) to snapshot (cp), pinning current HEAD', args: '<alias>' },
64
+ { name: 'reconcile', description: 'Compare lock file (desired) vs cold pool (actual), report drift', args: '[--apply]' },
44
65
  { name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
45
66
  ],
46
67
  options: [
@@ -100,6 +121,29 @@ switch (command) {
100
121
  removeSkill(removeTarget, deckPath, workdir)
101
122
  break
102
123
  }
124
+ case 'sync': {
125
+ const syncTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
126
+ if (!syncTarget) {
127
+ console.error('❌ Missing target. Usage: deck sync <alias>')
128
+ process.exit(1)
129
+ }
130
+ syncSkill(syncTarget, deckPath, workdir)
131
+ break
132
+ }
133
+ case 'freeze': {
134
+ const freezeTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
135
+ if (!freezeTarget) {
136
+ console.error('❌ Missing target. Usage: deck freeze <alias>')
137
+ process.exit(1)
138
+ }
139
+ freezeSkill(freezeTarget, deckPath, workdir)
140
+ break
141
+ }
142
+ case 'reconcile': {
143
+ const apply = args.includes('--apply')
144
+ reconcileDeck(deckPath, workdir, apply)
145
+ break
146
+ }
103
147
  case 'prune': {
104
148
  await pruneDeck(deckPath, workdir, yes)
105
149
  break
package/src/link.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type SkillDeckLock, type LinkedSkill, type ConstraintReport,
24
24
  } from "./schema.js";
25
25
  import { parseDeck } from "./parse-deck.js";
26
+ import { resolveDeckPathSync, fetchDeckUrl, isUrl } from "./resolve-deck.js";
26
27
 
27
28
  // ── 路径工具 ────────────────────────────────────────────────
28
29
 
@@ -129,33 +130,19 @@ export async function linkDeck(cliDeckPath?: string, cliWorkdir?: string, opts?:
129
130
  const MODE = opts?.mode ?? 'symlink'
130
131
  const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
131
132
 
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}`)
133
+ // URL deck: fetch first, then proceed as local
134
+ let DECK_PATH: string
135
+ if (cliDeck && isUrl(cliDeck)) {
136
+ try {
137
+ DECK_PATH = await fetchDeckUrl(cliDeck)
138
+ } catch (e: any) {
139
+ console.error(`❌ ${e.message}`)
145
140
  process.exit(1)
146
141
  }
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)
142
+ } else {
143
+ DECK_PATH = resolveDeckPathSync(cliDeck).path
153
144
  }
154
- } else {
155
- DECK_PATH = cliDeck
156
- ? resolve(cliDeck)
157
- : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
158
- }
145
+
159
146
 
160
147
  if (!existsSync(DECK_PATH)) {
161
148
  console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
@@ -469,6 +456,7 @@ for (const item of declared) {
469
456
  type: item.type,
470
457
  source: sourceRel,
471
458
  dest: relative(PROJECT_DIR, dest),
459
+ mode: MODE,
472
460
  content_hash: contentHash,
473
461
  linked_at: new Date().toISOString(),
474
462
  ...(item.expires ? { expires: item.expires } : {}),
@@ -10,7 +10,7 @@ cold_pool = "./cold-pool"
10
10
  path = "github.com/foo/bar/skill-a"
11
11
 
12
12
  [tool.skills.skill-b]
13
- path = "localhost/skill-b"
13
+ path = "localhost/me/skill-b"
14
14
  `
15
15
 
16
16
  describe('resolvePruneConfig', () => {
@@ -82,7 +82,7 @@ describe('buildPrunePlan', () => {
82
82
  test('empty candidates when all repos declared', () => {
83
83
  const pool = join('/tmp', 'prune-all-declared-' + Date.now())
84
84
  mkdirSync(join(pool, 'github.com', 'foo', 'bar', 'skill-a'), { recursive: true })
85
- mkdirSync(join(pool, 'localhost', 'skill-b'), { recursive: true })
85
+ mkdirSync(join(pool, 'localhost', 'me', 'skill-b'), { recursive: true })
86
86
 
87
87
  const plan = buildPrunePlan(deckToml, { coldPool: pool })
88
88
  expect(plan.candidates).toHaveLength(0)
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck reconcile — k8s-style desired vs actual convergence.
4
+ *
5
+ * Per ADR-20260507021957847: reads skill-deck.lock (desired state),
6
+ * compares against cold pool filesystem (actual state), reports diff.
7
+ * By default plan-first (report only); --apply executes convergence.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'node:fs'
11
+ import { resolve, dirname, join } from 'node:path'
12
+ import { findDeckToml, expandHome } from './link.js'
13
+ import { parse as parseToml } from '@iarna/toml'
14
+ import { ColdPool, buildReconcilePlan, type ReconcileDesiredState } from '@lythos/cold-pool'
15
+ import { SkillDeckLockSchema } from './schema.js'
16
+
17
+ export function reconcileDeck(cliDeckPath?: string, cliWorkdir?: string, apply?: boolean): void {
18
+ const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === '--deck')
19
+ const DECK_PATH = cliDeck
20
+ ? resolve(cliDeck)
21
+ : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
22
+
23
+ if (!existsSync(DECK_PATH)) {
24
+ console.error(`❌ skill-deck.toml not found`)
25
+ process.exit(1)
26
+ }
27
+
28
+ const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd()
29
+ const LOCK_PATH = resolve(PROJECT_DIR, 'skill-deck.lock')
30
+
31
+ if (!existsSync(LOCK_PATH)) {
32
+ console.error(`❌ No lock file found. Run 'deck link' first.`)
33
+ process.exit(1)
34
+ }
35
+
36
+ // Read lock
37
+ let lock: any
38
+ try {
39
+ lock = JSON.parse(readFileSync(LOCK_PATH, 'utf-8'))
40
+ } catch {
41
+ console.error(`❌ Failed to parse lock file: ${LOCK_PATH}`)
42
+ process.exit(1)
43
+ }
44
+
45
+ const parsed = SkillDeckLockSchema.safeParse(lock)
46
+ if (!parsed.success) {
47
+ console.error(`❌ Lock file schema mismatch. Run 'deck link' to regenerate.`)
48
+ process.exit(1)
49
+ }
50
+
51
+ const lockData = parsed.data
52
+ const coldPoolRaw = lockData.cold_pool || '~/.agents/skill-repos'
53
+ const COLD_POOL = expandHome(coldPoolRaw, PROJECT_DIR)
54
+
55
+ // Build desired state from lock
56
+ const desired: ReconcileDesiredState = {
57
+ deckPath: DECK_PATH,
58
+ skills: lockData.skills.map(s => ({
59
+ locator: s.source, // source is relative to cold pool, in FQ format
60
+ alias: s.alias,
61
+ })),
62
+ }
63
+
64
+ // Run reconcile plan
65
+ const pool = new ColdPool(COLD_POOL)
66
+ const plan = buildReconcilePlan(pool, desired)
67
+
68
+ // Report
69
+ console.log(`\n📊 Reconcile Report`)
70
+ console.log(` Deck: ${lockData.deck_source.path}`)
71
+ console.log(` Skills declared: ${lockData.skills.length}`)
72
+ console.log(` Cold pool: ${COLD_POOL}`)
73
+
74
+ if (plan.missing.length === 0 && plan.behind.length === 0 && plan.extra.length === 0) {
75
+ console.log(`\n✅ No drift detected — cold pool matches desired state.`)
76
+ pool.metadata.close()
77
+ return
78
+ }
79
+
80
+ console.log(`\n🔍 Drift detected:`)
81
+ console.log(` ❌ Missing: ${plan.missing.length}`)
82
+ console.log(` ⚠️ Behind: ${plan.behind.length}`)
83
+ console.log(` 📦 Extra: ${plan.extra.length}`)
84
+
85
+ for (const entry of plan.missing) {
86
+ console.log(`\n ❌ Missing: ${entry.host}/${entry.owner}/${entry.repo}`)
87
+ console.log(` Reason: ${entry.reason}`)
88
+ console.log(` Skills: ${entry.aliases.join(', ')}`)
89
+ }
90
+
91
+ for (const entry of plan.behind) {
92
+ console.log(`\n ⚠️ Behind: ${entry.host}/${entry.owner}/${entry.repo}`)
93
+ console.log(` ${entry.reason}`)
94
+ console.log(` Skills: ${entry.aliases.join(', ')}`)
95
+ }
96
+
97
+ for (const entry of plan.extra) {
98
+ console.log(`\n 📦 Extra: ${entry.host}/${entry.owner}/${entry.repo}`)
99
+ console.log(` Reason: ${entry.reason}`)
100
+ }
101
+
102
+ if (apply) {
103
+ console.log(`\n🏗️ --apply: convergence not yet implemented.`)
104
+ console.log(` For missing: use 'deck add <locator>'`)
105
+ console.log(` For behind: use 'deck refresh'`)
106
+ console.log(` For extra: use 'cold-pool prune'`)
107
+ } else {
108
+ console.log(`\n💡 Plan-first. Use --apply to converge, or handle individually:`)
109
+ console.log(` deck add <locator> → restore missing`)
110
+ console.log(` deck refresh → update behind`)
111
+ console.log(` cold-pool prune → GC extras`)
112
+ }
113
+
114
+ pool.metadata.close()
115
+ }
@@ -11,7 +11,7 @@ cold_pool = "./cold-pool"
11
11
  path = "github.com/foo/bar/skill-a"
12
12
 
13
13
  [tool.skills.skill-b]
14
- path = "localhost/skill-b"
14
+ path = "localhost/me/skill-b"
15
15
  `
16
16
 
17
17
  describe('resolveRefreshConfig', () => {
@@ -111,7 +111,7 @@ describe('buildRefreshPlan', () => {
111
111
  // Without a real cold pool, source resolution may fail → 'missing'
112
112
  // Plan structure is what matters; type depends on actual filesystem
113
113
  expect(localhost).toBeDefined()
114
- expect(localhost!.path).toBe('localhost/skill-b')
114
+ expect(localhost!.path).toBe('localhost/me/skill-b')
115
115
  })
116
116
 
117
117
  test('derives coldPool from deck toml when not in opts', () => {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * resolveDeckPath — shared deck URL/path resolution.
3
+ *
4
+ * Per ADR-20260508075301691: --deck accepts http/https URL.
5
+ * Extracted from link.ts so all commands (validate, add, refresh, etc.)
6
+ * inherit URL support without duplicating fetch logic.
7
+ *
8
+ * T1 of EPIC-20260508082810062 (Everything-from-URL).
9
+ */
10
+
11
+ import { existsSync, writeFileSync } from 'node:fs'
12
+ import { resolve } from 'node:path'
13
+ import { findDeckToml } from './link.js'
14
+
15
+ export interface ResolvedDeck {
16
+ path: string
17
+ source: 'url' | 'local' | 'default'
18
+ }
19
+
20
+ export function isUrl(s: string): boolean {
21
+ return s.startsWith('http://') || s.startsWith('https://')
22
+ }
23
+
24
+ function normalizeUrl(url: string): string {
25
+ try {
26
+ const u = new URL(url)
27
+ if (u.hostname === 'github.com' && u.pathname.includes('/blob/')) {
28
+ return `https://raw.githubusercontent.com${u.pathname.replace('/blob/', '/')}`
29
+ }
30
+ } catch {}
31
+ return url
32
+ }
33
+
34
+ /** Sync: resolve local path or default. URL case handled separately. */
35
+ export function resolveDeckPathSync(cliArg?: string): ResolvedDeck {
36
+ if (cliArg) {
37
+ return { path: resolve(cliArg), source: 'local' }
38
+ }
39
+ const found = findDeckToml(process.cwd()) || resolve('skill-deck.toml')
40
+ return { path: found, source: 'default' }
41
+ }
42
+
43
+ /** Async: fetch URL deck, save to cwd, return local path. */
44
+ export async function fetchDeckUrl(url: string): Promise<string> {
45
+ const normalized = normalizeUrl(url)
46
+ const dest = resolve(process.cwd(), 'skill-deck.toml')
47
+ console.log(`📥 Fetching deck: ${normalized}`)
48
+ const res = await fetch(normalized, { signal: AbortSignal.timeout(30_000) })
49
+ if (!res.ok) {
50
+ throw new Error(`Failed to fetch deck (HTTP ${res.status}): ${normalized}`)
51
+ }
52
+ writeFileSync(dest, await res.text())
53
+ console.log(` → saved to ${dest}`)
54
+ return dest
55
+ }
package/src/schema.ts CHANGED
@@ -8,6 +8,8 @@ export const LinkedSkillSchema = z.object({
8
8
  type: z.enum(["innate", "tool", "combo", "transient"]),
9
9
  source: z.string(),
10
10
  dest: z.string(),
11
+ /** Link mode: symlink (live, follows cold pool) or snapshot (pinned copy). */
12
+ mode: z.enum(["symlink", "snapshot"]).default("symlink"),
11
13
  content_hash: z.string().optional(),
12
14
  linked_at: z.string().datetime(),
13
15
  expires: z.string().optional(),
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { syncSkill, freezeSkill } from './sync-freeze'
6
+ import { cpSync, lstatSync } from 'node:fs'
7
+
8
+ // Build a minimal project with a cold pool, deck.toml, working set, and lock
9
+ function setupProject(opts: { mode: 'snapshot' | 'symlink' }) {
10
+ const project = mkdtempSync(join(tmpdir(), 'sync-freeze-test-'))
11
+ const coldPool = join(project, 'cold-pool')
12
+ const workingSet = join(project, '.claude', 'skills')
13
+
14
+ // Cold pool: create a fake skill repo
15
+ const skillDir = join(coldPool, 'github.com', 'test-org', 'test-skill')
16
+ mkdirSync(skillDir, { recursive: true })
17
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: test\n---\n\n# Test Skill')
18
+
19
+ // Working set
20
+ mkdirSync(workingSet, { recursive: true })
21
+
22
+ // Create the target in working set per mode
23
+ const dest = join(workingSet, 'test-skill')
24
+ if (opts.mode === 'snapshot') {
25
+ // cp from cold pool
26
+ cpSync(skillDir, dest, { recursive: true })
27
+ } else {
28
+ symlinkSync(skillDir, dest)
29
+ }
30
+
31
+ // deck.toml
32
+ const deckToml = `
33
+ [deck]
34
+ max_cards = 10
35
+ cold_pool = "cold-pool"
36
+ working_set = ".claude/skills"
37
+
38
+ [tool.skills.test-skill]
39
+ path = "github.com/test-org/test-skill"
40
+ `
41
+ writeFileSync(join(project, 'skill-deck.toml'), deckToml)
42
+
43
+ // lock file
44
+ const lock = {
45
+ version: '1.0.0' as const,
46
+ generated_at: new Date().toISOString(),
47
+ deck_source: { path: 'skill-deck.toml', content_hash: 'abc' },
48
+ working_set: '.claude/skills',
49
+ cold_pool: 'cold-pool',
50
+ skills: [
51
+ {
52
+ name: 'github.com/test-org/test-skill',
53
+ alias: 'test-skill',
54
+ deck_niche: '',
55
+ type: 'tool' as const,
56
+ source: 'github.com/test-org/test-skill',
57
+ dest: '.claude/skills/test-skill',
58
+ linked_at: new Date().toISOString(),
59
+ deck_managed_dirs: [],
60
+ },
61
+ ],
62
+ constraints: { total_cards: 1, max_cards: 10, within_budget: true, transient_warnings: [], dir_overlaps: [] },
63
+ }
64
+ writeFileSync(join(project, 'skill-deck.lock'), JSON.stringify(lock, null, 2))
65
+
66
+ const deckPath = join(project, 'skill-deck.toml')
67
+ return { project, coldPool, workingSet, skillDir, deckPath, dest }
68
+ }
69
+
70
+ describe('syncSkill — snapshot → symlink', () => {
71
+ test('switches real dir to symlink', () => {
72
+ const { deckPath, dest, project } = setupProject({ mode: 'snapshot' })
73
+ const originalCwd = process.cwd
74
+ const exit = process.exit
75
+
76
+ try {
77
+ process.cwd = () => project
78
+ process.exit = (() => { throw new Error('exit') }) as any
79
+ syncSkill('test-skill', deckPath, project)
80
+ } catch (e: any) {
81
+ if (e.message !== 'exit') throw e
82
+ } finally {
83
+ process.cwd = originalCwd
84
+ process.exit = exit
85
+ }
86
+
87
+ const st = lstatSync(dest)
88
+ expect(st.isSymbolicLink()).toBe(true)
89
+ rmSync(project, { recursive: true, force: true })
90
+ })
91
+
92
+ test('no-op when already symlink', () => {
93
+ const { deckPath, dest, project } = setupProject({ mode: 'symlink' })
94
+ const originalCwd = process.cwd
95
+ const exit = process.exit
96
+
97
+ try {
98
+ process.cwd = () => project
99
+ process.exit = (() => { throw new Error('exit') }) as any
100
+ syncSkill('test-skill', deckPath, project)
101
+ } catch (e: any) {
102
+ if (e.message !== 'exit') throw e
103
+ } finally {
104
+ process.cwd = originalCwd
105
+ process.exit = exit
106
+ }
107
+
108
+ const st = lstatSync(dest)
109
+ expect(st.isSymbolicLink()).toBe(true)
110
+ rmSync(project, { recursive: true, force: true })
111
+ })
112
+ })
113
+
114
+ describe('freezeSkill — symlink → snapshot', () => {
115
+ test('switches symlink to real dir', () => {
116
+ const { deckPath, dest, project } = setupProject({ mode: 'symlink' })
117
+ const originalCwd = process.cwd
118
+ const exit = process.exit
119
+
120
+ try {
121
+ process.cwd = () => project
122
+ process.exit = (() => { throw new Error('exit') }) as any
123
+ freezeSkill('test-skill', deckPath, project)
124
+ } catch (e: any) {
125
+ if (e.message !== 'exit') throw e
126
+ } finally {
127
+ process.cwd = originalCwd
128
+ process.exit = exit
129
+ }
130
+
131
+ const st = lstatSync(dest)
132
+ expect(st.isSymbolicLink()).toBe(false)
133
+ expect(st.isDirectory()).toBe(true)
134
+ // Should have SKILL.md
135
+ expect(readFileSync(join(dest, 'SKILL.md'), 'utf-8')).toContain('Test Skill')
136
+ rmSync(project, { recursive: true, force: true })
137
+ })
138
+
139
+ test('no-op when already snapshot', () => {
140
+ const { deckPath, dest, project } = setupProject({ mode: 'snapshot' })
141
+ const originalCwd = process.cwd
142
+ const exit = process.exit
143
+
144
+ try {
145
+ process.cwd = () => project
146
+ process.exit = (() => { throw new Error('exit') }) as any
147
+ freezeSkill('test-skill', deckPath, project)
148
+ } catch (e: any) {
149
+ if (e.message !== 'exit') throw e
150
+ } finally {
151
+ process.cwd = originalCwd
152
+ process.exit = exit
153
+ }
154
+
155
+ const st = lstatSync(dest)
156
+ expect(st.isDirectory()).toBe(true)
157
+ expect(st.isSymbolicLink()).toBe(false)
158
+ rmSync(project, { recursive: true, force: true })
159
+ })
160
+ })
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck sync/freeze — snapshot↔symlink intent switching per skill.
4
+ *
5
+ * Per ADR-20260507190157540: snapshot = default safe (cp), sync = live (symlink).
6
+ * These commands switch an individual skill between modes without re-linking all.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, rmSync, symlinkSync, cpSync, lstatSync } from 'node:fs'
10
+ import { resolve, dirname, join, relative } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+ import { findDeckToml, expandHome } from './link.js'
13
+ import { parseDeck } from './parse-deck.js'
14
+ import { ColdPool, parseLocator } from '@lythos/cold-pool'
15
+ import { findSource } from './link.js'
16
+ import { parse as parseToml } from '@iarna/toml'
17
+ import type { SkillDeckLock } from './schema.js'
18
+
19
+ function readLock(projectDir: string): SkillDeckLock | null {
20
+ const lockPath = join(projectDir, 'skill-deck.lock')
21
+ if (!existsSync(lockPath)) return null
22
+ try {
23
+ return JSON.parse(readFileSync(lockPath, 'utf-8'))
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ function writeLock(projectDir: string, lock: SkillDeckLock): void {
30
+ const lockPath = join(projectDir, 'skill-deck.lock')
31
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n')
32
+ }
33
+
34
+ function getProjectAndDeck(cliDeckPath?: string, cliWorkdir?: string) {
35
+ const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === '--deck')
36
+ const DECK_PATH = cliDeck
37
+ ? resolve(cliDeck)
38
+ : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
39
+
40
+ if (!existsSync(DECK_PATH)) {
41
+ console.error(`❌ skill-deck.toml not found in ${process.cwd()}`)
42
+ process.exit(1)
43
+ }
44
+
45
+ const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH)
46
+ const deckRaw = readFileSync(DECK_PATH, 'utf-8')
47
+ const deck = parseToml(deckRaw) as any
48
+ const WORKING_SET = expandHome(deck.deck?.working_set || '.claude/skills', PROJECT_DIR)
49
+ const COLD_POOL = expandHome(deck.deck?.cold_pool || '~/.agents/skill-repos', PROJECT_DIR)
50
+
51
+ return { DECK_PATH, PROJECT_DIR, deckRaw, deck, WORKING_SET, COLD_POOL }
52
+ }
53
+
54
+ /**
55
+ * Switch a skill from snapshot (real dir) to sync (symlink).
56
+ * The skill stays in a real directory if it's already not a symlink.
57
+ */
58
+ export function syncSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
59
+ const { DECK_PATH, PROJECT_DIR, deckRaw, WORKING_SET, COLD_POOL } = getProjectAndDeck(cliDeckPath, cliWorkdir)
60
+
61
+ const { entries: parsedEntries } = parseDeck(deckRaw)
62
+ const match = parsedEntries.find(e => e.alias === target || e.path === target)
63
+ if (!match) {
64
+ console.error(`❌ Skill not found in deck: ${target}`)
65
+ process.exit(1)
66
+ }
67
+
68
+ const dest = join(WORKING_SET, match.alias)
69
+ const source = findSource(match.path, COLD_POOL, PROJECT_DIR)
70
+
71
+ if (!source.path) {
72
+ console.error(`❌ Source not found in cold pool: ${match.path}`)
73
+ process.exit(1)
74
+ }
75
+
76
+ // Check current mode
77
+ let currentMode: 'snapshot' | 'symlink' | 'missing' = 'missing'
78
+ try {
79
+ const st = lstatSync(dest)
80
+ currentMode = st.isSymbolicLink() ? 'symlink' : 'snapshot'
81
+ } catch {}
82
+
83
+ if (currentMode === 'symlink') {
84
+ console.log(`⏭️ ${match.alias} is already in sync mode (symlink)`)
85
+ return
86
+ }
87
+
88
+ if (currentMode === 'missing') {
89
+ console.error(`❌ ${match.alias} not found in working set. Run 'deck link' first.`)
90
+ process.exit(1)
91
+ }
92
+
93
+ // Remove snapshot, create symlink
94
+ rmSync(dest, { recursive: true, force: true })
95
+ symlinkSync(source.path, dest)
96
+ console.log(`🔄 ${match.alias}: snapshot → sync (symlink to ${relative(PROJECT_DIR, source.path)})`)
97
+
98
+ // Update lock
99
+ const lock = readLock(PROJECT_DIR)
100
+ if (lock) {
101
+ const skill = lock.skills.find(s => s.alias === match.alias)
102
+ if (skill) {
103
+ skill.linked_at = new Date().toISOString()
104
+ skill.mode = 'symlink'
105
+ }
106
+ writeLock(PROJECT_DIR, lock)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Switch a skill from sync (symlink) to snapshot (real dir, pin current HEAD).
112
+ */
113
+ export function freezeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
114
+ const { DECK_PATH, PROJECT_DIR, deckRaw, WORKING_SET, COLD_POOL } = getProjectAndDeck(cliDeckPath, cliWorkdir)
115
+
116
+ const { entries: parsedEntries } = parseDeck(deckRaw)
117
+ const match = parsedEntries.find(e => e.alias === target || e.path === target)
118
+ if (!match) {
119
+ console.error(`❌ Skill not found in deck: ${target}`)
120
+ process.exit(1)
121
+ }
122
+
123
+ const dest = join(WORKING_SET, match.alias)
124
+ const source = findSource(match.path, COLD_POOL, PROJECT_DIR)
125
+
126
+ if (!source.path) {
127
+ console.error(`❌ Source not found in cold pool: ${match.path}`)
128
+ process.exit(1)
129
+ }
130
+
131
+ // Check current mode
132
+ let currentMode: 'snapshot' | 'symlink' | 'missing' = 'missing'
133
+ try {
134
+ const st = lstatSync(dest)
135
+ currentMode = st.isSymbolicLink() ? 'symlink' : 'snapshot'
136
+ } catch {}
137
+
138
+ if (currentMode === 'snapshot') {
139
+ console.log(`⏭️ ${match.alias} is already in snapshot mode (real directory)`)
140
+ return
141
+ }
142
+
143
+ if (currentMode === 'missing') {
144
+ console.error(`❌ ${match.alias} not found in working set. Run 'deck link' first.`)
145
+ process.exit(1)
146
+ }
147
+
148
+ // Remove symlink, cp snapshot
149
+ rmSync(dest, { recursive: true, force: true })
150
+ cpSync(source.path, dest, { recursive: true })
151
+ console.log(`🧊 ${match.alias}: sync → snapshot (pinned copy from ${relative(PROJECT_DIR, source.path)})`)
152
+
153
+ // Record HEAD in metadata
154
+ try {
155
+ const loc = parseLocator(match.path)
156
+ if (loc && !loc.isLocalhost) {
157
+ const pool = new ColdPool(COLD_POOL)
158
+ // Best-effort: record a note that this is now frozen
159
+ // The actual HEAD recording happens via git-hash async, but we note the intent
160
+ console.log(` 📌 Pinned. Run 'deck link' to regenerate lock with updated content_hash.`)
161
+ }
162
+ } catch {}
163
+
164
+ // Update lock
165
+ const lock = readLock(PROJECT_DIR)
166
+ if (lock) {
167
+ const skill = lock.skills.find(s => s.alias === match.alias)
168
+ if (skill) {
169
+ skill.linked_at = new Date().toISOString()
170
+ skill.mode = 'snapshot'
171
+ }
172
+ writeLock(PROJECT_DIR, lock)
173
+ }
174
+ }
package/src/validate.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { parse as parseToml } from "@iarna/toml";
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
+ import { resolveDeckPathSync, fetchDeckUrl, isUrl } from "./resolve-deck.js";
16
17
  import {
17
18
  buildValidationPlan,
18
19
  executeValidationPlan,
@@ -55,9 +56,17 @@ export async function buildDeckValidation(
55
56
  options: ValidateOptions = {},
56
57
  ): Promise<DeckValidationReport> {
57
58
  const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
58
- const DECK_PATH = cliDeckPath
59
- ? resolve(cliDeckPath)
60
- : findDeckToml(PROJECT_DIR) || resolve(PROJECT_DIR, "skill-deck.toml");
59
+ let DECK_PATH: string
60
+ if (cliDeckPath && isUrl(cliDeckPath)) {
61
+ try {
62
+ DECK_PATH = await fetchDeckUrl(cliDeckPath)
63
+ } catch (e: any) {
64
+ return { status: 'invalid', deckPath: cliDeckPath, errors: [`Failed to fetch deck: ${e.message}`], warnings: [], entries: [], skills: [], max_cards: 0, constraints: { total_cards: 0, within_budget: true, transient_warnings: [], dir_overlaps: [] } }
65
+ }
66
+ } else {
67
+ const resolved = resolveDeckPathSync(cliDeckPath)
68
+ DECK_PATH = resolved.path
69
+ }
61
70
 
62
71
  if (!existsSync(DECK_PATH)) {
63
72
  return {