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
@@ -0,0 +1,92 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run as useRun } from "./use.ts";
6
+ import { run as dropRun } from "./drop.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 ctx = {
13
+ config: { libraryPath },
14
+ libraryPath,
15
+ loadLibrary: () => loadLibrary(libraryPath),
16
+ log: () => {},
17
+ error: () => {},
18
+ json: (v: unknown) => json.push(v),
19
+ } as unknown as Ctx;
20
+ return { ctx, json };
21
+ }
22
+
23
+ async function writeSkill(library: string, name: string, domain: string) {
24
+ const dir = join(library, name);
25
+ await mkdir(dir, { recursive: true });
26
+ await writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${name}\ndomains: [${domain}]\n---\n\nbody\n`);
27
+ }
28
+
29
+ describe("skl use/drop — single-skill deploy (friction #2)", () => {
30
+ let tmp: string;
31
+ let library: string;
32
+ let project: string;
33
+ let prevCwd: string;
34
+
35
+ beforeEach(async () => {
36
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-use-")));
37
+ library = join(tmp, "library");
38
+ project = join(tmp, "project");
39
+ await mkdir(project, { recursive: true });
40
+ await writeSkill(library, "alpha", "bio");
41
+ await writeSkill(library, "beta", "bio");
42
+ prevCwd = process.cwd();
43
+ process.chdir(project);
44
+ });
45
+ afterEach(async () => {
46
+ process.chdir(prevCwd);
47
+ await rm(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ test("`use <skill>` deploys exactly one skill (kind: skill)", async () => {
51
+ const { ctx, json } = makeCtx(library);
52
+ const code = await useRun(["alpha", "--json"], ctx);
53
+ expect(code).toBe(0);
54
+ const out = json[0] as { kind: string; linked: Array<{ name: string; status: string }> };
55
+ expect(out.kind).toBe("skill");
56
+ expect(out.linked).toHaveLength(1);
57
+ expect(out.linked[0]!.name).toBe("alpha");
58
+ });
59
+
60
+ test("`use <bundle>` still resolves the whole tag query (kind: bundle)", async () => {
61
+ const { ctx, json } = makeCtx(library);
62
+ await useRun(["bio", "--json"], ctx);
63
+ const out = json[0] as { kind: string; linked: unknown[] };
64
+ expect(out.kind).toBe("bundle");
65
+ expect(out.linked).toHaveLength(2);
66
+ });
67
+
68
+ test("a skill name shadows bundle resolution (skill-first)", async () => {
69
+ // name a skill the same as no domain — exact-name match wins
70
+ const { ctx, json } = makeCtx(library);
71
+ await useRun(["beta", "--json"], ctx);
72
+ expect((json[0] as { kind: string }).kind).toBe("skill");
73
+ });
74
+
75
+ test("`drop <skill>` undoes `use <skill>` symmetrically", async () => {
76
+ const { ctx } = makeCtx(library);
77
+ await useRun(["alpha", "--json"], ctx);
78
+ const { ctx: ctx2, json } = makeCtx(library);
79
+ const code = await dropRun(["alpha", "--json"], ctx2);
80
+ expect(code).toBe(0);
81
+ const out = json[0] as { results: Array<{ status: string }>; removed: number };
82
+ expect(out.removed).toBe(1);
83
+ expect(out.results[0]!.status).toBe("removed");
84
+ });
85
+
86
+ test("unknown name is a clean empty-bundle error, not a crash", async () => {
87
+ const { ctx, json } = makeCtx(library);
88
+ const code = await useRun(["does-not-exist", "--json"], ctx);
89
+ expect(code).toBe(1);
90
+ expect((json[0] as { error: string }).error).toBe("empty-bundle");
91
+ });
92
+ });
@@ -3,15 +3,17 @@
3
3
  // links without error. Reports what was linked (and is JSON-parseable on --json).
4
4
 
5
5
  import { join } from "node:path";
6
- import type { Ctx } from "../types.ts";
6
+ import { mkdir } from "node:fs/promises";
7
+ import type { Ctx, Skill } from "../types.ts";
7
8
  import { resolveBundle } from "../core/bundle.ts";
8
- import { activeSkills } from "../core/library.ts";
9
+ import { activeSkills, findByName } from "../core/library.ts";
10
+ import { parseDeployTarget } from "../core/agents.ts";
9
11
  import { safeSymlink, isSymlink, realpathOrSelf, realpathOrSelfAsync } from "../lib/fs.ts";
10
12
 
11
13
  export const meta = {
12
14
  name: "use",
13
- summary: "Symlink a bundle's skills into ./.claude/skills/ (hot-loads)",
14
- usage: "skl use <bundle> [--json]",
15
+ summary: "Symlink a bundle (or skill) into an agent's skills dir (default: ./.claude/skills/)",
16
+ usage: "skl use <bundle|skill> [--agent <id>] [--global | --project <name>] [--json]",
15
17
  } as const;
16
18
 
17
19
  interface LinkResult {
@@ -21,15 +23,17 @@ interface LinkResult {
21
23
  status: "linked" | "already" | "conflict";
22
24
  }
23
25
 
24
- /** Project skills dir for the cwd. */
25
- function projectSkillsDir(): string {
26
- return join(process.cwd(), ".claude", "skills");
27
- }
28
-
29
26
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
30
27
  try {
31
28
  const json = argv.includes("--json");
32
- const bundleName = argv.find((a) => !a.startsWith("-"));
29
+ const parsed = parseDeployTarget(argv);
30
+ if ("error" in parsed) {
31
+ ctx.error(`skl use: ${parsed.error}`);
32
+ ctx.error("usage: " + meta.usage);
33
+ return 1;
34
+ }
35
+ const { positionals, target } = parsed;
36
+ const bundleName = positionals[0];
33
37
 
34
38
  if (!bundleName) {
35
39
  ctx.error("usage: " + meta.usage);
@@ -37,53 +41,72 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
37
41
  }
38
42
 
39
43
  const skills = await ctx.loadLibrary();
40
- const bundle = await resolveBundle(activeSkills(skills), bundleName);
44
+ const active = activeSkills(skills);
45
+
46
+ // Resolve the arg as a SINGLE SKILL first (exact name), then fall back to a
47
+ // bundle (a domain tag query). This makes `skl use <skill>` a first-class
48
+ // single-skill deploy instead of erroring 'empty-bundle' and forcing a hand
49
+ // `ln -s` — the exact manual symlink skillshelf exists to eliminate.
50
+ let resolved: { name: string; kind: "skill" | "bundle"; skills: Skill[] };
51
+ const single = findByName(active, bundleName);
52
+ if (single) {
53
+ resolved = { name: single.name, kind: "skill", skills: [single] };
54
+ } else {
55
+ const bundle = await resolveBundle(active, bundleName);
56
+ resolved = { name: bundle.name, kind: "bundle", skills: bundle.skills };
57
+ }
41
58
 
42
- if (bundle.skills.length === 0) {
59
+ if (resolved.skills.length === 0) {
43
60
  if (json) {
44
- ctx.json({ bundle: bundleName, linked: [], skillsDir: projectSkillsDir(), error: "empty-bundle" });
61
+ ctx.json({ bundle: bundleName, kind: "bundle", linked: [], skillsDir: target.dir, error: "empty-bundle" });
45
62
  } else {
46
- ctx.error(`No active skills match bundle '${bundleName}'.`);
63
+ ctx.error(`No active skill or bundle matches '${bundleName}'.`);
47
64
  }
48
65
  return 1;
49
66
  }
50
67
 
51
- const skillsDir = projectSkillsDir();
68
+ const bundle = { name: resolved.name, skills: resolved.skills };
69
+ const skillsDir = target.dir;
70
+ // The target agent's skills dir may not exist yet (e.g. a fresh ~/.codex/skills).
71
+ await mkdir(skillsDir, { recursive: true });
52
72
  const results: LinkResult[] = [];
53
73
 
54
74
  for (const s of bundle.skills) {
55
75
  const link = join(skillsDir, s.name);
56
- const target = s.path;
76
+ const skillPath = s.path;
57
77
  let status: LinkResult["status"] = "linked";
58
78
 
59
79
  // Determine prior state for accurate reporting before we touch it.
60
80
  if (isSymlink(link)) {
61
81
  const cur = realpathOrSelf(link);
62
- const want = await realpathOrSelfAsync(target);
82
+ const want = await realpathOrSelfAsync(skillPath);
63
83
  if (cur === want) status = "already";
64
84
  } else if (await pathTakenNonLink(link)) {
65
85
  // A real (non-symlink) file/dir occupies the slot — don't clobber it.
66
- results.push({ name: s.name, target, link, status: "conflict" });
86
+ results.push({ name: s.name, target: skillPath, link, status: "conflict" });
67
87
  continue;
68
88
  }
69
89
 
70
- await safeSymlink(target, link);
71
- results.push({ name: s.name, target, link, status });
90
+ await safeSymlink(skillPath, link);
91
+ results.push({ name: s.name, target: skillPath, link, status });
72
92
  }
73
93
 
74
94
  const conflicts = results.filter((r) => r.status === "conflict");
75
95
 
76
96
  if (json) {
77
- ctx.json({ bundle: bundle.name, skillsDir, linked: results });
97
+ ctx.json({ bundle: bundle.name, kind: resolved.kind, skillsDir, agent: target.agentId, scope: target.scope, linked: results });
78
98
  } else {
79
- ctx.log(`Bundle '${bundle.name}' -> ${skillsDir}`);
99
+ const label = resolved.kind === "skill" ? `Skill '${bundle.name}'` : `Bundle '${bundle.name}'`;
100
+ ctx.log(`${label} -> ${skillsDir}`);
80
101
  for (const r of results) {
81
102
  const tag =
82
103
  r.status === "linked" ? "linked" : r.status === "already" ? "ok" : "SKIP (real file present)";
83
104
  ctx.log(` ${r.name} [${tag}]`);
84
105
  }
85
106
  ctx.log("");
86
- ctx.log("Reminder: add '.claude/skills/' to this project's .gitignore so these symlinks aren't committed.");
107
+ if (target.scope !== "Global") {
108
+ ctx.log(`Reminder: add '${target.agentId === "claude" ? ".claude" : "." + target.agentId}/skills/' to this project's .gitignore so these symlinks aren't committed.`);
109
+ }
87
110
  }
88
111
 
89
112
  return conflicts.length > 0 ? 1 : 0;
@@ -0,0 +1,232 @@
1
+ // `skl where [name]` — the deployment map: where is each library skill actually
2
+ // deployed across every surface tools read skills from, and what's a mess?
3
+ //
4
+ // Skillshelf's founding job: many skills × many tools (Claude Code, Codex, …) that
5
+ // scatter copies/symlinks nobody can track. `status` only sees the cwd project;
6
+ // `scan` is root-centric. `where` is skill-centric across all surfaces, computed
7
+ // from reality (no stored state). It only REPORTS — it never mutates.
8
+ //
9
+ // skl where full map + a flagged "problems" section with fixes
10
+ // skl where <name> one skill: every place it's deployed, classified
11
+ // skl where --problems only the non-clean rows
12
+ // skl where --json structured DeploymentReport
13
+
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import type { Ctx, DeploymentSite } from "../types.ts";
17
+ import {
18
+ inventoryDeployments,
19
+ suggestionFor,
20
+ remediationFor,
21
+ type RemediationAction,
22
+ } from "../core/deployments.ts";
23
+ import { knownAgentSurfacePaths } from "../core/surfaces.ts";
24
+ import { resolveReadTarget } from "../core/agents.ts";
25
+ import { safeSymlink, removeSymlink } from "../lib/fs.ts";
26
+
27
+ export const meta = {
28
+ name: "where",
29
+ summary: "Show where each library skill is deployed across all surfaces (copies, symlinks, drift)",
30
+ // NB: --project is read-only on its own, but `--project <dir> --fix/--prune`
31
+ // WILL remediate problems found in that injected surface (intended: verify-and-fix
32
+ // a deploy you just made).
33
+ usage: "skl where [name] [--agent <id>] [--project <dir>] [--problems] [--prune | --fix] [--dry-run] [--json]",
34
+ } as const;
35
+
36
+ const HOME = homedir();
37
+ /** Shorten an absolute path under $HOME to ~ for display. */
38
+ function tilde(p: string): string {
39
+ return p === HOME ? "~" : p.startsWith(HOME + "/") ? "~" + p.slice(HOME.length) : p;
40
+ }
41
+
42
+ /** Short human label for a site's classification. */
43
+ function labelFor(s: DeploymentSite): string {
44
+ switch (s.kind) {
45
+ case "linked":
46
+ return "✓ linked";
47
+ case "source":
48
+ return "✓ source";
49
+ case "dead":
50
+ return "✗ dead link";
51
+ case "foreign-link":
52
+ return "⚠ 2nd-source";
53
+ case "aliased":
54
+ return "⚠ aliased link";
55
+ case "copy":
56
+ if (!s.inLibrary) return "⚠ untracked copy";
57
+ return s.drift ? "⚠ drifted copy" : "⚠ redundant copy";
58
+ }
59
+ }
60
+
61
+ interface Args {
62
+ name: string | null;
63
+ problems: boolean;
64
+ prune: boolean;
65
+ fix: boolean;
66
+ dryRun: boolean;
67
+ json: boolean;
68
+ }
69
+
70
+ function parseArgs(argv: string[]): { args: Args } | { error: string } {
71
+ const args: Args = { name: null, problems: false, prune: false, fix: false, dryRun: false, json: false };
72
+ for (const a of argv) {
73
+ if (a === "--problems") args.problems = true;
74
+ else if (a === "--prune") args.prune = true;
75
+ else if (a === "--fix") args.fix = true;
76
+ else if (a === "--dry-run") args.dryRun = true;
77
+ else if (a === "--json") args.json = true;
78
+ else if (a.startsWith("--")) return { error: `unknown argument: ${a}` };
79
+ else if (args.name === null) args.name = a;
80
+ else return { error: `unexpected extra argument: ${a}` };
81
+ }
82
+ if (args.prune && args.fix) {
83
+ return { error: "--prune and --fix are mutually exclusive (--fix includes --prune's dead-link cleanup)" };
84
+ }
85
+ return { args };
86
+ }
87
+
88
+ export interface FixOutcome {
89
+ name: string;
90
+ path: string;
91
+ action: RemediationAction;
92
+ applied: boolean;
93
+ note: string;
94
+ }
95
+
96
+ /**
97
+ * Apply where's own remediation to the flagged sites and return per-site outcomes.
98
+ * --prune handles only dead links; --fix also dedupes content-identical copies to a
99
+ * symlink into the library. `manual` problems (drift / 2nd-source / untracked) are
100
+ * NEVER auto-resolved — they carry a real decision; we report them with the existing
101
+ * suggestion so the loop is closed but judgment stays with the human/agent.
102
+ */
103
+ export async function remediate(
104
+ sites: DeploymentSite[],
105
+ libraryPath: string,
106
+ opts: { fix: boolean; dryRun: boolean },
107
+ ): Promise<FixOutcome[]> {
108
+ const out: FixOutcome[] = [];
109
+ for (const s of sites) {
110
+ const action = remediationFor(s);
111
+ if (action === "remove-dead") {
112
+ if (!opts.dryRun) await removeSymlink(s.path, { force: true });
113
+ out.push({ name: s.name, path: s.path, action, applied: !opts.dryRun, note: opts.dryRun ? "would remove dead link" : "removed dead link" });
114
+ } else if (action === "dedupe-copy" && opts.fix) {
115
+ if (!opts.dryRun) await safeSymlink(join(libraryPath, s.name), s.path, { force: true });
116
+ out.push({ name: s.name, path: s.path, action, applied: !opts.dryRun, note: opts.dryRun ? "would replace identical copy with a symlink into the library" : "deduped copy -> symlink into library" });
117
+ } else {
118
+ // manual (or dedupe-copy under --prune): not auto-applied.
119
+ out.push({ name: s.name, path: s.path, action: "manual", applied: false, note: suggestionFor(s) });
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
126
+ // Extract --agent/--project first (read-side targeting), then parse where's own
127
+ // flags from what remains.
128
+ const rt = resolveReadTarget(argv);
129
+ if ("error" in rt) {
130
+ ctx.error(`skl where: ${rt.error}`);
131
+ ctx.error(`usage: ${meta.usage}`);
132
+ return 1;
133
+ }
134
+ const parsed = parseArgs(rt.rest);
135
+ if ("error" in parsed) {
136
+ ctx.error(`skl where: ${parsed.error}`);
137
+ ctx.error(`usage: ${meta.usage}`);
138
+ return 1;
139
+ }
140
+ const { name, problems, json } = parsed.args;
141
+
142
+ try {
143
+ const lib = await ctx.loadLibrary();
144
+ // Surfaces = configured roots + the global-core target + the well-known
145
+ // cross-agent global skill dirs (claude, codex, …) so sprawl across agents
146
+ // shows up without manual `skl scan --add-root`. Plus any --project surfaces
147
+ // injected for THIS invocation only (verify a deploy you just made).
148
+ // inventoryDeployments realpath-de-duplicates and skips missing dirs.
149
+ const surfaces = [...ctx.roots, ctx.config.globalCoreTarget, ...knownAgentSurfacePaths(), ...rt.extraSurfaces];
150
+ const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
151
+
152
+ // --- remediation (--prune / --fix) -----------------------------------
153
+ if (parsed.args.prune || parsed.args.fix) {
154
+ const targets = name !== null ? report.problems.filter((s) => s.name === name) : report.problems;
155
+ const outcomes = await remediate(targets, ctx.libraryPath, {
156
+ fix: parsed.args.fix,
157
+ dryRun: parsed.args.dryRun,
158
+ });
159
+ const applied = outcomes.filter((o) => o.applied).length;
160
+ const manual = outcomes.filter((o) => o.action === "manual");
161
+ if (json) {
162
+ ctx.json({ dryRun: parsed.args.dryRun, mode: parsed.args.fix ? "fix" : "prune", applied, outcomes });
163
+ return 0;
164
+ }
165
+ const verb = parsed.args.dryRun ? "Would apply" : "Applied";
166
+ ctx.log(`${verb} ${parsed.args.fix ? "fix" : "prune"} to ${outcomes.length} problem site(s):`);
167
+ for (const o of outcomes) {
168
+ const flag = o.action === "manual" ? "•" : parsed.args.dryRun ? "?" : "✓";
169
+ ctx.log(` ${flag} ${o.name} ${tilde(o.path)}`);
170
+ ctx.log(` ${o.note}`);
171
+ }
172
+ ctx.log("");
173
+ ctx.log(`${applied} ${parsed.args.dryRun ? "would be " : ""}auto-fixed, ${manual.length} need a manual decision.`);
174
+ return 0;
175
+ }
176
+
177
+ // --- single skill ----------------------------------------------------
178
+ if (name !== null) {
179
+ const sites = report.sites.filter((s) => s.name === name);
180
+ const inLibrary = lib.some((s) => s.name === name);
181
+ if (json) {
182
+ ctx.json({ name, inLibrary, sites });
183
+ return 0;
184
+ }
185
+ if (sites.length === 0) {
186
+ ctx.log(
187
+ inLibrary
188
+ ? `${name}: in the library, but not deployed to any scanned surface.`
189
+ : `${name}: not in the library and not found in any scanned surface.`,
190
+ );
191
+ return 0;
192
+ }
193
+ ctx.log(`${name} — deployed at ${sites.length} site${sites.length === 1 ? "" : "s"}:`);
194
+ for (const s of sites) {
195
+ ctx.log(` ${labelFor(s)} ${tilde(s.path)}`);
196
+ if (s.kind === "foreign-link" && s.target) ctx.log(` → ${tilde(s.target)}`);
197
+ const fix = suggestionFor(s);
198
+ if (fix) ctx.log(` ${fix}`);
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ // --- full map / problems --------------------------------------------
204
+ if (json) {
205
+ ctx.json(problems ? { surfaces: report.surfaces, problems: report.problems } : report);
206
+ return 0;
207
+ }
208
+
209
+ const linked = report.sites.filter((s) => s.kind === "linked");
210
+ ctx.log(`Scanned ${report.surfaces.length} surface${report.surfaces.length === 1 ? "" : "s"}:`);
211
+ for (const s of report.surfaces) ctx.log(` ${tilde(s)}`);
212
+ ctx.log("");
213
+ ctx.log(`Clean: ${linked.length} linked deployment${linked.length === 1 ? "" : "s"}.`);
214
+
215
+ if (report.problems.length === 0) {
216
+ ctx.log("No deployment problems. ✨");
217
+ return 0;
218
+ }
219
+
220
+ ctx.log("");
221
+ ctx.log(`Problems (${report.problems.length}):`);
222
+ for (const s of report.problems) {
223
+ ctx.log(` ${s.name} [${labelFor(s)}]`);
224
+ ctx.log(` ${tilde(s.path)}${s.kind === "foreign-link" && s.target ? ` → ${tilde(s.target)}` : ""}`);
225
+ ctx.log(` → ${suggestionFor(s)}`);
226
+ }
227
+ return 0;
228
+ } catch (err) {
229
+ ctx.error(`skl where failed: ${err instanceof Error ? err.message : String(err)}`);
230
+ return 1;
231
+ }
232
+ }
@@ -0,0 +1,69 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, 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 { resolveConfig, addRoot, removeRoot, loadContext } from "./config.ts";
7
+
8
+ describe("config: SKILLSHELF_CONFIG isolation + root registry inverse", () => {
9
+ let tmp: string;
10
+ let cfg: string;
11
+
12
+ beforeEach(async () => {
13
+ tmp = await mkdtemp(join(tmpdir(), "skl-config-"));
14
+ cfg = join(tmp, "config.json");
15
+ });
16
+ afterEach(async () => {
17
+ await rm(tmp, { recursive: true, force: true });
18
+ });
19
+
20
+ test("SKILLSHELF_CONFIG env redirects the config file path", async () => {
21
+ const config = await resolveConfig({ env: { SKILLSHELF_CONFIG: cfg } as NodeJS.ProcessEnv });
22
+ expect(config.configFilePath).toBe(cfg);
23
+ });
24
+
25
+ test("addRoot then removeRoot is a clean round-trip", async () => {
26
+ await addRoot(cfg, [], "/tmp/alpha");
27
+ const after = await addRoot(cfg, ["/tmp/alpha"], "/tmp/beta");
28
+ expect(after).toEqual(["/tmp/alpha", "/tmp/beta"]);
29
+
30
+ const { roots, removed } = await removeRoot(cfg, "/tmp/alpha");
31
+ expect(removed).toBe(true);
32
+ expect(roots).toEqual(["/tmp/beta"]);
33
+
34
+ const onDisk = JSON.parse(await readFile(cfg, "utf8"));
35
+ expect(onDisk.roots).toEqual(["/tmp/beta"]);
36
+ });
37
+
38
+ test("removeRoot on a non-registered path is idempotent (removed:false)", async () => {
39
+ await addRoot(cfg, [], "/tmp/alpha");
40
+ const { roots, removed } = await removeRoot(cfg, "/tmp/nope");
41
+ expect(removed).toBe(false);
42
+ expect(roots).toEqual(["/tmp/alpha"]);
43
+ });
44
+
45
+ test("removeRoot preserves annotated RootEntry siblings", async () => {
46
+ await Bun.write(
47
+ cfg,
48
+ JSON.stringify({
49
+ roots: [{ path: "/tmp/keep", notes: "important" }, "/tmp/drop"],
50
+ }),
51
+ );
52
+ const { roots, removed } = await removeRoot(cfg, "/tmp/drop");
53
+ expect(removed).toBe(true);
54
+ expect(roots).toEqual(["/tmp/keep"]);
55
+ const onDisk = JSON.parse(await readFile(cfg, "utf8"));
56
+ // annotation on the surviving entry is preserved
57
+ expect(onDisk.roots).toEqual([{ path: "/tmp/keep", notes: "important" }]);
58
+ });
59
+
60
+ test("ctx.removeRoot persists and keeps the live roots view in sync", async () => {
61
+ const ctx = await loadContext({ env: { SKILLSHELF_CONFIG: cfg } as NodeJS.ProcessEnv });
62
+ await ctx.addRoot("/tmp/one");
63
+ await ctx.addRoot("/tmp/two");
64
+ const res = await ctx.removeRoot("/tmp/one");
65
+ expect(res.removed).toBe(true);
66
+ expect(ctx.roots).toEqual(["/tmp/two"]);
67
+ expect(existsSync(cfg)).toBe(true);
68
+ });
69
+ });
package/src/config.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { homedir } from "node:os";
9
9
  import { join, resolve, isAbsolute } from "node:path";
10
10
  import { existsSync } from "node:fs";
11
- import type { Config, ConfigFile, Ctx, Skill } from "./types.ts";
11
+ import type { Config, ConfigFile, Ctx, RootEntry, Skill } from "./types.ts";
12
12
 
13
13
  export const DEFAULT_CONFIG_FILE = join(homedir(), ".skillshelf", "config.json");
14
14
  export const DEFAULT_LIBRARY = join(homedir(), ".skillshelf", "library");
@@ -45,7 +45,15 @@ export async function resolveConfig(opts: {
45
45
  configFilePath?: string;
46
46
  } = {}): Promise<Config> {
47
47
  const env = opts.env ?? process.env;
48
- const configFilePath = opts.configFilePath ?? DEFAULT_CONFIG_FILE;
48
+ // Config-file resolution: explicit opt override (tests) → SKILLSHELF_CONFIG env →
49
+ // default ~/.skillshelf/config.json. The env override lets an experiment redirect
50
+ // ALL persisted state (roots especially) into a sandbox without touching the real
51
+ // config — `skl scan --add-root` otherwise writes the real file regardless of
52
+ // SKILLSHELF_LIBRARY (the isolation gap this closes).
53
+ const envCfg = env.SKILLSHELF_CONFIG;
54
+ const configFilePath =
55
+ opts.configFilePath ??
56
+ (envCfg && envCfg.trim() !== "" ? abs(envCfg.trim()) : DEFAULT_CONFIG_FILE);
49
57
 
50
58
  const fileCfg = await readConfigFile(configFilePath);
51
59
  const usedConfigFile = fileCfg ? configFilePath : null;
@@ -84,14 +92,25 @@ export async function resolveConfig(opts: {
84
92
  };
85
93
  }
86
94
 
87
- /** Expand ~, absolutize, and de-duplicate a list of root paths (order-preserving). */
95
+ /**
96
+ * Expand ~, absolutize, and de-duplicate a list of scan roots (order-preserving).
97
+ * Each entry may be a bare path string OR an annotated {path, layout?, notes?}
98
+ * object (see RootEntry); both normalize to an absolute path string here.
99
+ * layout/notes are informational only and dropped — crawl auto-detects layout and
100
+ * nothing consumes them programmatically.
101
+ */
88
102
  function normalizeRoots(input: unknown): string[] {
89
103
  if (!Array.isArray(input)) return [];
90
104
  const out: string[] = [];
91
105
  const seen = new Set<string>();
92
106
  for (const r of input) {
93
- if (typeof r !== "string" || r.trim() === "") continue;
94
- const a = abs(r.trim());
107
+ let raw: string | null = null;
108
+ if (typeof r === "string") raw = r;
109
+ else if (r && typeof r === "object" && typeof (r as { path?: unknown }).path === "string") {
110
+ raw = (r as { path: string }).path;
111
+ }
112
+ if (raw === null || raw.trim() === "") continue;
113
+ const a = abs(raw.trim());
95
114
  if (seen.has(a)) continue;
96
115
  seen.add(a);
97
116
  out.push(a);
@@ -102,7 +121,10 @@ function normalizeRoots(input: unknown): string[] {
102
121
  /**
103
122
  * Persist a new scan root into the config file (`configFilePath`), expanding ~,
104
123
  * absolutizing, and de-duplicating against existing roots. Preserves the rest of
105
- * the config file (library / globalCore). Returns the full updated roots list.
124
+ * the config file (library / globalCore) AND any annotations on existing root
125
+ * entries ({path, layout?, notes?}): we only APPEND a new bare-path entry when the
126
+ * resolved path is absent. Returns the full updated roots list as resolved
127
+ * absolute path strings (the in-memory Config.roots form).
106
128
  */
107
129
  export async function addRoot(
108
130
  configFilePath: string,
@@ -110,13 +132,53 @@ export async function addRoot(
110
132
  path: string,
111
133
  ): Promise<string[]> {
112
134
  const a = abs(path.trim());
113
- const roots = [...existingRoots];
114
- if (!roots.includes(a)) roots.push(a);
115
135
 
136
+ // Preserve the on-disk roots verbatim (including RootEntry annotations); only
137
+ // append the new path if it is not already present (compared after resolution).
116
138
  const current = (await readConfigFile(configFilePath)) ?? {};
117
- const next: ConfigFile = { ...current, roots };
139
+ const persisted: Array<string | RootEntry> = Array.isArray(current.roots)
140
+ ? [...current.roots]
141
+ : [];
142
+ const resolvedOf = (entry: string | RootEntry): string | null => {
143
+ const raw = typeof entry === "string" ? entry : entry?.path;
144
+ return typeof raw === "string" && raw.trim() !== "" ? abs(raw.trim()) : null;
145
+ };
146
+ if (!persisted.some((e) => resolvedOf(e) === a)) persisted.push(a);
147
+
148
+ const next: ConfigFile = { ...current, roots: persisted };
118
149
  await Bun.write(configFilePath, JSON.stringify(next, null, 2) + "\n");
119
- return roots;
150
+
151
+ // Return the resolved, de-duped absolute path list (Config.roots stays string[]).
152
+ return normalizeRoots(persisted);
153
+ }
154
+
155
+ /**
156
+ * Remove a scan root from the config file (`configFilePath`) — the inverse of
157
+ * addRoot. Matches by resolved absolute path, so the caller may pass a bare/`~`/
158
+ * relative form of an already-persisted root. Preserves the rest of the config and
159
+ * any annotations on the surviving root entries. Returns the updated roots list as
160
+ * resolved absolute path strings, plus whether anything was actually removed.
161
+ */
162
+ export async function removeRoot(
163
+ configFilePath: string,
164
+ path: string,
165
+ ): Promise<{ roots: string[]; removed: boolean }> {
166
+ const target = abs(path.trim());
167
+ const current = (await readConfigFile(configFilePath)) ?? {};
168
+ const persisted: Array<string | RootEntry> = Array.isArray(current.roots)
169
+ ? [...current.roots]
170
+ : [];
171
+ const resolvedOf = (entry: string | RootEntry): string | null => {
172
+ const raw = typeof entry === "string" ? entry : entry?.path;
173
+ return typeof raw === "string" && raw.trim() !== "" ? abs(raw.trim()) : null;
174
+ };
175
+ const kept = persisted.filter((e) => resolvedOf(e) !== target);
176
+ const removed = kept.length !== persisted.length;
177
+ if (removed) {
178
+ const next: ConfigFile = { ...current, roots: kept };
179
+ await Bun.write(configFilePath, JSON.stringify(next, null, 2) + "\n");
180
+ }
181
+ return { roots: normalizeRoots(kept), removed };
120
182
  }
121
183
 
122
184
  /**
@@ -146,6 +208,13 @@ export async function loadContext(opts: {
146
208
  config.roots = roots;
147
209
  return roots;
148
210
  },
211
+ removeRoot: async (path: string): Promise<{ roots: string[]; removed: boolean }> => {
212
+ const res = await removeRoot(config.configFilePath, path);
213
+ roots = res.roots;
214
+ ctx.roots = roots;
215
+ config.roots = roots;
216
+ return res;
217
+ },
149
218
  log: (...args: unknown[]) => {
150
219
  console.log(...args);
151
220
  },