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
@@ -32,6 +32,8 @@ import {
32
32
  validateAliasName,
33
33
  withoutDerivedChannel,
34
34
  } from "../../services/alias-store.js";
35
+ import { failureReport } from "../adapters/failureReport.js";
36
+ import { stepFailureReport } from "../adapters/startupReports.js";
35
37
  import {
36
38
  FlagDetailEditor,
37
39
  type FlagDetailEditorHandle,
@@ -39,7 +41,7 @@ import {
39
41
  import { ScreenLayout } from "../components/layout/index.js";
40
42
  import { useScreenKeys } from "../input/KeyRouter.js";
41
43
  import { moveDirection } from "../keymap.js";
42
- import { useApp, useModal, useNotify } from "../state/AppContext.js";
44
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
43
45
  import { displayWidth, truncate } from "../text-width.js";
44
46
  import { theme } from "../theme.js";
45
47
 
@@ -114,7 +116,7 @@ type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
114
116
  export function AliasScreen() {
115
117
  const { state } = useApp();
116
118
  const modal = useModal();
117
- const notify = useNotify();
119
+ const showFailures = useFailurePopup();
118
120
 
119
121
  const [config, setConfig] = useState<AliasConfig | null>(null);
120
122
  const [shells, setShells] = useState<ShellTarget[]>([]);
@@ -127,11 +129,6 @@ export function AliasScreen() {
127
129
  string,
128
130
  FlagValue
129
131
  > | null>(null);
130
- // Transient write feedback shown on the header status line (not a blocking
131
- // modal). Auto-clears after a few seconds. Errors still use a modal since
132
- // they need to be read and acknowledged.
133
- const [writeStatus, setWriteStatus] = useState<string | null>(null);
134
-
135
132
  // A hand-written `claude` alias found OUTSIDE any managed block on mount.
136
133
  // Set when the rc file has no managed block but a stray alias we could
137
134
  // adopt — drives the one-time "import it?" offer below. Cleared once the
@@ -144,13 +141,6 @@ export function AliasScreen() {
144
141
  [number, number] | null
145
142
  >(null);
146
143
 
147
- // Clear the write status line a few seconds after it appears.
148
- useEffect(() => {
149
- if (!writeStatus) return;
150
- const t = setTimeout(() => setWriteStatus(null), 4000);
151
- return () => clearTimeout(t);
152
- }, [writeStatus]);
153
-
154
144
  // One-time "adopt a hand-written alias?" offer. Fires when the mount load
155
145
  // found a stray `claude` alias outside any managed block. We import only the
156
146
  // flags we recognized; whether we also TAKE OWNERSHIP (remove the original
@@ -421,14 +411,12 @@ export function AliasScreen() {
421
411
  try {
422
412
  await saveAliasName(name);
423
413
  } catch (err) {
424
- await modal.message(
425
- "Save failed",
426
- err instanceof Error ? err.message : String(err),
427
- "error",
414
+ await showFailures(
415
+ stepFailureReport("The alias name was not saved", name, err),
428
416
  );
429
417
  }
430
418
  },
431
- [modal],
419
+ [showFailures],
432
420
  );
433
421
 
434
422
  // Cascade dependencies on every mutation:
@@ -500,12 +488,16 @@ export function AliasScreen() {
500
488
  if (v === null) return;
501
489
  const reason = validateAliasName(v);
502
490
  if (reason) {
503
- await modal.message("Invalid alias name", reason, "error");
491
+ await showFailures(
492
+ failureReport("The alias name was not changed", [
493
+ { subject: v === "" ? "(empty)" : v, reason },
494
+ ]),
495
+ );
504
496
  return;
505
497
  }
506
498
  setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
507
499
  void persistAliasName(v);
508
- }, [config, modal, persistAliasName]);
500
+ }, [config, modal, persistAliasName, showFailures]);
509
501
 
510
502
  const editSelected = useCallback(async () => {
511
503
  if (!selectedRow) return;
@@ -566,10 +558,16 @@ export function AliasScreen() {
566
558
  shells.find((s) => s.exists);
567
559
 
568
560
  if (!target) {
569
- // Nothing was written. Named files, because creating one is the fix.
570
- notify(
571
- "No shell config to write to — create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish first",
572
- "warning",
561
+ // Nothing was written — a failure, like the write failing below. Named
562
+ // files, because creating one is the fix.
563
+ await showFailures(
564
+ failureReport("The alias was not written", [
565
+ {
566
+ subject: "shell config",
567
+ reason: "there is no shell config file to write it to",
568
+ fix: "Create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish, then press w again",
569
+ },
570
+ ]),
573
571
  );
574
572
  return;
575
573
  }
@@ -579,7 +577,7 @@ export function AliasScreen() {
579
577
  // the FIRST write replaces that line in place (absorbing it into the
580
578
  // managed block) rather than appending a second alias. Once the block
581
579
  // exists, the writer ignores the range and splices by markers.
582
- const r = await writeAliasToShell(
580
+ await writeAliasToShell(
583
581
  config,
584
582
  target,
585
583
  pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {},
@@ -587,23 +585,20 @@ export function AliasScreen() {
587
585
  // The adopted line is now gone (replaced by the block); drop the range
588
586
  // so subsequent writes don't try to re-target a line that moved.
589
587
  if (pendingAdoptRange) setPendingAdoptRange(null);
590
- // Non-blocking confirmation on the status line — no "press any key".
591
- const name = r.path.split("/").pop() || r.path;
592
- setWriteStatus(
593
- `✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`,
594
- );
588
+ // Written: no message. The header shows it — the snapshot now matches
589
+ // the in-memory flags, so "unsaved, w writes …" disappears.
595
590
  setShells(await detectShells());
596
- // We just wrote the rc file with current in-memory flag values, so
597
- // the snapshot now matches in-memory state. Dirty indicator clears.
598
591
  setRcSnapshot(config.flags);
599
592
  } catch (err) {
600
- await modal.message(
601
- "Write failed",
602
- err instanceof Error ? err.message : String(err),
603
- "error",
593
+ await showFailures(
594
+ stepFailureReport(
595
+ "The alias was not written",
596
+ `${target.kind} config`,
597
+ err,
598
+ ),
604
599
  );
605
600
  }
606
- }, [config, shells, modal, notify, pendingAdoptRange]);
601
+ }, [config, shells, pendingAdoptRange, showFailures]);
607
602
 
608
603
  // ── Keyboard ──────────────────────────────────────────────────────────
609
604
  // j/k navigate the selectable subset, jumping over headers/legend lines.
@@ -830,13 +825,11 @@ export function AliasScreen() {
830
825
  : 0;
831
826
  const subtitle = inDetail
832
827
  ? `editing ${detailFlag.flag}`
833
- : writeStatus
834
- ? writeStatus
835
- : config
836
- ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled${
837
- dirty ? ` │ * unsaved, w writes ${writeLabel}` : ""
838
- }`
839
- : "";
828
+ : config
829
+ ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled${
830
+ dirty ? ` │ * unsaved, w writes ${writeLabel}` : ""
831
+ }`
832
+ : "";
840
833
 
841
834
  return (
842
835
  <ScreenLayout
@@ -1,19 +1,26 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
1
+ import { homedir } from "node:os";
3
2
  import React, { useEffect, useCallback, useState, useRef } from "react";
4
3
  import { ScreenLayout } from "../components/layout/index.js";
5
4
  import { useScreenKeys } from "../input/KeyRouter.js";
6
5
  import { moveDirection } from "../keymap.js";
7
- import { useApp, useModal, useNotify } from "../state/AppContext.js";
6
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
8
7
  import { useDimensions } from "../state/DimensionsContext.js";
9
8
 
10
- import { cliTools } from "../../data/cli-tools.js";
11
9
  import { loadActiveClosure } from "../../services/active-closure.js";
12
10
  import {
13
11
  type InstallMethod,
14
12
  cliToolUninstall,
13
+ isPluginManagedItem,
14
+ resolveCatalogue,
15
15
  uninstallBlocker,
16
+ unownedCatalogue,
16
17
  } from "../../services/cli-tool-commands.js";
18
+ import {
19
+ DOCTOR_CHECK_CAP_MS,
20
+ installText,
21
+ machineEnv,
22
+ probeBins,
23
+ } from "../../services/doctor-bins.js";
17
24
  import {
18
25
  type InstallInfo,
19
26
  PROBE_CONCURRENCY,
@@ -22,15 +29,28 @@ import {
22
29
  probeTools,
23
30
  specForCatalogTool,
24
31
  } from "../../services/tool-probe.js";
32
+ import { managedBins } from "../../services/toolchain.js";
25
33
  import type { Command } from "../../utils/command-utils.js";
26
- import { formatCommand } from "../../utils/command-utils.js";
27
34
  import { createRequestGate } from "../../utils/request-gate.js";
35
+ import { runArgv, succeeded } from "../../utils/run.js";
28
36
  import {
37
+ type CliToolCommandFailure,
29
38
  type CliToolRow,
39
+ type CliToolsRow,
40
+ type PluginManagedRow,
30
41
  buildToolRow,
42
+ cliToolConflictPartialReport,
43
+ cliToolConflictStuckReport,
31
44
  cliToolEnterAction,
45
+ cliToolFailureReport,
46
+ cliToolNotDone,
47
+ cliToolsNotUpdatedReport,
48
+ commandFailureReason,
32
49
  countToolRows,
50
+ isPluginManagedRow,
51
+ rowName,
33
52
  } from "../adapters/cliToolsAdapter.js";
53
+ import { installBinsInTui } from "../adapters/depsAdapter.js";
34
54
  import { ScrollableList } from "../components/ScrollableList.js";
35
55
  import {
36
56
  renderCliToolDetail,
@@ -38,7 +58,8 @@ import {
38
58
  } from "../renderers/cliToolRenderers.js";
39
59
  import { theme } from "../theme.js";
40
60
 
41
- const execFileAsync = promisify(execFile);
61
+ /** How long one installer command may run before it is stopped. */
62
+ const COMMAND_TIMEOUT_MS = 60_000;
42
63
 
43
64
  // ─── Session cache ─────────────────────────────────────────────────────────────
44
65
  //
@@ -47,7 +68,7 @@ const execFileAsync = promisify(execFile);
47
68
  // used to carry a private copy: a `/bin/bash` `which`, an `npm view` per tool,
48
69
  // its own version comparator, and a string-form `exec` of each check command.
49
70
 
50
- let cachedRows: CliToolRow[] | null = null;
71
+ let cachedRows: CliToolsRow[] | null = null;
51
72
  let cacheInitialized = false;
52
73
  /** Every fetch takes a token, so a refresh discards the answers of the one before. */
53
74
  const fetchGate = createRequestGate();
@@ -57,9 +78,12 @@ function clearCliToolsCache(): void {
57
78
  cacheInitialized = false;
58
79
  }
59
80
 
60
- /** Rows before anything is known: the catalogue mapping, checking. */
61
- function initialRows(): CliToolRow[] {
62
- return cliTools.map((tool) =>
81
+ /**
82
+ * Rows before anything is known: the catalogue mapping, checking. A tool a
83
+ * plugin owns joins once its plugin's declaration has been read.
84
+ */
85
+ function initialRows(): CliToolsRow[] {
86
+ return unownedCatalogue().map(({ tool }) =>
63
87
  buildToolRow(tool, specForCatalogTool(tool, null, undefined), undefined),
64
88
  );
65
89
  }
@@ -91,10 +115,10 @@ export function CliToolsScreen() {
91
115
  const { state, dispatch } = useApp();
92
116
  const { cliTools: cliToolsState } = state;
93
117
  const modal = useModal();
94
- const notify = useNotify();
118
+ const showFailures = useFailurePopup();
95
119
  const dimensions = useDimensions();
96
120
 
97
- const [rows, setRows] = useState<CliToolRow[]>(
121
+ const [rows, setRows] = useState<CliToolsRow[]>(
98
122
  () => cachedRows || initialRows(),
99
123
  );
100
124
  /** Why the last check could not run, shown under the header; null when it ran. */
@@ -104,7 +128,7 @@ export function CliToolsScreen() {
104
128
  rowsRef.current = rows;
105
129
 
106
130
  const setAndCache = useCallback(
107
- (update: (prev: CliToolRow[]) => CliToolRow[]) => {
131
+ (update: (prev: CliToolsRow[]) => CliToolsRow[]) => {
108
132
  setRows((prev) => {
109
133
  const next = update(prev);
110
134
  cachedRows = next;
@@ -128,45 +152,95 @@ export function CliToolsScreen() {
128
152
  setProbeError(null);
129
153
  try {
130
154
  const projectPath = state.projectPath || process.cwd();
155
+ const catalogue = await resolveCatalogue(projectPath);
156
+ if (!fetchGate.isCurrent(token)) return;
157
+ // Each item keeps its catalogue position as its row.
158
+ const tracked = catalogue.flatMap((item, row) =>
159
+ isPluginManagedItem(item) ? [] : [{ ...item, row }],
160
+ );
161
+ const pluginManaged = catalogue.flatMap((item, row) =>
162
+ isPluginManagedItem(item) ? [{ ...item, row }] : [],
163
+ );
131
164
  const [active, installs] = await Promise.all([
132
165
  // Full resolution, not `resolveBins: false`: a catalogue tool may be
133
166
  // declared by a PLUGIN's requires.bin, and parity needs the spec the
134
167
  // CLI plans with. `resolveBins: false` would still keep the profile's
135
168
  // own cliTools, but lose every plugin-declared bin and its pin.
136
169
  loadActiveClosure(projectPath),
137
- mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
170
+ mapLimited(tracked, PROBE_CONCURRENCY, ({ tool }) =>
138
171
  detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
139
172
  ),
140
173
  ]);
141
174
  if (!fetchGate.isCurrent(token)) return;
142
175
 
143
- const closureBins = active?.closure.bins ?? null;
144
- const planned = cliTools.map((tool, i) => ({
176
+ const closureBins = active ? managedBins(active.closure.bins) : null;
177
+ const planned = tracked.map(({ tool, owned, row }, i) => ({
145
178
  tool,
179
+ owned,
180
+ row,
146
181
  install: installs[i],
147
182
  spec: specForCatalogTool(tool, closureBins, installs[i]),
148
183
  }));
149
- setAndCache(() =>
150
- planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
184
+ const rowOf = (
185
+ p: (typeof planned)[number],
186
+ facts: Parameters<typeof buildToolRow>[2],
187
+ ): CliToolRow => ({
188
+ ...buildToolRow(p.tool, p.spec, facts, p.install),
189
+ ...(p.owned ? { owned: p.owned } : {}),
190
+ });
191
+ const env = machineEnv(
192
+ process.env.HOME || homedir(),
193
+ process.env.PATH ?? "",
194
+ process.env.SHELL ?? "",
151
195
  );
196
+ const managedRowOf = (
197
+ m: (typeof pluginManaged)[number],
198
+ presence: PluginManagedRow["presence"],
199
+ ): PluginManagedRow => ({
200
+ managedBy: m.entry.ownedBy,
201
+ entry: m.entry,
202
+ owned: m.owned,
203
+ presence,
204
+ installText: env ? installText(m.owned, env) : "",
205
+ });
206
+ const setRow = (row: number, value: CliToolsRow) =>
207
+ setAndCache((prev) => {
208
+ const next = [...prev];
209
+ next[row] = value;
210
+ return next;
211
+ });
212
+ setAndCache(() => {
213
+ const next: CliToolsRow[] = new Array(catalogue.length);
214
+ for (const p of planned) next[p.row] = rowOf(p, undefined);
215
+ for (const m of pluginManaged) next[m.row] = managedRowOf(m, null);
216
+ return next;
217
+ });
152
218
 
153
- await probeTools(
154
- planned.map((p) => p.spec),
155
- REAL_TOOL_PROBE_IO,
156
- {
157
- onResult: (facts, { index }) => {
158
- if (!fetchGate.isCurrent(token)) return;
159
- const p = planned[index];
160
- if (!p) return;
161
- const row = buildToolRow(p.tool, p.spec, facts, p.install);
162
- setAndCache((prev) => {
163
- const next = [...prev];
164
- next[index] = row;
165
- return next;
166
- });
219
+ await Promise.all([
220
+ probeTools(
221
+ planned.map((p) => p.spec),
222
+ REAL_TOOL_PROBE_IO,
223
+ {
224
+ onResult: (facts, { index }) => {
225
+ if (!fetchGate.isCurrent(token)) return;
226
+ const p = planned[index];
227
+ if (p) setRow(p.row, rowOf(p, facts));
228
+ },
167
229
  },
168
- },
169
- );
230
+ ),
231
+ // The probe `magus doctor` runs, so the row and doctor agree.
232
+ (async () => {
233
+ if (!env || pluginManaged.length === 0) return;
234
+ const presences = await probeBins(
235
+ pluginManaged.map((m) => m.owned),
236
+ { env, depth: "full", capMs: DOCTOR_CHECK_CAP_MS },
237
+ );
238
+ if (!fetchGate.isCurrent(token)) return;
239
+ pluginManaged.forEach((m, i) => {
240
+ setRow(m.row, managedRowOf(m, presences[i] ?? null));
241
+ });
242
+ })(),
243
+ ]);
170
244
  } catch (error) {
171
245
  if (!fetchGate.isCurrent(token)) return;
172
246
  setProbeError(error instanceof Error ? error.message : String(error));
@@ -209,47 +283,64 @@ export function CliToolsScreen() {
209
283
  };
210
284
 
211
285
  /**
212
- * Run one installer command and report success.
286
+ * Run one installer command: null when it succeeded, else why it failed.
213
287
  *
214
- * `execFile`, so there is no shell to re-parse a package name — the same
215
- * reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
216
- * that one inherits stdio, and a TUI must never hand the terminal to a
217
- * child. Output is captured and discarded here; the modal is the progress
218
- * indicator.
288
+ * Through `runArgv`, which captures output and never touches the terminal;
289
+ * the modal is the progress indicator, and on a failure the installer's own
290
+ * words become the popup's reason.
219
291
  */
220
- const runCommand = async (
221
- command: Command,
222
- ): Promise<{ ok: boolean; error?: string }> => {
223
- try {
224
- await execFileAsync(command.cmd, [...command.args], { timeout: 60000 });
225
- return { ok: true };
226
- } catch (err) {
227
- const msg = err instanceof Error ? err.message : String(err);
228
- return { ok: false, error: msg };
229
- }
292
+ const runCommand = async (command: Command): Promise<string | null> => {
293
+ const result = await runArgv(command, { timeoutMs: COMMAND_TIMEOUT_MS });
294
+ return succeeded(result)
295
+ ? null
296
+ : commandFailureReason(command, result, COMMAND_TIMEOUT_MS);
230
297
  };
231
298
 
232
299
  const handleInstall = async () => {
233
300
  const row = rows[cliToolsState.selectedIndex];
234
- const enter = row ? cliToolEnterAction(row) : null;
235
- if (!row || !enter) return;
301
+ // A plugin-managed row is read-only: its plugin installs it.
302
+ if (!row || isPluginManagedRow(row)) return;
303
+ const enter = cliToolEnterAction(row);
304
+ if (!enter) return;
236
305
 
237
306
  const { tool } = row;
238
307
  const { command, progress: action } = enter;
239
308
 
309
+ // A tool a plugin owns installs from that plugin's declaration, through
310
+ // the same plan and executor as `magus doctor --fix`.
311
+ if (row.owned && enter.kind === "install") {
312
+ try {
313
+ await installBinsInTui(tool.displayName, [row.owned], modal);
314
+ } catch (error) {
315
+ modal.hideModal();
316
+ await showFailures(
317
+ cliToolFailureReport(`${tool.displayName} was not installed`, [
318
+ {
319
+ tool: tool.displayName,
320
+ command,
321
+ reason: error instanceof Error ? error.message : String(error),
322
+ },
323
+ ]),
324
+ );
325
+ }
326
+ handleRefresh();
327
+ return;
328
+ }
329
+
240
330
  modal.loading(`${action} ${tool.displayName}...`);
241
- const result = await runCommand(command);
331
+ const reason = await runCommand(command);
242
332
  modal.hideModal();
243
333
 
244
- if (result.ok) {
334
+ if (reason === null) {
245
335
  handleRefresh();
246
- } else {
247
- await modal.message(
248
- "Error",
249
- `Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
250
- "error",
251
- );
336
+ return;
252
337
  }
338
+ await showFailures(
339
+ cliToolFailureReport(
340
+ `${tool.displayName} was not ${cliToolNotDone(enter.kind)}`,
341
+ [{ tool: tool.displayName, command, reason }],
342
+ ),
343
+ );
253
344
  };
254
345
 
255
346
  /**
@@ -268,8 +359,9 @@ export function CliToolsScreen() {
268
359
  */
269
360
  const handleResolveConflict = async () => {
270
361
  const row = rows[cliToolsState.selectedIndex];
271
- const install: InstallInfo | undefined = row?.install;
272
- if (!row || !install || install.all.length < 2) return;
362
+ if (!row || isPluginManagedRow(row)) return;
363
+ const install: InstallInfo | undefined = row.install;
364
+ if (!install || install.all.length < 2) return;
273
365
 
274
366
  const { tool } = row;
275
367
  const allMethods = install.all;
@@ -285,6 +377,11 @@ export function CliToolsScreen() {
285
377
  methods
286
378
  .map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
287
379
  .join("\n");
380
+ const blockerRows = (methods: readonly InstallMethod[]) =>
381
+ methods.map((method) => ({
382
+ method,
383
+ reason: blockers.get(method) ?? "magus cannot remove it",
384
+ }));
288
385
 
289
386
  // One choice per install that can be KEPT — but only where keeping it
290
387
  // removes something. "Keep npm" beside an unremovable native copy would
@@ -298,10 +395,8 @@ export function CliToolsScreen() {
298
395
  }));
299
396
 
300
397
  if (options.length === 0) {
301
- await modal.message(
302
- `${tool.displayName} conflict`,
303
- `Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
304
- "error",
398
+ await showFailures(
399
+ cliToolConflictStuckReport(tool.displayName, blockerRows(allMethods)),
305
400
  );
306
401
  return;
307
402
  }
@@ -319,42 +414,37 @@ export function CliToolsScreen() {
319
414
  const leftBehind = stuck.filter((m) => m !== keep);
320
415
  modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
321
416
 
322
- const errors: string[] = [];
417
+ const failed: Array<{
418
+ method: InstallMethod;
419
+ command: Command;
420
+ reason: string;
421
+ }> = [];
422
+ const unremovable = blockerRows(leftBehind);
323
423
  for (const method of toRemove) {
324
424
  const cmd = cliToolUninstall(tool, method, install.brewFormula);
325
425
  // Unreachable: `removable` was built from this same call. Reported
326
426
  // rather than skipped, because a silent skip is the defect above.
327
427
  if (!cmd) {
328
- errors.push(`${method}: no uninstall command`);
428
+ unremovable.push({
429
+ method,
430
+ reason: "magus has no uninstall command for it",
431
+ });
329
432
  continue;
330
433
  }
331
- const result = await runCommand(cmd);
332
- if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
434
+ const reason = await runCommand(cmd);
435
+ if (reason !== null) failed.push({ method, command: cmd, reason });
333
436
  }
334
437
 
335
438
  modal.hideModal();
336
439
 
337
- const stays =
338
- leftBehind.length > 0
339
- ? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
340
- : "";
341
-
342
- // A failed removal is an error, and stays a dialog with the commands to
343
- // run. An install magus could never remove is only a warning: nothing
344
- // failed, and the row still shows the conflict until it is dealt with.
345
- if (errors.length > 0) {
346
- await modal.message(
347
- "Partly removed",
348
- [
349
- `These removals failed. Run them by hand:\n${errors.join("\n")}`,
350
- stays,
351
- ]
352
- .filter((part) => part !== "")
353
- .join("\n\n"),
354
- "error",
440
+ // A failed removal goes into the failure popup with the command to run,
441
+ // and so does an install left behind beside it. An install magus could
442
+ // never remove is not, on its own, a failure: nothing failed, and the row
443
+ // still shows the conflict until it is dealt with.
444
+ if (failed.length > 0 || unremovable.length > leftBehind.length) {
445
+ await showFailures(
446
+ cliToolConflictPartialReport(tool.displayName, failed, unremovable),
355
447
  );
356
- } else if (stays !== "") {
357
- notify(stays.replace(/\n/g, " "), "warning");
358
448
  }
359
449
 
360
450
  handleRefresh();
@@ -364,20 +454,28 @@ export function CliToolsScreen() {
364
454
  // Only rows the shared classifier says are behind. An `unknown` is never
365
455
  // swept up in a bulk action — that is exactly the "cannot tell, so run
366
456
  // it anyway" the CLI no longer does either.
367
- const updatable = rows.filter((r) => r.status?.state === "upgrade");
368
- if (updatable.length === 0) {
369
- notify("No tool is known to be behind");
370
- return;
371
- }
457
+ const updatable = rows.filter(
458
+ (r): r is CliToolRow =>
459
+ !isPluginManagedRow(r) && r.status?.state === "upgrade",
460
+ );
461
+ if (updatable.length === 0) return;
372
462
 
373
463
  modal.loading(`Updating ${updatable.length} tool(s)...`);
374
464
 
465
+ // Every failure is kept: the reload shows the tool still behind, and
466
+ // only this says why.
467
+ const failed: CliToolCommandFailure[] = [];
375
468
  for (const row of updatable) {
376
- await runCommand(row.updateCommand ?? row.installCommand);
469
+ const command = row.updateCommand ?? row.installCommand;
470
+ const reason = await runCommand(command);
471
+ if (reason !== null) {
472
+ failed.push({ tool: row.tool.displayName, command, reason });
473
+ }
377
474
  }
378
475
 
379
476
  modal.hideModal();
380
477
  handleRefresh();
478
+ await showFailures(cliToolsNotUpdatedReport(failed));
381
479
  };
382
480
 
383
481
  const selectedRow = rows[cliToolsState.selectedIndex];
@@ -406,7 +504,10 @@ export function CliToolsScreen() {
406
504
 
407
505
  // The footer names what Enter does on THIS row, from the same function the
408
506
  // detail pane and the handler read; while the row is checked it does nothing.
409
- const enter = selectedRow ? cliToolEnterAction(selectedRow) : null;
507
+ const enter =
508
+ selectedRow && !isPluginManagedRow(selectedRow)
509
+ ? cliToolEnterAction(selectedRow)
510
+ : null;
410
511
 
411
512
  return (
412
513
  <ScreenLayout
@@ -429,7 +530,7 @@ export function CliToolsScreen() {
429
530
  selectedIndex={cliToolsState.selectedIndex}
430
531
  renderItem={renderCliToolRow}
431
532
  maxHeight={dimensions.listPanelHeight}
432
- getKey={(row) => row.tool.name}
533
+ getKey={rowName}
433
534
  />
434
535
  }
435
536
  detailPanel={(detailWidth) =>