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.
Files changed (67) hide show
  1. package/README.md +83 -20
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +39 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/adopted.test.ts +144 -0
  7. package/src/commands/agents-config.test.ts +126 -0
  8. package/src/commands/agents.test.ts +96 -0
  9. package/src/commands/agents.ts +243 -0
  10. package/src/commands/drop.ts +21 -13
  11. package/src/commands/import.ts +44 -28
  12. package/src/commands/infer.ts +6 -6
  13. package/src/commands/link.test.ts +160 -0
  14. package/src/commands/link.ts +317 -0
  15. package/src/commands/ls.ts +136 -19
  16. package/src/commands/migrate.test.ts +157 -0
  17. package/src/commands/migrate.ts +260 -0
  18. package/src/commands/mode-surfacing.test.ts +110 -0
  19. package/src/commands/outdated.test.ts +55 -0
  20. package/src/commands/outdated.ts +166 -18
  21. package/src/commands/projects.test.ts +85 -0
  22. package/src/commands/projects.ts +80 -0
  23. package/src/commands/refresh.ts +133 -0
  24. package/src/commands/remediation.test.ts +149 -0
  25. package/src/commands/rename.test.ts +121 -0
  26. package/src/commands/rename.ts +64 -0
  27. package/src/commands/retag.ts +58 -0
  28. package/src/commands/retire.ts +39 -0
  29. package/src/commands/rm.test.ts +133 -0
  30. package/src/commands/rm.ts +107 -0
  31. package/src/commands/roots.ts +41 -0
  32. package/src/commands/scan.ts +122 -30
  33. package/src/commands/show.ts +130 -11
  34. package/src/commands/status.ts +43 -8
  35. package/src/commands/tag.test.ts +109 -0
  36. package/src/commands/tag.ts +68 -0
  37. package/src/commands/track.test.ts +170 -0
  38. package/src/commands/track.ts +340 -0
  39. package/src/commands/unretire.ts +33 -0
  40. package/src/commands/untag.ts +73 -0
  41. package/src/commands/untrack.ts +44 -0
  42. package/src/commands/update.test.ts +71 -0
  43. package/src/commands/update.ts +157 -15
  44. package/src/commands/use.test.ts +122 -0
  45. package/src/commands/use.ts +46 -23
  46. package/src/commands/where.ts +232 -0
  47. package/src/config.test.ts +198 -0
  48. package/src/config.ts +232 -10
  49. package/src/core/agents.test.ts +319 -0
  50. package/src/core/agents.ts +438 -0
  51. package/src/core/bundle.ts +12 -15
  52. package/src/core/core.test.ts +21 -8
  53. package/src/core/crawl.ts +22 -5
  54. package/src/core/dedupe.ts +36 -0
  55. package/src/core/deployments.test.ts +147 -0
  56. package/src/core/deployments.ts +208 -0
  57. package/src/core/fetch.ts +371 -75
  58. package/src/core/indexgen.ts +2 -0
  59. package/src/core/library.test.ts +41 -0
  60. package/src/core/library.ts +61 -16
  61. package/src/core/lifecycle.ts +252 -0
  62. package/src/core/surfaces.ts +46 -0
  63. package/src/core/taxonomy.test.ts +159 -0
  64. package/src/core/taxonomy.ts +190 -0
  65. package/src/lib/fs.ts +2 -2
  66. package/src/types.ts +155 -15
  67. package/src/core/overlay.ts +0 -63
@@ -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
+ });
@@ -6,17 +6,22 @@
6
6
  //
7
7
  // Read command: supports --json.
8
8
 
9
- import type { Ctx, LockEntry } from "../types.ts";
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" | "adopted";
20
25
 
