@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 +23 -14
- package/package.json +1 -1
- package/src/cli.ts +45 -1
- package/src/link.ts +12 -24
- package/src/prune-plan.test.ts +2 -2
- package/src/reconcile.ts +115 -0
- package/src/refresh-plan.test.ts +2 -2
- package/src/resolve-deck.ts +55 -0
- package/src/schema.ts +2 -0
- package/src/sync-freeze.test.ts +160 -0
- package/src/sync-freeze.ts +174 -0
- package/src/validate.ts +12 -3
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.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.
|
|
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.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 (`/`).
|
|
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.
|
|
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** |
|
|
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.
|
|
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
|
|
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
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
|
|
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
|
-
//
|
|
133
|
-
let DECK_PATH: string
|
|
134
|
-
if (cliDeck && (cliDeck
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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 } : {}),
|
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', () => {
|
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|