@lythos/skill-deck 0.9.22 → 0.9.25
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 +11 -11
- package/package.json +2 -1
- package/src/add.test.ts +63 -0
- package/src/add.ts +160 -168
- package/src/cli.ts +19 -9
- package/src/link.test.ts +24 -14
- package/src/link.ts +31 -68
- package/src/prune-plan.ts +24 -2
- package/src/prune.test.ts +2 -2
- package/src/refresh-plan.ts +20 -0
- package/src/refresh.ts +1 -27
- package/src/validate.ts +124 -24
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.25 <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.
|
|
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
|
-
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.
|
|
58
|
+
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.25 link` |
|
|
59
|
+
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.25 validate` |
|
|
60
|
+
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.25 add owner/repo` |
|
|
61
|
+
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.25 refresh` |
|
|
62
|
+
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.25 refresh tdd` |
|
|
63
|
+
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.25 remove tdd` |
|
|
64
|
+
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.25 prune` |
|
|
65
|
+
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.25 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.
|
|
122
|
+
bunx @lythos/skill-deck@0.9.25 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.
|
|
151
|
+
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.25 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.
|
|
3
|
+
"version": "0.9.25",
|
|
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": "^0.9.25",
|
|
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
|
|
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 {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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: ${
|
|
124
|
+
console.log(` Cold pool: ${coldPoolPath}`)
|
|
113
125
|
console.log(` Deck: ${deckPath}`)
|
|
114
126
|
console.log()
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 (
|
|
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(
|
|
141
|
-
console.log(`📁 Creating cold pool: ${
|
|
142
|
-
mkdirSync(
|
|
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
|
|
146
|
-
|
|
164
|
+
const fetchResult = executeFetchPlan(fetchPlan, {
|
|
165
|
+
log: (msg) => console.log(msg),
|
|
166
|
+
})
|
|
147
167
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
console.
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
const alias = options.alias || skillName
|
|
171
|
-
const skillType = (options.type || 'tool').toLowerCase()
|
|
184
|
+
// ── 写 deck.toml ────────────────────────────────────────────
|
|
172
185
|
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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[
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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 =
|
|
21
|
-
const workdir =
|
|
22
|
-
const alias =
|
|
23
|
-
const type =
|
|
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
|
|
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
|
|
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, '
|
|
82
|
-
const result = findSource('
|
|
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
|
|
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, '
|
|
90
|
-
const result = findSource('
|
|
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('
|
|
94
|
+
it('rejects bare names with FQ-only error (per ADR-20260502012643244)', () => {
|
|
95
95
|
const coldPool = makeTmp()
|
|
96
96
|
const projectDir = makeTmp()
|
|
97
|
-
|
|
98
|
-
const result = findSource('
|
|
99
|
-
expect(result.path).
|
|
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
|
|
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('
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
93
|
-
const
|
|
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
|
-
//
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
package/src/refresh-plan.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|