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.
Files changed (57) hide show
  1. package/README.md +57 -19
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +31 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/agents.ts +120 -0
  7. package/src/commands/drop.ts +21 -13
  8. package/src/commands/import.ts +44 -28
  9. package/src/commands/infer.ts +6 -6
  10. package/src/commands/link.test.ts +160 -0
  11. package/src/commands/link.ts +317 -0
  12. package/src/commands/ls.ts +118 -18
  13. package/src/commands/mode-surfacing.test.ts +110 -0
  14. package/src/commands/outdated.test.ts +55 -0
  15. package/src/commands/outdated.ts +138 -18
  16. package/src/commands/refresh.ts +133 -0
  17. package/src/commands/remediation.test.ts +149 -0
  18. package/src/commands/rename.test.ts +121 -0
  19. package/src/commands/rename.ts +64 -0
  20. package/src/commands/retag.ts +58 -0
  21. package/src/commands/retire.ts +39 -0
  22. package/src/commands/rm.test.ts +133 -0
  23. package/src/commands/rm.ts +107 -0
  24. package/src/commands/roots.ts +41 -0
  25. package/src/commands/scan.ts +122 -30
  26. package/src/commands/show.ts +4 -1
  27. package/src/commands/status.ts +43 -8
  28. package/src/commands/tag.test.ts +109 -0
  29. package/src/commands/tag.ts +68 -0
  30. package/src/commands/unretire.ts +33 -0
  31. package/src/commands/untag.ts +73 -0
  32. package/src/commands/update.test.ts +71 -0
  33. package/src/commands/update.ts +65 -15
  34. package/src/commands/use.test.ts +92 -0
  35. package/src/commands/use.ts +46 -23
  36. package/src/commands/where.ts +232 -0
  37. package/src/config.test.ts +69 -0
  38. package/src/config.ts +79 -10
  39. package/src/core/agents.test.ts +232 -0
  40. package/src/core/agents.ts +363 -0
  41. package/src/core/bundle.ts +12 -15
  42. package/src/core/core.test.ts +14 -1
  43. package/src/core/crawl.ts +22 -5
  44. package/src/core/dedupe.ts +36 -0
  45. package/src/core/deployments.test.ts +147 -0
  46. package/src/core/deployments.ts +208 -0
  47. package/src/core/fetch.ts +344 -70
  48. package/src/core/indexgen.ts +2 -0
  49. package/src/core/library.test.ts +41 -0
  50. package/src/core/library.ts +61 -16
  51. package/src/core/lifecycle.ts +252 -0
  52. package/src/core/surfaces.ts +46 -0
  53. package/src/core/taxonomy.test.ts +159 -0
  54. package/src/core/taxonomy.ts +190 -0
  55. package/src/lib/fs.ts +2 -2
  56. package/src/types.ts +85 -15
  57. package/src/core/overlay.ts +0 -63
@@ -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
+ }
@@ -1,16 +1,49 @@
1
1
  // `skl ls [bundle]` — one-line listing of the whole library, or a single
2
2
  // bundle (tag query). Excludes retired by default; `--all` includes them.
3
3
 
4
+ import { statSync } from "node:fs";
4
5
  import type { Ctx, Skill } from "../types.ts";
5
- import { activeSkills } from "../core/library.ts";
6
+ import { activeSkills, entryModeInfo } from "../core/library.ts";
6
7
  import { resolveBundle } from "../core/bundle.ts";
8
+ import { inventoryDeployments } from "../core/deployments.ts";
9
+ import { knownAgentSurfacePaths } from "../core/surfaces.ts";
10
+ import { isCleanSite } from "../core/agents.ts";
7
11
 
8
12
  export const meta = {
9
13
  name: "ls",
10
14
  summary: "One-line listing of the library, or one bundle",
11
- usage: "skl ls [bundle] [--all] [--json]",
15
+ usage: "skl ls [bundle] [--all] [--sort modified|name|domain|deploys|source] [--json]",
12
16
  } as const;
13
17
 
