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,58 @@
1
+ // `skl retag <old-domain> <new-domain>` — deterministically rename a domain across
2
+ // the WHOLE library taxonomy (every skill tagged <old> becomes <new>). This is the
3
+ // pure rename the AI `infer` pass can't promise (it re-reasons every tag); retag
4
+ // touches nothing but the named domain. Fixes a domain typo in one pass instead of a
5
+ // hand-edit of taxonomy.json or a silently-failing `sed` loop over frontmatter.
6
+ //
7
+ // skl retag <old-domain> <new-domain> [--json]
8
+
9
+ import type { Ctx } from "../types.ts";
10
+ import { renameDomainAcrossLibrary } from "../core/taxonomy.ts";
11
+ import { reindexLibrary } from "../core/lifecycle.ts";
12
+
13
+ export const meta = {
14
+ name: "retag",
15
+ summary: "Rename a domain across the whole library taxonomy (deterministic)",
16
+ usage: "skl retag <old-domain> <new-domain> [--json]",
17
+ } as const;
18
+
19
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
20
+
21
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
22
+ const json = argv.includes("--json");
23
+ const positional = argv.filter((a) => !a.startsWith("--"));
24
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--json");
25
+ if (unknownFlag) {
26
+ ctx.error(`skl retag: unknown argument: ${unknownFlag}`);
27
+ ctx.error(`usage: ${meta.usage}`);
28
+ return 1;
29
+ }
30
+ const [oldDomain, newDomain] = positional;
31
+ if (!oldDomain || !newDomain) {
32
+ ctx.error("skl retag: an <old-domain> and a <new-domain> are required");
33
+ ctx.error(`usage: ${meta.usage}`);
34
+ return 1;
35
+ }
36
+ if (!SLUG_RE.test(newDomain)) {
37
+ ctx.error(`skl retag: invalid domain "${newDomain}" — use lowercase letters, digits, and hyphens`);
38
+ return 1;
39
+ }
40
+
41
+ try {
42
+ const changed = await renameDomainAcrossLibrary(ctx.libraryPath, oldDomain, newDomain);
43
+ if (changed.length > 0) await reindexLibrary(ctx.libraryPath);
44
+
45
+ if (json) {
46
+ ctx.json({ ok: true, from: oldDomain, to: newDomain, changed });
47
+ } else if (changed.length === 0) {
48
+ ctx.log(`No skills tagged '${oldDomain}' in the taxonomy — nothing to rename.`);
49
+ } else {
50
+ ctx.log(`retagged '${oldDomain}' -> '${newDomain}' across ${changed.length} skill(s):`);
51
+ for (const n of changed) ctx.log(` ${n}`);
52
+ }
53
+ return 0;
54
+ } catch (err) {
55
+ ctx.error(`skl retag: ${err instanceof Error ? err.message : String(err)}`);
56
+ return 1;
57
+ }
58
+ }
@@ -0,0 +1,39 @@
1
+ // `skl retire <name>` — soft-delete a skill into <library>/_retired/<name>/. The
2
+ // read layer already renders retired skills (struck-through in INDEX.md, "(retired)"
3
+ // in `ls --all`, excluded from bundles/deploys) but had no WRITE primitive to enter
4
+ // that state — forcing a hand `mkdir _retired && mv`. This is the reversible first
5
+ // step of the removal lifecycle (retire -> optionally `rm`); undo with `skl unretire`.
6
+ //
7
+ // skl retire <name> [--json]
8
+
9
+ import type { Ctx } from "../types.ts";
10
+ import { retireSkill, reindexLibrary } from "../core/lifecycle.ts";
11
+
12
+ export const meta = {
13
+ name: "retire",
14
+ summary: "Soft-delete a skill into library/_retired/ (reversible; excluded from deploys)",
15
+ usage: "skl retire <name> [--json]",
16
+ } as const;
17
+
18
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
19
+ const json = argv.includes("--json");
20
+ const name = argv.find((a) => !a.startsWith("--"));
21
+ if (!name) {
22
+ ctx.error("skl retire: a <name> is required");
23
+ ctx.error(`usage: ${meta.usage}`);
24
+ return 1;
25
+ }
26
+ try {
27
+ const dest = await retireSkill(ctx.libraryPath, name);
28
+ await reindexLibrary(ctx.libraryPath);
29
+ if (json) ctx.json({ ok: true, name, retiredTo: dest });
30
+ else {
31
+ ctx.log(`retired ${name} -> _retired/${name}`);
32
+ ctx.log(" (excluded from bundles/deploys; restore with `skl unretire`, purge with `skl rm`)");
33
+ }
34
+ return 0;
35
+ } catch (err) {
36
+ ctx.error(`skl retire: ${err instanceof Error ? err.message : String(err)}`);
37
+ return 1;
38
+ }
39
+ }
@@ -0,0 +1,133 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, symlink, rm as fsRm, 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 { run as rmRun } from "./rm.ts";
7
+ import { run as retireRun } from "./retire.ts";
8
+ import { run as unretireRun } from "./unretire.ts";
9
+ import { loadLibrary } from "../core/library.ts";
10
+ import type { Ctx } from "../types.ts";
11
+
12
+ function makeCtx(libraryPath: string) {
13
+ const json: unknown[] = [];
14
+ const errors: string[] = [];
15
+ const ctx = {
16
+ config: { libraryPath },
17
+ libraryPath,
18
+ loadLibrary: () => loadLibrary(libraryPath),
19
+ log: () => {},
20
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
21
+ json: (v: unknown) => json.push(v),
22
+ } as unknown as Ctx;
23
+ return { ctx, json, errors };
24
+ }
25
+
26
+ async function writeSkill(library: string, name: string) {
27
+ const dir = join(library, name);
28
+ await mkdir(dir, { recursive: true });
29
+ await writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${name}\n---\n\nbody\n`);
30
+ return dir;
31
+ }
32
+
33
+ describe("skl rm/retire/unretire — removal lifecycle (friction #1)", () => {
34
+ let tmp: string;
35
+ let library: string;
36
+
37
+ beforeEach(async () => {
38
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-rm-")));
39
+ library = join(tmp, "library");
40
+ await writeSkill(library, "alpha");
41
+ await writeFile(
42
+ join(library, "taxonomy.json"),
43
+ JSON.stringify({ version: 1, skills: { alpha: ["bio"] } }),
44
+ );
45
+ });
46
+ afterEach(async () => {
47
+ await fsRm(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ test("rm refuses a live OWNED skill without --force", async () => {
51
+ const { ctx, errors } = makeCtx(library);
52
+ const code = await rmRun(["alpha"], ctx);
53
+ expect(code).toBe(1);
54
+ expect(errors.join("\n")).toContain("live skill");
55
+ expect(existsSync(join(library, "alpha"))).toBe(true); // untouched
56
+ });
57
+
58
+ test("rm --dry-run previews without deleting", async () => {
59
+ const { ctx, json } = makeCtx(library);
60
+ const code = await rmRun(["alpha", "--force", "--dry-run", "--json"], ctx);
61
+ expect(code).toBe(0);
62
+ expect((json[0] as { dryRun: boolean }).dryRun).toBe(true);
63
+ expect(existsSync(join(library, "alpha"))).toBe(true); // still there
64
+ });
65
+
66
+ test("retire -> rm purges and drops taxonomy", async () => {
67
+ await retireRun(["alpha"], makeCtx(library).ctx);
68
+ expect(existsSync(join(library, "_retired", "alpha"))).toBe(true);
69
+
70
+ const { ctx, json } = makeCtx(library);
71
+ const code = await rmRun(["alpha", "--json"], ctx); // retired -> no --force needed
72
+ expect(code).toBe(0);
73
+ expect((json[0] as { taxonomyDropped: boolean }).taxonomyDropped).toBe(true);
74
+ expect(existsSync(join(library, "_retired", "alpha"))).toBe(false);
75
+ // _retired pruned when empty
76
+ expect(existsSync(join(library, "_retired"))).toBe(false);
77
+ });
78
+
79
+ test("retire then unretire round-trips", async () => {
80
+ await retireRun(["alpha"], makeCtx(library).ctx);
81
+ const { ctx } = makeCtx(library);
82
+ const code = await unretireRun(["alpha"], ctx);
83
+ expect(code).toBe(0);
84
+ expect(existsSync(join(library, "alpha"))).toBe(true);
85
+ expect(existsSync(join(library, "_retired", "alpha"))).toBe(false);
86
+ });
87
+
88
+ test("rm a LINKED entry without --force is a safe unlink (dev repo untouched)", async () => {
89
+ const dev = join(tmp, "dev", "linkedskill");
90
+ await mkdir(dev, { recursive: true });
91
+ await writeFile(join(dev, "SKILL.md"), "---\nname: linkedskill\n---\n\nbody\n");
92
+ await symlink(dev, join(library, "linkedskill"));
93
+
94
+ const { ctx, json } = makeCtx(library);
95
+ const code = await rmRun(["linkedskill", "--json"], ctx);
96
+ expect(code).toBe(0);
97
+ expect((json[0] as { wasLink: boolean }).wasLink).toBe(true);
98
+ expect(existsSync(join(library, "linkedskill"))).toBe(false); // symlink gone
99
+ expect(existsSync(join(dev, "SKILL.md"))).toBe(true); // dev repo intact
100
+ });
101
+
102
+ test("rm a missing skill errors", async () => {
103
+ const { ctx, errors } = makeCtx(library);
104
+ const code = await rmRun(["ghost", "--force"], ctx);
105
+ expect(code).toBe(1);
106
+ expect(errors.join("\n")).toContain("not in the library");
107
+ });
108
+
109
+ test("rm refuses a path-traversal name (no escape outside the library)", async () => {
110
+ // a sibling dir outside the library that must NOT be deletable via `../`
111
+ const victim = join(tmp, "victim");
112
+ await mkdir(victim, { recursive: true });
113
+ await writeFile(join(victim, "keep.txt"), "x");
114
+
115
+ const { ctx, errors } = makeCtx(library);
116
+ const code = await rmRun(["../victim", "--force"], ctx);
117
+ expect(code).toBe(1);
118
+ expect(errors.join("\n")).toContain("path separators");
119
+ expect(existsSync(join(victim, "keep.txt"))).toBe(true); // untouched
120
+ });
121
+
122
+ test("rm of a skill present in BOTH active and _retired still refuses the live copy without --force", async () => {
123
+ // manufacture the twin state (active alpha already exists; add a retired twin)
124
+ await mkdir(join(library, "_retired", "alpha"), { recursive: true });
125
+ await writeFile(join(library, "_retired", "alpha", "SKILL.md"), "---\nname: alpha\n---\n\nold\n");
126
+
127
+ const { ctx, errors } = makeCtx(library);
128
+ const code = await rmRun(["alpha"], ctx); // no --force
129
+ expect(code).toBe(1);
130
+ expect(errors.join("\n")).toContain("live skill");
131
+ expect(existsSync(join(library, "alpha"))).toBe(true); // active copy survives
132
+ });
133
+ });
@@ -0,0 +1,107 @@
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.
4
+ // Before this, the ONLY delete path was an unguarded `rm -rf` against the library —
5
+ // no safety rail for the most destructive lifecycle step.
6
+ //
7
+ // skl rm <name> [--force] [--dry-run] [--json]
8
+ //
9
+ // Safety (mirrors `link --at` refuse-by-default + named escape hatch): a LIVE (active,
10
+ // non-retired) skill is refused without --force — retire it first (reversible) or pass
11
+ // --force to hard-purge. A retired skill is already in the reversible holding area, so
12
+ // it purges without --force. --dry-run previews exactly what would be removed.
13
+
14
+ import type { Ctx } from "../types.ts";
15
+ import { locateEntry, removeSkill, reindexLibrary } from "../core/lifecycle.ts";
16
+ import { readTaxonomy } from "../core/taxonomy.ts";
17
+ import { readLockfile } from "../core/provenance.ts";
18
+
19
+ export const meta = {
20
+ name: "rm",
21
+ summary: "Delete a skill from the library (dir/symlink + taxonomy + lock), re-index",
22
+ usage: "skl rm <name> [--force] [--dry-run] [--json]",
23
+ } as const;
24
+
25
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
26
+ const json = argv.includes("--json");
27
+ const force = argv.includes("--force");
28
+ const dryRun = argv.includes("--dry-run");
29
+ const unknown = argv.find(
30
+ (a) => a.startsWith("--") && !["--json", "--force", "--dry-run"].includes(a),
31
+ );
32
+ if (unknown) {
33
+ ctx.error(`skl rm: unknown argument: ${unknown}`);
34
+ ctx.error(`usage: ${meta.usage}`);
35
+ return 1;
36
+ }
37
+ const name = argv.find((a) => !a.startsWith("--"));
38
+ if (!name) {
39
+ ctx.error("skl rm: a <name> is required");
40
+ ctx.error(`usage: ${meta.usage}`);
41
+ return 1;
42
+ }
43
+
44
+ try {
45
+ const loc = locateEntry(ctx.libraryPath, name);
46
+ if (!loc.path) {
47
+ ctx.error(`skl rm: '${name}' is not in the library`);
48
+ return 1;
49
+ }
50
+ // Refuse only a live OWNED skill unless forced — that destroys real bytes. A
51
+ // LINKED entry is just a symlink: removing it loses nothing (the dev repo stays
52
+ // canonical), so `skl rm <linked>` is the safe `unlink` and needs no --force. A
53
+ // purely-retired skill is already in the reversible holding area, so it purges
54
+ // freely. NOTE: gate on `loc.active` itself (the resolved path IS the active copy
55
+ // when active), NOT on the absence of a retired twin — a skill present in BOTH
56
+ // active and _retired still resolves `path` to the active copy, so a `!loc.retired`
57
+ // term would wrongly drop the guard and delete the live copy without --force.
58
+ if (loc.active && !loc.isLink && !force) {
59
+ if (json) {
60
+ ctx.json({ ok: false, name, refused: true, reason: "live-owned-needs-force" });
61
+ } else {
62
+ ctx.error(`skl rm: '${name}' is a live skill — this destroys real bytes.`);
63
+ ctx.error("Retire it first (reversible): skl retire " + name);
64
+ ctx.error("Or hard-purge now: skl rm " + name + " --force");
65
+ }
66
+ return 1;
67
+ }
68
+
69
+ // Preview what would be dropped (for --dry-run and richer reporting).
70
+ const tax = await readTaxonomy(ctx.libraryPath);
71
+ const lock = await readLockfile(ctx.libraryPath);
72
+ const plan = {
73
+ name,
74
+ path: loc.path,
75
+ mode: loc.isLink ? ("linked" as const) : ("owned" as const),
76
+ wasRetired: loc.retired && !loc.active,
77
+ taxonomyEntry: name in tax.skills,
78
+ lockEntry: name in lock.entries,
79
+ };
80
+
81
+ if (dryRun) {
82
+ if (json) ctx.json({ ok: true, dryRun: true, plan });
83
+ else {
84
+ ctx.log(`DRY RUN — would remove '${name}':`);
85
+ ctx.log(` path: ${plan.path}${plan.mode === "linked" ? " (symlink only — dev repo untouched)" : ""}`);
86
+ ctx.log(` taxonomy: ${plan.taxonomyEntry ? "drop entry" : "none"}`);
87
+ ctx.log(` lockfile: ${plan.lockEntry ? "drop entry" : "none"}`);
88
+ ctx.log("Re-run without --dry-run to apply.");
89
+ }
90
+ return 0;
91
+ }
92
+
93
+ const result = await removeSkill(ctx.libraryPath, name);
94
+ await reindexLibrary(ctx.libraryPath);
95
+ if (json) {
96
+ ctx.json({ ok: true, ...result });
97
+ } else {
98
+ ctx.log(`removed ${name}${result.wasLink ? " (symlink only — dev repo untouched)" : ""}`);
99
+ if (result.taxonomyDropped) ctx.log(" dropped taxonomy entry");
100
+ if (result.lockDropped) ctx.log(" dropped lockfile entry");
101
+ }
102
+ return 0;
103
+ } catch (err) {
104
+ ctx.error(`skl rm: ${err instanceof Error ? err.message : String(err)}`);
105
+ return 1;
106
+ }
107
+ }
@@ -0,0 +1,41 @@
1
+ // `skl roots` — list the persisted scan roots. A pure read of config.json, with NO
2
+ // disk crawl (unlike `skl scan`, which crawls every root). Lets a human or agent see
3
+ // the registered roots cheaply, and emit a minimal {"roots":[...]} for scripting
4
+ // without piping a heavy `scan --json` through jq.
5
+ //
6
+ // skl roots [--json]
7
+ //
8
+ // Mutate the set with `skl scan --add-root <path>` / `skl scan --remove-root <path>`.
9
+
10
+ import type { Ctx } from "../types.ts";
11
+
12
+ export const meta = {
13
+ name: "roots",
14
+ summary: "List the persisted scan roots (read-only; no crawl)",
15
+ usage: "skl roots [--json]",
16
+ } as const;
17
+
18
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
19
+ const json = argv.includes("--json");
20
+ const unknown = argv.find((a) => a.startsWith("-") && a !== "--json");
21
+ if (unknown) {
22
+ ctx.error(`skl roots: unknown argument: ${unknown}`);
23
+ ctx.error(`usage: ${meta.usage}`);
24
+ return 1;
25
+ }
26
+
27
+ const roots = ctx.roots;
28
+ if (json) {
29
+ ctx.json({ roots });
30
+ return 0;
31
+ }
32
+
33
+ if (roots.length === 0) {
34
+ ctx.log("No scan roots configured.");
35
+ ctx.log("Add one with: skl scan --add-root <path>");
36
+ return 0;
37
+ }
38
+ ctx.log(`Scan roots (${roots.length}):`);
39
+ for (const r of roots) ctx.log(` ${r}`);
40
+ return 0;
41
+ }
@@ -1,8 +1,10 @@
1
1
  // `skl scan [roots…]` — read-only discovery pass over scan roots.
2
2
  //
3
3
  // Crawls each root (see core/crawl.ts) and reports:
4
- // - per-root candidate counts and a total
5
- // - every candidate (a discovered skill — see CONTEXT.md) with its location
4
+ // - per-root candidate counts and a total, split into new vs already-in-library
5
+ // - every candidate (a discovered skill — see CONTEXT.md) with its location and
6
+ // an `imported` flag (already managed by the library, or symlinks into it)
7
+ // - the actionable list of NEW candidates (not yet imported) to feed `skl import`
6
8
  // - duplicate / drift groups (via core/dedupe.ts) with their locations and a
7
9
  // recommendation for the human/agent to act on
8
10
  //
@@ -21,6 +23,7 @@ import {
21
23
  findDuplicates,
22
24
  driftedGroups,
23
25
  exactDuplicateGroups,
26
+ genuineConflictGroups,
24
27
  } from "../core/dedupe.ts";
25
28
  import { realpathOrSelf } from "../lib/fs.ts";
26
29
 
@@ -28,17 +31,18 @@ export const meta = {
28
31
  name: "scan",
29
32
  summary: "Read-only discovery of skill candidates across roots (counts, duplicates, drift)",
30
33
  usage:
31
- "skl scan [roots…] [--add-root <path>] [--json]",
34
+ "skl scan [roots…] [--add-root <path>] [--remove-root <path>] [--json]",
32
35
  } as const;
33
36
 
34
37
  interface Args {
35
38
  roots: string[];
36
39
  addRoot: string | null;
40
+ removeRoot: string | null;
37
41
  json: boolean;
38
42
  }
39
43
 
40
44
  function parseArgs(argv: string[]): { args: Args } | { error: string } {
41
- const args: Args = { roots: [], addRoot: null, json: false };
45
+ const args: Args = { roots: [], addRoot: null, removeRoot: null, json: false };
42
46
  for (let i = 0; i < argv.length; i++) {
43
47
  const a = argv[i]!;
44
48
  if (a === "--json") {
@@ -50,29 +54,36 @@ function parseArgs(argv: string[]): { args: Args } | { error: string } {
50
54
  } else if (a.startsWith("--add-root=")) {
51
55
  args.addRoot = a.slice("--add-root=".length);
52
56
  if (args.addRoot === "") return { error: "--add-root requires a <path>" };
57
+ } else if (a === "--remove-root" || a === "--rm-root") {
58
+ const p = argv[++i];
59
+ if (!p) return { error: "--remove-root requires a <path>" };
60
+ args.removeRoot = p;
61
+ } else if (a.startsWith("--remove-root=")) {
62
+ args.removeRoot = a.slice("--remove-root=".length);
63
+ if (args.removeRoot === "") return { error: "--remove-root requires a <path>" };
64
+ } else if (a.startsWith("--rm-root=")) {
65
+ args.removeRoot = a.slice("--rm-root=".length);
66
+ if (args.removeRoot === "") return { error: "--remove-root requires a <path>" };
53
67
  } else if (a.startsWith("--")) {
54
68
  return { error: `unknown argument: ${a}` };
55
69
  } else {
56
70
  args.roots.push(a);
57
71
  }
58
72
  }
73
+ if (args.addRoot != null && args.removeRoot != null) {
74
+ return { error: "--add-root and --remove-root are mutually exclusive" };
75
+ }
59
76
  return { args };
60
77
  }
61
78
 
62
- /** True if a discovered skill dir lives under `root` (by realpath prefix). */
63
- function underRoot(skillPath: string, root: string): boolean {
64
- const r = realpathOrSelf(root);
65
- const p = realpathOrSelf(skillPath);
66
- if (p === r) return true;
67
- return p.startsWith(r.endsWith(sep) ? r : r + sep);
68
- }
69
-
70
- /** Attribute a discovered skill to the first matching scan root (else null). */
71
- function rootOf(skill: Skill, roots: string[]): string | null {
72
- for (const root of roots) {
73
- if (underRoot(skill.path, root)) return root;
74
- }
75
- return null;
79
+ /**
80
+ * Attribute a discovered skill to the scan root it was crawled under. crawl records
81
+ * this at discovery time (`Skill.discoveredRoot`); we must NOT re-derive it via
82
+ * realpath, because a skill reached through a symlink resolves OUTSIDE its declared
83
+ * root and would be mis-attributed to no root (the per-root undercount bug).
84
+ */
85
+ function rootOf(skill: Skill): string | null {
86
+ return skill.discoveredRoot ?? null;
76
87
  }
77
88
 
78
89
  interface CandidateView {
@@ -82,16 +93,34 @@ interface CandidateView {
82
93
  root: string | null;
83
94
  retired: boolean;
84
95
  mirror: boolean;
96
+ /** true if this candidate already lives in (or symlinks into) the library */
97
+ imported: boolean;
85
98
  }
86
99
 
87
- function toCandidate(s: Skill, roots: string[]): CandidateView {
100
+ function toCandidate(s: Skill, imported: boolean): CandidateView {
88
101
  return {
89
102
  name: s.name,
90
103
  description: s.description,
91
104
  path: s.path,
92
- root: rootOf(s, roots),
105
+ root: rootOf(s),
93
106
  retired: s.retired,
94
107
  mirror: s.mirrorOf != null,
108
+ imported,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Decide whether a discovered candidate is already managed by the library.
114
+ * A candidate counts as imported if a library skill shares its name (the usual
115
+ * case after `skl import`, including the symlink-back left at the old path) OR
116
+ * its real path resolves inside the library tree (a symlink pointing into it).
117
+ */
118
+ function makeIsImported(libNames: Set<string>, libraryRealPath: string) {
119
+ const prefix = libraryRealPath.endsWith(sep) ? libraryRealPath : libraryRealPath + sep;
120
+ return (s: Skill): boolean => {
121
+ if (libNames.has(s.name)) return true;
122
+ const real = realpathOrSelf(s.path);
123
+ return real === libraryRealPath || real.startsWith(prefix);
95
124
  };
96
125
  }
97
126
 
@@ -170,6 +199,20 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
170
199
  return 0;
171
200
  }
172
201
 
202
+ // --remove-root: de-register (inverse of --add-root), then report. Idempotent —
203
+ // a not-registered path reports removed:false and is not an error.
204
+ if (args.removeRoot != null) {
205
+ const { roots, removed } = await ctx.removeRoot(args.removeRoot);
206
+ if (args.json) {
207
+ ctx.json({ removed, target: args.removeRoot, roots });
208
+ return 0;
209
+ }
210
+ ctx.log(removed ? `Removed root: ${args.removeRoot}` : `Not a registered root: ${args.removeRoot}`);
211
+ ctx.log(`Roots (${roots.length}):`);
212
+ for (const r of roots) ctx.log(` ${r}`);
213
+ return 0;
214
+ }
215
+
173
216
  // Roots: explicit args win; else fall back to configured roots.
174
217
  const roots = args.roots.length > 0 ? args.roots : ctx.roots;
175
218
  if (roots.length === 0) {
@@ -180,17 +223,45 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
180
223
  // need every copy in one set.
181
224
  const { skills, dedupedRoots } = await crawl(roots);
182
225
 
226
+ // Which candidates are already in the library? Compare against the live
227
+ // library so scan can flag what's NEW (worth importing) vs already managed.
228
+ const lib = await ctx.loadLibrary();
229
+ const isImported = makeIsImported(
230
+ new Set(lib.map((s) => s.name)),
231
+ realpathOrSelf(ctx.libraryPath),
232
+ );
233
+
183
234
  // Per-root counts (a candidate is a discovered skill; mirrors counted too so
184
- // the count matches what's physically on disk under each root).
235
+ // the count matches what's physically on disk under each root). `new` =
236
+ // candidates not yet in the library.
185
237
  const perRoot = new Map<string, number>();
186
- for (const r of roots) perRoot.set(r, 0);
238
+ const perRootNew = new Map<string, number>();
239
+ for (const r of roots) {
240
+ perRoot.set(r, 0);
241
+ perRootNew.set(r, 0);
242
+ }
187
243
  for (const s of skills) {
188
- const r = rootOf(s, roots);
189
- if (r != null) perRoot.set(r, (perRoot.get(r) ?? 0) + 1);
244
+ const r = rootOf(s);
245
+ if (r != null) {
246
+ perRoot.set(r, (perRoot.get(r) ?? 0) + 1);
247
+ if (!isImported(s)) perRootNew.set(r, (perRootNew.get(r) ?? 0) + 1);
248
+ }
190
249
  }
191
250
 
192
- const candidates = skills.map((s) => toCandidate(s, roots));
193
- const allGroups = findDuplicates(skills);
251
+ const candidates = skills.map((s) => toCandidate(s, isImported(s)));
252
+ // New candidates worth importing: not yet in the library, de-duped by name
253
+ // (drop bridge mirrors and aliased copies), sorted for a stable report.
254
+ const newByName = new Map<string, CandidateView>();
255
+ for (const c of candidates) {
256
+ if (c.imported || c.mirror) continue;
257
+ if (!newByName.has(c.name)) newByName.set(c.name, c);
258
+ }
259
+ const newCandidates = [...newByName.values()].sort((a, b) =>
260
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
261
+ );
262
+ // Surface only genuine conflicts: faithful `.agents`/`.claude` bridge mirrors are
263
+ // a known, intended relationship, not a decision the user has to resolve.
264
+ const allGroups = genuineConflictGroups(findDuplicates(skills));
194
265
  const drifted = driftedGroups(allGroups);
195
266
  const exact = exactDuplicateGroups(allGroups);
196
267
  const reported = [...drifted, ...exact].sort((a, b) =>
@@ -204,13 +275,19 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
204
275
  totals: {
205
276
  roots: roots.length,
206
277
  candidates: candidates.length,
278
+ new: newCandidates.length,
207
279
  duplicateGroups: groupViews.length,
208
280
  driftGroups: drifted.length,
209
281
  exactDuplicateGroups: exact.length,
210
282
  },
211
- perRoot: roots.map((r) => ({ root: r, candidates: perRoot.get(r) ?? 0 })),
283
+ perRoot: roots.map((r) => ({
284
+ root: r,
285
+ candidates: perRoot.get(r) ?? 0,
286
+ new: perRootNew.get(r) ?? 0,
287
+ })),
212
288
  dedupedRoots,
213
289
  candidates,
290
+ newCandidates,
214
291
  duplicateGroups: groupViews,
215
292
  });
216
293
  return 0;
@@ -219,16 +296,31 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
219
296
  // --- Human report ----------------------------------------------------
220
297
  ctx.log(`Scanned ${roots.length} root${roots.length === 1 ? "" : "s"}:`);
221
298
  for (const r of roots) {
222
- ctx.log(` ${r} ${perRoot.get(r) ?? 0} candidate${(perRoot.get(r) ?? 0) === 1 ? "" : "s"}`);
299
+ const c = perRoot.get(r) ?? 0;
300
+ const n = perRootNew.get(r) ?? 0;
301
+ const breakdown = c === 0 ? "" : n === 0 ? " (all in library)" : ` (${n} new, ${c - n} in library)`;
302
+ ctx.log(` ${r} — ${c} candidate${c === 1 ? "" : "s"}${breakdown}`);
223
303
  }
224
304
  if (dedupedRoots.length) {
225
305
  ctx.log(` (skipped ${dedupedRoots.length} aliased root${dedupedRoots.length === 1 ? "" : "s"}: ${dedupedRoots.join(", ")})`);
226
306
  }
227
307
  ctx.log("");
228
- ctx.log(`Total candidates: ${candidates.length}`);
308
+ ctx.log(`Total candidates: ${candidates.length} (${newCandidates.length} new, not yet in library)`);
309
+
310
+ // New candidates: the actionable list — what you'd `skl import` next.
311
+ if (newCandidates.length > 0) {
312
+ ctx.log("");
313
+ ctx.log(`New (not in library) — ${newCandidates.length}:`);
314
+ for (const c of newCandidates) {
315
+ ctx.log(` ${c.name}${c.retired ? " [retired]" : ""}`);
316
+ ctx.log(` ${c.path}`);
317
+ }
318
+ ctx.log("");
319
+ ctx.log("Import one with: skl import <name> --from <path>");
320
+ }
229
321
 
230
322
  if (groupViews.length === 0) {
231
- ctx.log("No duplicates or drift detected.");
323
+ if (newCandidates.length === 0) ctx.log("No duplicates or drift detected.");
232
324
  return 0;
233
325
  }
234
326
 
@@ -4,7 +4,7 @@
4
4
  // progressive disclosure: cheap by default, deep on demand.
5
5
 
6
6
  import type { Ctx, Skill } from "../types.ts";
7
- import { findByName } from "../core/library.ts";
7
+ import { findByName, entryModeInfo } from "../core/library.ts";
8
8
  import { parseFrontmatter } from "../lib/frontmatter.ts";
9
9
 
10
10
  export const meta = {
@@ -40,6 +40,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
40
40
  const body = await bodyOf(skill);
41
41
 
42
42
  if (json) {
43
+ const { mode, linkTarget } = entryModeInfo(ctx.libraryPath, skill.name);
43
44
  ctx.json({
44
45
  name: skill.name,
45
46
  description: skill.description,
@@ -51,6 +52,8 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
51
52
  refFiles: skill.refFiles,
52
53
  retired: skill.retired,
53
54
  source: skill.source,
55
+ mode,
56
+ linkTarget,
54
57
  });
55
58
  return 0;
56
59
  }