skillshelf 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,16 +57,51 @@ skl drop bioinfo # unlink when you're done
57
57
  Add `--json` to any command for machine-readable output (skillshelf is built to be driven
58
58
  by an agent as well as a human).
59
59
 
60
+ ## Migrating scattered skills
61
+
62
+ Already have skills strewn across `~/.claude/skills`, an Obsidian vault, and a dozen project
63
+ `.claude` dirs? Consolidate them with three deterministic primitives — `scan` discovers,
64
+ `import` adopts, `infer` tags. The judgment in between (which copies to keep, which drift wins)
65
+ is yours (or your agent's); the tool never guesses.
66
+
67
+ ```bash
68
+ # 1. Register the places your skills live, then take a read-only inventory.
69
+ # scan moves nothing — it just reports candidates, duplicates, and drift.
70
+ skl scan --add-root ~/.claude/skills
71
+ skl scan --add-root ~/notes/.agents/skills
72
+ skl scan # report every candidate + duplicate/drift group
73
+
74
+ # 2. Adopt the ones you want, one at a time. Each import moves the skill into the
75
+ # library and leaves a symlink behind so old paths keep resolving.
76
+ skl import rnaseq-qc --from ~/.claude/skills/rnaseq-qc
77
+ skl import xhs-title --from ~/notes/.agents/skills/xhs-title
78
+
79
+ # For a skill living inside a project repo, copy instead of move (no symlink left behind):
80
+ skl import deploy-check --from ~/projects/web/.claude/skills/deploy-check --copy
81
+
82
+ # When two copies drifted and you've picked the winner, overwrite the loser:
83
+ skl import rnaseq-qc --from ~/projects/lab/.claude/skills/rnaseq-qc --force
84
+
85
+ # 3. Tag the now-populated library in one pass. Domain is tags, not folders, so this
86
+ # runs AFTER import with no reorg — no skill ever has to move because a tag changed.
87
+ skl infer --emit # hand the payload to your agent, then `skl infer --apply`
88
+ ```
89
+
90
+ Domain lives entirely in tags ([ADR-0001](./docs/adr/0001-domain-is-tags-not-folders.md)): the
91
+ library layout is flat (`library/<name>/`) and `import` never decides a domain, so there is no
92
+ chicken-and-egg between adopting a skill and tagging it.
93
+
60
94
  ## How it works
61
95
 
62
96
  skillshelf separates *owning* a skill from *loading* it.
63
97
 
64
- - **Canonical library** — a dedicated git repo, one file per skill in its primary-domain
65
- folder. This is a passive shelf: nothing here auto-loads, which is exactly what kills the
66
- all-at-once token cost.
67
- - **Domain bundles** — bundles are *tag queries*, not folders. A skill tagged
68
- `domains: [coding, bioinfo]` shows up in both bundles from a single copy on disk.
69
- `skl use bioinfo` resolves every skill carrying that tag.
98
+ - **Canonical library** — a dedicated git repo, one copy per skill in a flat, non-semantic
99
+ layout (`library/<name>/`). This is a passive shelf: nothing here auto-loads, which is
100
+ exactly what kills the all-at-once token cost.
101
+ - **Domain bundles** — domain is *tags, not folders* ([ADR-0001](./docs/adr/0001-domain-is-tags-not-folders.md)).
102
+ A skill tagged `domains: [coding, bioinfo]` shows up in both bundles from a single copy on
103
+ disk; `skl use bioinfo` resolves every skill carrying that tag. `primaryDomain` is just
104
+ `domains[0]`, never inferred from a folder.
70
105
  - **Thin global core** — a handful of universal skills (commit, search, memory) are
71
106
  symlinked permanently into `~/.claude/skills` so they always auto-trigger. Small, bounded
72
107
  token cost — "some loaded is fine; all-at-once is the problem."
@@ -95,6 +130,8 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
95
130
  | Command | Summary | Key flags |
96
131
  |---|---|---|
97
132
  | `skl init` | Set up `~/.skillshelf` config + library and link the global-core skills | `--force` |
133
+ | `skl scan [roots…]` | Read-only discovery of skill candidates across roots (counts, duplicates, drift) | `--add-root <path>` |
134
+ | `skl import <name> --from <path>` | Adopt your own skill into the library (move + symlink-back, or `--copy`) | `--copy`, `--as <slug>`, `--force` |
98
135
  | `skl new <name>` | Scaffold a new skill dir + SKILL.md into the library | `--domain <d>`, `--desc "..."`, `--force` |
99
136
  | `skl ls [bundle]` | One-line listing of the library, or one bundle | `--all` |
100
137
  | `skl search <kw...>` | Fuzzy match over name + description + domains across the library | — |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -27,6 +27,8 @@ import * as outdated from "./commands/outdated.ts";
27
27
  import * as update from "./commands/update.ts";
28
28
  import * as infer from "./commands/infer.ts";
29
29
  import * as newCmd from "./commands/new.ts";
30
+ import * as scan from "./commands/scan.ts";
31
+ import * as importCmd from "./commands/import.ts";
30
32
 
31
33
  // Registration order = display order in help.
32
34
  const MODULES: CommandModule[] = [
@@ -37,6 +39,8 @@ const MODULES: CommandModule[] = [
37
39
  use,
38
40
  drop,
39
41
  add,
42
+ scan,
43
+ importCmd,
40
44
  outdated,
41
45
  update,
42
46
  init,
@@ -0,0 +1,284 @@
1
+ // `skl import <name> --from <path>` — turn ONE candidate (a skill discovered in
2
+ // an external root) into a managed library skill.
3
+ //
4
+ // skl import <name> --from <path> [--copy] [--as <slug>] [--force] [--json]
5
+ //
6
+ // Flat layout (ADR-0001): the skill always lands at <library>/<name>/. No domain
7
+ // is decided here — import is a thin, deterministic primitive (move + symlink-back).
8
+ // Tagging happens AFTER, via `skl infer`.
9
+ //
10
+ // Default behavior = MOVE the candidate dir into the library, then leave a SYMLINK
11
+ // at the original <path> pointing at the library copy, so old paths still resolve
12
+ // (e.g. ~/.claude/skills/<name> keeps working).
13
+ //
14
+ // --copy copy instead of move; leave the original untouched (project repos)
15
+ // --no-link-back move WITHOUT leaving a symlink — the original location is emptied.
16
+ // Use to THIN a root (e.g. drop a skill out of ~/.claude/skills so it
17
+ // stops auto-loading; reach it on demand via `skl use`). Implies move.
18
+ // --as <slug> import under a different library name than <name>
19
+ // --force overwrite an existing same-named library skill
20
+ //
21
+ // Provenance: these are the user's OWN skills, not third-party — source is null and
22
+ // NO lockfile entry is written (that is `add`'s job). We still create an EMPTY
23
+ // overlay (<name>.shelf.json) so taxonomy can be applied later without clobbering
24
+ // the upstream SKILL.md.
25
+
26
+ import { join, basename, resolve } from "node:path";
27
+ import { existsSync } from "node:fs";
28
+ import { rename, cp, rm } from "node:fs/promises";
29
+ import type { Ctx, Skill } from "../types.ts";
30
+ import { writeOverlay } from "../core/overlay.ts";
31
+ import {
32
+ ensureDir,
33
+ safeSymlink,
34
+ isDirectory,
35
+ realpathOrSelfAsync,
36
+ } from "../lib/fs.ts";
37
+
38
+ export const meta = {
39
+ name: "import",
40
+ summary: "Adopt your own skill into the library (move + symlink-back, or --copy)",
41
+ usage: "skl import <name> --from <path> [--copy | --no-link-back] [--as <slug>] [--force] [--json]",
42
+ } as const;
43
+
44
+ interface Flags {
45
+ name: string | null;
46
+ from: string | null;
47
+ as: string | null;
48
+ copy: boolean;
49
+ noLinkBack: boolean;
50
+ force: boolean;
51
+ json: boolean;
52
+ }
53
+
54
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
55
+
56
+ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
57
+ const flags: Flags = {
58
+ name: null,
59
+ from: null,
60
+ as: null,
61
+ copy: false,
62
+ noLinkBack: false,
63
+ force: false,
64
+ json: false,
65
+ };
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i]!;
68
+ if (a === "--from") {
69
+ const v = argv[++i];
70
+ if (v === undefined) return { error: "--from requires a value" };
71
+ flags.from = v;
72
+ } else if (a.startsWith("--from=")) {
73
+ flags.from = a.slice("--from=".length);
74
+ } else if (a === "--as") {
75
+ const v = argv[++i];
76
+ if (v === undefined) return { error: "--as requires a value" };
77
+ flags.as = v;
78
+ } else if (a.startsWith("--as=")) {
79
+ flags.as = a.slice("--as=".length);
80
+ } else if (a === "--copy") {
81
+ flags.copy = true;
82
+ } else if (a === "--no-link-back" || a === "--no-link") {
83
+ flags.noLinkBack = true;
84
+ } else if (a === "--force") {
85
+ flags.force = true;
86
+ } else if (a === "--json") {
87
+ flags.json = true;
88
+ } else if (a.startsWith("--")) {
89
+ return { error: `unknown argument: ${a}` };
90
+ } else if (flags.name === null) {
91
+ flags.name = a;
92
+ } else {
93
+ return { error: `unexpected argument: ${a}` };
94
+ }
95
+ }
96
+ return { flags };
97
+ }
98
+
99
+ /**
100
+ * Move srcDir -> destDir. Prefers an atomic rename; falls back to copy+remove on
101
+ * EXDEV (cross-device, e.g. when the library lives on a different mount than the
102
+ * candidate). destDir's parent must already exist.
103
+ */
104
+ async function moveDir(srcDir: string, destDir: string): Promise<void> {
105
+ try {
106
+ await rename(srcDir, destDir);
107
+ } catch (err) {
108
+ const code = (err as { code?: string }).code;
109
+ if (code !== "EXDEV") throw err;
110
+ await cp(srcDir, destDir, {
111
+ recursive: true,
112
+ force: true,
113
+ filter: (s: string) => basename(s) !== ".git",
114
+ });
115
+ await rm(srcDir, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
120
+ const parsed = parseFlags(argv);
121
+ if ("error" in parsed) {
122
+ ctx.error(`skl import: ${parsed.error}`);
123
+ ctx.error(`usage: ${meta.usage}`);
124
+ return 1;
125
+ }
126
+ const flags = parsed.flags;
127
+
128
+ if (!flags.name || flags.name.trim() === "") {
129
+ ctx.error("skl import: a <name> is required");
130
+ ctx.error(`usage: ${meta.usage}`);
131
+ return 1;
132
+ }
133
+ if (!flags.from || flags.from.trim() === "") {
134
+ ctx.error("skl import: --from <path> is required");
135
+ ctx.error(`usage: ${meta.usage}`);
136
+ return 1;
137
+ }
138
+ if (flags.copy && flags.noLinkBack) {
139
+ ctx.error(
140
+ "skl import: --copy and --no-link-back are mutually exclusive (--copy keeps the original; --no-link-back removes it)",
141
+ );
142
+ return 1;
143
+ }
144
+
145
+ // The library name is --as if given, else <name>.
146
+ const targetName = (flags.as ?? flags.name).trim();
147
+ if (!SLUG_RE.test(targetName)) {
148
+ ctx.error(
149
+ `skl import: invalid skill name "${targetName}" — use lowercase letters, digits, and hyphens (e.g. my-skill)`,
150
+ );
151
+ return 1;
152
+ }
153
+
154
+ // Resolve the candidate path (absolute). Do NOT realpath it: if it is already a
155
+ // symlink we want to operate on the link location the user pointed at.
156
+ const fromPath = resolve(flags.from.trim());
157
+
158
+ try {
159
+ if (!existsSync(fromPath)) {
160
+ ctx.error(`skl import: --from path does not exist: ${fromPath}`);
161
+ return 1;
162
+ }
163
+ if (!(await isDirectory(fromPath))) {
164
+ ctx.error(`skl import: --from must be a skill directory: ${fromPath}`);
165
+ return 1;
166
+ }
167
+ if (!existsSync(join(fromPath, "SKILL.md"))) {
168
+ ctx.error(
169
+ `skl import: no SKILL.md found in ${fromPath} — not a skill directory`,
170
+ );
171
+ return 1;
172
+ }
173
+
174
+ const libraryPath = ctx.config.libraryPath;
175
+ // Flat, non-semantic layout (ADR-0001): always <library>/<name>/.
176
+ const destDir = join(libraryPath, targetName);
177
+
178
+ // Idempotency guard: refuse to clobber an existing library skill unless --force
179
+ // (or the user re-aimed with --as). This protects a managed copy from a stray
180
+ // re-import.
181
+ if (existsSync(destDir) && !flags.force) {
182
+ ctx.error(
183
+ `skl import: ${targetName} already exists at ${destDir} — pass --force to overwrite, or --as <slug> to import under another name`,
184
+ );
185
+ return 1;
186
+ }
187
+
188
+ // If --from already IS the library copy (a re-run pointing at the symlink, or
189
+ // the dir itself), there is nothing to move. Detect by realpath equality.
190
+ const fromReal = await realpathOrSelfAsync(fromPath);
191
+ const destReal = existsSync(destDir) ? await realpathOrSelfAsync(destDir) : destDir;
192
+ if (fromReal === destReal) {
193
+ ctx.error(
194
+ `skl import: ${fromPath} already resolves to the library copy at ${destDir} — nothing to import`,
195
+ );
196
+ return 1;
197
+ }
198
+
199
+ await ensureDir(libraryPath);
200
+ if (existsSync(destDir)) {
201
+ // --force: replace the existing managed copy.
202
+ await rm(destDir, { recursive: true, force: true });
203
+ }
204
+
205
+ const mode: "move" | "copy" = flags.copy ? "copy" : "move";
206
+ let linkedBack = false;
207
+
208
+ if (mode === "copy") {
209
+ // Copy into the library; leave the original untouched (no symlink-back).
210
+ await cp(fromPath, destDir, {
211
+ recursive: true,
212
+ force: true,
213
+ filter: (s: string) => basename(s) !== ".git",
214
+ });
215
+ } else {
216
+ // Move the dir into the library. By default symlink the old location back so
217
+ // existing paths keep resolving; with --no-link-back leave the original empty
218
+ // (thinning a root, e.g. removing a skill from ~/.claude/skills' auto-load).
219
+ await moveDir(fromPath, destDir);
220
+ if (!flags.noLinkBack) {
221
+ await safeSymlink(destDir, fromPath, { force: true });
222
+ linkedBack = true;
223
+
224
+ // Verify the symlink actually resolves to the library copy after the move.
225
+ const linkReal = await realpathOrSelfAsync(fromPath);
226
+ const movedReal = await realpathOrSelfAsync(destDir);
227
+ if (linkReal !== movedReal) {
228
+ ctx.error(
229
+ `skl import: symlink-back verification failed — ${fromPath} resolves to ${linkReal}, expected ${movedReal}`,
230
+ );
231
+ return 1;
232
+ }
233
+ }
234
+ }
235
+
236
+ // Empty overlay so taxonomy (domains/bundles) can be applied later via
237
+ // `skl infer` without touching the upstream SKILL.md. These are the user's own
238
+ // skills: source/provenance is null and NO lockfile entry is written.
239
+ const imported: Skill = {
240
+ name: targetName,
241
+ description: "",
242
+ primaryDomain: null,
243
+ domains: [],
244
+ path: destDir,
245
+ bodyPath: join(destDir, "SKILL.md"),
246
+ refFiles: [],
247
+ source: null,
248
+ retired: false,
249
+ mirrorOf: null,
250
+ contentHash: "",
251
+ };
252
+ const overlayPathStr = join(destDir, `${targetName}.shelf.json`);
253
+ if (!existsSync(overlayPathStr)) {
254
+ await writeOverlay(imported, {});
255
+ }
256
+
257
+ const summary = {
258
+ ok: true,
259
+ name: targetName,
260
+ from: fromPath,
261
+ to: destDir,
262
+ mode,
263
+ linkedBack,
264
+ };
265
+
266
+ if (flags.json) {
267
+ ctx.json(summary);
268
+ } else {
269
+ ctx.log(`imported ${targetName}`);
270
+ ctx.log(` from: ${fromPath}`);
271
+ ctx.log(` to: ${destDir}`);
272
+ ctx.log(` mode: ${mode}`);
273
+ if (linkedBack) ctx.log(` link: ${fromPath} -> ${destDir} (old path still resolves)`);
274
+ else if (mode === "move") ctx.log(` note: original location emptied (no symlink-back) — reach it via \`skl use\``);
275
+ ctx.log("Run `skl infer` to tag it and `skl index` to list it.");
276
+ }
277
+ return 0;
278
+ } catch (err) {
279
+ ctx.error(
280
+ `skl import: failed: ${err instanceof Error ? err.message : String(err)}`,
281
+ );
282
+ return 1;
283
+ }
284
+ }
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // skl new <name> [--domain <d>] [--desc "..."] [--force] [--json]
4
4
  //
5
- // Writes <library>/[<domain>/]<name>/SKILL.md with frontmatter (name,
6
- // description, domains). Refuses to clobber an existing SKILL.md unless --force.
5
+ // Writes <library>/<name>/SKILL.md with frontmatter (name, description, domains).
6
+ // Layout is FLAT (ADR-0001): `--domain` becomes a frontmatter tag, never a folder.
7
+ // Refuses to clobber an existing SKILL.md unless --force.
7
8
 
8
9
  import { join } from "node:path";
9
10
  import { existsSync } from "node:fs";
@@ -114,9 +115,8 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
114
115
 
115
116
  try {
116
117
  const libraryPath = ctx.config.libraryPath;
117
- const skillDir = domain
118
- ? join(libraryPath, domain, name)
119
- : join(libraryPath, name);
118
+ // Flat, non-semantic layout (ADR-0001): always <library>/<name>/.
119
+ const skillDir = join(libraryPath, name);
120
120
  const bodyPath = join(skillDir, "SKILL.md");
121
121
 
122
122
  if (existsSync(bodyPath) && !args.force) {
@@ -133,7 +133,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
133
133
  description: desc || `TODO: describe ${name}.`,
134
134
  };
135
135
  if (domain) {
136
- frontmatterData.primaryDomain = domain;
136
+ // Domain is a tag, not a folder; primaryDomain is derived as domains[0].
137
137
  frontmatterData.domains = [domain];
138
138
  }
139
139
 
@@ -0,0 +1,257 @@
1
+ // `skl scan [roots…]` — read-only discovery pass over scan roots.
2
+ //
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
6
+ // - duplicate / drift groups (via core/dedupe.ts) with their locations and a
7
+ // recommendation for the human/agent to act on
8
+ //
9
+ // Roots come from positional args if given, else ctx.roots (config-persisted).
10
+ // Scan NEVER moves anything and emits NO inference payload — taxonomy is the job
11
+ // of `skl infer`. Use `skl import` to actually consolidate candidates.
12
+ //
13
+ // Flags:
14
+ // --add-root <path> persist a scan root into config.json, then report roots
15
+ // --json emit a structured object instead of the human report
16
+
17
+ import { sep } from "node:path";
18
+ import type { Ctx, Skill, DuplicateGroup } from "../types.ts";
19
+ import { crawl } from "../core/crawl.ts";
20
+ import {
21
+ findDuplicates,
22
+ driftedGroups,
23
+ exactDuplicateGroups,
24
+ } from "../core/dedupe.ts";
25
+ import { realpathOrSelf } from "../lib/fs.ts";
26
+
27
+ export const meta = {
28
+ name: "scan",
29
+ summary: "Read-only discovery of skill candidates across roots (counts, duplicates, drift)",
30
+ usage:
31
+ "skl scan [roots…] [--add-root <path>] [--json]",
32
+ } as const;
33
+
34
+ interface Args {
35
+ roots: string[];
36
+ addRoot: string | null;
37
+ json: boolean;
38
+ }
39
+
40
+ function parseArgs(argv: string[]): { args: Args } | { error: string } {
41
+ const args: Args = { roots: [], addRoot: null, json: false };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i]!;
44
+ if (a === "--json") {
45
+ args.json = true;
46
+ } else if (a === "--add-root") {
47
+ const p = argv[++i];
48
+ if (!p) return { error: "--add-root requires a <path>" };
49
+ args.addRoot = p;
50
+ } else if (a.startsWith("--add-root=")) {
51
+ args.addRoot = a.slice("--add-root=".length);
52
+ if (args.addRoot === "") return { error: "--add-root requires a <path>" };
53
+ } else if (a.startsWith("--")) {
54
+ return { error: `unknown argument: ${a}` };
55
+ } else {
56
+ args.roots.push(a);
57
+ }
58
+ }
59
+ return { args };
60
+ }
61
+
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;
76
+ }
77
+
78
+ interface CandidateView {
79
+ name: string;
80
+ description: string;
81
+ path: string;
82
+ root: string | null;
83
+ retired: boolean;
84
+ mirror: boolean;
85
+ }
86
+
87
+ function toCandidate(s: Skill, roots: string[]): CandidateView {
88
+ return {
89
+ name: s.name,
90
+ description: s.description,
91
+ path: s.path,
92
+ root: rootOf(s, roots),
93
+ retired: s.retired,
94
+ mirror: s.mirrorOf != null,
95
+ };
96
+ }
97
+
98
+ /** Human-readable recommendation for one duplicate/drift group. */
99
+ function recommendationFor(g: DuplicateGroup): string {
100
+ if (g.divergent.length > 0) {
101
+ return `drift — ${g.divergent.length + 1} copies of "${g.name}" differ; review and pick a canonical copy (skillshelf won't choose for you)`;
102
+ }
103
+ return `exact duplicate — ${g.duplicates.length + 1} identical copies of "${g.name}"; import the canonical one and let the others become symlinks`;
104
+ }
105
+
106
+ function groupLocations(g: DuplicateGroup): string[] {
107
+ return [g.canonical, ...g.duplicates, ...g.divergent].map((s) => s.path);
108
+ }
109
+
110
+ interface GroupView {
111
+ name: string;
112
+ kind: "drift" | "duplicate";
113
+ identical: boolean;
114
+ canonical: string;
115
+ duplicates: string[];
116
+ divergent: string[];
117
+ locations: string[];
118
+ recommendation: string;
119
+ }
120
+
121
+ function toGroupView(g: DuplicateGroup): GroupView {
122
+ return {
123
+ name: g.name,
124
+ kind: g.divergent.length > 0 ? "drift" : "duplicate",
125
+ identical: g.identical,
126
+ canonical: g.canonical.path,
127
+ duplicates: g.duplicates.map((s) => s.path),
128
+ divergent: g.divergent.map((s) => s.path),
129
+ locations: groupLocations(g),
130
+ recommendation: recommendationFor(g),
131
+ };
132
+ }
133
+
134
+ /** Report the configured roots when there is nothing to scan. */
135
+ function reportNoRoots(args: Args, roots: string[], ctx: Ctx): number {
136
+ if (args.json) {
137
+ ctx.json({ roots, totals: { roots: roots.length, candidates: 0 }, candidates: [], duplicateGroups: [] });
138
+ return 0;
139
+ }
140
+ if (roots.length === 0) {
141
+ ctx.log("No scan roots configured.");
142
+ ctx.log("Add one with: skl scan --add-root <path>");
143
+ ctx.log("Or scan ad-hoc: skl scan <path> [<path>…]");
144
+ } else {
145
+ ctx.log(`Configured scan roots (${roots.length}):`);
146
+ for (const r of roots) ctx.log(` ${r}`);
147
+ }
148
+ return 0;
149
+ }
150
+
151
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
152
+ const parsed = parseArgs(argv);
153
+ if ("error" in parsed) {
154
+ ctx.error(`skl scan: ${parsed.error}`);
155
+ ctx.error(`usage: ${meta.usage}`);
156
+ return 1;
157
+ }
158
+ const args = parsed.args;
159
+
160
+ try {
161
+ // --add-root: persist, then report the updated roots (no scan side effect).
162
+ if (args.addRoot != null) {
163
+ const roots = await ctx.addRoot(args.addRoot);
164
+ if (args.json) {
165
+ ctx.json({ added: realpathLike(args.addRoot, roots), roots });
166
+ return 0;
167
+ }
168
+ ctx.log(`Roots (${roots.length}):`);
169
+ for (const r of roots) ctx.log(` ${r}`);
170
+ return 0;
171
+ }
172
+
173
+ // Roots: explicit args win; else fall back to configured roots.
174
+ const roots = args.roots.length > 0 ? args.roots : ctx.roots;
175
+ if (roots.length === 0) {
176
+ return reportNoRoots(args, ctx.roots, ctx);
177
+ }
178
+
179
+ // Single combined crawl: realpath-dedupe and cross-root drift detection both
180
+ // need every copy in one set.
181
+ const { skills, dedupedRoots } = await crawl(roots);
182
+
183
+ // 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).
185
+ const perRoot = new Map<string, number>();
186
+ for (const r of roots) perRoot.set(r, 0);
187
+ for (const s of skills) {
188
+ const r = rootOf(s, roots);
189
+ if (r != null) perRoot.set(r, (perRoot.get(r) ?? 0) + 1);
190
+ }
191
+
192
+ const candidates = skills.map((s) => toCandidate(s, roots));
193
+ const allGroups = findDuplicates(skills);
194
+ const drifted = driftedGroups(allGroups);
195
+ const exact = exactDuplicateGroups(allGroups);
196
+ const reported = [...drifted, ...exact].sort((a, b) =>
197
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
198
+ );
199
+ const groupViews = reported.map(toGroupView);
200
+
201
+ if (args.json) {
202
+ ctx.json({
203
+ roots,
204
+ totals: {
205
+ roots: roots.length,
206
+ candidates: candidates.length,
207
+ duplicateGroups: groupViews.length,
208
+ driftGroups: drifted.length,
209
+ exactDuplicateGroups: exact.length,
210
+ },
211
+ perRoot: roots.map((r) => ({ root: r, candidates: perRoot.get(r) ?? 0 })),
212
+ dedupedRoots,
213
+ candidates,
214
+ duplicateGroups: groupViews,
215
+ });
216
+ return 0;
217
+ }
218
+
219
+ // --- Human report ----------------------------------------------------
220
+ ctx.log(`Scanned ${roots.length} root${roots.length === 1 ? "" : "s"}:`);
221
+ for (const r of roots) {
222
+ ctx.log(` ${r} — ${perRoot.get(r) ?? 0} candidate${(perRoot.get(r) ?? 0) === 1 ? "" : "s"}`);
223
+ }
224
+ if (dedupedRoots.length) {
225
+ ctx.log(` (skipped ${dedupedRoots.length} aliased root${dedupedRoots.length === 1 ? "" : "s"}: ${dedupedRoots.join(", ")})`);
226
+ }
227
+ ctx.log("");
228
+ ctx.log(`Total candidates: ${candidates.length}`);
229
+
230
+ if (groupViews.length === 0) {
231
+ ctx.log("No duplicates or drift detected.");
232
+ return 0;
233
+ }
234
+
235
+ ctx.log("");
236
+ ctx.log(`Duplicate / drift groups (${groupViews.length}):`);
237
+ for (const g of groupViews) {
238
+ ctx.log(` ${g.name} [${g.kind}]`);
239
+ for (const loc of g.locations) ctx.log(` - ${loc}`);
240
+ ctx.log(` → ${g.recommendation}`);
241
+ }
242
+ return 0;
243
+ } catch (err) {
244
+ ctx.error(`scan failed: ${(err as Error).message}`);
245
+ return 1;
246
+ }
247
+ }
248
+
249
+ /** Best-effort: report which root in the list corresponds to the added path. */
250
+ function realpathLike(added: string, roots: string[]): string {
251
+ // ctx.addRoot expands/absolutizes; find the matching persisted entry to report.
252
+ const real = realpathOrSelf(added);
253
+ for (const r of roots) {
254
+ if (realpathOrSelf(r) === real) return r;
255
+ }
256
+ return roots[roots.length - 1] ?? added;
257
+ }
package/src/config.ts CHANGED
@@ -72,7 +72,51 @@ export async function resolveConfig(opts: {
72
72
  ? abs(fileCfg.globalCore.trim())
73
73
  : DEFAULT_GLOBAL_CORE;
74
74
 
75
- return { libraryPath, globalCoreTarget, configFile: usedConfigFile, source };
75
+ const roots = normalizeRoots(fileCfg?.roots);
76
+
77
+ return {
78
+ libraryPath,
79
+ globalCoreTarget,
80
+ roots,
81
+ configFile: usedConfigFile,
82
+ configFilePath,
83
+ source,
84
+ };
85
+ }
86
+
87
+ /** Expand ~, absolutize, and de-duplicate a list of root paths (order-preserving). */
88
+ function normalizeRoots(input: unknown): string[] {
89
+ if (!Array.isArray(input)) return [];
90
+ const out: string[] = [];
91
+ const seen = new Set<string>();
92
+ for (const r of input) {
93
+ if (typeof r !== "string" || r.trim() === "") continue;
94
+ const a = abs(r.trim());
95
+ if (seen.has(a)) continue;
96
+ seen.add(a);
97
+ out.push(a);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Persist a new scan root into the config file (`configFilePath`), expanding ~,
104
+ * absolutizing, and de-duplicating against existing roots. Preserves the rest of
105
+ * the config file (library / globalCore). Returns the full updated roots list.
106
+ */
107
+ export async function addRoot(
108
+ configFilePath: string,
109
+ existingRoots: string[],
110
+ path: string,
111
+ ): Promise<string[]> {
112
+ const a = abs(path.trim());
113
+ const roots = [...existingRoots];
114
+ if (!roots.includes(a)) roots.push(a);
115
+
116
+ const current = (await readConfigFile(configFilePath)) ?? {};
117
+ const next: ConfigFile = { ...current, roots };
118
+ await Bun.write(configFilePath, JSON.stringify(next, null, 2) + "\n");
119
+ return roots;
76
120
  }
77
121
 
78
122
  /**
@@ -85,6 +129,8 @@ export async function loadContext(opts: {
85
129
  } = {}): Promise<Ctx> {
86
130
  const config = await resolveConfig(opts);
87
131
 
132
+ let roots = config.roots;
133
+
88
134
  const ctx: Ctx = {
89
135
  config,
90
136
  libraryPath: config.libraryPath,
@@ -92,6 +138,14 @@ export async function loadContext(opts: {
92
138
  const { loadLibrary } = await import("./core/library.ts");
93
139
  return loadLibrary(config.libraryPath);
94
140
  },
141
+ roots,
142
+ addRoot: async (path: string): Promise<string[]> => {
143
+ roots = await addRoot(config.configFilePath, roots, path);
144
+ // keep the live ctx/config views in sync after a persist
145
+ ctx.roots = roots;
146
+ config.roots = roots;
147
+ return roots;
148
+ },
95
149
  log: (...args: unknown[]) => {
96
150
  console.log(...args);
97
151
  },
@@ -2,51 +2,38 @@
2
2
  // effective skills, attach provenance from the lockfile, content-hash, list.
3
3
 
4
4
  import { existsSync } from "node:fs";
5
- import { basename, dirname, sep } from "node:path";
5
+ import { basename, dirname } from "node:path";
6
6
  import type { Skill } from "../types.ts";
7
7
  import { crawl } from "./crawl.ts";
8
8
  import { withOverlay } from "./overlay.ts";
9
9
  import { readLockfile, provenanceForName } from "./provenance.ts";
10
10
 
11
- /**
12
- * Derive a primary-domain hint from a skill dir inside the library: the first
13
- * path segment under the library root is the primary-domain folder.
14
- * <lib>/bioinfo/foo/SKILL.md -> "bioinfo"
15
- */
16
- function libraryPrimaryDomain(libraryRoot: string): (dir: string) => string | null {
17
- const rootParts = libraryRoot.replace(/\/+$/, "").split(sep);
18
- // Structural folders that are not real domains (bridge/layout dirs).
19
- const STRUCTURAL = new Set([".agents", ".claude", "skills", "skill", "_retired"]);
20
- return (dir: string) => {
21
- const parts = dir.split(sep);
22
- // dir is <lib>/<domain>/<name>; domain is the segment right after root.
23
- if (parts.length > rootParts.length + 1) {
24
- const seg = parts[rootParts.length] ?? null;
25
- if (seg && !STRUCTURAL.has(seg)) return seg;
26
- }
27
- // Not a clean domain folder (e.g. a .agents mirror) — let frontmatter win.
28
- return null;
29
- };
30
- }
31
-
32
11
  /**
33
12
  * Load the canonical library at `libraryPath` into effective Skill[]:
34
13
  * crawl + overlay-merge + provenance attach. Returns [] if the path is missing.
14
+ *
15
+ * Library layout is FLAT and non-semantic (`library/<name>/`). Domain membership
16
+ * lives entirely in tags (frontmatter + overlay); `primaryDomain` is the derived
17
+ * view `domains[0]` of the *effective* (overlay-merged) tags, or null if a skill
18
+ * has no domains. See docs/adr/0001-domain-is-tags-not-folders.md.
35
19
  */
36
20
  export async function loadLibrary(libraryPath: string): Promise<Skill[]> {
37
21
  if (!existsSync(libraryPath)) return [];
38
22
 
39
- const { skills } = await crawl([libraryPath], {
40
- primaryDomainOf: libraryPrimaryDomain(libraryPath),
41
- });
23
+ const { skills } = await crawl([libraryPath]);
42
24
 
43
25
  const lock = await readLockfile(libraryPath);
44
26
 
45
27
  const effective: Skill[] = [];
46
28
  for (const s of skills) {
47
29
  const merged = await withOverlay(s);
48
- const prov = provenanceForName(lock, merged.name);
49
- effective.push(prov ? { ...merged, source: prov } : merged);
30
+ // primaryDomain is derived from the EFFECTIVE (post-overlay) tags: domains[0].
31
+ const withPrimary: Skill = {
32
+ ...merged,
33
+ primaryDomain: merged.domains.length > 0 ? merged.domains[0]! : null,
34
+ };
35
+ const prov = provenanceForName(lock, withPrimary.name);
36
+ effective.push(prov ? { ...withPrimary, source: prov } : withPrimary);
50
37
  }
51
38
  // stable ordering: primaryDomain then name
52
39
  effective.sort((a, b) => {
package/src/types.ts CHANGED
@@ -27,7 +27,7 @@ export interface Skill {
27
27
  name: string;
28
28
  /** frontmatter `description` (may be multi-line) */
29
29
  description: string;
30
- /** primary domain folder this skill physically lives under (library), or inferred. null if unknown */
30
+ /** derived view = effective `domains[0]` (post-overlay); null if the skill has no domains. NOT folder-derived (see ADR-0001). */
31
31
  primaryDomain: string | null;
32
32
  /** effective domain tags (primary first), de-duplicated */
33
33
  domains: string[];
@@ -136,8 +136,12 @@ export interface Config {
136
136
  libraryPath: string;
137
137
  /** absolute path to the global-core symlink target (~/.claude/skills) */
138
138
  globalCoreTarget: string;
139
+ /** persisted, absolute, de-duplicated scan roots (`skl scan` searches these) */
140
+ roots: string[];
139
141
  /** absolute path to the config file that was read, if any */
140
142
  configFile: string | null;
143
+ /** absolute path of the config file roots would be persisted to (read or default) */
144
+ configFilePath: string;
141
145
  /** how libraryPath was resolved */
142
146
  source: "env" | "config" | "default";
143
147
  }
@@ -148,6 +152,8 @@ export interface ConfigFile {
148
152
  library?: string;
149
153
  /** override global-core target */
150
154
  globalCore?: string;
155
+ /** persisted scan roots (`skl scan`) */
156
+ roots?: string[];
151
157
  }
152
158
 
153
159
  /**
@@ -161,6 +167,10 @@ export interface Ctx {
161
167
  libraryPath: string;
162
168
  /** load the canonical library (effective skills, overlays merged) */
163
169
  loadLibrary: () => Promise<Skill[]>;
170
+ /** configured scan roots (absolute, de-duplicated); convenience alias for config.roots */
171
+ roots: string[];
172
+ /** add a scan root: expands ~, makes absolute, de-dupes, persists to config.json. Returns the updated roots. */
173
+ addRoot: (path: string) => Promise<string[]>;
164
174
  /** human-readable logging to stdout */
165
175
  log: (...args: unknown[]) => void;
166
176
  /** machine-parseable single-line JSON to stdout */