skillshelf 0.4.0 → 0.5.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.
Files changed (39) hide show
  1. package/README.md +35 -35
  2. package/package.json +2 -2
  3. package/src/adapters/inference/api.ts +1 -4
  4. package/src/commands/add.published.test.ts +390 -0
  5. package/src/commands/add.test.ts +118 -0
  6. package/src/commands/add.ts +88 -40
  7. package/src/commands/drop.ts +28 -9
  8. package/src/commands/import.test.ts +71 -0
  9. package/src/commands/import.ts +14 -2
  10. package/src/commands/index.ts +3 -3
  11. package/src/commands/link.test.ts +14 -0
  12. package/src/commands/link.ts +11 -0
  13. package/src/commands/ls.ts +1 -5
  14. package/src/commands/new.test.ts +63 -0
  15. package/src/commands/new.ts +13 -2
  16. package/src/commands/rename.test.ts +14 -0
  17. package/src/commands/rename.ts +1 -3
  18. package/src/commands/retag.ts +1 -3
  19. package/src/commands/retire-reindex-once.test.ts +107 -0
  20. package/src/commands/retire.ts +18 -18
  21. package/src/commands/rm.test.ts +60 -0
  22. package/src/commands/search.ts +1 -5
  23. package/src/commands/tag.ts +1 -3
  24. package/src/commands/unretire.ts +14 -15
  25. package/src/commands/update.test.ts +135 -0
  26. package/src/commands/update.ts +196 -44
  27. package/src/commands/use.test.ts +52 -0
  28. package/src/commands/use.ts +54 -18
  29. package/src/config.test.ts +1 -1
  30. package/src/core/bundle.ts +0 -9
  31. package/src/core/crawl.ts +27 -28
  32. package/src/core/deployments.ts +0 -11
  33. package/src/core/fetch.ts +105 -3
  34. package/src/core/indexgen.ts +10 -6
  35. package/src/core/library.ts +55 -3
  36. package/src/core/lifecycle.ts +90 -31
  37. package/src/lib/frontmatter.test.ts +17 -0
  38. package/src/lib/frontmatter.ts +76 -9
  39. package/src/lib/fs.ts +1 -72
