skillshelf 0.6.1 → 0.6.2

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.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -166,6 +166,30 @@ describe("skl rm/retire/unretire — removal lifecycle (friction #1)", () => {
166
166
  expect(errors.join("\n")).toContain("not in the library");
167
167
  });
168
168
 
169
+ test("rm removes MULTIPLE names in one call (regression: batch must not drop names)", async () => {
170
+ await writeSkill(library, "beta");
171
+ await writeSkill(library, "gamma");
172
+ const { ctx } = makeCtx(library);
173
+ // Live OWNED skills need --force; the WHOLE batch must purge, not just the first
174
+ // (the old single-name rm silently ignored names 2..N).
175
+ const code = await rmRun(["alpha", "beta", "gamma", "--force"], ctx);
176
+ expect(code).toBe(0);
177
+ for (const n of ["alpha", "beta", "gamma"]) {
178
+ expect(existsSync(join(library, n))).toBe(false);
179
+ }
180
+ });
181
+
182
+ test("rm batch is ATOMIC on validation — one missing name deletes nothing", async () => {
183
+ await writeSkill(library, "beta");
184
+ const { ctx, errors } = makeCtx(library);
185
+ const code = await rmRun(["alpha", "ghost", "beta", "--force"], ctx);
186
+ expect(code).toBe(1);
187
+ expect(errors.join("\n")).toContain("not in the library");
188
+ // No partial purge — both valid names survive because one name was invalid.
189
+ expect(existsSync(join(library, "alpha"))).toBe(true);
190
+ expect(existsSync(join(library, "beta"))).toBe(true);
191
+ });
192
+
169
193
  test("rm refuses a path-traversal name (no escape outside the library)", async () => {
170
194
  // a sibling dir outside the library that must NOT be deletable via `../`
171
195
  const victim = join(tmp, "victim");
@@ -1,10 +1,14 @@
1
- // `skl rm <name>` — delete a skill from the library entirely: remove its dir (or, for
2
- // a LINKED entry, just the symlink — the dev repo it points at is never touched, so
3
- // this doubles as `unlink`) AND drop its taxonomy + lockfile entries, then re-index.
1
+ // `skl rm <name>...` — delete skill(s) from the library entirely: remove each dir (or,
2
+ // for a LINKED entry, just the symlink — the dev repo it points at is never touched, so
3
+ // this doubles as `unlink`) AND drop its taxonomy + lockfile entries, then re-index ONCE.
4
4
  // Before this, the ONLY delete path was an unguarded `rm -rf` against the library —
5
5
  // no safety rail for the most destructive lifecycle step.
6
6
  //
7
- // skl rm <name> [--force] [--dry-run] [--json]
7
+ // skl rm <name>... [--force] [--dry-run] [--json]
8
+ //
9
+ // Batch: multiple names delete in one call (reindex collapses to one, mirroring
10
+ // `retire`). Validation is ATOMIC — if any name is missing or a live OWNED skill without
11
+ // --force, the whole batch refuses and nothing is deleted (never a partial purge).
8
12
  //
9
13
  // Safety (mirrors `link --at` refuse-by-default + named escape hatch): a LIVE (active,
10
14
  // non-retired) skill is refused without --force — retire it first (reversible) or pass
@@ -12,15 +16,15 @@
12
16
  // it purges without --force. --dry-run previews exactly what would be removed.
13
17
 
14
18
  import type { Ctx } from "../types.ts";
15
- import { locateEntry, removeSkill, reindexLibrary } from "../core/lifecycle.ts";
19
+ import { locateEntry, removeSkill, reindexLibrary, type RemoveResult } from "../core/lifecycle.ts";
16
20
  import { readTaxonomy } from "../core/taxonomy.ts";
17
21
  import { readLockfile } from "../core/provenance.ts";
18
22
  import { render, type CommandResult } from "../core/report.ts";
19
23
 
20
24
  export const meta = {
21
25
  name: "rm",
22
- summary: "Delete a skill from the library (dir/symlink + taxonomy + lock), re-index",
23
- usage: "skl rm <name> [--force] [--dry-run] [--json]",
26
+ summary: "Delete skill(s) from the library (dir/symlink + taxonomy + lock), re-index",
27
+ usage: "skl rm <name>... [--force] [--dry-run] [--json]",
24
28
  } as const;
25
29
 
26
30
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
@@ -35,64 +39,71 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
35
39
  ctx.error(`usage: ${meta.usage}`);
36
40
  return 1;
37
41
  }
38
- const name = argv.find((a) => !a.startsWith("--"));
39
- if (!name) {
42
+ const names = argv.filter((a) => !a.startsWith("--"));
43
+ if (names.length === 0) {
40
44
  ctx.error("skl rm: a <name> is required");
41
45
  ctx.error(`usage: ${meta.usage}`);
42
46
  return 1;
43
47
  }
44
48
 
45
49
  try {
46
- const loc = locateEntry(ctx.libraryPath, name);
47
- if (!loc.path) {
48
- ctx.error(`skl rm: '${name}' is not in the library`);
50
+ // Locate + guard EVERY name up front. The batch is ATOMIC on validation: if any
51
+ // name is missing, or is a live OWNED skill without --force (destroys real bytes),
52
+ // refuse the WHOLE batch and delete nothing — never a partial purge.
53
+ const located = names.map((name) => ({ name, loc: locateEntry(ctx.libraryPath, name) }));
54
+
55
+ const notFound = located.filter((x) => !x.loc.path).map((x) => x.name);
56
+ if (notFound.length) {
57
+ // Not-found stays a plain error (no --json body), matching the prior single-name path.
58
+ for (const n of notFound) ctx.error(`skl rm: '${n}' is not in the library`);
49
59
  return 1;
50
60
  }
51
- // Refuse only a live OWNED skill unless forced — that destroys real bytes. A
52
- // LINKED entry is just a symlink: removing it loses nothing (the dev repo stays
53
- // canonical), so `skl rm <linked>` is the safe `unlink` and needs no --force. A
54
- // purely-retired skill is already in the reversible holding area, so it purges
55
- // freely. NOTE: gate on `loc.active` itself (the resolved path IS the active copy
56
- // when active), NOT on the absence of a retired twin — a skill present in BOTH
57
- // active and _retired still resolves `path` to the active copy, so a `!loc.retired`
58
- // term would wrongly drop the guard and delete the live copy without --force.
59
- if (loc.active && !loc.isLink && !force) {
60
- // Refusal is an ERROR path: the human side prints via ctx.error (not ctx.log),
61
- // so it stays in run(); only the --json payload routes through render().
61
+
62
+ // Refuse only a live OWNED skill unless forced that destroys real bytes. A LINKED
63
+ // entry is just a symlink (removing it loses nothing); a purely-retired skill is
64
+ // already in the reversible holding area both purge freely. Gate on `loc.active`
65
+ // itself (NOT `!loc.retired`) so a skill present in BOTH active and _retired keeps
66
+ // the guard on its live copy.
67
+ const refused = located.filter((x) => x.loc.active && !x.loc.isLink && !force).map((x) => x.name);
68
+ if (refused.length) {
62
69
  if (json) {
63
- const result: CommandResult = {
64
- json: { ok: false, name, refused: true, reason: "live-owned-needs-force" },
65
- human: () => {},
66
- };
67
- render(ctx, json, result);
70
+ // Preserve the single-name payload shape; use a list form for a batch.
71
+ const body =
72
+ names.length === 1
73
+ ? { ok: false, name: names[0], refused: true, reason: "live-owned-needs-force" }
74
+ : { ok: false, refused, reason: "live-owned-needs-force" };
75
+ render(ctx, json, { json: body, human: () => {} });
68
76
  } else {
69
- ctx.error(`skl rm: '${name}' is a live skill — this destroys real bytes.`);
70
- ctx.error("Retire it first (reversible): skl retire " + name);
71
- ctx.error("Or hard-purge now: skl rm " + name + " --force");
77
+ for (const n of refused) {
78
+ ctx.error(`skl rm: '${n}' is a live skill — this destroys real bytes.`);
79
+ ctx.error("Retire it first (reversible): skl retire " + n);
80
+ ctx.error("Or hard-purge now: skl rm " + n + " --force");
81
+ }
72
82
  }
73
83
  return 1;
74
84
  }
75
85
 
76
86
  // Preview what would be dropped (for --dry-run and richer reporting).
77
- const tax = await readTaxonomy(ctx.libraryPath);
78
- const lock = await readLockfile(ctx.libraryPath);
79
- const plan = {
80
- name,
81
- path: loc.path,
82
- mode: loc.isLink ? ("linked" as const) : ("owned" as const),
83
- wasRetired: loc.retired && !loc.active,
84
- taxonomyEntry: name in tax.skills,
85
- lockEntry: name in lock.entries,
86
- };
87
-
88
87
  if (dryRun) {
88
+ const tax = await readTaxonomy(ctx.libraryPath);
89
+ const lock = await readLockfile(ctx.libraryPath);
90
+ const plans = located.map(({ name, loc }) => ({
91
+ name,
92
+ path: loc.path,
93
+ mode: loc.isLink ? ("linked" as const) : ("owned" as const),
94
+ wasRetired: loc.retired && !loc.active,
95
+ taxonomyEntry: name in tax.skills,
96
+ lockEntry: name in lock.entries,
97
+ }));
89
98
  const result: CommandResult = {
90
- json: { ok: true, dryRun: true, plan },
99
+ json: names.length === 1 ? { ok: true, dryRun: true, plan: plans[0] } : { ok: true, dryRun: true, plans },
91
100
  human: (emit) => {
92
- emit(`DRY RUN would remove '${name}':`);
93
- emit(` path: ${plan.path}${plan.mode === "linked" ? " (symlink only dev repo untouched)" : ""}`);
94
- emit(` taxonomy: ${plan.taxonomyEntry ? "drop entry" : "none"}`);
95
- emit(` lockfile: ${plan.lockEntry ? "drop entry" : "none"}`);
101
+ for (const plan of plans) {
102
+ emit(`DRY RUNwould remove '${plan.name}':`);
103
+ emit(` path: ${plan.path}${plan.mode === "linked" ? " (symlink only — dev repo untouched)" : ""}`);
104
+ emit(` taxonomy: ${plan.taxonomyEntry ? "drop entry" : "none"}`);
105
+ emit(` lockfile: ${plan.lockEntry ? "drop entry" : "none"}`);
106
+ }
96
107
  emit("Re-run without --dry-run to apply.");
97
108
  },
98
109
  };
@@ -100,14 +111,22 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
100
111
  return 0;
101
112
  }
102
113
 
103
- const removed = await removeSkill(ctx.libraryPath, name);
114
+ // Apply: remove each, then re-index ONCE at the end (a per-name reindex is the cost
115
+ // a bulk rm avoids — collapse it to one, mirroring bulkLifecycle).
116
+ const removed: RemoveResult[] = [];
117
+ for (const { name } of located) {
118
+ removed.push(await removeSkill(ctx.libraryPath, name));
119
+ }
104
120
  await reindexLibrary(ctx.libraryPath);
121
+
105
122
  const result: CommandResult = {
106
- json: { ok: true, ...removed },
123
+ json: names.length === 1 ? { ok: true, ...removed[0] } : { ok: true, removed },
107
124
  human: (emit) => {
108
- emit(`removed ${name}${removed.wasLink ? " (symlink only dev repo untouched)" : ""}`);
109
- if (removed.taxonomyDropped) emit(" dropped taxonomy entry");
110
- if (removed.lockDropped) emit(" dropped lockfile entry");
125
+ for (const r of removed) {
126
+ emit(`removed ${r.name}${r.wasLink ? " (symlink only — dev repo untouched)" : ""}`);
127
+ if (r.taxonomyDropped) emit(" dropped taxonomy entry");
128
+ if (r.lockDropped) emit(" dropped lockfile entry");
129
+ }
111
130
  },
112
131
  };
113
132
  render(ctx, json, result);