skillshelf 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -19
- package/package.json +8 -2
- package/src/adapters/inference/agent.ts +23 -16
- package/src/cli.ts +31 -0
- package/src/commands/add.ts +624 -128
- package/src/commands/agents.ts +120 -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 +118 -18
- package/src/commands/mode-surfacing.test.ts +110 -0
- package/src/commands/outdated.test.ts +55 -0
- package/src/commands/outdated.ts +138 -18
- 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 +4 -1
- 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/unretire.ts +33 -0
- package/src/commands/untag.ts +73 -0
- package/src/commands/update.test.ts +71 -0
- package/src/commands/update.ts +65 -15
- package/src/commands/use.test.ts +92 -0
- package/src/commands/use.ts +46 -23
- package/src/commands/where.ts +232 -0
- package/src/config.test.ts +69 -0
- package/src/config.ts +79 -10
- package/src/core/agents.test.ts +232 -0
- package/src/core/agents.ts +363 -0
- package/src/core/bundle.ts +12 -15
- package/src/core/core.test.ts +14 -1
- 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 +344 -70
- 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 +85 -15
- package/src/core/overlay.ts +0 -63
package/src/commands/outdated.ts
CHANGED
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Read command: supports --json.
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import type { Ctx, LockEntry, Skill } from "../types.ts";
|
|
10
12
|
import { readLockfile } from "../core/provenance.ts";
|
|
13
|
+
import { entryMode, entryModeInfo, loadLibrary, findByName } from "../core/library.ts";
|
|
14
|
+
import { hashContent } from "../core/crawl.ts";
|
|
15
|
+
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
11
16
|
import { parseStoredSource, latestRef } from "../core/fetch.ts";
|
|
12
17
|
|
|
13
18
|
export const meta = {
|
|
14
19
|
name: "outdated",
|
|
15
20
|
summary: "Check upstream ref per tracked skill and mark stale ones",
|
|
16
|
-
usage: "skl outdated [name] [--json]",
|
|
21
|
+
usage: "skl outdated [name] [--check-local] [--json]",
|
|
17
22
|
} as const;
|
|
18
23
|
|
|
19
|
-
type Status = "stale" | "current" | "unknown";
|
|
24
|
+
type Status = "stale" | "current" | "unknown" | "linked" | "diverged";
|
|
20
25
|
|
|
21
26
|
interface Row {
|
|
22
27
|
name: string;
|
|
@@ -59,53 +64,168 @@ async function checkEntry(entry: LockEntry): Promise<Row> {
|
|
|
59
64
|
};
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A LINKED entry (library/<name> symlinks to an external dev repo) has no tracked
|
|
69
|
+
* upstream — its own git owns versioning. Report it as such instead of probing a
|
|
70
|
+
* (possibly stale) github ref (ADR-0004).
|
|
71
|
+
*/
|
|
72
|
+
function linkedRow(entry: LockEntry): Row {
|
|
73
|
+
return {
|
|
74
|
+
name: entry.name,
|
|
75
|
+
channel: "local",
|
|
76
|
+
source: entry.source,
|
|
77
|
+
installedRef: entry.ref,
|
|
78
|
+
latestRef: null,
|
|
79
|
+
status: "linked",
|
|
80
|
+
note: "dev repo owns versioning",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A LINKED library skill with NO lockfile entry (the normal case for `skl link
|
|
86
|
+
* --from` — it drops any lock entry). Without this, a freshly-linked dev skill is
|
|
87
|
+
* INVISIBLE to outdated, giving an agent zero positive evidence its dev repo is the
|
|
88
|
+
* canonical source. Surface it as a `linked` row regardless.
|
|
89
|
+
*/
|
|
90
|
+
function linkedRowFromName(name: string, linkTarget: string | null): Row {
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
channel: "local",
|
|
94
|
+
source: linkTarget ?? "(dev repo)",
|
|
95
|
+
installedRef: "-",
|
|
96
|
+
latestRef: null,
|
|
97
|
+
status: "linked",
|
|
98
|
+
note: "dev repo owns versioning",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Offline (no network) divergence check for an OWNED tracked skill: compare the local
|
|
104
|
+
* SKILL.md body hash to the baseline recorded at install/update time
|
|
105
|
+
* (lockfile.installedHash). Answers "have I locally edited this?" without probing
|
|
106
|
+
* upstream — usable on a plane / in CI with no creds.
|
|
107
|
+
*/
|
|
108
|
+
function checkEntryLocal(entry: LockEntry, library: Skill[], libraryPath: string): Row {
|
|
109
|
+
const skill = findByName(library, entry.name);
|
|
110
|
+
const bodyPath = skill?.bodyPath ?? join(libraryPath, entry.name, "SKILL.md");
|
|
111
|
+
let localHash: string | null = null;
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(bodyPath)) {
|
|
114
|
+
const raw = readFileSync(bodyPath, "utf8");
|
|
115
|
+
localHash = hashContent(parseFrontmatter(raw).body);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* leave null */
|
|
119
|
+
}
|
|
120
|
+
const base = {
|
|
121
|
+
name: entry.name,
|
|
122
|
+
channel: entry.channel,
|
|
123
|
+
source: entry.source,
|
|
124
|
+
installedRef: entry.ref,
|
|
125
|
+
latestRef: null,
|
|
126
|
+
};
|
|
127
|
+
if (entry.installedHash == null) {
|
|
128
|
+
return { ...base, status: "unknown", note: "no recorded baseline (installed before hash tracking) — re-run `skl update` to record one" };
|
|
129
|
+
}
|
|
130
|
+
if (localHash == null) {
|
|
131
|
+
return { ...base, status: "unknown", note: "local SKILL.md unreadable" };
|
|
132
|
+
}
|
|
133
|
+
return localHash === entry.installedHash
|
|
134
|
+
? { ...base, status: "current", note: "matches installed baseline (offline)" }
|
|
135
|
+
: { ...base, status: "diverged", note: "local body diverged from installed baseline (offline)" };
|
|
136
|
+
}
|
|
137
|
+
|
|
62
138
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
63
139
|
const json = argv.includes("--json");
|
|
140
|
+
const checkLocal = argv.includes("--check-local");
|
|
64
141
|
const nameArg = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
142
|
+
const libraryPath = ctx.config.libraryPath;
|
|
65
143
|
|
|
66
144
|
try {
|
|
67
|
-
const lock = await readLockfile(
|
|
145
|
+
const lock = await readLockfile(libraryPath);
|
|
68
146
|
let entries = Object.values(lock.entries);
|
|
69
147
|
if (nameArg) entries = entries.filter((e) => e.name === nameArg);
|
|
70
148
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
71
149
|
|
|
72
|
-
|
|
150
|
+
// Load the library so we can (a) hash local bodies for --check-local and
|
|
151
|
+
// (b) surface LINKED skills that have NO lock entry — the normal `skl link
|
|
152
|
+
// --from` case — which would otherwise be invisible here.
|
|
153
|
+
const library = await loadLibrary(libraryPath);
|
|
154
|
+
|
|
155
|
+
const rows = await Promise.all(
|
|
156
|
+
entries.map((e) =>
|
|
157
|
+
entryMode(libraryPath, e.name) === "linked"
|
|
158
|
+
? Promise.resolve(linkedRow(e))
|
|
159
|
+
: checkLocal
|
|
160
|
+
? Promise.resolve(checkEntryLocal(e, library, libraryPath))
|
|
161
|
+
: checkEntry(e),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Augment: LINKED library skills not already represented by a lock entry get a
|
|
166
|
+
// `linked` row, so a freshly-shelved dev skill shows positive evidence.
|
|
167
|
+
const known = new Set(rows.map((r) => r.name));
|
|
168
|
+
for (const s of library) {
|
|
169
|
+
if (known.has(s.name)) continue;
|
|
170
|
+
if (nameArg && s.name !== nameArg) continue;
|
|
171
|
+
const info = entryModeInfo(libraryPath, s.name);
|
|
172
|
+
if (info.mode === "linked") rows.push(linkedRowFromName(s.name, info.linkTarget));
|
|
173
|
+
}
|
|
174
|
+
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
175
|
+
|
|
176
|
+
if (rows.length === 0) {
|
|
73
177
|
if (json) ctx.json({ ok: true, checked: 0, stale: 0, rows: [] });
|
|
74
178
|
else if (nameArg) ctx.log(`no tracked skill named "${nameArg}"`);
|
|
75
179
|
else ctx.log("no tracked third-party skills (lockfile is empty)");
|
|
76
180
|
return 0;
|
|
77
181
|
}
|
|
78
182
|
|
|
79
|
-
const rows = await Promise.all(entries.map((e) => checkEntry(e)));
|
|
80
183
|
const stale = rows.filter((r) => r.status === "stale");
|
|
184
|
+
const diverged = rows.filter((r) => r.status === "diverged");
|
|
81
185
|
|
|
82
186
|
if (json) {
|
|
83
187
|
ctx.json({
|
|
84
188
|
ok: true,
|
|
85
189
|
checked: rows.length,
|
|
86
190
|
stale: stale.length,
|
|
191
|
+
diverged: diverged.length,
|
|
87
192
|
rows,
|
|
88
193
|
});
|
|
89
194
|
} else {
|
|
90
195
|
for (const r of rows) {
|
|
91
|
-
const mark =
|
|
196
|
+
const mark =
|
|
197
|
+
r.status === "stale" ? "STALE "
|
|
198
|
+
: r.status === "diverged" ? "DIVERGED"
|
|
199
|
+
: r.status === "current" ? "current "
|
|
200
|
+
: r.status === "linked" ? "linked "
|
|
201
|
+
: "unknown ";
|
|
92
202
|
const refInfo =
|
|
93
|
-
r.status === "
|
|
94
|
-
?
|
|
95
|
-
: r.status === "
|
|
96
|
-
? shortRef(r.installedRef)
|
|
97
|
-
:
|
|
98
|
-
|
|
203
|
+
r.status === "linked"
|
|
204
|
+
? r.note
|
|
205
|
+
: r.status === "stale"
|
|
206
|
+
? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
|
|
207
|
+
: r.status === "diverged"
|
|
208
|
+
? r.note
|
|
209
|
+
: r.status === "current"
|
|
210
|
+
? shortRef(r.installedRef) + (checkLocal ? " (offline)" : "")
|
|
211
|
+
: `${shortRef(r.installedRef)} (${r.note})`;
|
|
212
|
+
const extra =
|
|
213
|
+
r.note && !["unknown", "linked", "diverged"].includes(r.status) ? ` [${r.note}]` : "";
|
|
99
214
|
ctx.log(`${mark} ${r.name.padEnd(28)} ${r.channel.padEnd(15)} ${refInfo}${extra}`);
|
|
100
215
|
}
|
|
101
216
|
ctx.log("");
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
ctx.log(
|
|
217
|
+
if (checkLocal) {
|
|
218
|
+
ctx.log(`${rows.length} tracked, ${diverged.length} locally diverged (offline check — no upstream probed).`);
|
|
219
|
+
if (diverged.length > 0) ctx.log("re-run \`skl update [name]\` to see the upstream diff, or \`--force\` to overwrite.");
|
|
220
|
+
} else {
|
|
221
|
+
ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
|
|
222
|
+
if (stale.length > 0) ctx.log(`run \`skl update [name]\` to re-pull (domain tags are preserved).`);
|
|
105
223
|
}
|
|
106
224
|
}
|
|
107
|
-
// Non-zero exit when stale skills exist, so
|
|
108
|
-
|
|
225
|
+
// Non-zero exit when stale (or, offline, locally-diverged) skills exist, so
|
|
226
|
+
// agents/CI can branch on it.
|
|
227
|
+
const flagged = checkLocal ? diverged.length : stale.length;
|
|
228
|
+
return flagged > 0 ? 2 : 0;
|
|
109
229
|
} catch (err) {
|
|
110
230
|
ctx.error("outdated: failed:", err instanceof Error ? err.message : String(err));
|
|
111
231
|
return 1;
|
|
@@ -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
|
+
}
|