magus-cli 7.8.0 → 7.9.1

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 (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
  11. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  12. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  13. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  14. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  15. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  16. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  17. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  18. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  19. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  20. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  21. package/src/__tests__/blind-780-review2.test.ts +0 -25
  22. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  23. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  24. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  25. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  26. package/src/__tests__/deps-adapter.test.ts +191 -0
  27. package/src/__tests__/deps-check-hook.test.ts +220 -0
  28. package/src/__tests__/deps-plan.test.ts +897 -0
  29. package/src/__tests__/deps-report.test.ts +197 -0
  30. package/src/__tests__/doctor-bins.test.ts +134 -36
  31. package/src/__tests__/doctor-json.test.ts +261 -0
  32. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  33. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  34. package/src/__tests__/fixtures/bins.ts +55 -0
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  38. package/src/__tests__/npm-package-name.test.ts +6 -9
  39. package/src/__tests__/plugin-requires.test.ts +138 -153
  40. package/src/__tests__/requires-probe.test.ts +858 -0
  41. package/src/__tests__/resolver.test.ts +129 -52
  42. package/src/__tests__/run.test.ts +108 -0
  43. package/src/__tests__/tool-probe.test.ts +1 -1
  44. package/src/__tests__/toolchain.test.ts +123 -52
  45. package/src/__tests__/update-plan.test.ts +1 -1
  46. package/src/cli/ansi.ts +2 -7
  47. package/src/cli/deps-view.ts +213 -0
  48. package/src/cli/doctor.ts +169 -24
  49. package/src/cli/install.ts +51 -66
  50. package/src/cli/profile.ts +20 -2
  51. package/src/cli/router.ts +6 -2
  52. package/src/cli/update.ts +28 -7
  53. package/src/data/cli-tools.ts +24 -9
  54. package/src/services/active-closure.ts +3 -3
  55. package/src/services/claude-settings.ts +4 -348
  56. package/src/services/cli-tool-commands.ts +104 -3
  57. package/src/services/deps-plan.ts +1154 -0
  58. package/src/services/deps-report.ts +265 -0
  59. package/src/services/doctor-bins.ts +115 -43
  60. package/src/services/install-plan.ts +11 -1
  61. package/src/services/local-marketplace.ts +12 -206
  62. package/src/services/marketplace-refresh.ts +4 -4
  63. package/src/services/plugin-manager.ts +4 -18
  64. package/src/services/plugin-requires.ts +93 -162
  65. package/src/services/resolver.ts +168 -58
  66. package/src/services/tool-probe.ts +18 -25
  67. package/src/services/toolchain.ts +117 -111
  68. package/src/services/update-engine.ts +19 -14
  69. package/src/services/update-plan.ts +3 -3
  70. package/src/shared/plugin/hooks/bun-missing.sh +64 -0
  71. package/src/shared/plugin/hooks/deps-check.ts +240 -0
  72. package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
  73. package/src/types/index.ts +53 -16
  74. package/src/ui/App.tsx +6 -40
  75. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  76. package/src/ui/adapters/depsAdapter.ts +129 -0
  77. package/src/ui/adapters/startupReports.ts +0 -22
  78. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  79. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  80. package/src/ui/screens/PluginsScreen.tsx +12 -48
  81. package/src/utils/command-utils.ts +4 -5
  82. package/src/utils/run.ts +143 -52
  83. package/src/utils/string-utils.ts +9 -0
  84. package/src/__tests__/go-module.test.ts +0 -43
  85. package/src/__tests__/plugin-setup.test.ts +0 -1016
  86. package/src/__tests__/shell-script-callers.test.ts +0 -92
  87. package/src/services/plugin-setup.ts +0 -965
  88. package/src/utils/go-module.ts +0 -36
@@ -5,14 +5,18 @@ import {
5
5
  cliToolUninstall,
6
6
  uninstallBlocker,
7
7
  } from "../../services/cli-tool-commands.js";
8
+ import { doctorFixCommand } from "../../shared/plugin/hooks/lib/requires-probe.js";
8
9
  import { formatCommand } from "../../utils/command-utils.js";
9
10
  import {
10
11
  type CliToolEnterAction,
11
12
  type CliToolRow,
13
+ type CliToolsRow,
14
+ type PluginManagedRow,
12
15
  cliToolEnterAction,
13
16
  cliToolStatusText,
14
17
  displayVersion,
15
18
  isInstalled,
19
+ isPluginManagedRow,
16
20
  } from "../adapters/cliToolsAdapter.js";
17
21
  import { rowLead } from "../components/primitives/index.js";
18
22
  import { type UiColor, theme } from "../theme.js";
@@ -94,13 +98,139 @@ function toneColor(tone: StatusTone): UiColor {
94
98
  }
95
99
  }
