skillshelf 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,28 +18,31 @@
18
18
  //
19
19
  // Read-only commands take --json; add is a write, but still emits a --json summary.
20
20
 
21
- import { join, basename, dirname, sep } from "node:path";
22
- import { existsSync } from "node:fs";
23
- import type { Ctx, Skill, LockEntry } from "../types.ts";
21
+ import { basename } from "node:path";
22
+ import type { Ctx, Skill } from "../types.ts";
24
23
  import {
25
24
  parseSource,
26
25
  fetchSource,
27
26
  fetchRepo,
28
27
  discoverSkills,
29
28
  discoverSingleLenient,
30
- copySkillDir,
31
29
  cleanupStaging,
32
30
  readSkillBody,
33
31
  type DiscoveredSkill,
34
32
  type ParsedSource,
35
33
  } from "../core/fetch.ts";
36
34
  import { parseFrontmatter } from "../lib/frontmatter.ts";
37
- import { hashContent } from "../core/crawl.ts";
38
- import { recordEntry } from "../core/provenance.ts";
39
- import { setDomainsForName } from "../core/taxonomy.ts";
40
- import { assertSafeName, SLUG_RE } from "../core/lifecycle.ts";
41
- import { loadLibrary, findByName, entryStatus } from "../core/library.ts";
42
- import { ensureDir, isSymlink, realpathOrSelf } from "../lib/fs.ts";
35
+ import { SLUG_RE } from "../core/lifecycle.ts";
36
+ import { loadLibrary, findByName } from "../core/library.ts";
37
+ import {
38
+ installSkill,
39
+ destDirFor,
40
+ driftVerdict,
41
+ writesThroughSymlink,
42
+ type InstallOutcome,
43
+ type Verdict,
44
+ } from "../core/vendor.ts";
45
+ import { render, addDryRunVerdictMark, type CommandResult } from "../core/report.ts";
43
46
 
