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,419 @@
1
+ /**
2
+ * The failure popup — ONE shape for every plugin apply path on the Plugins
3
+ * screen (`a`, a single update, a scope key, a removal, a repair).
4
+ *
5
+ * The words are built by a pure function from what the apply engine reported,
6
+ * and each reason is the engine's own string — the one `magus update` prints on
7
+ * that item's `✗` line — so the two front ends name a failure the same way.
8
+ * Successes are never listed: the screen reloads and shows them.
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import type React from "react";
14
+ import { act } from "react";
15
+ import { stripAnsi } from "../cli/ansi.js";
16
+ import { applyRow } from "../cli/update-view.js";
17
+ import type { StaleLoadRepairResult } from "../services/claude-settings.js";
18
+ import {
19
+ type ApplyReporter,
20
+ type PluginApplyDeps,
21
+ applyPlugins,
22
+ } from "../services/update-engine.js";
23
+ import type { PluginUpdateItem } from "../services/update-plan.js";
24
+ import {
25
+ failureReasons,
26
+ pluginFailureReport,
27
+ reasonWithoutVersions,
28
+ } from "../ui/adapters/pluginsAdapter.js";
29
+ import {
30
+ FailureListModal,
31
+ failureListWidth,
32
+ reasonBelow,
33
+ } from "../ui/components/modals/FailureListModal.js";
34
+ import { theme } from "../ui/theme.js";
35
+
36
+ const PROJECT = "/tmp/magus-failure-popup";
37
+
38
+ function updateItem(pluginId: string): PluginUpdateItem {
39
+ return {
40
+ pluginId,
41
+ pinned: "latest",
42
+ installed: "2.0.3",
43
+ loaded: "2.0.3",
44
+ available: "2.0.6",
45
+ target: "2.0.6",
46
+ action: "update",
47
+ reason: "behind",
48
+ scopes: ["project"],
49
+ beforeVersions: { project: "2.0.3" },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Each plugin lands its update, and Claude Code keeps loading `loads[id]` —
55
+ * the failure `magus update` reports as "installed vX, but Claude Code still
56
+ * loads vY in this folder". A plugin in `throws` fails with that error instead.
57
+ */
58
+ function deps(
59
+ loads: Record<string, string>,
60
+ throws: Record<string, string> = {},
61
+ ): PluginApplyDeps {
62
+ return {
63
+ install: async () => {},
64
+ update: async (id) => {
65
+ if (throws[id]) throw new Error(throws[id]);
66
+ },
67
+ repair: async () => {},
68
+ readInstalled: async () => "2.0.6",
69
+ saveInstalled: async () => {},
70
+ loadedVersion: async (id) => loads[id] ?? null,
71
+ repairStaleLoad: async (id, target): Promise<StaleLoadRepairResult> => ({
72
+ pluginId: id,
73
+ target,
74
+ rewritten: 0,
75
+ backup: null,
76
+ loaded: loads[id] ?? null,
77
+ ok: false,
78
+ error: `Claude Code still loads v${loads[id]} in this folder`,
79
+ }),
80
+ };
81
+ }
82
+
83
+ /** What the CLI prints for each finished item: its `✗` row, colour stripped. */
84
+ function cliRows(): { reporter: ApplyReporter; rows: Map<string, string> } {
85
+ const rows = new Map<string, string>();
86
+ return {
87
+ rows,
88
+ reporter: {
89
+ begin: () => {},
90
+ finish: (ok, name, detail, ms) =>
91
+ rows.set(name, stripAnsi(applyRow(ok, name, 20, detail, ms))),
92
+ detach: (work) => work(),
93
+ },
94
+ };
95
+ }
96
+
97
+ /** Both reporters at once — one apply, the CLI's and the popup's view of it. */
98
+ function both(a: ApplyReporter, b: ApplyReporter): ApplyReporter {
99
+ return {
100
+ begin: (n) => {
101
+ a.begin(n);
102
+ b.begin(n);
103
+ },
104
+ finish: (...args) => {
105
+ a.finish(...args);
106
+ b.finish(...args);
107
+ },
108
+ detach: (work) => work(),
109
+ };
110
+ }
111
+
112
+ describe("the popup's words", () => {
113
+ test("one failure: the headline names it, the row carries the CLI's exact reason", async () => {
114
+ const popup = failureReasons();
115
+ const cli = cliRows();
116
+ const result = await applyPlugins(
117
+ [updateItem("claudish@magus"), updateItem("mnemex@magus")],
118
+ PROJECT,
119
+ deps({ "claudish@magus": "2.0.3", "mnemex@magus": "2.0.6" }),
120
+ both(popup.reporter, cli.reporter),
121
+ );
122
+
123
+ // mnemex loads its new version: a success, so it is not listed.
124
+ expect(result.failed).toEqual(["claudish@magus"]);
125
+ const report = popup.report(result.failed);
126
+ expect(report).toEqual({
127
+ headline: "claudish was not updated",
128
+ rows: [
129
+ {
130
+ subject: "claudish",
131
+ reason:
132
+ "installed v2.0.6, but Claude Code still loads v2.0.3 in this folder",
133
+ },
134
+ ],
135
+ fix: null,
136
+ });
137
+ // The same text the CLI prints on that item's ✗ line.
138
+ const line = cli.rows.get("claudish@magus") ?? "";
139
+ expect(line.trimStart().startsWith("✗")).toBe(true);
140
+ expect(line).toContain(report?.rows[0]?.reason ?? "<none>");
141
+ });
142
+
143
+ test("several: the headline counts them, and one reason at different versions is ONE row", async () => {
144
+ const popup = failureReasons();
145
+ const result = await applyPlugins(
146
+ ["claudish", "mnemex", "setup"].map((n) => updateItem(`${n}@magus`)),
147
+ PROJECT,
148
+ deps({
149
+ "claudish@magus": "2.0.3",
150
+ "mnemex@magus": "1.0.3",
151
+ "setup@magus": "0.9.0",
152
+ }),
153
+ popup.reporter,
154
+ );
155
+ expect(popup.report(result.failed)).toEqual({
156
+ headline: "3 plugins were not updated",
157
+ rows: [
158
+ {
159
+ subject: "claudish, mnemex, setup",
160
+ // M6: the versions differ, so each becomes a noun that keeps the
161
+ // sentence true — never "still loads in this folder".
162
+ reason:
163
+ "installed the new version, but Claude Code still loads an older version in this folder",
164
+ },
165
+ ],
166
+ fix: null,
167
+ });
168
+ });
169
+
170
+ test("different reasons: one row each; an exception keeps its first line only", async () => {
171
+ const popup = failureReasons();
172
+ const result = await applyPlugins(
173
+ [updateItem("claudish@magus"), updateItem("omega@magus")],
174
+ PROJECT,
175
+ deps(
176
+ { "claudish@magus": "2.0.3" },
177
+ {
178
+ "omega@magus":
179
+ "network unreachable (ECONNRESET)\n at fetch (node:internal)\n at run",
180
+ },
181
+ ),
182
+ popup.reporter,
183
+ );
184
+ const report = popup.report(result.failed);
185
+ expect(report?.headline).toBe("2 plugins were not updated");
186
+ expect(report?.rows).toEqual([
187
+ {
188
+ subject: "claudish",
189
+ reason:
190
+ "installed v2.0.6, but Claude Code still loads v2.0.3 in this folder",
191
+ },
192
+ // M10: a network failure is one magus recognises, so it carries a fix.
193
+ {
194
+ subject: "omega",
195
+ reason: "network unreachable (ECONNRESET)",
196
+ fix: "Check your network connection and try again",
197
+ },
198
+ ]);
199
+ });
200
+
201
+ test("a removal says 'removed'; a mix says both", () => {
202
+ expect(
203
+ pluginFailureReport([
204
+ {
205
+ pluginId: "code-analysis@magus",
206
+ verb: "removed",
207
+ detail: "still installed at USER scope",
208
+ },
209
+ ])?.headline,
210
+ ).toBe("code-analysis was not removed");
211
+ expect(
212
+ pluginFailureReport([
213
+ { pluginId: "a@magus", verb: "updated", detail: "x" },
214
+ { pluginId: "b@magus", verb: "removed", detail: "y" },
215
+ ])?.headline,
216
+ ).toBe("2 plugins were not updated or removed");
217
+ });
218
+
219
+ test("every row sharing one fix: said once, under the rows, and taken off each", () => {
220
+ const report = pluginFailureReport([
221
+ {
222
+ pluginId: "dev@magus",
223
+ verb: "updated",
224
+ detail: "v8.1.3 is not downloaded — run magus install",
225
+ },
226
+ {
227
+ pluginId: "go@magus",
228
+ verb: "updated",
229
+ detail: "v1.2.0 is not downloaded — run magus install",
230
+ },
231
+ ]);
232
+ expect(report).toEqual({
233
+ headline: "2 plugins were not updated",
234
+ rows: [{ subject: "dev, go", reason: "that version is not downloaded" }],
235
+ fix: "Run magus install",
236
+ });
237
+ });
238
+
239
+ test("NEGATIVE CONTROL: different fixes are not merged into one", () => {
240
+ const report = pluginFailureReport([
241
+ {
242
+ pluginId: "dev@magus",
243
+ verb: "updated",
244
+ detail: "v8.1.3 is not downloaded — run magus install",
245
+ },
246
+ {
247
+ pluginId: "go@magus",
248
+ verb: "updated",
249
+ detail:
250
+ "Claude Code loads the USER install of it here — update it at USER scope",
251
+ },
252
+ ]);
253
+ expect(report?.fix).toBeNull();
254
+ // Each row keeps its OWN fix, drawn under its reason; the reason no
255
+ // longer repeats it.
256
+ expect(report?.rows).toEqual([
257
+ {
258
+ subject: "dev",
259
+ reason: "v8.1.3 is not downloaded",
260
+ fix: "Run magus install",
261
+ },
262
+ {
263
+ subject: "go",
264
+ reason: "Claude Code loads the USER install of it here",
265
+ fix: "Update it at USER scope",
266
+ },
267
+ ]);
268
+ });
269
+
270
+ test("NEGATIVE CONTROL: nothing failed, no popup", () => {
271
+ expect(pluginFailureReport([])).toBeNull();
272
+ expect(failureReasons().report([])).toBeNull();
273
+ });
274
+
275
+ test("the grouping key replaces versions with nouns and changes nothing else", () => {
276
+ expect(
277
+ reasonWithoutVersions(
278
+ "installed v2.0.6, but Claude Code still loads v2.0.3 in this folder",
279
+ ),
280
+ ).toBe(
281
+ "installed the new version, but Claude Code still loads an older version in this folder",
282
+ );
283
+ expect(reasonWithoutVersions("still installed at USER scope")).toBe(
284
+ "still installed at USER scope",
285
+ );
286
+ });
287
+ });
288
+
289
+ // ── the rendering ────────────────────────────────────────────────────────────
290
+
291
+ interface Node {
292
+ type: unknown;
293
+ props: Record<string, unknown> & { children?: unknown };
294
+ }
295
+
296
+ /** Every text-bearing element in the tree, with the fg it is drawn in. */
297
+ function spans(
298
+ el: unknown,
299
+ inherited?: unknown,
300
+ ): Array<{ text: string; fg: unknown }> {
301
+ if (el === null || el === undefined || el === false) return [];
302
+ if (typeof el === "string" || typeof el === "number") {
303
+ return [{ text: String(el), fg: inherited }];
304
+ }
305
+ if (Array.isArray(el)) return el.flatMap((c) => spans(c, inherited));
306
+ const node = el as Node;
307
+ const fg = node.props?.fg ?? inherited;
308
+ return spans(node.props?.children, fg);
309
+ }
310
+
311
+ describe("the popup's drawing", () => {
312
+ const report = {
313
+ headline: "2 plugins were not updated",
314
+ rows: [
315
+ {
316
+ subject: "claudish",
317
+ reason: "Claude Code still loads v2.0.3 in this folder",
318
+ },
319
+ { subject: "omega", reason: "network unreachable" },
320
+ ],
321
+ fix: null,
322
+ };
323
+
324
+ test("✗ in danger, the subject in text, the reason muted — theme tokens only", () => {
325
+ const drawn = spans(
326
+ FailureListModal({ report, terminalWidth: 120 }) as React.ReactElement,
327
+ );
328
+ const text = drawn.map((s) => s.text).join("");
329
+ expect(text).toContain("2 plugins were not updated");
330
+ for (const s of drawn.filter((d) => d.text === "✗ ")) {
331
+ expect(s.fg).toBe(theme.colors.danger);
332
+ }
333
+ // Headline plus one mark per row.
334
+ expect(drawn.filter((d) => d.text === "✗ ")).toHaveLength(3);
335
+ expect(drawn.find((d) => d.text.startsWith("claudish"))?.fg).toBe(
336
+ theme.colors.text,
337
+ );
338
+ expect(
339
+ drawn.find(
340
+ (d) => d.text === "Claude Code still loads v2.0.3 in this folder",
341
+ )?.fg,
342
+ ).toBe(theme.colors.muted);
343
+ // No fix shared, so no fix line.
344
+ expect(text).not.toContain("Run ");
345
+ });
346
+
347
+ test("rows kept apart with group:false get distinct React keys even when subject and reason repeat", () => {
348
+ const row = { subject: "designer", reason: "network unreachable" };
349
+ const keys: string[] = [];
350
+ const walk = (node: unknown): void => {
351
+ if (Array.isArray(node)) {
352
+ for (const child of node) walk(child);
353
+ return;
354
+ }
355
+ if (!node || typeof node !== "object" || !("props" in node)) return;
356
+ const el = node as React.ReactElement<{ children?: unknown }>;
357
+ if (el.key !== null && el.key !== undefined) keys.push(String(el.key));
358
+ walk(el.props.children);
359
+ };
360
+ walk(
361
+ FailureListModal({
362
+ report: {
363
+ headline: "2 plugins were not updated",
364
+ rows: [row, row],
365
+ fix: null,
366
+ },
367
+ terminalWidth: 120,
368
+ }),
369
+ );
370
+ // One keyed box per row, and no two alike — a repeat makes React drop one.
371
+ expect(keys).toHaveLength(2);
372
+ expect(new Set(keys).size).toBe(2);
373
+ });
374
+
375
+ test("fits the terminal, and a long subject puts its reason underneath", () => {
376
+ expect(failureListWidth(200)).toBe(88);
377
+ expect(failureListWidth(60)).toBe(56);
378
+ expect(failureListWidth(20)).toBe(40);
379
+ expect(reasonBelow("claudish", 80)).toBe(false);
380
+ expect(reasonBelow("claudish, mnemex, setup, designer, terminal", 80)).toBe(
381
+ true,
382
+ );
383
+ });
384
+
385
+ test("drawn on a narrow terminal: the reason wraps, the name stays whole", async () => {
386
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
387
+ <FailureListModal
388
+ report={{
389
+ headline: "claudish was not updated",
390
+ rows: [
391
+ {
392
+ subject: "claudish",
393
+ reason:
394
+ "installed v2.0.6, but Claude Code still loads v2.0.3 in this folder",
395
+ },
396
+ ],
397
+ fix: "Run magus update to try again",
398
+ }}
399
+ terminalWidth={50}
400
+ />,
401
+ { width: 50, height: 16 },
402
+ );
403
+ await act(async () => {
404
+ await renderOnce();
405
+ await renderOnce();
406
+ });
407
+ const frame = captureCharFrame();
408
+ await act(async () => renderer.destroy());
409
+
410
+ expect(frame).toContain("✗ claudish was not updated");
411
+ expect(frame).toContain("✗ claudish installed v2.0.6, but");
412
+ // Wrapped onto the next line, not cut off.
413
+ expect(frame.replace(/[│\s]+/g, " ")).toContain(
414
+ "Claude Code still loads v2.0.3 in this folder",
415
+ );
416
+ expect(frame).toContain("Run magus update to try again");
417
+ expect(frame).toContain("Press any key to continue");
418
+ });
419
+ });