96
100
 
101
+ // ─── Plugin-managed rows ───────────────────────────────────────────────────────
102
+
103
+ /** The mark and words for a plugin-managed row, from the dependency probe. */
104
+ function managedStatus(presence: PluginManagedRow["presence"]): {
105
+ icon: string;
106
+ color: UiColor;
107
+ text: string;
108
+ } {
109
+ switch (presence?.status) {
110
+ case undefined:
111
+ return { icon: "○", color: theme.colors.muted, text: "● Checking..." };
112
+ case "present":
113
+ return { icon: "●", color: theme.colors.success, text: "● Installed" };
114
+ case "off-path":
115
+ return {
116
+ icon: "!",
117
+ color: theme.colors.warning,
118
+ text: `! Installed in ${presence.prefix}, which is not on the PATH Claude Code starts with`,
119
+ };
120
+ case "missing":
121
+ return { icon: "○", color: theme.colors.muted, text: "○ Not installed" };
122
+ case "unknown":
123
+ return {
124
+ icon: "?",
125
+ color: theme.colors.warning,
126
+ text: `? Could not tell: ${presence.reason}`,
127
+ };
128
+ }
129
+ }
130
+
131
+ function renderPluginManagedRow(
132
+ row: PluginManagedRow,
133
+ isSelected: boolean,
134
+ ): React.ReactNode {
135
+ const { icon, color } = managedStatus(row.presence);
136
+ const suffix =
137
+ row.presence === null ? " ..." : ` · managed by ${row.managedBy}`;
138
+ if (isSelected) {
139
+ return (
140
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
141
+ {rowLead(true)}
142
+ {icon} {row.entry.displayName}
143
+ {suffix}{" "}
144
+ </text>
145
+ );
146
+ }
147
+ return (
148
+ <text fg={theme.colors.text}>
149
+ <span fg={color}>
150
+ {rowLead(false)}
151
+ {icon}
152
+ </span>
153
+ <span fg={theme.colors.text}> {row.entry.displayName}</span>
154
+ <span fg={theme.colors.dim}>{suffix}</span>
155
+ </text>
156
+ );
157
+ }
158
+
159
+ function renderPluginManagedDetail(
160
+ row: PluginManagedRow,
161
+ width: number,
162
+ ): React.ReactNode {
163
+ const { entry, managedBy, presence } = row;
164
+ const status = managedStatus(presence);
165
+ const here =
166
+ presence?.status === "present" || presence?.status === "off-path";
167
+ return (
168
+ <box flexDirection="column" width={width}>
169
+ <box flexDirection="row" marginBottom={1}>
170
+ <text fg={theme.colors.info}>
171
+ <strong>{entry.displayName}</strong>
172
+ </text>
173
+ </box>
174
+
175
+ <text fg={theme.colors.muted}>{entry.description}</text>
176
+
177
+ <box marginTop={1} flexDirection="column">
178
+ <box flexDirection="row">
179
+ <text fg={theme.colors.muted} flexShrink={0}>
180
+ {"Status "}
181
+ </text>
182
+ <text fg={status.color} minWidth={0}>
183
+ {wrapValue(status.text, width)}
184
+ </text>
185
+ </box>
186
+ <box flexDirection="row">
187
+ <text fg={theme.colors.muted} flexShrink={0}>
188
+ {"Managed "}
189
+ </text>
190
+ <text fg={theme.colors.text}>by {managedBy}</text>
191
+ </box>
192
+ {!here && presence !== null && row.installText ? (
193
+ <box flexDirection="row">
194
+ <text fg={theme.colors.muted} flexShrink={0}>
195
+ {"Install "}
196
+ </text>
197
+ <text fg={theme.colors.info} minWidth={0}>
198
+ {wrapValue(row.installText, width)}
199
+ </text>
200
+ </box>
201
+ ) : null}
202
+ <box flexDirection="row">
203
+ <text fg={theme.colors.muted} flexShrink={0}>
204
+ {"Website "}
205
+ </text>
206
+ <box flexGrow={1} minWidth={0}>
207
+ <text fg={theme.colors.info} wrapMode="char">
208
+ {entry.website}
209
+ </text>
210
+ </box>
211
+ </box>
212
+ </box>
213
+
214
+ <box marginTop={2}>
215
+ <text fg={theme.colors.muted}>
216
+ {wrapText(
217
+ `${managedBy} installs ${entry.name} and decides its version; it cannot be installed, updated or removed from here. ${doctorFixCommand(null)} installs it when it is missing.`,
218
+ width,
219
+ ).join("\n")}
220
+ </text>
221
+ </box>
222
+ </box>
223
+ );
224
+ }
225
+
97
226
  // ─── Row renderer ──────────────────────────────────────────────────────────────
