skillshelf 0.4.1 → 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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
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
 
@@ -46,7 +46,7 @@ matrix (Global + the projects where it's pinned), plus lifecycle actions.
46
46
 
47
47
  ## Install
48
48
 
49
- 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.
50
50
 
51
51
  > **Bun is required, not optional.** The `skl` bin is a TypeScript entrypoint with a
52
52
  > `#!/usr/bin/env bun` shebang — there is no compiled Node build. `npm i -g skillshelf`
@@ -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.1",
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
+ });
@@ -37,7 +37,7 @@ import { parseFrontmatter } from "../lib/frontmatter.ts";
37
37
  import { hashContent } from "../core/crawl.ts";
38
38
  import { recordEntry } from "../core/provenance.ts";
39
39
  import { setDomainsForName } from "../core/taxonomy.ts";
40
- import { assertSafeName } from "../core/lifecycle.ts";
40
+ import { assertSafeName, SLUG_RE } from "../core/lifecycle.ts";
41
41
  import { loadLibrary, findByName, entryStatus } from "../core/library.ts";
42
42
  import { ensureDir, isSymlink, realpathOrSelf } from "../lib/fs.ts";
43
43
 
@@ -45,15 +45,25 @@ export const meta = {
45
45
  name: "add",
46
46
  summary: "Install third-party skill(s) (github:/git:/registry); repo-wide via --all/--skill",
47
47
  usage:
48
- "skl add <src> [--all|--skill <a,b,…>] [--list] [--dry-run] [--domain <d>] [--name <slug>] [--no-infer] [--force] [--json]",
48
+ "skl add <src> [--all|--skill <a,b,…>] [--list] [--dry-run] [--domain <d>] [--name <slug>] [--no-infer] [--force] [--yes] [--json]",
49
49
  } as const;
50
50
 
51
+ /**
52
+ * The `--all` count gate threshold (ADR-0012): if the resolved published set has MORE
53
+ * than this many skills, `add --all` refuses (bounds blast radius) until the user passes
54
+ * `--yes`, narrows with `--skill`, or inspects with `--list`. `--skill`/`--list`/`--dry-run`
55
+ * are never gated. On the count, not the provenance — manifest or full discovery alike.
56
+ */
57
+ export const ALL_COUNT_GATE = 15;
58
+
51
59
  interface Flags {
52
60
  json: boolean;
53
61
  domain: string | null;
54
62
  name: string | null;
55
63
  infer: boolean;
56
64
  force: boolean;
65
+ /** bypass ONLY the --all count gate (distinct from --force = overwrite differing body) */
66
+ yes: boolean;
57
67
  all: boolean;
58
68
  list: boolean;
59
69
  dryRun: boolean;
@@ -62,11 +72,10 @@ interface Flags {
62
72
  src: string | null;
63
73
  }
64
74
 
65
- // A skill slug is lowercase letters/digits/hyphens. This is also a SECURITY guard:
75
+ // SLUG_RE is shared from core/lifecycle.ts. This is also a SECURITY guard here:
66
76
  // `name` may be derived from an untrusted third-party SKILL.md frontmatter and
67
77
  // `domain` from a flag, and both are joined into a library path. Rejecting anything
68
78
  // outside this charset stops `..`/`/` path traversal out of the library.
69
- const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
70
79
 
71
80
  function addSkillFilter(cur: string[] | null, raw: string): string[] {
72
81
  const names = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
@@ -80,6 +89,7 @@ function parseFlags(argv: string[]): Flags {
80
89
  name: null,
81
90
  infer: true,
82
91
  force: false,
92
+ yes: false,
83
93
  all: false,
84
94
  list: false,
85
95
  dryRun: false,
@@ -91,6 +101,7 @@ function parseFlags(argv: string[]): Flags {
91
101
  if (a === "--json") f.json = true;
92
102
  else if (a === "--no-infer") f.infer = false;
93
103
  else if (a === "--force") f.force = true;
104
+ else if (a === "--yes") f.yes = true;
94
105
  else if (a === "--all") f.all = true;
95
106
  else if (a === "--list") f.list = true;
96
107
  else if (a === "--dry-run") f.dryRun = true;
@@ -165,36 +176,15 @@ async function driftVerdict(skill: DiscoveredSkill, destDir: string): Promise<Ve
165
176
  }
166
177
 
167
178
  /**
168
- * Optionally run an AI inference tagging pass over a freshly-installed skill.
169
- *
170
- * A MISSING hook module is expected and stays silent (untagged is valid). But a hook
171
- * that IS present and THROWS is a real failure we surface it via `warn` rather than
172
- * swallowing it. Either way the skill stays untagged. Returns the domains written.
179
+ * Optional AI inference tagging pass over a freshly-installed skill. No inference hook
180
+ * ships today, so this always leaves the skill untagged (returns null); installs land
181
+ * with whatever `--domain` gave them. Kept as a seam so `--infer`/`--no-infer` and the
182
+ * `tagged` summary field stay meaningful when a hook is wired in.
173
183
  */
174
184
  async function maybeInferTags(
175
- skill: Skill,
176
- warn?: (msg: string) => void,
185
+ _skill: Skill,
186
+ _warn?: (msg: string) => void,
177
187
  ): Promise<string[] | null> {
178
- const candidates = ["../core/infer.ts", "../adapters/inference/tag.ts"];
179
- for (const rel of candidates) {
180
- const spec: string = rel;
181
- const mod: unknown = await import(spec).catch(() => null);
182
- if (!mod || typeof mod !== "object") continue;
183
- const hook = (mod as Record<string, unknown>).tagSkill;
184
- if (typeof hook !== "function") continue;
185
- try {
186
- const result = await (hook as (s: Skill) => Promise<string[] | null>)(skill);
187
- if (Array.isArray(result)) {
188
- return result.filter((d) => typeof d === "string" && d.trim() !== "");
189
- }
190
- return null;
191
- } catch (err) {
192
- warn?.(
193
- `add: inference hook ${rel} failed (skill left untagged): ${err instanceof Error ? err.message : String(err)}`,
194
- );
195
- return null;
196
- }
197
- }
198
188
  return null;
199
189
  }
200
190
 
@@ -391,20 +381,27 @@ function reportList(
391
381
  description: d.description,
392
382
  subpath: d.subpath,
393
383
  inLibrary: Boolean(findByName(library, d.name)),
384
+ // ADR-0012: keep the FULL set visible, marked by published-set membership, so an
385
+ // unpublished/internal skill is discoverable even though `--all` skips it.
386
+ published: d.published,
387
+ internal: d.internal,
394
388
  }));
395
389
  if (flags.json) {
396
390
  ctx.json({ ok: true, action: "list", source: parsed.source, ref, count: rows.length, skills: rows });
397
391
  return 0;
398
392
  }
399
- ctx.log(`${rows.length} skill(s) in ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""}:`);
393
+ const publishedCount = rows.filter((r) => r.published).length;
394
+ ctx.log(`${rows.length} skill(s) in ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${publishedCount} published):`);
400
395
  ctx.log("");
401
396
  for (const r of rows) {
402
397
  const mark = r.inLibrary ? "✓" : " ";
403
- ctx.log(` ${mark} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
398
+ const tag = r.published ? "published " : r.internal ? "internal " : "unpublished";
399
+ ctx.log(` ${mark} ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
404
400
  if (r.description) ctx.log(` ${r.description.length > 100 ? r.description.slice(0, 99) + "…" : r.description}`);
405
401
  }
406
402
  ctx.log("");
407
- ctx.log(`✓ = already in your library. Install with: skl add ${flags.src} --all (or --skill <name,…>)`);
403
+ ctx.log(`✓ = already in your library. published = installed by --all; unpublished/internal = only via --skill <name>.`);
404
+ ctx.log(`Install with: skl add ${flags.src} --all (or --skill <name,…>)`);
408
405
  return 0;
409
406
  }
410
407
 
@@ -554,7 +551,11 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
554
551
  const { data } = parseFrontmatter(body);
555
552
  const nm = typeof data.name === "string" && data.name.trim() !== "" ? data.name.trim() : basename(fetched.skillDir);
556
553
  const desc = typeof data.description === "string" ? data.description.trim() : "";
557
- discovered = [{ name: nm, dir: fetched.skillDir, subpath: "", description: desc }];
554
+ const meta = data.metadata;
555
+ const internal =
556
+ typeof meta === "object" && meta !== null && (meta as Record<string, unknown>).internal === true;
557
+ // Single registry/explicit skill: no manifest context → published unless internal.
558
+ discovered = [{ name: nm, dir: fetched.skillDir, subpath: "", description: desc, internal, published: !internal }];
558
559
  }