44
47
  export const meta = {
45
48
  name: "add",
@@ -116,256 +119,11 @@ function parseFlags(argv: string[]): Flags {
116
119
  return f;
117
120
  }
118
121
 
119
- /** Body text after frontmatter the unit drift/install hashes operate on. */
120
- function bodyOf(text: string): string {
121
- return parseFrontmatter(text).body;
122
- }
123
-
124
- /** The library destination dir for a slug (under its domain folder, if any). */
125
- function destDirFor(libraryPath: string, domainFolder: string | null, name: string): string {
126
- return domainFolder ? join(libraryPath, domainFolder, name) : join(libraryPath, name);
127
- }
128
-
129
- /** The nearest ancestor of `p` (incl. `p`) that exists on disk; falls back to `p`. */
130
- function nearestExisting(p: string): string {
131
- let cur = p;
132
- while (!existsSync(cur) && !isSymlink(cur)) {
133
- const parent = dirname(cur);
134
- if (parent === cur) return cur;
135
- cur = parent;
136
- }
137
- return cur;
138
- }
139
-
140
- /**
141
- * True if writing to `destDir` would resolve OUTSIDE the library — i.e. the nearest
142
- * existing component on the way to destDir is (or is reached through) a symlink whose
143
- * realpath escapes the library. Catches a symlinked DOMAIN folder (`library/<d> ->
144
- * /external`) that the leaf-only `isSymlink(destDir)` check misses, so `--force` can't
145
- * clobber an external tree through a symlinked parent (ADR-0004). Both sides anchor to
146
- * their nearest existing ancestor, so a fresh (not-yet-created) library is not a false
147
- * positive — its anchor is the shared parent, which contains itself.
148
- */
149
- function destEscapesLibrary(libraryPath: string, destDir: string): boolean {
150
- const libReal = realpathOrSelf(nearestExisting(libraryPath));
151
- const destReal = realpathOrSelf(nearestExisting(destDir));
152
- return !(destReal === libReal || destReal.startsWith(libReal + sep));
153
- }
154
-
155
- type Verdict = "new" | "identical" | "differs";
156
-
157
- /**
158
- * Drift preflight for one skill against the library destination it would install to:
159
- * - new — nothing at the destination → would install
160
- * - identical — destination body hash matches upstream → lossless overwrite
161
- * - differs — destination body differs → would clobber local content (needs --force)
162
- * Compares the frontmatter-stripped BODY (matches installedHash / `skl update`).
163
- */
164
- async function driftVerdict(skill: DiscoveredSkill, destDir: string): Promise<Verdict> {
165
- const localPath = join(destDir, "SKILL.md");
166
- if (!existsSync(localPath)) return "new";
167
- const upstream = hashContent(bodyOf(await readSkillBody(skill.dir)));
168
- let localText = "";
169
- try {
170
- localText = await Bun.file(localPath).text();
171
- } catch {
172
- localText = "";
173
- }
174
- const local = hashContent(bodyOf(localText));
175
- return upstream === local ? "identical" : "differs";
176
- }
177
-
178
- /**
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.
183
- */
184
- async function maybeInferTags(
185
- _skill: Skill,
186
- _warn?: (msg: string) => void,
187
- ): Promise<string[] | null> {
188
- return null;
189
- }
190
-
191
- interface InstallOptions {
192
- libraryPath: string;
193
- domainFolder: string | null;
194
- /** single-skill --name override; ignored in multi mode */
195
- nameOverride: string | null;
196
- /** per-skill lockfile `source` string (already carries the @subpath / #subpath) */
197
- sourceStr: string;
198
- ref: string;
199
- channel: string;
200
- infer: boolean;
201
- force: boolean;
202
- /** multi mode applies skip-differs-without-force + the never-write-through-symlink
203
- * guard; single mode preserves the legacy "exists without --force → error" rule. */
204
- multi: boolean;
205
- }
206
-
207
- interface InstallOutcome {
208
- name: string;
209
- subpath: string;
210
- verdict: Verdict | "duplicate" | "retired";
211
- status: "installed" | "skipped" | "error";
212
- reason: string;
213
- path: string;
214
- source: string;
215
- ref: string;
216
- channel: string;
217
- installedAt: string;
218
- tagged: boolean;
219
- domains: string[];
220
- }
221
-
222
- /** Install (copy + record provenance + tag) a single discovered skill. */
223
- async function installOne(
224
- ctx: Ctx,
225
- skill: DiscoveredSkill,
226
- opts: InstallOptions,
227
- ): Promise<InstallOutcome> {
228
- const rawName =
229
- opts.nameOverride && opts.nameOverride.trim() !== "" ? opts.nameOverride.trim() : skill.name;
230
- const base: InstallOutcome = {
231
- name: rawName,
232
- subpath: skill.subpath,
233
- verdict: "new",
234
- status: "error",
235
- reason: "",
236
- path: "",
237
- source: opts.sourceStr,
238
- ref: opts.ref,
239
- channel: opts.channel,
240
- installedAt: "",
241
- tagged: false,
242
- domains: [],
243
- };
244
-
245
- // SECURITY: `name` derives from untrusted upstream frontmatter (or --name) and is
246
- // joined into a library path. Reject anything that isn't a clean slug, then a
247
- // belt-and-suspenders single-segment check, so a crafted name (e.g. "../../etc")
248
- // can't escape the library before it reaches join()/copy.
249
- if (!SLUG_RE.test(rawName)) {
250
- return {
251
- ...base,
252
- reason: `invalid skill name "${rawName}" — use lowercase letters, digits, and hyphens${opts.multi ? "" : " (override with --name <slug>)"}`,
253
- };
254
- }
255
- try {
256
- assertSafeName(rawName);
257
- } catch (err) {
258
- return { ...base, reason: err instanceof Error ? err.message : String(err) };
259
- }
260
-
261
- // Retired-aware collision guard: if this name exists ONLY as a retired tombstone
262
- // (<library>/_retired/<name>), do NOT install a fresh active copy beside it — that
263
- // strands a duplicate and breaks `skl unretire`. The user must unretire first. This
264
- // fires regardless of --force (force overwrites an ACTIVE copy, not a retired one).
265
- // Checked against the flat library root (retirement is never under a domain folder).
266
- const status = entryStatus(opts.libraryPath, rawName);
267
- if (status.retired && !status.active) {
268
- return {
269
- ...base,
270
- verdict: "retired",
271
- status: "skipped",
272
- reason: `a retired '${rawName}' exists — run \`skl unretire ${rawName}\` first`,
273
- };
274
- }
275
-
276
- const destDir = destDirFor(opts.libraryPath, opts.domainFolder, rawName);
277
- const verdict = await driftVerdict(skill, destDir);
278
- base.verdict = verdict;
279
-
280
- // Never copy THROUGH a symlink into something the library doesn't own: a LINKED leaf
281
- // entry, OR a destination reached through a symlinked ANCESTOR (e.g. a symlinked
282
- // --domain folder) whose realpath escapes the library. Writing through either would
283
- // clobber an external dev repo, even with --force (ADR-0004).
284
- const throughSymlink = isSymlink(destDir) || destEscapesLibrary(opts.libraryPath, destDir);
285
-
286
- if (opts.multi) {
287
- if (throughSymlink) {
288
- return {
289
- ...base,
290
- status: "skipped",
291
- reason: "linked entry / resolves outside the library — not overwriting via symlink",
292
- };
293
- }
294
- // new + identical install; differs needs --force.
295
- if (verdict === "differs" && !opts.force) {
296
- return { ...base, status: "skipped", reason: "local body differs from upstream — not overwriting (use --force)" };
297
- }
298
- } else {
299
- // SINGLE path: refuse to write through a symlink even with --force (never clobber a
300
- // dev repo), then preserve today's exact rule — refuse any existing dest w/o --force.
301
- if (throughSymlink) {
302
- return {
303
- ...base,
304
- reason: `${rawName} resolves through a symlink outside the library (${destDir}) — refusing to write (manage a linked entry with \`skl link\`/\`skl rm\`)`,
305
- };
306
- }
307
- if (existsSync(destDir) && !opts.force) {
308
- return {
309
- ...base,
310
- reason: `${rawName} already exists at ${destDir} (use --force to overwrite, or skl update ${rawName} to re-pull)`,
311
- };
312
- }
313
- }
314
-
315
- // ---- write into the library ----
316
- await ensureDir(opts.domainFolder ? join(opts.libraryPath, opts.domainFolder) : opts.libraryPath);
317
- await copySkillDir(skill.dir, destDir);
318
-
319
- const installedBody = bodyOf(await readSkillBody(skill.dir));
320
- const installedAt = new Date().toISOString();
321
- const entry: LockEntry = {
322
- name: rawName,
323
- source: opts.sourceStr,
324
- ref: opts.ref,
325
- channel: opts.channel,
326
- installedAt,
327
- localEdits: false,
328
- installedHash: hashContent(installedBody),
329
- };
330
- await recordEntry(opts.libraryPath, entry);
331
-
332
- const installed: Skill = {
333
- name: rawName,
334
- description: skill.description,
335
- primaryDomain: opts.domainFolder,
336
- domains: opts.domainFolder ? [opts.domainFolder] : [],
337
- path: destDir,
338
- bodyPath: join(destDir, "SKILL.md"),
339
- refFiles: [],
340
- source: { source: opts.sourceStr, ref: opts.ref, channel: opts.channel, installedAt, localEdits: false },
341
- retired: false,
342
- mirrorOf: null,
343
- contentHash: "",
344
- };
345
- if (opts.domainFolder) await setDomainsForName(opts.libraryPath, rawName, [opts.domainFolder]);
346
-
347
- let inferred: string[] | null = null;
348
- if (opts.infer) {
349
- inferred = await maybeInferTags(installed, (m) => ctx.error(m));
350
- if (inferred && inferred.length > 0) await setDomainsForName(opts.libraryPath, rawName, inferred);
351
- }
352
- const domains = inferred && inferred.length > 0 ? inferred : opts.domainFolder ? [opts.domainFolder] : [];
353
-
354
- return {
355
- ...base,
356
- status: "installed",
357
- reason:
358
- verdict === "identical"
359
- ? "re-installed (identical body)"
360
- : verdict === "differs"
361
- ? "overwrote differing body (--force)"
362
- : "installed",
363
- path: destDir,
364
- installedAt,
365
- tagged: Boolean(inferred && inferred.length > 0),
366
- domains,
367
- };
368
- }
122
+ // The vendor WRITE operations (installSkill copy+provenance+verdict, the drift verdict,
123
+ // destDirFor, and the symlink-escape guard) live in core/vendor.ts — the curator boundary
124
+ // where `add` (and only `add`) writes the library. This command keeps the parse + select
125
+ // + render; vendor owns the mutation. driftVerdict is still imported here for the
126
+ // read-only --dry-run preflight (no write).
369
127
 
370
128
  /** `--list`: discover + print, no writes. */
371
129
  function reportList(
@@ -386,22 +144,26 @@ function reportList(
386
144
  published: d.published,
387
145
  internal: d.internal,
388
146
  }));