18
+ const SORT_FIELDS = ["modified", "name", "domain", "deploys", "source"] as const;
19
+ type SortField = (typeof SORT_FIELDS)[number];
20
+
21
+ /** Sort a copy of skills by a report field (modified/deploys descending). */
22
+ function sortSkills(skills: Skill[], field: SortField, deployCounts: Map<string, number>): Skill[] {
23
+ const primary = (s: Skill) => s.primaryDomain ?? s.domains[0] ?? "_unclassified";
24
+ const mtimes =
25
+ field === "modified" ? new Map(skills.map((s) => [s.name, fileTimes(s.bodyPath).modifiedAt])) : null;
26
+ return skills.slice().sort((a, b) => {
27
+ if (field === "name") return a.name.localeCompare(b.name);
28
+ if (field === "domain") return primary(a).localeCompare(primary(b)) || a.name.localeCompare(b.name);
29
+ if (field === "source") {
30
+ const sa = a.source ? "vendored" : "local";
31
+ const sb = b.source ? "vendored" : "local";
32
+ return sa.localeCompare(sb) || a.name.localeCompare(b.name);
33
+ }
34
+ if (field === "deploys") {
35
+ return (deployCounts.get(b.name) ?? 0) - (deployCounts.get(a.name) ?? 0) || a.name.localeCompare(b.name);
36
+ }
37
+ // modified — most-recent first; null/untracked last.
38
+ const am = mtimes!.get(a.name) ?? null;
39
+ const bm = mtimes!.get(b.name) ?? null;
40
+ if (!am && !bm) return a.name.localeCompare(b.name);
41
+ if (!am) return 1;
42
+ if (!bm) return -1;
43
+ return bm.localeCompare(am);
44
+ });
45
+ }
46
+
14
47
  function oneLine(desc: string, max = 100): string {
15
48
  const flat = desc.replace(/\s+/g, " ").trim();
16
49
  return flat.length <= max ? flat : flat.slice(0, max - 1).trimEnd() + "…";
@@ -29,46 +62,113 @@ function emitHuman(ctx: Ctx, skills: Skill[]): void {
29
62
  }
30
63
  }
31
64
 