559
560
 
560
561
  try {
@@ -574,9 +575,16 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
574
575
  return reportList(ctx, flags, parsed, ref, discovered, library);
575
576
  }
576
577
 
577
- // ---- --dry-run (drift preflight over the full set, no writes) ----
578
+ // ---- --dry-run (drift preflight, no writes) ----
579
+ // Over the set it WOULD install: the published set for --all (ADR-0012), the named
580
+ // subset for --skill, else the full discovered set. Never gated (it doesn't write).
578
581
  if (flags.dryRun) {
579
- return await reportDryRun(ctx, flags, parsed, ref, discovered, domainFolder);
582
+ const preview = flags.all
583
+ ? discovered.filter((d) => d.published)
584
+ : flags.skill !== null
585
+ ? discovered.filter((d) => flags.skill!.includes(d.name))
586
+ : discovered;
587
+ return await reportDryRun(ctx, flags, parsed, ref, preview, domainFolder);
580
588
  }
581
589
 
582
590
  // ---- selection ----
@@ -593,7 +601,26 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
593
601
  }
594
602
  multi = true;
595
603
  } else if (flags.all) {
596
- selected = discovered;
604
+ // ADR-0012: --all installs the PUBLISHED set (manifest allowlist when present,
605
+ // else every discovered skill), always minus internal skills.
606
+ selected = discovered.filter((d) => d.published);
607
+ if (selected.length === 0) {
608
+ ctx.error(
609
+ `add: no published skills in ${parsed.source} — every discovered skill is unpublished or internal` +
610
+ ` (e.g. a .claude-plugin manifest that lists none/is unreadable, or skills marked metadata.internal).`,
611
+ );
612
+ ctx.error(` inspect the full set with --list, or install one by name with --skill <name>.`);
613
+ return 1;
614
+ }
615
+ // COUNT GATE: refuse a large blast radius unless --yes. On the final selected
616
+ // count, regardless of provenance. --skill/--list/--dry-run are never gated.
617
+ if (selected.length > ALL_COUNT_GATE && !flags.yes) {
618
+ ctx.error(
619
+ `add: ${selected.length} published skills in ${parsed.source} exceeds the --all gate of ${ALL_COUNT_GATE}.`,
620
+ );
621
+ ctx.error(` re-run with --yes to install them all, narrow with --skill <name,…>, or inspect with --list.`);
622
+ return 1;
623
+ }
597
624
  multi = true;
598
625
  } else if (discovered.length > 1) {
599
626
  ctx.error(