@lythos/skill-deck 0.4.0 → 0.5.1
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 +100 -29
- package/package.json +1 -1
- package/src/add.ts +181 -0
- package/src/cli.ts +24 -7
- package/src/link.ts +47 -16
package/README.md
CHANGED
|
@@ -1,22 +1,97 @@
|
|
|
1
1
|
# @lythos/skill-deck
|
|
2
2
|
|
|
3
|
-
> Declarative skill deck governance
|
|
3
|
+
> Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## For AI Agents
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This package exposes a **CLI**. Invoke via:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```bash
|
|
10
|
+
bunx @lythos/skill-deck <command> [options]
|
|
11
|
+
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
No installation required. `bunx` auto-downloads the package.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
### skill-deck.toml (minimal)
|
|
16
|
+
|
|
17
|
+
```toml
|
|
18
|
+
[deck]
|
|
19
|
+
max_cards = 10
|
|
20
|
+
|
|
21
|
+
[tool]
|
|
22
|
+
skills = ["lythoskill-deck"]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### skill-deck.toml (full reference)
|
|
26
|
+
|
|
27
|
+
```toml
|
|
28
|
+
[deck]
|
|
29
|
+
max_cards = 10 # Hard limit on total skills
|
|
30
|
+
working_set = ".claude/skills" # Where symlinks are created
|
|
31
|
+
cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
|
|
32
|
+
|
|
33
|
+
[innate] # Always-loaded skills
|
|
34
|
+
skills = ["lythoskill-deck"]
|
|
35
|
+
|
|
36
|
+
[tool] # Auto-triggered skills
|
|
37
|
+
skills = ["skill-a", "skill-b"]
|
|
38
|
+
|
|
39
|
+
[combo] # Multi-skill bundles
|
|
40
|
+
skills = ["report-generation-combo"]
|
|
41
|
+
|
|
42
|
+
[transient] # Temporary skills with expiry
|
|
43
|
+
[transient.handoff]
|
|
44
|
+
path = "./skills/handoff" # Local path (not cold pool)
|
|
45
|
+
expires = "2026-05-01" # ISO date; warns at ≤14 days
|
|
17
46
|
```
|
|
18
47
|
|
|
19
|
-
|
|
48
|
+
### When to invoke
|
|
49
|
+
|
|
50
|
+
| Situation | Command |
|
|
51
|
+
|-----------|---------|
|
|
52
|
+
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck link` |
|
|
53
|
+
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck validate` |
|
|
54
|
+
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck add owner/repo` |
|
|
55
|
+
| Use a custom deck file or working dir | `bunx @lythos/skill-deck link --deck ./my-deck.toml --workdir /path/to/project` |
|
|
56
|
+
|
|
57
|
+
### Commands
|
|
58
|
+
|
|
59
|
+
| Command | Args | Description |
|
|
60
|
+
|---------|------|-------------|
|
|
61
|
+
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
|
|
62
|
+
| `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
|
|
63
|
+
| `add` | `<locator> [--via <backend>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
|
|
64
|
+
|
|
65
|
+
### Options
|
|
66
|
+
|
|
67
|
+
| Flag | Description | Default |
|
|
68
|
+
|------|-------------|---------|
|
|
69
|
+
| `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
|
|
70
|
+
| `--workdir <dir>` | Working directory | cwd |
|
|
71
|
+
| `--via <backend>` | Download backend for `add`: `git` or `skills.sh` | `git` |
|
|
72
|
+
|
|
73
|
+
### Safety guards
|
|
74
|
+
|
|
75
|
+
`link` refuses to operate if `working_set` resolves to your home directory or root (`/`). It also only removes **symlinks** from the working set — real files or directories are skipped with a warning.
|
|
76
|
+
|
|
77
|
+
### Exit codes
|
|
78
|
+
|
|
79
|
+
| Code | Meaning |
|
|
80
|
+
|------|---------|
|
|
81
|
+
| `0` | Success |
|
|
82
|
+
| `1` | Validation failed, deck not found, or budget exceeded |
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## For Humans
|
|
87
|
+
|
|
88
|
+
### Why
|
|
89
|
+
|
|
90
|
+
When an AI agent has access to 50+ skills, context window pollution and silent conflicts become real problems. Two skills claiming the same niche, redundant descriptions, incompatible assumptions — all invisible until the agent hallucinates.
|
|
91
|
+
|
|
92
|
+
`skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to `.claude/skills/` and **removes everything else**. Deny-by-default means undeclared skills physically do not exist in the agent's view.
|
|
93
|
+
|
|
94
|
+
### Quick Start
|
|
20
95
|
|
|
21
96
|
```bash
|
|
22
97
|
# 1. Create a skill-deck.toml
|
|
@@ -32,32 +107,28 @@ EOF
|
|
|
32
107
|
bunx @lythos/skill-deck link
|
|
33
108
|
```
|
|
34
109
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
lythoskill-deck — Declarative skill deck governance — cold pool, working set, deny-by-default
|
|
39
|
-
|
|
40
|
-
Usage: lythoskill-deck link | lythoskill-deck validate [deck.toml]
|
|
41
|
-
|
|
42
|
-
Commands:
|
|
43
|
-
link Sync working set with skill-deck.toml
|
|
44
|
-
validate [deck.toml] Validate deck configuration
|
|
45
|
-
|
|
46
|
-
Options:
|
|
47
|
-
--deck <path> Specify skill-deck.toml path
|
|
48
|
-
--workdir <dir> Specify working directory
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Key Concepts
|
|
110
|
+
### Key Concepts
|
|
52
111
|
|
|
53
112
|
| Concept | One-liner |
|
|
54
113
|
|---------|-----------|
|
|
55
114
|
| **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
|
|
56
115
|
| **skill-deck.toml** | Declares desired state: "this project uses these skills." |
|
|
57
|
-
| **`deck link`** | Reconciler. Makes
|
|
58
|
-
| **Working Set** | `.claude/skills/` —
|
|
116
|
+
| **`deck link`** | Reconciler. Makes the working set match the declaration. |
|
|
117
|
+
| **Working Set** | Symlinks only. Default: `.claude/skills/` — where agents scan for skills. |
|
|
59
118
|
| **deny-by-default** | Undeclared skills are physically absent from the working set. |
|
|
60
119
|
|
|
120
|
+
### Agent skill scan locations
|
|
121
|
+
|
|
122
|
+
Different agents look for skills in different directories. `skill-deck.toml` configures the working set to match your agent:
|
|
123
|
+
|
|
124
|
+
| Agent | Default skills directory |
|
|
125
|
+
|-------|--------------------------|
|
|
126
|
+
| Claude Code | `.claude/skills/` |
|
|
127
|
+
| Cursor | `.cursor/skills/` |
|
|
128
|
+
| Generic / custom | Configure `working_set` in `skill-deck.toml` |
|
|
129
|
+
|
|
130
|
+
> **If you are an agent**: verify where your platform scans for skills, then set `working_set` to that path before running `deck link`.
|
|
131
|
+
|
|
61
132
|
## Skill Documentation
|
|
62
133
|
|
|
63
134
|
This package is the **Starter** layer (CLI implementation).
|
package/package.json
CHANGED
package/src/add.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* deck-add.ts — Skill acquisition command
|
|
4
|
+
*
|
|
5
|
+
* Downloads a skill to the cold pool, updates skill-deck.toml, and links.
|
|
6
|
+
* Supports multiple backends (git clone, skills.sh) without locking users
|
|
7
|
+
* into a single download method.
|
|
8
|
+
*/
|
|
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 { execSync } from 'node:child_process'
|
|
15
|
+
import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
|
|
16
|
+
import { findDeckToml, expandHome } from './link.js'
|
|
17
|
+
|
|
18
|
+
interface ParsedLocator {
|
|
19
|
+
host: string
|
|
20
|
+
owner: string
|
|
21
|
+
repo: string
|
|
22
|
+
skill: string | null
|
|
23
|
+
raw: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseLocator(input: string): ParsedLocator | null {
|
|
27
|
+
// Format: host.tld/owner/repo/skill or host.tld/owner/repo
|
|
28
|
+
// owner/repo/skill or owner/repo (shorthand for github.com)
|
|
29
|
+
const parts = input.split('/').filter(Boolean)
|
|
30
|
+
if (parts.length < 2) return null
|
|
31
|
+
|
|
32
|
+
const hasHost = parts[0].includes('.')
|
|
33
|
+
|
|
34
|
+
if (hasHost) {
|
|
35
|
+
if (parts.length < 3) return null
|
|
36
|
+
const host = parts[0]
|
|
37
|
+
const owner = parts[1]
|
|
38
|
+
const repo = parts[2]
|
|
39
|
+
const skill = parts.length > 3 ? parts.slice(3).join('/') : null
|
|
40
|
+
return { host, owner, repo, skill, raw: input }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const host = 'github.com'
|
|
44
|
+
const owner = parts[0]
|
|
45
|
+
const repo = parts[1]
|
|
46
|
+
const skill = parts.length > 2 ? parts.slice(2).join('/') : null
|
|
47
|
+
return { host, owner, repo, skill, raw: input }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findSkillDir(repoPath: string, skill: string | null): string | null {
|
|
51
|
+
if (skill) {
|
|
52
|
+
const inSkills = join(repoPath, 'skills', skill)
|
|
53
|
+
if (existsSync(join(inSkills, 'SKILL.md'))) return inSkills
|
|
54
|
+
const direct = join(repoPath, skill)
|
|
55
|
+
if (existsSync(join(direct, 'SKILL.md'))) return direct
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
if (existsSync(join(repoPath, 'SKILL.md'))) return repoPath
|
|
59
|
+
const skillsDir = join(repoPath, 'skills')
|
|
60
|
+
if (existsSync(skillsDir)) {
|
|
61
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
62
|
+
const dirs = entries.filter(e => e.isDirectory())
|
|
63
|
+
if (dirs.length === 1) {
|
|
64
|
+
const candidate = join(skillsDir, dirs[0].name)
|
|
65
|
+
if (existsSync(join(candidate, 'SKILL.md'))) return candidate
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolvePath(p: string): string {
|
|
72
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2))
|
|
73
|
+
return resolve(p)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string }) {
|
|
77
|
+
const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
|
|
78
|
+
const deckPath = options.deck
|
|
79
|
+
? resolvePath(options.deck)
|
|
80
|
+
: findDeckToml(workdir) || join(workdir, 'skill-deck.toml')
|
|
81
|
+
|
|
82
|
+
const parsed = parseLocator(locator)
|
|
83
|
+
if (!parsed) {
|
|
84
|
+
console.error(`❌ Invalid locator: ${locator}`)
|
|
85
|
+
console.error(` Expected: github.com/owner/repo[/skill] or owner/repo[/skill]`)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const backend = options.via || 'git'
|
|
90
|
+
|
|
91
|
+
let coldPool = join(homedir(), '.agents', 'skill-repos')
|
|
92
|
+
if (existsSync(deckPath)) {
|
|
93
|
+
try {
|
|
94
|
+
const deckRaw = readFileSync(deckPath, 'utf-8')
|
|
95
|
+
const deck = parseToml(deckRaw) as any
|
|
96
|
+
coldPool = expandHome(deck.deck?.cold_pool || '~/.agents/skill-repos', workdir)
|
|
97
|
+
} catch { /* use default */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const targetDir = join(coldPool, parsed.host, parsed.owner, parsed.repo)
|
|
101
|
+
|
|
102
|
+
if (existsSync(targetDir)) {
|
|
103
|
+
console.error(`❌ Already exists in cold pool: ${targetDir}`)
|
|
104
|
+
console.error(` To update: rm -rf ${targetDir} and re-run`)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!existsSync(coldPool)) {
|
|
109
|
+
console.log(`📁 Creating cold pool: ${coldPool}`)
|
|
110
|
+
mkdirSync(coldPool, { recursive: true })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'lythoskill-deck-add-'))
|
|
114
|
+
const tmpRepo = join(tmpDir, 'repo')
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
if (backend === 'skills.sh' || backend === 'vercel') {
|
|
118
|
+
const skillsShLocator = `${parsed.owner}/${parsed.repo}`
|
|
119
|
+
console.log(`📦 Downloading via skills.sh: ${skillsShLocator}`)
|
|
120
|
+
execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
|
|
121
|
+
} else {
|
|
122
|
+
const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
|
|
123
|
+
console.log(`📦 Cloning: ${gitUrl}`)
|
|
124
|
+
execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!existsSync(tmpRepo)) {
|
|
128
|
+
if (backend === 'skills.sh' || backend === 'vercel') {
|
|
129
|
+
console.error(`❌ skills.sh backend installs globally, not to cold pool.`)
|
|
130
|
+
console.error(` Please manually place the skill at: ${targetDir}`)
|
|
131
|
+
console.error(` Or use: deck add ${locator} --via git`)
|
|
132
|
+
process.exit(1)
|
|
133
|
+
}
|
|
134
|
+
console.error(`❌ Download failed: expected output not found at ${tmpRepo}`)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
mkdirSync(dirname(targetDir), { recursive: true })
|
|
139
|
+
renameSync(tmpRepo, targetDir)
|
|
140
|
+
|
|
141
|
+
const skillDir = findSkillDir(targetDir, parsed.skill)
|
|
142
|
+
if (!skillDir) {
|
|
143
|
+
console.error(`❌ No SKILL.md found in downloaded repo`)
|
|
144
|
+
console.error(` Checked: ${targetDir}`)
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
|
|
149
|
+
console.log(`✅ Skill ready: ${skillName}`)
|
|
150
|
+
console.log(` Location: ${skillDir}`)
|
|
151
|
+
|
|
152
|
+
if (existsSync(deckPath)) {
|
|
153
|
+
const deckRaw = readFileSync(deckPath, 'utf-8')
|
|
154
|
+
const deck = parseToml(deckRaw) as any
|
|
155
|
+
const toolSkills = deck.tool?.skills || []
|
|
156
|
+
if (!toolSkills.includes(skillName)) {
|
|
157
|
+
if (!deck.tool) deck.tool = {}
|
|
158
|
+
if (!deck.tool.skills) deck.tool.skills = []
|
|
159
|
+
deck.tool.skills.push(skillName)
|
|
160
|
+
writeFileSync(deckPath, stringifyToml(deck))
|
|
161
|
+
console.log(`📝 Added "${skillName}" to ${deckPath}`)
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`📝 "${skillName}" already declared in ${deckPath}`)
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
const minimal = { deck: { max_cards: 10 }, tool: { skills: [skillName] } }
|
|
167
|
+
writeFileSync(deckPath, stringifyToml(minimal))
|
|
168
|
+
console.log(`📝 Created ${deckPath} with "${skillName}"`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log('🔗 Running deck link...')
|
|
172
|
+
const { linkDeck } = await import('./link.js')
|
|
173
|
+
linkDeck(deckPath === join(workdir, 'skill-deck.toml') ? undefined : deckPath, workdir)
|
|
174
|
+
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`❌ Failed to add skill: ${err}`)
|
|
177
|
+
process.exit(1)
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { linkDeck } from './link.js'
|
|
3
3
|
import { validateDeck } from './validate.js'
|
|
4
|
+
import { addSkill } from './add.js'
|
|
4
5
|
import { formatHelp } from './help.js'
|
|
5
6
|
|
|
6
7
|
const HELP_CONFIG = {
|
|
@@ -8,19 +9,26 @@ const HELP_CONFIG = {
|
|
|
8
9
|
description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
|
|
9
10
|
commands: [
|
|
10
11
|
{ name: 'link', description: 'Sync working set with skill-deck.toml' },
|
|
12
|
+
{ name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
|
|
11
13
|
{ name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
|
|
12
14
|
],
|
|
13
15
|
options: [
|
|
14
|
-
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path' },
|
|
15
|
-
{ flag: '--workdir <dir>', description: 'Specify working directory' },
|
|
16
|
+
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
|
|
17
|
+
{ flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
|
|
18
|
+
{ flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
|
|
16
19
|
],
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
22
|
+
const args = process.argv.slice(2)
|
|
23
|
+
const command = args[0]
|
|
24
|
+
|
|
25
|
+
const deckFlagIdx = args.indexOf('--deck')
|
|
26
|
+
const workdirFlagIdx = args.indexOf('--workdir')
|
|
27
|
+
const viaFlagIdx = args.indexOf('--via')
|
|
28
|
+
|
|
29
|
+
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
30
|
+
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
31
|
+
const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
|
|
24
32
|
|
|
25
33
|
switch (command) {
|
|
26
34
|
case '--help':
|
|
@@ -30,6 +38,15 @@ switch (command) {
|
|
|
30
38
|
case 'link':
|
|
31
39
|
linkDeck(deckPath, workdir)
|
|
32
40
|
break
|
|
41
|
+
case 'add': {
|
|
42
|
+
const locator = args[1]
|
|
43
|
+
if (!locator) {
|
|
44
|
+
console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
await addSkill(locator, { via, deck: deckPath, workdir })
|
|
48
|
+
break
|
|
49
|
+
}
|
|
33
50
|
case 'validate':
|
|
34
51
|
validateDeck(deckPath, workdir)
|
|
35
52
|
break
|
package/src/link.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
existsSync, mkdirSync, readFileSync, readdirSync,
|
|
15
15
|
symlinkSync, lstatSync, rmSync, writeFileSync,
|
|
16
16
|
} from "fs";
|
|
17
|
-
import { resolve, dirname, join } from "path";
|
|
17
|
+
import { resolve, dirname, join, basename, relative } from "path";
|
|
18
18
|
import { homedir } from "os";
|
|
19
19
|
import {
|
|
20
20
|
SkillDeckLockSchema,
|
|
@@ -152,7 +152,7 @@ for (const section of ["innate", "tool", "combo"] as const) {
|
|
|
152
152
|
if (!name || typeof name !== "string") continue;
|
|
153
153
|
const src = findSource(name, COLD_POOL, PROJECT_DIR);
|
|
154
154
|
if (!src) {
|
|
155
|
-
errors.push(`
|
|
155
|
+
errors.push(`Skill not found: ${name}`);
|
|
156
156
|
continue;
|
|
157
157
|
}
|
|
158
158
|
declared.push({ name, type: section, sourcePath: src });
|
|
@@ -165,7 +165,7 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
|
|
|
165
165
|
if (!t?.path) continue;
|
|
166
166
|
const src = resolve(PROJECT_DIR, t.path);
|
|
167
167
|
if (!existsSync(src)) {
|
|
168
|
-
errors.push(`
|
|
168
|
+
errors.push(`Transient path does not exist: ${key} → ${src}`);
|
|
169
169
|
continue;
|
|
170
170
|
}
|
|
171
171
|
declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
@@ -179,23 +179,54 @@ if (errors.length > 0) {
|
|
|
179
179
|
// ── 预算检查(硬约束,链接前检查)──────────────────────────
|
|
180
180
|
|
|
181
181
|
if (declared.length > MAX_CARDS) {
|
|
182
|
-
console.error(`❌
|
|
183
|
-
console.error(`
|
|
182
|
+
console.error(`❌ Budget exceeded: declared ${declared.length}, max ${MAX_CARDS}`);
|
|
183
|
+
console.error(` Reduce declarations in skill-deck.toml or increase max_cards`);
|
|
184
184
|
process.exit(1);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// ── 工作目录安全 guard ──────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const resolvedWorkingSet = resolve(WORKING_SET);
|
|
190
|
+
const resolvedHome = resolve(homedir());
|
|
191
|
+
const resolvedCwd = resolve(process.cwd());
|
|
192
|
+
const resolvedColdPool = resolve(COLD_POOL);
|
|
193
|
+
|
|
194
|
+
if (resolvedWorkingSet === resolvedHome || resolvedWorkingSet === "/") {
|
|
195
|
+
console.error(`❌ Refusing operation: working_set resolves to home or root directory (${resolvedWorkingSet})`);
|
|
196
|
+
console.error(` Check working_set in skill-deck.toml`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const relWs = relative(resolvedColdPool, resolvedWorkingSet);
|
|
201
|
+
if (
|
|
202
|
+
resolvedWorkingSet.startsWith(resolvedColdPool + "/") &&
|
|
203
|
+
!relWs.split("/").some(p => p.startsWith("."))
|
|
204
|
+
) {
|
|
205
|
+
console.warn(`⚠️ working_set is inside cold_pool and not hidden — may be picked up by cold-pool scans`);
|
|
206
|
+
console.warn(` working_set: ${resolvedWorkingSet}`);
|
|
207
|
+
console.warn(` cold_pool: ${resolvedColdPool}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
// ── 收束 working set ────────────────────────────────────────
|
|
188
211
|
|
|
189
212
|
mkdirSync(WORKING_SET, { recursive: true });
|
|
190
213
|
|
|
191
|
-
//
|
|
214
|
+
// 清理未声明的条目(只删 symlink,防呆)
|
|
192
215
|
const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
|
|
193
216
|
try {
|
|
194
217
|
for (const entry of readdirSync(WORKING_SET)) {
|
|
195
218
|
if (entry.startsWith("_")) continue;
|
|
196
219
|
if (!declaredNames.has(entry)) {
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
const entryPath = join(WORKING_SET, entry);
|
|
221
|
+
try {
|
|
222
|
+
const st = lstatSync(entryPath);
|
|
223
|
+
if (!st.isSymbolicLink()) {
|
|
224
|
+
console.warn(`⚠️ Skipping non-symlink entry: ${entry}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
} catch { continue; }
|
|
228
|
+
rmSync(entryPath, { recursive: true, force: true });
|
|
229
|
+
console.log(` 🗑️ Removed: ${entry}`);
|
|
199
230
|
}
|
|
200
231
|
}
|
|
201
232
|
} catch {}
|
|
@@ -216,7 +247,7 @@ for (const item of declared) {
|
|
|
216
247
|
mkdirSync(dirname(dest), { recursive: true });
|
|
217
248
|
symlinkSync(item.sourcePath, dest);
|
|
218
249
|
} catch (err: any) {
|
|
219
|
-
console.error(`❌
|
|
250
|
+
console.error(`❌ Link failed: ${item.name}: ${err.message}`);
|
|
220
251
|
continue;
|
|
221
252
|
}
|
|
222
253
|
|
|
@@ -260,9 +291,9 @@ for (const s of linkedSkills) {
|
|
|
260
291
|
const days = Math.ceil((exp - now) / 86400000);
|
|
261
292
|
transientWarnings.push({ name: s.name, expires: s.expires, days_remaining: days });
|
|
262
293
|
if (days <= 0) {
|
|
263
|
-
console.warn(`⚠️
|
|
294
|
+
console.warn(`⚠️ Expired: ${s.name} (expires ${s.expires}) — evaluate if still needed`);
|
|
264
295
|
} else if (days <= 14) {
|
|
265
|
-
console.warn(`⏰
|
|
296
|
+
console.warn(`⏰ Expiring soon: ${s.name} (${days} days remaining)`);
|
|
266
297
|
}
|
|
267
298
|
}
|
|
268
299
|
|
|
@@ -282,7 +313,7 @@ const dirOverlaps: { dir: string; skills: string[] }[] = [];
|
|
|
282
313
|
for (const [dir, owners] of dirOwners) {
|
|
283
314
|
if (owners.length > 1) {
|
|
284
315
|
dirOverlaps.push({ dir, skills: owners });
|
|
285
|
-
console.warn(`⚠️
|
|
316
|
+
console.warn(`⚠️ Directory overlap: ${dir} ← ${owners.join(", ")}`);
|
|
286
317
|
}
|
|
287
318
|
}
|
|
288
319
|
|
|
@@ -297,7 +328,7 @@ for (let i = 0; i < allDirs.length; i++) {
|
|
|
297
328
|
const cross = parentOwners.filter(o => !childOwners.includes(o));
|
|
298
329
|
if (cross.length > 0) {
|
|
299
330
|
const msg = `${allDirs[i]} (${parentOwners.join(",")}) 包含 ${allDirs[j]} (${childOwners.join(",")})`;
|
|
300
|
-
console.warn(`⚠️
|
|
331
|
+
console.warn(`⚠️ Directory containment: ${msg}`);
|
|
301
332
|
dirOverlaps.push({ dir: `${allDirs[i]} ⊃ ${allDirs[j]}`, skills: [...new Set([...parentOwners, ...childOwners])] });
|
|
302
333
|
}
|
|
303
334
|
}
|
|
@@ -326,7 +357,7 @@ const lock: SkillDeckLock = {
|
|
|
326
357
|
|
|
327
358
|
const parsed = SkillDeckLockSchema.safeParse(lock);
|
|
328
359
|
if (!parsed.success) {
|
|
329
|
-
console.error("❌ Lock schema
|
|
360
|
+
console.error("❌ Lock schema validation failed:", JSON.stringify(parsed.error.format(), null, 2));
|
|
330
361
|
process.exit(1);
|
|
331
362
|
}
|
|
332
363
|
|
|
@@ -336,10 +367,10 @@ writeFileSync(LOCK_PATH, JSON.stringify(parsed.data, null, 2) + "\n");
|
|
|
336
367
|
// ── 报告 ────────────────────────────────────────────────────
|
|
337
368
|
|
|
338
369
|
console.log("");
|
|
339
|
-
console.log(`✅
|
|
370
|
+
console.log(`✅ Sync complete: ${linkedSkills.length}/${MAX_CARDS} skills`);
|
|
340
371
|
console.log(` lock: ${LOCK_PATH}`);
|
|
341
372
|
if (dirOverlaps.length > 0) {
|
|
342
|
-
console.log(` ⚠️ ${dirOverlaps.length}
|
|
373
|
+
console.log(` ⚠️ ${dirOverlaps.length} directory overlap(s) (see warnings above)`);
|
|
343
374
|
}
|
|
344
375
|
}
|
|
345
376
|
|