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
@@ -6,17 +6,22 @@
6
6
  //
7
7
  // Read command: supports --json.
8
8
 
9
- import type { Ctx, LockEntry } from "../types.ts";
9
+ import { join } from "node:path";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import type { Ctx, LockEntry, Skill } from "../types.ts";
10
12
  import { readLockfile } from "../core/provenance.ts";
13
+ import { entryMode, entryModeInfo, loadLibrary, findByName } from "../core/library.ts";
14
+ import { hashContent } from "../core/crawl.ts";
15
+ import { parseFrontmatter } from "../lib/frontmatter.ts";
11
16
  import { parseStoredSource, latestRef } from "../core/fetch.ts";
12
17
 
13
18
  export const meta = {
14
19
  name: "outdated",
15
20
  summary: "Check upstream ref per tracked skill and mark stale ones",
16
- usage: "skl outdated [name] [--json]",
21
+ usage: "skl outdated [name] [--check-local] [--json]",
17
22
  } as const;
18
23
 
19
- type Status = "stale" | "current" | "unknown";
24
+ type Status = "stale" | "current" | "unknown" | "linked" | "diverged";
20
25
 
21
26
  interface Row {
22
27
  name: string;
@@ -59,53 +64,168 @@ async function checkEntry(entry: LockEntry): Promise<Row> {
59
64
  };
60
65
  }
61
66
 