32
- function toJson(skills: Skill[]): unknown {
33
- return skills.map((s) => ({
34
- name: s.name,
35
- description: s.description,
36
- primaryDomain: s.primaryDomain,
37
- domains: s.domains,
38
- path: s.path,
39
- retired: s.retired,
40
- }));
65
+ /** Stat-derived timestamps for a skill's SKILL.md (ISO-8601), null if unavailable. */
66
+ function fileTimes(bodyPath: string): { modifiedAt: string | null; createdAt: string | null } {
67
+ try {
68
+ const st = statSync(bodyPath);
69
+ const created = st.birthtimeMs > 0 ? st.birthtime : null;
70
+ return {
71
+ modifiedAt: st.mtime.toISOString(),
72
+ createdAt: created ? created.toISOString() : null,
73
+ };
74
+ } catch {
75
+ return { modifiedAt: null, createdAt: null };
76
+ }
77
+ }
78
+
79
+ function toJson(
80
+ skills: Skill[],
81
+ libraryPath: string,
82
+ deployCounts: Map<string, number>,
83
+ ): unknown {
84
+ return skills.map((s) => {
85
+ const { mode, linkTarget } = entryModeInfo(libraryPath, s.name);
86
+ const { modifiedAt, createdAt } = fileTimes(s.bodyPath);
87
+ return {
88
+ name: s.name,
89
+ description: s.description,
90
+ primaryDomain: s.primaryDomain,
91
+ domains: s.domains,
92
+ path: s.path,
93
+ retired: s.retired,
94
+ mode,
95
+ linkTarget,
96
+ // ADR-0008 §7.1 additions: a string source (UI maps "vendored"/"local"),
97
+ // stat timestamps, and the count of clean deployment sites.
98
+ source: s.source ? "vendored" : "local",
99
+ modifiedAt,
100
+ createdAt,
101
+ deployCount: deployCounts.get(s.name) ?? 0,
102
+ };
103
+ });
104
+ }
105
+
106
+ /** Count clean (`linked`) deployment sites per skill across all surfaces. */
107
+ async function deployCountsFor(ctx: Ctx, lib: Skill[]): Promise<Map<string, number>> {
108
+ const counts = new Map<string, number>();
109
+ try {
110
+ const surfaces = [...ctx.roots, ctx.config.globalCoreTarget, ...knownAgentSurfacePaths()];
111
+ const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
112
+ for (const site of report.sites) {
113
+ // "clean" = the ✓ states in `skl where` (linked OR canonical source), shared
114
+ // with the agents matrix via isCleanSite so the Deploys column agrees with it.
115
+ if (isCleanSite(site)) counts.set(site.name, (counts.get(site.name) ?? 0) + 1);
116
+ }
117
+ } catch {
118
+ // deployment scan is best-effort enrichment; ls still returns the library.
119
+ }
120
+ return counts;
41
121
  }
42
122
 
43
123
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
44
124
  try {
45
125
  const json = argv.includes("--json");
46
126
  const all = argv.includes("--all");
47
- const positional = argv.filter((a) => !a.startsWith("--"));
127
+ // Extract `--sort <field>` and its value so the value isn't taken as a bundle.
128
+ const sortIdx = argv.indexOf("--sort");
129
+ const sortField = sortIdx >= 0 ? argv[sortIdx + 1] : undefined;
130
+ if (sortField !== undefined && !SORT_FIELDS.includes(sortField as SortField)) {
131
+ ctx.error(`ls: --sort expects one of: ${SORT_FIELDS.join(", ")}`);
132
+ return 1;
133
+ }
134
+ const consumed = new Set<number>();
135
+ if (sortIdx >= 0) {
136
+ consumed.add(sortIdx);
137
+ consumed.add(sortIdx + 1);
138
+ }
139
+ const positional = argv.filter((a, i) => !a.startsWith("--") && !consumed.has(i));
48
140
  const bundleName = positional[0];
49
141
 
50
142
  const skills = await ctx.loadLibrary();
143
+ // deployCounts needed for --json, or to sort by deploys.
144
+ const deployCounts =
145
+ json || sortField === "deploys"
146
+ ? await deployCountsFor(ctx, skills)
147
+ : new Map<string, number>();
148
+ const applySort = (list: Skill[]) =>
149
+ sortField ? sortSkills(list, sortField as SortField, deployCounts) : list;
51
150
 
52
151
  if (bundleName) {
53
152
  const bundle = await resolveBundle(skills, bundleName, {
54
153
  includeRetired: all,
55
154
  });
155
+ const rows = applySort(bundle.skills);
56
156
  if (json) {
57
- ctx.json({ bundle: bundle.name, skills: toJson(bundle.skills) });
157
+ ctx.json({ bundle: bundle.name, skills: toJson(rows, ctx.libraryPath, deployCounts) });
58
158
  return 0;
59
159
  }
60
- if (bundle.skills.length === 0) {
160
+ if (rows.length === 0) {
61
161
  ctx.log(`Bundle "${bundle.name}" has no skills.`);
62
162
  return 0;
63
163
  }
64
- ctx.log(`# ${bundle.name} (${bundle.skills.length})`);
65
- emitHuman(ctx, bundle.skills);
164
+ ctx.log(`# ${bundle.name} (${rows.length})`);
165
+ emitHuman(ctx, rows);
66
166
  return 0;
67
167
  }
68
168
 
69
- const listed = all ? skills : activeSkills(skills);
169
+ const listed = applySort(all ? skills : activeSkills(skills));
70
170
  if (json) {
71
- ctx.json(toJson(listed));
171
+ ctx.json(toJson(listed, ctx.libraryPath, deployCounts));
72
172
  return 0;
73
173
  }
74
174
  emitHuman(ctx, listed);
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run as lsRun } from "./ls.ts";
6
+ import { run as outdatedRun } from "./outdated.ts";
7
+ import { run as updateRun } from "./update.ts";
8
+ import { loadLibrary } from "../core/library.ts";
9
+ import { hashContent } from "../core/crawl.ts";
10
+ import { parseFrontmatter } from "../lib/frontmatter.ts";
11
+ import type { Ctx } from "../types.ts";
12
+
13
+ function makeCtx(libraryPath: string) {
14
+ const json: unknown[] = [];
15
+ const ctx = {
16
+ config: { libraryPath },
17
+ libraryPath,
18
+ loadLibrary: () => loadLibrary(libraryPath),
19
+ log: () => {},
20
+ error: () => {},
21
+ json: (v: unknown) => json.push(v),
22
+ } as unknown as Ctx;
23
+ return { ctx, json };
24
+ }
25
+
26
+ describe("owned-vs-linked surfacing (friction #7)", () => {
27
+ let tmp: string;
28
+ let library: string;
29
+ let dev: string;
30
+
31
+ beforeEach(async () => {
32
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-mode-")));
33
+ library = join(tmp, "library");
34
+ await mkdir(join(library, "owned1"), { recursive: true });
35
+ await writeFile(join(library, "owned1", "SKILL.md"), "---\nname: owned1\ndescription: o\n---\n\nbody\n");
36
+ dev = join(tmp, "dev", "devskill");
37
+ await mkdir(dev, { recursive: true });
38
+ await writeFile(join(dev, "SKILL.md"), "---\nname: devskill\ndescription: d\n---\n\nbody\n");
39
+ await symlink(dev, join(library, "devskill"));
40
+ });
41
+ afterEach(async () => {
42
+ await rm(tmp, { recursive: true, force: true });
43
+ });
44
+
45
+ test("ls --json carries mode + linkTarget", async () => {
46
+ const { ctx, json } = makeCtx(library);
47
+ await lsRun(["--all", "--json"], ctx);
48
+ const rows = json[0] as Array<{ name: string; mode: string; linkTarget: string | null }>;
49
+ const owned = rows.find((r) => r.name === "owned1")!;
50
+ const linked = rows.find((r) => r.name === "devskill")!;
51
+ expect(owned.mode).toBe("owned");
52
+ expect(owned.linkTarget).toBeNull();
53
+ expect(linked.mode).toBe("linked");
54
+ expect(linked.linkTarget).toBe(dev);
55
+ });
56
+
57
+ test("outdated surfaces a LINKED skill that has NO lock entry", async () => {
58
+ const { ctx, json } = makeCtx(library);
59
+ const code = await outdatedRun(["--json"], ctx);
60
+ expect(code).toBe(0);
61
+ const rows = (json[0] as { rows: Array<{ name: string; status: string }> }).rows;
62
+ expect(rows.find((r) => r.name === "devskill")!.status).toBe("linked");
63
+ });
64
+
65
+ test("update reports a LINKED skill (no lock entry) as explicitly skipped", async () => {
66
+ const { ctx, json } = makeCtx(library);
67
+ await updateRun(["devskill", "--json"], ctx);
68
+ const results = (json[0] as { results: Array<{ name: string; outcome: string }> }).results;
69
+ expect(results.find((r) => r.name === "devskill")!.outcome).toBe("skipped");
70
+ });
71
+
72
+ test("outdated --check-local flags local divergence offline (no network)", async () => {
73
+ // owned1 tracked with an installedHash that does NOT match the local body.
74
+ const localBody = parseFrontmatter("---\nname: owned1\n---\n\nbody\n").body;
75
+ const staleHash = hashContent(localBody + "DIFFERENT");
76
+ await writeFile(
77
+ join(library, "shelf.lock.json"),
78
+ JSON.stringify({
79
+ version: 1,
80
+ entries: {
81
+ owned1: { name: "owned1", source: "github:o/r", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false, installedHash: staleHash },
82
+ },
83
+ }),
84
+ );
85
+ const { ctx, json } = makeCtx(library);
86
+ const code = await outdatedRun(["--check-local", "--json"], ctx);
87
+ expect(code).toBe(2); // diverged -> non-zero
88
+ const rows = (json[0] as { rows: Array<{ name: string; status: string }> }).rows;
89
+ expect(rows.find((r) => r.name === "owned1")!.status).toBe("diverged");
90
+ });
91
+
92
+ test("outdated --check-local reports a matching baseline as current (offline)", async () => {
93
+ const localBody = parseFrontmatter("---\nname: owned1\n---\n\nbody\n").body;
94
+ const matchHash = hashContent(localBody);
95
+ await writeFile(
96
+ join(library, "shelf.lock.json"),
97
+ JSON.stringify({
98
+ version: 1,
99
+ entries: {
100
+ owned1: { name: "owned1", source: "github:o/r", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false, installedHash: matchHash },
101
+ },
102
+ }),
103
+ );
104
+ const { ctx, json } = makeCtx(library);
105
+ const code = await outdatedRun(["--check-local", "--json"], ctx);
106
+ expect(code).toBe(0);
107
+ const rows = (json[0] as { rows: Array<{ name: string; status: string }> }).rows;
108
+ expect(rows.find((r) => r.name === "owned1")!.status).toBe("current");
109
+ });
110
+ });
@@ -0,0 +1,55 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run } from "./outdated.ts";
6
+ import type { Ctx } from "../types.ts";
7
+
8
+ function makeCtx(libraryPath: string) {
9
+ const json: unknown[] = [];
10
+ const ctx = {
11
+ config: { libraryPath },
12
+ libraryPath,
13
+ log: () => {},
14
+ error: () => {},
15
+ json: (v: unknown) => json.push(v),
16
+ } as unknown as Ctx;
17
+ return { ctx, json };
18
+ }
19
+
20
+ describe("skl outdated — LINKED entries are reported, not probed (ADR-0004)", () => {
21
+ let tmp: string;
22
+ let library: string;
23
+
24
+ beforeEach(async () => {
25
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-outdated-")));
26
+ library = join(tmp, "library");
27
+ const dev = join(tmp, "dev", "devskill");
28
+ await mkdir(library, { recursive: true });
29
+ await mkdir(dev, { recursive: true });
30
+ await writeFile(join(dev, "SKILL.md"), "---\nname: devskill\n---\n\nbody\n");
31
+ await symlink(dev, join(library, "devskill"));
32
+ await writeFile(
33
+ join(library, "shelf.lock.json"),
34
+ JSON.stringify({
35
+ version: 1,
36
+ entries: {
37
+ devskill: { name: "devskill", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
38
+ },
39
+ }),
40
+ );
41
+ });
42
+ afterEach(async () => {
43
+ await rm(tmp, { recursive: true, force: true });
44
+ });
45
+
46
+ test("a LINKED entry is status 'linked', never counted stale", async () => {
47
+ const { ctx, json } = makeCtx(library);
48
+ const code = await run(["--json"], ctx);
49
+
50
+ expect(code).toBe(0); // not stale -> exit 0 (no network probe of the dead github ref)
51
+ const report = json[0] as { stale: number; rows: Array<{ name: string; status: string }> };
52
+ expect(report.stale).toBe(0);
53
+ expect(report.rows.find((r) => r.name === "devskill")!.status).toBe("linked");
54
+ });
55
+ });