skillshelf 0.2.0 → 0.3.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 (57) hide show
  1. package/README.md +57 -19
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +31 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/agents.ts +120 -0
  7. package/src/commands/drop.ts +21 -13
  8. package/src/commands/import.ts +44 -28
  9. package/src/commands/infer.ts +6 -6
  10. package/src/commands/link.test.ts +160 -0
  11. package/src/commands/link.ts +317 -0
  12. package/src/commands/ls.ts +118 -18
  13. package/src/commands/mode-surfacing.test.ts +110 -0
  14. package/src/commands/outdated.test.ts +55 -0
  15. package/src/commands/outdated.ts +138 -18
  16. package/src/commands/refresh.ts +133 -0
  17. package/src/commands/remediation.test.ts +149 -0
  18. package/src/commands/rename.test.ts +121 -0
  19. package/src/commands/rename.ts +64 -0
  20. package/src/commands/retag.ts +58 -0
  21. package/src/commands/retire.ts +39 -0
  22. package/src/commands/rm.test.ts +133 -0
  23. package/src/commands/rm.ts +107 -0
  24. package/src/commands/roots.ts +41 -0
  25. package/src/commands/scan.ts +122 -30
  26. package/src/commands/show.ts +4 -1
  27. package/src/commands/status.ts +43 -8
  28. package/src/commands/tag.test.ts +109 -0
  29. package/src/commands/tag.ts +68 -0
  30. package/src/commands/unretire.ts +33 -0
  31. package/src/commands/untag.ts +73 -0
  32. package/src/commands/update.test.ts +71 -0
  33. package/src/commands/update.ts +65 -15
  34. package/src/commands/use.test.ts +92 -0
  35. package/src/commands/use.ts +46 -23
  36. package/src/commands/where.ts +232 -0
  37. package/src/config.test.ts +69 -0
  38. package/src/config.ts +79 -10
  39. package/src/core/agents.test.ts +232 -0
  40. package/src/core/agents.ts +363 -0
  41. package/src/core/bundle.ts +12 -15
  42. package/src/core/core.test.ts +14 -1
  43. package/src/core/crawl.ts +22 -5
  44. package/src/core/dedupe.ts +36 -0
  45. package/src/core/deployments.test.ts +147 -0
  46. package/src/core/deployments.ts +208 -0
  47. package/src/core/fetch.ts +344 -70
  48. package/src/core/indexgen.ts +2 -0
  49. package/src/core/library.test.ts +41 -0
  50. package/src/core/library.ts +61 -16
  51. package/src/core/lifecycle.ts +252 -0
  52. package/src/core/surfaces.ts +46 -0
  53. package/src/core/taxonomy.test.ts +159 -0
  54. package/src/core/taxonomy.ts +190 -0
  55. package/src/lib/fs.ts +2 -2
  56. package/src/types.ts +85 -15
  57. package/src/core/overlay.ts +0 -63
@@ -1,36 +1,51 @@
1
- // skl add <src> — install a third-party skill into the library.
1
+ // skl add <src> — install third-party skill(s) into the library.
2
2
  //
3
- // Flow:
4
- // 1. parse <src> (github:owner/repo[/path] or a bare registry name)
5
- // 2. shell out (git / `skills`) to DOWNLOAD only never reinvent fetching
6
- // 3. copy the skill dir into the library under its primary-domain folder
7
- // 4. write a provenance lockfile entry (source + ref + channel + installedAt)
8
- // 5. create an empty overlay (<name>.shelf.json) so taxonomy survives updates
9
- // 6. call the inference tagging hook if one is available, else leave untagged
3
+ // Two shapes, ONE clone:
4
+ // skl add github:owner/repo/path/to/skill → install that one skill (unchanged)
5
+ // skl add github:owner/repo → if exactly one skill, install it;
6
+ // if several, error and point at the
7
+ // flags below (never silently pick one)
8
+ // skl add github:owner/repo --list → discover + print, no writes
9
+ // skl add github:owner/repo --all → install every discovered skill
10
+ // skl add github:owner/repo --skill a,b → install only those (by frontmatter name)
11
+ // skl add github:owner/repo --all --dry-run → drift preflight (new/identical/differs)
10
12
  //
11
- // Read-only commands take --json; add is a write, but still emits a --json
12
- // summary on success for agent consumption.
13
+ // `add` is a LIBRARIAN, not an installer: it writes ONLY into ~/.skillshelf/library
14
+ // (provenance + central taxonomy). It never touches agent dirs / symlink fan-out —
15
+ // that stays with `skl use` (project) / a future `skl deploy` (ADR-0003). A repo-wide
16
+ // add clones the repo ONCE (fetchRepo), discovers all skills, and copies the selected
17
+ // subset out of the single staging checkout — N installs, one network fetch.
18
+ //
19
+ // Read-only commands take --json; add is a write, but still emits a --json summary.
13
20
 
14
- import { join, basename } from "node:path";
21
+ import { join, basename, dirname, sep } from "node:path";
15
22
  import { existsSync } from "node:fs";
16
23
  import type { Ctx, Skill, LockEntry } from "../types.ts";