package/README.md CHANGED
@@ -3,26 +3,21 @@
3
3
  **A package manager for your agent skills — one canonical library, loaded on demand, never all at once.**
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
- [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-black?logo=bun)](https://bun.sh)
6
+ [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.2-black?logo=bun)](https://bun.sh)
7
7
  [![CI](https://img.shields.io/github/actions/workflow/status/Wang-Cankun/skillshelf/ci.yml?branch=main)](https://github.com/Wang-Cankun/skillshelf/actions)
8
8
  [![npm](https://img.shields.io/npm/v/skillshelf.svg)](https://www.npmjs.com/package/skillshelf)
9
9
 
10
- Your skills are scattered across **every agent you use** — some in `~/.claude/skills`, some in
11
- `~/.codex/skills` or `~/.cursor/skills`, some buried in Obsidian or notes vaults, more copied into
12
- a dozen per-project `.claude` / `.codex` directories. Each tool scatters its own copies and
13
- symlinks; you forget which ones exist, rewrite ones you already have, and copies drift out of sync.
14
- The naive fix — dump everything into one agent's dir — makes every session pay the token cost of
15
- loading hundreds of skill descriptions at once.
10
+ Your skills are scattered across **every agent you use** — `~/.claude/skills`, `~/.codex/skills`,
11
+ `~/.cursor/skills`, Obsidian vaults, and a dozen per-project `.claude` dirs. Copies drift, you
12
+ rewrite skills you already have, and you forget which exist. The naive fix dump everything into
13
+ one agent's dir makes every session pay to load hundreds of skill descriptions at once.
16
14
 
17
- skillshelf is **agent-agnostic** (Claude Code, Codex, Cursor, and compatible agents): the library
18
- is a neutral source, and `skl where` maps where every skill is actually deployed across all of
19
- them surfacing untracked copies, drift, and dead links. It's the curation layer *over* your
20
- agent dirs, complementary to installers like [`vercel-labs/skills`](https://github.com/vercel-labs/skills).
21
-
22
- skillshelf is the middle path: a single git-backed **library** that is a *passive shelf*
23
- (nothing auto-loads), plus a CLI to **search, tag, bundle, and load** exactly the skills a
24
- project needs, exactly when it needs them. Find anything in one place; pay only for what you
25
- actually use.
15
+ skillshelf is the middle path: one git-backed **library** that is a *passive shelf* (nothing
16
+ auto-loads), plus a CLI to **search, tag, bundle, and load** exactly the skills a project needs,
17
+ when it needs them. It's **agent-agnostic** (Claude Code, Codex, Cursor, …) `skl where` maps
18
+ where every skill is actually deployed across all of them, surfacing untracked copies, drift, and
19
+ dead links. Find anything in one place; pay only for what you use. (Complementary to installers
20
+ like [`vercel-labs/skills`](https://github.com/vercel-labs/skills).)
26
21
 
27
22
  ## Desktop app
28
23
 
@@ -51,7 +46,7 @@ matrix (Global + the projects where it's pinned), plus lifecycle actions.
51
46
 
52
47
  ## Install
53
48
 
54
- skillshelf runs on [Bun](https://bun.sh) (>= 1.0). No other runtime dependencies.
49
+ skillshelf runs on [Bun](https://bun.sh) (>= 1.2). No other runtime dependencies.
55
50
 
56
51
  > **Bun is required, not optional.** The `skl` bin is a TypeScript entrypoint with a
57
52
  > `#!/usr/bin/env bun` shebang — there is no compiled Node build. `npm i -g skillshelf`
@@ -148,27 +143,32 @@ skillshelf separates *owning* a skill from *loading* it.
148
143
  - **On-demand `show`** — prints only the SKILL.md instruction body and lists the paths of
149
144
  any bundled reference files (without reading them). Progressive disclosure: cheap by
150
145
  default, deep when you ask. Works mid-task with no reload.
151
- - **Owned vs linked entries** ([ADR-0004](./docs/adr/0004-owned-vs-linked-entries.md)) — the
152
- library is a *bookshelf*: an entry either **owns** its bytes (a real copy; the library is
153
- canonical for downloads and stabilized skills) or is **linked** (a symlink to an external dev
154
- repo that stays canonical for skills you actively develop in their own git, e.g. `claim-log`).
155
- `skl link --from <dev-repo>` registers a linked entry; `skl where` shows it as a clean
156
- `✓ source`; `skl update` / `outdated` skip linked entries so they never pull upstream into your
157
- dev repo. The mode is derived from the filesystem (a symlink resolving outside the library),
158
- never stored, so it can't go stale.
146
+ - **Owned vs linked entries** ([ADR-0004](./docs/adr/0004-owned-vs-linked-entries.md)) — an entry
147
+ either **owns** its bytes (a real copy for downloads and stabilized skills) or is **linked**
148
+ (`skl link --from <dev-repo>` a symlink to an external repo that stays canonical, for skills you
149
+ develop in their own git). `update` / `outdated` skip linked entries so they never push upstream
150
+ into your dev repo. The mode is derived from the filesystem, never stored, so it can't go stale.
159
151
  - **Updates never clobber your tags** — domain tags live in the central `taxonomy.json`
160
152
  ([ADR-0002](./docs/adr/0002-central-taxonomy-not-sidecars.md)), separate from the skill body, so
161
153
  `skl update` can swap an owned skill's upstream `SKILL.md` cleanly while your taxonomy survives.
162
154
 
163
- ```
164
- skl search / ls / show skl use <bundle>
165
- │ │
166
- ┌──────────────────────┐ │ ┌──────────────────────┐ │ ┌─────────────────────┐
167
- │ canonical library │────┴──▶│ bundles = tag query │───┴──▶│ project .claude/
168
- │ (passive git shelf) │ │ bioinfo · coding · … │ │ skills/ (symlinks) │
169
- └──────────┬───────────┘ └──────────────────────┘ └─────────────────────┘
170
-
171
- └──── thin global core ──▶ ~/.claude/skills (always-on, bounded)
155
+ ```mermaid
156
+ flowchart LR
157
+ L["📚 Canonical library<br/><i>passive git shelf</i>"]
158
+ B["🏷️ Bundles<br/><i>tag query — bioinfo · coding · …</i>"]
159
+ P["📁 Project .claude/skills/<br/><i>symlinks, on demand</i>"]
160
+ G["⚡ ~/.claude/skills<br/><i>thin global core, always-on</i>"]
161
+
162
+ L -- "skl use bundle" --> B
163
+ B -- symlink --> P
164
+ L -- "thin global core" --> G
165
+
166
+ search(["skl search · ls · show"]) -. reads .-> L
167
+
168
+ classDef shelf fill:#1f2937,stroke:#4b5563,color:#e5e7eb;
169
+ classDef live fill:#064e3b,stroke:#10b981,color:#d1fae5;
170
+ class L,B shelf;
171
+ class P,G live;
172
172
  ```
173
173
 
174
174
  See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
@@ -198,7 +198,7 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
198
198
  | `skl use <bundle\|skill>` | Symlink a bundle (or a single skill) into `./.claude/skills/` (hot-loads) | — |
199
199
  | `skl drop <bundle\|skill>` | Remove a bundle's (or single skill's) symlinks from `./.claude/skills/` | — |
200
200
  | `skl refresh` | Re-sync this project's `./.claude/skills` symlinks to current library reality (repoint stale, prune vanished) | `--dry-run` |
201
- | `skl add <src>` | Install third-party skill(s) into the library (librarian only — no agent-dir writes). One repo = **one clone**: a bare repo with several skills needs `--all`/`--skill`/`--list`; single-skill `add <repo>/<path>` is unchanged. `--list` discovers + prints; `--dry-run` previews drift (new/identical/differs); a `differs` skill is skipped without `--force` | `--all`, `--skill <a,b>`, `--list`, `--dry-run`, `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
201
+ | `skl add <src>` | Install third-party skill(s) into the library (librarian only — no agent-dir writes). One repo = **one clone**: a bare repo with several skills needs `--all`/`--skill`/`--list`; single-skill `add <repo>/<path>` is unchanged. `--all` installs the **published set** (the `.claude-plugin`/`marketplace.json` manifest allowlist when present, else every discovered skill; minus `metadata.internal`); an unpublished skill installs only via `--skill <name>`. A published set over **15** skills refuses without `--yes` (a count gate on blast radius; `--skill` is never gated). `--list` discovers + prints (marks `published`/`unpublished`); `--dry-run` previews drift (new/identical/differs); a `differs` skill is skipped without `--force` ([ADR-0012](./docs/adr/0012-published-set-and-all-count-gate.md)) | `--all`, `--skill <a,b>`, `--list`, `--yes`, `--dry-run`, `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
202
202
  | `skl outdated [name]` | Check upstream ref per tracked skill and mark stale ones (LINKED dev-repo entries are reported, never probed); `--check-local` diffs the local body against its baseline offline | `--check-local` |
203
203
  | `skl update [name]` | Re-pull upstream body, preserve domain tags, diff if local body diverged (LINKED entries are skipped — their own git owns versioning) | `--force`, `--dry-run` |
204
204
  | `skl index` | Regenerate `INDEX.md` (catalog grouped by domain) | — |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "engines": {
33
- "bun": ">=1.0.0"
33
+ "bun": ">=1.2.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
@@ -93,10 +93,7 @@ function resolveEnvFilePath(env: NodeJS.ProcessEnv, override?: string): string |
93
93
 
94
94
  /** First non-empty trimmed value, or "". */
95
95
  function firstNonEmpty(...vals: (string | undefined)[]): string {
96
- for (const v of vals) {
97
- if (v && v.trim() !== "") return v.trim();
98
- }
99
- return "";
96
+ return vals.find(v => v?.trim())?.trim() ?? "";
100
97
  }
101
98
 
102
99
  /**
@@ -0,0 +1,390 @@
1
+ // ADR-0012 — published set (manifest allowlist + metadata.internal) + the > 15 count gate.
2
+ //
3
+ // These tests drive `skl add` over real local git repos (offline `git:` channel,
4
+ // HOME-isolated library) exercising:
5
+ // (a) manifest-present repo -> --all installs only the allowlisted subset
6
+ // (b) --skill reaches an UNPUBLISHED (folder-excluded) skill, never gated
7
+ // (c) metadata.internal:true excluded from --all but installable by name
8
+ // (d) marketplace.json union across plugins
9
+ // (e) the > 15 count gate trips, and --yes bypasses it
10
+ // (f) --list shows the FULL set with published/unpublished/internal markers
11
+ // (g) a no-manifest repo still installs every skill (under the gate)
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { run } from "./add.ts";
19
+ import { discoverSkills } from "../core/fetch.ts";
20
+ import type { Ctx } from "../types.ts";
21
+
22
+ interface Captured {
23
+ ctx: Ctx;
24
+ logs: string[];
25
+ errors: string[];
26
+ json: unknown[];
27
+ }
28
+
29
+ function makeCtx(libraryPath: string): Captured {
30
+ const logs: string[] = [];
31
+ const errors: string[] = [];
32
+ const json: unknown[] = [];
33
+ const ctx = {
34
+ config: { libraryPath },
35
+ libraryPath,
36
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
37
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
38
+ json: (v: unknown) => json.push(v),
39
+ } as unknown as Ctx;
40
+ return { ctx, logs, errors, json };
41
+ }
42
+
43
+ function skillBody(
44
+ name: string,
45
+ opts: { internal?: boolean; internalFlow?: boolean; description?: string } = {},
46
+ ): string {
47
+ const desc = opts.description ?? `a ${name} skill for testing`;
48
+ // Block style by default; flow style (`metadata: {internal: true}`) exercises the
49
+ // YAML-flow-mapping path so the internal signal can't be bypassed by author style.
50
+ const meta = opts.internalFlow
51
+ ? `metadata: {internal: true}\n`
52
+ : opts.internal
53
+ ? `metadata:\n internal: true\n`
54
+ : "";
55
+ return `---\nname: ${name}\ndescription: ${desc}\n${meta}---\n\n# ${name}\n\nbody for ${name}\n`;
56
+ }
57
+
58
+ interface SkillSpec {
59
+ /** repo-relative dir, e.g. "engineering/foo" */
60
+ path: string;
61
+ /** frontmatter name (defaults to basename of path) */
62
+ name?: string;
63
+ internal?: boolean;
64
+ /** mark internal via inline flow mapping instead of a block mapping */
65
+ internalFlow?: boolean;
66
+ }
67
+
68
+ /** Build a real local git repo with the given skills + optional manifest files. */
69
+ async function makeRepo(
70
+ parent: string,
71
+ skills: SkillSpec[],
72
+ manifest?: { kind: "plugin" | "marketplace"; json: unknown },
73
+ ): Promise<string> {
74
+ const repo = join(parent, "src-repo");
75
+ await mkdir(repo, { recursive: true });
76
+ for (const s of skills) {
77
+ const dir = join(repo, s.path);
78
+ await mkdir(dir, { recursive: true });
79
+ const nm = s.name ?? s.path.split("/").pop()!;
80
+ await writeFile(
81
+ join(dir, "SKILL.md"),
82
+ skillBody(nm, { internal: s.internal, internalFlow: s.internalFlow }),
83
+ );
84
+ }
85
+ if (manifest) {
86
+ await mkdir(join(repo, ".claude-plugin"), { recursive: true });
87
+ const file = manifest.kind === "plugin" ? "plugin.json" : "marketplace.json";
88
+ await writeFile(join(repo, ".claude-plugin", file), JSON.stringify(manifest.json, null, 2));
89
+ }
90
+ const gitRun = (cmd: string[]) =>
91
+ Bun.spawn(cmd, { cwd: repo, stdout: "ignore", stderr: "ignore" }).exited;
92
+ await gitRun(["git", "init", "-q"]);
93
+ await gitRun(["git", "config", "user.email", "test@example.com"]);
94
+ await gitRun(["git", "config", "user.name", "test"]);
95
+ await gitRun(["git", "add", "-A"]);
96
+ await gitRun(["git", "commit", "-q", "-m", "init"]);
97
+ return repo;
98
+ }
99
+
100
+ describe("ADR-0012 published set + count gate", () => {
101
+ let tmp: string;
102
+ let library: string;
103
+
104
+ beforeEach(async () => {
105
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-pub-")));
106
+ library = join(tmp, "library");
107
+ await mkdir(library, { recursive: true });
108
+ });
109
+ afterEach(async () => {
110
+ await rm(tmp, { recursive: true, force: true });
111
+ });
112
+
113
+ // (a) manifest-present repo -> --all installs only the allowlisted subset.
114
+ test("plugin.json allowlist bounds --all to the listed skills", async () => {
115
+ const repo = await makeRepo(
116
+ tmp,
117
+ [
118
+ { path: "engineering/alpha" },
119
+ { path: "engineering/beta" },
120
+ { path: "deprecated/gamma" },
121
+ { path: "in-progress/delta" },
122
+ ],
123
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/beta"] } },
124
+ );
125
+
126
+ const { ctx, json } = makeCtx(library);
127
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
128
+ expect(code).toBe(0);
129
+
130
+ const out = json[0] as { results: Array<{ name: string; status: string }> };
131
+ const installed = out.results.filter((r) => r.status === "installed").map((r) => r.name).sort();
132
+ expect(installed).toEqual(["alpha", "beta"]);
133
+ expect(existsSync(join(library, "alpha", "SKILL.md"))).toBe(true);
134
+ expect(existsSync(join(library, "beta", "SKILL.md"))).toBe(true);
135
+ expect(existsSync(join(library, "gamma"))).toBe(false);
136
+ expect(existsSync(join(library, "delta"))).toBe(false);
137
+ });
138
+
139
+ // (b) --skill reaches an UNPUBLISHED (folder-excluded) skill, NOT gated.
140
+ test("--skill installs an unpublished skill the manifest omits", async () => {
141
+ const repo = await makeRepo(
142
+ tmp,
143
+ [
144
+ { path: "engineering/alpha" },
145
+ { path: "deprecated/gamma" },
146
+ ],
147
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha"] } },
148
+ );
149
+
150
+ const { ctx, json } = makeCtx(library);
151
+ const code = await run([`git:${repo}`, "--skill", "gamma", "--no-infer", "--json"], ctx);
152
+ expect(code).toBe(0);
153
+ const out = json[0] as { results: Array<{ name: string; status: string }> };
154
+ const gamma = out.results.find((r) => r.name === "gamma")!;
155
+ expect(gamma.status).toBe("installed");
156
+ expect(existsSync(join(library, "gamma", "SKILL.md"))).toBe(true);
157
+ });
158
+
159
+ // (c) metadata.internal:true excluded from --all but installable by name.
160
+ test("metadata.internal skill is excluded from --all but installable by --skill", async () => {
161
+ const repo = await makeRepo(
162
+ tmp,
163
+ [
164
+ { path: "engineering/alpha" },
165
+ { path: "engineering/secret", internal: true },
166
+ ],
167
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
168
+ );
169
+
170
+ // --all skips the internal one even though the manifest lists it.
171
+ const all = makeCtx(library);
172
+ const codeAll = await run([`git:${repo}`, "--all", "--no-infer", "--json"], all.ctx);
173
+ expect(codeAll).toBe(0);
174
+ const outAll = all.json[0] as { results: Array<{ name: string; status: string }> };
175
+ const installedAll = outAll.results.filter((r) => r.status === "installed").map((r) => r.name);
176
+ expect(installedAll).toEqual(["alpha"]);
177
+ expect(existsSync(join(library, "secret"))).toBe(false);
178
+
179
+ // ...but --skill reaches it.
180
+ const byName = makeCtx(library);
181
+ const codeName = await run([`git:${repo}`, "--skill", "secret", "--no-infer", "--json"], byName.ctx);
182
+ expect(codeName).toBe(0);
183
+ expect(existsSync(join(library, "secret", "SKILL.md"))).toBe(true);
184
+ });
185
+
186
+ // (c2) the internal signal can't be bypassed by writing it as a YAML FLOW mapping
187
+ // (`metadata: {internal: true}`) instead of a block mapping.
188
+ test("flow-style metadata.internal is still excluded from --all", async () => {
189
+ const repo = await makeRepo(
190
+ tmp,
191
+ [
192
+ { path: "engineering/alpha" },
193
+ { path: "engineering/secret", internalFlow: true },
194
+ ],
195
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
196
+ );
197
+ const { ctx, json } = makeCtx(library);
198
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
199
+ expect(code).toBe(0);
200
+ const out = json[0] as { results: Array<{ name: string; status: string }> };
201
+ expect(out.results.filter((r) => r.status === "installed").map((r) => r.name)).toEqual(["alpha"]);
202
+ expect(existsSync(join(library, "secret"))).toBe(false);
203
+ });
204
+
205
+ // (d) marketplace.json union across plugins.
206
+ test("marketplace.json unions every plugin's skills", async () => {
207
+ const repo = await makeRepo(
208
+ tmp,
209
+ [
210
+ { path: "engineering/alpha" },
211
+ { path: "productivity/beta" },
212
+ { path: "deprecated/gamma" },
213
+ ],
214
+ {
215
+ kind: "marketplace",
216
+ json: {
217
+ plugins: [
218
+ { name: "eng", skills: ["./engineering/alpha"] },
219
+ { name: "prod", skills: ["./productivity/beta"] },
220
+ ],
221
+ },
222
+ },
223
+ );
224
+
225
+ const { ctx, json } = makeCtx(library);
226
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
227
+ expect(code).toBe(0);
228
+ const out = json[0] as { results: Array<{ name: string; status: string }> };
229
+ const installed = out.results.filter((r) => r.status === "installed").map((r) => r.name).sort();
230
+ expect(installed).toEqual(["alpha", "beta"]);
231
+ expect(existsSync(join(library, "gamma"))).toBe(false);
232
+ });
233
+
234
+ // (e) the count gate trips at > 15 published, and --yes bypasses it.
235
+ test("count gate refuses > 15 published skills; --yes bypasses", async () => {
236
+ // 16 skills, NO manifest -> all 16 are published.
237
+ const specs: SkillSpec[] = Array.from({ length: 16 }, (_, i) => ({
238
+ path: `skills/s${String(i).padStart(2, "0")}`,
239
+ }));
240
+ const repo = await makeRepo(tmp, specs);
241
+
242
+ const gated = makeCtx(library);
243
+ const codeGated = await run([`git:${repo}`, "--all", "--no-infer", "--json"], gated.ctx);
244
+ expect(codeGated).toBe(1);
245
+ const errText = gated.errors.join("\n");
246
+ expect(errText).toContain("16");
247
+ expect(errText).toMatch(/--yes/);
248
+ // Nothing installed.
249
+ expect(existsSync(join(library, "s00"))).toBe(false);
250
+
251
+ const bypass = makeCtx(library);
252
+ const codeYes = await run([`git:${repo}`, "--all", "--yes", "--no-infer", "--json"], bypass.ctx);
253
+ expect(codeYes).toBe(0);
254
+ const out = bypass.json[0] as { counts: { installed: number } };
255
+ expect(out.counts.installed).toBe(16);
256
+ });
257
+
258
+ test("count gate does NOT trip at exactly 15", async () => {
259
+ const specs: SkillSpec[] = Array.from({ length: 15 }, (_, i) => ({
260
+ path: `skills/s${String(i).padStart(2, "0")}`,
261
+ }));
262
+ const repo = await makeRepo(tmp, specs);
263
+ const { ctx, json } = makeCtx(library);
264
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
265
+ expect(code).toBe(0);
266
+ const out = json[0] as { counts: { installed: number } };
267
+ expect(out.counts.installed).toBe(15);
268
+ });
269
+
270
+ test("--skill is never gated even over a large repo", async () => {
271
+ const specs: SkillSpec[] = Array.from({ length: 20 }, (_, i) => ({
272
+ path: `skills/s${String(i).padStart(2, "0")}`,
273
+ }));
274
+ const repo = await makeRepo(tmp, specs);
275
+ const { ctx } = makeCtx(library);
276
+ const code = await run([`git:${repo}`, "--skill", "s00,s01", "--no-infer", "--json"], ctx);
277
+ expect(code).toBe(0);
278
+ expect(existsSync(join(library, "s00", "SKILL.md"))).toBe(true);
279
+ expect(existsSync(join(library, "s01", "SKILL.md"))).toBe(true);
280
+ });
281
+
282
+ // (f) --list shows the FULL set with published/unpublished/internal markers.
283
+ test("--list marks every skill published/unpublished/internal; never gated", async () => {
284
+ const repo = await makeRepo(
285
+ tmp,
286
+ [
287
+ { path: "engineering/alpha" },
288
+ { path: "deprecated/gamma" },
289
+ { path: "engineering/secret", internal: true },
290
+ ],
291
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
292
+ );
293
+
294
+ const { ctx, json } = makeCtx(library);
295
+ const code = await run([`git:${repo}`, "--list", "--json"], ctx);
296
+ expect(code).toBe(0);
297
+ const out = json[0] as {
298
+ skills: Array<{ name: string; published: boolean; internal: boolean }>;
299
+ };
300
+ const byName = new Map(out.skills.map((s) => [s.name, s]));
301
+ expect(out.skills.length).toBe(3);
302
+ expect(byName.get("alpha")!.published).toBe(true);
303
+ expect(byName.get("alpha")!.internal).toBe(false);
304
+ expect(byName.get("gamma")!.published).toBe(false); // unlisted by manifest
305
+ expect(byName.get("secret")!.published).toBe(false); // internal excluded
306
+ expect(byName.get("secret")!.internal).toBe(true);
307
+ });
308
+
309
+ test("--list is never gated even over a large repo", async () => {
310
+ const specs: SkillSpec[] = Array.from({ length: 30 }, (_, i) => ({
311
+ path: `skills/s${String(i).padStart(2, "0")}`,
312
+ }));
313
+ const repo = await makeRepo(tmp, specs);
314
+ const { ctx, json } = makeCtx(library);
315
+ const code = await run([`git:${repo}`, "--list", "--json"], ctx);
316
+ expect(code).toBe(0);
317
+ const out = json[0] as { skills: unknown[] };
318
+ expect(out.skills.length).toBe(30);
319
+ });
320
+
321
+ // (g) no-manifest repo still installs every (valid) discovered skill, under the gate.
322
+ test("no-manifest repo: --all installs every skill (today's behavior)", async () => {
323
+ const repo = await makeRepo(tmp, [
324
+ { path: "skills/alpha" },
325
+ { path: "skills/beta" },
326
+ { path: "skills/gamma" },
327
+ ]);
328
+ const { ctx, json } = makeCtx(library);
329
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
330
+ expect(code).toBe(0);
331
+ const out = json[0] as { counts: { installed: number } };
332
+ expect(out.counts.installed).toBe(3);
333
+ });
334
+
335
+ // --dry-run runs over the PUBLISHED set (the set --all would install), never gated.
336
+ test("--dry-run preflights only the published set", async () => {
337
+ const repo = await makeRepo(
338
+ tmp,
339
+ [
340
+ { path: "engineering/alpha" },
341
+ { path: "deprecated/gamma" },
342
+ ],
343
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha"] } },
344
+ );
345
+ const { ctx, json } = makeCtx(library);
346
+ const code = await run([`git:${repo}`, "--all", "--dry-run", "--json"], ctx);
347
+ expect(code).toBe(0);
348
+ const out = json[0] as { skills: Array<{ name: string }>; willInstall: number };
349
+ const names = out.skills.map((s) => s.name).sort();
350
+ expect(names).toEqual(["alpha"]); // gamma is unpublished -> not previewed
351
+ expect(out.willInstall).toBe(1);
352
+ });
353
+
354
+ // Discovery still finds EVERYTHING (existence) — the manifest is an allowlist, not a source of truth.
355
+ test("discoverSkills still surfaces all skills, tagged published/internal", async () => {
356
+ const repo = await makeRepo(
357
+ tmp,
358
+ [
359
+ { path: "engineering/alpha" },
360
+ { path: "deprecated/gamma" },
361
+ { path: "engineering/secret", internal: true },
362
+ ],
363
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
364
+ );
365
+ // discoverSkills runs against a CHECKOUT root; clone manually to a plain dir.
366
+ const checkout = join(tmp, "checkout");
367
+ await Bun.spawn(["git", "clone", "-q", repo, checkout], { stdout: "ignore", stderr: "ignore" }).exited;
368
+ const found = await discoverSkills(checkout);
369
+ const byName = new Map(found.map((d) => [d.name, d]));
370
+ expect(found.length).toBe(3);
371
+ expect(byName.get("alpha")!.published).toBe(true);
372
+ expect(byName.get("gamma")!.published).toBe(false);
373
+ expect(byName.get("secret")!.published).toBe(false);
374
+ expect(byName.get("secret")!.internal).toBe(true);
375
+ });
376
+
377
+ // A manifest entry that points at a path with no valid SKILL.md contributes nothing.
378
+ test("manifest allowlisting a nonexistent path contributes nothing", async () => {
379
+ const repo = await makeRepo(
380
+ tmp,
381
+ [{ path: "engineering/alpha" }],
382
+ { kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./does/not/exist"] } },
383
+ );
384
+ const { ctx, json } = makeCtx(library);
385
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
386
+ expect(code).toBe(0);
387
+ const out = json[0] as { counts: { installed: number } };
388
+ expect(out.counts.installed).toBe(1);
389
+ });
390
+ });
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { run } from "./add.ts";
7
+ import type { Ctx } from "../types.ts";
8
+
9
+ interface Captured {
10
+ ctx: Ctx;
11
+ logs: string[];
12
+ errors: string[];
13
+ json: unknown[];
14
+ }
15
+
16
+ /** Minimal Ctx mock — add.run reads config.libraryPath + log/error/json. */
17
+ function makeCtx(libraryPath: string): Captured {
18
+ const logs: string[] = [];
19
+ const errors: string[] = [];
20
+ const json: unknown[] = [];
21
+ const ctx = {
22
+ config: { libraryPath },
23
+ libraryPath,
24
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
25
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
26
+ json: (v: unknown) => json.push(v),
27
+ } as unknown as Ctx;
28
+ return { ctx, logs, errors, json };
29
+ }
30
+
31
+ function skillBody(name: string): string {
32
+ return `---\nname: ${name}\ndescription: a ${name} skill for testing\n---\n\n# ${name}\n\nbody for ${name}\n`;
33
+ }
34
+
35
+ /** Build a real local git repo holding the given skills, for offline `git:` add. */
36
+ async function makeGitRepo(parent: string, skills: string[]): Promise<string> {
37
+ const repo = join(parent, "src-repo");
38
+ await mkdir(repo, { recursive: true });
39
+ for (const s of skills) {
40
+ await mkdir(join(repo, s), { recursive: true });
41
+ await writeFile(join(repo, s, "SKILL.md"), skillBody(s));
42
+ }
43
+ const run = (cmd: string[]) =>
44
+ Bun.spawn(cmd, { cwd: repo, stdout: "ignore", stderr: "ignore" }).exited;
45
+ await run(["git", "init", "-q"]);
46
+ await run(["git", "config", "user.email", "test@example.com"]);
47
+ await run(["git", "config", "user.name", "test"]);
48
+ await run(["git", "add", "-A"]);
49
+ await run(["git", "commit", "-q", "-m", "init"]);
50
+ return repo;
51
+ }
52
+
53
+ describe("skl add — retired-aware collision", () => {
54
+ let tmp: string;
55
+ let library: string;
56
+
57
+ beforeEach(async () => {
58
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-add-")));
59
+ library = join(tmp, "library");
60
+ await mkdir(library, { recursive: true });
61
+ });
62
+ afterEach(async () => {
63
+ await rm(tmp, { recursive: true, force: true });
64
+ });
65
+
66
+ test("--all over a repo where one name is retired: skips it (no duplicate), installs the rest", async () => {
67
+ const repo = await makeGitRepo(tmp, ["caveman", "tdd"]);
68
+ // Retire "caveman": a tombstone under _retired/, NO active copy.
69
+ await mkdir(join(library, "_retired", "caveman"), { recursive: true });
70
+ await writeFile(join(library, "_retired", "caveman", "SKILL.md"), skillBody("caveman"));
71
+
72
+ const { ctx, json } = makeCtx(library);
73
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
74
+ expect(code).toBe(0);
75
+
76
+ const out = json[0] as {
77
+ results: Array<{ name: string; status: string; verdict: string; reason: string }>;
78
+ };
79
+ const caveman = out.results.find((r) => r.name === "caveman")!;
80
+ const tdd = out.results.find((r) => r.name === "tdd")!;
81
+
82
+ expect(caveman.status).toBe("skipped");
83
+ expect(caveman.verdict).toBe("retired");
84
+ expect(caveman.reason).toContain("skl unretire caveman");
85
+
86
+ // No active duplicate beside the tombstone.
87
+ expect(existsSync(join(library, "caveman"))).toBe(false);
88
+ expect(existsSync(join(library, "_retired", "caveman"))).toBe(true);
89
+
90
+ // The non-colliding name still installs.
91
+ expect(tdd.status).toBe("installed");
92
+ expect(existsSync(join(library, "tdd", "SKILL.md"))).toBe(true);
93
+ });
94
+
95
+ test("single add of a retired name refuses (exit 1, no duplicate)", async () => {
96
+ const repo = await makeGitRepo(tmp, ["caveman"]);
97
+ await mkdir(join(library, "_retired", "caveman"), { recursive: true });
98
+ await writeFile(join(library, "_retired", "caveman", "SKILL.md"), skillBody("caveman"));
99
+
100
+ const { ctx, errors } = makeCtx(library);
101
+ const code = await run([`git:${repo}`, "--no-infer"], ctx);
102
+ expect(code).toBe(1);
103
+ expect(errors.join("\n")).toContain("skl unretire caveman");
104
+ expect(existsSync(join(library, "caveman"))).toBe(false);
105
+ });
106
+
107
+ test("active-collision still refuses without --force (no regression)", async () => {
108
+ const repo = await makeGitRepo(tmp, ["tdd"]);
109
+ // An ACTIVE copy already exists.
110
+ await mkdir(join(library, "tdd"), { recursive: true });
111
+ await writeFile(join(library, "tdd", "SKILL.md"), skillBody("tdd") + "local edit\n");
112
+
113
+ const { ctx, errors } = makeCtx(library);
114
+ const code = await run([`git:${repo}`, "--no-infer"], ctx);
115
+ expect(code).toBe(1);
116
+ expect(errors.join("\n")).toContain("already exists");
117
+ });
118
+ });