98
227
 
99
228
  export function renderCliToolRow(
100
- row: CliToolRow,
229
+ row: CliToolsRow,
101
230
  _index: number,
102
231
  isSelected: boolean,
103
232
  ): React.ReactNode {
233
+ if (isPluginManagedRow(row)) return renderPluginManagedRow(row, isSelected);
104
234
  const { tool, status, install } = row;
105
235
  const installed = isInstalled(status);
106
236
  const allMethods = install?.all ?? [];
@@ -167,10 +297,13 @@ export function renderCliToolRow(
167
297
  // ─── Detail renderer ───────────────────────────────────────────────────────────
168
298
 
169
299
  export function renderCliToolDetail(
170
- row: CliToolRow | undefined,
300
+ row: CliToolsRow | undefined,
171
301
  /** Usable detail-panel columns (`detailPanelWidth`); the scrollbox does not constrain width. */
172
302
  width: number,
173
303
  ): React.ReactNode {
304
+ if (row && isPluginManagedRow(row)) {
305
+ return renderPluginManagedDetail(row, width);
306
+ }
174
307
  if (!row) {
175
308
  return (
176
309
  <box
@@ -1,5 +1,4 @@
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";
@@ -7,13 +6,21 @@ import { moveDirection } from "../keymap.js";
7
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,11 +29,15 @@ 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
34
  import { createRequestGate } from "../../utils/request-gate.js";
35
+ import { runArgv, succeeded } from "../../utils/run.js";
27
36
  import {
28
37
  type CliToolCommandFailure,
29
38
  type CliToolRow,
39
+ type CliToolsRow,
40
+ type PluginManagedRow,
30
41
  buildToolRow,
31
42
  cliToolConflictPartialReport,
32
43
  cliToolConflictStuckReport,
@@ -36,7 +47,10 @@ import {
36
47
  cliToolsNotUpdatedReport,
37
48
  commandFailureReason,
38
49
  countToolRows,
50
+ isPluginManagedRow,
51
+ rowName,
39
52
  } from "../adapters/cliToolsAdapter.js";
53
+ import { installBinsInTui } from "../adapters/depsAdapter.js";
40
54
  import { ScrollableList } from "../components/ScrollableList.js";
41
55
  import {
42
56
  renderCliToolDetail,
@@ -44,7 +58,6 @@ import {
44
58
  } from "../renderers/cliToolRenderers.js";
45
59
  import { theme } from "../theme.js";
46
60
 
47
- const execFileAsync = promisify(execFile);
48
61
  /** How long one installer command may run before it is stopped. */
49
62
  const COMMAND_TIMEOUT_MS = 60_000;
50
63
 
@@ -55,7 +68,7 @@ const COMMAND_TIMEOUT_MS = 60_000;
55
68
  // used to carry a private copy: a `/bin/bash` `which`, an `npm view` per tool,
56
69
  // its own version comparator, and a string-form `exec` of each check command.
57
70
 
58
- let cachedRows: CliToolRow[] | null = null;
71
+ let cachedRows: CliToolsRow[] | null = null;
59
72
  let cacheInitialized = false;
60
73
  /** Every fetch takes a token, so a refresh discards the answers of the one before. */
61
74
  const fetchGate = createRequestGate();
@@ -65,9 +78,12 @@ function clearCliToolsCache(): void {
65
78
  cacheInitialized = false;
66
79
  }
67
80
 
68
- /** Rows before anything is known: the catalogue mapping, checking. */
69
- function initialRows(): CliToolRow[] {
70
- 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 }) =>
71
87
  buildToolRow(tool, specForCatalogTool(tool, null, undefined), undefined),
72
88
  );
73
89
  }
@@ -102,7 +118,7 @@ export function CliToolsScreen() {
102
118
  const showFailures = useFailurePopup();
103
119
  const dimensions = useDimensions();
104
120
 
105
- const [rows, setRows] = useState<CliToolRow[]>(
121
+ const [rows, setRows] = useState<CliToolsRow[]>(
106
122
  () => cachedRows || initialRows(),
107
123
  );
108
124
  /** Why the last check could not run, shown under the header; null when it ran. */
@@ -112,7 +128,7 @@ export function CliToolsScreen() {
112
128
  rowsRef.current = rows;
113
129
 
114
130
  const setAndCache = useCallback(
115
- (update: (prev: CliToolRow[]) => CliToolRow[]) => {
131
+ (update: (prev: CliToolsRow[]) => CliToolsRow[]) => {
116
132
  setRows((prev) => {
117
133
  const next = update(prev);
118
134
  cachedRows = next;
@@ -136,45 +152,95 @@ export function CliToolsScreen() {
136
152
  setProbeError(null);
137
153
  try {
138
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
+ );
139
164
  const [active, installs] = await Promise.all([
140
165
  // Full resolution, not `resolveBins: false`: a catalogue tool may be
141
166
  // declared by a PLUGIN's requires.bin, and parity needs the spec the
142
167
  // CLI plans with. `resolveBins: false` would still keep the profile's
143
168
  // own cliTools, but lose every plugin-declared bin and its pin.
144
169
  loadActiveClosure(projectPath),
145
- mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
170
+ mapLimited(tracked, PROBE_CONCURRENCY, ({ tool }) =>
146
171
  detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
147
172
  ),
148
173
  ]);
149
174
  if (!fetchGate.isCurrent(token)) return;
150
175
 
151
- const closureBins = active?.closure.bins ?? null;
152
- const planned = cliTools.map((tool, i) => ({
176
+ const closureBins = active ? managedBins(active.closure.bins) : null;
177
+ const planned = tracked.map(({ tool, owned, row }, i) => ({
153
178
  tool,
179
+ owned,
180
+ row,
154
181
  install: installs[i],
155
182
  spec: specForCatalogTool(tool, closureBins, installs[i]),
156
183
  }));
157
- setAndCache(() =>
158
- 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 ?? "",
159
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
+ });
160
218
 
161
- await probeTools(
162
- planned.map((p) => p.spec),
163
- REAL_TOOL_PROBE_IO,
164
- {
165
- onResult: (facts, { index }) => {
166
- if (!fetchGate.isCurrent(token)) return;
167
- const p = planned[index];
168
- if (!p) return;
169
- const row = buildToolRow(p.tool, p.spec, facts, p.install);
170
- setAndCache((prev) => {
171
- const next = [...prev];
172
- next[index] = row;
173
- return next;
174
- });
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
+ },
175
229
  },
176
- },
177
- );
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
+ ]);
178
244
  } catch (error) {
179
245
  if (!fetchGate.isCurrent(token)) return;
180
246
  setProbeError(error instanceof Error ? error.message : String(error));
@@ -219,31 +285,48 @@ export function CliToolsScreen() {
219
285
  /**
220
286
  * Run one installer command: null when it succeeded, else why it failed.
221
287
  *
222
- * `execFile`, so there is no shell to re-parse a package name — the same
223
- * reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
224
- * that one inherits stdio, and a TUI must never hand the terminal to a
225
- * child. Output is captured; the modal is the progress indicator, and on a
226
- * failure the installer's own words become the popup's reason.
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.
227
291
  */
228
292
  const runCommand = async (command: Command): Promise<string | null> => {
229
- try {
230
- await execFileAsync(command.cmd, [...command.args], {
231
- timeout: COMMAND_TIMEOUT_MS,
232
- });
233
- return null;
234
- } catch (err) {
235
- return commandFailureReason(command, err, COMMAND_TIMEOUT_MS);
236
- }
293
+ const result = await runArgv(command, { timeoutMs: COMMAND_TIMEOUT_MS });
294
+ return succeeded(result)
295
+ ? null
296
+ : commandFailureReason(command, result, COMMAND_TIMEOUT_MS);
237
297
  };
238
298
 
239
299
  const handleInstall = async () => {
240
300
  const row = rows[cliToolsState.selectedIndex];
241
- const enter = row ? cliToolEnterAction(row) : null;
242
- 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;
243
305
 
244
306
  const { tool } = row;
245
307
  const { command, progress: action } = enter;
246
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
+
247
330
  modal.loading(`${action} ${tool.displayName}...`);
248
331
  const reason = await runCommand(command);
249
332
  modal.hideModal();
@@ -276,8 +359,9 @@ export function CliToolsScreen() {
276
359
  */
277
360
  const handleResolveConflict = async () => {
278
361
  const row = rows[cliToolsState.selectedIndex];
279
- const install: InstallInfo | undefined = row?.install;
280
- 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;
281
365
 
282
366
  const { tool } = row;
283
367
  const allMethods = install.all;
@@ -370,7 +454,10 @@ export function CliToolsScreen() {
370
454
  // Only rows the shared classifier says are behind. An `unknown` is never
371
455
  // swept up in a bulk action — that is exactly the "cannot tell, so run
372
456
  // it anyway" the CLI no longer does either.
373
- const updatable = rows.filter((r) => r.status?.state === "upgrade");
457
+ const updatable = rows.filter(
458
+ (r): r is CliToolRow =>
459
+ !isPluginManagedRow(r) && r.status?.state === "upgrade",
460
+ );
374
461
  if (updatable.length === 0) return;
375
462
 
376
463
  modal.loading(`Updating ${updatable.length} tool(s)...`);
@@ -417,7 +504,10 @@ export function CliToolsScreen() {
417
504
 
418
505
  // The footer names what Enter does on THIS row, from the same function the
419
506
  // detail pane and the handler read; while the row is checked it does nothing.
420
- const enter = selectedRow ? cliToolEnterAction(selectedRow) : null;
507
+ const enter =
508
+ selectedRow && !isPluginManagedRow(selectedRow)
509
+ ? cliToolEnterAction(selectedRow)
510
+ : null;
421
511
 
422
512
  return (
423
513
  <ScreenLayout
@@ -440,7 +530,7 @@ export function CliToolsScreen() {
440
530
  selectedIndex={cliToolsState.selectedIndex}
441
531
  renderItem={renderCliToolRow}
442
532
  maxHeight={dimensions.listPanelHeight}
443
- getKey={(row) => row.tool.name}
533
+ getKey={rowName}
444
534
  />
445
535
  }
446
536
  detailPanel={(detailWidth) =>
@@ -29,11 +29,7 @@ import {
29
29
  scopeFilesMissing,
30
30
  versionInScope,
31
31
  } from "../../services/plugin-manager.js";
32
- import {
33
- checkMissingDeps,
34
- getPluginSetupFromSource,
35
- installPluginDeps,
36
- } from "../../services/plugin-setup.js";
32
+ import { resolvePluginBinRequirements } from "../../services/plugin-requires.js";
37
33
  import { saveProfile } from "../../services/profiles.js";
38
34
  import { requirersHere } from "../../services/resolver.js";
39
35
  import {
@@ -64,6 +60,7 @@ import {
64
60
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
65
61
  import { createRequestGate } from "../../utils/request-gate.js";
66
62
  import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
63
+ import { installBinsInTui } from "../adapters/depsAdapter.js";
67
64
  import { counted, failureReport } from "../adapters/failureReport.js";
68
65
  import {
69
66
  type FailureVerb,
@@ -865,57 +862,24 @@ export function PluginsScreen() {
865
862
  };
866
863
 
867
864
  /**
868
- * Install system dependencies required by a plugin's MCP servers.
865
+ * Install what a just-installed plugin declares in `requires` and this
866
+ * machine lacks — the same plan and executor as `magus doctor --fix`.
869
867
  */
870
868
  const installPluginSystemDeps = async (
871
869
  pluginName: string,
872
870
  marketplace: string,
873
871
  ): Promise<void> => {
872
+ const pluginId = `${pluginName}@${marketplace}`;
874
873
  try {
875
- const setup = await getPluginSetupFromSource(marketplace, pluginName);
876
- if (!setup) return;
877
-
878
- const missing = await checkMissingDeps(setup);
879
- const hasMissing =
880
- (missing.pip?.length || 0) +
881
- (missing.brew?.length || 0) +
882
- (missing.npm?.length || 0) +
883
- (missing.cargo?.length || 0) +
884
- (missing.go?.length || 0) >
885
- 0;
886
-
887
- if (!hasMissing) return;
888
-
889
- const parts: string[] = [];
890
- if (missing.pip?.length) parts.push(`pip: ${missing.pip.join(", ")}`);
891
- if (missing.brew?.length) parts.push(`brew: ${missing.brew.join(", ")}`);
892
- if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
893
- if (missing.cargo?.length)
894
- parts.push(`cargo: ${missing.cargo.join(", ")}`);
895
- if (missing.go?.length) parts.push(`go: ${missing.go.join(", ")}`);
896
-
897
- const wantInstall = await modal.confirm(
898
- "Install Dependencies?",
899
- `This plugin needs system dependencies:\n\n${parts.join("\n")}\n\nInstall now?`,
874
+ const bins = await resolvePluginBinRequirements(
875
+ pluginId,
876
+ state.projectPath || process.cwd(),
900
877
  );
901
-
902
- if (!wantInstall) return;
903
-
904
- modal.loading("Installing dependencies...");
905
- const result = await installPluginDeps(missing);
906
- modal.hideModal();
907
-
908
- // Only what did not install, each with why; what did shows nowhere.
909
- const report = failureReport(
910
- `${counted(result.failed.length, "dependency", "dependencies")} of ${pluginName} ${
911
- result.failed.length === 1 ? "was" : "were"
912
- } not installed`,
913
- result.failed.map((f) => ({
914
- subject: f.pkg,
915
- reason: exceptionReason(f.error).reason,
916
- })),
878
+ await installBinsInTui(
879
+ pluginId,
880
+ bins.map((bin) => ({ ...bin, sources: [`plugin:${pluginId}`] })),
881
+ modal,
917
882
  );
918
- if (report) await modal.failures(report);
919
883
  } catch (error) {
920
884
  // Hide the loading modal first: it would otherwise stay up forever,
921
885
  // and the failure behind it would never be read.
@@ -24,8 +24,8 @@ import { join } from "node:path";
24
24
  * characters it contains, and `cmd` must name a real executable rather than
25
25
  * anything PATH-plus-a-profile might have meant.
26
26
  *
27
- * `services/plugin-setup.ts` arrived at this same shape independently for the
28
- * MCP dependency installer. This is the codebase agreeing with itself.
27
+ * Plugin dependency installs are argv for the same reason: requires-probe
28
+ * renders every install step as one, and `utils/run.ts` runs it.
29
29
  */
30
30
  export interface Command {
31
31
  readonly cmd: string;
@@ -71,9 +71,8 @@ function quoteToken(token: string): string {
71
71
  * Async only for its callers' sake — it spawns nothing. This used to run
72
72
  * `execFileAsync("which", [command])`, a real `which(1)` process with no
73
73
  * timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
74
- * does not need a process, and under load that process is what stalls:
75
- * `detectToolchains` calls this per toolchain, and in a 103-file parallel test
76
- * run the spawn queue backed up until the test failed at **20,007 ms** — after
74
+ * does not need a process, and under load that process is what stalls: in a
75
+ * 103-file parallel test run the spawn queue backed up until the test failed at **20,007 ms** — after
77
76
  * the per-test timeout had already been raised from 5s to 20s to accommodate
78
77
  * the earlier 5s failures. Raising the limit moved the goalposts; removing the
79
78
  * spawn removes the stall.