389
- if (flags.json) {
390
- ctx.json({ ok: true, action: "list", source: parsed.source, ref, count: rows.length, skills: rows });
391
- return 0;
392
- }
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):`);
395
- ctx.log("");
396
- for (const r of rows) {
397
- const mark = r.inLibrary ? "✓" : " ";
398
- const tag = r.published ? "published " : r.internal ? "internal " : "unpublished";
399
- ctx.log(` ${mark} ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
400
- if (r.description) ctx.log(` ${r.description.length > 100 ? r.description.slice(0, 99) + "…" : r.description}`);
401
- }
402
- ctx.log("");
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,…>)`);
147
+ // Structured payload (verbatim) + human renderer; the json/human fork goes through
148
+ // render() (the reporter seam). Read-only --list always exits 0.
149
+ const result: CommandResult = {
150
+ json: { ok: true, action: "list", source: parsed.source, ref, count: rows.length, skills: rows },
151
+ human: (emit) => {
152
+ const publishedCount = rows.filter((r) => r.published).length;
153
+ emit(`${rows.length} skill(s) in ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${publishedCount} published):`);
154
+ emit();
155
+ for (const r of rows) {
156
+ const mark = r.inLibrary ? "" : " ";
157
+ const tag = r.published ? "published " : r.internal ? "internal " : "unpublished";
158
+ emit(` ${mark} ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}`);
159
+ if (r.description) emit(` ${r.description.length > 100 ? r.description.slice(0, 99) + "…" : r.description}`);
160
+ }
161
+ emit();
162
+ emit(`✓ = already in your library. published = installed by --all; unpublished/internal = only via --skill <name>.`);
163
+ emit(`Install with: skl add ${flags.src} --all (or --skill <name,…>)`);
164
+ },
165
+ };
166
+ render(ctx, flags.json, result);
405
167
  return 0;
406
168
  }
407
169
 
@@ -428,7 +190,7 @@ async function reportDryRun(
428
190
  continue;
429
191
  }
430
192
  const destDir = destDirFor(ctx.config.libraryPath, domainFolder, d.name);
431
- if (isSymlink(destDir) || destEscapesLibrary(ctx.config.libraryPath, destDir)) {
193
+ if (writesThroughSymlink(ctx.config.libraryPath, destDir)) {
432
194
  rows.push({ name: d.name, subpath: d.subpath, verdict: "linked", willInstall: false, needsForce: false });
433
195
  continue;
434
196
  }
@@ -445,31 +207,25 @@ async function reportDryRun(
445
207
  invalid: rows.filter((r) => r.verdict === "invalid").length,
446
208
  };
447
209
  const willInstall = rows.filter((r) => r.willInstall).length;
448
- if (flags.json) {
449
- ctx.json({ ok: true, action: "dry-run", source: parsed.source, ref, counts, willInstall, force: flags.force, skills: rows });
450
- return 0;
451
- }
452
- ctx.log(`dry-run for ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${rows.length} skill(s)):`);
453
- ctx.log("");
454
- for (const r of rows) {
455
- const tag =
456
- r.verdict === "new"
457
- ? "new "
458
- : r.verdict === "identical"
459
- ? "identical"
460
- : r.verdict === "differs"
461
- ? "DIFFERS "
462
- : r.verdict === "linked"
463
- ? "linked "
464
- : "INVALID ";
465
- const note = r.verdict === "differs" && !flags.force ? " (needs --force)" : "";
466
- ctx.log(` ${tag} ${r.name.padEnd(28)} ${r.subpath || "(root)"}${note}`);
467
- }
468
- ctx.log("");
469
- ctx.log(
470
- `${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)" : ""}.`,
471
- );
472
- if (counts.differs > 0 && !flags.force) ctx.log("re-run with --force to overwrite differing skills.");
210
+ // Structured payload (verbatim) + human renderer; the verdict->tag ladder now lives in
211
+ // report.ts as addDryRunVerdictMark(). Read-only --dry-run always exits 0.
212
+ const result: CommandResult = {
213
+ json: { ok: true, action: "dry-run", source: parsed.source, ref, counts, willInstall, force: flags.force, skills: rows },
214
+ human: (emit) => {
215
+ emit(`dry-run for ${parsed.source}${ref ? ` @ ${ref.slice(0, 10)}` : ""} (${rows.length} skill(s)):`);
216
+ emit();
217
+ for (const r of rows) {
218
+ const note = r.verdict === "differs" && !flags.force ? " (needs --force)" : "";
219
+ emit(` ${addDryRunVerdictMark(r.verdict)} ${r.name.padEnd(28)} ${r.subpath || "(root)"}${note}`);
220
+ }
221
+ emit();
222
+ emit(
223
+ `${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)" : ""}.`,
224
+ );
225
+ if (counts.differs > 0 && !flags.force) emit("re-run with --force to overwrite differing skills.");
226
+ },
227
+ };
228
+ render(ctx, flags.json, result);
473
229
  return 0;
