magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,896 @@
1
+ /**
2
+ * The second review of the 7.8.0 failure popup: everything the user must know
3
+ * because something failed goes into ONE popup, for every kind of subject, and
4
+ * nothing else is said. One describe per review item (H1–H5, M6–M10, L11).
5
+ *
6
+ * Pure builders are tested directly; the apply paths through the real engine
7
+ * with injected Claude Code calls, so the words are the engine's own.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
+ import { mkdir } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { testRender } from "@opentui/react/test-utils";
16
+ import { act } from "react";
17
+ import { findPluginLoadProblems } from "../cli/doctor.js";
18
+ import { claudeFailureMessage } from "../services/claude-cli.js";
19
+ import {
20
+ type StaleLoadRepairResult,
21
+ forgetInstalledPluginVersion,
22
+ notDownloadedReason,
23
+ } from "../services/claude-settings.js";
24
+ import {
25
+ type PluginInfo,
26
+ orphanCandidateIds,
27
+ } from "../services/plugin-manager.js";
28
+ import type { ActivationResult } from "../services/profile-activate.js";
29
+ import { requiredByHere, resolveDependencies } from "../services/resolver.js";
30
+ import {
31
+ type PluginApplyDeps,
32
+ applyPlugins,
33
+ describeLoadNotForward,
34
+ } from "../services/update-engine.js";
35
+ import {
36
+ type PluginUpdateItem,
37
+ planPluginUpdates,
38
+ } from "../services/update-plan.js";
39
+ import { failureReport } from "../ui/adapters/failureReport.js";
40
+ import {
41
+ INSTALL_CLAUDE_FIX,
42
+ RETRY_NETWORK_FIX,
43
+ exceptionReason,
44
+ failureReasons,
45
+ pluginFailureReport,
46
+ unchangedReason,
47
+ } from "../ui/adapters/pluginsAdapter.js";
48
+ import {
49
+ activationFailureReport,
50
+ gitignoreStartupReport,
51
+ gitignoreTemplateReport,
52
+ profileProblemRow,
53
+ profileProblemsReport,
54
+ staleRepairReport,
55
+ } from "../ui/adapters/startupReports.js";
56
+ import {
57
+ FailureListModal,
58
+ visibleFailureRows,
59
+ } from "../ui/components/modals/FailureListModal.js";
60
+ import { renderMcpDetail, renderMcpRow } from "../ui/renderers/mcpRenderers.js";
61
+ import { appReducer, initialState } from "../ui/state/reducer.js";
62
+ import type { FailureReport } from "../ui/state/types.js";
63
+
64
+ const PROJECT = "/tmp/magus-failure-popup-review2";
65
+
66
+ function updateItem(
67
+ pluginId: string,
68
+ over: Partial<PluginUpdateItem> = {},
69
+ ): PluginUpdateItem {
70
+ return {
71
+ pluginId,
72
+ pinned: "latest",
73
+ installed: "1.0.0",
74
+ loaded: "1.0.0",
75
+ available: "2.0.0",
76
+ target: "2.0.0",
77
+ action: "update",
78
+ reason: "behind",
79
+ scopes: ["project"],
80
+ beforeVersions: { project: "1.0.0" },
81
+ ...over,
82
+ };
83
+ }
84
+
85
+ /** Every call succeeds; `readInstalled` answers `lands` per plugin. */
86
+ function deps(
87
+ lands: Record<string, string | null>,
88
+ throws: Record<string, string> = {},
89
+ ): PluginApplyDeps {
90
+ return {
91
+ install: async (id) => {
92
+ if (throws[id]) throw new Error(throws[id]);
93
+ },
94
+ update: async (id) => {
95
+ if (throws[id]) throw new Error(throws[id]);
96
+ },
97
+ repair: async () => {},
98
+ readInstalled: async (id) => lands[id] ?? null,
99
+ saveInstalled: async () => {},
100
+ repairStaleLoad: async (id, target) => ({
101
+ pluginId: id,
102
+ target,
103
+ rewritten: 0,
104
+ backup: null,
105
+ loaded: target,
106
+ ok: true,
107
+ }),
108
+ };
109
+ }
110
+
111
+ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
112
+ const [name = over.id, marketplace = ""] = over.id.split("@");
113
+ return {
114
+ name,
115
+ version: null,
116
+ description: "",
117
+ marketplace,
118
+ marketplaceDisplay: marketplace,
119
+ ...over,
120
+ };
121
+ }
122
+
123
+ /** Every string the popup shows. */
124
+ function texts(report: FailureReport): string {
125
+ return [
126
+ report.headline,
127
+ ...report.rows.flatMap((r) => [r.subject, r.reason, r.fix ?? ""]),
128
+ report.fix ?? "",
129
+ ].join("\n");
130
+ }
131
+
132
+ // ── H1 ───────────────────────────────────────────────────────────────────────
133
+
134
+ describe("H1: an update that ran and changed nothing is 'not updated'", () => {
135
+ test("the engine's unchanged item reaches the popup, with the reason in the user's terms", async () => {
136
+ const popup = failureReasons();
137
+ const items = [updateItem("dev@magus"), updateItem("go@magus")];
138
+ // dev reads back the version it had; go moves.
139
+ const result = await applyPlugins(
140
+ items,
141
+ PROJECT,
142
+ deps({ "dev@magus": "1.0.0", "go@magus": "2.0.0" }),
143
+ popup.reporter,
144
+ );
145
+ expect(result.failed).toEqual([]);
146
+ expect(result.unchanged).toEqual(["dev@magus"]);
147
+
148
+ const report = popup.report(
149
+ result.failed,
150
+ new Map(items.map((i) => [i.pluginId, i.action])),
151
+ result.unchanged,
152
+ );
153
+ expect(report).toEqual({
154
+ headline: "dev was not updated",
155
+ rows: [{ subject: "dev", reason: "it ran, but it is still v1.0.0" }],
156
+ fix: null,
157
+ });
158
+ expect(texts(report as FailureReport)).not.toContain("installer");
159
+ expect(texts(report as FailureReport)).not.toContain("exited 0");
160
+ });
161
+
162
+ test("NEGATIVE CONTROL: nothing unchanged and nothing failed — no popup", async () => {
163
+ const popup = failureReasons();
164
+ const result = await applyPlugins(
165
+ [updateItem("go@magus")],
166
+ PROJECT,
167
+ deps({ "go@magus": "2.0.0" }),
168
+ popup.reporter,
169
+ );
170
+ expect(popup.report(result.failed, undefined, result.unchanged)).toBeNull();
171
+ });
172
+
173
+ test("the words: version, nothing recorded, and a misdirected update with its fix", () => {
174
+ expect(
175
+ unchangedReason(
176
+ "no change — the installer exited 0 but dev@magus is still 8.0.0",
177
+ ),
178
+ ).toBe("it ran, but it is still v8.0.0");
179
+ expect(
180
+ unchangedReason(
181
+ "no change — the installer exited 0 but no version of dev@magus was recorded",
182
+ ),
183
+ ).toBe("it ran, but no installed version of it can be found afterwards");
184
+ const report = pluginFailureReport([
185
+ {
186
+ pluginId: "dev@magus",
187
+ verb: "updated",
188
+ unchanged: true,
189
+ detail:
190
+ "no change — the installer exited 0 but dev@magus is still 0.4.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: v0.6.0 is not downloaded — run magus install)",
191
+ },
192
+ ]);
193
+ expect(report?.fix).toBe("Run magus install");
194
+ expect(report?.rows[0]?.reason).toStartWith(
195
+ "it ran, but it is still v0.4.0; ",
196
+ );
197
+ });
198
+ });
199
+
200
+ // ── H2 ───────────────────────────────────────────────────────────────────────
201
+
202
+ describe("H2: the startup gate's refusals go into the popup", () => {
203
+ const problem =
204
+ ".claude/settings.json cannot be read (Unexpected token } in JSON at position 12) — fix it by hand; magus left it as it is and did not switch";
205
+
206
+ test("the file is the subject, the instruction the fix", () => {
207
+ expect(profileProblemRow(problem)).toEqual({
208
+ subject: ".claude/settings.json",
209
+ reason:
210
+ "cannot be read (Unexpected token } in JSON at position 12) — magus left it as it is and did not switch",
211
+ fix: "Fix it by hand",
212
+ });
213
+ const report = profileProblemsReport([problem]);
214
+ expect(report?.headline).toBe(
215
+ "This project's profile was not brought up to date",
216
+ );
217
+ expect(report?.fix).toBe("Fix it by hand");
218
+ });
219
+
220
+ test("a line of another shape is about the profile", () => {
221
+ expect(profileProblemRow('Profile "x" not found. Available: a, b')).toEqual(
222
+ {
223
+ subject: "profile",
224
+ reason: 'Profile "x" not found. Available: a, b',
225
+ },
226
+ );
227
+ });
228
+
229
+ test("NEGATIVE CONTROL: no problems, no popup", () => {
230
+ expect(profileProblemsReport([])).toBeNull();
231
+ });
232
+ });
233
+
234
+ // ── H3 ───────────────────────────────────────────────────────────────────────
235
+
236
+ function activation(over: Partial<ActivationResult> = {}): ActivationResult {
237
+ return {
238
+ profileId: "team",
239
+ displaced: [],
240
+ skills: { skills: {}, installed: [], removed: [], failed: [] },
241
+ conversion: {
242
+ converted: false,
243
+ managed: {},
244
+ restored: [],
245
+ conflicts: [],
246
+ keptAt: null,
247
+ },
248
+ tracked: [],
249
+ keptAt: null,
250
+ ...over,
251
+ };
252
+ }
253
+
254
+ describe("H3: what an activation could not do, from ONE builder", () => {
255
+ test("blocked dependency, not downloaded, failed skill, two copies — each a row with its fix", () => {
256
+ const report = activationFailureReport([
257
+ activation({
258
+ dependencies: {
259
+ derived: [],
260
+ unreadable: ["mnemex@magus"],
261
+ blocked: [
262
+ { plugin: "multimodel@magus", dependency: "claudish@magus" },
263
+ ],
264
+ },
265
+ skills: {
266
+ skills: {},
267
+ installed: [],
268
+ removed: [],
269
+ failed: [{ skill: "tdd", reason: "GitHub answered 404\nstack" }],
270
+ },
271
+ conversion: {
272
+ converted: true,
273
+ managed: {},
274
+ restored: [],
275
+ conflicts: [
276
+ {
277
+ folder: "notes",
278
+ kept: ".claude/skills/notes",
279
+ other: ".claude/_profiles/team/skills/notes",
280
+ },
281
+ ],
282
+ keptAt: null,
283
+ },
284
+ }),
285
+ ]) as FailureReport;
286
+
287
+ expect(report.headline).toBe(
288
+ 'Profile "team" is active, but 4 things did not work',
289
+ );
290
+ expect(report.fix).toBeNull();
291
+ expect(report.rows).toEqual([
292
+ {
293
+ subject: "multimodel",
294
+ reason:
295
+ 'will not load: it depends on claudish, which profile "team" turns off',
296
+ fix: 'Remove "claudish@magus": false from the profile to let it load',
297
+ },
298
+ {
299
+ subject: "mnemex",
300
+ reason:
301
+ "is not downloaded on this machine, so nothing it depends on was turned on",
302
+ fix: "Run magus install",
303
+ },
304
+ {
305
+ subject: "skill tdd",
306
+ reason: "was not installed: GitHub answered 404",
307
+ },
308
+ {
309
+ subject: "skill notes",
310
+ reason:
311
+ "exists in two copies: .claude/skills/notes is the one in use, the other is at .claude/_profiles/team/skills/notes",
312
+ fix: "Compare the two and delete the one you do not want",
313
+ },
314
+ ]);
315
+ });
316
+
317
+ test("the same finding from two activations (re-render, then conversion) is said once", () => {
318
+ const blocked = activation({
319
+ dependencies: {
320
+ derived: [],
321
+ unreadable: ["mnemex@magus"],
322
+ blocked: [],
323
+ },
324
+ });
325
+ const report = activationFailureReport([blocked, blocked]);
326
+ expect(report?.rows).toHaveLength(1);
327
+ expect(report?.fix).toBe("Run magus install");
328
+ });
329
+
330
+ test("NEGATIVE CONTROL: a clean activation says nothing", () => {
331
+ expect(activationFailureReport([activation()])).toBeNull();
332
+ expect(activationFailureReport([])).toBeNull();
333
+ });
334
+ });
335
+
336
+ // ── H4 ───────────────────────────────────────────────────────────────────────
337
+
338
+ describe("H4: the startup repair's failures go into the popup", () => {
339
+ const result = (
340
+ pluginId: string,
341
+ ok: boolean,
342
+ error?: string,
343
+ ): StaleLoadRepairResult => ({
344
+ pluginId,
345
+ target: "2.0.0",
346
+ rewritten: ok ? 1 : 0,
347
+ backup: null,
348
+ loaded: ok ? "2.0.0" : "1.0.0",
349
+ ok,
350
+ ...(error ? { error } : {}),
351
+ });
352
+
353
+ test("only the repairs that did not take, each with its reason", () => {
354
+ const report = staleRepairReport([
355
+ result("dev@magus", true),
356
+ result(
357
+ "go@magus",
358
+ false,
359
+ "Claude Code loads the USER install of it here — update it at USER scope",
360
+ ),
361
+ ]);
362
+ expect(report).toEqual({
363
+ headline: "go was not repaired",
364
+ rows: [
365
+ {
366
+ subject: "go",
367
+ reason: "Claude Code loads the USER install of it here",
368
+ },
369
+ ],
370
+ fix: "Update it at USER scope",
371
+ });
372
+ });
373
+
374
+ test("NEGATIVE CONTROL: every repair took — no popup", () => {
375
+ expect(staleRepairReport([result("dev@magus", true)])).toBeNull();
376
+ });
377
+ });
378
+
379
+ // ── H5 ───────────────────────────────────────────────────────────────────────
380
+
381
+ describe("H5: installedPluginVersions alone is never an install", () => {
382
+ let dir: string;
383
+ beforeEach(async () => {
384
+ dir = await mkdtemp(join(tmpdir(), "magus-h5-"));
385
+ await mkdir(join(dir, ".claude"), { recursive: true });
386
+ });
387
+ afterEach(async () => {
388
+ await rm(dir, { recursive: true, force: true });
389
+ });
390
+
391
+ test("forgetInstalledPluginVersion drops only magus's claim, leaving Claude Code's fields", async () => {
392
+ const file = join(dir, ".claude", "settings.json");
393
+ await writeFile(
394
+ file,
395
+ JSON.stringify({
396
+ enabledPlugins: { "old@magus": true },
397
+ installedPluginVersions: { "old@magus": "1.0.0", "dev@magus": "9.0.0" },
398
+ }),
399
+ );
400
+ await forgetInstalledPluginVersion("old@magus", "project", dir);
401
+ const after = JSON.parse(await readFile(file, "utf8"));
402
+ expect(after.installedPluginVersions).toEqual({ "dev@magus": "9.0.0" });
403
+ expect(after.enabledPlugins).toEqual({ "old@magus": true });
404
+ });
405
+
406
+ test("NEGATIVE CONTROL: a file with no claim for the plugin is not rewritten", async () => {
407
+ const file = join(dir, ".claude", "settings.json");
408
+ const bytes = '{ "installedPluginVersions": {"dev@magus":"9.0.0"} }';
409
+ await writeFile(file, bytes);
410
+ await forgetInstalledPluginVersion("old@magus", "project", dir);
411
+ expect(await readFile(file, "utf8")).toBe(bytes);
412
+ });
413
+
414
+ test("a removal clears the claim at each scope it removed from — not where it is still installed", async () => {
415
+ const forgotten: string[] = [];
416
+ const stillAt = new Set(["user"]);
417
+ const result = await applyPlugins(
418
+ [
419
+ updateItem("old@magus", {
420
+ action: "remove",
421
+ reason: "deprecated",
422
+ available: null,
423
+ target: null,
424
+ scopes: ["project", "user"],
425
+ }),
426
+ ],
427
+ PROJECT,
428
+ {
429
+ ...deps({}),
430
+ remove: {
431
+ uninstall: async () => {},
432
+ isInstalled: async (_id, scope) => stillAt.has(scope),
433
+ dropFromProfile: async () => true,
434
+ forgetVersion: async (_id, scope) => {
435
+ forgotten.push(scope);
436
+ },
437
+ },
438
+ },
439
+ );
440
+ expect(result.failed).toEqual(["old@magus"]);
441
+ expect(forgotten).toEqual(["project"]);
442
+ });
443
+
444
+ test("doctor: a settings-only claim does not 'load nothing'", () => {
445
+ const HERE = "/nonexistent/magus-h5/project";
446
+ expect(
447
+ findPluginLoadProblems({ "ghost@magus": "1.0.0" }, {}, HERE),
448
+ ).toEqual({ stale: [], loadsNothing: [] });
449
+ // Turned on here, or with registry rows: that IS an install that loads nothing.
450
+ expect(
451
+ findPluginLoadProblems({ "ghost@magus": "1.0.0" }, {}, HERE, {
452
+ "ghost@magus": true,
453
+ }).loadsNothing,
454
+ ).toEqual(["ghost@magus"]);
455
+ });
456
+
457
+ test("the Plugins list builds no row from a settings-only claim", () => {
458
+ const ids = orphanCandidateIds(
459
+ { "on@magus": true },
460
+ { "ghost@magus": "1.0.0", "rows@magus": "2.0.0" },
461
+ {
462
+ "rows@magus": [
463
+ {
464
+ scope: "user",
465
+ version: "2.0.0",
466
+ installPath: "/nonexistent",
467
+ installedAt: "",
468
+ lastUpdated: "",
469
+ },
470
+ ],
471
+ },
472
+ );
473
+ expect([...ids].sort()).toEqual(["on@magus", "rows@magus"]);
474
+ });
475
+ });
476
+
477
+ // ── M6 ───────────────────────────────────────────────────────────────────────
478
+
479
+ describe("M6: grouping keeps every sentence true", () => {
480
+ test("different versions become nouns; never 'still loads in this folder'", () => {
481
+ const report = pluginFailureReport([
482
+ {
483
+ pluginId: "a@magus",
484
+ verb: "updated",
485
+ detail: "installed v1.3.0, but it did not download — run magus install",
486
+ },
487
+ {
488
+ pluginId: "b@magus",
489
+ verb: "updated",
490
+ detail: "installed v2.0.0, but it did not download — run magus install",
491
+ },
492
+ ]) as FailureReport;
493
+ expect(report.rows).toEqual([
494
+ {
495
+ subject: "a, b",
496
+ reason: "installed the new version, but it did not download",
497
+ },
498
+ ]);
499
+ expect(report.fix).toBe("Run magus install");
500
+ expect(texts(report)).not.toContain("is not downloaded");
501
+ expect(texts(report)).not.toContain("still loads in this folder");
502
+ });
503
+
504
+ test("the same recognised exception is one row, its hint said once as the fix", () => {
505
+ const message =
506
+ "claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code";
507
+ const report = pluginFailureReport([
508
+ {
509
+ pluginId: "a@magus",
510
+ verb: "updated",
511
+ detail: message,
512
+ exception: true,
513
+ },
514
+ {
515
+ pluginId: "b@magus",
516
+ verb: "installed",
517
+ detail: message,
518
+ exception: true,
519
+ },
520
+ ]) as FailureReport;
521
+ expect(report).toEqual({
522
+ headline: "2 plugins were not updated or installed",
523
+ rows: [{ subject: "a, b", reason: "claude CLI not found in PATH" }],
524
+ fix: INSTALL_CLAUDE_FIX,
525
+ });
526
+ });
527
+
528
+ test("NEGATIVE CONTROL: an exception magus cannot explain keeps a row of its own", () => {
529
+ const report = pluginFailureReport([
530
+ { pluginId: "a@magus", verb: "updated", detail: "boom", exception: true },
531
+ { pluginId: "b@magus", verb: "updated", detail: "boom", exception: true },
532
+ ]);
533
+ expect(report?.rows).toHaveLength(2);
534
+ expect(report?.fix).toBeNull();
535
+ });
536
+ });
537
+
538
+ // ── M7 ───────────────────────────────────────────────────────────────────────
539
+
540
+ describe("M7: every dependency edge counts, and a required plugin is never removed", () => {
541
+ test("an edge to a plugin the profile also names is recorded", async () => {
542
+ const report = await resolveDependencies(
543
+ ["dev@magus", "kanban@magus"],
544
+ new Set(),
545
+ async (id) => (id === "dev@magus" ? ["kanban@magus"] : []),
546
+ );
547
+ expect(report.derived).toEqual([]);
548
+ expect(report.requiredBy).toEqual({ "kanban@magus": "dev@magus" });
549
+ });
550
+
551
+ test("installed plugins' edges join the profile's; the profile's wins", async () => {
552
+ const edges = await requiredByHere(
553
+ { "x@magus": "profile-plugin@magus" },
554
+ ["user-plugin@magus", "other@magus"],
555
+ PROJECT,
556
+ async (id) =>
557
+ id === "user-plugin@magus"
558
+ ? ["kanban@magus", "x@magus"]
559
+ : id === "other@magus"
560
+ ? null
561
+ : [],
562
+ );
563
+ expect(edges).toEqual({
564
+ "kanban@magus": "user-plugin@magus",
565
+ "x@magus": "profile-plugin@magus",
566
+ });
567
+ });
568
+
569
+ test("a USER-scope install no profile declares, required by an installed plugin, is not removed", () => {
570
+ const listed = info({
571
+ id: "user-plugin@magus",
572
+ version: "1.0.0",
573
+ userScope: { installed: true, version: "1.0.0" },
574
+ });
575
+ const kanban = info({
576
+ id: "kanban@magus",
577
+ version: "1.6.2",
578
+ installedVersion: "1.6.2",
579
+ isOrphaned: true,
580
+ userScope: { installed: true, version: "1.6.2" },
581
+ });
582
+ const [kept] = planPluginUpdates({}, [listed, kanban], {
583
+ "kanban@magus": "user-plugin@magus",
584
+ }).filter((i) => i.pluginId === "kanban@magus");
585
+ expect(kept).toMatchObject({ action: "unknown", reason: "still-required" });
586
+
587
+ // NEGATIVE CONTROL: without the edge it is deprecated and removed.
588
+ const [removed] = planPluginUpdates({}, [listed, kanban]).filter(
589
+ (i) => i.pluginId === "kanban@magus",
590
+ );
591
+ expect(removed).toMatchObject({ action: "remove" });
592
+ });
593
+ });
594
+
595
+ // ── M9 ───────────────────────────────────────────────────────────────────────
596
+
597
+ describe("M9: MCP restart is state; gitignore failures go into the popup", () => {
598
+ const server = {
599
+ name: "context7",
600
+ description: "docs",
601
+ type: "http" as const,
602
+ url: "https://example.invalid",
603
+ };
604
+
605
+ test("a server changed this session carries the restart on its row and detail", async () => {
606
+ const state = appReducer(initialState, {
607
+ type: "MCP_SERVER_CHANGED",
608
+ name: "context7",
609
+ });
610
+ expect(state.mcp.changedThisSession).toEqual(["context7"]);
611
+ // Idempotent.
612
+ expect(
613
+ appReducer(state, { type: "MCP_SERVER_CHANGED", name: "context7" }).mcp
614
+ .changedThisSession,
615
+ ).toEqual(["context7"]);
616
+
617
+ const item = {
618
+ kind: "server" as const,
619
+ server: server as never,
620
+ isInstalled: true,
621
+ needsRestart: true,
622
+ };
623
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
624
+ <box flexDirection="column">
625
+ {renderMcpRow(item, 0, false)}
626
+ {renderMcpDetail(item, false)}
627
+ </box>,
628
+ { width: 80, height: 20 },
629
+ );
630
+ await act(async () => {
631
+ await renderOnce();
632
+ });
633
+ const frame = captureCharFrame();
634
+ await act(async () => renderer.destroy());
635
+ expect(frame).toContain("restart Claude Code");
636
+ expect(frame).toContain("after restarting Claude Code");
637
+ });
638
+
639
+ test("NEGATIVE CONTROL: a server not changed this session says nothing about a restart", async () => {
640
+ const item = {
641
+ kind: "server" as const,
642
+ server: server as never,
643
+ isInstalled: true,
644
+ };
645
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
646
+ <box flexDirection="column">
647
+ {renderMcpRow(item, 0, false)}
648
+ {renderMcpDetail(item, false)}
649
+ </box>,
650
+ { width: 80, height: 20 },
651
+ );
652
+ await act(async () => {
653
+ await renderOnce();
654
+ });
655
+ const frame = captureCharFrame();
656
+ await act(async () => renderer.destroy());
657
+ expect(frame).not.toContain("restart");
658
+ });
659
+
660
+ test("the startup fix's untracking failures carry git's reason", () => {
661
+ expect(
662
+ gitignoreStartupReport([
663
+ {
664
+ path: ".claude/settings.json",
665
+ reason:
666
+ "fatal: pathspec '.claude/settings.json' did not match any files",
667
+ },
668
+ ]),
669
+ ).toEqual({
670
+ headline: "1 generated file is still tracked by git",
671
+ rows: [
672
+ {
673
+ subject: ".claude/settings.json",
674
+ reason:
675
+ "fatal: pathspec '.claude/settings.json' did not match any files",
676
+ },
677
+ ],
678
+ fix: null,
679
+ });
680
+ expect(gitignoreStartupReport([])).toBeNull();
681
+ });
682
+
683
+ test("a template's skipped rules are named, sharing one row", () => {
684
+ expect(gitignoreTemplateReport("node", ["dist/", "*.log"])).toEqual({
685
+ headline: '2 rules from "node" were not added',
686
+ rows: [
687
+ {
688
+ subject: "dist/, *.log",
689
+ reason: "skipped: conflicts with existing rules",
690
+ },
691
+ ],
692
+ fix: null,
693
+ });
694
+ expect(gitignoreTemplateReport("node", [])).toBeNull();
695
+ });
696
+ });
697
+
698
+ // ── M10 ──────────────────────────────────────────────────────────────────────
699
+
700
+ describe("M10: known failures carry their fix", () => {
701
+ test("claude not on PATH: the hint leaves the reason and becomes the fix", () => {
702
+ expect(
703
+ exceptionReason(
704
+ "claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code",
705
+ ),
706
+ ).toEqual({
707
+ reason: "claude CLI not found in PATH",
708
+ fix: INSTALL_CLAUDE_FIX,
709
+ known: true,
710
+ });
711
+ });
712
+
713
+ test("a failure through the real engine produces a fix line", async () => {
714
+ const popup = failureReasons();
715
+ const result = await applyPlugins(
716
+ [updateItem("a@magus"), updateItem("b@magus")],
717
+ PROJECT,
718
+ deps(
719
+ {},
720
+ {
721
+ "a@magus": "connect ETIMEDOUT 140.82.112.3:443",
722
+ "b@magus": "connect ETIMEDOUT 140.82.112.3:443",
723
+ },
724
+ ),
725
+ popup.reporter,
726
+ );
727
+ const report = popup.report(result.failed) as FailureReport;
728
+ expect(report.fix).toBe(RETRY_NETWORK_FIX);
729
+ expect(report.rows).toEqual([
730
+ { subject: "a, b", reason: "connect ETIMEDOUT 140.82.112.3:443" },
731
+ ]);
732
+ });
733
+
734
+ test("NEGATIVE CONTROL: an unrecognised exception has no fix", () => {
735
+ expect(exceptionReason("boom")).toEqual({ reason: "boom", known: false });
736
+ });
737
+ });
738
+
739
+ // ── L11 ──────────────────────────────────────────────────────────────────────
740
+
741
+ describe("L11: no mechanism in the words", () => {
742
+ test("'Command failed: <path> …' is dropped; Claude Code's own output wins", () => {
743
+ const args = ["plugin", "install", "dev@magus", "--scope", "project"];
744
+ expect(
745
+ claudeFailureMessage(
746
+ {
747
+ message:
748
+ "Command failed: /Users/x/.local/bin/claude plugin install dev@magus --scope project\nPlugin not found",
749
+ },
750
+ args,
751
+ 60000,
752
+ ),
753
+ ).toBe("Plugin not found");
754
+ expect(
755
+ claudeFailureMessage(
756
+ { message: "Command failed: /x/claude plugin install", stderr: "boom" },
757
+ args,
758
+ 60000,
759
+ ),
760
+ ).toBe("boom");
761
+ expect(
762
+ claudeFailureMessage(
763
+ { message: "Command failed: /x/claude plugin install", killed: true },
764
+ args,
765
+ 60000,
766
+ ),
767
+ ).toBe("claude plugin install did not finish within 60s");
768
+ expect(
769
+ claudeFailureMessage(
770
+ { message: "Command failed: /x/claude plugin install" },
771
+ args,
772
+ 60000,
773
+ ),
774
+ ).toBe("claude plugin install failed");
775
+ });
776
+
777
+ test("a version that did not download is not 'installed vX, but vX is not downloaded'", () => {
778
+ const text = describeLoadNotForward({
779
+ pluginId: "dev@magus",
780
+ target: "1.3.0",
781
+ rewritten: 0,
782
+ backup: null,
783
+ loaded: null,
784
+ ok: false,
785
+ error: notDownloadedReason("1.3.0"),
786
+ });
787
+ expect(text).toBe(
788
+ "installed v1.3.0, but it did not download — run magus install",
789
+ );
790
+ expect(
791
+ pluginFailureReport([
792
+ { pluginId: "dev@magus", verb: "updated", detail: text },
793
+ ]),
794
+ ).toEqual({
795
+ headline: "dev was not updated",
796
+ rows: [
797
+ { subject: "dev", reason: "installed v1.3.0, but it did not download" },
798
+ ],
799
+ fix: "Run magus install",
800
+ });
801
+ });
802
+ });
803
+
804
+ // ── the popup's shape ─────────────────────────────────────────────────────────
805
+
806
+ describe("the popup is not plugin-only, and fits the terminal", () => {
807
+ const many: FailureReport = {
808
+ headline: "30 things did not work",
809
+ rows: Array.from({ length: 30 }, (_, i) => ({
810
+ subject: `file-${i}.json`,
811
+ reason: "cannot be read",
812
+ })),
813
+ fix: null,
814
+ };
815
+
816
+ test("any subject, and a fix shared by every row is said once", () => {
817
+ expect(
818
+ failureReport("2 files were not written", [
819
+ { subject: "a.json", reason: "locked — fix it by hand" },
820
+ { subject: "b.json", reason: "read-only — fix it by hand" },
821
+ ]),
822
+ ).toEqual({
823
+ headline: "2 files were not written",
824
+ rows: [
825
+ { subject: "a.json", reason: "locked" },
826
+ { subject: "b.json", reason: "read-only" },
827
+ ],
828
+ fix: "Fix it by hand",
829
+ });
830
+ });
831
+
832
+ test("capped to the terminal's height, ending with '+N more'", async () => {
833
+ const { rows, hidden } = visibleFailureRows(many, 80, 24);
834
+ expect(hidden).toBeGreaterThan(0);
835
+ expect(rows.length + hidden).toBe(30);
836
+
837
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
838
+ <FailureListModal report={many} terminalWidth={80} terminalHeight={24} />,
839
+ { width: 80, height: 24 },
840
+ );
841
+ await act(async () => {
842
+ await renderOnce();
843
+ await renderOnce();
844
+ });
845
+ const frame = captureCharFrame();
846
+ await act(async () => renderer.destroy());
847
+ expect(frame).toContain(`+${hidden} more`);
848
+ expect(frame).toContain("30 things did not work");
849
+ expect(frame).toContain("Press any key to continue");
850
+ });
851
+
852
+ test("NEGATIVE CONTROL: rows that fit are all drawn, with no '+N more'", () => {
853
+ expect(visibleFailureRows(many, 80, 60)).toEqual({
854
+ rows: many.rows,
855
+ hidden: 0,
856
+ });
857
+ expect(visibleFailureRows(many, 80)).toEqual({
858
+ rows: many.rows,
859
+ hidden: 0,
860
+ });
861
+ });
862
+
863
+ test("a row's own fix is drawn under its reason", async () => {
864
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
865
+ <FailureListModal
866
+ report={{
867
+ headline: "2 plugins were not updated",
868
+ rows: [
869
+ {
870
+ subject: "dev",
871
+ reason: "v8.1.3 is not downloaded",
872
+ fix: "Run magus install",
873
+ },
874
+ { subject: "go", reason: "the folder could not be written" },
875
+ ],
876
+ fix: null,
877
+ }}
878
+ terminalWidth={80}
879
+ terminalHeight={24}
880
+ />,
881
+ { width: 80, height: 24 },
882
+ );
883
+ await act(async () => {
884
+ await renderOnce();
885
+ await renderOnce();
886
+ });
887
+ const lines = captureCharFrame().split("\n");
888
+ await act(async () => renderer.destroy());
889
+ const reasonAt = lines.findIndex((l) =>
890
+ l.includes("v8.1.3 is not downloaded"),
891
+ );
892
+ const fixAt = lines.findIndex((l) => l.includes("Run magus install"));
893
+ expect(reasonAt).toBeGreaterThanOrEqual(0);
894
+ expect(fixAt).toBe(reasonAt + 1);
895
+ });
896
+ });