skillshelf 0.6.1 → 0.7.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
@@ -115,7 +115,7 @@ skl import rnaseq-qc --from ~/projects/lab/.claude/skills/rnaseq-qc --force
115
115
 
116
116
  # For a skill you actively develop in its own git repo, shelve a LINK instead of a copy —
117
117
  # the repo stays canonical and edits show up live, no drift, no re-sync (ADR-0004):
118
- skl link --from ~/Documents/GitHub/claim-log/skill/claim-log
118
+ skl link --from ~/dev/headline-picker/skill/headline-picker
119
119
 
120
120
  # 3. Tag the now-populated library in one pass. Domain is tags, not folders, so this
121
121
  # runs AFTER import with no reorg — no skill ever has to move because a tag changed.
@@ -183,29 +183,35 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
183
183
  | `skl import <name> --from <path>` | Adopt your own skill into the library as an OWNED copy (move + symlink-back, or `--copy`) | `--copy`, `--as <slug>`, `--force` |
184
184
  | `skl link [<name>] --from <dev-repo>` | Shelve a dev-repo skill as a LINKED entry (library symlinks to it; the repo stays canonical). `--at <path>` instead collapses a stray copy into the library | `--from`, `--at`, `--force` |
185
185
  | `skl new <name>` | Scaffold a new skill dir + SKILL.md into the library | `--domain <d>`, `--desc "..."`, `--force` |
186
- | `skl ls [bundle]` | One-line listing of the library, or one bundle (`--json` carries `mode`/`linkTarget`) | `--all` |
186
+ | `skl ls [bundle]` | One-line listing of the library, or one bundle (`--json` carries `mode`/`linkTarget`) | `--all`, `--sort modified\|name\|domain\|deploys\|source` |
187
187
  | `skl search <kw...>` | Fuzzy match over name + description + domains across the library | — |
188
188
  | `skl show <name>` | Print a skill's SKILL.md body; list reference-file paths (not contents) | — |
189
189
  | `skl tag <name> <domain>…` | Add domain tag(s) to a skill in the central taxonomy (deterministic, no LLM) | — |
190
190
  | `skl untag <name> <domain>` | Remove a domain tag from a skill | — |
191
191
  | `skl retag <old> <new>` | Rename a domain across the whole library taxonomy (deterministic) | — |
192
192
  | `skl rename <old> <new>` | Rename a skill slug atomically (dir + frontmatter + taxonomy + lock). Alias `skl mv` | — |
193
- | `skl retire <name>` | Soft-delete a skill into `_retired/` (reversible; excluded from deploys) | — |
194
- | `skl unretire <name>` | Restore a retired skill back to the active library | — |
195
- | `skl rm <name>` | Delete a skill (dir/symlink + taxonomy + lock), re-index. Refuses a live OWNED skill without `--force`; a LINKED entry `rm`s freely (safe unlink) | `--force`, `--dry-run` |
196
- | `skl status` | Show which library skills are linked into `./.claude/skills`; flags unmanaged real copies (drift-prone) | |
193
+ | `skl retire <name…>` | Soft-delete skill(s) into `_retired/` (reversible; excluded from deploys) | — |
194
+ | `skl unretire <name…>` | Restore retired skill(s) back to the active library | — |
195
+ | `skl rm <name…>` | Delete skill(s) (dir/symlink + taxonomy + lock), re-index. Refuses a live OWNED skill without `--force`; a LINKED entry `rm`s freely (safe unlink) | `--force`, `--dry-run` |
196
+ | `skl status` | Show which library skills are linked into an agent's project skills dir (default `./.claude/skills`); flags unmanaged real copies (drift-prone) | `--agent <id>`, `--project <dir>` |
197
197
  | `skl where [name]` | Map where each skill is deployed across all agents (Claude, Codex, Cursor…); flags copies, drift, 2nd-sources, dead links — a dev repo a library entry links to shows as a clean `✓ source` | `--problems`, `--prune`, `--fix`, `--dry-run` |
