magus-cli 7.1.0 → 7.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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. package/src/ui/theme-mode.ts +54 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.1.0",
3
+ "version": "7.3.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "magus-cli-darwin-arm64": "7.1.0",
68
- "magus-cli-darwin-x64": "7.1.0",
69
- "magus-cli-linux-x64": "7.1.0"
67
+ "magus-cli-darwin-arm64": "7.3.0",
68
+ "magus-cli-darwin-x64": "7.3.0",
69
+ "magus-cli-linux-x64": "7.3.0"
70
70
  }
71
71
  }
@@ -51,6 +51,7 @@ function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
51
51
  pluginId: "x@magus",
52
52
  pinned: "latest",
53
53
  installed: "1.0.0",
54
+ loaded: "1.0.0",
54
55
  available: "1.0.0",
55
56
  target: null,
56
57
  action: "current",
@@ -318,6 +319,7 @@ describe("countPluginStates", () => {
318
319
  update: 2,
319
320
  repair: 1,
320
321
  current: 1,
322
+ "stale-load": 0,
321
323
  unknown: 1,
322
324
  });
323
325
  });
@@ -328,6 +330,7 @@ describe("countPluginStates", () => {
328
330
  update: 0,
329
331
  repair: 0,
330
332
  current: 0,
333
+ "stale-load": 0,
331
334
  unknown: 0,
332
335
  });
333
336
  });
@@ -15,6 +15,7 @@ import { existsSync } from "node:fs";
15
15
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
+ import { doctorExitCode } from "../cli/doctor.js";
18
19
  import { checkProject, fixDoctorIssues } from "../services/doctor.js";
19
20
  import type { DoctorResult } from "../services/doctor.js";
20
21
 
@@ -461,3 +462,33 @@ describe("doctor", () => {
461
462
  });
462
463
  });
463
464
  });
