@lythos/skill-deck 0.9.31 → 0.9.32
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 +20 -11
- package/package.json +1 -1
- package/src/cli.ts +28 -0
- package/src/link.ts +1 -0
- package/src/prune-plan.test.ts +2 -2
- package/src/reconcile.ts +115 -0
- package/src/refresh-plan.test.ts +2 -2
- package/src/schema.ts +2 -0
- package/src/sync-freeze.test.ts +160 -0
- package/src/sync-freeze.ts +174 -0
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.
|
|
12
|
+
bunx @lythos/skill-deck@0.9.32 <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.
|
|
59
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.
|
|
60
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.
|
|
61
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.
|
|
62
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.
|
|
63
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.
|
|
64
|
-
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.
|
|
65
|
-
|
|
|
58
|
+
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.32 link` |
|
|
59
|
+
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.32 validate` |
|
|
60
|
+
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.32 add owner/repo` |
|
|
61
|
+
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.32 refresh` |
|
|
62
|
+
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.32 refresh tdd` |
|
|
63
|
+
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.32 remove tdd` |
|
|
64
|
+
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.32 prune` |
|
|
65
|
+
| Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.32 sync tdd` |
|
|
66
|
+
| Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.32 freeze tdd` |
|
|
67
|
+
| Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.32 reconcile` |
|
|
68
|
+
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.32 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,11 +90,14 @@ 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
97
|
`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.
|
|
91
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.
|
|
100
|
+
|
|
92
101
|
### Exit codes
|
|
93
102
|
|
|
94
103
|
| Code | Meaning |
|
|
@@ -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.
|
|
131
|
+
bunx @lythos/skill-deck@0.9.32 link
|
|
123
132
|
```
|
|
124
133
|
|
|
125
134
|
### Key Concepts
|
|
@@ -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.
|
|
160
|
+
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.32 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 |
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,8 @@ 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'
|
|
10
12
|
import { formatHelp } from './help.js'
|
|
11
13
|
|
|
12
14
|
const args = process.argv.slice(2)
|
|
@@ -41,6 +43,9 @@ const HELP_CONFIG = {
|
|
|
41
43
|
{ name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
|
|
42
44
|
{ name: 'remove', description: 'Remove a skill from deck.toml and working set', args: '<fq|alias>' },
|
|
43
45
|
{ name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
|
|
46
|
+
{ name: 'sync', description: 'Switch skill from snapshot (cp) to sync (symlink)', args: '<alias>' },
|
|
47
|
+
{ name: 'freeze', description: 'Switch skill from sync (symlink) to snapshot (cp), pinning current HEAD', args: '<alias>' },
|
|
48
|
+
{ name: 'reconcile', description: 'Compare lock file (desired) vs cold pool (actual), report drift', args: '[--apply]' },
|
|
44
49
|
{ name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
|
|
45
50
|
],
|
|
46
51
|
options: [
|
|
@@ -100,6 +105,29 @@ switch (command) {
|
|
|
100
105
|
removeSkill(removeTarget, deckPath, workdir)
|
|
101
106
|
break
|
|
102
107
|
}
|
|
108
|
+
case 'sync': {
|
|
109
|
+
const syncTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
110
|
+
if (!syncTarget) {
|
|
111
|
+
console.error('❌ Missing target. Usage: deck sync <alias>')
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
syncSkill(syncTarget, deckPath, workdir)
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
case 'freeze': {
|
|
118
|
+
const freezeTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
119
|
+
if (!freezeTarget) {
|
|
120
|
+
console.error('❌ Missing target. Usage: deck freeze <alias>')
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
freezeSkill(freezeTarget, deckPath, workdir)
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
case 'reconcile': {
|
|
127
|
+
const apply = args.includes('--apply')
|
|
128
|
+
reconcileDeck(deckPath, workdir, apply)
|
|
129
|
+
break
|
|
130
|
+
}
|
|
103
131
|
case 'prune': {
|
|
104
132
|
await pruneDeck(deckPath, workdir, yes)
|
|
105
133
|
break
|
package/src/link.ts
CHANGED
|
@@ -469,6 +469,7 @@ for (const item of declared) {
|
|
|
469
469
|
type: item.type,
|
|
470
470
|
source: sourceRel,
|
|
471
471
|
dest: relative(PROJECT_DIR, dest),
|
|
472
|
+
mode: MODE,
|
|
472
473
|
content_hash: contentHash,
|
|
473
474
|
linked_at: new Date().toISOString(),
|
|
474
475
|
...(item.expires ? { expires: item.expires } : {}),
|
package/src/prune-plan.test.ts
CHANGED
|
@@ -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)
|
package/src/reconcile.ts
ADDED
|
@@ -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
|
+
}
|
package/src/refresh-plan.test.ts
CHANGED
|
@@ -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', () => {
|
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
|
+
}
|