198
- | `skl use <bundle\|skill>` | Symlink a bundle (or a single skill) into `./.claude/skills/` (hot-loads) | |
199
- | `skl drop <bundle\|skill>` | Remove a bundle's (or single skill's) symlinks from `./.claude/skills/` | |
198
+ | `skl agents [name]` | Per-agent × per-scope deployment matrix (one skill's row, or the whole library) | `--agent <id>`, `--project <dir>` |
199
+ | `skl use <bundle\|skill>…` | Symlink bundle(s)/skill(s) into an agent's skills dir (default `./.claude/skills/`, hot-loads). `--force` replaces a real-file conflict with the library symlink | `--agent <id>`, `--global \| --project <name>`, `--force` |
200
+ | `skl drop <bundle\|skill>…` | Remove bundle/skill symlinks from an agent's skills dir | `--agent <id>`, `--global \| --project <name>` |
201
+ | `skl realign <deployed-name>` | Rename an aliased deployment symlink (wrong name) to match its library skill | `--agent <id>`, `--global \| --project <name>` |
202
+ | `skl diff <name>` | Unified diff of a deployed copy's `SKILL.md` against the library skill (read-only) | `--agent <id>`, `--global \| --project <name>` |
200
203
  | `skl refresh` | Re-sync this project's `./.claude/skills` symlinks to current library reality (repoint stale, prune vanished) | `--dry-run` |
204
+ | `skl projects` | Manage the persisted nav projects shown as scopes in the desktop app (`ls`/`add`/`rm`) | — |
201
205
  | `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` |
206
+ | `skl track <name> --source <src>` | Adopt provenance for a library skill you already have (offline; no re-download). `skl untrack` drops the lock entry (idempotent inverse) | `--ref <r>`, `--resolve`, `--force` |
207
+ | `skl migrate` | Bulk-adopt provenance from a vendor skill-lock for skills already in your library | `--from <path>`, `--dry-run`, `--resolve`, `--force` |
202
208
  | `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
209
  | `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
210
  | `skl index` | Regenerate `INDEX.md` (catalog grouped by domain) | — |
205
211
  | `skl infer` | Re-run AI domain taxonomy over the library (emit/apply/provider modes) | see below |
206
212
 
207
213
  Every command also accepts `--json`. Destructive/edit verbs (`rm`, `retire`/`unretire`,
208
- `rename`, `tag`/`untag`/`retag`, `scan --remove-root`, `where --prune`/`--fix`,
214
+ `rename`, `realign`, `tag`/`untag`/`retag`, `scan --remove-root`, `where --prune`/`--fix`,
209
215
  `refresh`) are the inverse + fine-grained-edit family from
210
216
  [ADR-0005](./docs/adr/0005-inverse-and-edit-verbs.md): reversible by default, transactional
211
217
  across the skill dir + `taxonomy.json` + `shelf.lock.json` + `INDEX.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "skl": "./src/cli.ts"
26
26
  },
27
27
  "scripts": {
28
- "preinstall": "bun --version >/dev/null 2>&1 || { echo '\\nskillshelf requires the Bun runtime (https://bun.sh) its bin is a TypeScript entrypoint, not a compiled Node script.\\nInstall Bun, then: bun add -g skillshelf (or run without installing: bunx skillshelf <command>)\\n' >&2; exit 1; }",
28
+ "preinstall": "bun --version >/dev/null 2>&1 || { echo '\\nskillshelf requires the Bun runtime (https://bun.sh) \u2014 its bin is a TypeScript entrypoint, not a compiled Node script.\\nInstall Bun, then: bun add -g skillshelf (or run without installing: bunx skillshelf <command>)\\n' >&2; exit 1; }",
29
29
  "skl": "bun run src/cli.ts",
30
30
  "test": "bun test"
31
31
  },
package/src/cli.ts CHANGED
@@ -21,6 +21,8 @@ import * as show from "./commands/show.ts";
21
21
  import * as index from "./commands/index.ts";
22
22
  import * as use from "./commands/use.ts";
23
23
  import * as drop from "./commands/drop.ts";
24
+ import * as realign from "./commands/realign.ts";
25
+ import * as diffCmd from "./commands/diff.ts";
24
26
  import * as init from "./commands/init.ts";
25
27
  import * as add from "./commands/add.ts";
26
28
  import * as outdated from "./commands/outdated.ts";
@@ -56,6 +58,8 @@ const MODULES: CommandModule[] = [
56
58
  show,
57
59
  use,
58
60
  drop,
61
+ realign,
62
+ diffCmd,
59
63
  refresh,
60
64
  add,
61
65
  scan,
@@ -174,7 +174,6 @@ async function reportDryRun(
174
174
  parsed: ParsedSource,
175
175
  ref: string,
176
176
  discovered: DiscoveredSkill[],
177
- domainFolder: string | null,
178
177
  ): Promise<number> {
179
178
  interface Row {
180
179
  name: string;
@@ -189,7 +188,7 @@ async function reportDryRun(
189
188
  rows.push({ name: d.name, subpath: d.subpath, verdict: "invalid", willInstall: false, needsForce: false });
190
189
  continue;
191
190
  }
192
- const destDir = destDirFor(ctx.config.libraryPath, domainFolder, d.name);
191
+ const destDir = destDirFor(ctx.config.libraryPath, d.name);
193
192
  if (writesThroughSymlink(ctx.config.libraryPath, destDir)) {
194
193
  rows.push({ name: d.name, subpath: d.subpath, verdict: "linked", willInstall: false, needsForce: false });
195
194
  continue;
@@ -240,10 +239,11 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
240
239
  return 1;
241
240
  }
242
241
 
243
- // --domain validated once up front (applies to every install path).
244
- const domainFolder = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
245
- if (domainFolder !== null && !SLUG_RE.test(domainFolder)) {
246
- ctx.error(`add: invalid --domain "${domainFolder}" use lowercase letters, digits, and hyphens`);
242
+ // --domain validated once up front (applies to every install path). It is a taxonomy
243
+ // tag, never a folder installs always land flat at library/<name> (ADR-0001).
244
+ const domain = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
245
+ if (domain !== null && !SLUG_RE.test(domain)) {
246
+ ctx.error(`add: invalid --domain "${domain}" — use lowercase letters, digits, and hyphens`);
247
247
  return 1;
248
248
  }
249
249
 
@@ -340,7 +340,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
340
340
  : flags.skill !== null
341
341
  ? discovered.filter((d) => flags.skill!.includes(d.name))
342
342
  : discovered;
343
- return await reportDryRun(ctx, flags, parsed, ref, preview, domainFolder);
343
+ return await reportDryRun(ctx, flags, parsed, ref, preview);
344
344
  }
345
345
 
346
346
  // ---- selection ----
@@ -395,7 +395,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
395
395
  selected[0]!,
396
396
  {
397
397
  libraryPath: ctx.config.libraryPath,
398
- domainFolder,
398
+ domain,
399
399
  nameOverride: flags.name,
400
400
  sourceStr: sourceOf(selected[0]!),
401
401
  ref,
@@ -474,7 +474,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
474
474
  s,
475
475
  {
476
476
  libraryPath: ctx.config.libraryPath,
477
- domainFolder,
477
+ domain,
478
478
  nameOverride: null,
479
479
  sourceStr: sourceOf(s),
480
480
  ref,
@@ -0,0 +1,78 @@
1
+ // `skl diff <name>` — unified diff between a DEPLOYED site's SKILL.md and the
2
+ // library skill's SKILL.md, for one agent surface. Read-only (an L2 fact per
3
+ // ADR-0007: two runs can't disagree). The engine verb behind the UI's drift
4
+ // "View diff" action. A clean symlink deployment trivially reports identical —
5
+ // its realpath IS the library copy.
6
+
7
+ import { join } from "node:path";
8
+ import { existsSync } from "node:fs";
9
+ import type { Ctx } from "../types.ts";
10
+ import { parseDeployTarget } from "../core/agents.ts";
11
+ import { findByName } from "../core/library.ts";
12
+ import { readSkillBody, unifiedDiff } from "../core/fetch.ts";
13
+ import { isSymlink, realpathOrSelfAsync } from "../lib/fs.ts";
14
+ import { render, type CommandResult } from "../core/report.ts";
15
+
16
+ export const meta = {
17
+ name: "diff",
18
+ summary: "Unified diff of a deployed copy's SKILL.md against the library skill",
19
+ usage: "skl diff <name> [--agent <id>] [--global | --project <name>] [--json]",
20
+ } as const;
21
+
22
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
23
+ try {
24
+ const json = argv.includes("--json");
25
+ const parsed = parseDeployTarget(argv);
26
+ if ("error" in parsed) {
27
+ ctx.error(`skl diff: ${parsed.error}`);
28
+ ctx.error("usage: " + meta.usage);
29
+ return 1;
30
+ }
31
+ const { positionals, target } = parsed;
32
+ const name = positionals[0];
33
+ if (!name || positionals.length > 1) {
34
+ ctx.error("usage: " + meta.usage);
35
+ return 1;
36
+ }
37
+
38
+ const skills = await ctx.loadLibrary();
39
+ const skill = findByName(
40
+ skills.filter((s) => !s.retired),
41
+ name,
42
+ );
43
+ if (!skill) {
44
+ ctx.error(`skl diff: no library skill named "${name}"`);
45
+ return 1;
46
+ }
47
+
48
+ const site = join(target.dir, name);
49
+ if (!existsSync(site) && !isSymlink(site)) {
50
+ ctx.error(`skl diff: ${name} is not deployed at ${target.dir}`);
51
+ return 1;
52
+ }
53
+
54
+ // A symlink resolving to the library skill can't drift — same bytes.
55
+ const siteReal = await realpathOrSelfAsync(site);
56
+ const libReal = await realpathOrSelfAsync(skill.path);
57
+ const deployedBody = siteReal === libReal ? null : await readSkillBody(site);
58
+ const libraryBody = await readSkillBody(skill.path);
59
+ const text =
60
+ deployedBody === null
61
+ ? ""
62
+ : await unifiedDiff(libraryBody, deployedBody, `library/${name}/SKILL.md`, `${site}/SKILL.md`);
63
+
64
+ const identical = text.trim() === "";
65
+ const result: CommandResult = {
66
+ json: { name, site, library: skill.path, identical, diff: text },
67
+ human: (emit) => {
68
+ if (identical) emit(`${name}: deployed copy matches the library (no drift)`);
69
+ else emit(text.trimEnd());
70
+ },
71
+ };
72
+ render(ctx, json, result);
73
+ return 0;
74
+ } catch (err) {
75
+ ctx.error(`skl diff failed: ${err instanceof Error ? err.message : String(err)}`);
76
+ return 1;
77
+ }
78
+ }
@@ -0,0 +1,89 @@
1
+ // `skl realign <deployed-name>` — fix an ALIASED deployment: a symlink in an
2
+ // agent's skills dir that resolves to a library skill but carries the WRONG
3
+ // name (e.g. `nuwa` -> <library>/huashu-nuwa). Renames the link to the library
4
+ // skill's name so name-keyed views (status/agents) see it again. The engine
5
+ // verb behind the UI's "Realign name" remediation. Only ever renames a symlink
6
+ // in place — never touches the library or the link target.
7
+
8
+ import { join } from "node:path";
9
+ import { rename } from "node:fs/promises";
10
+ import { existsSync } from "node:fs";
11
+ import type { Ctx } from "../types.ts";
12
+ import { parseDeployTarget } from "../core/agents.ts";
13
+ import { isSymlink, realpathOrSelf, realpathOrSelfAsync } from "../lib/fs.ts";
14
+ import { render, type CommandResult } from "../core/report.ts";
15
+
16
+ export const meta = {
17
+ name: "realign",
18
+ summary: "Rename an aliased deployment symlink to match its library skill",
19
+ usage: "skl realign <deployed-name> [--agent <id>] [--global | --project <name>] [--json]",
20
+ } as const;
21
+
22
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
23
+ try {
24
+ const json = argv.includes("--json");
25
+ const parsed = parseDeployTarget(argv);
26
+ if ("error" in parsed) {
27
+ ctx.error(`skl realign: ${parsed.error}`);
28
+ ctx.error("usage: " + meta.usage);
29
+ return 1;
30
+ }
31
+ const { positionals, target } = parsed;
32
+ const deployedName = positionals[0];
33
+ if (!deployedName || positionals.length > 1) {
34
+ ctx.error("usage: " + meta.usage);
35
+ return 1;
36
+ }
37
+
38
+ const link = join(target.dir, deployedName);
39
+ if (!isSymlink(link)) {
40
+ ctx.error(
41
+ `skl realign: ${link} is not a symlink — realign only renames aliased links (a real copy is a different anomaly; see \`skl where\`)`,
42
+ );
43
+ return 1;
44
+ }
45
+
46
+ // Resolve which library skill the link points at, by realpath — this also
47
+ // matches a LINKED library entry, whose canonical realpath is its external
48
+ // dev repo, exactly like the `where` classifier does.
49
+ const real = await realpathOrSelfAsync(link);
50
+ const skills = await ctx.loadLibrary();
51
+ const owner = skills.find((s) => !s.retired && realpathOrSelf(s.path) === real);
52
+ if (!owner) {
53
+ ctx.error(
54
+ `skl realign: ${link} does not resolve to any library skill (target: ${real}) — nothing to align to`,
55
+ );
56
+ return 1;
57
+ }
58
+
59
+ if (owner.name === deployedName) {
60
+ const result: CommandResult = {
61
+ json: { from: deployedName, to: owner.name, link, status: "already" },
62
+ human: (emit) => emit(`${deployedName} already matches its library skill — nothing to do`),
63
+ };
64
+ render(ctx, json, result);
65
+ return 0;
66
+ }
67
+
68
+ const dest = join(target.dir, owner.name);
69
+ if (existsSync(dest) || isSymlink(dest)) {
70
+ ctx.error(
71
+ `skl realign: ${dest} already exists — remove one of the two deployments first (\`skl drop ${deployedName}\` keeps the aligned one)`,
72
+ );
73
+ return 1;
74
+ }
75
+
76
+ // Atomic same-dir rename of the LINK itself (rename never dereferences).
77
+ await rename(link, dest);
78
+
79
+ const result: CommandResult = {
80
+ json: { from: deployedName, to: owner.name, link: dest, status: "realigned" },
81
+ human: (emit) => emit(`realigned ${deployedName} -> ${owner.name} in ${target.dir}`),
82
+ };
83
+ render(ctx, json, result);
84
+ return 0;
85
+ } catch (err) {
86
+ ctx.error(`skl realign failed: ${err instanceof Error ? err.message : String(err)}`);
87
+ return 1;
88
+ }
89
+ }
@@ -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);
@@ -5,7 +5,7 @@
5
5
  // library crawl, one symlink pass); the single-name shape is byte-identical to before.
