magus-cli 7.6.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 +9 -34
  16. package/src/__tests__/gitignore-profile.test.ts +0 -4
  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 -42
  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/gitignore-service.ts +0 -19
  36. package/src/services/plugin-env-needs.ts +69 -0
  37. package/src/services/plugin-manager.ts +31 -19
  38. package/src/services/plugin-resolution.ts +34 -42
  39. package/src/services/profile-absorb.ts +9 -0
  40. package/src/services/profile-activate.ts +1 -1
  41. package/src/services/profile-startup.ts +30 -27
  42. package/src/services/profile-state.ts +81 -2
  43. package/src/services/resolver.ts +95 -1
  44. package/src/services/update-engine.ts +326 -31
  45. package/src/services/update-plan.ts +293 -33
  46. package/src/types/index.ts +7 -0
  47. package/src/ui/App.tsx +186 -123
  48. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  49. package/src/ui/adapters/failureReport.ts +137 -0
  50. package/src/ui/adapters/pluginsAdapter.ts +299 -28
  51. package/src/ui/adapters/startupReports.ts +282 -0
  52. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  53. package/src/ui/components/modals/MessageModal.tsx +7 -13
  54. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  55. package/src/ui/components/modals/index.ts +1 -0
  56. package/src/ui/hooks/startupQueue.ts +56 -0
  57. package/src/ui/hooks/useGitignoreModal.ts +31 -31
  58. package/src/ui/hooks/useProfileStartupModal.ts +127 -122
  59. package/src/ui/hooks/useStaleLoadModal.ts +33 -53
  60. package/src/ui/keymap.ts +14 -1
  61. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  62. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  63. package/src/ui/renderers/pluginRowText.ts +25 -25
  64. package/src/ui/renderers/pluginStatusText.ts +18 -1
  65. package/src/ui/screens/AliasScreen.tsx +38 -44
  66. package/src/ui/screens/CliToolsScreen.tsx +65 -51
  67. package/src/ui/screens/EnvVarsScreen.tsx +21 -6
  68. package/src/ui/screens/GitignoreScreen.tsx +49 -75
  69. package/src/ui/screens/McpRegistryScreen.tsx +72 -40
  70. package/src/ui/screens/McpScreen.tsx +55 -24
  71. package/src/ui/screens/ModelsScreen.tsx +46 -86
  72. package/src/ui/screens/PluginsScreen.tsx +316 -245
  73. package/src/ui/screens/ProfilesScreen.tsx +88 -73
  74. package/src/ui/screens/SkillsScreen.tsx +83 -80
  75. package/src/ui/screens/StylesScreen.tsx +211 -275
  76. package/src/ui/state/AppContext.tsx +72 -7
  77. package/src/ui/state/DimensionsContext.tsx +3 -22
  78. package/src/ui/state/reducer.ts +25 -14
  79. package/src/ui/state/types.ts +61 -31
  80. package/src/utils/user-path.ts +60 -0
  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
@@ -17,6 +17,7 @@
17
17
  * Pure: no IO, no React. The screen gathers facts; this only assembles.
18
18
  */
19
19
 
20
+ import path from "node:path";
20
21
  import type { CliTool } from "../../data/cli-tools.js";
