@lythos/skill-deck 0.14.6 → 0.15.0
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 +62 -287
- package/package.json +3 -3
- package/src/link.test.ts +81 -0
- package/src/link.ts +105 -67
- package/src/remove.test.ts +60 -0
- package/src/remove.ts +19 -1
package/README.md
CHANGED
|
@@ -2,358 +2,133 @@
|
|
|
2
2
|
|
|
3
3
|
   
|
|
4
4
|
|
|
5
|
-
> Declarative skill deck governance. Declare skills
|
|
5
|
+
> Declarative skill deck governance. Declare which skills a project needs in `skill-deck.toml`, run `deck link`, and the working set becomes an exact mirror. Undeclared skills are physically removed — deny-by-default.
|
|
6
6
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
# Add a skill from skills.sh (owner/repo
|
|
11
|
-
bunx @lythos/skill-deck@0.
|
|
10
|
+
# Add a skill from skills.sh syntax (owner/repo, no conversion needed)
|
|
11
|
+
bunx @lythos/skill-deck@0.15.0 add vercel-labs/agent-skills
|
|
12
12
|
|
|
13
|
-
# Or with @skill filter
|
|
14
|
-
bunx @lythos/skill-deck@0.
|
|
13
|
+
# Or with @skill filter:
|
|
14
|
+
bunx @lythos/skill-deck@0.15.0 add mattpocock/skills@tdd
|
|
15
15
|
|
|
16
|
-
# Or
|
|
17
|
-
bunx @lythos/skill-deck@0.
|
|
16
|
+
# Or full-qualified locator:
|
|
17
|
+
bunx @lythos/skill-deck@0.15.0 add github.com/anthropics/skills/skills/frontend-design
|
|
18
18
|
|
|
19
|
-
# Sync working set (
|
|
20
|
-
bunx @lythos/skill-deck@0.
|
|
19
|
+
# Sync working set (creates symlinks, removes undeclared skills):
|
|
20
|
+
bunx @lythos/skill-deck@0.15.0 link
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
This package exposes a **CLI**. Invoke via:
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
bunx @lythos/skill-deck@0.14.6 <command> [options]
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
No installation required. `bunx` auto-downloads the package.
|
|
32
|
-
|
|
33
|
-
### skill-deck.toml (minimal)
|
|
34
|
-
|
|
35
|
-
```toml
|
|
36
|
-
[deck]
|
|
37
|
-
max_cards = 10
|
|
38
|
-
|
|
39
|
-
[tool.skills.lythoskill-deck]
|
|
40
|
-
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
41
|
-
source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
### skill-deck.toml (full reference)
|
|
45
|
-
|
|
46
|
-
```toml
|
|
47
|
-
[deck]
|
|
48
|
-
max_cards = 10 # Hard limit on total skills
|
|
49
|
-
working_set = ".claude/skills" # Where symlinks are created
|
|
50
|
-
cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
|
|
51
|
-
|
|
52
|
-
[innate.skills.lythoskill-deck] # Always-loaded skills
|
|
53
|
-
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
54
|
-
source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
|
|
55
|
-
|
|
56
|
-
[tool.skills.tdd] # Auto-triggered skills
|
|
57
|
-
path = "github.com/mattpocock/skills/skills/engineering/tdd"
|
|
58
|
-
|
|
59
|
-
[tool.skills.gstack]
|
|
60
|
-
path = "github.com/garrytan/gstack"
|
|
61
|
-
|
|
62
|
-
[transient.trial-skill] # Trial skills with auto-expiry
|
|
63
|
-
path = "./skills/experimental"
|
|
64
|
-
expires = "2026-06-01" # ISO date; warns at ≤14 days
|
|
65
|
-
|
|
66
|
-
# combo is a meta-declaration, not a skill type (doesn't count against max_cards):
|
|
67
|
-
[combo.report-generation]
|
|
68
|
-
skills = ["web-search", "docx", "mermaid"]
|
|
69
|
-
prompt = "Search for latest info, then generate professional document with diagrams"
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### When to invoke
|
|
73
|
-
|
|
74
|
-
| Situation | Command |
|
|
75
|
-
|-----------|---------|
|
|
76
|
-
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.14.6 link` |
|
|
77
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.14.6 validate` |
|
|
78
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.14.6 add owner/repo` |
|
|
79
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.14.6 refresh` |
|
|
80
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.14.6 refresh tdd` |
|
|
81
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.14.6 remove tdd` |
|
|
82
|
-
| Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.14.6 to-symlink tdd` |
|
|
83
|
-
| Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.14.6 to-snapshot tdd` |
|
|
84
|
-
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.14.6 link --deck ./my-deck.toml --workdir /path/to/project` |
|
|
85
|
-
|
|
86
|
-
### Commands
|
|
23
|
+
## Commands
|
|
87
24
|
|
|
88
25
|
| Command | Args | Description |
|
|
89
26
|
|---------|------|-------------|
|
|
90
|
-
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `remove` | `<
|
|
95
|
-
| `to-symlink` | `<alias> [--deck
|
|
96
|
-
| `to-snapshot` | `<alias> [--deck
|
|
27
|
+
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills. |
|
|
28
|
+
| `add` | `<locator> [--alias] [--type] [--deck]` | Add skill to cold pool + deck.toml. Accepts skills.sh syntax and FQ locators. |
|
|
29
|
+
| `refresh` | `[<alias>] [--deck] [--exec]` | Scan declared skills for upstream updates. Plan-only by default; add `--exec` to pull. |
|
|
30
|
+
| `validate` | `[deck.toml] [--workdir]` | Validate deck config without modifying files. |
|
|
31
|
+
| `remove` | `<alias> [--deck]` | Remove skill from deck.toml and working set. Cold pool untouched. |
|
|
32
|
+
| `to-symlink` | `<alias> [--deck] [--workdir]` | Switch a skill to symlink mode (live link, follows cold pool). |
|
|
33
|
+
| `to-snapshot` | `<alias> [--deck] [--workdir]` | Switch a skill to snapshot mode (pinned copy of current HEAD). |
|
|
34
|
+
| `migrate-schema` | `[--dry-run]` | Convert legacy string-array deck.toml to alias-as-key dict. |
|
|
97
35
|
|
|
98
|
-
|
|
36
|
+
## Options
|
|
99
37
|
|
|
100
38
|
| Flag | Description | Default |
|
|
101
39
|
|------|-------------|---------|
|
|
102
40
|
| `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
|
|
103
41
|
| `--workdir <dir>` | Working directory | cwd |
|
|
42
|
+
| `--alias <name>` | Explicit alias (default: basename of path) | — |
|
|
43
|
+
| `--type <type>` | Target section for `add`: `innate`, `tool`, `transient` | `tool` |
|
|
44
|
+
| `--mode <mode>` | Link mode: `symlink` (default) or `snapshot` | `symlink` |
|
|
45
|
+
| `--no-backup` | Skip tar backup when removing non-symlink entries | — |
|
|
46
|
+
| `--dry-run` | Show plan without executing | — |
|
|
47
|
+
| `--exec` | For `refresh`: execute git pull instead of plan-only | — |
|
|
104
48
|
|
|
105
|
-
|
|
106
|
-
| `--type <type>` | Target section for `add`: `innate`, `tool`, or `transient` | `tool` |
|
|
107
|
-
| `--mode <mode>` | Link mode for `add`/`link`: `symlink` (default) or `snapshot` (copy, pinned to cold pool HEAD) | `symlink` |
|
|
108
|
-
|
|
109
|
-
### Safety guards
|
|
49
|
+
## Safety guards
|
|
110
50
|
|
|
111
51
|
`link` refuses to operate if `working_set` resolves to your home directory or root (`/`).
|
|
112
52
|
|
|
113
|
-
**Snapshot mode** (`--mode snapshot`
|
|
53
|
+
**Snapshot mode** (`--mode snapshot`): copies the source directory into the working set instead of symlinking. Snapshots are pinned to the cold pool version at link time. Use `deck to-symlink <alias>` to switch back.
|
|
114
54
|
|
|
115
|
-
|
|
55
|
+
## Exit codes
|
|
116
56
|
|
|
117
57
|
| Code | Meaning |
|
|
118
58
|
|------|---------|
|
|
119
59
|
| `0` | Success |
|
|
120
60
|
| `1` | Validation failed, deck not found, or budget exceeded |
|
|
121
61
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
## For Humans
|
|
125
|
-
|
|
126
|
-
### Why
|
|
62
|
+
## Why
|
|
127
63
|
|
|
128
64
|
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.
|
|
129
65
|
|
|
130
|
-
`skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to
|
|
131
|
-
|
|
132
|
-
### Quick Start
|
|
66
|
+
`skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to the working set and **removes everything else**. Deny-by-default means undeclared skills physically do not exist in the agent's view.
|
|
133
67
|
|
|
134
|
-
|
|
135
|
-
# 1. Create a skill-deck.toml
|
|
136
|
-
cat > skill-deck.toml << 'EOF'
|
|
137
|
-
[deck]
|
|
138
|
-
max_cards = 10
|
|
139
|
-
|
|
140
|
-
[tool.skills.lythoskill-deck]
|
|
141
|
-
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
142
|
-
source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
|
|
143
|
-
EOF
|
|
144
|
-
|
|
145
|
-
# 2. Link — creates symlinks in .claude/skills/
|
|
146
|
-
bunx @lythos/skill-deck@0.14.6 link
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
### Key Concepts
|
|
68
|
+
## Key Concepts
|
|
150
69
|
|
|
151
70
|
| Concept | One-liner |
|
|
152
71
|
|---------|-----------|
|
|
153
72
|
| **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
|
|
154
73
|
| **skill-deck.toml** | Declares desired state: "this project uses these skills." |
|
|
155
74
|
| **`deck link`** | Reconciler. Makes the working set match the declaration. |
|
|
156
|
-
| **Working Set** |
|
|
75
|
+
| **Working Set** | Where symlinks are created. Default: `.claude/skills/`. |
|
|
157
76
|
| **deny-by-default** | Undeclared skills are physically absent from the working set. |
|
|
158
77
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
Different agents look for skills in different directories. `skill-deck.toml` configures the working set to match your agent:
|
|
162
|
-
|
|
163
|
-
| Agent | Default skills directory |
|
|
164
|
-
|-------|--------------------------|
|
|
165
|
-
| Claude Code | `.claude/skills/` |
|
|
166
|
-
| Cursor | `.cursor/skills/` |
|
|
167
|
-
| Generic / custom | Configure `working_set` in `skill-deck.toml` |
|
|
78
|
+
## Platform Quick Reference
|
|
168
79
|
|
|
169
|
-
|
|
80
|
+
Different agents scan different directories. Set `working_set` in `skill-deck.toml` to match your agent:
|
|
170
81
|
|
|
171
|
-
|
|
82
|
+
| Agent | Default path | Notes |
|
|
83
|
+
|-------|-------------|-------|
|
|
84
|
+
| Claude Code | `.claude/skills/` | Default. No config needed. |
|
|
85
|
+
| Cursor | `.cursor/skills/` | Set `working_set` before `link`. |
|
|
86
|
+
| OpenClaw | `.agents/skills/` | Project-level deck recommended. See [POSSE syndication](https://github.com/lythos-labs/lythoskill/blob/main/cortex/wiki/01-patterns/2026-05-05-multi-agent-posse-syndication.md). |
|
|
87
|
+
| Hermes | `.hermes/skills/` | Use `external_dirs` in `~/.hermes/config.yaml` to point to project path. |
|
|
172
88
|
|
|
173
|
-
|
|
89
|
+
> **If you are an agent**: verify where your platform scans for skills, then set `working_set` before running `deck link`.
|
|
174
90
|
|
|
175
|
-
|
|
176
|
-
|----------|----------|----------|
|
|
177
|
-
| 1 | `<workspace>/skills` | Workspace-level override |
|
|
178
|
-
| 2 | `<workspace>/.agents/skills` | **Project deck (recommended)** |
|
|
179
|
-
| 3 | `~/.agents/skills` | Personal agent skills |
|
|
180
|
-
| 4 | `~/.openclaw/skills` | Global managed skills |
|
|
181
|
-
|
|
182
|
-
**Per-project deck** (most common):
|
|
183
|
-
```toml
|
|
184
|
-
[deck]
|
|
185
|
-
working_set = ".agents/skills"
|
|
186
|
-
```
|
|
187
|
-
This matches OpenClaw's "project agent skills" path. Run `deck link` from your project root.
|
|
188
|
-
|
|
189
|
-
**Global deck** (manage all OpenClaw skills centrally):
|
|
190
|
-
```toml
|
|
191
|
-
[deck]
|
|
192
|
-
working_set = "~/.openclaw/skills"
|
|
193
|
-
```
|
|
194
|
-
Create this in your home directory. One global deck can syndicate to all projects via OpenClaw's fallback chain.
|
|
195
|
-
|
|
196
|
-
### For Hermes
|
|
197
|
-
|
|
198
|
-
Hermes keeps skills in `~/.hermes/skills/` and supports scanning additional directories via `external_dirs` in `~/.hermes/config.yaml`. This makes Hermes + deck integration clean: deck manages the working set, Hermes reads it through `external_dirs`.
|
|
199
|
-
|
|
200
|
-
**Recommended: project-level deck + external_dirs**
|
|
201
|
-
|
|
202
|
-
```toml
|
|
203
|
-
# skill-deck.toml
|
|
204
|
-
[deck]
|
|
205
|
-
working_set = ".hermes/skills"
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
```yaml
|
|
209
|
-
# ~/.hermes/config.yaml
|
|
210
|
-
skills:
|
|
211
|
-
external_dirs:
|
|
212
|
-
- /absolute/path/to/your/project/.hermes/skills
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Run `deck link` from your project root. Hermes picks up the syndicated skills without touching its primary `~/.hermes/skills/` directory.
|
|
216
|
-
|
|
217
|
-
**Alternative: direct mode (not recommended)**
|
|
218
|
-
|
|
219
|
-
You can point deck directly at `~/.hermes/skills`:
|
|
220
|
-
|
|
221
|
-
```toml
|
|
222
|
-
[deck]
|
|
223
|
-
working_set = "~/.hermes/skills"
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
Caution: deck's deny-by-default will remove any skills not declared in your deck, including Hermes' bundled skills. Only use this if your deck explicitly declares every skill you want Hermes to see.
|
|
227
|
-
|
|
228
|
-
### Troubleshooting
|
|
91
|
+
## Troubleshooting
|
|
229
92
|
|
|
230
93
|
| Symptom | Cause | Fix |
|
|
231
94
|
|---------|-------|-----|
|
|
232
|
-
| `❌ Skill not found: <name>` | Skill declared
|
|
233
|
-
| `link` skips entries with warnings | Real files/directories exist in working set
|
|
234
|
-
| `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone
|
|
235
|
-
| `
|
|
236
|
-
| `link` refuses with "
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
| `deck add` fails with 404 | Locator format wrong or repo doesn't exist | Format: `github.com/owner/repo/skill-name` (path to skill directory inside repo) |
|
|
95
|
+
| `❌ Skill not found: <name>` | Skill declared but not in cold pool | `deck add github.com/owner/repo/skill` or clone manually |
|
|
96
|
+
| `link` skips entries with warnings | Real files/directories exist in working set | Delete real directories in `working_set` and re-run `link` |
|
|
97
|
+
| `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone or use `deck add` |
|
|
98
|
+
| `link` refuses with "budget exceeded" | Declared skills > `max_cards` | Increase `max_cards` or remove unused skills |
|
|
99
|
+
| `link` refuses with "unsafe working_set" | `working_set` resolves to `~` or `/` | Use a relative path (e.g. `.claude/skills/`) |
|
|
100
|
+
| Agent doesn't see skills after `link` | `working_set` path doesn't match agent's scan location | Verify platform docs and set `working_set` correctly |
|
|
101
|
+
| Broken symlinks in working set | Skill moved or deleted from cold pool | Re-run `link` — recreates symlinks automatically |
|
|
102
|
+
| `deck add` fails with 404 | Locator format wrong or repo doesn't exist | Format: `github.com/owner/repo/skill-name` |
|
|
241
103
|
| `skill-deck.toml not found` | Running `link` outside project tree | Run from project root, or use `--deck ./path/to/skill-deck.toml` |
|
|
242
104
|
|
|
243
|
-
##
|
|
105
|
+
## Architecture
|
|
244
106
|
|
|
245
|
-
Deck
|
|
107
|
+
Deck separates pure logic from IO:
|
|
246
108
|
|
|
247
109
|
```
|
|
248
|
-
|
|
249
|
-
↑ │
|
|
250
|
-
└──────────────────── reconciliation loop ─────────────────────┘
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
| K8s Concept | Deck Equivalent |
|
|
254
|
-
|-------------|-----------------|
|
|
255
|
-
| Desired state (YAML manifest) | `skill-deck.toml` |
|
|
256
|
-
| Actual state (running pods) | Working set (`~/.claude/skills/`) |
|
|
257
|
-
| Controller manager (reconcile loop) | Agent reads state → builds plan → user confirms |
|
|
258
|
-
| `kubectl apply` | `deck link` |
|
|
259
|
-
| Namespace (isolation) | Per-project deck file |
|
|
260
|
-
| PersistentVolume | Cold pool (`~/.agents/skill-repos/`) |
|
|
261
|
-
|
|
262
|
-
The loop doesn't run automatically (no daemon). The agent is the loop — it observes, plans, confirms, and executes on demand. This is K8s-style **declarative governance**: declare what you want, reconcile to match.
|
|
263
|
-
|
|
264
|
-
## Multi-Agent POSSE Syndication
|
|
265
|
-
|
|
266
|
-
Not "switching between agents" — **syndicating everywhere simultaneously**. Like IndieWeb's POSSE (Publish on your Own Site, Syndicate Elsewhere):
|
|
267
|
-
|
|
268
|
-
```
|
|
269
|
-
Cold Pool (~/.agents/skill-repos/) ← canonical "own site"
|
|
270
|
-
↓ deck link --workdir
|
|
271
|
-
├── .claude/skills/ ← syndicate to Claude Code
|
|
272
|
-
├── .cursor/skills/ ← syndicate to Cursor
|
|
273
|
-
├── .codex/skills/ ← syndicate to Codex
|
|
274
|
-
└── .windsurf/skills/ ← syndicate to Windsurf
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
One cold pool, one deck declaration, synced to every agent you use. Adding a new platform is updating a key-value registry — no code changes needed. See [multi-agent-posse-syndication](https://github.com/lythos-labs/lythoskill/blob/main/cortex/wiki/01-patterns/2026-05-05-multi-agent-posse-syndication.md).
|
|
278
|
-
|
|
279
|
-
## Migration: For Existing Skill Users
|
|
280
|
-
|
|
281
|
-
If you already have skills installed (in working set, globally, or mixed), deck respects your existing state:
|
|
282
|
-
|
|
283
|
-
```
|
|
284
|
-
1. SCAN Agent surveys: what's in ~/.claude/skills/? What's global? What's mixed?
|
|
285
|
-
curator scan helps — indexes cold pool or existing working set.
|
|
286
|
-
|
|
287
|
-
2. PLAN Agent shows: "We found 12 skills. After migration:
|
|
288
|
-
- 2 → innate (deck infrastructure)
|
|
289
|
-
- 4 → tool section
|
|
290
|
-
- 3 → cold pool (already there, just link)
|
|
291
|
-
- 3 → backup only (unused, stale)
|
|
292
|
-
All 12 backed up to ~/.agents/lythos/backups/<date>.tar.gz"
|
|
293
|
-
|
|
294
|
-
3. BACKUP Always. `link` creates tar backups for non-symlink entries before removal.
|
|
295
|
-
Use `--no-backup` only if you're certain.
|
|
296
|
-
|
|
297
|
-
4. EXECUTE deck link — creates symlinks (default) or snapshots (--mode snapshot), removes undeclared entries.
|
|
298
|
-
|
|
299
|
-
5. VERIFY Agent checks: all declared skills resolve? Working set clean?
|
|
300
|
-
If unhappy: tar xf backup → rollback to pre-migration state.
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
**Key principle**: existing skill users aren't beginners. They have working setups. Migration is a conversation — scan, show the plan, confirm before acting. Backup is non-negotiable.
|
|
304
|
-
|
|
305
|
-
## Architecture: Intent / Plan / Execute
|
|
306
|
-
|
|
307
|
-
Deck commands separate pure logic from IO:
|
|
308
|
-
|
|
309
|
-
```
|
|
310
|
-
deck.toml → RefreshPlan / PrunePlan (pure) → execute with injectable IO
|
|
110
|
+
deck.toml → RefreshPlan / PrunePlan (pure) → execute with injectable IO
|
|
311
111
|
```
|
|
312
112
|
|
|
313
113
|
- **Plan**: `buildRefreshPlan()`, `buildPrunePlan()` — pure functions, unit-testable
|
|
314
114
|
- **Execute**: `executeRefreshPlan(plan, io)`, `executePrunePlan(plan, io)` — IO injected (`gitPull`, `delete`, `log`)
|
|
315
|
-
- **Config**: `workdir`, `coldPool`, `deckPath` all accept explicit overrides, defaults are fallback
|
|
316
115
|
|
|
317
|
-
This enables testing without real git operations
|
|
116
|
+
This enables testing without real git operations. Full pattern: [Intent / Plan / Execute](https://github.com/lythos-labs/lythoskill/blob/main/cortex/wiki/01-patterns/2026-05-04-intent-plan-execute-fractal-architecture-pattern.md).
|
|
318
117
|
|
|
319
118
|
## Test Coverage
|
|
320
119
|
|
|
321
|
-
| Layer | Count | CI |
|
|
322
|
-
|
|
323
|
-
| Unit tests | 71 | ✅ |
|
|
324
|
-
| CLI BDD | 21 | ✅ |
|
|
325
|
-
| Agent BDD | 5 |
|
|
326
|
-
|
|
327
|
-
Coverage is honest — no gate, no inflation. Agent BDD scenarios run locally only.
|
|
120
|
+
| Layer | Count | CI |
|
|
121
|
+
|-------|-------|----|
|
|
122
|
+
| Unit tests | 71 | ✅ |
|
|
123
|
+
| CLI BDD | 21 | ✅ |
|
|
124
|
+
| Agent BDD | 5 | local only |
|
|
328
125
|
|
|
329
126
|
## More Documentation
|
|
330
127
|
|
|
331
|
-
- **Skill layer** (agent-facing
|
|
332
|
-
|
|
333
|
-
- **
|
|
334
|
-
[`README.md`](https://github.com/lythos-labs/lythoskill#readme)
|
|
335
|
-
- **Architecture** (thin-skill pattern, three-layer separation):
|
|
336
|
-
[`AGENTS.md`](https://github.com/lythos-labs/lythoskill/blob/main/AGENTS.md)
|
|
128
|
+
- **Skill layer** (agent-facing): [SKILL.md](https://github.com/lythos-labs/lythoskill/blob/main/packages/lythoskill-deck/skill/SKILL.md)
|
|
129
|
+
- **Project README** (ecosystem overview): [README.md](https://github.com/lythos-labs/lythoskill#readme)
|
|
130
|
+
- **Architecture**: [AGENTS.md](https://github.com/lythos-labs/lythoskill/blob/main/AGENTS.md)
|
|
337
131
|
|
|
338
132
|
## License
|
|
339
133
|
|
|
340
134
|
MIT
|
|
341
|
-
|
|
342
|
-
<!-- test-stats -->
|
|
343
|
-
 
|
|
344
|
-
|
|
345
|
-
```
|
|
346
|
-
File | % Funcs | % Lines | Uncovered Line #s
|
|
347
|
-
| --- | --- | --- |
|
|
348
|
-
All files | 74.76 | 81.66 |
|
|
349
|
-
src/add.ts | 83.33 | 71.17 | 43,45-49,54-58,61-70,86-88,103-105,123-124,132-134,166,170,181-187,195-200
|
|
350
|
-
src/link.ts | 43.75 | 61.40 | 112-120,125,128-132,140-151,155-156,171-180,189,215-216,219-220,227-234,246-248,253-254,256-270,274-277,280-287,294-296,307-309,316-319,335,343-346,348-353,355-358,360-373,375-376,379-381,416-417,463-470,488-489,497-499,501-507,533-534,546
|
|
351
|
-
src/parse-deck.ts | 100.00 | 92.45 | 47-50
|
|
352
|
-
src/prune-plan.ts | 75.00 | 97.27 | 179-181
|
|
353
|
-
src/prune.ts | 50.00 | 36.21 | 24-26,29-40,44-73,77-90,94-100,111-113,125-126,144,149-150
|
|
354
|
-
src/refresh-plan.ts | 75.00 | 97.66 | 170-172
|
|
355
|
-
src/refresh.ts | 85.71 | 87.30 | 43-44,56-58,73,77-78
|
|
356
|
-
src/remove.ts | 60.00 | 91.53 | 22-24,44
|
|
357
|
-
src/schema.ts | 100.00 | 100.00 |
|
|
358
|
-
```
|
|
359
|
-
<!-- /test-stats -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/skill-deck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@iarna/toml": "^2.2.5",
|
|
34
|
-
"@lythos/cold-pool": "^0.
|
|
35
|
-
"@lythos/infra": "^0.
|
|
34
|
+
"@lythos/cold-pool": "^0.15.0",
|
|
35
|
+
"@lythos/infra": "^0.15.0",
|
|
36
36
|
"yaml": "^2.8.3",
|
|
37
37
|
"zod": "^4.3.6"
|
|
38
38
|
},
|
package/src/link.test.ts
CHANGED
|
@@ -285,4 +285,85 @@ describe('linkDeck reconciler', () => {
|
|
|
285
285
|
expect(existsSync(join(dest, 'SKILL.md'))).toBe(true)
|
|
286
286
|
})
|
|
287
287
|
|
|
288
|
+
it('B4: also_link_to fan-out creates symlinks in additional targets', () => {
|
|
289
|
+
const projectDir = makeTmp()
|
|
290
|
+
const coldPoolRel = 'cold-pool'
|
|
291
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
292
|
+
mkdirSync(join(projectDir, '.claude'), { recursive: true })
|
|
293
|
+
|
|
294
|
+
const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill')
|
|
295
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
296
|
+
const deckContent = `[deck]
|
|
297
|
+
working_set = ".claude/skills"
|
|
298
|
+
cold_pool = "${coldPoolRel}"
|
|
299
|
+
also_link_to = [".agents/skills", ".kimi/skills"]
|
|
300
|
+
|
|
301
|
+
[tool.skills.my-alias]
|
|
302
|
+
path = "github.com/owner/repo/skill"
|
|
303
|
+
`
|
|
304
|
+
writeFileSync(deckPath, deckContent)
|
|
305
|
+
linkDeck(deckPath, projectDir, { noBackup: true })
|
|
306
|
+
|
|
307
|
+
const primary = join(projectDir, '.claude', 'skills', 'my-alias')
|
|
308
|
+
const agents = join(projectDir, '.agents', 'skills', 'my-alias')
|
|
309
|
+
const kimi = join(projectDir, '.kimi', 'skills', 'my-alias')
|
|
310
|
+
|
|
311
|
+
expect(existsSync(primary)).toBe(true)
|
|
312
|
+
expect(lstatSync(primary).isSymbolicLink()).toBe(true)
|
|
313
|
+
expect(existsSync(agents)).toBe(true)
|
|
314
|
+
expect(lstatSync(agents).isSymbolicLink()).toBe(true)
|
|
315
|
+
expect(existsSync(kimi)).toBe(true)
|
|
316
|
+
expect(lstatSync(kimi).isSymbolicLink()).toBe(true)
|
|
317
|
+
|
|
318
|
+
// All point to the same source
|
|
319
|
+
expect(readlinkSync(primary)).toBe(skillDir)
|
|
320
|
+
expect(readlinkSync(agents)).toBe(skillDir)
|
|
321
|
+
expect(readlinkSync(kimi)).toBe(skillDir)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('B5: also_link_to respects deny-by-default in each target', () => {
|
|
325
|
+
const projectDir = makeTmp()
|
|
326
|
+
const coldPoolRel = 'cold-pool'
|
|
327
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
328
|
+
|
|
329
|
+
const skillA = placeSkill(coldPool, 'github.com/owner/skill-a')
|
|
330
|
+
const skillB = placeSkill(coldPool, 'github.com/owner/skill-b')
|
|
331
|
+
|
|
332
|
+
// First link with both skills
|
|
333
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
334
|
+
const deckV1 = `[deck]
|
|
335
|
+
working_set = ".claude/skills"
|
|
336
|
+
cold_pool = "${coldPoolRel}"
|
|
337
|
+
also_link_to = [".agents/skills"]
|
|
338
|
+
|
|
339
|
+
[tool.skills.skill-a]
|
|
340
|
+
path = "github.com/owner/skill-a"
|
|
341
|
+
|
|
342
|
+
[tool.skills.skill-b]
|
|
343
|
+
path = "github.com/owner/skill-b"
|
|
344
|
+
`
|
|
345
|
+
writeFileSync(deckPath, deckV1)
|
|
346
|
+
linkDeck(deckPath, projectDir, { noBackup: true })
|
|
347
|
+
|
|
348
|
+
expect(existsSync(join(projectDir, '.agents', 'skills', 'skill-b'))).toBe(true)
|
|
349
|
+
|
|
350
|
+
// Second link: remove skill-b
|
|
351
|
+
const deckV2 = `[deck]
|
|
352
|
+
working_set = ".claude/skills"
|
|
353
|
+
cold_pool = "${coldPoolRel}"
|
|
354
|
+
also_link_to = [".agents/skills"]
|
|
355
|
+
|
|
356
|
+
[tool.skills.skill-a]
|
|
357
|
+
path = "github.com/owner/skill-a"
|
|
358
|
+
`
|
|
359
|
+
writeFileSync(deckPath, deckV2)
|
|
360
|
+
linkDeck(deckPath, projectDir, { noBackup: true })
|
|
361
|
+
|
|
362
|
+
// skill-b should be removed from BOTH working_set and also_link_to
|
|
363
|
+
expect(existsSync(join(projectDir, '.claude', 'skills', 'skill-a'))).toBe(true)
|
|
364
|
+
expect(existsSync(join(projectDir, '.claude', 'skills', 'skill-b'))).toBe(false)
|
|
365
|
+
expect(existsSync(join(projectDir, '.agents', 'skills', 'skill-a'))).toBe(true)
|
|
366
|
+
expect(existsSync(join(projectDir, '.agents', 'skills', 'skill-b'))).toBe(false)
|
|
367
|
+
})
|
|
368
|
+
|
|
288
369
|
})
|
package/src/link.ts
CHANGED
|
@@ -39,6 +39,20 @@ export function expandHome(p: string, base: string): string {
|
|
|
39
39
|
return resolve(base, p);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function parseAlsoLinkTo(raw: any, projectDir: string): { targets: string[], deprecated: boolean } {
|
|
43
|
+
if (Array.isArray(raw)) {
|
|
44
|
+
return {
|
|
45
|
+
targets: raw.filter((v: any) => typeof v === 'string').map(p => expandHome(p, projectDir)),
|
|
46
|
+
deprecated: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
50
|
+
const targets = raw.split(',').map(s => s.trim()).filter(Boolean).map(p => expandHome(p, projectDir));
|
|
51
|
+
return { targets, deprecated: true };
|
|
52
|
+
}
|
|
53
|
+
return { targets: [], deprecated: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function hashContent(content: string): string {
|
|
43
57
|
return createHash("sha256").update(content).digest("hex");
|
|
44
58
|
}
|
|
@@ -180,6 +194,11 @@ const COLD_POOL_RAW = parsedToml.deck?.cold_pool || "~/.agents/skill-repos";
|
|
|
180
194
|
const WORKING_SET = expandHome(WORKING_SET_RAW, PROJECT_DIR);
|
|
181
195
|
const COLD_POOL = expandHome(COLD_POOL_RAW, PROJECT_DIR);
|
|
182
196
|
const MAX_CARDS = Number(parsedToml.deck?.max_cards || 10);
|
|
197
|
+
const ALSO_LINK_TO_RESULT = parseAlsoLinkTo(parsedToml.deck?.also_link_to, PROJECT_DIR);
|
|
198
|
+
const ALSO_LINK_TO = ALSO_LINK_TO_RESULT.targets;
|
|
199
|
+
if (ALSO_LINK_TO_RESULT.deprecated) {
|
|
200
|
+
console.warn('⚠️ Deprecation: also_link_to as comma-separated string is deprecated. Use TOML array: also_link_to = [".agents/skills"]');
|
|
201
|
+
}
|
|
183
202
|
|
|
184
203
|
// ── 收集声明 ────────────────────────────────────────────────
|
|
185
204
|
|
|
@@ -336,89 +355,108 @@ if (
|
|
|
336
355
|
console.warn(` cold_pool: ${resolvedColdPool}`);
|
|
337
356
|
}
|
|
338
357
|
|
|
339
|
-
// ──
|
|
358
|
+
// ── 目录收束(复用于 working_set 和 also_link_to)────────
|
|
359
|
+
function reconcileTargetDir(
|
|
360
|
+
targetDir: string,
|
|
361
|
+
declared: DeclaredSkill[],
|
|
362
|
+
declaredNames: Set<string>,
|
|
363
|
+
noBackup: boolean | undefined,
|
|
364
|
+
mode: 'symlink' | 'snapshot',
|
|
365
|
+
PROJECT_DIR: string,
|
|
366
|
+
): void {
|
|
367
|
+
mkdirSync(targetDir, { recursive: true });
|
|
368
|
+
|
|
369
|
+
const nonSymlinks: string[] = [];
|
|
370
|
+
try {
|
|
371
|
+
for (const entry of readdirSync(targetDir)) {
|
|
372
|
+
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
373
|
+
const entryPath = join(targetDir, entry);
|
|
374
|
+
try {
|
|
375
|
+
const st = lstatSync(entryPath);
|
|
376
|
+
if (!st.isSymbolicLink()) nonSymlinks.push(entry);
|
|
377
|
+
} catch { continue; }
|
|
378
|
+
}
|
|
379
|
+
} catch {}
|
|
340
380
|
|
|
341
|
-
|
|
381
|
+
if (nonSymlinks.length > 0) {
|
|
382
|
+
let totalSize = 0;
|
|
383
|
+
for (const e of nonSymlinks) totalSize += calculateDirSize(join(targetDir, e));
|
|
342
384
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
const st = lstatSync(entryPath);
|
|
351
|
-
if (!st.isSymbolicLink()) {
|
|
352
|
-
nonSymlinks.push(entry);
|
|
353
|
-
}
|
|
354
|
-
} catch { continue; }
|
|
355
|
-
}
|
|
356
|
-
} catch {}
|
|
385
|
+
if (!noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
|
|
386
|
+
console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, targetDir)} (> 100MB total).`);
|
|
387
|
+
console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
|
|
388
|
+
console.error(" Use --no-backup to skip backup, or clean up manually.");
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
357
391
|
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
392
|
+
if (!noBackup) {
|
|
393
|
+
const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
|
|
394
|
+
const bakPath = join(PROJECT_DIR, ".claude", bakName);
|
|
395
|
+
mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
|
|
396
|
+
const tarArgs = ["czf", bakPath, "--", ...nonSymlinks.map(e => "./" + relative(PROJECT_DIR, join(targetDir, e)))];
|
|
397
|
+
try {
|
|
398
|
+
execFileSync("tar", tarArgs, { cwd: PROJECT_DIR, stdio: "pipe" });
|
|
399
|
+
console.log(`📦 Backed up ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} to .claude/${bakName}`);
|
|
400
|
+
} catch (err: any) {
|
|
401
|
+
console.error(`❌ Backup failed: ${err.message || err}`);
|
|
402
|
+
console.error(" Use --no-backup to skip backup, or fix the issue and retry.");
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
console.log(`⚠️ --no-backup: removing ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} without backup`);
|
|
407
|
+
}
|
|
364
408
|
|
|
365
|
-
|
|
366
|
-
console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, WORKING_SET)} (> 100MB total).`);
|
|
367
|
-
console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
|
|
368
|
-
console.error(` Use --no-backup to skip backup (removes without saving), or clean up manually.`);
|
|
369
|
-
process.exit(1);
|
|
409
|
+
for (const e of nonSymlinks) rmSync(join(targetDir, e), { recursive: true, force: true });
|
|
370
410
|
}
|
|
371
411
|
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
412
|
+
try {
|
|
413
|
+
for (const entry of readdirSync(targetDir)) {
|
|
414
|
+
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
415
|
+
if (!declaredNames.has(entry)) {
|
|
416
|
+
const entryPath = join(targetDir, entry);
|
|
417
|
+
try {
|
|
418
|
+
const st = lstatSync(entryPath);
|
|
419
|
+
if (!st.isSymbolicLink()) continue;
|
|
420
|
+
} catch { continue; }
|
|
421
|
+
rmSync(entryPath, { recursive: true, force: true });
|
|
422
|
+
console.log(` 🗑️ Removed: ${entry}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch {}
|
|
376
426
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
];
|
|
427
|
+
for (const item of declared) {
|
|
428
|
+
const dest = join(targetDir, item.alias);
|
|
429
|
+
try { lstatSync(dest); rmSync(dest, { recursive: true, force: true }); } catch {}
|
|
381
430
|
try {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
console.log(`📦 Backed up ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} to .claude/${bakName}`);
|
|
431
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
432
|
+
const linkMode = item.mode ?? mode;
|
|
433
|
+
if (linkMode === 'snapshot') cpSync(item.sourcePath, dest, { recursive: true });
|
|
434
|
+
else symlinkSync(item.sourcePath, dest);
|
|
387
435
|
} catch (err: any) {
|
|
388
|
-
console.error(`❌
|
|
389
|
-
|
|
390
|
-
process.exit(1);
|
|
436
|
+
console.error(`❌ Link failed: ${item.alias}: ${err.message || err}`);
|
|
437
|
+
continue;
|
|
391
438
|
}
|
|
392
|
-
|
|
393
|
-
console.log(`⚠️ --no-backup: removing ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} without backup`);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
for (const e of nonSymlinks) {
|
|
397
|
-
rmSync(join(WORKING_SET, e), { recursive: true, force: true });
|
|
439
|
+
console.log(` 🔗 ${item.alias}`);
|
|
398
440
|
}
|
|
399
441
|
}
|
|
400
442
|
|
|
401
|
-
//
|
|
443
|
+
// ── 收束 working set ────────────────────────────────────────
|
|
444
|
+
|
|
402
445
|
const declaredNames = new Set(declared.map(d => d.alias));
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
console.log(` 🗑️ Removed: ${entry}`);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
} catch {}
|
|
446
|
+
reconcileTargetDir(WORKING_SET, declared, declaredNames, opts?.noBackup, MODE, PROJECT_DIR);
|
|
447
|
+
|
|
448
|
+
// also_link_to fan-out (POSSE pattern, ADR-20260517152850372)
|
|
449
|
+
for (const target of ALSO_LINK_TO) {
|
|
450
|
+
console.log('');
|
|
451
|
+
console.log('📋 also_link_to: ' + relative(PROJECT_DIR, target));
|
|
452
|
+
reconcileTargetDir(target, declared, declaredNames, opts?.noBackup, MODE, PROJECT_DIR);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── 收集元数据 ──────────────────────────────────────────────
|
|
417
456
|
|
|
418
|
-
|
|
419
|
-
const linkedSkills: LinkedSkill[] = [];
|
|
457
|
+
const linkedSkills: LinkedSkill[] = [];
|
|
420
458
|
|
|
421
|
-
for (const item of declared) {
|
|
459
|
+
for (const item of declared) {
|
|
422
460
|
const dest = join(WORKING_SET, item.alias);
|
|
423
461
|
|
|
424
462
|
// 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
|
package/src/remove.test.ts
CHANGED
|
@@ -142,4 +142,64 @@ describe('removeSkill', () => {
|
|
|
142
142
|
expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
|
|
143
143
|
expect(existsSync(skillDir)).toBe(true)
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
it('C12: remove cleans also_link_to targets', async () => {
|
|
147
|
+
const projectDir = makeTmp()
|
|
148
|
+
const coldPoolRel = 'cold-pool'
|
|
149
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
150
|
+
const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
|
|
151
|
+
|
|
152
|
+
const deckContent = `[deck]
|
|
153
|
+
max_cards = 10
|
|
154
|
+
working_set = ".claude/skills"
|
|
155
|
+
cold_pool = "${coldPoolRel}"
|
|
156
|
+
also_link_to = [".agents/skills", ".kimi/skills"]
|
|
157
|
+
|
|
158
|
+
[tool.skills.skill-a]
|
|
159
|
+
path = "github.com/owner/repo/skill-a"
|
|
160
|
+
`
|
|
161
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
162
|
+
writeFileSync(deckPath, deckContent)
|
|
163
|
+
|
|
164
|
+
// Create symlinks in all 3 targets to simulate post-link state
|
|
165
|
+
const targets = [
|
|
166
|
+
join(projectDir, '.claude', 'skills'),
|
|
167
|
+
join(projectDir, '.agents', 'skills'),
|
|
168
|
+
join(projectDir, '.kimi', 'skills'),
|
|
169
|
+
]
|
|
170
|
+
for (const t of targets) {
|
|
171
|
+
mkdirSync(t, { recursive: true })
|
|
172
|
+
symlinkSync(skillDir, join(t, 'skill-a'))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { removeSkill } = await import('./remove.ts')
|
|
176
|
+
removeSkill('skill-a', deckPath, projectDir)
|
|
177
|
+
|
|
178
|
+
const deckContentAfter = readFileSync(deckPath, 'utf-8')
|
|
179
|
+
expect(deckContentAfter).not.toContain('[tool.skills.skill-a]')
|
|
180
|
+
|
|
181
|
+
for (const t of targets) {
|
|
182
|
+
expect(existsSync(join(t, 'skill-a'))).toBe(false)
|
|
183
|
+
}
|
|
184
|
+
expect(existsSync(skillDir)).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('C13: remove with empty also_link_to preserves backward compat', async () => {
|
|
188
|
+
const projectDir = makeTmp()
|
|
189
|
+
const coldPoolRel = 'cold-pool'
|
|
190
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
191
|
+
const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
|
|
192
|
+
|
|
193
|
+
const deckPath = buildDeck(projectDir, coldPoolRel, 'skill-a', 'github.com/owner/repo/skill-a')
|
|
194
|
+
|
|
195
|
+
const workingSet = join(projectDir, '.claude', 'skills')
|
|
196
|
+
mkdirSync(workingSet, { recursive: true })
|
|
197
|
+
symlinkSync(skillDir, join(workingSet, 'skill-a'))
|
|
198
|
+
|
|
199
|
+
const { removeSkill } = await import('./remove.ts')
|
|
200
|
+
removeSkill('skill-a', deckPath, projectDir)
|
|
201
|
+
|
|
202
|
+
expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
|
|
203
|
+
expect(existsSync(skillDir)).toBe(true)
|
|
204
|
+
})
|
|
145
205
|
})
|
package/src/remove.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { parse as parseToml, stringify as stringifyToml } from "@iarna/toml";
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
11
|
import { resolve, dirname, join } from "node:path";
|
|
12
|
-
import { findDeckToml, expandHome } from "./link.js";
|
|
12
|
+
import { findDeckToml, expandHome, parseAlsoLinkTo } from "./link.js";
|
|
13
13
|
import { parseDeck } from "./parse-deck.js";
|
|
14
14
|
import { ColdPool } from "@lythos/cold-pool";
|
|
15
15
|
import { homedir } from "node:os";
|
|
@@ -33,6 +33,12 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
|
|
|
33
33
|
|
|
34
34
|
const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
|
|
35
35
|
|
|
36
|
+
const ALSO_LINK_TO_RESULT = parseAlsoLinkTo(deck.deck?.also_link_to, PROJECT_DIR);
|
|
37
|
+
const ALSO_LINK_TO = ALSO_LINK_TO_RESULT.targets;
|
|
38
|
+
if (ALSO_LINK_TO_RESULT.deprecated) {
|
|
39
|
+
console.warn('⚠️ Deprecation: also_link_to as comma-separated string is deprecated. Use TOML array: also_link_to = [".agents/skills"]');
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
// ── 定位目标 ────────────────────────────────────────────────
|
|
37
43
|
|
|
38
44
|
const { entries: parsedEntries } = parseDeck(deckRaw);
|
|
@@ -97,6 +103,18 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
|
|
|
97
103
|
console.log(` ⚠️ Symlink not found: ${symlinkPath}`);
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
// ── 删 also_link_to symlinks ─────────────────────────────────
|
|
107
|
+
|
|
108
|
+
for (const target of ALSO_LINK_TO) {
|
|
109
|
+
const linkPath = join(target, alias);
|
|
110
|
+
if (existsSync(linkPath)) {
|
|
111
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
112
|
+
console.log(` 🗑️ Removed also_link_to symlink: ${linkPath}`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(` ⚠️ also_link_to symlink not found: ${linkPath}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
// ── Metadata cleanup ────────────────────────────────────────
|
|
101
119
|
|
|
102
120
|
try {
|