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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// `skl refresh` — re-sync THIS project's ./.claude/skills/ symlinks to current library
|
|
2
|
+
// reality. The idempotent re-`use` was already the de-facto re-sync; this names it.
|
|
3
|
+
//
|
|
4
|
+
// For every managed symlink in the project skills dir:
|
|
5
|
+
// - resolves to a library skill that still exists -> repoint at its current path
|
|
6
|
+
// (repairs a relocated library: SKILLSHELF_LIBRARY moved, absolute link stale)
|
|
7
|
+
// - the same-named library skill no longer exists -> prune (renamed/removed/retired)
|
|
8
|
+
// - points somewhere unrelated (not a library skill) -> left untouched
|
|
9
|
+
//
|
|
10
|
+
// Deliberately does NOT expand bundles (that would guess intent) — to pick up NEW
|
|
11
|
+
// members of a bundle, re-run `skl use <bundle>` (also idempotent).
|
|
12
|
+
//
|
|
13
|
+
// skl refresh [--dry-run] [--json]
|
|
14
|
+
|
|
15
|
+
import { join, resolve, isAbsolute, dirname } from "node:path";
|
|
16
|
+
import { readdir, readlink } from "node:fs/promises";
|
|
17
|
+
import type { Ctx, Skill } from "../types.ts";
|
|
18
|
+
import { activeSkills } from "../core/library.ts";
|
|
19
|
+
import {
|
|
20
|
+
pathExists,
|
|
21
|
+
isSymlink,
|
|
22
|
+
realpathOrSelf,
|
|
23
|
+
realpathOrSelfAsync,
|
|
24
|
+
safeSymlink,
|
|
25
|
+
removeSymlink,
|
|
26
|
+
} from "../lib/fs.ts";
|
|
27
|
+
|
|
28
|
+
/** Direct entry names in a dir that are symlinks — INCLUDING dead ones (which
|
|
29
|
+
* listDirNames drops because they don't resolve to a directory). */
|
|
30
|
+
async function symlinkNames(dir: string): Promise<string[]> {
|
|
31
|
+
try {
|
|
32
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
33
|
+
return entries.filter((e) => e.isSymbolicLink()).map((e) => e.name);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const meta = {
|
|
40
|
+
name: "refresh",
|
|
41
|
+
summary: "Re-sync this project's .claude/skills symlinks to current library reality",
|
|
42
|
+
usage: "skl refresh [--dry-run] [--json]",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
type Action = "repointed" | "ok" | "pruned" | "foreign";
|
|
46
|
+
|
|
47
|
+
interface Outcome {
|
|
48
|
+
name: string;
|
|
49
|
+
action: Action;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
53
|
+
const json = argv.includes("--json");
|
|
54
|
+
const dryRun = argv.includes("--dry-run");
|
|
55
|
+
const skillsDir = join(process.cwd(), ".claude", "skills");
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const skills = activeSkills(await ctx.loadLibrary());
|
|
59
|
+
const byName = new Map<string, Skill>(skills.map((s) => [s.name, s]));
|
|
60
|
+
// Two library prefixes: realpath (resolves /tmp -> /private/tmp etc.) and the
|
|
61
|
+
// plain resolved path. A DEAD link's target can't be realpath'd, so we match its
|
|
62
|
+
// raw readlink target against either form to decide if it pointed into the library.
|
|
63
|
+
const libPrefixes = [realpathOrSelf(ctx.libraryPath), resolve(ctx.libraryPath)].map((p) =>
|
|
64
|
+
p.endsWith("/") ? p : p + "/",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const outcomes: Outcome[] = [];
|
|
68
|
+
if (pathExists(skillsDir)) {
|
|
69
|
+
for (const name of await symlinkNames(skillsDir)) {
|
|
70
|
+
const link = join(skillsDir, name);
|
|
71
|
+
if (!isSymlink(link)) continue; // only manage symlinks; never touch real files
|
|
72
|
+
const skill = byName.get(name);
|
|
73
|
+
|
|
74
|
+
if (skill) {
|
|
75
|
+
// The same-named library skill exists: ensure the link points at it.
|
|
76
|
+
const cur = realpathOrSelf(link);
|
|
77
|
+
const want = await realpathOrSelfAsync(skill.path);
|
|
78
|
+
if (cur === want) {
|
|
79
|
+
outcomes.push({ name, action: "ok" });
|
|
80
|
+
} else {
|
|
81
|
+
if (!dryRun) await safeSymlink(skill.path, link, { force: true });
|
|
82
|
+
outcomes.push({ name, action: "repointed" });
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// No same-named library skill. Read the RAW target to decide if it pointed
|
|
88
|
+
// into the library (renamed/removed -> prune) or somewhere unrelated (leave).
|
|
89
|
+
const raw = await readlink(link).catch(() => null);
|
|
90
|
+
const targetAbs = raw == null ? "" : isAbsolute(raw) ? raw : resolve(dirname(link), raw);
|
|
91
|
+
// Strictly UNDER the library (a per-skill entry) — a link to the library ROOT
|
|
92
|
+
// itself was never a skill deployment, so leave it as `foreign` rather than prune.
|
|
93
|
+
const pointsIntoLibrary = libPrefixes.some((pre) => targetAbs.startsWith(pre));
|
|
94
|
+
|
|
95
|
+
if (pointsIntoLibrary) {
|
|
96
|
+
// Was a library skill (link still resolves into the library tree) but no
|
|
97
|
+
// active skill of this name remains — renamed/removed/retired. Prune.
|
|
98
|
+
if (!dryRun) await removeSymlink(link, { force: true });
|
|
99
|
+
outcomes.push({ name, action: "pruned" });
|
|
100
|
+
} else {
|
|
101
|
+
outcomes.push({ name, action: "foreign" });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const count = (a: Action) => outcomes.filter((o) => o.action === a).length;
|
|
107
|
+
if (json) {
|
|
108
|
+
ctx.json({
|
|
109
|
+
dryRun,
|
|
110
|
+
skillsDir,
|
|
111
|
+
outcomes,
|
|
112
|
+
repointed: count("repointed"),
|
|
113
|
+
pruned: count("pruned"),
|
|
114
|
+
ok: count("ok"),
|
|
115
|
+
});
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (outcomes.length === 0) {
|
|
120
|
+
ctx.log(`No managed symlinks in ${skillsDir} — nothing to refresh.`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
const verb = dryRun ? "would" : "did";
|
|
124
|
+
ctx.log(`Refresh ${skillsDir}:`);
|
|
125
|
+
for (const o of outcomes) ctx.log(` ${o.name} [${o.action}]`);
|
|
126
|
+
ctx.log("");
|
|
127
|
+
ctx.log(`${count("repointed")} repointed, ${count("pruned")} pruned, ${count("ok")} already current (${verb} apply).`);
|
|
128
|
+
return 0;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
ctx.error(`skl refresh: ${err instanceof Error ? err.message : String(err)}`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm, 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 { remediate } from "./where.ts";
|
|
7
|
+
import { run as refreshRun } from "./refresh.ts";
|
|
8
|
+
import { run as statusRun } from "./status.ts";
|
|
9
|
+
import { isSymlink, realpathOrSelf } from "../lib/fs.ts";
|
|
10
|
+
import { loadLibrary } from "../core/library.ts";
|
|
11
|
+
import { inventoryDeployments, remediationFor } from "../core/deployments.ts";
|
|
12
|
+
import type { Ctx, DeploymentSite } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
function site(partial: Partial<DeploymentSite> & Pick<DeploymentSite, "name" | "path" | "kind">): DeploymentSite {
|
|
15
|
+
return { surface: "/surface", target: null, inLibrary: false, drift: false, ...partial };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(libraryPath: string) {
|
|
19
|
+
const json: unknown[] = [];
|
|
20
|
+
const ctx = {
|
|
21
|
+
config: { libraryPath },
|
|
22
|
+
libraryPath,
|
|
23
|
+
loadLibrary: () => loadLibrary(libraryPath),
|
|
24
|
+
log: () => {},
|
|
25
|
+
error: () => {},
|
|
26
|
+
json: (v: unknown) => json.push(v),
|
|
27
|
+
} as unknown as Ctx;
|
|
28
|
+
return { ctx, json };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("where remediation + refresh + status drift (friction #6)", () => {
|
|
32
|
+
let tmp: string;
|
|
33
|
+
let library: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-rem-")));
|
|
37
|
+
library = join(tmp, "library");
|
|
38
|
+
await mkdir(join(library, "alpha"), { recursive: true });
|
|
39
|
+
await writeFile(join(library, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: a\n---\n\nbody\n");
|
|
40
|
+
});
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await rm(tmp, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("--prune removes dead links only; copies stay manual", async () => {
|
|
46
|
+
const surface = join(tmp, "surface");
|
|
47
|
+
await mkdir(surface, { recursive: true });
|
|
48
|
+
await symlink(join(tmp, "gone"), join(surface, "deadlink"));
|
|
49
|
+
const dead = site({ name: "deadlink", path: join(surface, "deadlink"), kind: "dead" });
|
|
50
|
+
const copy = site({ name: "alpha", path: join(surface, "alpha"), kind: "copy", inLibrary: true, drift: false });
|
|
51
|
+
|
|
52
|
+
const outcomes = await remediate([dead, copy], library, { fix: false, dryRun: false });
|
|
53
|
+
expect(outcomes.find((o) => o.name === "deadlink")!.action).toBe("remove-dead");
|
|
54
|
+
expect(outcomes.find((o) => o.name === "deadlink")!.applied).toBe(true);
|
|
55
|
+
expect(existsSync(join(surface, "deadlink"))).toBe(false);
|
|
56
|
+
// under --prune a dedupe-able copy is left as manual
|
|
57
|
+
expect(outcomes.find((o) => o.name === "alpha")!.action).toBe("manual");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("--fix dedupes a content-identical copy into a library symlink", async () => {
|
|
61
|
+
const surface = join(tmp, "surface");
|
|
62
|
+
await mkdir(join(surface, "alpha"), { recursive: true });
|
|
63
|
+
await writeFile(join(surface, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: a\n---\n\nbody\n");
|
|
64
|
+
const copy = site({ name: "alpha", path: join(surface, "alpha"), kind: "copy", inLibrary: true, drift: false });
|
|
65
|
+
|
|
66
|
+
const outcomes = await remediate([copy], library, { fix: true, dryRun: false });
|
|
67
|
+
expect(outcomes[0]!.action).toBe("dedupe-copy");
|
|
68
|
+
expect(isSymlink(join(surface, "alpha"))).toBe(true);
|
|
69
|
+
expect(realpathOrSelf(join(surface, "alpha"))).toBe(realpathOrSelf(join(library, "alpha")));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("--fix never auto-resolves a drifted copy or a foreign link", async () => {
|
|
73
|
+
const drifted = site({ name: "alpha", path: "/s/alpha", kind: "copy", inLibrary: true, drift: true });
|
|
74
|
+
const foreign = site({ name: "beta", path: "/s/beta", kind: "foreign-link", target: "/elsewhere" });
|
|
75
|
+
const outcomes = await remediate([drifted, foreign], library, { fix: true, dryRun: true });
|
|
76
|
+
expect(outcomes.every((o) => o.action === "manual")).toBe(true);
|
|
77
|
+
expect(outcomes.every((o) => !o.applied)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("--dry-run reports without mutating", async () => {
|
|
81
|
+
const surface = join(tmp, "surface");
|
|
82
|
+
await mkdir(surface, { recursive: true });
|
|
83
|
+
await symlink(join(tmp, "gone"), join(surface, "deadlink"));
|
|
84
|
+
const dead = site({ name: "deadlink", path: join(surface, "deadlink"), kind: "dead" });
|
|
85
|
+
const outcomes = await remediate([dead], library, { fix: true, dryRun: true });
|
|
86
|
+
expect(outcomes[0]!.applied).toBe(false);
|
|
87
|
+
expect(isSymlink(join(surface, "deadlink"))).toBe(true); // still there
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("refresh prunes a stale link whose library skill is gone, leaves foreign links", async () => {
|
|
91
|
+
const proj = join(tmp, "proj");
|
|
92
|
+
const skillsDir = join(proj, ".claude", "skills");
|
|
93
|
+
await mkdir(skillsDir, { recursive: true });
|
|
94
|
+
await symlink(join(library, "alpha"), join(skillsDir, "alpha")); // valid
|
|
95
|
+
await symlink(join(library, "ghost"), join(skillsDir, "ghost")); // dead, was-library
|
|
96
|
+
await symlink("/foreign/x", join(skillsDir, "foreign")); // foreign
|
|
97
|
+
|
|
98
|
+
const prev = process.cwd();
|
|
99
|
+
process.chdir(proj);
|
|
100
|
+
try {
|
|
101
|
+
const { ctx, json } = makeCtx(library);
|
|
102
|
+
await refreshRun(["--json"], ctx);
|
|
103
|
+
const out = json[0] as { outcomes: Array<{ name: string; action: string }> };
|
|
104
|
+
const by = Object.fromEntries(out.outcomes.map((o) => [o.name, o.action]));
|
|
105
|
+
expect(by.alpha).toBe("ok");
|
|
106
|
+
expect(by.ghost).toBe("pruned");
|
|
107
|
+
expect(by.foreign).toBe("foreign");
|
|
108
|
+
expect(existsSync(join(skillsDir, "ghost"))).toBe(false);
|
|
109
|
+
expect(isSymlink(join(skillsDir, "foreign"))).toBe(true);
|
|
110
|
+
} finally {
|
|
111
|
+
process.chdir(prev);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a copy with identical body but a CUSTOMIZED description is drift, not dedupe-able", async () => {
|
|
116
|
+
// the load-bearing `description` differs while the body is byte-identical —
|
|
117
|
+
// `where --fix` must NOT silently replace it with a symlink (data loss).
|
|
118
|
+
const surface = join(tmp, "surface");
|
|
119
|
+
await mkdir(join(surface, "alpha"), { recursive: true });
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(surface, "alpha", "SKILL.md"),
|
|
122
|
+
"---\nname: alpha\ndescription: CUSTOMIZED trigger text\n---\n\nbody\n", // same body, diff description
|
|
123
|
+
);
|
|
124
|
+
const lib = await loadLibrary(library);
|
|
125
|
+
const report = await inventoryDeployments([surface], library, lib);
|
|
126
|
+
const alpha = report.sites.find((s) => s.surface === surface && s.name === "alpha")!;
|
|
127
|
+
expect(alpha.kind).toBe("copy");
|
|
128
|
+
expect(alpha.drift).toBe(true); // description difference counts as drift
|
|
129
|
+
expect(remediationFor(alpha)).toBe("manual"); // never auto-deduped
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("status flags an unmanaged real copy (drift-prone)", async () => {
|
|
133
|
+
const proj = join(tmp, "proj2");
|
|
134
|
+
const skillsDir = join(proj, ".claude", "skills");
|
|
135
|
+
await mkdir(join(skillsDir, "realcopy"), { recursive: true });
|
|
136
|
+
await writeFile(join(skillsDir, "realcopy", "SKILL.md"), "---\nname: realcopy\n---\n\nx\n");
|
|
137
|
+
|
|
138
|
+
const prev = process.cwd();
|
|
139
|
+
process.chdir(proj);
|
|
140
|
+
try {
|
|
141
|
+
const { ctx, json } = makeCtx(library);
|
|
142
|
+
await statusRun(["--json"], ctx);
|
|
143
|
+
const out = json[0] as { unmanaged: Array<{ name: string }> };
|
|
144
|
+
expect(out.unmanaged.map((u) => u.name)).toContain("realcopy");
|
|
145
|
+
} finally {
|
|
146
|
+
process.chdir(prev);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm as fsRm, realpath, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { run as renameRun } from "./rename.ts";
|
|
7
|
+
import { loadLibrary } from "../core/library.ts";
|
|
8
|
+
import type { Ctx } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
function makeCtx(libraryPath: string) {
|
|
11
|
+
const json: unknown[] = [];
|
|
12
|
+
const errors: string[] = [];
|
|
13
|
+
const ctx = {
|
|
14
|
+
config: { libraryPath },
|
|
15
|
+
libraryPath,
|
|
16
|
+
loadLibrary: () => loadLibrary(libraryPath),
|
|
17
|
+
log: () => {},
|
|
18
|
+
error: (...a: unknown[]) => errors.push(a.join(" ")),
|
|
19
|
+
json: (v: unknown) => json.push(v),
|
|
20
|
+
} as unknown as Ctx;
|
|
21
|
+
return { ctx, json, errors };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("skl rename — atomic slug move (friction #5)", () => {
|
|
25
|
+
let tmp: string;
|
|
26
|
+
let library: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-rename-")));
|
|
30
|
+
library = join(tmp, "library");
|
|
31
|
+
await mkdir(join(library, "alpha"), { recursive: true });
|
|
32
|
+
await writeFile(join(library, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: a\n---\n\nbody\n");
|
|
33
|
+
await writeFile(join(library, "taxonomy.json"), JSON.stringify({ version: 1, skills: { alpha: ["bio"] } }));
|
|
34
|
+
await writeFile(
|
|
35
|
+
join(library, "shelf.lock.json"),
|
|
36
|
+
JSON.stringify({ version: 1, entries: { alpha: { name: "alpha", source: "github:o/r", ref: "x", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false } } }),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await fsRm(tmp, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("moves dir + frontmatter + taxonomy + lock together", async () => {
|
|
44
|
+
const { ctx } = makeCtx(library);
|
|
45
|
+
const code = await renameRun(["alpha", "alpha2", "--json"], ctx);
|
|
46
|
+
expect(code).toBe(0);
|
|
47
|
+
|
|
48
|
+
expect(existsSync(join(library, "alpha"))).toBe(false);
|
|
49
|
+
expect(existsSync(join(library, "alpha2"))).toBe(true);
|
|
50
|
+
|
|
51
|
+
const fm = await readFile(join(library, "alpha2", "SKILL.md"), "utf8");
|
|
52
|
+
expect(fm).toContain("name: alpha2");
|
|
53
|
+
|
|
54
|
+
const tax = JSON.parse(await readFile(join(library, "taxonomy.json"), "utf8"));
|
|
55
|
+
expect(tax.skills.alpha2).toEqual(["bio"]);
|
|
56
|
+
expect(tax.skills.alpha).toBeUndefined();
|
|
57
|
+
|
|
58
|
+
const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
|
|
59
|
+
expect(lock.entries.alpha2.name).toBe("alpha2");
|
|
60
|
+
expect(lock.entries.alpha).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("refuses an existing target name", async () => {
|
|
64
|
+
await mkdir(join(library, "beta"), { recursive: true });
|
|
65
|
+
await writeFile(join(library, "beta", "SKILL.md"), "---\nname: beta\n---\n\nb\n");
|
|
66
|
+
const { ctx, errors } = makeCtx(library);
|
|
67
|
+
const code = await renameRun(["alpha", "beta"], ctx);
|
|
68
|
+
expect(code).toBe(1);
|
|
69
|
+
expect(errors.join("\n")).toContain("already exists");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("refuses a missing source", async () => {
|
|
73
|
+
const { ctx, errors } = makeCtx(library);
|
|
74
|
+
const code = await renameRun(["ghost", "x"], ctx);
|
|
75
|
+
expect(code).toBe(1);
|
|
76
|
+
expect(errors.join("\n")).toContain("not in the library");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("renames a RETIRED skill in place (stays under _retired/)", async () => {
|
|
80
|
+
// retire alpha by moving it into _retired/
|
|
81
|
+
await mkdir(join(library, "_retired"), { recursive: true });
|
|
82
|
+
const { rename: fsRename } = await import("node:fs/promises");
|
|
83
|
+
await fsRename(join(library, "alpha"), join(library, "_retired", "alpha"));
|
|
84
|
+
|
|
85
|
+
const { ctx, json } = makeCtx(library);
|
|
86
|
+
const code = await renameRun(["alpha", "alpha2", "--json"], ctx);
|
|
87
|
+
expect(code).toBe(0);
|
|
88
|
+
expect((json[0] as { wasRetired: boolean }).wasRetired).toBe(true);
|
|
89
|
+
expect(existsSync(join(library, "_retired", "alpha2", "SKILL.md"))).toBe(true);
|
|
90
|
+
expect(existsSync(join(library, "_retired", "alpha"))).toBe(false);
|
|
91
|
+
expect(existsSync(join(library, "alpha2"))).toBe(false); // did NOT escape to active
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("refuses a path-traversal <old> name", async () => {
|
|
95
|
+
const victim = join(tmp, "victim");
|
|
96
|
+
await mkdir(victim, { recursive: true });
|
|
97
|
+
const { ctx, errors } = makeCtx(library);
|
|
98
|
+
const code = await renameRun(["../victim", "stolen"], ctx);
|
|
99
|
+
expect(code).toBe(1);
|
|
100
|
+
expect(errors.join("\n")).toContain("path separators");
|
|
101
|
+
expect(existsSync(victim)).toBe(true);
|
|
102
|
+
expect(existsSync(join(library, "stolen"))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a LINKED entry rekeys metadata but leaves the dev-repo SKILL.md untouched", async () => {
|
|
106
|
+
const dev = join(tmp, "dev", "linked");
|
|
107
|
+
await mkdir(dev, { recursive: true });
|
|
108
|
+
await writeFile(join(dev, "SKILL.md"), "---\nname: linked\n---\n\nbody\n");
|
|
109
|
+
await symlink(dev, join(library, "linked"));
|
|
110
|
+
|
|
111
|
+
const { ctx, json } = makeCtx(library);
|
|
112
|
+
const code = await renameRun(["linked", "linked2", "--json"], ctx);
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
expect((json[0] as { frontmatterRewritten: boolean }).frontmatterRewritten).toBe(false);
|
|
115
|
+
// dev-repo body is NOT rewritten (we must not edit the dev repo)
|
|
116
|
+
const devFm = await readFile(join(dev, "SKILL.md"), "utf8");
|
|
117
|
+
expect(devFm).toContain("name: linked");
|
|
118
|
+
// library symlink now lives under the new name, still resolving to the dev repo
|
|
119
|
+
expect(existsSync(join(library, "linked2", "SKILL.md"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `skl rename <old> <new>` (alias `skl mv`) — rename a skill's slug, moving every
|
|
2
|
+
// coupled piece of state together in ONE op: the library dir, the SKILL.md
|
|
3
|
+
// frontmatter `name:`, the taxonomy key, and the lockfile key. A hand `mv` alone
|
|
4
|
+
// leaves a half-renamed skill (dir=new, frontmatter/taxonomy=old) — the exact
|
|
5
|
+
// multi-file-consistency hazard the dogfood pass hit.
|
|
6
|
+
//
|
|
7
|
+
// skl rename <old> <new> [--json]
|
|
8
|
+
//
|
|
9
|
+
// Does NOT repoint external deploy symlinks (they point at the old library path);
|
|
10
|
+
// re-run `skl use` in affected projects (or `skl where` to find stragglers) after.
|
|
11
|
+
|
|
12
|
+
import type { Ctx } from "../types.ts";
|
|
13
|
+
import { renameSkill, reindexLibrary } from "../core/lifecycle.ts";
|
|
14
|
+
|
|
15
|
+
export const meta = {
|
|
16
|
+
name: "rename",
|
|
17
|
+
summary: "Rename a skill slug atomically (dir + frontmatter + taxonomy + lock)",
|
|
18
|
+
usage: "skl rename <old> <new> [--json]",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
22
|
+
|
|
23
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
24
|
+
const json = argv.includes("--json");
|
|
25
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
26
|
+
const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--json");
|
|
27
|
+
if (unknownFlag) {
|
|
28
|
+
ctx.error(`skl rename: unknown argument: ${unknownFlag}`);
|
|
29
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
const [from, to] = positional;
|
|
33
|
+
if (!from || !to) {
|
|
34
|
+
ctx.error("skl rename: an <old> and a <new> name are required");
|
|
35
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
if (!SLUG_RE.test(to)) {
|
|
39
|
+
ctx.error(`skl rename: invalid name "${to}" — use lowercase letters, digits, and hyphens`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (from === to) {
|
|
43
|
+
ctx.error("skl rename: <old> and <new> are the same");
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const result = await renameSkill(ctx.libraryPath, from, to);
|
|
49
|
+
await reindexLibrary(ctx.libraryPath);
|
|
50
|
+
if (json) {
|
|
51
|
+
ctx.json({ ok: true, ...result });
|
|
52
|
+
} else {
|
|
53
|
+
ctx.log(`renamed ${from} -> ${to}`);
|
|
54
|
+
if (result.frontmatterRewritten) ctx.log(" rewrote SKILL.md frontmatter name:");
|
|
55
|
+
if (result.taxonomyMoved) ctx.log(" moved taxonomy entry");
|
|
56
|
+
if (result.lockMoved) ctx.log(" moved lockfile entry");
|
|
57
|
+
ctx.log(" note: deploy symlinks pointing at the old name are now stale — re-run `skl use` (or `skl where`).");
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
ctx.error(`skl rename: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// `skl retag <old-domain> <new-domain>` — deterministically rename a domain across
|
|
2
|
+
// the WHOLE library taxonomy (every skill tagged <old> becomes <new>). This is the
|
|
3
|
+
// pure rename the AI `infer` pass can't promise (it re-reasons every tag); retag
|
|
4
|
+
// touches nothing but the named domain. Fixes a domain typo in one pass instead of a
|
|
5
|
+
// hand-edit of taxonomy.json or a silently-failing `sed` loop over frontmatter.
|
|
6
|
+
//
|
|
7
|
+
// skl retag <old-domain> <new-domain> [--json]
|
|
8
|
+
|
|
9
|
+
import type { Ctx } from "../types.ts";
|
|
10
|
+
import { renameDomainAcrossLibrary } from "../core/taxonomy.ts";
|
|
11
|
+
import { reindexLibrary } from "../core/lifecycle.ts";
|
|
12
|
+
|
|
13
|
+
export const meta = {
|
|
14
|
+
name: "retag",
|
|
15
|
+
summary: "Rename a domain across the whole library taxonomy (deterministic)",
|
|
16
|
+
usage: "skl retag <old-domain> <new-domain> [--json]",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
|
+
|
|
21
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
22
|
+
const json = argv.includes("--json");
|
|
23
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
24
|
+
const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--json");
|
|
25
|
+
if (unknownFlag) {
|
|
26
|
+
ctx.error(`skl retag: unknown argument: ${unknownFlag}`);
|
|
27
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
const [oldDomain, newDomain] = positional;
|
|
31
|
+
if (!oldDomain || !newDomain) {
|
|
32
|
+
ctx.error("skl retag: an <old-domain> and a <new-domain> are required");
|
|
33
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
if (!SLUG_RE.test(newDomain)) {
|
|
37
|
+
ctx.error(`skl retag: invalid domain "${newDomain}" — use lowercase letters, digits, and hyphens`);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const changed = await renameDomainAcrossLibrary(ctx.libraryPath, oldDomain, newDomain);
|
|
43
|
+
if (changed.length > 0) await reindexLibrary(ctx.libraryPath);
|
|
44
|
+
|
|
45
|
+
if (json) {
|
|
46
|
+
ctx.json({ ok: true, from: oldDomain, to: newDomain, changed });
|
|
47
|
+
} else if (changed.length === 0) {
|
|
48
|
+
ctx.log(`No skills tagged '${oldDomain}' in the taxonomy — nothing to rename.`);
|
|
49
|
+
} else {
|
|
50
|
+
ctx.log(`retagged '${oldDomain}' -> '${newDomain}' across ${changed.length} skill(s):`);
|
|
51
|
+
for (const n of changed) ctx.log(` ${n}`);
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
ctx.error(`skl retag: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `skl retire <name>` — soft-delete a skill into <library>/_retired/<name>/. The
|
|
2
|
+
// read layer already renders retired skills (struck-through in INDEX.md, "(retired)"
|
|
3
|
+
// in `ls --all`, excluded from bundles/deploys) but had no WRITE primitive to enter
|
|
4
|
+
// that state — forcing a hand `mkdir _retired && mv`. This is the reversible first
|
|
5
|
+
// step of the removal lifecycle (retire -> optionally `rm`); undo with `skl unretire`.
|
|
6
|
+
//
|
|
7
|
+
// skl retire <name> [--json]
|
|
8
|
+
|
|
9
|
+
import type { Ctx } from "../types.ts";
|
|
10
|
+
import { retireSkill, reindexLibrary } from "../core/lifecycle.ts";
|
|
11
|
+
|
|
12
|
+
export const meta = {
|
|
13
|
+
name: "retire",
|
|
14
|
+
summary: "Soft-delete a skill into library/_retired/ (reversible; excluded from deploys)",
|
|
15
|
+
usage: "skl retire <name> [--json]",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
19
|
+
const json = argv.includes("--json");
|
|
20
|
+
const name = argv.find((a) => !a.startsWith("--"));
|
|
21
|
+
if (!name) {
|
|
22
|
+
ctx.error("skl retire: a <name> is required");
|
|
23
|
+
ctx.error(`usage: ${meta.usage}`);
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const dest = await retireSkill(ctx.libraryPath, name);
|
|
28
|
+
await reindexLibrary(ctx.libraryPath);
|
|
29
|
+
if (json) ctx.json({ ok: true, name, retiredTo: dest });
|
|
30
|
+
else {
|
|
31
|
+
ctx.log(`retired ${name} -> _retired/${name}`);
|
|
32
|
+
ctx.log(" (excluded from bundles/deploys; restore with `skl unretire`, purge with `skl rm`)");
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
ctx.error(`skl retire: ${err instanceof Error ? err.message : String(err)}`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
}
|