@lythos/skill-deck 0.9.22 → 0.9.24

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.22 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.24 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -55,14 +55,14 @@ prompt = "Search for latest info, then generate professional document with diagr
55
55
 
56
56
  | Situation | Command |
57
57
  |-----------|---------|
58
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.22 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.22 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.22 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.22 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.22 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.22 remove tdd` |
64
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.22 prune` |
65
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.22 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.24 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.24 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.24 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.24 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.24 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.24 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.24 prune` |
65
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.24 link --deck ./my-deck.toml --workdir /path/to/project` |
66
66
 
67
67
  ### Commands
68
68
 
@@ -119,7 +119,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
119
119
  EOF
120
120
 
121
121
  # 2. Link — creates symlinks in .claude/skills/
122
- bunx @lythos/skill-deck@0.9.22 link
122
+ bunx @lythos/skill-deck@0.9.24 link
123
123
  ```
124
124
 
125
125
  ### Key Concepts
@@ -148,7 +148,7 @@ Different agents look for skills in different directories. `skill-deck.toml` con
148
148
 
149
149
  | Symptom | Cause | Fix |
150
150
  |---------|-------|-----|
151
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.22 add github.com/owner/repo/skill` or clone manually into cold pool |
151
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.24 add github.com/owner/repo/skill` or clone manually into cold pool |
152
152
  | `link` skips entries with warnings | Real files/directories exist in working set (not symlinks) | Delete the real directories in `working_set` and re-run `link`. Never create directories manually there |
153
153
  | `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone with `git clone` or use `deck add` which clones by default |
154
154
  | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -26,6 +26,7 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@iarna/toml": "^2.2.5",
29
+ "@lythos/cold-pool": "workspace:*",
29
30
  "yaml": "^2.8.3",
30
31
  "zod": "^4.3.6"
31
32
  },
package/src/add.test.ts CHANGED
@@ -10,6 +10,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync
10
10
  import { join } from 'node:path'
11
11
  import { tmpdir } from 'node:os'
12
12
  import * as childProcess from 'node:child_process'
13
+ import { findSkillDir } from './add.ts'
13
14
 
14
15
  // Control homedir() return value for tests that need default cold_pool under tmpdir
15
16
  let mockHomeDir = '/tmp'
@@ -193,3 +194,65 @@ describe('addSkill', () => {
193
194
  }
194
195
  })
195
196
  })
197
+
198
+ describe('findSkillDir', () => {
199
+ function makeRepo(): string {
200
+ const dir = mkdtempSync(join(tmpdir(), 'deck-findskill-'))
201
+ cleanup.push(dir)
202
+ return dir
203
+ }
204
+
205
+ function placeSkill(repo: string, relPath: string): string {
206
+ const skillDir = join(repo, relPath)
207
+ mkdirSync(skillDir, { recursive: true })
208
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
209
+ return skillDir
210
+ }
211
+
212
+ it('returns repoPath for standalone skill (SKILL.md at repo root)', () => {
213
+ const repo = makeRepo()
214
+ writeFileSync(join(repo, 'SKILL.md'), '---\nname: standalone\n---\n')
215
+ expect(findSkillDir(repo, null)).toBe(repo)
216
+ })
217
+
218
+ it('returns skills/ subdir when single skill exists there', () => {
219
+ const repo = makeRepo()
220
+ const expected = placeSkill(repo, 'skills/my-skill')
221
+ expect(findSkillDir(repo, null)).toBe(expected)
222
+ })
223
+
224
+ it('returns flat root dir when single skill exists at repo root', () => {
225
+ const repo = makeRepo()
226
+ const expected = placeSkill(repo, 'my-skill')
227
+ expect(findSkillDir(repo, null)).toBe(expected)
228
+ })
229
+
230
+ it('returns null when multiple flat skills exist at repo root (ambiguous)', () => {
231
+ const repo = makeRepo()
232
+ placeSkill(repo, 'skill-a')
233
+ placeSkill(repo, 'skill-b')
234
+ expect(findSkillDir(repo, null)).toBeNull()
235
+ })
236
+
237
+ it('finds skill in skills/ subdir when skill name is provided', () => {
238
+ const repo = makeRepo()
239
+ const expected = placeSkill(repo, 'skills/my-skill')
240
+ expect(findSkillDir(repo, 'my-skill')).toBe(expected)
241
+ })
242
+
243
+ it('finds skill at repo root when skill name is provided (flat)', () => {
244
+ const repo = makeRepo()
245
+ const expected = placeSkill(repo, 'my-skill')
246
+ expect(findSkillDir(repo, 'my-skill')).toBe(expected)
247
+ })
248
+
249
+ it('returns null when skill name provided but not found anywhere', () => {
250
+ const repo = makeRepo()
251
+ expect(findSkillDir(repo, 'nonexistent')).toBeNull()
252
+ })
253
+
254
+ it('returns null when no SKILL.md exists anywhere in repo', () => {
255
+ const repo = makeRepo()
256
+ expect(findSkillDir(repo, null)).toBeNull()
257
+ })
258
+ })
package/src/add.ts CHANGED
@@ -3,53 +3,32 @@
3
3
  * deck-add.ts — Skill acquisition command
4
4
  *
5
5
  * Downloads a skill to the cold pool, updates skill-deck.toml, and links.