474
230
  }
475
231
 
@@ -635,17 +391,21 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
635
391
 
636
392
  // ---- install ----
637
393
  if (!multi) {
638
- const o = await installOne(ctx, selected[0]!, {
639
- libraryPath: ctx.config.libraryPath,
640
- domainFolder,
641
- nameOverride: flags.name,
642
- sourceStr: sourceOf(selected[0]!),
643
- ref,
644
- channel,
645
- infer: flags.infer,
646
- force: flags.force,
647
- multi: false,
648
- });
394
+ const o = await installSkill(
395
+ selected[0]!,
396
+ {
397
+ libraryPath: ctx.config.libraryPath,
398
+ domainFolder,
399
+ nameOverride: flags.name,
400
+ sourceStr: sourceOf(selected[0]!),
401
+ ref,
402
+ channel,
403
+ infer: flags.infer,
404
+ force: flags.force,
405
+ multi: false,
406
+ },
407
+ (m) => ctx.error(m),
408
+ );
649
409
  if (o.status === "error") {
650
410
  ctx.error("add:", o.reason);
651
411
  return 1;
@@ -668,17 +428,19 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
668
428
  tagged: o.tagged,
669
429
  domains: o.domains,
670
430
  };
671
- if (flags.json) {
672
- ctx.json(summary);
673
- } else {
674
- ctx.log(`added ${o.name}`);
675
- ctx.log(` path: ${o.path}`);
676
- ctx.log(` source: ${o.source}`);
677
- ctx.log(` ref: ${o.ref || "(unknown)"}`);
678
- ctx.log(` channel: ${o.channel}`);
679
- if (o.tagged) ctx.log(` domains: ${o.domains.join(", ")}`);
680
- else ctx.log(` domains: (untagged — run \`skl infer\` to assign)`);
681
- }
431
+ const result: CommandResult = {
432
+ json: summary,
433
+ human: (emit) => {
434
+ emit(`added ${o.name}`);
435
+ emit(` path: ${o.path}`);
436
+ emit(` source: ${o.source}`);
437
+ emit(` ref: ${o.ref || "(unknown)"}`);
438
+ emit(` channel: ${o.channel}`);
439
+ if (o.tagged) emit(` domains: ${o.domains.join(", ")}`);
440
+ else emit(` domains: (untagged — run \`skl infer\` to assign)`);
441
+ },
442
+ };
443
+ render(ctx, flags.json, result);
682
444
  return 0;
683
445
  }
