skillshelf 0.2.0 → 0.4.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 +83 -20
- package/package.json +8 -2
- package/src/adapters/inference/agent.ts +23 -16
- package/src/cli.ts +39 -0
- package/src/commands/add.ts +624 -128
- package/src/commands/adopted.test.ts +144 -0
- package/src/commands/agents-config.test.ts +126 -0
- package/src/commands/agents.test.ts +96 -0
- package/src/commands/agents.ts +243 -0
- package/src/commands/drop.ts +21 -13
- package/src/commands/import.ts +44 -28
- package/src/commands/infer.ts +6 -6
- package/src/commands/link.test.ts +160 -0
- package/src/commands/link.ts +317 -0
- package/src/commands/ls.ts +136 -19
- package/src/commands/migrate.test.ts +157 -0
- package/src/commands/migrate.ts +260 -0
- package/src/commands/mode-surfacing.test.ts +110 -0
- package/src/commands/outdated.test.ts +55 -0
- package/src/commands/outdated.ts +166 -18
- package/src/commands/projects.test.ts +85 -0
- package/src/commands/projects.ts +80 -0
- package/src/commands/refresh.ts +133 -0
- package/src/commands/remediation.test.ts +149 -0
- package/src/commands/rename.test.ts +121 -0
- package/src/commands/rename.ts +64 -0
- package/src/commands/retag.ts +58 -0
- package/src/commands/retire.ts +39 -0
- package/src/commands/rm.test.ts +133 -0
- package/src/commands/rm.ts +107 -0
- package/src/commands/roots.ts +41 -0
- package/src/commands/scan.ts +122 -30
- package/src/commands/show.ts +130 -11
- package/src/commands/status.ts +43 -8
- package/src/commands/tag.test.ts +109 -0
- package/src/commands/tag.ts +68 -0
- package/src/commands/track.test.ts +170 -0
- package/src/commands/track.ts +340 -0
- package/src/commands/unretire.ts +33 -0
- package/src/commands/untag.ts +73 -0
- package/src/commands/untrack.ts +44 -0
- package/src/commands/update.test.ts +71 -0
- package/src/commands/update.ts +157 -15
- package/src/commands/use.test.ts +122 -0
- package/src/commands/use.ts +46 -23
- package/src/commands/where.ts +232 -0
- package/src/config.test.ts +198 -0
- package/src/config.ts +232 -10
- package/src/core/agents.test.ts +319 -0
- package/src/core/agents.ts +438 -0
- package/src/core/bundle.ts +12 -15
- package/src/core/core.test.ts +21 -8
- package/src/core/crawl.ts +22 -5
- package/src/core/dedupe.ts +36 -0
- package/src/core/deployments.test.ts +147 -0
- package/src/core/deployments.ts +208 -0
- package/src/core/fetch.ts +371 -75
- package/src/core/indexgen.ts +2 -0
- package/src/core/library.test.ts +41 -0
- package/src/core/library.ts +61 -16
- package/src/core/lifecycle.ts +252 -0
- package/src/core/surfaces.ts +46 -0
- package/src/core/taxonomy.test.ts +159 -0
- package/src/core/taxonomy.ts +190 -0
- package/src/lib/fs.ts +2 -2
- package/src/types.ts +155 -15
- package/src/core/overlay.ts +0 -63
package/src/commands/import.ts
CHANGED
|
@@ -19,26 +19,26 @@
|
|
|
19
19
|
// --force overwrite an existing same-named library skill
|
|
20
20
|
//
|
|
21
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).
|
|
23
|
-
//
|
|
24
|
-
// the
|
|
22
|
+
// NO lockfile entry is written (that is `add`'s job). Import is purely mechanical
|
|
23
|
+
// (move + symlink-back, or --copy); domain tags are applied later by `skl infer`
|
|
24
|
+
// into the central <library>/taxonomy.json, which never touches the SKILL.md body.
|
|
25
25
|
|
|
26
26
|
import { join, basename, resolve } from "node:path";
|
|
27
27
|
import { existsSync } from "node:fs";
|
|
28
28
|
import { rename, cp, rm } from "node:fs/promises";
|
|
29
|
-
import type { Ctx
|
|
30
|
-
import { writeOverlay } from "../core/overlay.ts";
|
|
29
|
+
import type { Ctx } from "../types.ts";
|
|
31
30
|
import {
|
|
32
31
|
ensureDir,
|
|
33
32
|
safeSymlink,
|
|
34
33
|
isDirectory,
|
|
34
|
+
isSymlink,
|
|
35
35
|
realpathOrSelfAsync,
|
|
36
36
|
} from "../lib/fs.ts";
|
|
37
37
|
|
|
38
38
|
export const meta = {
|
|
39
39
|
name: "import",
|
|
40
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]",
|
|
41
|
+
usage: "skl import <name> --from <path> [--copy | --no-link-back] [--follow] [--as <slug>] [--force] [--json]",
|
|
42
42
|
} as const;
|
|
43
43
|
|
|
44
44
|
interface Flags {
|
|
@@ -47,6 +47,7 @@ interface Flags {
|
|
|
47
47
|
as: string | null;
|
|
48
48
|
copy: boolean;
|
|
49
49
|
noLinkBack: boolean;
|
|
50
|
+
follow: boolean;
|
|
50
51
|
force: boolean;
|
|
51
52
|
json: boolean;
|
|
52
53
|
}
|
|
@@ -60,6 +61,7 @@ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
|
|
|
60
61
|
as: null,
|
|
61
62
|
copy: false,
|
|
62
63
|
noLinkBack: false,
|
|
64
|
+
follow: false,
|
|
63
65
|
force: false,
|
|
64
66
|
json: false,
|
|
65
67
|
};
|
|
@@ -81,6 +83,8 @@ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
|
|
|
81
83
|
flags.copy = true;
|
|
82
84
|
} else if (a === "--no-link-back" || a === "--no-link") {
|
|
83
85
|
flags.noLinkBack = true;
|
|
86
|
+
} else if (a === "--follow" || a === "--deref") {
|
|
87
|
+
flags.follow = true;
|
|
84
88
|
} else if (a === "--force") {
|
|
85
89
|
flags.force = true;
|
|
86
90
|
} else if (a === "--json") {
|
|
@@ -141,6 +145,12 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
141
145
|
);
|
|
142
146
|
return 1;
|
|
143
147
|
}
|
|
148
|
+
if (flags.follow && flags.noLinkBack) {
|
|
149
|
+
ctx.error(
|
|
150
|
+
"skl import: --follow copies the dereferenced target; it cannot be combined with --no-link-back (a move option)",
|
|
151
|
+
);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
144
154
|
|
|
145
155
|
// The library name is --as if given, else <name>.
|
|
146
156
|
const targetName = (flags.as ?? flags.name).trim();
|
|
@@ -171,6 +181,23 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
171
181
|
return 1;
|
|
172
182
|
}
|
|
173
183
|
|
|
184
|
+
// Symlink safety (option b): a symlinked source dir is refused unless --follow.
|
|
185
|
+
// Without it, a move would `rename` the LINK (the library would point back at the
|
|
186
|
+
// target and own no real copy) and a copy would copy the link itself. With
|
|
187
|
+
// --follow we dereference to the real target and COPY its contents (below).
|
|
188
|
+
const linkSource = isSymlink(fromPath);
|
|
189
|
+
if (linkSource && !flags.follow) {
|
|
190
|
+
const tgt = await realpathOrSelfAsync(fromPath);
|
|
191
|
+
ctx.error(`skl import: --from is a symlink (${fromPath} -> ${tgt}).`);
|
|
192
|
+
ctx.error(
|
|
193
|
+
"Refusing to import a symlink source: a move would relocate the link, not the content.",
|
|
194
|
+
);
|
|
195
|
+
ctx.error(
|
|
196
|
+
"Re-run with --follow (alias --deref) to dereference and copy the target's real contents into the library.",
|
|
197
|
+
);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
const libraryPath = ctx.config.libraryPath;
|
|
175
202
|
// Flat, non-semantic layout (ADR-0001): always <library>/<name>/.
|
|
176
203
|
const destDir = join(libraryPath, targetName);
|
|
@@ -202,12 +229,15 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
202
229
|
await rm(destDir, { recursive: true, force: true });
|
|
203
230
|
}
|
|
204
231
|
|
|
205
|
-
|
|
232
|
+
// A symlinked source with --follow: copy the dereferenced TARGET's contents
|
|
233
|
+
// (never move — that would relocate the canonical store the link points at).
|
|
234
|
+
const srcDir = linkSource ? await realpathOrSelfAsync(fromPath) : fromPath;
|
|
235
|
+
const mode: "move" | "copy" = linkSource ? "copy" : flags.copy ? "copy" : "move";
|
|
206
236
|
let linkedBack = false;
|
|
207
237
|
|
|
208
238
|
if (mode === "copy") {
|
|
209
239
|
// Copy into the library; leave the original untouched (no symlink-back).
|
|
210
|
-
await cp(
|
|
240
|
+
await cp(srcDir, destDir, {
|
|
211
241
|
recursive: true,
|
|
212
242
|
force: true,
|
|
213
243
|
filter: (s: string) => basename(s) !== ".git",
|
|
@@ -233,26 +263,10 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
233
263
|
}
|
|
234
264
|
}
|
|
235
265
|
|
|
236
|
-
//
|
|
237
|
-
// `skl infer`
|
|
238
|
-
//
|
|
239
|
-
|
|
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
|
-
}
|
|
266
|
+
// Import is mechanical: no domain is decided here. Domain tags are applied
|
|
267
|
+
// later via `skl infer` into the central <library>/taxonomy.json — never into
|
|
268
|
+
// the upstream SKILL.md. These are the user's own skills: source/provenance is
|
|
269
|
+
// null and NO lockfile entry is written.
|
|
256
270
|
|
|
257
271
|
const summary = {
|
|
258
272
|
ok: true,
|
|
@@ -261,6 +275,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
261
275
|
to: destDir,
|
|
262
276
|
mode,
|
|
263
277
|
linkedBack,
|
|
278
|
+
followed: linkSource,
|
|
264
279
|
};
|
|
265
280
|
|
|
266
281
|
if (flags.json) {
|
|
@@ -268,6 +283,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
268
283
|
} else {
|
|
269
284
|
ctx.log(`imported ${targetName}`);
|
|
270
285
|
ctx.log(` from: ${fromPath}`);
|
|
286
|
+
if (linkSource) ctx.log(` follow: ${fromPath} -> ${srcDir} (copied target contents)`);
|
|
271
287
|
ctx.log(` to: ${destDir}`);
|
|
272
288
|
ctx.log(` mode: ${mode}`);
|
|
273
289
|
if (linkedBack) ctx.log(` link: ${fromPath} -> ${destDir} (old path still resolves)`);
|
package/src/commands/infer.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Dual-mode, LLM-FREE core:
|
|
4
4
|
// skl infer --emit print {instruction, schema, corpus} for a
|
|
5
5
|
// host agent to reason over (no LLM call here).
|
|
6
|
-
// skl infer --apply <file.json> write the agent's proposal into
|
|
7
|
-
//
|
|
6
|
+
// skl infer --apply <file.json> write the agent's proposal into the central
|
|
7
|
+
// taxonomy.json (never upstream SKILL.md).
|
|
8
8
|
// skl infer --provider openai API mode: POST the corpus to an
|
|
9
9
|
// OpenAI-compatible endpoint and apply the
|
|
10
10
|
// strict-JSON result automatically.
|
|
@@ -164,7 +164,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
164
164
|
ctx.error("skl infer: proposal contained no assignments");
|
|
165
165
|
return 1;
|
|
166
166
|
}
|
|
167
|
-
const result = await applyProposal(skills, proposal);
|
|
167
|
+
const result = await applyProposal(ctx.libraryPath, skills, proposal);
|
|
168
168
|
return reportApply(result, args.json, ctx);
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -193,7 +193,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
193
193
|
ctx.error("skl infer: gateway returned no assignments");
|
|
194
194
|
return 1;
|
|
195
195
|
}
|
|
196
|
-
const result = await applyProposal(skills, inferred.proposal);
|
|
196
|
+
const result = await applyProposal(ctx.libraryPath, skills, inferred.proposal);
|
|
197
197
|
return reportApply(result, args.json, ctx, prov.config.name, prov.config.model);
|
|
198
198
|
}
|
|
199
199
|
|
|
@@ -227,7 +227,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
227
227
|
ctx.error(
|
|
228
228
|
"skl infer: no inference mode available. Provide one of:\n" +
|
|
229
229
|
" --emit print corpus for a host agent to reason over\n" +
|
|
230
|
-
" --apply <file.json> apply an agent proposal into
|
|
230
|
+
" --apply <file.json> apply an agent proposal into taxonomy.json\n" +
|
|
231
231
|
` --provider <name> call an OpenAI-compatible endpoint (${knownProviders().join(", ")})\n` +
|
|
232
232
|
" --base-url <url> call a custom OpenAI-compatible endpoint\n" +
|
|
233
233
|
"(auto-emit only activates inside a Claude Code agent context.)",
|
|
@@ -268,7 +268,7 @@ function reportApply(
|
|
|
268
268
|
ctx.log(` ${a.name}: ${a.domains.join(", ")}${addedNote}`);
|
|
269
269
|
}
|
|
270
270
|
ctx.log(
|
|
271
|
-
`Applied ${result.applied.length}
|
|
271
|
+
`Applied ${result.applied.length} taxonomy update${
|
|
272
272
|
result.applied.length === 1 ? "" : "s"
|
|
273
273
|
}.`,
|
|
274
274
|
);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm, lstat, readlink, 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 } from "./link.ts";
|
|
7
|
+
import type { Ctx } from "../types.ts";
|
|
8
|
+
|
|
9
|
+
const BODY = "---\nname: claim-log\ndescription: a test skill\n---\n\nbody\n";
|
|
10
|
+
|
|
11
|
+
async function makeSkillDir(parent: string, name: string, body = BODY): Promise<string> {
|
|
12
|
+
const dir = join(parent, name);
|
|
13
|
+
await mkdir(dir, { recursive: true });
|
|
14
|
+
await writeFile(join(dir, "SKILL.md"), body);
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Captured {
|
|
19
|
+
ctx: Ctx;
|
|
20
|
+
logs: string[];
|
|
21
|
+
errors: string[];
|
|
22
|
+
json: unknown[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal Ctx mock — link.run only reads config.libraryPath + log/error/json. */
|
|
26
|
+
function makeCtx(libraryPath: string): Captured {
|
|
27
|
+
const logs: string[] = [];
|
|
28
|
+
const errors: string[] = [];
|
|
29
|
+
const json: unknown[] = [];
|
|
30
|
+
const ctx = {
|
|
31
|
+
config: { libraryPath },
|
|
32
|
+
libraryPath,
|
|
33
|
+
log: (...a: unknown[]) => logs.push(a.join(" ")),
|
|
34
|
+
error: (...a: unknown[]) => errors.push(a.join(" ")),
|
|
35
|
+
json: (v: unknown) => json.push(v),
|
|
36
|
+
} as unknown as Ctx;
|
|
37
|
+
return { ctx, logs, errors, json };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("skl link --from (LINKED mode)", () => {
|
|
41
|
+
let tmp: string;
|
|
42
|
+
let library: string;
|
|
43
|
+
let devRepo: string;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-link-")));
|
|
47
|
+
library = join(tmp, "library");
|
|
48
|
+
devRepo = join(tmp, "dev");
|
|
49
|
+
await mkdir(library, { recursive: true });
|
|
50
|
+
await mkdir(devRepo, { recursive: true });
|
|
51
|
+
});
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
await rm(tmp, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("registers a dev-repo skill as a library symlink", async () => {
|
|
57
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
58
|
+
const { ctx, json } = makeCtx(library);
|
|
59
|
+
const code = await run(["claim-log", "--from", src, "--json"], ctx);
|
|
60
|
+
|
|
61
|
+
expect(code).toBe(0);
|
|
62
|
+
const libEntry = join(library, "claim-log");
|
|
63
|
+
const st = await lstat(libEntry);
|
|
64
|
+
expect(st.isSymbolicLink()).toBe(true);
|
|
65
|
+
expect(await realpath(libEntry)).toBe(await realpath(src));
|
|
66
|
+
expect(json[0]).toMatchObject({ ok: true, name: "claim-log", mode: "linked", discarded: false });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("derives the name from the dev-repo dir basename when omitted", async () => {
|
|
70
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
71
|
+
const { ctx } = makeCtx(library);
|
|
72
|
+
const code = await run(["--from", src], ctx);
|
|
73
|
+
|
|
74
|
+
expect(code).toBe(0);
|
|
75
|
+
const libEntry = join(library, "claim-log");
|
|
76
|
+
expect((await lstat(libEntry)).isSymbolicLink()).toBe(true);
|
|
77
|
+
expect(await realpath(libEntry)).toBe(await realpath(src));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("is idempotent — re-running reports 'already'", async () => {
|
|
81
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
82
|
+
const { ctx } = makeCtx(library);
|
|
83
|
+
await run(["claim-log", "--from", src], ctx);
|
|
84
|
+
|
|
85
|
+
const { ctx: ctx2, json } = makeCtx(library);
|
|
86
|
+
const code = await run(["claim-log", "--from", src, "--json"], ctx2);
|
|
87
|
+
expect(code).toBe(0);
|
|
88
|
+
expect(json[0]).toMatchObject({ status: "already", mode: "linked" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("refuses to clobber an existing owned library copy without --force", async () => {
|
|
92
|
+
await makeSkillDir(library, "claim-log"); // a real OWNED copy already in the library
|
|
93
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
94
|
+
const { ctx, errors } = makeCtx(library);
|
|
95
|
+
|
|
96
|
+
const code = await run(["claim-log", "--from", src], ctx);
|
|
97
|
+
expect(code).toBe(1);
|
|
98
|
+
expect(errors.join("\n")).toContain("already exists in the library");
|
|
99
|
+
// unchanged: still a real dir, not a symlink
|
|
100
|
+
expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("--force replaces an owned copy with the symlink and reports discarded", async () => {
|
|
104
|
+
await makeSkillDir(library, "claim-log");
|
|
105
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
106
|
+
const { ctx, json } = makeCtx(library);
|
|
107
|
+
|
|
108
|
+
const code = await run(["claim-log", "--from", src, "--force", "--json"], ctx);
|
|
109
|
+
expect(code).toBe(0);
|
|
110
|
+
expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(true);
|
|
111
|
+
expect(json[0]).toMatchObject({ discarded: true, mode: "linked" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("drops a stale lockfile entry so update/outdated skip the now-LINKED skill", async () => {
|
|
115
|
+
// An owned import existed (real copy + a github lock entry); now convert to LINKED.
|
|
116
|
+
await makeSkillDir(library, "claim-log");
|
|
117
|
+
await writeFile(
|
|
118
|
+
join(library, "shelf.lock.json"),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
version: 1,
|
|
121
|
+
entries: {
|
|
122
|
+
"claim-log": { name: "claim-log", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
127
|
+
const { ctx } = makeCtx(library);
|
|
128
|
+
|
|
129
|
+
const code = await run(["claim-log", "--from", src, "--force"], ctx);
|
|
130
|
+
expect(code).toBe(0);
|
|
131
|
+
const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
|
|
132
|
+
expect(lock.entries["claim-log"]).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("rejects --at and --from together", async () => {
|
|
136
|
+
const src = await makeSkillDir(devRepo, "claim-log");
|
|
137
|
+
const { ctx, errors } = makeCtx(library);
|
|
138
|
+
const code = await run(["claim-log", "--from", src, "--at", "/tmp/x"], ctx);
|
|
139
|
+
expect(code).toBe(1);
|
|
140
|
+
expect(errors.join("\n")).toContain("mutually exclusive");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("refuses a --from source inside the library", async () => {
|
|
144
|
+
const inside = await makeSkillDir(library, "claim-log");
|
|
145
|
+
const { ctx, errors } = makeCtx(library);
|
|
146
|
+
const code = await run(["other", "--from", inside], ctx);
|
|
147
|
+
expect(code).toBe(1);
|
|
148
|
+
expect(errors.join("\n")).toContain("inside the library");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("refuses a --from dir with no SKILL.md", async () => {
|
|
152
|
+
const bare = join(devRepo, "bare");
|
|
153
|
+
await mkdir(bare, { recursive: true });
|
|
154
|
+
const { ctx, errors } = makeCtx(library);
|
|
155
|
+
const code = await run(["bare", "--from", bare], ctx);
|
|
156
|
+
expect(code).toBe(1);
|
|
157
|
+
expect(errors.join("\n")).toContain("no SKILL.md");
|
|
158
|
+
expect(existsSync(join(library, "bare"))).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// `skl link` — manage the symlink relationship between the library and on-disk copies.
|
|
2
|
+
//
|
|
3
|
+
// Two modes (the bookshelf model, ADR-0004):
|
|
4
|
+
//
|
|
5
|
+
// skl link <name> --at <path> OWNED side. The library already owns <name>; replace
|
|
6
|
+
// some other on-disk copy at <path> with a symlink INTO
|
|
7
|
+
// the library — fulfilling the one-canonical-copy rule for
|
|
8
|
+
// locations that were never consolidated (e.g. an old
|
|
9
|
+
// `.claude/skills/<name>` duplicate).
|
|
10
|
+
//
|
|
11
|
+
// skl link [<name>] --from <dev-repo> LINKED side. Register an external dev-repo skill as a
|
|
12
|
+
// library entry: make <library>/<name> a symlink pointing
|
|
13
|
+
// AT the dev repo, which stays canonical. The inverse of
|
|
14
|
+
// --at — the library shelves a reference instead of owning
|
|
15
|
+
// the bytes (for skills you actively develop in their own
|
|
16
|
+
// git repo). Name defaults to the dev-repo dir's basename.
|
|
17
|
+
//
|
|
18
|
+
// --force --at: replace even if <path>'s body differs from the library copy (the divergent
|
|
19
|
+
// copy is DISCARDED). Without it, a content mismatch is refused — pick a
|
|
20
|
+
// winner: keep library (this, with --force) or make <path> canonical
|
|
21
|
+
// (`skl import <name> --from <path> --force`).
|
|
22
|
+
// --from: replace an existing library entry (its current contents are DISCARDED).
|
|
23
|
+
// --json machine-readable summary.
|
|
24
|
+
//
|
|
25
|
+
// Safety: --at never touches the library copy and refuses paths inside the library; --from
|
|
26
|
+
// refuses a source inside the library; both verify the resulting symlink resolves as intended and
|
|
27
|
+
// are idempotent when the link already points where intended.
|
|
28
|
+
|
|
29
|
+
import { join, resolve, basename } from "node:path";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
import { rm } from "node:fs/promises";
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import type { Ctx } from "../types.ts";
|
|
34
|
+
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
35
|
+
import { removeEntry } from "../core/provenance.ts";
|
|
36
|
+
import {
|
|
37
|
+
isDirectory,
|
|
38
|
+
isSymlink,
|
|
39
|
+
safeSymlink,
|
|
40
|
+
realpathOrSelfAsync,
|
|
41
|
+
} from "../lib/fs.ts";
|
|
42
|
+
|
|
43
|
+
export const meta = {
|
|
44
|
+
name: "link",
|
|
45
|
+
summary: "Link a skill to the library: collapse a copy (--at) or shelve a dev repo (--from)",
|
|
46
|
+
usage: "skl link <name> --at <path> | skl link [<name>] --from <dev-repo> [--force] [--json]",
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
interface Flags {
|
|
50
|
+
name: string | null;
|
|
51
|
+
at: string | null;
|
|
52
|
+
from: string | null;
|
|
53
|
+
force: boolean;
|
|
54
|
+
json: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
|
|
58
|
+
const flags: Flags = { name: null, at: null, from: null, force: false, json: false };
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const a = argv[i]!;
|
|
61
|
+
if (a === "--at") {
|
|
62
|
+
const v = argv[++i];
|
|
63
|
+
if (v === undefined) return { error: "--at requires a <path>" };
|
|
64
|
+
flags.at = v;
|
|
65
|
+
} else if (a.startsWith("--at=")) {
|
|
66
|
+
flags.at = a.slice("--at=".length);
|
|
67
|
+
} else if (a === "--from") {
|
|
68
|
+
const v = argv[++i];
|
|
69
|
+
if (v === undefined) return { error: "--from requires a <dev-repo path>" };
|
|
70
|
+
flags.from = v;
|
|
71
|
+
} else if (a.startsWith("--from=")) {
|
|
72
|
+
flags.from = a.slice("--from=".length);
|
|
73
|
+
} else if (a === "--force") {
|
|
74
|
+
flags.force = true;
|
|
75
|
+
} else if (a === "--json") {
|
|
76
|
+
flags.json = true;
|
|
77
|
+
} else if (a.startsWith("--")) {
|
|
78
|
+
return { error: `unknown argument: ${a}` };
|
|
79
|
+
} else if (flags.name === null) {
|
|
80
|
+
flags.name = a;
|
|
81
|
+
} else {
|
|
82
|
+
return { error: `unexpected argument: ${a}` };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { flags };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** sha-256 of a SKILL.md body (frontmatter stripped) — matches crawl/dedupe hashing. */
|
|
89
|
+
async function bodyHash(skillMdPath: string): Promise<string | null> {
|
|
90
|
+
if (!existsSync(skillMdPath)) return null;
|
|
91
|
+
try {
|
|
92
|
+
const raw = await Bun.file(skillMdPath).text();
|
|
93
|
+
const { body } = parseFrontmatter(raw);
|
|
94
|
+
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
101
|
+
const parsed = parseFlags(argv);
|
|
102
|
+
if ("error" in parsed) {
|
|
103
|
+
ctx.error(`skl link: ${parsed.error}`);
|
|
104
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
const flags = parsed.flags;
|
|
108
|
+
|
|
109
|
+
if (flags.at && flags.from) {
|
|
110
|
+
ctx.error("skl link: --at and --from are mutually exclusive (collapse a copy vs. shelve a dev repo)");
|
|
111
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
if (!flags.at && !flags.from) {
|
|
115
|
+
ctx.error("skl link: one of --at <path> or --from <dev-repo> is required");
|
|
116
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return flags.from
|
|
121
|
+
? await runFrom(flags, ctx)
|
|
122
|
+
: await runAt(flags, ctx);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* LINKED mode: register an external dev-repo skill as a library symlink. The library entry
|
|
127
|
+
* <library>/<name> becomes a symlink pointing AT the dev repo (which stays canonical).
|
|
128
|
+
*/
|
|
129
|
+
async function runFrom(flags: Flags, ctx: Ctx): Promise<number> {
|
|
130
|
+
const fromPath = resolve(flags.from!.trim());
|
|
131
|
+
const name = (flags.name?.trim()) || basename(fromPath);
|
|
132
|
+
if (!name || name === "." || name === "/") {
|
|
133
|
+
ctx.error("skl link: could not determine a <name> — pass one explicitly");
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const libraryPath = ctx.config.libraryPath;
|
|
137
|
+
const libDir = join(libraryPath, name);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// The source must be a real skill dir (has a SKILL.md).
|
|
141
|
+
if (!existsSync(fromPath) || !(await isDirectory(fromPath))) {
|
|
142
|
+
ctx.error(`skl link: --from must be an existing directory: ${fromPath}`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
if (!existsSync(join(fromPath, "SKILL.md"))) {
|
|
146
|
+
ctx.error(`skl link: ${fromPath} has no SKILL.md (not a skill dir).`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Refuse a source inside the library — that would link the library to itself.
|
|
151
|
+
const fromReal = await realpathOrSelfAsync(fromPath);
|
|
152
|
+
const libRoot = await realpathOrSelfAsync(libraryPath);
|
|
153
|
+
if (fromReal === libRoot || fromReal.startsWith(libRoot + "/")) {
|
|
154
|
+
ctx.error(`skl link: --from is inside the library (${fromPath}) — nothing to register`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Idempotent: library entry is already a symlink resolving to this source.
|
|
159
|
+
if (isSymlink(libDir)) {
|
|
160
|
+
const cur = await realpathOrSelfAsync(libDir);
|
|
161
|
+
if (cur === fromReal) {
|
|
162
|
+
const summary = { ok: true, name, from: fromPath, to: libDir, status: "already" as const, mode: "linked" as const, discarded: false };
|
|
163
|
+
if (flags.json) ctx.json(summary);
|
|
164
|
+
else ctx.log(`link: library/${name} already points at ${fromPath}`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// An existing library entry won't be clobbered silently.
|
|
170
|
+
const exists = existsSync(libDir) || isSymlink(libDir);
|
|
171
|
+
if (exists && !flags.force) {
|
|
172
|
+
ctx.error(`skl link: '${name}' already exists in the library (${libDir}).`);
|
|
173
|
+
ctx.error("Pass --force to replace it with a symlink to the dev repo (its current contents are discarded).");
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
const discarded = exists && !isSymlink(libDir); // a real OWNED copy is being dropped
|
|
177
|
+
if (exists) await rm(libDir, { recursive: true, force: true });
|
|
178
|
+
await safeSymlink(fromPath, libDir, { force: true });
|
|
179
|
+
|
|
180
|
+
// Verify the library entry resolves to the dev repo.
|
|
181
|
+
const linkReal = await realpathOrSelfAsync(libDir);
|
|
182
|
+
if (linkReal !== fromReal) {
|
|
183
|
+
ctx.error(`skl link: verification failed — library/${name} resolves to ${linkReal}, expected ${fromReal}`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A LINKED entry is not a tracked github import — drop any stale lock entry so
|
|
188
|
+
// `skl update`/`outdated` never try to pull upstream into the dev repo (ADR-0004).
|
|
189
|
+
await removeEntry(libraryPath, name);
|
|
190
|
+
|
|
191
|
+
const summary = { ok: true, name, from: fromPath, to: libDir, status: "linked" as const, mode: "linked" as const, discarded };
|
|
192
|
+
if (flags.json) {
|
|
193
|
+
ctx.json(summary);
|
|
194
|
+
} else {
|
|
195
|
+
ctx.log(`shelved ${name} -> ${fromPath} (LINKED)`);
|
|
196
|
+
if (discarded) ctx.log(" (discarded the previous owned library copy; library now points at the dev repo)");
|
|
197
|
+
}
|
|
198
|
+
return 0;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
ctx.error(`skl link: failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* OWNED mode: replace a redundant on-disk copy at <path> with a symlink INTO the library copy
|
|
207
|
+
* the library already owns.
|
|
208
|
+
*/
|
|
209
|
+
async function runAt(flags: Flags, ctx: Ctx): Promise<number> {
|
|
210
|
+
if (!flags.name || flags.name.trim() === "") {
|
|
211
|
+
ctx.error("skl link: a <name> is required with --at");
|
|
212
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const name = flags.name.trim();
|
|
217
|
+
const atPath = resolve(flags.at!.trim());
|
|
218
|
+
const libraryPath = ctx.config.libraryPath;
|
|
219
|
+
const libDir = join(libraryPath, name);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
// The library must already own this skill — link points AT the canonical copy.
|
|
223
|
+
if (!existsSync(libDir) || !existsSync(join(libDir, "SKILL.md"))) {
|
|
224
|
+
ctx.error(
|
|
225
|
+
`skl link: '${name}' is not in the library (${libDir}). Import it first with \`skl import\`.`,
|
|
226
|
+
);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const libReal = await realpathOrSelfAsync(libDir);
|
|
231
|
+
|
|
232
|
+
// Idempotent: already a symlink resolving to the library copy.
|
|
233
|
+
if (isSymlink(atPath)) {
|
|
234
|
+
const cur = await realpathOrSelfAsync(atPath);
|
|
235
|
+
if (cur === libReal) {
|
|
236
|
+
const summary = { ok: true, name, at: atPath, to: libDir, status: "already" as const, discarded: false };
|
|
237
|
+
if (flags.json) ctx.json(summary);
|
|
238
|
+
else ctx.log(`link: ${atPath} already points at the library copy of ${name}`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Safety: never operate on the library copy itself or anything inside the library.
|
|
244
|
+
const atReal = await realpathOrSelfAsync(atPath);
|
|
245
|
+
if (atReal === libReal) {
|
|
246
|
+
ctx.error(`skl link: --at is the library copy itself (${atPath}) — nothing to do`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
const libRoot = await realpathOrSelfAsync(libraryPath);
|
|
250
|
+
if (atReal === libRoot || atReal.startsWith(libRoot + "/")) {
|
|
251
|
+
ctx.error(`skl link: refusing to operate on a path inside the library (${atPath})`);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// If the target exists as a real dir, require it to look like a skill and compare
|
|
256
|
+
// content. A body mismatch means a real decision the tool won't make silently.
|
|
257
|
+
if (existsSync(atPath) && !isSymlink(atPath)) {
|
|
258
|
+
if (!(await isDirectory(atPath))) {
|
|
259
|
+
ctx.error(`skl link: --at must be a directory (the redundant copy): ${atPath}`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
const atSkillMd = join(atPath, "SKILL.md");
|
|
263
|
+
if (!existsSync(atSkillMd) && !flags.force) {
|
|
264
|
+
ctx.error(
|
|
265
|
+
`skl link: ${atPath} has no SKILL.md (not a skill dir). Pass --force to replace it anyway.`,
|
|
266
|
+
);
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
if (existsSync(atSkillMd) && !flags.force) {
|
|
270
|
+
const [a, b] = await Promise.all([
|
|
271
|
+
bodyHash(atSkillMd),
|
|
272
|
+
bodyHash(join(libDir, "SKILL.md")),
|
|
273
|
+
]);
|
|
274
|
+
if (a !== b) {
|
|
275
|
+
ctx.error(
|
|
276
|
+
`skl link: ${atPath} differs from the library copy of '${name}'.`,
|
|
277
|
+
);
|
|
278
|
+
ctx.error(
|
|
279
|
+
"Pass --force to discard the divergent copy and replace it with a symlink,",
|
|
280
|
+
);
|
|
281
|
+
ctx.error(
|
|
282
|
+
`or make this copy canonical instead: \`skl import ${name} --from ${atPath} --force\`.`,
|
|
283
|
+
);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Replace the redundant copy with a symlink into the library.
|
|
290
|
+
const discarded = existsSync(atPath) && !isSymlink(atPath);
|
|
291
|
+
if (existsSync(atPath) || isSymlink(atPath)) {
|
|
292
|
+
await rm(atPath, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
await safeSymlink(libDir, atPath, { force: true });
|
|
295
|
+
|
|
296
|
+
// Verify the link resolves to the library copy.
|
|
297
|
+
const linkReal = await realpathOrSelfAsync(atPath);
|
|
298
|
+
if (linkReal !== libReal) {
|
|
299
|
+
ctx.error(
|
|
300
|
+
`skl link: verification failed — ${atPath} resolves to ${linkReal}, expected ${libReal}`,
|
|
301
|
+
);
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const summary = { ok: true, name, at: atPath, to: libDir, status: "linked" as const, discarded };
|
|
306
|
+
if (flags.json) {
|
|
307
|
+
ctx.json(summary);
|
|
308
|
+
} else {
|
|
309
|
+
ctx.log(`linked ${basename(atPath)} -> ${libDir}`);
|
|
310
|
+
if (discarded) ctx.log(" (discarded the redundant copy; old path now resolves to the library)");
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
ctx.error(`skl link: failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
}
|