magus-cli 7.7.0 → 7.8.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 (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -1,604 +0,0 @@
1
- /**
2
- * Spec "no confirmation screens in the magus TUI" — rules 1, 3, 4, 5: results become
3
- * non-blocking notice lines instead of success modals.
4
- *
5
- * Written blind: from the spec and the exported signatures and types only
6
- * (reducer actions NOTIFY / EXPIRE_NOTICES, `useNotify`, `NOTICE_DURATION_MS`, and the
7
- * three pure wording functions). No implementation body was read.
8
- *
9
- * Coverage map (requirement → describe block):
10
- * REQ-3a notify adds a notice → "REQ-3 reducer"
11
- * REQ-3b same text replaces, never stacks → "REQ-3 reducer"
12
- * REQ-3c at most MAX_NOTICES, newest kept → "REQ-3 reducer"
13
- * REQ-3d expiry drops only expired notices → "REQ-3 reducer"
14
- * REQ-3e warnings stay longer than successes → "REQ-3 durations", "REQ-3 useNotify"
15
- * REQ-3f disappears by itself (no key) → reducer expiry only (see below)
16
- * REQ-5a activation → `Using profile "<id>"` → "REQ-5 activationNotices"
17
- * REQ-5b backup ("moved aside") lines dropped → "REQ-5 activationNotices"
18
- * REQ-5c missing plugin → warning, short name, `magus install`, one line if several
19
- * REQ-4 wording: one line, no internals → "REQ-4 wording"
20
- * REQ-1 gitignore fix / stale-load repair results are notices with a tone
21
- *
22
- * Not covered: rendering of NoticeBanner and "never takes keys" — the banner is not
23
- * exported and is only reachable by rendering the whole App, which reads the machine's
24
- * real plugin state. For the same reason the timer that dispatches EXPIRE_NOTICES is not
25
- * exercised: the contract does not say which component owns it, and under AppProvider
26
- * alone a success notice was still present after its duration.
27
- *
28
- * Rule 4 lets a notice name a file the user must act on. Untracking generated files
29
- * leaves a staged removal the user has to commit, so the gitignore fix may name those
30
- * files, and may word that line as a warning (rule 6: a result the user must act on).
31
- */
32
- import { afterAll, describe, expect, test } from "bun:test";
33
- import { mkdtempSync, rmSync } from "node:fs";
34
- import { tmpdir } from "node:os";
35
- import path from "node:path";
36
- import { testRender } from "@opentui/react/test-utils";
37
- import { act, useEffect } from "react";
38
- import type { StartupFixResult } from "../services/gitignore-service.js";
39
- import type { ActivationResult } from "../services/profile-activate.js";
40
- import { startupFixNotices } from "../ui/hooks/useGitignoreModal.js";
41
- import { activationNotices } from "../ui/hooks/useProfileStartupModal.js";
42
- import { staleLoadRepairNotices } from "../ui/hooks/useStaleLoadModal.js";
43
- import {
44
- AppProvider,
45
- NOTICE_DURATION_MS,
46
- useApp,
47
- useNotify,
48
- } from "../ui/state/AppContext.js";
49
- import { MAX_NOTICES, appReducer, initialState } from "../ui/state/reducer.js";
50
- import type {
51
- AppState,
52
- Notice,
53
- NoticeLine,
54
- NoticeTone,
55
- } from "../ui/state/types.js";
56
-
57
- // ---------------------------------------------------------------------------
58
- // Builders
59
- // ---------------------------------------------------------------------------
60
-
61
- let nextId = 1;
62
- function notice(
63
- text: string,
64
- tone: NoticeTone = "success",
65
- expiresAt = 10_000,
66
- ): Notice {
67
- return { id: nextId++, text, tone, expiresAt };
68
- }
69
-
70
- function stateWith(notices: Notice[] = []): AppState {
71
- return { ...initialState, notices };
72
- }
73
-
74
- function notify(state: AppState, n: Notice): AppState {
75
- return appReducer(state, { type: "NOTIFY", notice: n });
76
- }
77
-
78
- function activation(
79
- overrides: Partial<ActivationResult> = {},
80
- ): ActivationResult {
81
- return {
82
- profileId: "magus-main",
83
- displaced: [],
84
- skills: { skills: {}, installed: [], removed: [], failed: [] },
85
- conversion: {
86
- converted: false,
87
- managed: {},
88
- restored: [],
89
- conflicts: [],
90
- keptAt: null,
91
- },
92
- tracked: [],
93
- keptAt: null,
94
- ...overrides,
95
- };
96
- }
97
-
98
- /** The spec's own example: a file moved aside into the backup folder. */
99
- const DISPLACED = [
100
- {
101
- link: ".claude/settings.json",
102
- savedTo: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/settings.json",
103
- },
104
- ];
105
-
106
- const FORBIDDEN = [
107
- ".magus-backups",
108
- "plugin.json",
109
- "settings.json",
110
- "installed_plugins",
111
- "registry",
112
- "marker",
113
- ];
114
-
115
- /** `allowed`: file names the user must act on, which rule 4 permits naming. */
116
- function expectUserTerms(
117
- lines: readonly NoticeLine[],
118
- allowed: readonly string[] = [],
119
- ): void {
120
- for (const line of lines) {
121
- expect(line.text.length).toBeGreaterThan(0);
122
- expect(line.text).not.toContain("\n");
123
- expect(["success", "warning"]).toContain(line.tone);
124
- for (const word of FORBIDDEN.filter((w) => !allowed.includes(w))) {
125
- expect({ text: line.text, leaks: line.text.includes(word) }).toEqual({
126
- text: line.text,
127
- leaks: false,
128
- });
129
- }
130
- }
131
- }
132
-
133
- // ---------------------------------------------------------------------------
134
- // REQ-3 reducer
135
- // ---------------------------------------------------------------------------
136
-
137
- describe("REQ-3 reducer: NOTIFY", () => {
138
- test("REQ-3a adds the notice to an empty banner", () => {
139
- const n = notice("Profile saved");
140
-
141
- const next = notify(stateWith(), n);
142
-
143
- expect(next.notices).toEqual([n]);
144
- });
145
-
146
- test("REQ-3a keeps earlier distinct notices, oldest first", () => {
147
- const a = notice("first");
148
- const b = notice("second", "warning");
149
-
150
- const next = notify(notify(stateWith(), a), b);
151
-
152
- expect(next.notices).toEqual([a, b]);
153
- });
154
-
155
- test("REQ-3b the same text replaces the earlier notice instead of stacking", () => {
156
- const first = notice('Using profile "magus-main"', "success", 1_000);
157
- const again = notice('Using profile "magus-main"', "success", 9_000);
158
-
159
- const next = notify(notify(stateWith(), first), again);
160
-
161
- expect(next.notices).toEqual([again]);
162
- });
163
-
164
- test("REQ-3b same text replaces while other notices stay", () => {
165
- const other = notice("other");
166
- const first = notice("repeat");
167
- const again = notice("repeat", "warning", 20_000);
168
-
169
- const next = notify(notify(notify(stateWith(), other), first), again);
170
-
171
- expect(next.notices.filter((n) => n.text === "repeat")).toEqual([again]);
172
- expect(next.notices.map((n) => n.text).sort()).toEqual(["other", "repeat"]);
173
- });
174
-
175
- test("REQ-3c MAX_NOTICES is 4", () => {
176
- expect(MAX_NOTICES).toBe(4);
177
- });
178
-
179
- test("REQ-3c a fifth distinct notice drops the oldest one", () => {
180
- const five = ["one", "two", "three", "four", "five"].map((t) => notice(t));
181
-
182
- const next = five.reduce(notify, stateWith());
183
-
184
- expect(next.notices).toEqual(five.slice(1));
185
- });
186
- });
187
-
188
- describe("REQ-3 reducer: EXPIRE_NOTICES", () => {
189
- test("REQ-3d removes only the notices whose time has passed", () => {
190
- const expired = notice("old", "success", 1_000);
191
- const live = notice("new", "warning", 5_000);
192
-
193
- const next = appReducer(stateWith([expired, live]), {
194
- type: "EXPIRE_NOTICES",
195
- now: 3_000,
196
- });
197
-
198
- expect(next.notices).toEqual([live]);
199
- });
200
-
201
- test("REQ-3d removes every notice once all have expired", () => {
202
- const next = appReducer(
203
- stateWith([notice("a", "success", 1_000), notice("b", "warning", 2_000)]),
204
- { type: "EXPIRE_NOTICES", now: 50_000 },
205
- );
206
-
207
- expect(next.notices).toEqual([]);
208
- });
209
-
210
- test("REQ-3d returns the very same state object when nothing expired", () => {
211
- const state = stateWith([notice("a", "success", 5_000)]);
212
-
213
- const next = appReducer(state, { type: "EXPIRE_NOTICES", now: 1_000 });
214
-
215
- expect(next).toBe(state);
216
- });
217
-
218
- test("REQ-3d returns the very same state object when there are no notices", () => {
219
- const state = stateWith();
220
-
221
- const next = appReducer(state, { type: "EXPIRE_NOTICES", now: 1_000 });
222
-
223
- expect(next).toBe(state);
224
- });
225
- });
226
-
227
- describe("REQ-3 durations", () => {
228
- test("REQ-3e a warning stays visible longer than a success", () => {
229
- expect(NOTICE_DURATION_MS.warning).toBeGreaterThan(
230
- NOTICE_DURATION_MS.success,
231
- );
232
- });
233
-
234
- test("REQ-3e a success disappears within a few seconds", () => {
235
- expect(NOTICE_DURATION_MS.success).toBeGreaterThan(0);
236
- expect(NOTICE_DURATION_MS.success).toBeLessThanOrEqual(10_000);
237
- });
238
-
239
- test("REQ-3e posted together, the success expires while the warning is still shown", () => {
240
- const posted = 100_000;
241
- const ok = notice("done", "success", posted + NOTICE_DURATION_MS.success);
242
- const warn = notice(
243
- "careful",
244
- "warning",
245
- posted + NOTICE_DURATION_MS.warning,
246
- );
247
-
248
- const next = appReducer(stateWith([ok, warn]), {
249
- type: "EXPIRE_NOTICES",
250
- now: posted + NOTICE_DURATION_MS.success + 1,
251
- });
252
-
253
- expect(next.notices).toEqual([warn]);
254
- });
255
- });
256
-
257
- // ---------------------------------------------------------------------------
258
- // REQ-3 useNotify, through the real provider
259
- // ---------------------------------------------------------------------------
260
-
261
- const scratch = mkdtempSync(path.join(tmpdir(), "magus-notices-"));
262
- afterAll(() => rmSync(scratch, { recursive: true, force: true }));
263
-
264
- interface Posted {
265
- text: string;
266
- tone?: NoticeTone;
267
- }
268
-
269
- /** Renders a provider whose child posts `posts` on mount; `seen()` reads the banner state. */
270
- async function withProvider(posts: Posted[]) {
271
- let current: Notice[] = [];
272
- let modal: unknown = "never rendered";
273
- function Probe() {
274
- const notifyFn = useNotify();
275
- const { state } = useApp();
276
- current = state.notices;
277
- modal = state.modal;
278
- useEffect(() => {
279
- for (const p of posts) {
280
- if (p.tone === undefined) notifyFn(p.text);
281
- else notifyFn(p.text, p.tone);
282
- }
283
- }, [notifyFn, posts]);
284
- return <text>probe</text>;
285
- }
286
- const before = Date.now();
287
- const { renderOnce, renderer } = await testRender(
288
- <AppProvider initialProjectPath={scratch}>
289
- <Probe />
290
- </AppProvider>,
291
- { width: 40, height: 5 },
292
- );
293
- await act(async () => {
294
- await renderOnce();
295
- await renderOnce();
296
- });
297
- const after = Date.now();
298
- return {
299
- before,
300
- after,
301
- seen: () => current,
302
- modal: () => modal,
303
- destroy: () => act(async () => renderer.destroy()),
304
- };
305
- }
306
-
307
- describe("REQ-3 useNotify", () => {
308
- test("REQ-3a notify without a tone posts a success that expires after the success duration", async () => {
309
- const h = await withProvider([{ text: "Plugin updated" }]);
310
-
311
- const seen = h.seen();
312
- await h.destroy();
313
-
314
- expect(seen.map((n) => ({ text: n.text, tone: n.tone }))).toEqual([
315
- { text: "Plugin updated", tone: "success" },
316
- ]);
317
- expect(seen[0].expiresAt).toBeGreaterThanOrEqual(
318
- h.before + NOTICE_DURATION_MS.success,
319
- );
320
- expect(seen[0].expiresAt).toBeLessThanOrEqual(
321
- h.after + NOTICE_DURATION_MS.success,
322
- );
323
- });
324
-
325
- test("REQ-3e a warning notice expires after the longer warning duration", async () => {
326
- const h = await withProvider([
327
- { text: "typescript-lsp isn't downloaded yet", tone: "warning" },
328
- ]);
329
-
330
- const seen = h.seen();
331
- await h.destroy();
332
-
333
- expect(seen).toHaveLength(1);
334
- expect(seen[0].tone).toBe("warning");
335
- expect(seen[0].expiresAt).toBeGreaterThanOrEqual(
336
- h.before + NOTICE_DURATION_MS.warning,
337
- );
338
- expect(seen[0].expiresAt).toBeLessThanOrEqual(
339
- h.after + NOTICE_DURATION_MS.warning,
340
- );
341
- });
342
-
343
- test("REQ-3b notifying the same text twice shows it once", async () => {
344
- const h = await withProvider([{ text: "Saved" }, { text: "Saved" }]);
345
-
346
- const seen = h.seen();
347
- await h.destroy();
348
-
349
- expect(seen.map((n) => n.text)).toEqual(["Saved"]);
350
- });
351
-
352
- test("REQ-3 a notice never occupies the modal slot", async () => {
353
- const h = await withProvider([{ text: "Done", tone: "warning" }]);
354
-
355
- const seen = h.seen();
356
- const modal = h.modal();
357
- await h.destroy();
358
-
359
- expect(seen).toHaveLength(1);
360
- expect(modal).toBeNull();
361
- });
362
- });
363
-
364
- // ---------------------------------------------------------------------------
365
- // REQ-5 activationNotices
366
- // ---------------------------------------------------------------------------
367
-
368
- describe("REQ-5 activationNotices", () => {
369
- test('REQ-5a a clean activation of magus-main is one success line: Using profile "magus-main"', () => {
370
- const lines = activationNotices(activation());
371
-
372
- expect(lines).toHaveLength(1);
373
- expect(lines[0].tone).toBe("success");
374
- expect(lines[0].text).toContain('Using profile "magus-main"');
375
- });
376
-
377
- test("REQ-5a the success line names the activated profile, whatever it is", () => {
378
- const lines = activationNotices(activation({ profileId: "frontend" }));
379
-
380
- const success = lines.filter((l) => l.tone === "success");
381
- expect(
382
- success.some((l) => l.text.includes('Using profile "frontend"')),
383
- ).toBe(true);
384
- expect(lines.some((l) => l.text.includes("magus-main"))).toBe(false);
385
- });
386
-
387
- test("REQ-5b files moved aside at activation add nothing to the notices", () => {
388
- const clean = activationNotices(activation());
389
-
390
- const withBackup = activationNotices(activation({ displaced: DISPLACED }));
391
-
392
- expect(withBackup).toEqual(clean);
393
- for (const line of withBackup) {
394
- expect(line.text).not.toContain(".magus-backups");
395
- expect(line.text.toLowerCase()).not.toContain("moved aside");
396
- }
397
- });
398
-
399
- test("REQ-5c a plugin whose details are missing is a warning naming its short name and magus install", () => {
400
- const lines = activationNotices(
401
- activation({
402
- dependencies: {
403
- derived: [],
404
- unreadable: ["typescript-lsp@claude-plugins-official"],
405
- blocked: [],
406
- },
407
- }),
408
- );
409
-
410
- const warnings = lines.filter((l) => l.tone === "warning");
411
- expect(warnings).toHaveLength(1);
412
- expect(warnings[0].text).toContain("typescript-lsp");
413
- expect(warnings[0].text).toContain("magus install");
414
- expect(warnings[0].text).not.toContain("@claude-plugins-official");
415
- expect(warnings[0].text).not.toContain("plugin.json");
416
- });
417
-
418
- test("REQ-5c a missing plugin still reports the profile as in use", () => {
419
- const lines = activationNotices(
420
- activation({
421
- dependencies: {
422
- derived: [],
423
- unreadable: ["typescript-lsp@claude-plugins-official"],
424
- blocked: [],
425
- },
426
- }),
427
- );
428
-
429
- expect(
430
- lines.some(
431
- (l) =>
432
- l.tone === "success" && l.text.includes('Using profile "magus-main"'),
433
- ),
434
- ).toBe(true);
435
- });
436
-
437
- test("REQ-5c several missing plugins combine into one warning line naming each", () => {
438
- const lines = activationNotices(
439
- activation({
440
- dependencies: {
441
- derived: [],
442
- unreadable: [
443
- "typescript-lsp@claude-plugins-official",
444
- "gopls-lsp@claude-plugins-official",
445
- "code-search@magus",
446
- ],
447
- blocked: [],
448
- },
449
- }),
450
- );
451
-
452
- const install = lines.filter((l) => l.text.includes("magus install"));
453
- expect(install).toHaveLength(1);
454
- expect(install[0].tone).toBe("warning");
455
- for (const name of ["typescript-lsp", "gopls-lsp", "code-search"]) {
456
- expect(install[0].text).toContain(name);
457
- }
458
- expect(install[0].text).not.toContain("@claude-plugins-official");
459
- expect(install[0].text).not.toContain("@magus");
460
- });
461
- });
462
-
463
- // ---------------------------------------------------------------------------
464
- // REQ-1 result notices for the gitignore fix and the stale-load repair
465
- // ---------------------------------------------------------------------------
466
-
467
- describe("REQ-1 startupFixNotices", () => {
468
- test("REQ-1 adding missing ignore entries, with nothing to commit, yields success notices only", () => {
469
- const lines = startupFixNotices({
470
- untracked: [],
471
- appended: [".claude/settings.json", ".mcp.json"],
472
- failed: [],
473
- });
474
-
475
- expect(lines.length).toBeGreaterThan(0);
476
- expect(lines.every((l) => l.tone === "success")).toBe(true);
477
- });
478
-
479
- test("REQ-1 untracking generated files yields at least one notice", () => {
480
- const lines = startupFixNotices({
481
- untracked: [".claude/settings.json", ".mcp.json"],
482
- appended: [".claude/settings.json", ".mcp.json"],
483
- failed: [],
484
- });
485
-
486
- expect(lines.length).toBeGreaterThan(0);
487
- });
488
-
489
- test("REQ-1 a fix with a file that could not be untracked yields a warning", () => {
490
- const lines = startupFixNotices({
491
- untracked: [".claude/settings.json"],
492
- appended: [],
493
- failed: [{ path: ".mcp.json", reason: "pathspec did not match" }],
494
- });
495
-
496
- expect(lines.some((l) => l.tone === "warning")).toBe(true);
497
- });
498
- });
499
-
500
- describe("REQ-1 staleLoadRepairNotices", () => {
501
- test("REQ-1 every repair succeeding yields success notices only", () => {
502
- const lines = staleLoadRepairNotices([
503
- { pluginId: "code-search@magus", target: "2.3.0", ok: true },
504
- { pluginId: "dev@magus", target: "3.1.0", ok: true },
505
- ]);
506
-
507
- expect(lines.length).toBeGreaterThan(0);
508
- expect(lines.every((l) => l.tone === "success")).toBe(true);
509
- });
510
-
511
- test("REQ-1 a repair that failed yields a warning", () => {
512
- const lines = staleLoadRepairNotices([
513
- { pluginId: "code-search@magus", target: "2.3.0", ok: true },
514
- {
515
- pluginId: "dev@magus",
516
- target: "3.1.0",
517
- ok: false,
518
- error: "permission denied",
519
- },
520
- ]);
521
-
522
- expect(lines.some((l) => l.tone === "warning")).toBe(true);
523
- });
524
- });
525
-
526
- // ---------------------------------------------------------------------------
527
- // REQ-4 wording: one line, the user's terms, no internals
528
- // ---------------------------------------------------------------------------
529
-
530
- describe("REQ-4 wording", () => {
531
- test("REQ-4 activationNotices never names backups, plugin.json, settings.json, the registry or the marker", () => {
532
- const lines = activationNotices(
533
- activation({
534
- displaced: DISPLACED,
535
- keptAt: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/_profiles",
536
- skills: {
537
- skills: { "api-design": "abc123" },
538
- installed: ["api-design"],
539
- removed: ["old-skill"],
540
- failed: [{ skill: "broken-skill", reason: "download failed" }],
541
- },
542
- conversion: {
543
- converted: true,
544
- managed: { "api-design": "abc123" },
545
- restored: ["my-custom-skill"],
546
- conflicts: [
547
- { folder: "dup-skill", kept: "dup-skill", other: "dup-skill-2" },
548
- ],
549
- keptAt: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/_profiles",
550
- },
551
- absorbed: ["Added the plugin dev@magus"],
552
- dependencies: {
553
- derived: ["claudish@magus"],
554
- unreadable: ["typescript-lsp@claude-plugins-official"],
555
- blocked: [
556
- { plugin: "multimodel@magus", dependency: "claudish@magus" },
557
- ],
558
- },
559
- committedSkills: ["team-skill"],
560
- releasedCommitted: ["shared-skill"],
561
- }),
562
- );
563
-
564
- expect(lines.length).toBeGreaterThan(0);
565
- expectUserTerms(lines);
566
- });
567
-
568
- test("REQ-4 startupFixNotices for added ignore entries names no file and no internals", () => {
569
- const lines = startupFixNotices({
570
- untracked: [],
571
- appended: [".claude/settings.json", ".claude/settings.local.json"],
572
- failed: [],
573
- });
574
-
575
- expect(lines.length).toBeGreaterThan(0);
576
- expectUserTerms(lines);
577
- });
578
-
579
- test("REQ-4 startupFixNotices never names backups, plugin.json, the registry or the marker, even when files must be committed", () => {
580
- const lines = startupFixNotices({
581
- untracked: [".claude/settings.json", ".claude/settings.local.json"],
582
- appended: [".claude/settings.json"],
583
- failed: [{ path: ".mcp.json", reason: "pathspec did not match" }],
584
- });
585
-
586
- expect(lines.length).toBeGreaterThan(0);
587
- expectUserTerms(lines, ["settings.json"]);
588
- });
589
-
590
- test("REQ-4 staleLoadRepairNotices never names settings.json, plugin.json, backups, the registry or the marker", () => {
591
- const lines = staleLoadRepairNotices([
592
- { pluginId: "code-search@magus", target: "2.3.0", ok: true },
593
- {
594
- pluginId: "dev@magus",
595
- target: "3.1.0",
596
- ok: false,
597
- error: "permission denied",
598
- },
599
- ]);
600
-
601
- expect(lines.length).toBeGreaterThan(0);
602
- expectUserTerms(lines);
603
- });
604
- });
@@ -1,72 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { appReducer, initialState } from "../ui/state/reducer.js";
3
- import type { AppAction, AppState } from "../ui/state/types.js";
4
-
5
- function run(actions: AppAction[], from: AppState = initialState): AppState {
6
- return actions.reduce(appReducer, from);
7
- }
8
-
9
- /**
10
- * The status line lives in app state, not in the screen.
11
- *
12
- * Two bugs drove this, and both were invisible to every test we had:
13
- *
14
- * 1. It was cleared on a 5-second wall clock, so the message describing what
15
- * you just did was usually gone before you looked at it — and unobservable
16
- * to anything sampling the screen a moment later.
17
- * 2. It was component-local, and `Router` swaps the component type on a tab
18
- * change, so `9 → 1 → 9` destroyed it.
19
- */
20
- describe("status line state", () => {
21
- test("survives leaving the tab and coming back", () => {
22
- const shown = run([
23
- {
24
- type: "STYLES_STATUS_SET",
25
- status: { text: "Selection reset to the live style", tone: "success" },
26
- },
27
- ]);
28
- expect(shown.styles.status?.text).toBe("Selection reset to the live style");
29
-
30
- // Navigating away and back is what unmounts the screen.
31
- const roundTrip = run(
32
- [
33
- { type: "NAVIGATE", route: { screen: "plugins" } },
34
- { type: "NAVIGATE", route: { screen: "styles" } },
35
- ],
36
- shown,
37
- );
38
- expect(roundTrip.styles.status?.text).toBe(
39
- "Selection reset to the live style",
40
- );
41
- });
42
-
43
- test("a new message replaces the previous one", () => {
44
- const state = run([
45
- { type: "STYLES_STATUS_SET", status: { text: "first", tone: "success" } },
46
- { type: "STYLES_STATUS_SET", status: { text: "second", tone: "error" } },
47
- ]);
48
- expect(state.styles.status).toEqual({ text: "second", tone: "error" });
49
- });
50
-
51
- test("clearing returns the line to its default", () => {
52
- const state = run([
53
- { type: "STYLES_STATUS_SET", status: { text: "done", tone: "success" } },
54
- { type: "STYLES_STATUS_CLEAR" },
55
- ]);
56
- expect(state.styles.status).toBeNull();
57
- });
58
-
59
- test("starts empty", () => {
60
- expect(initialState.styles.status).toBeNull();
61
- });
62
-
63
- test("keeps the tone, so an error cannot render as a success", () => {
64
- const state = run([
65
- {
66
- type: "STYLES_STATUS_SET",
67
- status: { text: "Already talking to GitHub", tone: "error" },
68
- },
69
- ]);
70
- expect(state.styles.status?.tone).toBe("error");
71
- });
72
- });