465
+
466
+ /**
467
+ * `magus doctor`'s exit code, and the one thing `--fix` must not silence.
468
+ *
469
+ * Stale plugin loads are printed by doctor and repaired by nothing it runs — the
470
+ * section says so itself. They used to be added to the same counter as the
471
+ * fixable problems, and the code was `problems > 0 && !fix ? 1 : 0`, so
472
+ * `magus doctor --fix` printed its red lines and exited 0. A CI or pre-flight
473
+ * step reading that exit code went green over a plugin whose sessions load a
474
+ * version nobody installed.
475
+ */
476
+ describe("doctorExitCode", () => {
477
+ it("exits 0 when nothing is wrong, with or without --fix", () => {
478
+ expect(doctorExitCode({ fixable: 0, unfixable: 0, fix: false })).toBe(0);
479
+ expect(doctorExitCode({ fixable: 0, unfixable: 0, fix: true })).toBe(0);
480
+ });
481
+
482
+ it("exits 1 for fixable problems only when --fix was NOT asked for", () => {
483
+ expect(doctorExitCode({ fixable: 3, unfixable: 0, fix: false })).toBe(1);
484
+ expect(doctorExitCode({ fixable: 3, unfixable: 0, fix: true })).toBe(0);
485
+ });
486
+
487
+ it("exits 1 for a stale plugin load EVEN WITH --fix", () => {
488
+ // The regression: --fix does not repair these, so suppressing the exit code
489
+ // reports success over output that says the opposite.
490
+ expect(doctorExitCode({ fixable: 0, unfixable: 1, fix: true })).toBe(1);
491
+ expect(doctorExitCode({ fixable: 0, unfixable: 1, fix: false })).toBe(1);
492
+ expect(doctorExitCode({ fixable: 2, unfixable: 1, fix: true })).toBe(1);
493
+ });
494
+ });
@@ -0,0 +1,431 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { execFileSync } from "node:child_process";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import fs from "fs-extra";
6
+ import {
7
+ isRelevantInstallation,
8
+ pickLoadedEntry,
9
+ repoRootForPath,
10
+ resolveLoadedVersions,
11
+ } from "../services/plugin-resolution.js";
12
+ import type {
13
+ InstalledPluginEntry,
14
+ InstalledPluginsRegistry,
15
+ } from "../types/index.js";
16
+
17
+ /**
18
+ * The loader's own resolution, not magus's.
19
+ *
20
+ * `pickRegistryEntry` (registry-version-resolution.test.ts) answers "what does
21
+ * magus consider installed here". These cases answer the other question — what
22
+ * Claude Code will LOAD here — and the two disagree in ways that are invisible
23
+ * from the settings file: a row belonging to a worktree that no longer exists
24
+ * still decides the answer, and array order beats version order.
25
+ *
26
+ * Exercised against real repositories rather than a hand-written `.git` file,
27
+ * because the point of `repoRootForPath` is agreeing with git about what a
28
+ * linked worktree is. A stub would confirm whatever parse it was written from.
29
+ */
30
+
31
+ // Isolate from the operator's git config: a global `core.excludesFile`,
32
+ // `init.defaultBranch`, or commit signing would otherwise leak into these repos.
33
+ const GIT_CFG = [
34
+ "-c",
35
+ "user.name=test",
36
+ "-c",
37
+ "user.email=test@example.com",
38
+ "-c",
39
+ "commit.gpgsign=false",
40
+ "-c",
41
+ "init.defaultBranch=main",
42
+ ];
43
+
44
+ const git = (cwd: string, ...args: string[]) =>
45
+ execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
46
+
47
+ let base: string;
48
+ /** The repository under test, standing in for magus-src. */
49
+ let mainTree: string;
50
+ /** A live linked worktree of it. */
51
+ let linked: string;
52
+ /** A worktree that was created, registered, and then REMOVED. */
53
+ let deletedTree: string;
54
+ /** A repository that has nothing to do with the one under test. */
55
+ let otherRepo: string;
56
+ /** Cache directories, as `~/.claude/plugins/cache/magus/madbench/<version>`. */
57
+ let cache030: string;
58
+ let cache050: string;
59
+
60
+ const entry = (
61
+ over: Partial<InstalledPluginEntry> & { version: string },
62
+ ): InstalledPluginEntry => ({
63
+ scope: "project",
64
+ installPath: `/cache/${over.version}`,
65
+ installedAt: "2026-01-01T00:00:00.000Z",
66
+ lastUpdated: "2026-01-01T00:00:00.000Z",
67
+ ...over,
68
+ });
69
+
70
+ beforeAll(async () => {
71
+ // realpath: on macOS os.tmpdir() is a symlink, and git reports real paths.
72
+ base = await fs.realpath(
73
+ await fs.mkdtemp(path.join(os.tmpdir(), "magus-loader-")),
74
+ );
75
+ mainTree = path.join(base, "magus-src");
76
+ linked = path.join(mainTree, ".claude", "worktrees", "madbench");
77
+ deletedTree = path.join(mainTree, ".claude", "worktrees", "gone");
78
+ otherRepo = path.join(base, "unrelated");
79
+
80
+ await fs.ensureDir(mainTree);
81
+ git(mainTree, "init", "-q");
82
+ await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
83
+ git(mainTree, "add", "-A");
84
+ git(mainTree, "commit", "-qm", "initial");
85
+ git(mainTree, "worktree", "add", "-q", "-b", "madbench", linked);
86
+
87
+ // Created, then removed — its registry rows are what survive it.
88
+ git(mainTree, "worktree", "add", "-q", "-b", "gone", deletedTree);
89
+ await fs.remove(deletedTree);
90
+
91
+ await fs.ensureDir(otherRepo);
92
+ git(otherRepo, "init", "-q");
93
+
94
+ cache030 = path.join(base, "cache", "magus", "madbench", "0.3.0");
95
+ cache050 = path.join(base, "cache", "magus", "madbench", "0.5.0");
96
+ await fs.ensureDir(cache030);
97
+ await fs.ensureDir(cache050);
98
+ });
99
+
100
+ afterAll(async () => {
101
+ if (base) await fs.remove(base);
102
+ });
103
+
104
+ describe("repoRootForPath", () => {
105
+ test("a linked worktree and its main checkout resolve to the SAME root", () => {
106
+ expect(repoRootForPath(linked)).toBe(mainTree);
107
+ expect(repoRootForPath(mainTree)).toBe(mainTree);
108
+ });
109
+
110
+ test("a path inside the main checkout resolves to the checkout", () => {
111
+ expect(repoRootForPath(path.join(mainTree, "tools", "magus"))).toBe(
112
+ mainTree,
113
+ );
114
+ });
115
+
116
+ test("a DELETED worktree's path still resolves, to the repository that held it", () => {
117
+ // The directory is gone; the walk finds the nearest surviving ancestor's
118
+ // `.git`. This is why the implementation may not shell out to git — every
119
+ // git command fails here, and "no root" is the one answer that would make
120
+ // the entry below stop counting.
121
+ expect(fs.existsSync(deletedTree)).toBe(false);
122
+ expect(repoRootForPath(deletedTree)).toBe(mainTree);
123
+ });
124
+
125
+ test("a path that never existed resolves through its surviving ancestors", () => {
126
+ expect(repoRootForPath(path.join(mainTree, "no", "such", "dir"))).toBe(
127
+ mainTree,
128
+ );
129
+ });
130
+
131
+ test("a path in no repository is null, and two of them are not equal-by-null", () => {
132
+ expect(repoRootForPath(path.join(base, "loose", "file"))).toBeNull();
133
+ expect(repoRootForPath(otherRepo)).toBe(otherRepo);
134
+ });
135
+
136
+ test("an empty path is null rather than the process's own root", () => {
137
+ expect(repoRootForPath("")).toBeNull();
138
+ });
139
+ });
140
+
141
+ describe("isRelevantInstallation", () => {
142
+ const root = () => repoRootForPath(mainTree);
143
+
144
+ test("a user-scope entry is always relevant, wherever it was installed", () => {
145
+ expect(
146
+ isRelevantInstallation(
147
+ entry({ scope: "user", version: "1.0.0", projectPath: undefined }),
148
+ linked,
149
+ repoRootForPath(linked),
150
+ ),
151
+ ).toBe(true);
152
+ });
153
+
154
+ test("an entry from an UNRELATED repository is never relevant", () => {
155
+ expect(
156
+ isRelevantInstallation(
157
+ entry({ version: "9.9.9", projectPath: otherRepo }),
158
+ mainTree,
159
+ root(),
160
+ ),
161
+ ).toBe(false);
162
+ });
163
+
164
+ test("an entry pinned to this exact directory is relevant", () => {
165
+ expect(
166
+ isRelevantInstallation(
167
+ entry({ version: "1.0.0", projectPath: linked }),
168
+ linked,
169
+ repoRootForPath(linked),
170
+ ),
171
+ ).toBe(true);
172
+ });
173
+
174
+ test("a sibling worktree of the same repository is relevant", () => {
175
+ expect(
176
+ isRelevantInstallation(
177
+ entry({ version: "1.0.0", projectPath: deletedTree }),
178
+ linked,
179
+ repoRootForPath(linked),
180
+ ),
181
+ ).toBe(true);
182
+ });
183
+
184
+ test("a project-scope entry with no projectPath is not relevant", () => {
185
+ expect(
186
+ isRelevantInstallation(
187
+ entry({ version: "1.0.0", projectPath: undefined }),
188
+ mainTree,
189
+ root(),
190
+ ),
191
+ ).toBe(false);
192
+ });
193
+
194
+ test("two rootless paths DO match each other, as the original's compare does", () => {
195
+ // `repoRoot(entry.projectPath) === repoRoot(originalCwd)` with both sides
196
+ // null is true in the decompiled filter, so a row from any directory in no
197
+ // repository competes when Claude Code runs from another such directory —
198
+ // a scratch dir, a home directory, a plain non-git project.
199
+ //
200
+ // The port used to answer false here, which made magus name a
201
+ // `loadedVersion` the loader would not pick in any non-git directory. It is
202
+ // the loader's answer this module owes, not the more cautious one; what
203
+ // magus may WRITE is a separate question, pinned in stale-load-repair.test.ts
204
+ // ("a rootless row from another directory is relevant but NOT rewritten").
205
+ const loose = path.join(base, "loose");
206
+ expect(
207
+ isRelevantInstallation(
208
+ entry({
209
+ version: "1.0.0",
210
+ projectPath: path.join(base, "other-loose"),
211
+ }),
212
+ loose,
213
+ repoRootForPath(loose),
214
+ ),
215
+ ).toBe(true);
216
+ });
217
+
218
+ test("a rootless row is still irrelevant to a directory inside a repository", () => {
219
+ // The null-matches-null rule must not leak the other way: `cwdRoot` is a
220
+ // real root here, so a rootless row's null root does not equal it.
221
+ expect(
222
+ isRelevantInstallation(
223
+ entry({ version: "1.0.0", projectPath: path.join(base, "loose") }),
224
+ mainTree,
225
+ root(),
226
+ ),
227
+ ).toBe(false);
228
+ });
229
+ });
230
+
231
+ describe("pickLoadedEntry — madbench 0.3.0", () => {
232
+ test("a DELETED sibling worktree's row beats this project's own newer row", () => {
233
+ // The measured defect. madbench was installed into a worktree that has
234
+ // since been removed; that row is still in installed_plugins.json, still
235
+ // points at a cache directory that still exists, and was written FIRST.
236
+ // This project then installed 0.5.0. magus reported 0.5.0 and every
237
+ // session in this directory loaded 0.3.0.
238
+ const entries = [
239
+ entry({
240
+ version: "0.3.0",
241
+ projectPath: deletedTree,
242
+ installPath: cache030,
243
+ }),
244
+ entry({ version: "0.5.0", projectPath: linked, installPath: cache050 }),
245
+ ];
246
+
247
+ expect(pickLoadedEntry(entries, linked)?.version).toBe("0.3.0");
248
+ });
249
+
250
+ test("with the stale row gone, this project's own row loads", () => {
251
+ const entries = [
252
+ entry({ version: "0.5.0", projectPath: linked, installPath: cache050 }),
253
+ ];
254
+ expect(pickLoadedEntry(entries, linked)?.version).toBe("0.5.0");
255
+ });
256
+
257
+ test("install ORDER decides, not the version: reversing the rows changes the answer", () => {
258
+ // Stated as its own case because it is the part that reads as a bug and
259
+ // is not one: nothing sorts these, so the same two installs in the other
260
+ // order load the newer plugin.
261
+ const stale = entry({
262
+ version: "0.3.0",
263
+ projectPath: deletedTree,
264
+ installPath: cache030,
265
+ });
266
+ const own = entry({
267
+ version: "0.5.0",
268
+ projectPath: linked,
269
+ installPath: cache050,
270
+ });
271
+
272
+ expect(pickLoadedEntry([stale, own], linked)?.version).toBe("0.3.0");
273
+ expect(pickLoadedEntry([own, stale], linked)?.version).toBe("0.5.0");
274
+ });
275
+
276
+ test("a row whose cache directory is gone is skipped for one that exists", () => {
277
+ const entries = [
278
+ entry({
279
+ version: "0.3.0",
280
+ projectPath: deletedTree,
281
+ installPath: path.join(base, "cache", "magus", "madbench", "evicted"),
282
+ }),
283
+ entry({ version: "0.5.0", projectPath: linked, installPath: cache050 }),
284
+ ];
285
+
286
+ expect(pickLoadedEntry(entries, linked)?.version).toBe("0.5.0");
287
+ });
288
+ });
289
+
290
+ describe("pickLoadedEntry — the selection rules", () => {
291
+ test("no relevant entry at all yields undefined", () => {
292
+ const entries = [entry({ version: "9.9.9", projectPath: otherRepo })];
293
+ expect(pickLoadedEntry(entries, mainTree)).toBeUndefined();
294
+ });
295
+
296
+ test("empty and missing entry lists are handled", () => {
297
+ expect(pickLoadedEntry([], mainTree)).toBeUndefined();
298
+ expect(pickLoadedEntry(undefined, mainTree)).toBeUndefined();
299
+ });
300
+
301
+ test("ONE relevant entry loads even though its installPath does not exist", () => {
302
+ // The single-candidate branch carries no existence check. That is the
303
+ // binary's behaviour, not an oversight of the port: one relevant row means
304
+ // that row is what the loader resolves, and it fails at load rather than
305
+ // falling through to anything else.
306
+ const missing = path.join(base, "cache", "magus", "madbench", "not-here");
307
+ expect(fs.existsSync(missing)).toBe(false);
308
+
309
+ const only = entry({
310
+ version: "0.4.0",
311
+ projectPath: linked,
312
+ installPath: missing,
313
+ });
314
+ expect(pickLoadedEntry([only], linked)).toBe(only);
315
+ });
316
+
317
+ test("with no existing installPath anywhere, the FIRST one with a path wins", () => {
318
+ // `firstValid` is captured before the existence test, so the fallback is
319
+ // the first entry that has an installPath at all — not the first existing
320
+ // one, of which there are none here.
321
+ const first = entry({
322
+ version: "0.3.0",
323
+ projectPath: deletedTree,
324
+ installPath: path.join(base, "gone-a"),
325
+ });
326
+ const second = entry({
327
+ version: "0.5.0",
328
+ projectPath: linked,
329
+ installPath: path.join(base, "gone-b"),
330
+ });
331
+
332
+ expect(pickLoadedEntry([first, second], linked)).toBe(first);
333
+ });
334
+
335
+ test("an entry with an empty installPath is passed over for the fallback", () => {
336
+ const blank = entry({
337
+ version: "0.3.0",
338
+ projectPath: deletedTree,
339
+ installPath: "",
340
+ });
341
+ const pathed = entry({
342
+ version: "0.5.0",
343
+ projectPath: linked,
344
+ installPath: path.join(base, "gone-b"),
345
+ });
346
+
347
+ expect(pickLoadedEntry([blank, pathed], linked)).toBe(pathed);
348
+ });
349
+
350
+ test("every relevant entry lacking an installPath falls back to the first of them", () => {
351
+ const a = entry({
352
+ version: "0.3.0",
353
+ projectPath: deletedTree,
354
+ installPath: "",
355
+ });
356
+ const b = entry({ version: "0.5.0", projectPath: linked, installPath: "" });
357
+
358
+ expect(pickLoadedEntry([a, b], linked)).toBe(a);
359
+ });
360
+
361
+ test("a user-scope row competes with this project's rows on install order", () => {
362
+ const user = entry({
363
+ scope: "user",
364
+ version: "0.3.0",
365
+ projectPath: undefined,
366
+ installPath: cache030,
367
+ });
368
+ const own = entry({
369
+ version: "0.5.0",
370
+ projectPath: linked,
371
+ installPath: cache050,
372
+ });
373
+
374
+ expect(pickLoadedEntry([user, own], linked)?.version).toBe("0.3.0");
375
+ expect(pickLoadedEntry([own, user], linked)?.version).toBe("0.5.0");
376
+ });
377
+ });
378
+
379
+ describe("resolveLoadedVersions", () => {
380
+ const registry = (
381
+ plugins: Record<string, InstalledPluginEntry[]>,
382
+ ): InstalledPluginsRegistry => ({ version: 2, plugins });
383
+
384
+ test("maps each plugin id to the version that will actually load", () => {
385
+ const loaded = resolveLoadedVersions(
386
+ registry({
387
+ "madbench@magus": [
388
+ entry({
389
+ version: "0.3.0",
390
+ projectPath: deletedTree,
391
+ installPath: cache030,
392
+ }),
393
+ entry({
394
+ version: "0.5.0",
395
+ projectPath: linked,
396
+ installPath: cache050,
397
+ }),
398
+ ],
399
+ "dev@magus": [
400
+ entry({
401
+ scope: "user",
402
+ version: "7.4.0",
403
+ projectPath: undefined,
404
+ installPath: cache050,
405
+ }),
406
+ ],
407
+ }),
408
+ linked,
409
+ );
410
+
411
+ expect(loaded).toEqual({ "madbench@magus": "0.3.0", "dev@magus": "7.4.0" });
412
+ });
413
+
414
+ test("a plugin with no relevant entry is absent from the map, not null in it", () => {
415
+ const loaded = resolveLoadedVersions(
416
+ registry({
417
+ "elsewhere@magus": [
418
+ entry({ version: "9.9.9", projectPath: otherRepo }),
419
+ ],
420
+ }),
421
+ mainTree,
422
+ );
423
+
424
+ expect(loaded).toEqual({});
425
+ expect(Object.hasOwn(loaded, "elsewhere@magus")).toBe(false);
426
+ });
427
+
428
+ test("an empty registry resolves to an empty map", () => {
429
+ expect(resolveLoadedVersions(registry({}), mainTree)).toEqual({});
430
+ });
431
+ });
@@ -268,6 +268,7 @@ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", ()
268
268
  pluginId: "x@magus",
