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
@@ -17,7 +17,8 @@
17
17
  * Pure: no IO, no React. The screen gathers facts; this only assembles.
18
18
  */
19
19
 
20
- import type { CliTool } from "../../data/cli-tools.js";
20
+ import path from "node:path";
21
+ import type { CliTool, OwnedCatalogueEntry } from "../../data/cli-tools.js";
21
22
  import {
22
23
  cliToolInstall,
23
24
  cliToolUpdate,
@@ -30,14 +31,23 @@ import {
30
31
  classifyTool,
31
32
  } from "../../services/tool-probe.js";
32
33
  import { binCommandForState } from "../../services/toolchain.js";
33
- import type { BinRequirement, ResolvedBin } from "../../types/index.js";
34
- import type { Command } from "../../utils/command-utils.js";
34
+ import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
35
+ import type {
36
+ ManagedBin,
37
+ ManagedTool,
38
+ ResolvedBin,
39
+ } from "../../types/index.js";
40
+ import { type Command, formatCommand } from "../../utils/command-utils.js";
41
+ import type { RunResult } from "../../utils/run.js";
35
42
  import type { StatusTone } from "../renderers/pluginStatusText.js";
43
+ import type { FailureReport } from "../state/types.js";
44
+ import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
45
+ import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
36
46
 
37
47
  export interface CliToolRow {
38
48
  tool: CliTool;
39
49
  /** The requirement the tool was probed through (closure → detected → catalogue). */
40
- spec: BinRequirement;
50
+ spec: ManagedTool;
41
51
  /** True until facts arrive. */
42
52
  checking: boolean;
43
53
  /** Null while checking. */
@@ -52,25 +62,65 @@ export interface CliToolRow {
52
62
  updateCommand?: Command;
53
63
  /** What Enter runs for a tool that is not installed. */
54
64
  installCommand: Command;
65
+ /**
66
+ * For a tool a plugin owns: that plugin's requirement. Enter on a missing
67
+ * one installs it through the deps executor, like `magus doctor --fix`.
68
+ */
69
+ owned?: ResolvedBin;
70
+ }
71
+
72
+ /**
73
+ * A catalogue tool its plugin installs with an option magus does not version
74
+ * (a release, a Homebrew cask, apt): read-only. No Enter action, no update,
75
+ * no conflict fix — the row says which plugin manages it and whether it is
76
+ * here, from the same probe `magus doctor` runs.
77
+ */
78
+ export interface PluginManagedRow {
79
+ /** The plugin that installs it: `<plugin>@<marketplace>`. */
80
+ managedBy: string;
81
+ entry: OwnedCatalogueEntry;
82
+ owned: ResolvedBin;
83
+ /** Null while checking. */
84
+ presence: Presence | null;
85
+ /** The plugin's install line, for display; `magus doctor --fix` runs it. */
86
+ installText: string;
87
+ }
88
+
89
+ /** A CLI Tools list row. */
90
+ export type CliToolsRow = CliToolRow | PluginManagedRow;
91
+
92
+ export const isPluginManagedRow = (row: CliToolsRow): row is PluginManagedRow =>
93
+ "managedBy" in row;
94
+
95
+ /** The tool's catalogue name, whichever kind of row. */
96
+ export function rowName(row: CliToolsRow): string {
97
+ return isPluginManagedRow(row) ? row.entry.name : row.tool.name;
98
+ }
99
+
100
+ /** On PATH or in a user prefix, as far as the row knows. */
101
+ function rowInstalled(row: CliToolsRow): boolean {
102
+ if (!isPluginManagedRow(row)) return isInstalled(row.status);
103
+ const status = row.presence?.status;
104
+ return status === "present" || status === "off-path";
55
105
  }
56
106
 
57
107
  /**
58
108
  * Did this spec come from the resolver's closure — i.e. does the active profile
59
109
  * declare this tool?
60
110
  *
61
- * `ResolvedBin` is `BinRequirement` plus `sources`, and only the resolver adds
111
+ * `ManagedBin` is `ManagedTool` plus `sources`, and only the resolver adds
62
112
  * them, so the provenance travels with the spec itself rather than being
63
113
  * re-derived here (which would be a second copy of `specForCatalogTool`'s
64
114
  * branch 1, free to disagree with it).
65
115
  */
66
- function declaredSpec(spec: BinRequirement): ResolvedBin | null {
67
- const candidate = spec as ResolvedBin;
116
+ function declaredSpec(spec: ManagedTool): ManagedBin | null {
117
+ const candidate = spec as ManagedBin;
68
118
  return Array.isArray(candidate.sources) ? candidate : null;
69
119
  }
70
120
 
71
121
  export function buildToolRow(
72
122
  tool: CliTool,
73
- spec: BinRequirement,
123
+ spec: ManagedTool,
74
124
  facts: ToolFacts | undefined,
75
125
  install?: InstallInfo,
76
126
  ): CliToolRow {
@@ -118,7 +168,7 @@ export function buildToolRow(
118
168
  */
119
169
  function updateCommandFor(
120
170
  tool: CliTool,
121
- declared: ResolvedBin | null,
171
+ declared: ManagedBin | null,
122
172
  status: ToolStatus,
123
173
  facts: ToolFacts,
124
174
  install: InstallInfo | undefined,
@@ -233,7 +283,7 @@ export interface CliToolStatusText {
233
283
  * within 24. Keyed by reason alone — plus the spec's installer, which is a
234
284
  * word, never a path.
235
285
  */
236
- function unknownLabel(reason: ToolReason, via: BinRequirement["via"]): string {
286
+ function unknownLabel(reason: ToolReason, via: ManagedTool["via"]): string {
237
287
  switch (reason) {
238
288
  case "not-managed":
239
289
  return `not managed by ${via}`;
@@ -288,7 +338,7 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
288
338
  }
289
339
 
290
340
  /** Header counts, from the rows' states. */
291
- export function countToolRows(rows: readonly CliToolRow[]): {
341
+ export function countToolRows(rows: readonly CliToolsRow[]): {
292
342
  installed: number;
293
343
  updates: number;
294
344
  unverified: number;
@@ -297,9 +347,199 @@ export function countToolRows(rows: readonly CliToolRow[]): {
297
347
  let updates = 0;
298
348
  let unverified = 0;
299
349
  for (const row of rows) {
300
- if (isInstalled(row.status)) installed++;
350
+ if (rowInstalled(row)) installed++;
351
+ if (isPluginManagedRow(row)) {
352
+ if (row.presence?.status === "unknown") unverified++;
353
+ continue;
354
+ }
301
355
  if (row.status?.state === "upgrade") updates++;
302
356
  if (row.status?.state === "unknown") unverified++;
303
357
  }
304
358
  return { installed, updates, unverified };
305
359
  }
360
+
361
+ // ─── Failures ────────────────────────────────────────────────────────────────
362
+ //
363
+ // Every installer command that fails goes into the failure popup with why and
364
+ // the command to run by hand. Update-all used to discard them: the reload
365
+ // showed the tool still behind, with no reason anywhere.
366
+
367
+ /**
368
+ * The verb for what an Enter action did not do — "installed", "updated",
369
+ * "reinstalled". The caller writes the headline around it:
370
+ * `${tool} was not ${cliToolNotDone(kind)}`.
371
+ */
372
+ export function cliToolNotDone(kind: CliToolEnterAction["kind"]): string {
373
+ if (kind === "install") return "installed";
374
+ if (kind === "reinstall") return "reinstalled";
375
+ return "updated";
376
+ }
377
+
378
+ /** One installer command that did not succeed. */
379
+ export interface CliToolCommandFailure {
380
+ /** The tool's display name. */
381
+ tool: string;
382
+ command: Command;
383
+ /** Why, in {@link commandFailureReason}'s words. */
384
+ reason: string;
385
+ }
386
+
387
+ /** The longest reason kept from an installer's own output. */
388
+ const MAX_REASON = 160;
389
+
390
+ /**
391
+ * npm's bookkeeping lines, which never say why: the error code and syscall
392
+ * fields, and where its log went.
393
+ */
394
+ const INSTALLER_NOISE =
395
+ /^(?: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;
396
+
397
+ /**
398
+ * The name a user knows a command by: `/usr/local/bin/npm` → `npm`. Which
399
+ * copy of npm the machine found is mechanism, never part of a reason.
400
+ */
401
+ function commandName(command: Command): string {
402
+ return path.basename(command.cmd) || command.cmd;
403
+ }
404
+
405
+ /** How a timeout reads, so its fix can be told apart from an ordinary failure. */
406
+ const TIMED_OUT_REASON = /^did not finish within \d+ seconds$/;
407
+
408
+ /**
409
+ * The fix for a timeout. Not "Run it yourself": the same command would take
410
+ * just as long. A slow download is the usual cause, so it names the network
411
+ * without claiming that is what happened.
412
+ */
413
+ export const RETRY_TIMEOUT_FIX =
414
+ "Try again; if it keeps timing out, check your network connection";
415
+
416
+ /**
417
+ * Why an installer command failed, as ONE line, from its {@link RunResult}.
418
+ *
419
+ * The installer's own words are in its output: the text after an `error:`
420
+ * (npm, brew and cargo all say it that way), else its first line that is not
421
+ * bookkeeping. A missing installer and a timeout are said plainly — a missing
422
+ * `claude` in the words every other screen uses for it. No absolute binary
423
+ * path survives into the reason.
424
+ */
425
+ export function commandFailureReason(
426
+ command: Command,
427
+ result: RunResult,
428
+ timeoutMs: number,
429
+ ): string {
430
+ const name = commandName(command);
431
+ if (result.spawnError?.includes("ENOENT")) {
432
+ return name === "claude"
433
+ ? CLAUDE_MISSING_REASON
434
+ : `${name} is not installed on this machine`;
435
+ }
436
+ if (result.timedOut) {
437
+ return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
438
+ }
439
+ const lines = result.outputTail
440
+ .split("\n")
441
+ .map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
442
+ .filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
443
+ const said = lines.find((line) => /\berror:\s*\S/i.test(line));
444
+ let reason = said
445
+ ? said.slice(said.toLowerCase().lastIndexOf("error:") + 6).trim()
446
+ : (lines[0] ?? "");
447
+ if (!reason) {
448
+ reason =
449
+ result.spawnError ??
450
+ (result.exitCode !== null
451
+ ? `${name} exited with code ${result.exitCode}`
452
+ : `${name} failed`);
453
+ }
454
+ reason = withoutBinaryPaths(reason);
455
+ return reason.length > MAX_REASON
456
+ ? `${reason.slice(0, MAX_REASON - 1)}…`
457
+ : reason;
458
+ }
459
+
460
+ /**
461
+ * The fix for one failed installer command.
462
+ *
463
+ * Usually the command itself, to run by hand. Not for the three causes that
464
+ * running the same command again would not cure: a timeout, no `claude` to
465
+ * run, and no network. Each keeps the fix that does help.
466
+ */
467
+ export function commandFailureFix(command: Command, reason: string): string {
468
+ if (TIMED_OUT_REASON.test(reason)) return RETRY_TIMEOUT_FIX;
469
+ const known = exceptionReason(reason);
470
+ if (known.fix) return known.fix;
471
+ return `Run it yourself: ${formatCommand(command)}`;
472
+ }
473
+
474
+ /** The popup for failed installer commands, each with the fix that helps. */
475
+ export function cliToolFailureReport(
476
+ headline: string,
477
+ failures: readonly CliToolCommandFailure[],
478
+ ): FailureReport | null {
479
+ return failureReport(
480
+ headline,
481
+ failures.map((f) => ({
482
+ subject: f.tool,
483
+ reason: f.reason,
484
+ fix: commandFailureFix(f.command, f.reason),
485
+ })),
486
+ { group: false },
487
+ );
488
+ }
489
+
490
+ /** Update-all: the tools whose update command failed. */
491
+ export function cliToolsNotUpdatedReport(
492
+ failures: readonly CliToolCommandFailure[],
493
+ ): FailureReport | null {
494
+ const n = failures.length;
495
+ return cliToolFailureReport(
496
+ `${counted(n, "tool")} ${n === 1 ? "was" : "were"} not updated`,
497
+ failures,
498
+ );
499
+ }
500
+
501
+ /**
502
+ * A conflict magus cannot resolve: the tool is installed more than one way
503
+ * and none of the installs can be removed from here.
504
+ */
505
+ export function cliToolConflictStuckReport(
506
+ tool: string,
507
+ blockers: ReadonlyArray<{ method: string; reason: string }>,
508
+ ): FailureReport | null {
509
+ return failureReport(
510
+ `${tool} is installed ${blockers.length} ways, and magus can remove none of them`,
511
+ blockers.map(({ method, reason }) => ({
512
+ subject: `${method} install`,
513
+ reason,
514
+ fix: "Remove one by hand, then press r",
515
+ })),
516
+ { group: false },
517
+ );
518
+ }
519
+
520
+ /**
521
+ * A conflict resolved only in part: each removal that failed, with the
522
+ * command to run by hand, and each install magus could never remove.
523
+ */
524
+ export function cliToolConflictPartialReport(
525
+ tool: string,
526
+ failed: ReadonlyArray<{ method: string; command: Command; reason: string }>,
527
+ leftBehind: ReadonlyArray<{ method: string; reason: string }>,
528
+ ): FailureReport | null {
529
+ return failureReport(
530
+ `${tool} is still installed more than one way`,
531
+ [
532
+ ...failed.map((f) => ({
533
+ subject: `${f.method} install`,
534
+ reason: `was not removed: ${f.reason}`,
535
+ fix: `Run it yourself: ${formatCommand(f.command)}`,
536
+ })),
537
+ ...leftBehind.map(({ method, reason }) => ({
538
+ subject: `${method} install`,
539
+ reason: `stays: ${reason}`,
540
+ fix: "Remove it by hand if you want it gone",
541
+ })),
542
+ ],
543
+ { group: false },
544
+ );
545
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Plugin dependencies in the TUI: the same plan and the same `ApplyOutcome[]`
3
+ * `magus doctor --fix` renders as text, rendered as a confirm modal and a
4
+ * failure popup.
5
+ *
6
+ * `installBinsInTui` is the one flow the Plugins screen (a plugin just
7
+ * installed) and the CLI Tools screen (a tool a plugin owns) both run:
8
+ * probe → `planDeps` → confirm listing every step → `applyDepsPlan` → popup.
9
+ */
10
+
11
+ import { homedir } from "node:os";
12
+ import {
13
+ type ApplyOutcome,
14
+ type DepsPlan,
15
+ applyDepsPlan,
16
+ defaultLockPath,
17
+ planLines,
18
+ } from "../../services/deps-plan.js";
19
+ import { assessBins } from "../../services/deps-report.js";
20
+ import { machineEnv } from "../../services/doctor-bins.js";
21
+ import type { ResolvedBin } from "../../types/index.js";
22
+ import type { FailureReport, FailureRow } from "../state/types.js";
23
+ import { counted, failureReport } from "./failureReport.js";
24
+
25
+ const STATUS_REASON: Record<ApplyOutcome["status"], string> = {
26
+ installed: "installed",
27
+ "off-path": "installed, but not on the PATH Claude Code starts with",
28
+ failed: "the installer failed",
29
+ "needs-password": "needs your sudo password",
30
+ "needs-root": "needs root, and sudo is not available",
31
+ blocked: "what it needs was not installed",
32
+ };
33
+
34
+ /**
35
+ * What did not install, each with why and the exact command; then what can
36
+ * never be installed here; then the PATH lines. Null when there is nothing to
37
+ * say. The headline counts what needs the user.
38
+ */
39
+ export function depsFailureReport(
40
+ subject: string,
41
+ outcomes: readonly ApplyOutcome[],
42
+ plan: Pick<DepsPlan, "unfixable" | "pathAdvice">,
43
+ ): FailureReport | null {
44
+ const rows: FailureRow[] = [];
45
+ for (const o of outcomes) {
46
+ if (o.status === "installed") continue;
47
+ const last = o.outputTail.trim().split("\n").pop()?.trim();
48
+ rows.push({
49
+ subject: o.name,
50
+ reason:
51
+ o.status === "failed" && last
52
+ ? `${STATUS_REASON[o.status]}: ${last}`
53
+ : o.dir
54
+ ? `${STATUS_REASON[o.status]}: it is in ${o.dir}`
55
+ : STATUS_REASON[o.status],
56
+ ...(o.remedy ? { fix: o.remedy } : {}),
57
+ });
58
+ }
59
+ for (const u of plan.unfixable) {
60
+ rows.push({
61
+ subject: u.name,
62
+ reason: u.why,
63
+ });
64
+ }
65
+ for (const a of plan.pathAdvice) {
66
+ rows.push({
67
+ subject: a.prefix,
68
+ reason: "not on the PATH Claude Code starts with",
69
+ fix: `add ${a.rcLine} to ${a.rcFile}, then restart Claude Code from a new terminal`,
70
+ });
71
+ }
72
+ return failureReport(
73
+ `${counted(rows.length, "dependency of", "dependencies of")} ${subject} ${
74
+ rows.length === 1 ? "needs" : "need"
75
+ } you`,
76
+ rows,
77
+ { group: false },
78
+ );
79
+ }
80
+
81
+ /** The part of the modal API the flow uses. */
82
+ export interface DepsModal {
83
+ confirm(title: string, message: string): Promise<boolean>;
84
+ loading(message: string): void;
85
+ hideModal(): void;
86
+ failures(report: FailureReport): Promise<void>;
87
+ }
88
+
89
+ /** Probe, plan, ask once, install, and report — for `bins` of `subject`. */
90
+ export async function installBinsInTui(
91
+ subject: string,
92
+ bins: ResolvedBin[],
93
+ modal: DepsModal,
94
+ ): Promise<void> {
95
+ const env = machineEnv(
96
+ process.env.HOME || homedir(),
97
+ process.env.PATH ?? "",
98
+ process.env.SHELL ?? "",
99
+ );
100
+ if (!env || bins.length === 0) return;
101
+ const { plan } = await assessBins(
102
+ { plugins: [subject], bins, problems: [] },
103
+ env,
104
+ );
105
+ let outcomes: ApplyOutcome[] = [];
106
+ if (plan.steps.length > 0) {
107
+ const ok = await modal.confirm(
108
+ "Install Dependencies?",
109
+ `${subject} needs:\n\n${planLines(plan, env.os).join("\n")}\n\nInstall now?`,
110
+ );
111
+ if (!ok) return;
112
+ modal.loading("Installing dependencies...");
113
+ try {
114
+ outcomes = await applyDepsPlan(plan, {
115
+ yes: true,
116
+ confirm: async () => true,
117
+ env,
118
+ lockPath: defaultLockPath(env.home, process.env.XDG_CACHE_HOME),
119
+ onEvent: (e) => {
120
+ if (e.type === "step-start") modal.loading(`Installing ${e.name}...`);
121
+ },
122
+ });
123
+ } finally {
124
+ modal.hideModal();
125
+ }
126
+ }
127
+ const report = depsFailureReport(subject, outcomes, plan);
128
+ if (report) await modal.failures(report);
129
+ }
@@ -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
+ }