6
6
 
7
7
  import { join } from "node:path";
8
- import { mkdir } from "node:fs/promises";
8
+ import { mkdir, rm } from "node:fs/promises";
9
9
  import type { Ctx, Skill } from "../types.ts";
10
10
  import { resolveBundle } from "../core/bundle.ts";
11
11
  import { activeSkills, findByName } from "../core/library.ts";
@@ -16,20 +16,25 @@ import { render, type CommandResult } from "../core/report.ts";
16
16
  export const meta = {
17
17
  name: "use",
18
18
  summary: "Symlink bundle(s)/skill(s) into an agent's skills dir (default: ./.claude/skills/)",
19
- usage: "skl use <bundle|skill>... [--agent <id>] [--global | --project <name>] [--json]",
19
+ usage: "skl use <bundle|skill>... [--agent <id>] [--global | --project <name>] [--force] [--json]",
20
20
  } as const;
21
21
 
22
22
  interface LinkResult {
23
23
  name: string;
24
24
  target: string;
25
25
  link: string;
26
- status: "linked" | "already" | "conflict";
26
+ status: "linked" | "already" | "conflict" | "overwritten";
27
27
  }
28
28
 
29
29
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
30
30
  try {
31
31
  const json = argv.includes("--json");
32
- const parsed = parseDeployTarget(argv);
32
+ // --force: a real (non-symlink) file/dir occupying a slot is REPLACED by the
33
+ // library symlink instead of reported as a conflict — the UI's "Overwrite
34
+ // from library" drift remediation. Consumed here; parseDeployTarget rejects
35
+ // unknown flags.
36
+ const force = argv.includes("--force");
37
+ const parsed = parseDeployTarget(argv.filter((a) => a !== "--force"));
33
38
  if ("error" in parsed) {
34
39
  ctx.error(`skl use: ${parsed.error}`);
35
40
  ctx.error("usage: " + meta.usage);
@@ -118,9 +123,16 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
118
123
  const want = await realpathOrSelfAsync(skillPath);
119
124
  if (cur === want) status = "already";
120
125
  } else if (await pathTakenNonLink(link)) {
121
- // A real (non-symlink) file/dir occupies the slot — don't clobber it.
122
- results.push({ name: s.name, target: skillPath, link, status: "conflict" });
123
- continue;
126
+ if (!force) {
127
+ // A real (non-symlink) file/dir occupies the slot don't clobber it.
128
+ results.push({ name: s.name, target: skillPath, link, status: "conflict" });
129
+ continue;
130
+ }
131
+ // --force: replace the real entry with the library symlink. `link` is
132
+ // always join(skillsDir, <library skill name>), so the removal cannot
133
+ // reach outside the agent's skills dir.
134
+ await rm(link, { recursive: true, force: true });
135
+ status = "overwritten";
124
136
  }
125
137
 
126
138
  await safeSymlink(skillPath, link);
@@ -139,7 +151,13 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
139
151
  emit(`${label} -> ${skillsDir}`);
140
152
  for (const r of results) {
141
153
  const tag =
142
- r.status === "linked" ? "linked" : r.status === "already" ? "ok" : "SKIP (real file present)";
154
+ r.status === "linked"
155
+ ? "linked"
156
+ : r.status === "already"
157
+ ? "ok"
158
+ : r.status === "overwritten"
159
+ ? "overwritten (real file replaced)"
160
+ : "SKIP (real file present)";
143
161
  emit(` ${r.name} [${tag}]`);
144
162
  }
145
163
  for (const u of unresolved) {
@@ -14,7 +14,7 @@ import {
14
14
  } from "./agent-matrix.ts";
15
15
 
16
16
  const HOME = "/home/u";
17
- const IDS = ["claude", "codex", "cursor", "opencode", "gemini", "omp"] as const;
17
+ const IDS = ["claude", "codex", "cursor", "opencode", "gemini", "pi"] as const;
18
18
 
19
19
  function site(p: Partial<DeploymentSite> & { name: string; surface: string }): DeploymentSite {
20
20
  return {
@@ -31,9 +31,9 @@ function site(p: Partial<DeploymentSite> & { name: string; surface: string }): D
31
31
  describe("agentIdForSurface honors `ids` ordering", () => {
32
32
  test("returns the FIRST matching id in the given order (custom precedence)", () => {
33
33
  // A surface that could match two ids: the caller's order decides the winner.
34
- const s = "/work/proj/.pi/skills";
35
- expect(agentIdForSurface(s, ["pi", "claude"])).toBe("pi");
36
- // pi not in the id set → no match (the engine never widened to it).
34
+ const s = "/work/proj/.walrus/skills";
35
+ expect(agentIdForSurface(s, ["walrus", "claude"])).toBe("walrus");
36
+ // walrus not in the id set → no match (the engine never widened to it).
37
37
  expect(agentIdForSurface(s, ["claude", "codex"])).toBeNull();
38
38
  });
39
39
  test("matches segment, trailing dotdir, and trailing /skills", () => {
@@ -146,8 +146,8 @@ describe("foldAgentMatrix", () => {
146
146
  });
147
147
 
148
148
  test("custom-agent surface detected via a widened id set", () => {
149
- const sites = [site({ name: "x", surface: "/work/proj/.pi/skills", kind: "linked" })];
150
- const m = foldAgentMatrix(sites, { home: HOME, agentIds: [...IDS, "pi"] });
151
- expect(m.deployments.x!.pi!.p!.proj).toBe("clean");
149
+ const sites = [site({ name: "x", surface: "/work/proj/.walrus/skills", kind: "linked" })];
150
+ const m = foldAgentMatrix(sites, { home: HOME, agentIds: [...IDS, "walrus"] });
151
+ expect(m.deployments.x!.walrus!.p!.proj).toBe("clean");
152
152
  });
153
153
  });
@@ -113,7 +113,7 @@ describe("computeAgentsReport", () => {
113
113
  const r = computeAgentsReport(report, HOME);
114
114
 
115
115
  test("registers the 6 known agents (installed=false under a fake HOME)", () => {
116
- expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "omp"]);
116
+ expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "pi"]);
117
117
  expect(r.agents.every((a) => a.installed === false)).toBe(true);
118
118
  expect(r.agents[0]!.global).toBe("~/.claude/skills");
119
119
  });
@@ -138,9 +138,9 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
138
138
  problems: [],
139
139
  };
140
140
 
141
- test("opts.agents appends a custom agent (pi) after the seeds", () => {
141
+ test("opts.agents appends a custom agent (walrus) after the seeds", () => {
142
142
  const r = computeAgentsReport(baseReport, HOME, {
143
- agents: [{ id: "pi", name: "PI Agent", short: "PI", icon: "anthropic" }],
143
+ agents: [{ id: "walrus", name: "Walrus Agent", short: "Walrus", icon: "anthropic" }],
144
144
  });
145
145
  expect(r.agents.map((a) => a.id)).toEqual([
146
146
  "claude",
@@ -148,8 +148,8 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
148
148
  "cursor",
149
149
  "opencode",
150
150
  "gemini",
151
- "omp",
152
151
  "pi",
152
+ "walrus",
153
153
  ]);
154
154
  });
155
155
 
@@ -177,17 +177,17 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
177
177
  const r = computeAgentsReport(
178
178
  {
179
179
  surfaces: [],
180
- sites: [site({ name: "x", surface: "/work/proj/.pi/skills", kind: "linked" })],
180
+ sites: [site({ name: "x", surface: "/work/proj/.walrus/skills", kind: "linked" })],
181
181
  problems: [],
182
182
  },
183
183
  HOME,
184
- { agents: [{ id: "pi", name: "PI", short: "PI" }] },
184
+ { agents: [{ id: "walrus", name: "Walrus", short: "Walrus" }] },
185
185
  );
186
- expect(r.deployments.x!.pi!.p!.proj).toBe("clean");
186
+ expect(r.deployments.x!.walrus!.p!.proj).toBe("clean");
187
187
  });
188
188
  test("no opts is backward-compatible (seed-only, default extraScopes)", () => {
189
189
  const r = computeAgentsReport(baseReport, HOME);
190
- expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "omp"]);
190
+ expect(r.agents.map((a) => a.id)).toEqual(["claude", "codex", "cursor", "opencode", "gemini", "pi"]);
191
191
  });
192
192
 
193
193
  test("inheritsGlobal: all seeds default true; report carries the flag", () => {
@@ -199,11 +199,11 @@ describe("computeAgentsReport — opts (ADR-0010)", () => {
199
199
  test("inheritsGlobal: a custom agent defaults true unless inheritsGlobal:false", () => {
200
200
  const r = computeAgentsReport(baseReport, HOME, {
201
201
  agents: [
202
- { id: "pi", name: "PI", short: "PI" }, // no flag → inherits (true)
202
+ { id: "walrus", name: "Walrus", short: "Walrus" }, // no flag → inherits (true)
203
203
  { id: "static", name: "Static", short: "Static", inheritsGlobal: false },
204
204
  ],
205
205
  });
206
- expect(r.agents.find((a) => a.id === "pi")!.inheritsGlobal).toBe(true);
206
+ expect(r.agents.find((a) => a.id === "walrus")!.inheritsGlobal).toBe(true);
207
207
  expect(r.agents.find((a) => a.id === "static")!.inheritsGlobal).toBe(false);
208
208
  });
209
209
 
@@ -72,22 +72,22 @@ interface AgentSeed {
72
72
  short: string;
73
73
  }
74
74
  // ENGINE seed list — the full set of agents `skl` can DETECT on disk (claude,
75
- // codex, cursor, opencode, gemini, omp; no `pi` `pi` is a user-facing alias for
76
- // `omp` in the app). This INTENTIONALLY diverges from the app-side seed list in
77
- // `app/src/lib/agents.ts` (claude, codex, pi, omp), which shows only the agents a
78
- // given user actually deploys to and reconstructs the report in the browser dev
79
- // fallback. The two lists are NOT meant to match: the engine errs toward broad
80
- // detection, the app toward the user's real surfaces, and custom/overridden agents
81
- // flow through config (delta 4) on both sides. If you add/remove an id here, decide
82
- // deliberately whether the app list should follow — see the matching comment over
83
- // AGENT_SEEDS in app/src/lib/agents.ts.
75
+ // codex, cursor, opencode, gemini, pi). `pi` is now a first-class engine-seeded
76
+ // agent, matching the app-side seed list in `app/src/lib/agents.ts` (claude,
77
+ // codex, pi), which shows only the agents a given user actually deploys to and
78
+ // reconstructs the report in the browser dev fallback. The two lists are NOT
79
+ // meant to match exactly: the engine errs toward broad detection (cursor,
80
+ // opencode, gemini included), the app toward the user's real surfaces, and
81
+ // custom/overridden agents flow through config (delta 4) on both sides. If you
82
+ // add/remove an id here, decide deliberately whether the app list should follow
83
+ // — see the matching comment over AGENT_SEEDS in app/src/lib/agents.ts.
84
84
  const AGENT_SEEDS: AgentSeed[] = [
85
85
  { id: "claude", name: "Claude Code", short: "Claude" },
86
86
  { id: "codex", name: "Codex", short: "Codex" },
87
87
  { id: "cursor", name: "Cursor", short: "Cursor" },
88
88
  { id: "opencode", name: "OpenCode", short: "OpenCode" },
89
89
  { id: "gemini", name: "Gemini", short: "Gemini" },
90
- { id: "omp", name: "Oh My Pi", short: "OMP" },
90
+ { id: "pi", name: "Pi", short: "Pi" },
91
91
  ];
92
92
 
93
93
  const AGENT_IDS = AGENT_SEEDS.map((a) => a.id);
@@ -105,9 +105,9 @@ export function agentIdForSurface(
105
105
  return agentIdForSurfaceCore(surface, ids);
106
106
  }
107
107
 
108
- /** Relative subdirectory under .<id>/ where skills live (agent/skills for omp, skills otherwise). */
108
+ /** Relative subdirectory under .<id>/ where skills live (agent/skills for pi, skills otherwise). */
109
109
  function skillsSuffix(id: string): string {
110
- return id === "omp" ? join("agent", "skills") : "skills";
110
+ return id === "pi" ? join("agent", "skills") : "skills";
111
111
  }
112
112
 
113
113
  /** Absolute path to an agent's global skills dir. */
@@ -37,7 +37,7 @@ export function knownAgentSurfaces(home: string = homedir()): AgentSurface[] {
37
37
  // matrix advertises must have its global surface scanned, or `installed`
38
38
  // can disagree with the (empty) matrix. See agents.test.ts coverage check.
39
39
  { agent: "gemini", path: join(home, ".gemini", "skills") },
40
- { agent: "omp", path: join(home, ".omp", "agent", "skills") },
40
+ { agent: "pi", path: join(home, ".pi", "agent", "skills") },
41
41
  ];
42
42
  }
43
43
 
@@ -49,7 +49,7 @@ async function makeStagedSkill(parent: string, name: string, body?: string): Pro
49
49
  function installOpts(libraryPath: string, over: Partial<InstallOptions> = {}): InstallOptions {
50
50
  return {
51
51
  libraryPath,
52
- domainFolder: null,
52
+ domain: null,
53
53
  nameOverride: null,
54
54
  sourceStr: "github:owner/repo",
55
55
  ref: "abc123",
@@ -146,13 +146,14 @@ describe("vendor.installSkill — copy + provenance + verdict", () => {
146
146
  expect(o.reason).toContain("not overwriting");
147
147
  });
148
148
 
149
- test("a domain folder lands the skill under it and tags the taxonomy", async () => {
149
+ test("--domain tags the taxonomy but the skill lands FLAT (ADR-0001: tags, not folders)", async () => {
150
150
  const skill = await makeStagedSkill(tmp, "foo");
151
- const o = await installSkill(skill, installOpts(library, { domainFolder: "data" }));
151
+ const o = await installSkill(skill, installOpts(library, { domain: "data" }));
152
152
  expect(o.status).toBe("installed");
153
- expect(o.path).toBe(join(library, "data", "foo"));
153
+ expect(o.path).toBe(join(library, "foo"));
154
154
  expect(o.domains).toEqual(["data"]);
155
- expect(existsSync(join(library, "data", "foo", "SKILL.md"))).toBe(true);
155
+ expect(existsSync(join(library, "foo", "SKILL.md"))).toBe(true);
156
+ expect(existsSync(join(library, "data"))).toBe(false);
156
157
  });
157
158
 
158
159
  test("nameOverride renames the install slug", async () => {
@@ -214,16 +215,18 @@ describe("vendor.installSkill — guard suite", () => {
214
215
  expect(devBody).toContain("DEV REPO");
215
216
  });
216
217
 
217
- test("multi mode skips a write through a symlinked DOMAIN folder (ancestor escape)", async () => {
218
- // <library>/ext -> /external; installing under --domain ext must not write through it.
218
+ test("--domain never routes the write through a same-named symlinked dir (regression: flat layout)", async () => {
219
+ // <library>/ext -> /external. Pre-ADR-0001 leftovers made --domain ext write into
220
+ // library/ext/foo (through the symlink); flat layout must land at library/foo.
219
221
  const external = join(tmp, "external");
220
222
  await mkdir(external, { recursive: true });
221
223
  await symlink(external, join(library, "ext"));
222
224
 
223
225
  const skill = await makeStagedSkill(tmp, "foo");
224
- const o = await installSkill(skill, installOpts(library, { domainFolder: "ext", multi: true }));
225
- expect(o.status).toBe("skipped");
226
- expect(o.reason).toContain("symlink");
226
+ const o = await installSkill(skill, installOpts(library, { domain: "ext", multi: true }));
227
+ expect(o.status).toBe("installed");
228
+ expect(o.path).toBe(join(library, "foo"));
229
+ expect(o.domains).toEqual(["ext"]);
227
230
  expect(existsSync(join(external, "foo"))).toBe(false);
228
231
  });
229
232
  });
@@ -241,9 +244,8 @@ describe("vendor guard primitives (pure-ish, fs-anchored)", () => {
241
244
  await rm(tmp, { recursive: true, force: true });
242
245
  });
243
246
 
244
- test("destDirFor honors the optional domain folder", () => {
245
- expect(destDirFor("/lib", null, "foo")).toBe(join("/lib", "foo"));
246
- expect(destDirFor("/lib", "data", "foo")).toBe(join("/lib", "data", "foo"));
247
+ test("destDirFor is always flat under the library root", () => {
248
+ expect(destDirFor("/lib", "foo")).toBe(join("/lib", "foo"));
247
249
  });
248
250
 
249
251
  test("nearestExisting climbs to the closest on-disk ancestor", () => {
@@ -59,9 +59,9 @@ export function bodyOf(text: string): string {
59
59
  // GUARD SUITE — the checks every library-write path funnels through, ONCE.
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
- /** The library destination dir for a slug (under its domain folder, if any). */
63
- export function destDirFor(libraryPath: string, domainFolder: string | null, name: string): string {
64
- return domainFolder ? join(libraryPath, domainFolder, name) : join(libraryPath, name);
62
+ /** The library destination dir for a slug always flat `library/<name>` (ADR-0001). */
63
+ export function destDirFor(libraryPath: string, name: string): string {
64
+ return join(libraryPath, name);
65
65
  }
66
66
 
67
67
  /** The nearest ancestor of `p` (incl. `p`) that exists on disk; falls back to `p`. */
@@ -78,7 +78,7 @@ export function nearestExisting(p: string): string {
78
78
  /**
79
79
  * True if writing to `destDir` would resolve OUTSIDE the library — i.e. the nearest
80
80
  * existing component on the way to destDir is (or is reached through) a symlink whose
81
- * realpath escapes the library. Catches a symlinked DOMAIN folder (`library/<d> ->
81
+ * realpath escapes the library. Catches a symlinked ancestor dir (`library/<d> ->
82
82
  * /external`) that the leaf-only `isSymlink(destDir)` check misses, so `--force` can't
83
83
  * clobber an external tree through a symlinked parent (ADR-0004). Both sides anchor to
84
84
  * their nearest existing ancestor, so a fresh (not-yet-created) library is not a false
@@ -93,8 +93,8 @@ export function destEscapesLibrary(libraryPath: string, destDir: string): boolea
93
93
  /**
94
94
  * The symlink-escape guard: would writing `destDir` go THROUGH a symlink into something
95
95
  * the library doesn't own — a LINKED leaf entry, OR a destination reached through a
96
- * symlinked ANCESTOR (e.g. a symlinked --domain folder) whose realpath escapes the
97
- * library? Writing through either clobbers an external dev repo, even with --force
96
+ * symlinked ANCESTOR whose realpath escapes the library? Writing through either
97
+ * clobbers an external dev repo, even with --force
98
98
  * (ADR-0004). The two add/dry-run sites that combined `isSymlink(destDir) ||
99
99
  * destEscapesLibrary(...)` inline now share this one predicate.
100
100
  */
@@ -108,8 +108,8 @@ export function writesThroughSymlink(libraryPath: string, destDir: string): bool
108
108
  * beside a tombstone would strand a duplicate and break `skl unretire`, so add/import/
109
109
  * link all refuse it regardless of --force (force overwrites an ACTIVE copy, not a
110
110
  * retired one). Each command keeps its own bespoke error wording + exit handling; this
111
- * is the shared PREDICATE they all branch on. Checked against the flat library root
112
- * (retirement is never under a domain folder); name-validated via entryStatus.
111
+ * is the shared PREDICATE they all branch on. Checked against the flat library root;
112
+ * name-validated via entryStatus.
113
113
  */
114
114
  export function isRetiredOnly(libraryPath: string, name: string): boolean {
115
115
  const status = entryStatus(libraryPath, name);
@@ -179,7 +179,8 @@ async function maybeInferTags(
179
179
 
180
180
  export interface InstallOptions {
181
181
  libraryPath: string;
182
- domainFolder: string | null;
182
+ /** taxonomy tag to apply after install — never a folder (ADR-0001) */
183
+ domain: string | null;
183
184
  /** single-skill --name override; ignored in multi mode */
184
185
  nameOverride: string | null;
185
186
  /** per-skill lockfile `source` string (already carries the @subpath / #subpath) */
@@ -258,7 +259,7 @@ export async function installSkill(
258
259
  // (<library>/_retired/<name>), do NOT install a fresh active copy beside it — that
259
260
  // strands a duplicate and breaks `skl unretire`. The user must unretire first. This
260
261
  // fires regardless of --force (force overwrites an ACTIVE copy, not a retired one).
261
- // Checked against the flat library root (retirement is never under a domain folder).
262
+ // Checked against the flat library root.
262
263
  if (isRetiredOnly(opts.libraryPath, rawName)) {
263
264
  return {
264
265
  ...base,
@@ -268,14 +269,14 @@ export async function installSkill(
268
269
  };
269
270
  }
270
271
 
271
- const destDir = destDirFor(opts.libraryPath, opts.domainFolder, rawName);
272
+ const destDir = destDirFor(opts.libraryPath, rawName);
272
273
  const verdict = await driftVerdict(skill, destDir);
273
274
  base.verdict = verdict;
274
275
 
275
276
  // 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).
277
+ // entry, OR a destination reached through a symlinked ANCESTOR whose realpath escapes
278
+ // the library. Writing through either would clobber an external dev repo, even with
279
+ // --force (ADR-0004).
279
280
  const throughSymlink = writesThroughSymlink(opts.libraryPath, destDir);
280
281
 
281
282
  if (opts.multi) {
@@ -308,7 +309,7 @@ export async function installSkill(
308
309
  }
309
310
 
310
311
  // ---- write into the library ----
311
- await ensureDir(opts.domainFolder ? join(opts.libraryPath, opts.domainFolder) : opts.libraryPath);
312
+ await ensureDir(opts.libraryPath);
312
313
  await copySkillDir(skill.dir, destDir);
313
314
 
314
315
  const installedBody = bodyOf(await readSkillBody(skill.dir));
@@ -327,8 +328,8 @@ export async function installSkill(
327
328
  const installed: Skill = {
328
329
  name: rawName,
329
330
  description: skill.description,
330
- primaryDomain: opts.domainFolder,
331
- domains: opts.domainFolder ? [opts.domainFolder] : [],
331
+ primaryDomain: opts.domain,
332
+ domains: opts.domain ? [opts.domain] : [],
332
333
  path: destDir,
333
334
  bodyPath: join(destDir, "SKILL.md"),
334
335
  refFiles: [],
@@ -337,14 +338,14 @@ export async function installSkill(
337
338
  mirrorOf: null,
338
339
  contentHash: "",
339
340
  };
340
- if (opts.domainFolder) await setDomainsForName(opts.libraryPath, rawName, [opts.domainFolder]);
341
+ if (opts.domain) await setDomainsForName(opts.libraryPath, rawName, [opts.domain]);
341
342
 
342
343
  let inferred: string[] | null = null;
343
344
  if (opts.infer) {
344
345
  inferred = await maybeInferTags(installed, warn);
345
346
  if (inferred && inferred.length > 0) await setDomainsForName(opts.libraryPath, rawName, inferred);
346
347
  }
347
- const domains = inferred && inferred.length > 0 ? inferred : opts.domainFolder ? [opts.domainFolder] : [];
348
+ const domains = inferred && inferred.length > 0 ? inferred : opts.domain ? [opts.domain] : [];
348
349
 
349
350
  return {
350
351
  ...base,