21
26
  interface Row {
22
27
  name: string;
@@ -59,53 +64,196 @@ 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
+ /**
91
+ * An ADOPTED entry (`skl track`/`skl migrate`): provenance is known but the upstream
92
+ * baseline was NEVER verified against real upstream (the recorded ref may be empty and
93
+ * the installedHash describes the LOCAL copy only). Reporting it as stale/current off the
94
+ * empty ref would be a lie, so surface it as `adopted` and do NOT network-probe (ADR-0011).
95
+ */
96
+ function adoptedRow(entry: LockEntry): Row {
97
+ return {
98
+ name: entry.name,
99
+ channel: entry.channel,
100
+ source: entry.source,
101
+ installedRef: entry.ref || "-",
102
+ latestRef: null,
103
+ status: "adopted",
104
+ note: "provenance adopted; baseline unverified — run `skl update` to reconcile",
105
+ };
106
+ }
107
+
108
+ function linkedRowFromName(name: string, linkTarget: string | null): Row {
109
+ return {
110
+ name,
111
+ channel: "local",
112
+ source: linkTarget ?? "(dev repo)",
113
+ installedRef: "-",
114
+ latestRef: null,
115
+ status: "linked",
116
+ note: "dev repo owns versioning",
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Offline (no network) divergence check for an OWNED tracked skill: compare the local
122
+ * SKILL.md body hash to the baseline recorded at install/update time
123
+ * (lockfile.installedHash). Answers "have I locally edited this?" without probing
124
+ * upstream — usable on a plane / in CI with no creds.
125
+ */
126
+ function checkEntryLocal(entry: LockEntry, library: Skill[], libraryPath: string): Row {
127
+ const skill = findByName(library, entry.name);
128
+ const bodyPath = skill?.bodyPath ?? join(libraryPath, entry.name, "SKILL.md");
129
+ let localHash: string | null = null;
130
+ try {
131
+ if (existsSync(bodyPath)) {
132
+ const raw = readFileSync(bodyPath, "utf8");
133
+ localHash = hashContent(parseFrontmatter(raw).body);
134
+ }
135
+ } catch {
136
+ /* leave null */
137
+ }
138
+ const base = {
139
+ name: entry.name,
140
+ channel: entry.channel,
141
+ source: entry.source,
142
+ installedRef: entry.ref,
143
+ latestRef: null,
144
+ };
145
+ if (entry.installedHash == null) {
146
+ return { ...base, status: "unknown", note: "no recorded baseline (installed before hash tracking) — re-run `skl update` to record one" };
147
+ }
148
+ if (localHash == null) {
149
+ return { ...base, status: "unknown", note: "local SKILL.md unreadable" };
150
+ }
151
+ return localHash === entry.installedHash
152
+ ? { ...base, status: "current", note: "matches installed baseline (offline)" }
153
+ : { ...base, status: "diverged", note: "local body diverged from installed baseline (offline)" };
154
+ }
155
+
62
156
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
63
157
  const json = argv.includes("--json");
158
+ const checkLocal = argv.includes("--check-local");
64
159
  const nameArg = argv.find((a) => !a.startsWith("-")) ?? null;
160
+ const libraryPath = ctx.config.libraryPath;
65
161
 
66
162
  try {
67
- const lock = await readLockfile(ctx.config.libraryPath);
163
+ const lock = await readLockfile(libraryPath);
68
164
  let entries = Object.values(lock.entries);
69
165
  if (nameArg) entries = entries.filter((e) => e.name === nameArg);
70
166
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
71
167
 
72
- if (entries.length === 0) {
168
+ // Load the library so we can (a) hash local bodies for --check-local and
169
+ // (b) surface LINKED skills that have NO lock entry — the normal `skl link
170
+ // --from` case — which would otherwise be invisible here.
171
+ const library = await loadLibrary(libraryPath);
172
+
173
+ const rows = await Promise.all(
174
+ entries.map((e) =>
175
+ entryMode(libraryPath, e.name) === "linked"
176
+ ? Promise.resolve(linkedRow(e))
177
+ : e.adopted === true
178
+ ? // An adopted entry has an unverified (often empty) baseline — never probe
179
+ // upstream off it; report it as `adopted` so `update` reconciles (ADR-0011).
180
+ // --check-local still does the offline body-vs-baseline compare below.
181
+ checkLocal
182
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
183
+ : Promise.resolve(adoptedRow(e))
184
+ : checkLocal
185
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
186
+ : checkEntry(e),
187
+ ),
188
+ );
189
+
190
+ // Augment: LINKED library skills not already represented by a lock entry get a
191
+ // `linked` row, so a freshly-shelved dev skill shows positive evidence.
192
+ const known = new Set(rows.map((r) => r.name));
193
+ for (const s of library) {
194
+ if (known.has(s.name)) continue;
195
+ if (nameArg && s.name !== nameArg) continue;
196
+ const info = entryModeInfo(libraryPath, s.name);
197
+ if (info.mode === "linked") rows.push(linkedRowFromName(s.name, info.linkTarget));
198
+ }
199
+ rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
200
+
201
+ if (rows.length === 0) {
73
202
  if (json) ctx.json({ ok: true, checked: 0, stale: 0, rows: [] });
74
203
  else if (nameArg) ctx.log(`no tracked skill named "${nameArg}"`);
75
204
  else ctx.log("no tracked third-party skills (lockfile is empty)");
76
205
  return 0;
77
206
  }
78
207
 
79
- const rows = await Promise.all(entries.map((e) => checkEntry(e)));
80
208
  const stale = rows.filter((r) => r.status === "stale");
209
+ const diverged = rows.filter((r) => r.status === "diverged");
81
210
 
82
211
  if (json) {
83
212
  ctx.json({
84
213
  ok: true,
85
214
  checked: rows.length,
86
215
  stale: stale.length,
216
+ diverged: diverged.length,
87
217
  rows,
88
218
  });
89
219
  } else {
90
220
  for (const r of rows) {
91
- const mark = r.status === "stale" ? "STALE " : r.status === "current" ? "current" : "unknown";
221
+ const mark =
222
+ r.status === "stale" ? "STALE "
223
+ : r.status === "diverged" ? "DIVERGED"
224
+ : r.status === "current" ? "current "
225
+ : r.status === "linked" ? "linked "
226
+ : r.status === "adopted" ? "adopted "
227
+ : "unknown ";
92
228
  const refInfo =
93
- r.status === "stale"
94
- ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
95
- : r.status === "current"
96
- ? shortRef(r.installedRef)
97
- : `${shortRef(r.installedRef)} (${r.note})`;
98
- const extra = r.note && r.status !== "unknown" ? ` [${r.note}]` : "";
229
+ r.status === "linked"
230
+ ? r.note
231
+ : r.status === "adopted"
232
+ ? r.note
233
+ : r.status === "stale"
234
+ ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
235
+ : r.status === "diverged"
236
+ ? r.note
237
+ : r.status === "current"
238
+ ? shortRef(r.installedRef) + (checkLocal ? " (offline)" : "")
239
+ : `${shortRef(r.installedRef)} (${r.note})`;
240
+ const extra =
241
+ r.note && !["unknown", "linked", "diverged", "adopted"].includes(r.status) ? ` [${r.note}]` : "";
99
242
  ctx.log(`${mark} ${r.name.padEnd(28)} ${r.channel.padEnd(15)} ${refInfo}${extra}`);
100
243
  }
101
244
  ctx.log("");
102
- ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
103
- if (stale.length > 0) {
104
- ctx.log(`run \`skl update [name]\` to re-pull (overlays are preserved).`);
245
+ if (checkLocal) {
246
+ ctx.log(`${rows.length} tracked, ${diverged.length} locally diverged (offline check — no upstream probed).`);
247
+ if (diverged.length > 0) ctx.log("re-run \`skl update [name]\` to see the upstream diff, or \`--force\` to overwrite.");
248
+ } else {
249
+ ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
250
+ if (stale.length > 0) ctx.log(`run \`skl update [name]\` to re-pull (domain tags are preserved).`);
105
251
  }
106
252
  }
107
- // Non-zero exit when stale skills exist, so agents/CI can branch on it.
108
- return stale.length > 0 ? 2 : 0;
253
+ // Non-zero exit when stale (or, offline, locally-diverged) skills exist, so
254
+ // agents/CI can branch on it.
255
+ const flagged = checkLocal ? diverged.length : stale.length;
256
+ return flagged > 0 ? 2 : 0;
109
257
  } catch (err) {
110
258
  ctx.error("outdated: failed:", err instanceof Error ? err.message : String(err));
111
259
  return 1;
@@ -0,0 +1,85 @@
1
+ // `skl projects [add|rm|ls]` verb (ADR-0010 §5a). Drives the real ctx through an
2
+ // isolated SKILLSHELF_CONFIG so the JSON shapes the GUI/bridge bind to are pinned.
3
+
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { loadContext } from "../config.ts";
9
+ import { run as projectsRun } from "./projects.ts";
10
+ import type { Ctx } from "../types.ts";
11
+
12
+ describe("skl projects verb", () => {
13
+ let tmp: string;
14
+ let cfg: string;
15
+
16
+ async function makeCtx(): Promise<{ ctx: Ctx; json: unknown[]; logs: string[] }> {
17
+ const json: unknown[] = [];
18
+ const logs: string[] = [];
19
+ const ctx = await loadContext({ env: { SKILLSHELF_CONFIG: cfg } as NodeJS.ProcessEnv });
20
+ ctx.json = (v: unknown) => json.push(v);
21
+ ctx.log = (...a: unknown[]) => logs.push(a.join(" "));
22
+ ctx.error = () => {};
23
+ return { ctx, json, logs };
24
+ }
25
+
26
+ beforeEach(async () => {
27
+ tmp = await mkdtemp(join(tmpdir(), "skl-projects-cmd-"));
28
+ cfg = join(tmp, "config.json");
29
+ });
30
+ afterEach(async () => {
31
+ await rm(tmp, { recursive: true, force: true });
32
+ });
33
+
34
+ test("ls --json on an empty registry returns {projects:[]}", async () => {
35
+ const { ctx, json } = await makeCtx();
36
+ const code = await projectsRun(["ls", "--json"], ctx);
37
+ expect(code).toBe(0);
38
+ expect(json[0]).toEqual({ projects: [] });
39
+ });
40
+
41
+ test("default verb (no args) is ls", async () => {
42
+ const { ctx, json } = await makeCtx();
43
+ const code = await projectsRun(["--json"], ctx);
44
+ expect(code).toBe(0);
45
+ expect(json[0]).toEqual({ projects: [] });
46
+ });
47
+
48
+ test("add persists and reports the updated list", async () => {
49
+ const { ctx, json } = await makeCtx();
50
+ const code = await projectsRun(["add", "/tmp/webapp", "--json"], ctx);
51
+ expect(code).toBe(0);
52
+ expect(json[0]).toEqual({ projects: ["/tmp/webapp"], added: true });
53
+
54
+ // a fresh ctx reads it back from disk
55
+ const { ctx: ctx2, json: json2 } = await makeCtx();
56
+ await projectsRun(["ls", "--json"], ctx2);
57
+ expect(json2[0]).toEqual({ projects: ["/tmp/webapp"] });
58
+ });
59
+
60
+ test("rm removes a persisted project", async () => {
61
+ const { ctx } = await makeCtx();
62
+ await projectsRun(["add", "/tmp/webapp"], ctx);
63
+ const { ctx: ctx2, json } = await makeCtx();
64
+ const code = await projectsRun(["rm", "/tmp/webapp", "--json"], ctx2);
65
+ expect(code).toBe(0);
66
+ expect(json[0]).toEqual({ projects: [], removed: true });
67
+ });
68
+
69
+ test("rm on a non-registered path reports removed:false", async () => {
70
+ const { ctx, json } = await makeCtx();
71
+ const code = await projectsRun(["rm", "/tmp/nope", "--json"], ctx);
72
+ expect(code).toBe(0);
73
+ expect(json[0]).toEqual({ projects: [], removed: false });
74
+ });
75
+
76
+ test("add without a path errors", async () => {
77
+ const { ctx } = await makeCtx();
78
+ expect(await projectsRun(["add"], ctx)).toBe(1);
79
+ });
80
+
81
+ test("unknown verb errors", async () => {
82
+ const { ctx } = await makeCtx();
83
+ expect(await projectsRun(["frobnicate"], ctx)).toBe(1);
84
+ });
85
+ });
@@ -0,0 +1,80 @@
1
+ // `skl projects [add|rm|ls] [path]` — manage the persisted NAV projects list
2
+ // (ADR-0010 §5a). This is navigation state only: a persisted project becomes a
3
+ // selectable scope in the management UI even before anything is deployed into it.
4
+ // It is NEVER deployment truth — cell/count state is always derived from reality
5
+ // (`skl agents`/`skl where`), so an added-but-empty project shows all-absent cells.
6
+ //
7
+ // skl projects [ls] [--json] list the persisted nav projects (default verb)
8
+ // skl projects add <path> add a project dir (expands ~, absolutizes, de-dupes)
9
+ // skl projects rm <path> remove a project dir (matched by resolved path)
10
+ //
11
+ // The GUI's `+ Add project` persists through this verb behind the Tauri bridge.
12
+
13
+ import type { Ctx } from "../types.ts";
14
+
15
+ export const meta = {
16
+ name: "projects",
17
+ summary: "Manage the persisted nav projects shown as scopes in the UI (add/rm/ls)",
18
+ usage: "skl projects [ls|add <path>|rm <path>] [--json]",
19
+ } as const;
20
+
21
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
22
+ const json = argv.includes("--json");
23
+ const rest = argv.filter((a) => a !== "--json");
24
+ const verb = rest[0] && !rest[0].startsWith("-") ? rest[0] : "ls";
25
+
26
+ if (verb === "ls") {
27
+ const unknown = rest.slice(verb === rest[0] ? 1 : 0).find((a) => a.startsWith("-"));
28
+ if (unknown) return usageError(ctx, `unknown argument: ${unknown}`);
29
+ const projects = ctx.config.projects;
30
+ if (json) {
31
+ ctx.json({ projects });
32
+ return 0;
33
+ }
34
+ if (projects.length === 0) {
35
+ ctx.log("No nav projects configured.");
36
+ ctx.log("Add one with: skl projects add <path>");
37
+ return 0;
38
+ }
39
+ ctx.log(`Nav projects (${projects.length}):`);
40
+ for (const p of projects) ctx.log(` ${p}`);
41
+ return 0;
42
+ }
43
+
44
+ if (verb === "add") {
45
+ const path = rest[1];
46
+ if (!path || path.startsWith("-")) return usageError(ctx, "add requires a path");
47
+ const projects = await ctx.addProject(path);
48
+ if (json) {
49
+ ctx.json({ projects, added: true });
50
+ return 0;
51
+ }
52
+ ctx.log(`Added project. Nav projects (${projects.length}):`);
53
+ for (const p of projects) ctx.log(` ${p}`);
54
+ return 0;
55
+ }
56
+
57
+ if (verb === "rm") {
58
+ const path = rest[1];
59
+ if (!path || path.startsWith("-")) return usageError(ctx, "rm requires a path");
60
+ const { projects, removed } = await ctx.removeProject(path);
61
+ if (json) {
62
+ ctx.json({ projects, removed });
63
+ return 0;
64
+ }
65
+ ctx.log(removed ? "Removed project." : "No matching project (nothing removed).");
66
+ if (projects.length > 0) {
67
+ ctx.log(`Nav projects (${projects.length}):`);
68
+ for (const p of projects) ctx.log(` ${p}`);
69
+ }
70
+ return 0;
71
+ }
72
+
73
+ return usageError(ctx, `unknown verb: ${verb}`);
74
+ }
75
+
76
+ function usageError(ctx: Ctx, msg: string): number {
77
+ ctx.error(`skl projects: ${msg}`);
78
+ ctx.error(`usage: ${meta.usage}`);
79
+ return 1;
80
+ }