684
446
 
@@ -708,25 +470,29 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
708
470
  }
709
471
  seenSlugs.add(s.name);
710
472
  outcomes.push(
711
- await installOne(ctx, s, {
712
- libraryPath: ctx.config.libraryPath,
713
- domainFolder,
714
- nameOverride: null,
715
- sourceStr: sourceOf(s),
716
- ref,
717
- channel,
718
- infer: flags.infer,
719
- force: flags.force,
720
- multi: true,
721
- }),
473
+ await installSkill(
474
+ s,
475
+ {
476
+ libraryPath: ctx.config.libraryPath,
477
+ domainFolder,
478
+ nameOverride: null,
479
+ sourceStr: sourceOf(s),
480
+ ref,
481
+ channel,
482
+ infer: flags.infer,
483
+ force: flags.force,
484
+ multi: true,
485
+ },
486
+ (m) => ctx.error(m),
487
+ ),
722
488
  );
723
489
  }
724
490
  const installed = outcomes.filter((o) => o.status === "installed");
725
491
  const skipped = outcomes.filter((o) => o.status === "skipped");
726
492
  const errored = outcomes.filter((o) => o.status === "error");
727
493
 
728
- if (flags.json) {
729
- ctx.json({
494
+ const result: CommandResult = {
495
+ json: {
730
496
  ok: errored.length === 0,
731
497
  action: "add",
732
498
  source: parsed.source,
@@ -744,18 +510,20 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
744
510
  tagged: o.tagged,
745
511
  domains: o.domains,
746
512
  })),
747
- });
748
- } else {
749
- for (const o of outcomes) {
750
- const tag = o.status === "installed" ? "added " : o.status === "skipped" ? "skipped " : "ERROR ";
751
- ctx.log(`${tag} ${o.name.padEnd(28)} ${o.reason}`);
752
- }
753
- ctx.log("");
754
- ctx.log(
755
- `${selected.length} selected, ${installed.length} installed, ${skipped.length} skipped${errored.length ? `, ${errored.length} error(s)` : ""} from ${parsed.source}`,
756
- );
757
- if (skipped.some((o) => o.verdict === "differs")) ctx.log("re-run with --force to overwrite differing skills.");
758
- }
513
+ },
514
+ human: (emit) => {
515
+ for (const o of outcomes) {
516
+ const tag = o.status === "installed" ? "added " : o.status === "skipped" ? "skipped " : "ERROR ";
517
+ emit(`${tag} ${o.name.padEnd(28)} ${o.reason}`);
518
+ }
519
+ emit("");
520
+ emit(
521
+ `${selected.length} selected, ${installed.length} installed, ${skipped.length} skipped${errored.length ? `, ${errored.length} error(s)` : ""} from ${parsed.source}`,
522
+ );
523
+ if (skipped.some((o) => o.verdict === "differs")) emit("re-run with --force to overwrite differing skills.");
524
+ },
525
+ };
526
+ render(ctx, flags.json, result);
759
527
  return errored.length > 0 ? 1 : 0;
760
528
  } catch (err) {
761
529
  ctx.error("add: failed:", err instanceof Error ? err.message : String(err));