6
- * Single backend: git clone. For feed-based discovery with decision tracking,
7
- * use curator add instead.
6
+ * Single backend: git clone (delegated to @lythos/cold-pool's executeFetchPlan).
7
+ * For feed-based discovery with decision tracking, use curator add instead.
8
8
  */
9
9
 
10
- import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
11
- import { mkdtempSync } from 'node:fs'
12
- import { tmpdir, homedir } from 'node:os'
13
- import { join, basename, dirname, resolve } from 'node:path'
14
- import { execFileSync } from 'node:child_process'
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { dirname, join, basename, resolve } from 'node:path'
15
20
  import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
21
+ import {
22
+ ColdPool,
23
+ buildFetchPlan,
24
+ executeFetchPlan,
25
+ parseLocator,
26
+ formatLocator,
27
+ type Locator,
28
+ } from '@lythos/cold-pool'
16
29
  import { findDeckToml, expandHome } from './link.js'
17
- import { parseDeck } from './parse-deck.js'
18
30
 
19
-
20
- interface ParsedLocator {
21
- host: string
22
- owner: string
23
- repo: string
24
- skill: string | null
25
- raw: string
26
- }
27
-
28
- function parseLocator(input: string): ParsedLocator | null {
29
- // Format: host.tld/owner/repo/skill or host.tld/owner/repo
30
- // owner/repo/skill or owner/repo (shorthand for github.com)
31
- const parts = input.split('/').filter(Boolean)
32
- if (parts.length < 2) return null
33
-
34
- const hasHost = parts[0].includes('.')
35
-
36
- if (hasHost) {
37
- if (parts.length < 3) return null
38
- const host = parts[0]
39
- const owner = parts[1]
40
- const repo = parts[2]
41
- const skill = parts.length > 3 ? parts.slice(3).join('/') : null
42
- return { host, owner, repo, skill, raw: input }
43
- }
44
-
45
- const host = 'github.com'
46
- const owner = parts[0]
47
- const repo = parts[1]
48
- const skill = parts.length > 2 ? parts.slice(2).join('/') : null
49
- return { host, owner, repo, skill, raw: input }
50
- }
51
-
52
- function findSkillDir(repoPath: string, skill: string | null): string | null {
31
+ export function findSkillDir(repoPath: string, skill: string | null): string | null {
53
32
  if (skill) {
54
33
  const inSkills = join(repoPath, 'skills', skill)
55
34
  if (existsSync(join(inSkills, 'SKILL.md'))) return inSkills
@@ -67,6 +46,15 @@ function findSkillDir(repoPath: string, skill: string | null): string | null {
67
46
  if (existsSync(join(candidate, 'SKILL.md'))) return candidate
68
47
  }
69
48
  }
49
+ // Flat structure: scan repo root for directories containing SKILL.md
50
+ try {
51
+ const rootEntries = readdirSync(repoPath, { withFileTypes: true })
52
+ const rootSkillDirs = rootEntries
53
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
54
+ .map(e => join(repoPath, e.name))
55
+ .filter(p => existsSync(join(p, 'SKILL.md')))
56
+ if (rootSkillDirs.length === 1) return rootSkillDirs[0]
57
+ } catch {}
70
58
  return null
71
59
  }
72
60
 
@@ -75,7 +63,34 @@ function resolvePath(p: string): string {
75
63
  return resolve(p)
76
64
  }
77
65
 
78
- export async function addSkill(locator: string, options: { deck?: string; workdir?: string; alias?: string; type?: string; dryRun?: boolean }) {
66
+ function resolveColdPoolPath(deckPath: string, workdir: string): string {
67
+ if (existsSync(deckPath)) {
68
+ try {
69
+ const deckRaw = readFileSync(deckPath, 'utf-8')
70
+ const deck = parseToml(deckRaw) as { deck?: { cold_pool?: string } }
71
+ return expandHome(deck.deck?.cold_pool || '~/.agents/skill-repos', workdir)
72
+ } catch { /* fall through to default */ }
73
+ }
74
+ return join(homedir(), '.agents', 'skill-repos')
75
+ }
76
+
77
+ function fqOf(loc: Locator): string {
78
+ return formatLocator(loc)
79
+ }
80
+
81
+ function exitInvalidLocator(locator: string): never {
82
+ console.error(`❌ Invalid locator: ${locator}`)
83
+ console.error(` Expected FQ form (per ADR-20260502012643244):`)
84
+ console.error(` host.tld/owner/repo[/skill] — remote skill`)
85
+ console.error(` localhost/<name> — local-only skill`)
86
+ console.error(` Bare names and shorthand 'owner/repo' are rejected.`)
87
+ process.exit(1)
88
+ }
89
+
90
+ export async function addSkill(
91
+ locator: string,
92
+ options: { deck?: string; workdir?: string; alias?: string; type?: string; dryRun?: boolean },
93
+ ) {
79
94
  const dryRun = options.dryRun || false
80
95
  const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
81
96
  const deckPath = options.deck
@@ -83,44 +98,46 @@ export async function addSkill(locator: string, options: { deck?: string; workdi
83
98
  : findDeckToml(workdir) || join(workdir, 'skill-deck.toml')
84
99
 
85
100
  const parsed = parseLocator(locator)
86
- if (!parsed) {
87
- console.error(`❌ Invalid locator: ${locator}`)
88
- console.error(` Expected: github.com/owner/repo[/skill] or owner/repo[/skill]`)
101
+ if (!parsed) exitInvalidLocator(locator)
102
+
103
+ if (parsed.isLocalhost) {
104
+ console.error(`❌ deck add does not support localhost locators (no remote to clone).`)
105
+ console.error(` For local skills, place SKILL.md in your cold pool manually then run "deck link".`)
89
106
  process.exit(1)
90
107
  }
91
108
 
92
- let coldPool = join(homedir(), '.agents', 'skill-repos')
93
- if (existsSync(deckPath)) {
94
- try {
95
- const deckRaw = readFileSync(deckPath, 'utf-8')
96
- const deck = parseToml(deckRaw) as any
97
- coldPool = expandHome(deck.deck?.cold_pool || '~/.agents/skill-repos', workdir)
98
- } catch { /* use default */ }
99
- }
109
+ const coldPoolPath = resolveColdPoolPath(deckPath, workdir)
110
+ const pool = new ColdPool(coldPoolPath)
111
+ const fetchPlan = buildFetchPlan(pool, parsed)
112
+ const fqPath = fqOf(parsed)
113
+ const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo!
114
+ const alias = options.alias || skillName
115
+ const skillType = (options.type || 'tool').toLowerCase()
100
116
 
101
- const targetDir = join(coldPool, parsed.host, parsed.owner, parsed.repo)
117
+ if (!['innate', 'tool', 'combo'].includes(skillType)) {
118
+ console.error(`❌ Invalid type: ${skillType}. Must be innate, tool, or combo.`)
119
+ process.exit(1)
120
+ }
102
121
 
103
122
  if (dryRun) {
104
- const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
105
- const alias = options.alias || skillName
106
- const skillType = (options.type || 'tool').toLowerCase()
107
- const fqPath = parsed.skill
108
- ? `${parsed.host}/${parsed.owner}/${parsed.repo}/${parsed.skill}`
109
- : `${parsed.host}/${parsed.owner}/${parsed.repo}`
110
-
111
123
  console.log(`🔎 Dry-run: deck add ${locator}`)
112
- console.log(` Cold pool: ${coldPool}`)
124
+ console.log(` Cold pool: ${coldPoolPath}`)
113
125
  console.log(` Deck: ${deckPath}`)
114
126
  console.log()
115
- console.log(`📂 Repo status: ${existsSync(join(targetDir, '.git')) ? 'already cloned' : existsSync(targetDir) ? 'dir exists (partial clone?)' : 'not in cold pool'}`)
116
- if (!existsSync(join(targetDir, '.git'))) {
117
- console.log(`📦 Would clone: https://${parsed.host}/${parsed.owner}/${parsed.repo}.git --depth 1`)
127
+ const repoStatus = existsSync(join(fetchPlan.targetDir, '.git'))
128
+ ? 'already cloned'
129
+ : existsSync(fetchPlan.targetDir)
130
+ ? 'dir exists (partial clone?)'
131
+ : 'not in cold pool'
132
+ console.log(`📂 Repo status: ${repoStatus}`)
133
+ if (!existsSync(join(fetchPlan.targetDir, '.git'))) {
134
+ console.log(`📦 Would clone: ${fetchPlan.cloneUrl} --depth 1`)
118
135
  }
119
136
  if (parsed.skill) {
120
- const skillMd = join(targetDir, parsed.skill, 'SKILL.md')
121
- if (existsSync(targetDir) && existsSync(skillMd)) {
137
+ const skillMd = join(fetchPlan.targetDir, parsed.skill, 'SKILL.md')
138
+ if (existsSync(fetchPlan.targetDir) && existsSync(skillMd)) {
122
139
  console.log(`📄 Skill path: valid — ${skillMd}`)
123
- } else if (existsSync(targetDir)) {
140
+ } else if (existsSync(fetchPlan.targetDir)) {
124
141
  console.log(`⚠️ Skill path: NOT FOUND — check repo layout`)
125
142
  }
126
143
  }
@@ -131,125 +148,100 @@ export async function addSkill(locator: string, options: { deck?: string; workdi
131
148
  return
132
149
  }
133
150
 
134
- if (existsSync(targetDir)) {
135
- console.error(`❌ Already exists in cold pool: ${targetDir}`)
136
- console.error(` To update: rm -rf ${targetDir} and re-run`)
151
+ if (fetchPlan.alreadyExists) {
152
+ console.error(`❌ Already exists in cold pool: ${fetchPlan.targetDir}`)
153
+ console.error(` To update: rm -rf ${fetchPlan.targetDir} and re-run`)
137
154
  process.exit(1)
138
155
  }
139
156
 
140
- if (!existsSync(coldPool)) {
141
- console.log(`📁 Creating cold pool: ${coldPool}`)
142
- mkdirSync(coldPool, { recursive: true })
157
+ if (!existsSync(coldPoolPath)) {
158
+ console.log(`📁 Creating cold pool: ${coldPoolPath}`)
159
+ mkdirSync(coldPoolPath, { recursive: true })
143
160
  }
161
+ // git clone needs the parent of the target dir (e.g. host/owner/) to exist
162
+ mkdirSync(dirname(fetchPlan.targetDir), { recursive: true })
144
163
 
145
- const tmpDir = mkdtempSync(join(tmpdir(), 'lythoskill-deck-add-'))
146
- const tmpRepo = join(tmpDir, 'repo')
164
+ const fetchResult = executeFetchPlan(fetchPlan, {
165
+ log: (msg) => console.log(msg),
166
+ })
147
167
 
148
- try {
149
- const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
150
- console.log(`📦 Cloning: ${gitUrl}`)
151
- execFileSync('git', ['clone', '--depth', '1', gitUrl, tmpRepo], { stdio: 'inherit' })
152
- let skillSourceDir = tmpRepo
153
-
154
- if (!existsSync(skillSourceDir)) {
155
- console.error(`❌ Download failed: expected output not found at ${skillSourceDir}`)
156
- process.exit(1)
157
- }
168
+ if (fetchResult.status === 'failed') {
169
+ rmSync(fetchPlan.targetDir, { recursive: true, force: true })
170
+ console.error(`❌ Failed to fetch: ${fetchResult.message ?? 'unknown error'}`)
171
+ process.exit(1)
172
+ }
158
173
 
159
- mkdirSync(dirname(targetDir), { recursive: true })
160
- renameSync(skillSourceDir, targetDir)
174
+ const skillDir = findSkillDir(fetchPlan.targetDir, parsed.skill)
175
+ if (!skillDir) {
176
+ console.error(`❌ No SKILL.md found in downloaded repo`)
177
+ console.error(` Checked: ${fetchPlan.targetDir}`)
178
+ process.exit(1)
179
+ }
161
180
 
162
- const skillDir = findSkillDir(targetDir, parsed.skill)
163
- if (!skillDir) {
164
- console.error(`❌ No SKILL.md found in downloaded repo`)
165
- console.error(` Checked: ${targetDir}`)
166
- process.exit(1)
167
- }
181
+ console.log(`✅ Skill ready: ${skillName} (alias: ${alias})`)
182
+ console.log(` Location: ${skillDir}`)
168
183
 
169
- const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
170
- const alias = options.alias || skillName
171
- const skillType = (options.type || 'tool').toLowerCase()
184
+ // ── deck.toml ────────────────────────────────────────────
172
185
 
173
- if (!['innate', 'tool', 'combo'].includes(skillType)) {
174
- console.error(`❌ Invalid type: ${skillType}. Must be innate, tool, or combo.`)
186
+ if (existsSync(deckPath)) {
187
+ const deckRaw = readFileSync(deckPath, 'utf-8')
188
+ const deck = parseToml(deckRaw) as Record<string, any>
189
+
190
+ // Alias collision check across all sections
191
+ const allAliases = new Set<string>()
192
+ for (const section of ['innate', 'tool', 'combo'] as const) {
193
+ const skills = deck[section]?.skills
194
+ if (skills && typeof skills === 'object' && !Array.isArray(skills)) {
195
+ for (const key of Object.keys(skills)) allAliases.add(key)
196
+ } else if (Array.isArray(skills)) {
197
+ for (const name of skills) allAliases.add(name.split('/').pop() || name)
198
+ }
199
+ }
200
+ for (const key of Object.keys(deck.transient || {})) {
201
+ allAliases.add(key)
202
+ }
203
+ if (allAliases.has(alias)) {
204
+ console.error(`❌ Alias "${alias}" already exists in deck`)
175
205
  process.exit(1)
176
206
  }
177
207
 
178
- const fqPath = parsed.skill
179
- ? `${parsed.host}/${parsed.owner}/${parsed.repo}/${parsed.skill}`
180
- : `${parsed.host}/${parsed.owner}/${parsed.repo}`
181
-
182
- console.log(`✅ Skill ready: ${skillName} (alias: ${alias})`)
183
- console.log(` Location: ${skillDir}`)
184
-
185
- // ── 写 deck.toml ────────────────────────────────────────────
186
-
187
- if (existsSync(deckPath)) {
188
- const deckRaw = readFileSync(deckPath, 'utf-8')
189
- const deck = parseToml(deckRaw) as any
190
-
191
- // Alias collision check across all sections
192
- const allAliases = new Set<string>()
193
- for (const section of ['innate', 'tool', 'combo'] as const) {
194
- const skills = deck[section]?.skills
195
- if (skills && typeof skills === 'object' && !Array.isArray(skills)) {
196
- for (const key of Object.keys(skills)) allAliases.add(key)
197
- } else if (Array.isArray(skills)) {
198
- for (const name of skills) allAliases.add(name.split('/').pop() || name)
199
- }
200
- }
201
- for (const key of Object.keys(deck.transient || {})) {
202
- allAliases.add(key)
203
- }
204
- if (allAliases.has(alias)) {
205
- console.error(`❌ Alias "${alias}" already exists in deck`)
206
- process.exit(1)
207
- }
208
-
209
- // Auto-migrate old string-array format to dict
210
- for (const section of ['innate', 'tool', 'combo'] as const) {
211
- const sectionData = deck[section]
212
- if (sectionData && Array.isArray(sectionData.skills)) {
213
- const dict: Record<string, { path: string }> = {}
214
- for (const name of sectionData.skills) {
215
- const a = name.split('/').pop() || name
216
- dict[a] = { path: name }
217
- }
218
- deck[section].skills = dict
219
- console.log(`📝 Auto-migrated [${section}] from string-array to dict format`)
220
- }
221
- }
222
-
223
- // Ensure target section exists and is dict format
224
- if (!deck[skillType]) deck[skillType] = {}
225
- if (!deck[skillType].skills) deck[skillType].skills = {}
226
- if (Array.isArray(deck[skillType].skills)) {
208
+ // Auto-migrate old string-array format to dict
209
+ for (const section of ['innate', 'tool', 'combo'] as const) {
210
+ const sectionData = deck[section]
211
+ if (sectionData && Array.isArray(sectionData.skills)) {
227
212
  const dict: Record<string, { path: string }> = {}
228
- for (const name of deck[skillType].skills) {
213
+ for (const name of sectionData.skills) {
229
214
  const a = name.split('/').pop() || name
230
215
  dict[a] = { path: name }
231
216
  }
232
- deck[skillType].skills = dict
217
+ deck[section].skills = dict
218
+ console.log(`📝 Auto-migrated [${section}] from string-array to dict format`)
233
219
  }
234
-
235
- deck[skillType].skills[alias] = { path: fqPath }
236
- writeFileSync(deckPath, stringifyToml(deck))
237
- console.log(`📝 Added "${alias}" to [${skillType}.skills] in ${deckPath}`)
238
- } else {
239
- const minimal: any = { deck: { max_cards: 10 } }
240
- minimal[skillType] = { skills: { [alias]: { path: fqPath } } }
241
- writeFileSync(deckPath, stringifyToml(minimal))
242
- console.log(`📝 Created ${deckPath} with "${alias}"`)
243
220
  }
244
221
 
245
- console.log('🔗 Running deck link...')
246
- const { linkDeck } = await import('./link.js')
247
- linkDeck(deckPath, workdir)
222
+ // Ensure target section exists and is dict format
223
+ if (!deck[skillType]) deck[skillType] = {}
224
+ if (!deck[skillType].skills) deck[skillType].skills = {}
225
+ if (Array.isArray(deck[skillType].skills)) {
226
+ const dict: Record<string, { path: string }> = {}
227
+ for (const name of deck[skillType].skills) {
228
+ const a = name.split('/').pop() || name
229
+ dict[a] = { path: name }
230
+ }
231
+ deck[skillType].skills = dict
232
+ }
248
233
 
249
- } catch (err) {
250
- console.error(`❌ Failed to add skill: ${err}`)
251
- process.exit(1)
252
- } finally {
253
- rmSync(tmpDir, { recursive: true, force: true })
234
+ deck[skillType].skills[alias] = { path: fqPath }
235
+ writeFileSync(deckPath, stringifyToml(deck))
236
+ console.log(`📝 Added "${alias}" to [${skillType}.skills] in ${deckPath}`)
237
+ } else {
238
+ const minimal: Record<string, any> = { deck: { max_cards: 10 } }
239
+ minimal[skillType] = { skills: { [alias]: { path: fqPath } } }
240
+ writeFileSync(deckPath, stringifyToml(minimal))
241
+ console.log(`📝 Created ${deckPath} with "${alias}"`)
254
242
  }
243
+
244
+ console.log('🔗 Running deck link...')
245
+ const { linkDeck } = await import('./link.js')
246
+ linkDeck(deckPath, workdir)
255
247
  }
package/src/cli.ts CHANGED
@@ -12,18 +12,23 @@ import { formatHelp } from './help.js'
12
12
  const args = process.argv.slice(2)
13
13
  const command = args[0]
14
14
 
15
- const deckFlagIdx = args.indexOf('--deck')
16
- const workdirFlagIdx = args.indexOf('--workdir')
17
- const aliasFlagIdx = args.indexOf('--alias')
18
- const typeFlagIdx = args.indexOf('--type')
15
+ // Argument helpers — accept both `--flag value` and `--flag=value` forms.
16
+ function flagValue(name: string): string | undefined {
17
+ const direct = args.find((a) => a.startsWith(name + '='))
18
+ if (direct) return direct.slice(name.length + 1)
19
+ const idx = args.indexOf(name)
20
+ return idx >= 0 ? args[idx + 1] : undefined
21
+ }
19
22
 
20
- const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
21
- const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
22
- const alias = aliasFlagIdx >= 0 ? args[aliasFlagIdx + 1] : undefined
23
- const type = typeFlagIdx >= 0 ? args[typeFlagIdx + 1] : undefined
23
+ const deckPath = flagValue('--deck')
24
+ const workdir = flagValue('--workdir')
25
+ const alias = flagValue('--alias')
26
+ const type = flagValue('--type')
27
+ const format = flagValue('--format')
24
28
  const noBackup = args.includes('--no-backup')
25
29
  const yes = args.includes('--yes')
26
30
  const dryRun = args.includes('--dry-run')
31
+ const remote = args.includes('--remote')
27
32
 
28
33
  const HELP_CONFIG = {
29
34
  binName: 'lythoskill-deck',
@@ -46,6 +51,8 @@ const HELP_CONFIG = {
46
51
  { flag: '--type <type>', description: 'Target section: innate | tool | combo (default: tool)' },
47
52
  { flag: '--dry-run', description: 'Show plan without executing (add, prune)' },
48
53
  { flag: '--yes', description: 'Skip interactive confirmation (for prune)' },
54
+ { flag: '--remote', description: 'For validate: probe each FQ locator against api.github.com' },
55
+ { flag: '--format <text|json>', description: 'For validate: output format (default: text)' },
49
56
  ],
50
57
  }
51
58
 
@@ -77,7 +84,10 @@ switch (command) {
77
84
  break
78
85
  }
79
86
  case 'validate':
80
- validateDeck(deckPath, workdir)
87
+ await validateDeck(deckPath, workdir, {
88
+ remote,
89
+ format: format === 'json' ? 'json' : 'text',
90
+ })
81
91
  break
82
92
  case 'remove': {
83
93
  const removeTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
package/src/link.test.ts CHANGED
@@ -67,42 +67,52 @@ describe('expandHome', () => {
67
67
  })
68
68
 
69
69
  describe('findSource', () => {
70
- it('resolves fully-qualified host.tld/owner/repo/skill via cold-pool skills/ subdir', () => {
70
+ it('resolves FQ host.tld/owner/repo/skill via cold-pool direct path', () => {
71
71
  const coldPool = makeTmp()
72
72
  const projectDir = makeTmp()
73
73
  const expected = placeSkill(coldPool, 'github.com/lythos-labs/lythoskill/skills/lythoskill-deck')
74
- const result = findSource('github.com/lythos-labs/lythoskill/lythoskill-deck', coldPool, projectDir)
74
+ const result = findSource('github.com/lythos-labs/lythoskill/skills/lythoskill-deck', coldPool, projectDir)
75
75
  expect(result.path).toBe(expected)
76
76
  })
77
77
 
78
- it('resolves a direct cold-pool hit when name matches a top-level dir with SKILL.md', () => {
78
+ it('resolves FQ standalone host.tld/owner/repo (skill = null) via repo-root SKILL.md', () => {
79
79
  const coldPool = makeTmp()
80
80
  const projectDir = makeTmp()
81
- const expected = placeSkill(coldPool, 'my-skill')
82
- const result = findSource('my-skill', coldPool, projectDir)
81
+ const expected = placeSkill(coldPool, 'github.com/owner/standalone')
82
+ const result = findSource('github.com/owner/standalone', coldPool, projectDir)
83
83
  expect(result.path).toBe(expected)
84
84
  })
85
85
 
86
- it('resolves a monorepo layout (repo/skill → coldPool/repo/skills/skill)', () => {
86
+ it('resolves localhost/<owner>/<repo> via uniform <host>/<owner>/<repo> layout', () => {
87
87
  const coldPool = makeTmp()
88
88
  const projectDir = makeTmp()
89
- const expected = placeSkill(coldPool, 'mono-repo/skills/inner-skill')
90
- const result = findSource('mono-repo/inner-skill', coldPool, projectDir)
89
+ const expected = placeSkill(coldPool, 'localhost/me/my-local-skill')
90
+ const result = findSource('localhost/me/my-local-skill', coldPool, projectDir)
91
91
  expect(result.path).toBe(expected)
92
92
  })
93
93
 
94
- it('falls back to projectDir/skills/<name> for project-local skills', () => {
94
+ it('rejects bare names with FQ-only error (per ADR-20260502012643244)', () => {
95
95
  const coldPool = makeTmp()
96
96
  const projectDir = makeTmp()
97
- const expected = placeSkill(projectDir, 'skills/local-skill')
98
- const result = findSource('local-skill', coldPool, projectDir)
99
- expect(result.path).toBe(expected)
97
+ placeSkill(coldPool, 'my-skill') // even if a dir exists
98
+ const result = findSource('my-skill', coldPool, projectDir)
99
+ expect(result.path).toBeNull()
100
+ expect(result.error).toBeDefined()
101
+ expect(result.error).toContain('not FQ')
102
+ })
103
+
104
+ it('rejects shorthand owner/repo (no host) with FQ-only error', () => {
105
+ const coldPool = makeTmp()
106
+ const projectDir = makeTmp()
107
+ const result = findSource('owner/repo', coldPool, projectDir)
108
+ expect(result.path).toBeNull()
109
+ expect(result.error).toBeDefined()
100
110
  })
101
111
 
102
- it('returns {path: null} when no strategy resolves the name', () => {
112
+ it('returns {path: null} when FQ locator is well-formed but path absent on disk', () => {
103
113
  const coldPool = makeTmp()
104
114
  const projectDir = makeTmp()
105
- const result = findSource('nonexistent-skill', coldPool, projectDir)
115
+ const result = findSource('github.com/owner/missing-repo/skill', coldPool, projectDir)
106
116
  expect(result.path).toBeNull()
107
117
  expect(result.error).toBeUndefined()
108
118
  })
package/src/link.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  import { execFileSync } from "node:child_process";
18
18
  import { resolve, dirname, join, basename, relative } from "node:path";
19
19
  import { homedir } from "node:os";
20
+ import { ColdPool, parseLocator } from "@lythos/cold-pool";
20
21
  import {
21
22
  SkillDeckLockSchema,
22
23
  type SkillDeckLock, type LinkedSkill, type ConstraintReport,
@@ -58,81 +59,43 @@ export interface FindSourceResult {
58
59
  error?: string;
59
60
  }
60
61
 
61
- export function findSource(name: string, coldPool: string, projectDir: string): FindSourceResult {
62
- // 0. Fully-qualified path: host.tld/owner/repo/skill
63
- // → cold_pool/host.tld/owner/repo/skills/skill
64
- // Also handles host.tld/owner/repo (standalone skill without skills/ subdir)
65
- const fqMatch = name.match(/^[a-z0-9-]+\.[a-z0-9-]+\//);
66
- if (fqMatch) {
67
- const parts = name.split("/");
68
- const host = parts[0]; // github.com
69
- const owner = parts[1]; // lythos-labs
70
- const repo = parts[2]; // lythoskill
71
- const skill = parts.slice(3).join("/"); // lythoskill-deck
72
-
73
- if (skill) {
74
- const fqPath = join(coldPool, host, owner, repo, "skills", skill);
75
- if (existsSync(join(fqPath, "SKILL.md"))) return { path: fqPath };
76
- }
77
- // fallback: standalone skill at repo root
78
- const directPath = join(coldPool, host, owner, repo);
79
- if (existsSync(join(directPath, "SKILL.md"))) return { path: directPath };
80
- }
81
-
82
- // 0.5 localhost skills: localhost/skill → cold_pool/<skill>
83
- if (name.startsWith('localhost/')) {
84
- const skill = name.slice('localhost/'.length);
85
- if (skill) {
86
- const localPath = join(coldPool, skill);
87
- if (existsSync(join(localPath, "SKILL.md"))) return { path: localPath };
88
- }
89
- return { path: null };
62
+ /**
63
+ * Resolve a deck-declared locator to its physical SKILL.md directory in
64
+ * the cold pool. Per ADR-20260502012643244, locators are FQ-only — bare
65
+ * names and shorthand `owner/repo` are rejected. Internally delegates to
66
+ * `@lythos/cold-pool` for parsing and pool path computation.
67
+ *
68
+ * `projectDir` is currently unused (legacy parameter from the deprecated
69
+ * project-local fallback strategy). Kept for caller compatibility; will
70
+ * be removed in 0.10.x cleanup.
71
+ */
72
+ export function findSource(name: string, coldPool: string, _projectDir: string): FindSourceResult {
73
+ const locator = parseLocator(name);
74
+ if (!locator) {
75
+ return {
76
+ path: null,
77
+ error: `Locator "${name}" is not FQ. Expected: host.tld/owner/repo[/skill] or localhost/<name>. Bare names rejected per ADR-20260502012643244.`,
78
+ };
90
79
  }
91
80
 
92
- // 1. 直接路径
93
- const direct = resolve(coldPool, name);
94
- if (existsSync(join(direct, "SKILL.md"))) return { path: direct };
81
+ const pool = new ColdPool(coldPool);
82
+ const baseDir = pool.resolveDir(locator);
95
83
 
96
- // 2. Monorepo: repo/skill cold_pool/repo/skills/skill
97
- if (name.includes("/")) {
98
- const [repo, ...rest] = name.split("/");
99
- const mono = join(coldPool, repo, "skills", rest.join("/"));
100
- if (existsSync(join(mono, "SKILL.md"))) return { path: mono };
84
+ // localhost: baseDir is the skill dir itself
85
+ if (locator.isLocalhost) {
86
+ if (existsSync(join(baseDir, "SKILL.md"))) return { path: baseDir };
87
+ return { path: null };
101
88
  }
102
89
 
103
- // 3. 项目本地: <project>/skills/<name>(build 输出目录,优先级高于扁平扫描)
104
- const local = resolve(projectDir, "skills", name);
105
- if (existsSync(join(local, "SKILL.md"))) return { path: local };
106
-
107
- // 4. 扁平扫描: cold_pool/<any-repo>/<name> 或 <any-repo>/skills/<name>
108
- // 跳过隐藏目录(agent working set、git、配置等)和 node_modules,
109
- // 避免把 .claude/skills/ 里的 symlink 误判为有效 cold-pool 源
110
- const matches: string[] = [];
111
- try {
112
- for (const entry of readdirSync(coldPool, { withFileTypes: true })) {
113
- if (!entry.isDirectory()) continue;
114
- if (entry.name.startsWith('.')) continue;
115
- if (entry.name === 'node_modules') continue;
116
- const base = join(coldPool, entry.name);
117
- for (const sub of [join(base, name), join(base, "skills", name)]) {
118
- if (existsSync(join(sub, "SKILL.md"))) {
119
- matches.push(sub);
120
- }
121
- }
122
- }
123
- } catch {}
124
-
125
- if (matches.length === 1) {
126
- return { path: matches[0] };
127
- }
128
- if (matches.length > 1) {
129
- const candidates = matches.map(m => relative(coldPool, m)).join(', ');
130
- return {
131
- path: null,
132
- error: `Ambiguous skill name "${name}": found ${matches.length} matches (${candidates}). Use fully-qualified name (e.g., github.com/owner/repo/${name})`,
133
- };
90
+ // Remote with skill subpath: SKILL.md sits inside the subpath
91
+ if (locator.skill) {
92
+ const skillDir = join(baseDir, locator.skill);
93
+ if (existsSync(join(skillDir, "SKILL.md"))) return { path: skillDir };
94
+ return { path: null };
134
95
  }
135
96
 
97
+ // Standalone repo: SKILL.md is at repo root
98
+ if (existsSync(join(baseDir, "SKILL.md"))) return { path: baseDir };
136
99
  return { path: null };
137
100
  }
138
101
 
package/src/prune-plan.ts CHANGED
@@ -44,6 +44,19 @@ export function resolvePruneConfig(opts?: {
44
44
 
45
45
  // ── Cold pool scanner (pure: reads, no delete) ─────────────────────────────
46
46
 
47
+ /**
48
+ * Scan cold pool for skill repos.
49
+ *
50
+ * Layout convention (per ADR-20260502012643344):
51
+ * - `<coldPool>/localhost/<name>/SKILL.md` — local skill
52
+ * - `<coldPool>/<host>/<owner>/<repo>/...` — remote skill
53
+ *
54
+ * Legacy drift detection: a top-level dir `<coldPool>/<x>/SKILL.md` (with
55
+ * SKILL.md directly, not under `localhost/`) is non-canonical state from
56
+ * older agents that bypassed FQ-only enforcement. We surface it here so
57
+ * prune's heredoc can list it as cleanup candidate; future writes should
58
+ * never produce this shape.
59
+ */
47
60
  export function scanColdPool(coldPool: string): string[] {
48
61
  const repos: string[] = []
49
62
  if (!existsSync(coldPool)) return repos
@@ -53,13 +66,22 @@ export function scanColdPool(coldPool: string): string[] {
53
66
  if (!host.isDirectory() || host.name.startsWith('.')) continue
54
67
  const hostPath = join(coldPool, host.name)
55
68
 
56
- // Flat skill: cold-pool/skill-name/SKILL.md (localhost style)
69
+ // Localhost layout: <coldPool>/localhost/<name>/SKILL.md
70
+ if (host.name === 'localhost') {
71
+ for (const entry of readdirSync(hostPath, { withFileTypes: true })) {
72
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
73
+ repos.push(join(hostPath, entry.name))
74
+ }
75
+ continue
76
+ }
77
+
78
+ // Legacy drift: top-level dir with SKILL.md (not canonical)
57
79
  if (existsSync(join(hostPath, 'SKILL.md'))) {
58
80
  repos.push(hostPath)
59
81
  continue
60
82
  }
61
83
 
62
- // Nested: cold-pool/github.com/owner/repo/
84
+ // Nested: <coldPool>/<host>/<owner>/<repo>/
63
85
  for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
64
86
  if (!owner.isDirectory() || owner.name.startsWith('.')) continue
65
87
  const ownerPath = join(hostPath, owner.name)
package/src/prune.test.ts CHANGED
@@ -63,7 +63,7 @@ describe('pruneDeck', () => {
63
63
  const repoB = placeRepo(coldPool, 'github.com', 'owner', 'repo-b')
64
64
  placeSkillInRepo(repoB, 'skill-b')
65
65
 
66
- const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skill-a"\n`
66
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skills/skill-a"\n`
67
67
  const deckPath = join(projectDir, 'skill-deck.toml')
68
68
  writeFileSync(deckPath, deckContent)
69
69
 
@@ -84,7 +84,7 @@ describe('pruneDeck', () => {
84
84
  const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
85
85
  placeSkillInRepo(repoA, 'skill-a')
86
86
 
87
- const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skill-a"\n`
87
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skills/skill-a"\n`
88
88
  const deckPath = join(projectDir, 'skill-deck.toml')
89
89
  writeFileSync(deckPath, deckContent)
90
90
 
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { resolve, dirname, relative } from 'node:path'
3
3
  import { realpathSync } from 'node:fs'
4
4
  import { execSync } from 'node:child_process'
5
+ import { parseLocator } from '@lythos/cold-pool'
5
6
  import { findDeckToml, expandHome, findSource } from './link'
6
7
  import { parseDeck, type ParsedSkillEntry } from './parse-deck'
7
8
 
@@ -120,6 +121,25 @@ export function buildRefreshPlan(
120
121
  const targets: RefreshTarget[] = []
121
122
 
122
123
  for (const entry of declared) {
124
+ // Localhost shortcut: parse the locator and short-circuit before
125
+ // hitting fs. localhost layout per ADR-20260507021957847 is a
126
+ // top-level dir under coldPool (no `localhost/` directory prefix),
127
+ // so path-based detectGitRoot can't distinguish it from a regular
128
+ // standalone skill. The locator string is the authoritative signal.
129
+ const locator = parseLocator(entry.path)
130
+ if (locator?.isLocalhost) {
131
+ const source = findSource(entry.path, coldPool, workdir)
132
+ const sourcePath = source.path ?? ''
133
+ targets.push({
134
+ alias: entry.alias,
135
+ path: entry.path,
136
+ sourcePath,
137
+ sourceRel: sourcePath ? relative(coldPool, sourcePath) : '',
138
+ type: sourcePath ? 'localhost' : 'missing',
139
+ })
140
+ continue
141
+ }
142
+
123
143
  const source = findSource(entry.path, coldPool, workdir)
124
144
 
125
145
  if (source.error || !source.path) {
package/src/refresh.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readFileSync } from "node:fs";
11
- import { execSync } from "node:child_process";
12
11
  import { resolve } from "node:path";
12
+ import { gitPull } from "@lythos/cold-pool";
13
13
  import { findDeckToml, linkDeck } from "./link.js";
14
14
  import { parseDeck } from "./parse-deck.js";
15
15
  import { buildRefreshPlan, detectGitRoot, executeRefreshPlan } from "./refresh-plan.js";
@@ -20,32 +20,6 @@ export function findGitRoot(dir: string, coldPool: string): string | null {
20
20
  return result.gitRoot ?? null
21
21
  }
22
22
 
23
- interface RefreshResult {
24
- name: string;
25
- path: string;
26
- status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
27
- message?: string;
28
- }
29
-
30
- function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
31
- try {
32
- const output = execSync("git pull", {
33
- cwd: dir,
34
- encoding: "utf-8",
35
- stdio: ["pipe", "pipe", "pipe"],
36
- timeout: 30000,
37
- }).trim();
38
-
39
- if (output.includes("Already up to date") || output.includes("Already up-to-date")) {
40
- return { status: "up-to-date", message: output };
41
- }
42
- return { status: "updated", message: output };
43
- } catch (err: any) {
44
- const stderr = err.stderr?.toString() || err.message || "";
45
- return { status: "failed", message: stderr.trim() };
46
- }
47
- }
48
-
49
23
  export function refreshDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
50
24
  const deckPath = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
51
25
  const workdir = cliWorkdir
package/src/validate.ts CHANGED
@@ -3,24 +3,63 @@
3
3
  * deck-validate.ts — Skill Deck configuration validator
4
4
  *
5
5
  * 读取 skill-deck.toml → 校验 schema、引用有效性、约束合规性。
6
- * 不做:创建 symlink、修改文件系统。
6
+ * Optional remote check (T8 of EPIC-20260507020846020):
7
+ * `--remote` → for each FQ locator, call cold-pool's buildValidationPlan
8
+ * + executeValidationPlan against api.github.com to verify repo and
9
+ * skill path exist BEFORE clone. Output structured ValidationReport.
10
+ * `--format=json` emits machine-readable output for agents.
7
11
  */
8
12
 
9
13
  import { parse as parseToml } from "@iarna/toml";
10
14
  import { existsSync, readFileSync } from "node:fs";
11
15
  import { resolve } from "node:path";
16
+ import {
17
+ buildValidationPlan,
18
+ executeValidationPlan,
19
+ type ValidationReport,
20
+ } from "@lythos/cold-pool";
12
21
  import { findDeckToml, expandHome, findSource } from "./link.js";
13
22
  import { parseDeck } from "./parse-deck.js";
14
23
 
15
- export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
24
+ export interface ValidateOptions {
25
+ remote?: boolean;
26
+ format?: 'text' | 'json';
27
+ }
28
+
29
+ export interface DeckValidationReport {
30
+ status: 'valid' | 'invalid';
31
+ deckPath: string;
32
+ errors: string[];
33
+ warnings: string[];
34
+ entries: Array<{
35
+ locator: string;
36
+ type: string;
37
+ alias: string;
38
+ localStatus: 'found' | 'missing' | 'parse-error';
39
+ remote?: ValidationReport;
40
+ }>;
41
+ budget: { declared: number; max_cards: number; within_budget: boolean };
42
+ }
43
+
44
+ export async function buildDeckValidation(
45
+ cliDeckPath?: string,
46
+ cliWorkdir?: string,
47
+ options: ValidateOptions = {},
48
+ ): Promise<DeckValidationReport> {
16
49
  const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
17
50
  const DECK_PATH = cliDeckPath
18
51
  ? resolve(cliDeckPath)
19
52
  : findDeckToml(PROJECT_DIR) || resolve(PROJECT_DIR, "skill-deck.toml");
20
53
 
21
54
  if (!existsSync(DECK_PATH)) {
22
- console.error(`❌ skill-deck.toml not found: ${DECK_PATH}`);
23
- process.exit(1);
55
+ return {
56
+ status: 'invalid',
57
+ deckPath: DECK_PATH,
58
+ errors: [`skill-deck.toml not found: ${DECK_PATH}`],
59
+ warnings: [],
60
+ entries: [],
61
+ budget: { declared: 0, max_cards: 0, within_budget: true },
62
+ };
24
63
  }
25
64
 
26
65
  const deckRaw = readFileSync(DECK_PATH, "utf-8");
@@ -28,15 +67,19 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
28
67
  try {
29
68
  deck = parseToml(deckRaw);
30
69
  } catch (err: any) {
31
- console.error(`❌ TOML parse error: ${err.message}`);
32
- process.exit(1);
70
+ return {
71
+ status: 'invalid',
72
+ deckPath: DECK_PATH,
73
+ errors: [`TOML parse error: ${err.message}`],
74
+ warnings: [],
75
+ entries: [],
76
+ budget: { declared: 0, max_cards: 0, within_budget: true },
77
+ };
33
78
  }
34
79
 
35
80
  const errors: string[] = [];
36
81
  const warnings: string[] = [];
37
82
 
38
- // ── Validate deck section ──────────────────────────────────
39
-
40
83
  if (!deck.deck || typeof deck.deck !== "object") {
41
84
  errors.push("[deck] section is required");
42
85
  } else {
@@ -51,8 +94,6 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
51
94
  const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
52
95
  const MAX_CARDS = Number(deck.deck?.max_cards || 10);
53
96
 
54
- // ── Validate skill declarations ────────────────────────────
55
-
56
97
  const { entries: parsedEntries, deprecated: isDeprecated, errors: parseErrors } = parseDeck(deckRaw);
57
98
  if (isDeprecated) {
58
99
  warnings.push("string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
@@ -61,6 +102,7 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
61
102
 
62
103
  const declaredNames = new Set<string>();
63
104
  let declaredCount = 0;
105
+ const entryReports: DeckValidationReport['entries'] = [];
64
106
 
65
107
  for (const entry of parsedEntries) {
66
108
  declaredCount++;
@@ -70,14 +112,37 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
70
112
  declaredNames.add(entry.path);
71
113
 
72
114
  const result = findSource(entry.path, COLD_POOL, PROJECT_DIR);
115
+ let localStatus: 'found' | 'missing' | 'parse-error';
73
116
  if (result.error) {
74
117
  errors.push(result.error);
118
+ localStatus = 'parse-error';
75
119
  } else if (!result.path) {
76
- errors.push(`Skill not found: ${entry.path} (${entry.type})`);
120
+ // Don't add to errors yet remote check may have suggestions.
121
+ // Local missing is only an error if remote also fails or is skipped.
122
+ localStatus = 'missing';
123
+ } else {
124
+ localStatus = 'found';
125
+ }
126
+
127
+ let remote: ValidationReport | undefined;
128
+ if (options.remote) {
129
+ const plan = buildValidationPlan(entry.path);
130
+ remote = await executeValidationPlan(plan);
131
+ if (remote.status === 'invalid') {
132
+ errors.push(`Remote check invalid: ${entry.path} (${remote.phase})`);
133
+ }
134
+ } else if (localStatus === 'missing') {
135
+ errors.push(`Skill not found in cold pool: ${entry.path} (${entry.type})`);
77
136
  }
78
- }
79
137
 
80
- // ── Validate transient section ─────────────────────────────
138
+ entryReports.push({
139
+ locator: entry.path,
140
+ type: entry.type,
141
+ alias: entry.alias,
142
+ localStatus,
143
+ remote,
144
+ });
145
+ }
81
146
 
82
147
  const transientCount = Object.keys(deck.transient || {}).length;
83
148
  if (deck.transient) {
@@ -104,24 +169,59 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
104
169
  }
105
170
  }
106
171
 
107
- // ── Budget check ───────────────────────────────────────────
108
-
109
172
  const total = declaredCount + transientCount;
110
- if (total > MAX_CARDS) {
173
+ const within_budget = total <= MAX_CARDS;
174
+ if (!within_budget) {
111
175
  errors.push(`Budget exceeded: declared ${total} skill(s), max_cards = ${MAX_CARDS}`);
112
176
  }
113
177
 
114
- // ── Report ─────────────────────────────────────────────────
178
+ return {
179
+ status: errors.length > 0 ? 'invalid' : 'valid',
180
+ deckPath: DECK_PATH,
181
+ errors,
182
+ warnings,
183
+ entries: entryReports,
184
+ budget: { declared: total, max_cards: MAX_CARDS, within_budget },
185
+ };
186
+ }
115
187
 
116
- if (warnings.length > 0) {
117
- for (const w of warnings) console.warn(`⚠️ ${w}`);
188
+ function renderText(report: DeckValidationReport): void {
189
+ for (const w of report.warnings) console.warn(`⚠️ ${w}`);
190
+
191
+ for (const entry of report.entries) {
192
+ if (entry.remote) {
193
+ const status = entry.remote.status
194
+ const icon = status === 'valid' ? '✅' : status === 'ambiguous' ? '⚠️ ' : '❌'
195
+ console.log(`${icon} ${entry.locator} (${entry.type}) — ${status} (${entry.remote.phase})`)
196
+ for (const fix of entry.remote.suggestedFixes) {
197
+ const tag = fix.action === 'update-locator' && fix.newLocator ? `→ ${fix.newLocator}` : fix.action
198
+ console.log(` ${tag} (confidence: ${fix.confidence.toFixed(2)}) — ${fix.message}`)
199
+ }
200
+ }
118
201
  }
119
202
 
120
- if (errors.length > 0) {
121
- for (const e of errors) console.error(`❌ ${e}`);
122
- console.error(`\n❌ Validation failed: ${errors.length} error(s)`);
123
- process.exit(1);
203
+ if (report.errors.length > 0) {
204
+ for (const e of report.errors) console.error(`❌ ${e}`);
205
+ console.error(`\n❌ Validation failed: ${report.errors.length} error(s)`);
206
+ } else {
207
+ console.log(`✅ Validation passed: ${report.budget.declared} skill(s), max_cards = ${report.budget.max_cards}`);
124
208
  }
209
+ }
125
210
 
126
- console.log(`✅ Validation passed: ${total} skill(s), max_cards = ${MAX_CARDS}`);
211
+ export async function validateDeck(
212
+ cliDeckPath?: string,
213
+ cliWorkdir?: string,
214
+ options: ValidateOptions = {},
215
+ ): Promise<void> {
216
+ const report = await buildDeckValidation(cliDeckPath, cliWorkdir, options);
217
+
218
+ if (options.format === 'json') {
219
+ console.log(JSON.stringify(report, null, 2));
220
+ } else {
221
+ renderText(report);
222
+ }
223
+
224
+ if (report.status === 'invalid') {
225
+ process.exit(1);
226
+ }
127
227
  }