269
269
  pinned: "latest",
270
270
  installed: null,
271
+ loaded: null,
271
272
  available: "1.0.0",
272
273
  target: "1.0.0",
273
274
  action: "install",
@@ -343,3 +344,68 @@ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", ()
343
344
  ).toBe(" stale — reinstall (files changed without a version bump)");
344
345
  });
345
346
  });
347
+
348
+ describe("pluginRowText — a broken plugin says so, and names the key", () => {
349
+ /**
350
+ * The measured madbench case: 0.5.0 installed in this worktree, 0.3.0 still
351
+ * registered against a sibling worktree that was deleted, and that older row
352
+ * is what Claude Code resolves. Every version magus records says 0.5.0, so
353
+ * without this the row reads as up to date — which was the bug.
354
+ */
355
+ const MADBENCH = info({
356
+ id: "madbench@magus",
357
+ version: "0.5.0",
358
+ installedVersion: "0.5.0",
359
+ loadedVersion: "0.3.0",
360
+ projectScope: { enabled: true, version: "0.5.0" },
361
+ author: { name: "MadAppGang" },
362
+ });
363
+
364
+ test("the row states the verdict and the key — NO version archaeology", () => {
365
+ const row = rows([MADBENCH]).get("madbench@magus");
366
+ if (!row) throw new Error("row missing");
367
+
368
+ expect(row.status?.action).toBe("stale-load");
369
+ expect(row.status?.loaded).toBe("0.3.0");
370
+ expect(pluginRowText(row).text).toBe(" v0.5.0 BROKEN — press a to fix");
371
+ });
372
+
373
+ test("the row does NOT carry the classifier's note", () => {
374
+ // It used to print it verbatim — "≠ loads v0.3.0, installed v0.5.0" — which
375
+ // is this codebase's internal vocabulary: it tells a reader neither what is
376
+ // wrong nor what to do, because the loads/installed distinction is one only
377
+ // magus draws. The note itself is unchanged and still reaches the CLI report
378
+ // and the TUI detail panel, which is where the mechanism belongs.
379
+ const row = rows([MADBENCH]).get("madbench@magus");
380
+ if (!row) throw new Error("row missing");
381
+
382
+ const note = cliNote([MADBENCH], "madbench@magus");
383
+ expect(note).toBe("loads v0.3.0, installed v0.5.0");
384
+ expect(pluginRowText(row).text).not.toContain(note ?? "<missing note>");
385
+ expect(pluginRowText(row).text).not.toContain("loads v");
386
+ });
387
+
388
+ test("the row is RED — broken, not the amber of a routine update", () => {
389
+ // Amber is this file's tone for "cannot tell" and for ordinary work. A
390
+ // session running a version nobody installed is broken, and `a` fixes it.
391
+ const row = rows([MADBENCH]).get("madbench@magus");
392
+ if (!row) throw new Error("row missing");
393
+ expect(pluginStatusText(row.status).tone).toBe("danger");
394
+ });
395
+
396
+ test("the same plugin loading its installed version says nothing extra", () => {
397
+ const healthy = info({
398
+ id: "madbench@magus",
399
+ version: "0.5.0",
400
+ installedVersion: "0.5.0",
401
+ loadedVersion: "0.5.0",
402
+ projectScope: { enabled: true, version: "0.5.0" },
403
+ author: { name: "MadAppGang" },
404
+ });
405
+ const row = rows([healthy]).get("madbench@magus");
406
+ if (!row) throw new Error("row missing");
407
+
408
+ expect(row.status?.action).toBe("current");
409
+ expect(pluginRowText(row).text).toBe(" v0.5.0");
410
+ });
411
+ });