67
+ /**
68
+ * A LINKED entry (library/<name> symlinks to an external dev repo) has no tracked
69
+ * upstream — its own git owns versioning. Report it as such instead of probing a
70
+ * (possibly stale) github ref (ADR-0004).
71
+ */
72
+ function linkedRow(entry: LockEntry): Row {
73
+ return {
74
+ name: entry.name,
75
+ channel: "local",
76
+ source: entry.source,
77
+ installedRef: entry.ref,
78
+ latestRef: null,
79
+ status: "linked",
80
+ note: "dev repo owns versioning",
81
+ };
82
+ }
83
+
84
+ /**
85
+ * A LINKED library skill with NO lockfile entry (the normal case for `skl link
86
+ * --from` — it drops any lock entry). Without this, a freshly-linked dev skill is
87
+ * INVISIBLE to outdated, giving an agent zero positive evidence its dev repo is the
88
+ * canonical source. Surface it as a `linked` row regardless.
89
+ */
90
+ function linkedRowFromName(name: string, linkTarget: string | null): Row {
91
+ return {
92
+ name,
93
+ channel: "local",
94
+ source: linkTarget ?? "(dev repo)",
95
+ installedRef: "-",
96
+ latestRef: null,
97
+ status: "linked",
98
+ note: "dev repo owns versioning",
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Offline (no network) divergence check for an OWNED tracked skill: compare the local
104
+ * SKILL.md body hash to the baseline recorded at install/update time
105
+ * (lockfile.installedHash). Answers "have I locally edited this?" without probing
106
+ * upstream — usable on a plane / in CI with no creds.
107
+ */
108
+ function checkEntryLocal(entry: LockEntry, library: Skill[], libraryPath: string): Row {
109
+ const skill = findByName(library, entry.name);
110
+ const bodyPath = skill?.bodyPath ?? join(libraryPath, entry.name, "SKILL.md");
111
+ let localHash: string | null = null;
112
+ try {
113
+ if (existsSync(bodyPath)) {
114
+ const raw = readFileSync(bodyPath, "utf8");
115
+ localHash = hashContent(parseFrontmatter(raw).body);
116
+ }
117
+ } catch {
118
+ /* leave null */
119
+ }
120
+ const base = {
121
+ name: entry.name,
122
+ channel: entry.channel,
123
+ source: entry.source,
124
+ installedRef: entry.ref,
125
+ latestRef: null,
126
+ };
127
+ if (entry.installedHash == null) {
128
+ return { ...base, status: "unknown", note: "no recorded baseline (installed before hash tracking) — re-run `skl update` to record one" };
129
+ }
130
+ if (localHash == null) {
131
+ return { ...base, status: "unknown", note: "local SKILL.md unreadable" };
132
+ }
133
+ return localHash === entry.installedHash
134
+ ? { ...base, status: "current", note: "matches installed baseline (offline)" }
135
+ : { ...base, status: "diverged", note: "local body diverged from installed baseline (offline)" };
136
+ }
137
+
62
138
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
63
139
  const json = argv.includes("--json");
140
+ const checkLocal = argv.includes("--check-local");
64
141
  const nameArg = argv.find((a) => !a.startsWith("-")) ?? null;
142
+ const libraryPath = ctx.config.libraryPath;
65
143
 
66
144
  try {
67
- const lock = await readLockfile(ctx.config.libraryPath);
145
+ const lock = await readLockfile(libraryPath);
68
146
  let entries = Object.values(lock.entries);
69
147
  if (nameArg) entries = entries.filter((e) => e.name === nameArg);
70
148
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
71
149
 
72
- if (entries.length === 0) {
150
+ // Load the library so we can (a) hash local bodies for --check-local and
151
+ // (b) surface LINKED skills that have NO lock entry — the normal `skl link
152
+ // --from` case — which would otherwise be invisible here.
153
+ const library = await loadLibrary(libraryPath);
154
+
155
+ const rows = await Promise.all(
156
+ entries.map((e) =>
157
+ entryMode(libraryPath, e.name) === "linked"
158
+ ? Promise.resolve(linkedRow(e))
159
+ : checkLocal
160
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
161
+ : checkEntry(e),
162
+ ),
163
+ );
164
+
165
+ // Augment: LINKED library skills not already represented by a lock entry get a
166
+ // `linked` row, so a freshly-shelved dev skill shows positive evidence.
167
+ const known = new Set(rows.map((r) => r.name));
168
+ for (const s of library) {
169
+ if (known.has(s.name)) continue;
170
+ if (nameArg && s.name !== nameArg) continue;
171
+ const info = entryModeInfo(libraryPath, s.name);
172
+ if (info.mode === "linked") rows.push(linkedRowFromName(s.name, info.linkTarget));
173
+ }
174
+ rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
175
+
176
+ if (rows.length === 0) {
73
177
  if (json) ctx.json({ ok: true, checked: 0, stale: 0, rows: [] });
74
178
  else if (nameArg) ctx.log(`no tracked skill named "${nameArg}"`);
75
179
  else ctx.log("no tracked third-party skills (lockfile is empty)");
76
180
  return 0;
77
181
  }
78
182
 
79
- const rows = await Promise.all(entries.map((e) => checkEntry(e)));
80
183
  const stale = rows.filter((r) => r.status === "stale");
184
+ const diverged = rows.filter((r) => r.status === "diverged");
81
185
 
82
186
  if (json) {
83
187
  ctx.json({
84
188
  ok: true,
85
189
  checked: rows.length,
86
190
  stale: stale.length,
191
+ diverged: diverged.length,
87
192
  rows,
88
193
  });
89
194
  } else {
90
195
  for (const r of rows) {
91
- const mark = r.status === "stale" ? "STALE " : r.status === "current" ? "current" : "unknown";
196
+ const mark =
197
+ r.status === "stale" ? "STALE "
198
+ : r.status === "diverged" ? "DIVERGED"
199
+ : r.status === "current" ? "current "
200
+ : r.status === "linked" ? "linked "
201
+ : "unknown ";
92
202
  const refInfo =
93
- r.status === "stale"
94
- ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
95
- : r.status === "current"
96
- ? shortRef(r.installedRef)
97
- : `${shortRef(r.installedRef)} (${r.note})`;
98
- const extra = r.note && r.status !== "unknown" ? ` [${r.note}]` : "";
203
+ r.status === "linked"
204
+ ? r.note
205
+ : r.status === "stale"
206
+ ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
207
+ : r.status === "diverged"
208
+ ? r.note
209
+ : r.status === "current"
210
+ ? shortRef(r.installedRef) + (checkLocal ? " (offline)" : "")
211
+ : `${shortRef(r.installedRef)} (${r.note})`;
212
+ const extra =
213
+ r.note && !["unknown", "linked", "diverged"].includes(r.status) ? ` [${r.note}]` : "";
99
214
  ctx.log(`${mark} ${r.name.padEnd(28)} ${r.channel.padEnd(15)} ${refInfo}${extra}`);
100
215
  }
101
216
  ctx.log("");
102
- ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
103
- if (stale.length > 0) {
104
- ctx.log(`run \`skl update [name]\` to re-pull (overlays are preserved).`);
217
+ if (checkLocal) {
218
+ ctx.log(`${rows.length} tracked, ${diverged.length} locally diverged (offline check — no upstream probed).`);
219
+ if (diverged.length > 0) ctx.log("re-run \`skl update [name]\` to see the upstream diff, or \`--force\` to overwrite.");
220
+ } else {
221
+ ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
222
+ if (stale.length > 0) ctx.log(`run \`skl update [name]\` to re-pull (domain tags are preserved).`);
105
223
  }
106
224
  }
107
- // Non-zero exit when stale skills exist, so agents/CI can branch on it.
108
- return stale.length > 0 ? 2 : 0;
225
+ // Non-zero exit when stale (or, offline, locally-diverged) skills exist, so
226
+ // agents/CI can branch on it.
227
+ const flagged = checkLocal ? diverged.length : stale.length;
228
+ return flagged > 0 ? 2 : 0;
109
229
  } catch (err) {
110
230
  ctx.error("outdated: failed:", err instanceof Error ? err.message : String(err));
111
231
  return 1;
@@ -0,0 +1,133 @@
1
+ // `skl refresh` — re-sync THIS project's ./.claude/skills/ symlinks to current library
2
+ // reality. The idempotent re-`use` was already the de-facto re-sync; this names it.
3
+ //
4
+ // For every managed symlink in the project skills dir:
5
+ // - resolves to a library skill that still exists -> repoint at its current path
6
+ // (repairs a relocated library: SKILLSHELF_LIBRARY moved, absolute link stale)
7
+ // - the same-named library skill no longer exists -> prune (renamed/removed/retired)
8
+ // - points somewhere unrelated (not a library skill) -> left untouched
9
+ //
10
+ // Deliberately does NOT expand bundles (that would guess intent) — to pick up NEW
11
+ // members of a bundle, re-run `skl use <bundle>` (also idempotent).
12
+ //
13
+ // skl refresh [--dry-run] [--json]
14
+
15
+ import { join, resolve, isAbsolute, dirname } from "node:path";
16
+ import { readdir, readlink } from "node:fs/promises";
17
+ import type { Ctx, Skill } from "../types.ts";
18
+ import { activeSkills } from "../core/library.ts";
19
+ import {
20
+ pathExists,
21
+ isSymlink,
22
+ realpathOrSelf,
23
+ realpathOrSelfAsync,
24
+ safeSymlink,
25
+ removeSymlink,
26
+ } from "../lib/fs.ts";
27
+
28
+ /** Direct entry names in a dir that are symlinks — INCLUDING dead ones (which
29
+ * listDirNames drops because they don't resolve to a directory). */
30
+ async function symlinkNames(dir: string): Promise<string[]> {
31
+ try {
32
+ const entries = await readdir(dir, { withFileTypes: true });
33
+ return entries.filter((e) => e.isSymbolicLink()).map((e) => e.name);
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ export const meta = {
40
+ name: "refresh",
41
+ summary: "Re-sync this project's .claude/skills symlinks to current library reality",
42
+ usage: "skl refresh [--dry-run] [--json]",
43
+ } as const;
44
+
45
+ type Action = "repointed" | "ok" | "pruned" | "foreign";
46
+
47
+ interface Outcome {
48
+ name: string;
49
+ action: Action;
50
+ }
51
+
52
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
53
+ const json = argv.includes("--json");
54
+ const dryRun = argv.includes("--dry-run");
55
+ const skillsDir = join(process.cwd(), ".claude", "skills");
56
+
57
+ try {
58
+ const skills = activeSkills(await ctx.loadLibrary());
59
+ const byName = new Map<string, Skill>(skills.map((s) => [s.name, s]));
60
+ // Two library prefixes: realpath (resolves /tmp -> /private/tmp etc.) and the
61
+ // plain resolved path. A DEAD link's target can't be realpath'd, so we match its
62
+ // raw readlink target against either form to decide if it pointed into the library.
63
+ const libPrefixes = [realpathOrSelf(ctx.libraryPath), resolve(ctx.libraryPath)].map((p) =>
64
+ p.endsWith("/") ? p : p + "/",
65
+ );
66
+
67
+ const outcomes: Outcome[] = [];
68
+ if (pathExists(skillsDir)) {
69
+ for (const name of await symlinkNames(skillsDir)) {
70
+ const link = join(skillsDir, name);
71
+ if (!isSymlink(link)) continue; // only manage symlinks; never touch real files
72
+ const skill = byName.get(name);
73
+
74
+ if (skill) {
75
+ // The same-named library skill exists: ensure the link points at it.
76
+ const cur = realpathOrSelf(link);
77
+ const want = await realpathOrSelfAsync(skill.path);
78
+ if (cur === want) {
79
+ outcomes.push({ name, action: "ok" });
80
+ } else {
81
+ if (!dryRun) await safeSymlink(skill.path, link, { force: true });
82
+ outcomes.push({ name, action: "repointed" });
83
+ }
84
+ continue;
85
+ }
86
+
87
+ // No same-named library skill. Read the RAW target to decide if it pointed
88
+ // into the library (renamed/removed -> prune) or somewhere unrelated (leave).
89
+ const raw = await readlink(link).catch(() => null);
90
+ const targetAbs = raw == null ? "" : isAbsolute(raw) ? raw : resolve(dirname(link), raw);
91
+ // Strictly UNDER the library (a per-skill entry) — a link to the library ROOT
92
+ // itself was never a skill deployment, so leave it as `foreign` rather than prune.
93
+ const pointsIntoLibrary = libPrefixes.some((pre) => targetAbs.startsWith(pre));
94
+
95
+ if (pointsIntoLibrary) {
96
+ // Was a library skill (link still resolves into the library tree) but no
97
+ // active skill of this name remains — renamed/removed/retired. Prune.
98
+ if (!dryRun) await removeSymlink(link, { force: true });
99
+ outcomes.push({ name, action: "pruned" });
100
+ } else {
101
+ outcomes.push({ name, action: "foreign" });
102
+ }
103
+ }
104
+ }
105
+
106
+ const count = (a: Action) => outcomes.filter((o) => o.action === a).length;
107
+ if (json) {
108
+ ctx.json({
109
+ dryRun,
110
+ skillsDir,
111
+ outcomes,
112
+ repointed: count("repointed"),
113
+ pruned: count("pruned"),
114
+ ok: count("ok"),
115
+ });
116
+ return 0;
117
+ }
118
+
119
+ if (outcomes.length === 0) {
120
+ ctx.log(`No managed symlinks in ${skillsDir} — nothing to refresh.`);
121
+ return 0;
122
+ }
123
+ const verb = dryRun ? "would" : "did";
124
+ ctx.log(`Refresh ${skillsDir}:`);
125
+ for (const o of outcomes) ctx.log(` ${o.name} [${o.action}]`);
126
+ ctx.log("");
127
+ ctx.log(`${count("repointed")} repointed, ${count("pruned")} pruned, ${count("ok")} already current (${verb} apply).`);
128
+ return 0;
129
+ } catch (err) {
130
+ ctx.error(`skl refresh: ${err instanceof Error ? err.message : String(err)}`);
131
+ return 1;
132
+ }
133
+ }
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { remediate } from "./where.ts";
7
+ import { run as refreshRun } from "./refresh.ts";
8
+ import { run as statusRun } from "./status.ts";
9
+ import { isSymlink, realpathOrSelf } from "../lib/fs.ts";
10
+ import { loadLibrary } from "../core/library.ts";
11
+ import { inventoryDeployments, remediationFor } from "../core/deployments.ts";
12
+ import type { Ctx, DeploymentSite } from "../types.ts";
13
+
14
+ function site(partial: Partial<DeploymentSite> & Pick<DeploymentSite, "name" | "path" | "kind">): DeploymentSite {
15
+ return { surface: "/surface", target: null, inLibrary: false, drift: false, ...partial };
16
+ }
17
+
18
+ function makeCtx(libraryPath: string) {
19
+ const json: unknown[] = [];
20
+ const ctx = {
21
+ config: { libraryPath },
22
+ libraryPath,
23
+ loadLibrary: () => loadLibrary(libraryPath),
24
+ log: () => {},
25
+ error: () => {},
26
+ json: (v: unknown) => json.push(v),
27
+ } as unknown as Ctx;
28
+ return { ctx, json };
29
+ }
30
+
31
+ describe("where remediation + refresh + status drift (friction #6)", () => {
32
+ let tmp: string;
33
+ let library: string;
34
+
35
+ beforeEach(async () => {
36
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-rem-")));
37
+ library = join(tmp, "library");
38
+ await mkdir(join(library, "alpha"), { recursive: true });
39
+ await writeFile(join(library, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: a\n---\n\nbody\n");
40
+ });
41
+ afterEach(async () => {
42
+ await rm(tmp, { recursive: true, force: true });
43
+ });
44
+
45
+ test("--prune removes dead links only; copies stay manual", async () => {
46
+ const surface = join(tmp, "surface");
47
+ await mkdir(surface, { recursive: true });
48
+ await symlink(join(tmp, "gone"), join(surface, "deadlink"));
49
+ const dead = site({ name: "deadlink", path: join(surface, "deadlink"), kind: "dead" });
50
+ const copy = site({ name: "alpha", path: join(surface, "alpha"), kind: "copy", inLibrary: true, drift: false });
51
+
52
+ const outcomes = await remediate([dead, copy], library, { fix: false, dryRun: false });
53
+ expect(outcomes.find((o) => o.name === "deadlink")!.action).toBe("remove-dead");
54
+ expect(outcomes.find((o) => o.name === "deadlink")!.applied).toBe(true);
55
+ expect(existsSync(join(surface, "deadlink"))).toBe(false);
56
+ // under --prune a dedupe-able copy is left as manual
57
+ expect(outcomes.find((o) => o.name === "alpha")!.action).toBe("manual");
58
+ });
59
+
60
+ test("--fix dedupes a content-identical copy into a library symlink", async () => {
61
+ const surface = join(tmp, "surface");
62
+ await mkdir(join(surface, "alpha"), { recursive: true });
63
+ await writeFile(join(surface, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: a\n---\n\nbody\n");
64
+ const copy = site({ name: "alpha", path: join(surface, "alpha"), kind: "copy", inLibrary: true, drift: false });
65
+
66
+ const outcomes = await remediate([copy], library, { fix: true, dryRun: false });
67
+ expect(outcomes[0]!.action).toBe("dedupe-copy");
68
+ expect(isSymlink(join(surface, "alpha"))).toBe(true);
69
+ expect(realpathOrSelf(join(surface, "alpha"))).toBe(realpathOrSelf(join(library, "alpha")));
70
+ });
71
+
72
+ test("--fix never auto-resolves a drifted copy or a foreign link", async () => {
73
+ const drifted = site({ name: "alpha", path: "/s/alpha", kind: "copy", inLibrary: true, drift: true });
74
+ const foreign = site({ name: "beta", path: "/s/beta", kind: "foreign-link", target: "/elsewhere" });
75
+ const outcomes = await remediate([drifted, foreign], library, { fix: true, dryRun: true });
76
+ expect(outcomes.every((o) => o.action === "manual")).toBe(true);
77
+ expect(outcomes.every((o) => !o.applied)).toBe(true);
78
+ });
79
+
80
+ test("--dry-run reports without mutating", async () => {
81
+ const surface = join(tmp, "surface");
82
+ await mkdir(surface, { recursive: true });
83
+ await symlink(join(tmp, "gone"), join(surface, "deadlink"));
84
+ const dead = site({ name: "deadlink", path: join(surface, "deadlink"), kind: "dead" });
85
+ const outcomes = await remediate([dead], library, { fix: true, dryRun: true });
86
+ expect(outcomes[0]!.applied).toBe(false);
87
+ expect(isSymlink(join(surface, "deadlink"))).toBe(true); // still there
88
+ });
89
+
90
+ test("refresh prunes a stale link whose library skill is gone, leaves foreign links", async () => {
91
+ const proj = join(tmp, "proj");
92
+ const skillsDir = join(proj, ".claude", "skills");
93
+ await mkdir(skillsDir, { recursive: true });
94
+ await symlink(join(library, "alpha"), join(skillsDir, "alpha")); // valid
95
+ await symlink(join(library, "ghost"), join(skillsDir, "ghost")); // dead, was-library
96
+ await symlink("/foreign/x", join(skillsDir, "foreign")); // foreign
97
+
98
+ const prev = process.cwd();
99
+ process.chdir(proj);
100
+ try {
101
+ const { ctx, json } = makeCtx(library);
102
+ await refreshRun(["--json"], ctx);
103
+ const out = json[0] as { outcomes: Array<{ name: string; action: string }> };
104
+ const by = Object.fromEntries(out.outcomes.map((o) => [o.name, o.action]));
105
+ expect(by.alpha).toBe("ok");
106
+ expect(by.ghost).toBe("pruned");
107
+ expect(by.foreign).toBe("foreign");
108
+ expect(existsSync(join(skillsDir, "ghost"))).toBe(false);
109
+ expect(isSymlink(join(skillsDir, "foreign"))).toBe(true);
110
+ } finally {
111
+ process.chdir(prev);
112
+ }
113
+ });
114
+
115
+ test("a copy with identical body but a CUSTOMIZED description is drift, not dedupe-able", async () => {
116
+ // the load-bearing `description` differs while the body is byte-identical —
117
+ // `where --fix` must NOT silently replace it with a symlink (data loss).
118
+ const surface = join(tmp, "surface");
119
+ await mkdir(join(surface, "alpha"), { recursive: true });
120
+ await writeFile(
121
+ join(surface, "alpha", "SKILL.md"),
122
+ "---\nname: alpha\ndescription: CUSTOMIZED trigger text\n---\n\nbody\n", // same body, diff description
123
+ );
124
+ const lib = await loadLibrary(library);
125
+ const report = await inventoryDeployments([surface], library, lib);
126
+ const alpha = report.sites.find((s) => s.surface === surface && s.name === "alpha")!;
127
+ expect(alpha.kind).toBe("copy");
128
+ expect(alpha.drift).toBe(true); // description difference counts as drift
129
+ expect(remediationFor(alpha)).toBe("manual"); // never auto-deduped
130
+ });
131
+
132
+ test("status flags an unmanaged real copy (drift-prone)", async () => {
133
+ const proj = join(tmp, "proj2");
134
+ const skillsDir = join(proj, ".claude", "skills");
135
+ await mkdir(join(skillsDir, "realcopy"), { recursive: true });
136
+ await writeFile(join(skillsDir, "realcopy", "SKILL.md"), "---\nname: realcopy\n---\n\nx\n");
137
+
138
+ const prev = process.cwd();
139
+ process.chdir(proj);
140
+ try {
141
+ const { ctx, json } = makeCtx(library);
142
+ await statusRun(["--json"], ctx);
143
+ const out = json[0] as { unmanaged: Array<{ name: string }> };
144
+ expect(out.unmanaged.map((u) => u.name)).toContain("realcopy");
145
+ } finally {
146
+ process.chdir(prev);
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,121 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, symlink, rm as fsRm, realpath, readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { run as renameRun } from "./rename.ts";
7
+ import { loadLibrary } from "../core/library.ts";
8
+ import type { Ctx } from "../types.ts";
9
+
10
+ function makeCtx(libraryPath: string) {
11
+ const json: unknown[] = [];
12
+ const errors: string[] = [];
13
+ const ctx = {
14
+ config: { libraryPath },
15
+ libraryPath,
16
+ loadLibrary: () => loadLibrary(libraryPath),
17
+ log: () => {},
18
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
19
+ json: (v: unknown) => json.push(v),
20
+ } as unknown as Ctx;
21
+ return { ctx, json, errors };
22
+ }
23
+
24
+ describe("skl rename — atomic slug move (friction #5)", () => {
25
+ let tmp: string;
26
+ let library: string;
27
+
28
+ beforeEach(async () => {
29
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-rename-")));
30
+ library = join(tmp, "library");
31
+ await mkdir(join(library, "alpha"), { recursive: true });
32
+ await writeFile(join(library, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: a\n---\n\nbody\n");
33
+ await writeFile(join(library, "taxonomy.json"), JSON.stringify({ version: 1, skills: { alpha: ["bio"] } }));
34
+ await writeFile(
35
+ join(library, "shelf.lock.json"),
36
+ JSON.stringify({ version: 1, entries: { alpha: { name: "alpha", source: "github:o/r", ref: "x", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false } } }),
37
+ );
38
+ });
39
+ afterEach(async () => {
40
+ await fsRm(tmp, { recursive: true, force: true });
41
+ });
42
+
43
+ test("moves dir + frontmatter + taxonomy + lock together", async () => {
44
+ const { ctx } = makeCtx(library);
45
+ const code = await renameRun(["alpha", "alpha2", "--json"], ctx);
46
+ expect(code).toBe(0);
47
+
48
+ expect(existsSync(join(library, "alpha"))).toBe(false);
49
+ expect(existsSync(join(library, "alpha2"))).toBe(true);
50
+
51
+ const fm = await readFile(join(library, "alpha2", "SKILL.md"), "utf8");
52
+ expect(fm).toContain("name: alpha2");
53
+
54
+ const tax = JSON.parse(await readFile(join(library, "taxonomy.json"), "utf8"));
55
+ expect(tax.skills.alpha2).toEqual(["bio"]);
56
+ expect(tax.skills.alpha).toBeUndefined();
57
+
58
+ const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
59
+ expect(lock.entries.alpha2.name).toBe("alpha2");
60
+ expect(lock.entries.alpha).toBeUndefined();
61
+ });
62
+
63
+ test("refuses an existing target name", async () => {
64
+ await mkdir(join(library, "beta"), { recursive: true });
65
+ await writeFile(join(library, "beta", "SKILL.md"), "---\nname: beta\n---\n\nb\n");
66
+ const { ctx, errors } = makeCtx(library);
67
+ const code = await renameRun(["alpha", "beta"], ctx);
68
+ expect(code).toBe(1);
69
+ expect(errors.join("\n")).toContain("already exists");
70
+ });
71
+
72
+ test("refuses a missing source", async () => {
73
+ const { ctx, errors } = makeCtx(library);
74
+ const code = await renameRun(["ghost", "x"], ctx);
75
+ expect(code).toBe(1);
76
+ expect(errors.join("\n")).toContain("not in the library");
77
+ });
78
+
79
+ test("renames a RETIRED skill in place (stays under _retired/)", async () => {
80
+ // retire alpha by moving it into _retired/
81
+ await mkdir(join(library, "_retired"), { recursive: true });
82
+ const { rename: fsRename } = await import("node:fs/promises");
83
+ await fsRename(join(library, "alpha"), join(library, "_retired", "alpha"));
84
+
85
+ const { ctx, json } = makeCtx(library);
86
+ const code = await renameRun(["alpha", "alpha2", "--json"], ctx);
87
+ expect(code).toBe(0);
88
+ expect((json[0] as { wasRetired: boolean }).wasRetired).toBe(true);
89
+ expect(existsSync(join(library, "_retired", "alpha2", "SKILL.md"))).toBe(true);
90
+ expect(existsSync(join(library, "_retired", "alpha"))).toBe(false);
91
+ expect(existsSync(join(library, "alpha2"))).toBe(false); // did NOT escape to active
92
+ });
93
+
94
+ test("refuses a path-traversal <old> name", async () => {
95
+ const victim = join(tmp, "victim");
96
+ await mkdir(victim, { recursive: true });
97
+ const { ctx, errors } = makeCtx(library);
98
+ const code = await renameRun(["../victim", "stolen"], ctx);
99
+ expect(code).toBe(1);
100
+ expect(errors.join("\n")).toContain("path separators");
101
+ expect(existsSync(victim)).toBe(true);
102
+ expect(existsSync(join(library, "stolen"))).toBe(false);
103
+ });
104
+
105
+ test("a LINKED entry rekeys metadata but leaves the dev-repo SKILL.md untouched", async () => {
106
+ const dev = join(tmp, "dev", "linked");
107
+ await mkdir(dev, { recursive: true });
108
+ await writeFile(join(dev, "SKILL.md"), "---\nname: linked\n---\n\nbody\n");
109
+ await symlink(dev, join(library, "linked"));
110
+
111
+ const { ctx, json } = makeCtx(library);
112
+ const code = await renameRun(["linked", "linked2", "--json"], ctx);
113
+ expect(code).toBe(0);
114
+ expect((json[0] as { frontmatterRewritten: boolean }).frontmatterRewritten).toBe(false);
115
+ // dev-repo body is NOT rewritten (we must not edit the dev repo)
116
+ const devFm = await readFile(join(dev, "SKILL.md"), "utf8");
117
+ expect(devFm).toContain("name: linked");
118
+ // library symlink now lives under the new name, still resolving to the dev repo
119
+ expect(existsSync(join(library, "linked2", "SKILL.md"))).toBe(true);
120
+ });
121
+ });
@@ -0,0 +1,64 @@
1
+ // `skl rename <old> <new>` (alias `skl mv`) — rename a skill's slug, moving every
2
+ // coupled piece of state together in ONE op: the library dir, the SKILL.md
3
+ // frontmatter `name:`, the taxonomy key, and the lockfile key. A hand `mv` alone
4
+ // leaves a half-renamed skill (dir=new, frontmatter/taxonomy=old) — the exact
5
+ // multi-file-consistency hazard the dogfood pass hit.
6
+ //
7
+ // skl rename <old> <new> [--json]
8
+ //
9
+ // Does NOT repoint external deploy symlinks (they point at the old library path);
10
+ // re-run `skl use` in affected projects (or `skl where` to find stragglers) after.
11
+
12
+ import type { Ctx } from "../types.ts";
13
+ import { renameSkill, reindexLibrary } from "../core/lifecycle.ts";
14
+
15
+ export const meta = {
16
+ name: "rename",
17
+ summary: "Rename a skill slug atomically (dir + frontmatter + taxonomy + lock)",
18
+ usage: "skl rename <old> <new> [--json]",
19
+ } as const;
20
+
21
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
22
+
23
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
24
+ const json = argv.includes("--json");
25
+ const positional = argv.filter((a) => !a.startsWith("--"));
26
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--json");
27
+ if (unknownFlag) {
28
+ ctx.error(`skl rename: unknown argument: ${unknownFlag}`);
29
+ ctx.error(`usage: ${meta.usage}`);
30
+ return 1;
31
+ }
32
+ const [from, to] = positional;
33
+ if (!from || !to) {
34
+ ctx.error("skl rename: an <old> and a <new> name are required");
35
+ ctx.error(`usage: ${meta.usage}`);
36
+ return 1;
37
+ }
38
+ if (!SLUG_RE.test(to)) {
39
+ ctx.error(`skl rename: invalid name "${to}" — use lowercase letters, digits, and hyphens`);
40
+ return 1;
41
+ }
42
+ if (from === to) {
43
+ ctx.error("skl rename: <old> and <new> are the same");
44
+ return 1;
45
+ }
46
+
47
+ try {
48
+ const result = await renameSkill(ctx.libraryPath, from, to);
49
+ await reindexLibrary(ctx.libraryPath);
50
+ if (json) {
51
+ ctx.json({ ok: true, ...result });
52
+ } else {
53
+ ctx.log(`renamed ${from} -> ${to}`);
54
+ if (result.frontmatterRewritten) ctx.log(" rewrote SKILL.md frontmatter name:");
55
+ if (result.taxonomyMoved) ctx.log(" moved taxonomy entry");
56
+ if (result.lockMoved) ctx.log(" moved lockfile entry");
57
+ ctx.log(" note: deploy symlinks pointing at the old name are now stale — re-run `skl use` (or `skl where`).");
58
+ }
59
+ return 0;
60
+ } catch (err) {
61
+ ctx.error(`skl rename: ${err instanceof Error ? err.message : String(err)}`);
62
+ return 1;
63
+ }
64
+ }