21
22
  import {
22
23
  cliToolInstall,
@@ -31,8 +32,11 @@ import {
31
32
  } from "../../services/tool-probe.js";
32
33
  import { binCommandForState } from "../../services/toolchain.js";
33
34
  import type { BinRequirement, ResolvedBin } from "../../types/index.js";
34
- import type { Command } from "../../utils/command-utils.js";
35
+ import { type Command, formatCommand } from "../../utils/command-utils.js";
35
36
  import type { StatusTone } from "../renderers/pluginStatusText.js";
37
+ import type { FailureReport } from "../state/types.js";
38
+ import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
39
+ import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
36
40
 
37
41
  export interface CliToolRow {
38
42
  tool: CliTool;
@@ -303,3 +307,201 @@ export function countToolRows(rows: readonly CliToolRow[]): {
303
307
  }
304
308
  return { installed, updates, unverified };
305
309
  }
310
+
311
+ // ─── Failures ────────────────────────────────────────────────────────────────
312
+ //
313
+ // Every installer command that fails goes into the failure popup with why and
314
+ // the command to run by hand. Update-all used to discard them: the reload
315
+ // showed the tool still behind, with no reason anywhere.
316
+
317
+ /**
318
+ * The verb for what an Enter action did not do — "installed", "updated",
319
+ * "reinstalled". The caller writes the headline around it:
320
+ * `${tool} was not ${cliToolNotDone(kind)}`.
321
+ */
322
+ export function cliToolNotDone(kind: CliToolEnterAction["kind"]): string {
323
+ if (kind === "install") return "installed";
324
+ if (kind === "reinstall") return "reinstalled";
325
+ return "updated";
326
+ }
327
+
328
+ /** One installer command that did not succeed. */
329
+ export interface CliToolCommandFailure {
330
+ /** The tool's display name. */
331
+ tool: string;
332
+ command: Command;
333
+ /** Why, in {@link commandFailureReason}'s words. */
334
+ reason: string;
335
+ }
336
+
337
+ /** The longest reason kept from an installer's own output. */
338
+ const MAX_REASON = 160;
339
+
340
+ /**
341
+ * npm's bookkeeping lines, which never say why: the error code and syscall
342
+ * fields, and where its log went.
343
+ */
344
+ const INSTALLER_NOISE =
345
+ /^(?:code|errno|syscall|path|dest)\b|^(?:A complete log of this run|Log files were not written|You can rerun|For a full report see)\b/i;
346
+
347
+ /**
348
+ * The name a user knows a command by: `/usr/local/bin/npm` → `npm`. Which
349
+ * copy of npm the machine found is mechanism, never part of a reason.
350
+ */
351
+ function commandName(command: Command): string {
352
+ return path.basename(command.cmd) || command.cmd;
353
+ }
354
+
355
+ /** How a timeout reads, so its fix can be told apart from an ordinary failure. */
356
+ const TIMED_OUT_REASON = /^did not finish within \d+ seconds$/;
357
+
358
+ /**
359
+ * The fix for a timeout. Not "Run it yourself": the same command would take
360
+ * just as long. A slow download is the usual cause, so it names the network
361
+ * without claiming that is what happened.
362
+ */
363
+ export const RETRY_TIMEOUT_FIX =
364
+ "Try again; if it keeps timing out, check your network connection";
365
+
366
+ /**
367
+ * Why an installer command failed, as ONE line, from what `execFile` threw.
368
+ *
369
+ * The thrown message's first line is "Command failed: <the command>", which
370
+ * says nothing the fix line does not. The installer's own words are in its
371
+ * stderr: the text after an `error:` (npm, brew and cargo all say it that
372
+ * way), else its first line that is not bookkeeping. A missing installer and
373
+ * a timeout are said plainly — a missing `claude` in the words every other
374
+ * screen uses for it. No absolute binary path survives into the reason.
375
+ */
376
+ export function commandFailureReason(
377
+ command: Command,
378
+ error: unknown,
379
+ timeoutMs: number,
380
+ ): string {
381
+ if (!(error instanceof Error)) {
382
+ return withoutBinaryPaths(String(error).split("\n")[0]?.trim() || "failed");
383
+ }
384
+ const e = error as Error & {
385
+ code?: unknown;
386
+ killed?: boolean;
387
+ signal?: unknown;
388
+ stderr?: unknown;
389
+ };
390
+ const name = commandName(command);
391
+ if (e.code === "ENOENT") {
392
+ return name === "claude"
393
+ ? CLAUDE_MISSING_REASON
394
+ : `${name} is not installed on this machine`;
395
+ }
396
+ if (e.killed || e.signal === "SIGTERM") {
397
+ return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
398
+ }
399
+ const lines = String(e.stderr ?? "")
400
+ .split("\n")
401
+ .map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
402
+ .filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
403
+ const said = lines.find((line) => /\berror:\s*\S/i.test(line));
404
+ let reason = said
405
+ ? said.slice(said.toLowerCase().lastIndexOf("error:") + 6).trim()
406
+ : (lines[0] ?? "");
407
+ if (!reason) {
408
+ reason =
409
+ typeof e.code === "number"
410
+ ? `${name} exited with code ${e.code}`
411
+ : e.message
412
+ .split("\n")[0]
413
+ ?.replace(/^Command failed: .*$/, "")
414
+ .trim() || `${name} failed`;
415
+ }
416
+ reason = withoutBinaryPaths(reason);
417
+ return reason.length > MAX_REASON
418
+ ? `${reason.slice(0, MAX_REASON - 1)}…`
419
+ : reason;
420
+ }
421
+
422
+ /**
423
+ * The fix for one failed installer command.
424
+ *
425
+ * Usually the command itself, to run by hand. Not for the three causes that
426
+ * running the same command again would not cure: a timeout, no `claude` to
427
+ * run, and no network. Each keeps the fix that does help.
428
+ */
429
+ export function commandFailureFix(command: Command, reason: string): string {
430
+ if (TIMED_OUT_REASON.test(reason)) return RETRY_TIMEOUT_FIX;
431
+ const known = exceptionReason(reason);
432
+ if (known.fix) return known.fix;
433
+ return `Run it yourself: ${formatCommand(command)}`;
434
+ }
435
+
436
+ /** The popup for failed installer commands, each with the fix that helps. */
437
+ export function cliToolFailureReport(
438
+ headline: string,
439
+ failures: readonly CliToolCommandFailure[],
440
+ ): FailureReport | null {
441
+ return failureReport(
442
+ headline,
443
+ failures.map((f) => ({
444
+ subject: f.tool,
445
+ reason: f.reason,
446
+ fix: commandFailureFix(f.command, f.reason),
447
+ })),
448
+ { group: false },
449
+ );
450
+ }
451
+
452
+ /** Update-all: the tools whose update command failed. */
453
+ export function cliToolsNotUpdatedReport(
454
+ failures: readonly CliToolCommandFailure[],
455
+ ): FailureReport | null {
456
+ const n = failures.length;
457
+ return cliToolFailureReport(
458
+ `${counted(n, "tool")} ${n === 1 ? "was" : "were"} not updated`,
459
+ failures,
460
+ );
461
+ }
462
+
463
+ /**
464
+ * A conflict magus cannot resolve: the tool is installed more than one way
465
+ * and none of the installs can be removed from here.
466
+ */
467
+ export function cliToolConflictStuckReport(
468
+ tool: string,
469
+ blockers: ReadonlyArray<{ method: string; reason: string }>,
470
+ ): FailureReport | null {
471
+ return failureReport(
472
+ `${tool} is installed ${blockers.length} ways, and magus can remove none of them`,
473
+ blockers.map(({ method, reason }) => ({
474
+ subject: `${method} install`,
475
+ reason,
476
+ fix: "Remove one by hand, then press r",
477
+ })),
478
+ { group: false },
479
+ );
480
+ }
481
+
482
+ /**
483
+ * A conflict resolved only in part: each removal that failed, with the
484
+ * command to run by hand, and each install magus could never remove.
485
+ */
486
+ export function cliToolConflictPartialReport(
487
+ tool: string,
488
+ failed: ReadonlyArray<{ method: string; command: Command; reason: string }>,
489
+ leftBehind: ReadonlyArray<{ method: string; reason: string }>,
490
+ ): FailureReport | null {
491
+ return failureReport(
492
+ `${tool} is still installed more than one way`,
493
+ [
494
+ ...failed.map((f) => ({
495
+ subject: `${f.method} install`,
496
+ reason: `was not removed: ${f.reason}`,
497
+ fix: `Run it yourself: ${formatCommand(f.command)}`,
498
+ })),
499
+ ...leftBehind.map(({ method, reason }) => ({
500
+ subject: `${method} install`,
501
+ reason: `stays: ${reason}`,
502
+ fix: "Remove it by hand if you want it gone",
503
+ })),
504
+ ],
505
+ { group: false },
506
+ );
507
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * THE failure popup's words, for every screen and every kind of subject.
3
+ *
4
+ * The TUI shows no status lines, notices or success messages: a screen reloads
5
+ * and shows state. What the user must KNOW because something failed — or must
6
+ * ACT on — goes into ONE popup (`modal.failures`, `FailureListModal`), and its
7
+ * shape is built here: a headline, one row per thing that failed (a plugin, a
8
+ * marketplace, a skill, a file, the profile), and the one fix when every row
9
+ * shares it. Pure, so the words are tested without a renderer.
10
+ *
11
+ * Each domain has a builder on top of {@link failureReport} —
12
+ * `pluginFailureReport` (pluginsAdapter.ts), `activationFailureReport` and the
13
+ * startup and Git State builders (startupReports.ts) — so they all read the
14
+ * same way.
15
+ */
16
+
17
+ import type { FailureReport, FailureRow } from "../state/types.js";
18
+
19
+ /**
20
+ * A fix a reason names at its end — "… — run magus install", "… — fix it by
21
+ * hand". Taken off the reason and carried as the row's fix, so it is said once
22
+ * (under the rows when every row shares it) rather than inside every reason.
23
+ */
24
+ const FIX_CLAUSE =
25
+ / — ((?:run|update it|ask them|fix it|remove|install|check|compare|commit)\b.*)$/;
26
+
27
+ /** `run magus install` → `Run magus install`. */
28
+ function sentence(text: string): string {
29
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
30
+ }
31
+
32
+ /**
33
+ * An executable named by absolute path — `/usr/local/bin/npm`,
34
+ * `/Users/someone/.local/bin/claude` — is the mechanism: which copy the
35
+ * machine happened to find. The user knows it by its name.
36
+ */
37
+ const BINARY_PATH = /(?:\/[^\s'"/:]+)+\/bin\/([\w.-]+)/g;
38
+
39
+ /** Every absolute binary path in `text` replaced by the binary's name. */
40
+ export function withoutBinaryPaths(text: string): string {
41
+ return text.replace(BINARY_PATH, "$1");
42
+ }
43
+
44
+ /**
45
+ * Split a reason into what went wrong and what to do about it, when the reason
46
+ * ends in a fix clause ({@link FIX_CLAUSE}). A row's own `fix` wins.
47
+ */
48
+ export function splitFix(row: FailureRow): FailureRow {
49
+ if (row.fix) return row;
50
+ const match = FIX_CLAUSE.exec(row.reason);
51
+ if (!match?.[1]) return row;
52
+ return {
53
+ subject: row.subject,
54
+ reason: row.reason.slice(0, match.index).trim() || row.reason,
55
+ fix: sentence(match[1]),
56
+ };
57
+ }
58
+
59
+ export interface FailureReportOptions {
60
+ /**
61
+ * Merge rows whose reason AND fix are identical into one row naming every
62
+ * subject (default true). A caller with its own grouping rules — the plugin
63
+ * builder keeps unexplained exceptions apart — passes false.
64
+ */
65
+ group?: boolean;
66
+ }
67
+
68
+ /**
69
+ * The popup for `rows`, headed by `headline`; null when nothing failed — a
70
+ * success says nothing, because the reloaded screen shows it.
71
+ *
72
+ * Fixes: a fix clause at the end of a reason becomes the row's fix. When every
73
+ * row has the same fix, it is the report's `fix`, said once under the rows,
74
+ * and taken off each row; otherwise each row keeps its own (or none).
75
+ */
76
+ export function failureReport(
77
+ headline: string,
78
+ rows: readonly FailureRow[],
79
+ opts: FailureReportOptions = {},
80
+ ): FailureReport | null {
81
+ if (rows.length === 0) return null;
82
+ let out = rows.map(splitFix);
83
+
84
+ if (opts.group !== false) {
85
+ const grouped: FailureRow[] = [];
86
+ const byKey = new Map<string, FailureRow>();
87
+ for (const row of out) {
88
+ const key = `${row.reason}\u0000${row.fix ?? ""}`;
89
+ const existing = byKey.get(key);
90
+ if (existing) {
91
+ existing.subject = `${existing.subject}, ${row.subject}`;
92
+ continue;
93
+ }
94
+ const copy = { ...row };
95
+ byKey.set(key, copy);
96
+ grouped.push(copy);
97
+ }
98
+ out = grouped;
99
+ }
100
+
101
+ const [first] = out;
102
+ const shared =
103
+ first?.fix && out.every((row) => row.fix === first.fix) ? first.fix : null;
104
+ if (shared) {
105
+ out = out.map(({ fix: _fix, ...row }) => row);
106
+ }
107
+ return { headline, rows: out, fix: shared };
108
+ }
109
+
110
+ /**
111
+ * Several reports about ONE event, as one popup under `headline` — so a user
112
+ * reads one list, not two popups in a row. A single report passes through
113
+ * with its own headline; none is null.
114
+ *
115
+ * Each report's shared fix goes back onto its own rows first, so no row loses
116
+ * its fix; the merged report then says a fix once again only when every row
117
+ * shares it.
118
+ */
119
+ export function mergeFailureReports(
120
+ headline: string,
121
+ reports: ReadonlyArray<FailureReport | null | undefined>,
122
+ ): FailureReport | null {
123
+ const present = reports.filter((r): r is FailureReport => !!r);
124
+ const [only] = present;
125
+ if (present.length <= 1) return only ?? null;
126
+ const rows = present.flatMap((report) =>
127
+ report.rows.map((row) =>
128
+ row.fix || !report.fix ? row : { ...row, fix: report.fix },
129
+ ),
130
+ );
131
+ return failureReport(headline, rows, { group: false });
132
+ }
133
+
134
+ /** `1 plugin`, `3 plugins`. */
135
+ export function counted(n: number, noun: string, plural = `${noun}s`): string {
136
+ return `${n} ${n === 1 ? noun : plural}`;
137
+ }