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 +43 -6
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/import.ts +284 -0
- package/src/commands/new.ts +6 -6
- package/src/commands/scan.ts +257 -0
- package/src/config.ts +55 -1
- package/src/core/library.ts +14 -27
- package/src/types.ts +11 -1
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
|
|
65
|
-
|
|
66
|
-
all-at-once token cost.
|
|
67
|
-
- **Domain bundles** —
|
|
68
|
-
`domains: [coding, bioinfo]` shows up in both bundles from a single copy on
|
|
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
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
|
+
}
|
package/src/commands/new.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// skl new <name> [--domain <d>] [--desc "..."] [--force] [--json]
|
|
4
4
|
//
|
|
5
|
-
// Writes <library
|
|
6
|
-
//
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/src/core/library.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
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 */
|