17
24
  import {
18
25
  parseSource,
19
26
  fetchSource,
27
+ fetchRepo,
28
+ discoverSkills,
29
+ discoverSingleLenient,
20
30
  copySkillDir,
21
31
  cleanupStaging,
22
32
  readSkillBody,
33
+ type DiscoveredSkill,
34
+ type ParsedSource,
23
35
  } from "../core/fetch.ts";
24
36
  import { parseFrontmatter } from "../lib/frontmatter.ts";
25
37
  import { hashContent } from "../core/crawl.ts";
26
38
  import { recordEntry } from "../core/provenance.ts";
27
- import { writeOverlay } from "../core/overlay.ts";
28
- import { ensureDir } from "../lib/fs.ts";
39
+ import { setDomainsForName } from "../core/taxonomy.ts";
40
+ import { assertSafeName } from "../core/lifecycle.ts";
41
+ import { loadLibrary, findByName } from "../core/library.ts";
42
+ import { ensureDir, isSymlink, realpathOrSelf } from "../lib/fs.ts";
29
43
 
30
44
  export const meta = {
31
45
  name: "add",
32
- summary: "Install a third-party skill (github:/registry), record provenance, tag",
33
- usage: "skl add <src> [--domain <d>] [--name <slug>] [--no-infer] [--force] [--json]",
46
+ summary: "Install third-party skill(s) (github:/git:/registry); repo-wide via --all/--skill",
47
+ usage:
48
+ "skl add <src> [--all|--skill <a,b,…>] [--list] [--dry-run] [--domain <d>] [--name <slug>] [--no-infer] [--force] [--json]",
34
49
  } as const;
35
50
 
36
51
  interface Flags {
@@ -39,184 +54,665 @@ interface Flags {
39
54
  name: string | null;
40
55
  infer: boolean;
41
56
  force: boolean;
57
+ all: boolean;
58
+ list: boolean;
59
+ dryRun: boolean;
60
+ /** null = flag not given; otherwise the (possibly empty) list of requested names */
61
+ skill: string[] | null;
42
62
  src: string | null;
43
63
  }
44
64
 
65
+ // A skill slug is lowercase letters/digits/hyphens. This is also a SECURITY guard:
66
+ // `name` may be derived from an untrusted third-party SKILL.md frontmatter and
67
+ // `domain` from a flag, and both are joined into a library path. Rejecting anything
68
+ // outside this charset stops `..`/`/` path traversal out of the library.
69
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
70
+
71
+ function addSkillFilter(cur: string[] | null, raw: string): string[] {
72
+ const names = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
73
+ return [...(cur ?? []), ...names];
74
+ }
75
+
45
76
  function parseFlags(argv: string[]): Flags {
46
- const f: Flags = { json: false, domain: null, name: null, infer: true, force: false, src: null };
77
+ const f: Flags = {
78
+ json: false,
79
+ domain: null,
80
+ name: null,
81
+ infer: true,
82
+ force: false,
83
+ all: false,
84
+ list: false,
85
+ dryRun: false,
86
+ skill: null,
87
+ src: null,
88
+ };
47
89
  for (let i = 0; i < argv.length; i++) {
48
90
  const a = argv[i]!;
49
91
  if (a === "--json") f.json = true;
50
92
  else if (a === "--no-infer") f.infer = false;
51
93
  else if (a === "--force") f.force = true;
94
+ else if (a === "--all") f.all = true;
95
+ else if (a === "--list") f.list = true;
96
+ else if (a === "--dry-run") f.dryRun = true;
52
97
  else if (a === "--domain") f.domain = argv[++i] ?? null;
53
98
  else if (a === "--name") f.name = argv[++i] ?? null;
54
- else if (a === "--domain=" || a.startsWith("--domain=")) f.domain = a.slice("--domain=".length);
99
+ else if (a === "--skill") f.skill = addSkillFilter(f.skill, argv[++i] ?? "");
100
+ else if (a.startsWith("--domain=")) f.domain = a.slice("--domain=".length);
55
101
  else if (a.startsWith("--name=")) f.name = a.slice("--name=".length);
102
+ else if (a.startsWith("--skill=")) f.skill = addSkillFilter(f.skill, a.slice("--skill=".length));
56
103
  else if (!a.startsWith("-") && f.src === null) f.src = a;
57
104
  }
58
105
  return f;
59
106
  }
60
107
 
61
- /** Slug from frontmatter `name`, else the source dir name. */
62
- async function deriveName(skillDir: string, override: string | null): Promise<string> {
63
- if (override && override.trim() !== "") return override.trim();
64
- const body = await readSkillBody(skillDir);
65
- const { data } = parseFrontmatter(body);
66
- if (typeof data.name === "string" && data.name.trim() !== "") return data.name.trim();
67
- return basename(skillDir);
108
+ /** Body text after frontmatter the unit drift/install hashes operate on. */
109
+ function bodyOf(text: string): string {
110
+ return parseFrontmatter(text).body;
111
+ }
112
+
113
+ /** The library destination dir for a slug (under its domain folder, if any). */
114
+ function destDirFor(libraryPath: string, domainFolder: string | null, name: string): string {
115
+ return domainFolder ? join(libraryPath, domainFolder, name) : join(libraryPath, name);
116
+ }
117
+
118
+ /** The nearest ancestor of `p` (incl. `p`) that exists on disk; falls back to `p`. */
119
+ function nearestExisting(p: string): string {
120
+ let cur = p;
121
+ while (!existsSync(cur) && !isSymlink(cur)) {
122
+ const parent = dirname(cur);
123
+ if (parent === cur) return cur;
124
+ cur = parent;
125
+ }
126
+ return cur;
68
127
  }
69
128
 
70
129
  /**
71
- * Optionally run an AI inference tagging pass over the freshly-installed skill.
72
- *
73
- * The taxonomy pass (`skl infer`) is corpus-based and lives in the inference
74
- * adapters; there is no committed single-skill tagging hook in the public API.
75
- * To stay decoupled (and to leave the skill *untagged* rather than fail when no
76
- * hook is present), we look for an OPTIONAL convention module that may export a
77
- * `tagSkill(skill) => string[]`. The specifier is built at runtime so a missing
78
- * module degrades gracefully instead of becoming a static import error.
130
+ * True if writing to `destDir` would resolve OUTSIDE the library — i.e. the nearest
131
+ * existing component on the way to destDir is (or is reached through) a symlink whose
132
+ * realpath escapes the library. Catches a symlinked DOMAIN folder (`library/<d> ->
133
+ * /external`) that the leaf-only `isSymlink(destDir)` check misses, so `--force` can't
134
+ * clobber an external tree through a symlinked parent (ADR-0004). Both sides anchor to
135
+ * their nearest existing ancestor, so a fresh (not-yet-created) library is not a false
136
+ * positive its anchor is the shared parent, which contains itself.
137
+ */
138
+ function destEscapesLibrary(libraryPath: string, destDir: string): boolean {
139
+ const libReal = realpathOrSelf(nearestExisting(libraryPath));
140
+ const destReal = realpathOrSelf(nearestExisting(destDir));
141
+ return !(destReal === libReal || destReal.startsWith(libReal + sep));
142
+ }
143
+
144
+ type Verdict = "new" | "identical" | "differs";
145
+
146
+ /**
147
+ * Drift preflight for one skill against the library destination it would install to:
148
+ * - new — nothing at the destination → would install
149
+ * - identical — destination body hash matches upstream → lossless overwrite
150
+ * - differs — destination body differs → would clobber local content (needs --force)
151
+ * Compares the frontmatter-stripped BODY (matches installedHash / `skl update`).
152
+ */
153
+ async function driftVerdict(skill: DiscoveredSkill, destDir: string): Promise<Verdict> {
154
+ const localPath = join(destDir, "SKILL.md");
155
+ if (!existsSync(localPath)) return "new";
156
+ const upstream = hashContent(bodyOf(await readSkillBody(skill.dir)));
157
+ let localText = "";
158
+ try {
159
+ localText = await Bun.file(localPath).text();
160
+ } catch {
161
+ localText = "";
162
+ }
163
+ const local = hashContent(bodyOf(localText));
164
+ return upstream === local ? "identical" : "differs";
165
+ }
166
+
167
+ /**
168
+ * Optionally run an AI inference tagging pass over a freshly-installed skill.
79
169
  *
80
- * On any failure the skill stays untagged (empty overlay) a valid state.
81
- * Returns the domains written, if any.
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.
82
173
  */
83
- async function maybeInferTags(skill: Skill): Promise<string[] | null> {
174
+ async function maybeInferTags(
175
+ skill: Skill,
176
+ warn?: (msg: string) => void,
177
+ ): Promise<string[] | null> {
84
178
  const candidates = ["../core/infer.ts", "../adapters/inference/tag.ts"];
85
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;
86
185
  try {
87
- // Non-literal specifier: keeps a missing optional module from becoming a
88
- // static import/resolution error; degrades to "untagged" at runtime.
89
- const spec: string = rel;
90
- const mod: unknown = await import(spec).catch(() => null);
91
- if (!mod || typeof mod !== "object") continue;
92
- const hook = (mod as Record<string, unknown>).tagSkill;
93
- if (typeof hook !== "function") continue;
94
186
  const result = await (hook as (s: Skill) => Promise<string[] | null>)(skill);
95
187
  if (Array.isArray(result)) {
96
188
  return result.filter((d) => typeof d === "string" && d.trim() !== "");
97
189
  }
98
190
  return null;
99
- } catch {
100
- /* try next candidate / leave untagged */
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;
101
196
  }
102
197
  }
103
198
  return null;
104
199
  }
105
200
 
201
+ interface InstallOptions {
202
+ libraryPath: string;
203
+ domainFolder: string | null;
204
+ /** single-skill --name override; ignored in multi mode */
205
+ nameOverride: string | null;
206
+ /** per-skill lockfile `source` string (already carries the @subpath / #subpath) */
207
+ sourceStr: string;
208
+ ref: string;
209
+ channel: string;
210
+ infer: boolean;
211
+ force: boolean;
212
+ /** multi mode applies skip-differs-without-force + the never-write-through-symlink
213
+ * guard; single mode preserves the legacy "exists without --force → error" rule. */
214
+ multi: boolean;
215
+ }
216
+
217
+ interface InstallOutcome {
218
+ name: string;
219
+ subpath: string;
220
+ verdict: Verdict | "duplicate";
221
+ status: "installed" | "skipped" | "error";
222
+ reason: string;
223
+ path: string;
224
+ source: string;
225
+ ref: string;
226
+ channel: string;
227
+ installedAt: string;
228
+ tagged: boolean;
229
+ domains: string[];
230
+ }
231
+
232
+ /** Install (copy + record provenance + tag) a single discovered skill. */
233
+ async function installOne(
234
+ ctx: Ctx,
235
+ skill: DiscoveredSkill,
236
+ opts: InstallOptions,
237
+ ): Promise<InstallOutcome> {
238
+ const rawName =
239
+ opts.nameOverride && opts.nameOverride.trim() !== "" ? opts.nameOverride.trim() : skill.name;
240
+ const base: InstallOutcome = {
241
+ name: rawName,
242
+ subpath: skill.subpath,
243
+ verdict: "new",
244
+ status: "error",
245
+ reason: "",
246
+ path: "",
247
+ source: opts.sourceStr,
248
+ ref: opts.ref,
249
+ channel: opts.channel,
250
+ installedAt: "",
251
+ tagged: false,
252
+ domains: [],
253
+ };
254
+
255
+ // SECURITY: `name` derives from untrusted upstream frontmatter (or --name) and is
256
+ // joined into a library path. Reject anything that isn't a clean slug, then a
257
+ // belt-and-suspenders single-segment check, so a crafted name (e.g. "../../etc")
258
+ // can't escape the library before it reaches join()/copy.
259
+ if (!SLUG_RE.test(rawName)) {
260
+ return {
261
+ ...base,
262
+ reason: `invalid skill name "${rawName}" — use lowercase letters, digits, and hyphens${opts.multi ? "" : " (override with --name <slug>)"}`,
263
+ };
264
+ }
265
+ try {
266
+ assertSafeName(rawName);
267
+ } catch (err) {
268
+ return { ...base, reason: err instanceof Error ? err.message : String(err) };
269
+ }
270
+
271
+ const destDir = destDirFor(opts.libraryPath, opts.domainFolder, rawName);
272
+ const verdict = await driftVerdict(skill, destDir);
273
+ base.verdict = verdict;
274
+
275
+ // Never copy THROUGH a symlink into something the library doesn't own: a LINKED leaf
276
+ // entry, OR a destination reached through a symlinked ANCESTOR (e.g. a symlinked
277
+ // --domain folder) whose realpath escapes the library. Writing through either would
278
+ // clobber an external dev repo, even with --force (ADR-0004).
279
+ const throughSymlink = isSymlink(destDir) || destEscapesLibrary(opts.libraryPath, destDir);
280
+
281
+ if (opts.multi) {
282
+ if (throughSymlink) {
283
+ return {
284
+ ...base,
285
+ status: "skipped",
286
+ reason: "linked entry / resolves outside the library — not overwriting via symlink",
287
+ };
288
+ }
289
+ // new + identical install; differs needs --force.
290
+ if (verdict === "differs" && !opts.force) {
291
+ return { ...base, status: "skipped", reason: "local body differs from upstream — not overwriting (use --force)" };
292
+ }
293
+ } else {
294
+ // SINGLE path: refuse to write through a symlink even with --force (never clobber a
295
+ // dev repo), then preserve today's exact rule — refuse any existing dest w/o --force.
296
+ if (throughSymlink) {
297
+ return {
298
+ ...base,
299
+ reason: `${rawName} resolves through a symlink outside the library (${destDir}) — refusing to write (manage a linked entry with \`skl link\`/\`skl rm\`)`,
300
+ };
301
+ }
302
+ if (existsSync(destDir) && !opts.force) {
303
+ return {
304
+ ...base,
305
+ reason: `${rawName} already exists at ${destDir} (use --force to overwrite, or skl update ${rawName} to re-pull)`,
306
+ };
307
+ }
308
+ }
309
+
310
+ // ---- write into the library ----
311
+ await ensureDir(opts.domainFolder ? join(opts.libraryPath, opts.domainFolder) : opts.libraryPath);
312
+ await copySkillDir(skill.dir, destDir);
313
+
314
+ const installedBody = bodyOf(await readSkillBody(skill.dir));
315
+ const installedAt = new Date().toISOString();
316
+ const entry: LockEntry = {
317
+ name: rawName,
318
+ source: opts.sourceStr,
319
+ ref: opts.ref,
320
+ channel: opts.channel,
321
+ installedAt,
322
+ localEdits: false,
323
+ installedHash: hashContent(installedBody),
324
+ };
325
+ await recordEntry(opts.libraryPath, entry);
326
+
327
+ const installed: Skill = {
328
+ name: rawName,
329
+ description: skill.description,
330
+ primaryDomain: opts.domainFolder,
331
+ domains: opts.domainFolder ? [opts.domainFolder] : [],
332
+ path: destDir,
333
+ bodyPath: join(destDir, "SKILL.md"),
334
+ refFiles: [],
335
+ source: { source: opts.sourceStr, ref: opts.ref, channel: opts.channel, installedAt, localEdits: false },
336
+ retired: false,
337
+ mirrorOf: null,
338
+ contentHash: "",
339
+ };
340
+ if (opts.domainFolder) await setDomainsForName(opts.libraryPath, rawName, [opts.domainFolder]);
341
+
342
+ let inferred: string[] | null = null;
343
+ if (opts.infer) {
344
+ inferred = await maybeInferTags(installed, (m) => ctx.error(m));
345
+ if (inferred && inferred.length > 0) await setDomainsForName(opts.libraryPath, rawName, inferred);
346
+ }
347
+ const domains = inferred && inferred.length > 0 ? inferred : opts.domainFolder ? [opts.domainFolder] : [];
348
+
349
+ return {
350
+ ...base,
351
+ status: "installed",
352
+ reason:
353
+ verdict === "identical"
354
+ ? "re-installed (identical body)"
355
+ : verdict === "differs"
356
+ ? "overwrote differing body (--force)"
357
+ : "installed",
358
+ path: destDir,
359
+ installedAt,
360
+ tagged: Boolean(inferred && inferred.length > 0),
361
+ domains,
362
+ };
363
+ }
364
+
365
+ /** `--list`: discover + print, no writes. */
366
+ function reportList(
367
+ ctx: Ctx,
368
+ flags: Flags,
369
+ parsed: ParsedSource,
370
+ ref: string,
371
+ discovered: DiscoveredSkill[],
372
+ library: Skill[],
373
+ ): number {
374
+ const rows = discovered.map((d) => ({
375
+ name: d.name,
376
+ description: d.description,
377
+ subpath: d.subpath,
378
+ inLibrary: Boolean(findByName(library, d.name)),
379
+ }));
380
+ if (flags.json) {
381
+ ctx.json({ ok: true, action: "list", source: parsed.source, ref, count: rows.length, skills: rows });
382
+ return 0;
383
+ }
384
+ ctx.log(`${rows.length} skill(s) in ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""}:`);
385
+ ctx.log("");
386
+ for (const r of rows) {
387
+ const mark = r.inLibrary ? "✓" : " ";
388
+ ctx.log(` ${mark} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
389
+ if (r.description) ctx.log(` ${r.description.length > 100 ? r.description.slice(0, 99) + "…" : r.description}`);
390
+ }
391
+ ctx.log("");
392
+ ctx.log(`✓ = already in your library. Install with: skl add ${flags.src} --all (or --skill <name,…>)`);
393
+ return 0;
394
+ }
395
+
396
+ /** `--dry-run`: drift preflight over the full discovered set, no writes. */
397
+ async function reportDryRun(
398
+ ctx: Ctx,
399
+ flags: Flags,
400
+ parsed: ParsedSource,
401
+ ref: string,
402
+ discovered: DiscoveredSkill[],
403
+ domainFolder: string | null,
404
+ ): Promise<number> {
405
+ interface Row {
406
+ name: string;
407
+ subpath: string;
408
+ verdict: Verdict | "invalid" | "linked";
409
+ willInstall: boolean;
410
+ needsForce: boolean;
411
+ }
412
+ const rows: Row[] = [];
413
+ for (const d of discovered) {
414
+ if (!SLUG_RE.test(d.name)) {
415
+ rows.push({ name: d.name, subpath: d.subpath, verdict: "invalid", willInstall: false, needsForce: false });
416
+ continue;
417
+ }
418
+ const destDir = destDirFor(ctx.config.libraryPath, domainFolder, d.name);
419
+ if (isSymlink(destDir) || destEscapesLibrary(ctx.config.libraryPath, destDir)) {
420
+ rows.push({ name: d.name, subpath: d.subpath, verdict: "linked", willInstall: false, needsForce: false });
421
+ continue;
422
+ }
423
+ const verdict = await driftVerdict(d, destDir);
424
+ const needsForce = verdict === "differs";
425
+ const willInstall = verdict === "new" || verdict === "identical" || (verdict === "differs" && flags.force);
426
+ rows.push({ name: d.name, subpath: d.subpath, verdict, willInstall, needsForce });
427
+ }
428
+ const counts = {
429
+ new: rows.filter((r) => r.verdict === "new").length,
430
+ identical: rows.filter((r) => r.verdict === "identical").length,
431
+ differs: rows.filter((r) => r.verdict === "differs").length,
432
+ linked: rows.filter((r) => r.verdict === "linked").length,
433
+ invalid: rows.filter((r) => r.verdict === "invalid").length,
434
+ };
435
+ const willInstall = rows.filter((r) => r.willInstall).length;
436
+ if (flags.json) {
437
+ ctx.json({ ok: true, action: "dry-run", source: parsed.source, ref, counts, willInstall, force: flags.force, skills: rows });
438
+ return 0;
439
+ }
440
+ ctx.log(`dry-run for ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${rows.length} skill(s)):`);
441
+ ctx.log("");
442
+ for (const r of rows) {
443
+ const tag =
444
+ r.verdict === "new"
445
+ ? "new "
446
+ : r.verdict === "identical"
447
+ ? "identical"
448
+ : r.verdict === "differs"
449
+ ? "DIFFERS "
450
+ : r.verdict === "linked"
451
+ ? "linked "
452
+ : "INVALID ";
453
+ const note = r.verdict === "differs" && !flags.force ? " (needs --force)" : "";
454
+ ctx.log(` ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}${note}`);
455
+ }
456
+ ctx.log("");
457
+ ctx.log(
458
+ `${counts.new} new, ${counts.identical} identical, ${counts.differs} differ${counts.linked ? `, ${counts.linked} linked` : ""}${counts.invalid ? `, ${counts.invalid} invalid` : ""} → ${willInstall} would install${flags.force ? " (--force)" : ""}.`,
459
+ );
460
+ if (counts.differs > 0 && !flags.force) ctx.log("re-run with --force to overwrite differing skills.");
461
+ return 0;
462
+ }
463
+
106
464
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
107
465
  const flags = parseFlags(argv);
108
466
  if (!flags.src) {
109
467
  ctx.error("usage:", meta.usage);
110
468
  return 1;
111
469
  }
470
+ if (flags.all && flags.skill !== null) {
471
+ ctx.error("add: --all and --skill are mutually exclusive");
472
+ return 1;
473
+ }
474
+
475
+ // --domain validated once up front (applies to every install path).
476
+ const domainFolder = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
477
+ if (domainFolder !== null && !SLUG_RE.test(domainFolder)) {
478
+ ctx.error(`add: invalid --domain "${domainFolder}" — use lowercase letters, digits, and hyphens`);
479
+ return 1;
480
+ }
112
481
 
113
482
  const parsed = parseSource(flags.src);
483
+ const repoChannel = parsed.channel === "github" || parsed.channel === "git";
484
+ const wantsMulti = flags.all || flags.skill !== null;
114
485
 
115
- // 1+2. DOWNLOAD into a staging dir (shell out only).
116
- const fetched = await fetchSource(parsed);
117
- if (!fetched.ok) {
118
- await cleanupStaging(fetched.staging);
119
- ctx.error("add: download failed:", fetched.error);
486
+ if (flags.name && wantsMulti) {
487
+ ctx.error("add: --name applies only to a single-skill add (omit --all/--skill)");
488
+ return 1;
489
+ }
490
+ if (!repoChannel && (wantsMulti || flags.list || flags.dryRun)) {
491
+ ctx.error("add: --all/--skill/--list/--dry-run apply to github:/git: repo sources, not registry names");
120
492
  return 1;
121
493
  }
122
494
 
495
+ // Per-skill lockfile `source`: each skill carries its OWN subpath (github uses the
496
+ // `owner/repo@subpath` convention; git encodes it after `#`; registry is the name).
497
+ const sourceOf = (skill: DiscoveredSkill): string => {
498
+ if (parsed.channel === "github") return `${parsed.source}${skill.subpath ? `@${skill.subpath}` : ""}`;
499
+ if (parsed.channel === "git") return `git:${parsed.localPath}${skill.subpath ? `#${skill.subpath}` : ""}`;
500
+ return parsed.source; // registry: the bare name
501
+ };
502
+
503
+ // ---- 1+2. FETCH (clone once for repos; the `skills` CLI for a registry name) ----
504
+ let staging: string | undefined;
505
+ let ref: string;
506
+ let channel: string;
507
+ let discovered: DiscoveredSkill[];
508
+
509
+ if (repoChannel) {
510
+ const repo = await fetchRepo(parsed);
511
+ if (!repo.ok) {
512
+ await cleanupStaging(repo.staging);
513
+ ctx.error("add: download failed:", repo.error);
514
+ return 1;
515
+ }
516
+ staging = repo.staging;
517
+ ref = repo.ref;
518
+ channel = repo.channel;
519
+ discovered = await discoverSkills(repo.checkout, parsed.subpath);
520
+ // Implicit single-skill add: if the convention gate found nothing, fall back to
521
+ // lenient single resolution so a one-skill repo whose SKILL.md omits a description
522
+ // still installs (pre-ADR-0006 behavior). NOT for --all/--skill/--list/--dry-run,
523
+ // where the name+description gate is the intended filter.
524
+ if (discovered.length === 0 && !flags.all && flags.skill === null && !flags.list && !flags.dryRun) {
525
+ const one = await discoverSingleLenient(repo.checkout, parsed.subpath);
526
+ if (one) discovered = [one];
527
+ }
528
+ } else {
529
+ const fetched = await fetchSource(parsed);
530
+ if (!fetched.ok) {
531
+ await cleanupStaging(fetched.staging);
532
+ ctx.error("add: download failed:", fetched.error);
533
+ return 1;
534
+ }
535
+ staging = fetched.staging;
536
+ ref = fetched.ref;
537
+ channel = fetched.channel;
538
+ const body = await readSkillBody(fetched.skillDir);
539
+ const { data } = parseFrontmatter(body);
540
+ const nm = typeof data.name === "string" && data.name.trim() !== "" ? data.name.trim() : basename(fetched.skillDir);
541
+ const desc = typeof data.description === "string" ? data.description.trim() : "";
542
+ discovered = [{ name: nm, dir: fetched.skillDir, subpath: "", description: desc }];
543
+ }
544
+
123
545
  try {
124
- // 3. Determine destination in the library.
125
- const name = await deriveName(fetched.skillDir, flags.name);
126
- const domainFolder = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
127
- const destDir = domainFolder
128
- ? join(ctx.config.libraryPath, domainFolder, name)
129
- : join(ctx.config.libraryPath, name);
130
-
131
- if (existsSync(destDir) && !flags.force) {
546
+ if (discovered.length === 0) {
132
547
  ctx.error(
133
- `add: ${name} already exists at ${destDir} (use --force to overwrite, or skl update ${name} to re-pull)`,
548
+ "add:",
549
+ parsed.subpath
550
+ ? `no SKILL.md found at ${parsed.subpath} in ${parsed.source}`
551
+ : `no skills found in ${parsed.source}`,
134
552
  );
135
553
  return 1;
136
554
  }
137
555
 
138
- await ensureDir(domainFolder ? join(ctx.config.libraryPath, domainFolder) : ctx.config.libraryPath);
139
- await copySkillDir(fetched.skillDir, destDir);
140
-
141
- // 4. Provenance lockfile entry. Record the installed body hash so a later
142
- // `skl update` can tell a user hand-edit apart from upstream moving forward.
143
- const installedBody = parseFrontmatter(await readSkillBody(fetched.skillDir)).body;
144
- // git: sources already encode their subpath as `#subpath` in fetched.source;
145
- // only github sources use the `@subpath` suffix convention here.
146
- const subSuffix = parsed.subpath && parsed.channel !== "git" ? `@${parsed.subpath}` : "";
147
- const entry: LockEntry = {
148
- name,
149
- source: `${fetched.source}${subSuffix}`,
150
- ref: fetched.ref,
151
- channel: fetched.channel,
152
- installedAt: new Date().toISOString(),
153
- localEdits: false,
154
- installedHash: hashContent(installedBody),
155
- };
156
- await recordEntry(ctx.config.libraryPath, entry);
157
-
158
- // 5. Empty overlay so taxonomy survives future updates.
159
- const installed: Skill = {
160
- name,
161
- description: "",
162
- primaryDomain: domainFolder,
163
- domains: domainFolder ? [domainFolder] : [],
164
- path: destDir,
165
- bodyPath: join(destDir, "SKILL.md"),
166
- refFiles: [],
167
- source: {
168
- source: entry.source,
169
- ref: entry.ref,
170
- channel: entry.channel,
171
- installedAt: entry.installedAt,
172
- localEdits: false,
173
- },
174
- retired: false,
175
- mirrorOf: null,
176
- contentHash: "",
177
- };
178
- const overlayPathStr = join(destDir, `${name}.shelf.json`);
179
- if (!existsSync(overlayPathStr)) {
180
- await writeOverlay(installed, domainFolder ? { domains: [domainFolder] } : {});
556
+ // ---- --list (report full set, no writes) ----
557
+ if (flags.list) {
558
+ const library = await loadLibrary(ctx.config.libraryPath);
559
+ return reportList(ctx, flags, parsed, ref, discovered, library);
560
+ }
561
+
562
+ // ---- --dry-run (drift preflight over the full set, no writes) ----
563
+ if (flags.dryRun) {
564
+ return await reportDryRun(ctx, flags, parsed, ref, discovered, domainFolder);
181
565
  }
182
566
 
183
- // 6. Inference tagging hook (best-effort, leaves untagged if unavailable).
184
- let inferredDomains: string[] | null = null;
185
- if (flags.infer) {
186
- inferredDomains = await maybeInferTags(installed);
187
- if (inferredDomains && inferredDomains.length > 0) {
188
- await writeOverlay(installed, { domains: inferredDomains });
567
+ // ---- selection ----
568
+ let selected: DiscoveredSkill[];
569
+ let multi: boolean;
570
+ if (flags.skill !== null) {
571
+ const want = new Set(flags.skill);
572
+ selected = discovered.filter((d) => want.has(d.name));
573
+ const missing = [...want].filter((n) => !discovered.some((d) => d.name === n));
574
+ if (missing.length > 0) {
575
+ ctx.error(`add: requested skill(s) not found in ${parsed.source}: ${missing.join(", ")}`);
576
+ ctx.error(` available: ${discovered.map((d) => d.name).join(", ") || "(none)"}`);
577
+ return 1;
189
578
  }
579
+ multi = true;
580
+ } else if (flags.all) {
581
+ selected = discovered;
582
+ multi = true;
583
+ } else if (discovered.length > 1) {
584
+ ctx.error(
585
+ `add: ${discovered.length} skills found in ${parsed.source} — choose with --all, --skill <name,…>, or inspect with --list:`,
586
+ );
587
+ for (const d of discovered) ctx.error(` ${d.name}${d.subpath ? ` (${d.subpath})` : ""}`);
588
+ return 1;
589
+ } else {
590
+ selected = discovered;
591
+ multi = false;
190
592
  }
191
593
 
192
- const summary = {
193
- ok: true,
194
- name,
195
- path: destDir,
196
- source: entry.source,
197
- ref: entry.ref,
198
- channel: entry.channel,
199
- installedAt: entry.installedAt,
200
- tagged: Boolean(inferredDomains && inferredDomains.length > 0),
201
- domains: inferredDomains ?? (domainFolder ? [domainFolder] : []),
202
- };
594
+ // ---- install ----
595
+ if (!multi) {
596
+ const o = await installOne(ctx, selected[0]!, {
597
+ libraryPath: ctx.config.libraryPath,
598
+ domainFolder,
599
+ nameOverride: flags.name,
600
+ sourceStr: sourceOf(selected[0]!),
601
+ ref,
602
+ channel,
603
+ infer: flags.infer,
604
+ force: flags.force,
605
+ multi: false,
606
+ });
607
+ if (o.status === "error") {
608
+ ctx.error("add:", o.reason);
609
+ return 1;
610
+ }
611
+ // Legacy single-skill summary shape (unchanged for existing consumers).
612
+ const summary = {
613
+ ok: true,
614
+ name: o.name,
615
+ path: o.path,
616
+ source: o.source,
617
+ ref: o.ref,
618
+ channel: o.channel,
619
+ installedAt: o.installedAt,
620
+ tagged: o.tagged,
621
+ domains: o.domains,
622
+ };
623
+ if (flags.json) {
624
+ ctx.json(summary);
625
+ } else {
626
+ ctx.log(`added ${o.name}`);
627
+ ctx.log(` path: ${o.path}`);
628
+ ctx.log(` source: ${o.source}`);
629
+ ctx.log(` ref: ${o.ref || "(unknown)"}`);
630
+ ctx.log(` channel: ${o.channel}`);
631
+ if (o.tagged) ctx.log(` domains: ${o.domains.join(", ")}`);
632
+ else ctx.log(` domains: (untagged — run \`skl infer\` to assign)`);
633
+ }
634
+ return 0;
635
+ }
636
+
637
+ // multi: install each selected skill out of the single staging checkout. Two
638
+ // upstream skills sharing a frontmatter `name` would target the SAME library slug
639
+ // (last-write-wins clobber + a single lockfile entry); install the first, skip the
640
+ // rest with a duplicate-slug reason so nothing is silently lost or miscounted.
641
+ const outcomes: InstallOutcome[] = [];
642
+ const seenSlugs = new Set<string>();
643
+ for (const s of selected) {
644
+ if (seenSlugs.has(s.name)) {
645
+ outcomes.push({
646
+ name: s.name,
647
+ subpath: s.subpath,
648
+ verdict: "duplicate",
649
+ status: "skipped",
650
+ reason: `duplicate slug "${s.name}" — another skill in this repo already installs to it; skipped to avoid clobbering`,
651
+ path: "",
652
+ source: sourceOf(s),
653
+ ref,
654
+ channel,
655
+ installedAt: "",
656
+ tagged: false,
657
+ domains: [],
658
+ });
659
+ continue;
660
+ }
661
+ seenSlugs.add(s.name);
662
+ outcomes.push(
663
+ await installOne(ctx, s, {
664
+ libraryPath: ctx.config.libraryPath,
665
+ domainFolder,
666
+ nameOverride: null,
667
+ sourceStr: sourceOf(s),
668
+ ref,
669
+ channel,
670
+ infer: flags.infer,
671
+ force: flags.force,
672
+ multi: true,
673
+ }),
674
+ );
675
+ }
676
+ const installed = outcomes.filter((o) => o.status === "installed");
677
+ const skipped = outcomes.filter((o) => o.status === "skipped");
678
+ const errored = outcomes.filter((o) => o.status === "error");
203
679
 
204
680
  if (flags.json) {
205
- ctx.json(summary);
681
+ ctx.json({
682
+ ok: errored.length === 0,
683
+ action: "add",
684
+ source: parsed.source,
685
+ ref,
686
+ counts: { selected: selected.length, installed: installed.length, skipped: skipped.length, errors: errored.length },
687
+ results: outcomes.map((o) => ({
688
+ name: o.name,
689
+ subpath: o.subpath,
690
+ status: o.status,
691
+ verdict: o.verdict,
692
+ reason: o.reason,
693
+ source: o.source,
694
+ ref: o.ref,
695
+ path: o.path,
696
+ tagged: o.tagged,
697
+ domains: o.domains,
698
+ })),
699
+ });
206
700
  } else {
207
- ctx.log(`added ${name}`);
208
- ctx.log(` path: ${destDir}`);
209
- ctx.log(` source: ${entry.source}`);
210
- ctx.log(` ref: ${entry.ref || "(unknown)"}`);
211
- ctx.log(` channel: ${entry.channel}`);
212
- if (summary.tagged) ctx.log(` domains: ${summary.domains.join(", ")}`);
213
- else ctx.log(` domains: (untagged run \`skl infer\` to assign)`);
701
+ for (const o of outcomes) {
702
+ const tag = o.status === "installed" ? "added " : o.status === "skipped" ? "skipped " : "ERROR ";
703
+ ctx.log(`${tag} ${o.name.padEnd(28)} ${o.reason}`);
704
+ }
705
+ ctx.log("");
706
+ ctx.log(
707
+ `${selected.length} selected, ${installed.length} installed, ${skipped.length} skipped${errored.length ? `, ${errored.length} error(s)` : ""} from ${parsed.source}`,
708
+ );
709
+ if (skipped.some((o) => o.verdict === "differs")) ctx.log("re-run with --force to overwrite differing skills.");
214
710
  }
215
- return 0;
711
+ return errored.length > 0 ? 1 : 0;
216
712
  } catch (err) {
217
713
  ctx.error("add: failed:", err instanceof Error ? err.message : String(err));
218
714
  return 1;
219
715
  } finally {
220
- await cleanupStaging(fetched.staging);
716
+ await cleanupStaging(staging);
221
717
  }
222
718
  }