magus-cli 7.7.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
package/src/ui/App.tsx CHANGED
@@ -24,22 +24,20 @@ import {
24
24
  checkForUpdates,
25
25
  getCurrentVersion,
26
26
  } from "../services/version-check.js";
27
+ import {
28
+ gitignoreStartupReport,
29
+ marketplaceRefreshReport,
30
+ profileStartupReport,
31
+ staleRepairReport,
32
+ } from "./adapters/startupReports.js";
27
33
  import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
28
34
  import { TABS } from "./components/TabBar.js";
29
35
  import { ProgressBar } from "./components/layout/ProgressBar.js";
30
36
  import { ModalContainer } from "./components/modals/index.js";
31
- import {
32
- startupFixNotices,
33
- useGitignoreModal,
34
- } from "./hooks/useGitignoreModal.js";
35
- import {
36
- activationWarnings,
37
- useProfileStartupModal,
38
- } from "./hooks/useProfileStartupModal.js";
39
- import {
40
- staleLoadRepairNotices,
41
- useStaleLoadModal,
42
- } from "./hooks/useStaleLoadModal.js";
37
+ import { nextStartupStep } from "./hooks/startupQueue.js";
38
+ import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
39
+ import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
40
+ import { useStaleLoadModal } from "./hooks/useStaleLoadModal.js";
43
41
  import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
44
42
  import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
45
43
  import {
@@ -60,14 +58,12 @@ import {
60
58
  useApp,
61
59
  useModal,
62
60
  useNavigation,
63
- useNotify,
64
61
  } from "./state/AppContext.js";
65
62
  import {
66
63
  DimensionsProvider,
67
64
  useDimensions,
68
65
  } from "./state/DimensionsContext.js";
69
- import type { Notice, Screen } from "./state/types.js";
70
- import { truncate } from "./text-width.js";
66
+ import type { FailureReport, Screen } from "./state/types.js";
71
67
  import { theme } from "./theme.js";
72
68
 
73
69
  export const VERSION = getCurrentVersion();
@@ -185,72 +181,6 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
185
181
  );
186
182
  }
187
183
 
188
- /**
189
- * NoticeBanner Component
190
- * The app's one notice line (or a few): what an action did, from `useNotify`.
191
- * It sits above the screen and outside the modal slot, so it never takes a key
192
- * and never stands in the way of a startup question. Each line clears itself
193
- * at its `expiresAt` — counting only time with no modal open.
194
- *
195
- * One notice is exactly ONE row: the layout reserves one row per notice
196
- * (`transientBannerCount`), so a line that wrapped would push the screen's
197
- * last rows off the terminal. Hence no wrapping, and the text clipped to the
198
- * width — the start of a line says what happened, and that is what survives.
199
- */
200
- function NoticeBanner({ notices }: { notices: readonly Notice[] }) {
201
- const { state, dispatch } = useApp();
202
- const { terminalWidth } = useDimensions();
203
- const modalOpen = state.modal !== null;
204
- const paused = state.noticesPausedAt !== null;
205
-
206
- // Hold the notices while a modal is open. A warning posted just before a
207
- // startup question, or under it, would otherwise run down behind it and be
208
- // gone by the time the user has answered.
209
- useEffect(() => {
210
- dispatch({
211
- type: modalOpen ? "PAUSE_NOTICES" : "RESUME_NOTICES",
212
- now: Date.now(),
213
- });
214
- }, [modalOpen, dispatch]);
215
-
216
- // One timer, for the line that expires first; the reducer then drops every
217
- // line past its time, and the next render schedules the one after. None
218
- // while paused: RESUME_NOTICES moves every `expiresAt` on first.
219
- useEffect(() => {
220
- if (notices.length === 0 || paused) return;
221
- const next = Math.min(...notices.map((n) => n.expiresAt));
222
- const timer = setTimeout(
223
- () => dispatch({ type: "EXPIRE_NOTICES", now: Date.now() }),
224
- Math.max(0, next - Date.now()),
225
- );
226
- return () => clearTimeout(timer);
227
- }, [notices, paused, dispatch]);
228
-
229
- // The box's left and right padding.
230
- const width = Math.max(0, terminalWidth - 2);
231
- return (
232
- <>
233
- {notices.map((notice) => (
234
- <box key={notice.id} paddingLeft={1} paddingRight={1} height={1}>
235
- <text
236
- wrapMode="none"
237
- fg={
238
- notice.tone === "warning"
239
- ? theme.colors.warning
240
- : theme.colors.success
241
- }
242
- >
243
- {truncate(
244
- `${notice.tone === "warning" ? "⚠" : "✓"} ${notice.text}`,
245
- width,
246
- )}
247
- </text>
248
- </box>
249
- ))}
250
- </>
251
- );
252
- }
253
-
254
184
  /**
255
185
  * ProgressIndicator Component
256
186
  * Shows progress bar when operations are running
@@ -282,13 +212,12 @@ interface AppContentInnerProps {
282
212
 
283
213
  function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
284
214
  const { state } = useApp();
285
- const { progress, notices } = state;
215
+ const { progress } = state;
286
216
  const dimensions = useDimensions();
287
217
 
288
218
  return (
289
219
  <box flexDirection="column" height={dimensions.terminalHeight}>
290
220
  {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
291
- <NoticeBanner notices={notices} />
292
221
  {progress && <ProgressIndicator {...progress} />}
293
222
  <box
294
223
  flexDirection="column"
@@ -336,7 +265,6 @@ interface AppContentProps {
336
265
  function AppContent({ onExit }: AppContentProps) {
337
266
  const { state, dispatch } = useApp();
338
267
  const { progress } = state;
339
- const notify = useNotify();
340
268
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
341
269
  const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
342
270
  null,
@@ -354,6 +282,19 @@ function AppContent({ onExit }: AppContentProps) {
354
282
  const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
355
283
  null,
356
284
  );
285
+ // What startup work could not do — the profile gate's refusals and its
286
+ // activations, a marketplace refresh — waiting for the modal slot, oldest
287
+ // first. Each is the one failure popup; the TUI says nothing else.
288
+ const [startupReports, setStartupReports] = useState<FailureReport[]>([]);
289
+ const queueReports = React.useCallback(
290
+ (...reports: Array<FailureReport | null>) => {
291
+ const present = reports.filter((r): r is FailureReport => r !== null);
292
+ if (present.length > 0) {
293
+ setStartupReports((queued) => [...queued, ...present]);
294
+ }
295
+ },
296
+ [],
297
+ );
357
298
  const gitignoreModal = useGitignoreModal();
358
299
  const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
359
300
  null,
@@ -409,8 +350,8 @@ function AppContent({ onExit }: AppContentProps) {
409
350
  // The hooks keep the slot occupied for their whole flow (a loading modal
410
351
  // while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
411
352
  // what lets the next question in. A flow that succeeds ends by hiding its
412
- // loading modal and reporting through `useNotify`: a notice is not in the
413
- // modal slot, so it cannot hold the next question back or be replaced by it. That does not stop a SCREEN from emptying
353
+ // loading modal and reports nothing: the screen it leaves shows the result.
354
+ // That does not stop a SCREEN from emptying
414
355
  // it: `useModal().confirm` hides its modal when answered and the screen shows
415
356
  // its loading modal a microtask later. A key pressed before the profile check
416
357
  // resolved lands the profile question in exactly that gap, and the screen's
@@ -436,8 +377,32 @@ function AppContent({ onExit }: AppContentProps) {
436
377
  // it and then the fresh one asked the same question twice. The fresh result
437
378
  // replaces the queued one instead (see the check effect below).
438
379
  useEffect(() => {
439
- if (state.modal) return;
440
- if (gitignoreData && !gitignorePending) {
380
+ // The order lives in `nextStartupStep`, where it is tested: failure
381
+ // reports first — something that already happened, and the question
382
+ // after it may be answered differently once it is known — so the profile
383
+ // gate's refusals land before the profile question, which they do not
384
+ // replace; then gitignore, broken plugins, and the profile last.
385
+ const step = nextStartupStep({
386
+ modalOpen: !!state.modal,
387
+ reports: startupReports,
388
+ gitignoreReady: !!gitignoreData && !gitignorePending,
389
+ brokenCount: brokenPluginCount,
390
+ profileNeed,
391
+ });
392
+ if (!step) return;
393
+ if (step.kind === "report") {
394
+ setStartupReports(step.rest);
395
+ dispatch({
396
+ type: "SHOW_MODAL",
397
+ modal: {
398
+ type: "failures",
399
+ report: step.report,
400
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
401
+ },
402
+ });
403
+ return;
404
+ }
405
+ if (step.kind === "gitignore" && gitignoreData) {
441
406
  // Anything older than what is now on screen is dropped on arrival.
442
407
  gitignoreCheck.current.shown = gitignoreData.request;
443
408
  const need = gitignoreData.need;
@@ -447,15 +412,18 @@ function AppContent({ onExit }: AppContentProps) {
447
412
  generated: need.generated,
448
413
  // In place, then back to the app: tracked generated files are
449
414
  // untracked, missing entries added; other items stay for the tab.
415
+ // A file git would not untrack is named with git's reason.
450
416
  onFixSafe: async () =>
451
- startupFixNotices(await applyStartupFixes(state.projectPath)),
417
+ gitignoreStartupReport(
418
+ (await applyStartupFixes(state.projectPath)).failed,
419
+ ),
452
420
  });
453
421
  setGitignoreData(null);
454
422
  return;
455
423
  }
456
- if (brokenPluginCount !== null) {
424
+ if (step.kind === "stale-load") {
457
425
  staleLoadModal.show({
458
- brokenCount: brokenPluginCount,
426
+ brokenCount: step.brokenCount,
459
427
  // Re-surveys before repairing, like the gitignore fixer: the registry
460
428
  // may have moved between the startup scan and the user pressing Enter,
461
429
  // and repairing from the stale count would act on rows that are no
@@ -465,8 +433,10 @@ function AppContent({ onExit }: AppContentProps) {
465
433
  // The pins are re-read with it, from the same helper the survey used: a
466
434
  // repair that capped at a different ceiling than the offer was counted
467
435
  // against would move rows the dialog never mentioned.
436
+ //
437
+ // A repair that did not take is named, with its reason.
468
438
  onFix: async () =>
469
- staleLoadRepairNotices(
439
+ staleRepairReport(
470
440
  await repairAllStaleLoads(
471
441
  state.projectPath,
472
442
  undefined,
@@ -479,10 +449,12 @@ function AppContent({ onExit }: AppContentProps) {
479
449
  }
480
450
  // Not cleared here — see above. Re-called on every empty slot until the
481
451
  // hook reports it answered.
482
- if (profileNeed) profileModal.show(profileNeed);
452
+ if (step.kind === "profile") profileModal.show(step.need);
483
453
  }, [
484
454
  state.modal,
485
455
  state.projectPath,
456
+ dispatch,
457
+ startupReports,
486
458
  gitignoreData,
487
459
  gitignorePending,
488
460
  gitignoreModal,
@@ -572,25 +544,21 @@ function AppContent({ onExit }: AppContentProps) {
572
544
  // edits into the active profile, regenerate from a changed
573
545
  // profiles.json, restore a lost marker — then the question, if one
574
546
  // is left to ask.
575
- const { need, notices, problems, activations } =
576
- await prepareProfileStartup(process.cwd());
577
- // A refusal never blocks the TUI at startup: it is shown beside what
578
- // the gate did, as a warning, and the user is still asked whatever
579
- // is left. What a re-render left to act on — a plugin that will not
580
- // load, one not downloaded yet — is a warning too, in user terms.
581
- for (const line of notices) notify(line);
582
- for (const activation of activations) {
583
- for (const line of activationWarnings(activation)) {
584
- notify(line.text, line.tone);
585
- }
586
- }
587
- for (const line of problems) notify(line, "warning");
547
+ // A refusal never blocks the TUI at startup: the user is still asked
548
+ // whatever is left, and the screens show the state as it is. What
549
+ // the gate refused, and what its re-render could not do, go into ONE
550
+ // failure popup first — one step, one popup — queued in the same
551
+ // tick as the question, so it is shown before it.
552
+ const { need, problems, activations } = await prepareProfileStartup(
553
+ process.cwd(),
554
+ );
555
+ queueReports(profileStartupReport(problems, activations ?? []));
588
556
  if (!needsNothing(need)) setProfileNeed(need);
589
557
  } catch {
590
558
  // non-fatal: the Profiles tab still works by hand
591
559
  }
592
560
  })();
593
- }, [notify]);
561
+ }, [queueReports]);
594
562
 
595
563
  // Auto-refresh marketplaces on startup
596
564
  useEffect(() => {
@@ -612,50 +580,29 @@ function AppContent({ onExit }: AppContentProps) {
612
580
  (async () => {
613
581
  try {
614
582
  if (!(await isClaudeAvailable())) return;
615
- const added = await autoAddMissingMarketplaces(process.cwd());
616
- if (added.length > 0) {
617
- notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
618
- }
583
+ await autoAddMissingMarketplaces(process.cwd());
619
584
  } catch {
620
585
  // non-fatal: user can still install marketplaces manually
621
586
  }
622
587
  })();
623
588
 
624
- // Recover stale marketplace registry entries (e.g. "directory" -> "github")
589
+ // Recover stale marketplace registry entries (e.g. "directory" -> "github"),
590
+ // then refresh the clones whose source that fixed. A refresh that fails
591
+ // leaves that marketplace's plugins read from an old copy, so it is named
592
+ // in the failure popup with why.
625
593
  recoverMarketplaceSettings()
626
594
  .then(async (recovery) => {
627
- const parts: string[] = [];
628
- // Apart from `parts`: a success line would carry them in green,
629
- // and a marketplace left stale is something the user may act on.
630
- const failed: string[] = [];
631
- if (recovery.reregistered.length > 0) {
632
- // Update the marketplace clone now that the source is fixed
633
- const { updateMarketplace } = await import(
634
- "../services/claude-cli.js"
635
- );
636
- for (const mp of recovery.reregistered) {
637
- try {
638
- await updateMarketplace(mp);
639
- parts.push(`${mp} refreshed`);
640
- } catch {
641
- failed.push(mp);
642
- }
643
- }
644
- }
645
- if (recovery.removed.length > 0) {
646
- parts.push(`removed: ${recovery.removed.join(", ")}`);
647
- }
648
- if (parts.length > 0) {
649
- notify(`Fixed: ${parts.join(" | ")}`);
650
- }
651
- if (failed.length > 0) {
652
- notify(
653
- `Could not refresh marketplace(s): ${failed.join(", ")}`,
654
- "warning",
655
- );
595
+ if (recovery.reregistered.length === 0) return;
596
+ const { updateMarketplace } = await import("../services/claude-cli.js");
597
+ const failed: Array<{ marketplace: string; error: unknown }> = [];
598
+ for (const mp of recovery.reregistered) {
599
+ await updateMarketplace(mp).catch((error: unknown) => {
600
+ failed.push({ marketplace: mp, error });
601
+ });
656
602
  }
603
+ queueReports(marketplaceRefreshReport(failed));
657
604
  })
658
- .catch(() => {}); // non-fatal
605
+ .catch(() => {}); // the recovery itself: non-fatal
659
606
 
660
607
  repairAllMarketplaces()
661
608
  .then(async () => {
@@ -665,15 +612,12 @@ function AppContent({ onExit }: AppContentProps) {
665
612
  .catch(() => {
666
613
  dispatch({ type: "HIDE_PROGRESS" });
667
614
  });
668
- }, [dispatch, notify]);
615
+ }, [dispatch, queueReports]);
669
616
 
670
617
  return (
671
618
  <DimensionsProvider
672
619
  showProgress={!!progress}
673
620
  showUpdateBanner={!!updateInfo?.updateAvailable}
674
- // One row per notice line, so the screen below shrinks rather than
675
- // being pushed off the bottom of the terminal.
676
- transientBannerCount={state.notices.length}
677
621
  >
678
622
  <AppContentInner updateInfo={updateInfo} onExit={onExit} />
679
623
  </DimensionsProvider>
@@ -17,6 +17,7 @@
17
17
  * Pure: no IO, no React. The screen gathers facts; this only assembles.
18
18
  */
19
19
 
20
+ import path from "node:path";
20
21
  import type { CliTool } from "../../data/cli-tools.js";
21
22
  import {
22
23
  cliToolInstall,
@@ -31,8 +32,11 @@ import {
31
32
  } from "../../services/tool-probe.js";
32
33
  import { binCommandForState } from "../../services/toolchain.js";
33
34
  import type { BinRequirement, ResolvedBin } from "../../types/index.js";
34
- import type { Command } from "../../utils/command-utils.js";
35
+ import { type Command, formatCommand } from "../../utils/command-utils.js";
35
36
  import type { StatusTone } from "../renderers/pluginStatusText.js";
37
+ import type { FailureReport } from "../state/types.js";
38
+ import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
39
+ import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
36
40
 
37
41
  export interface CliToolRow {
38
42
  tool: CliTool;
@@ -303,3 +307,201 @@ export function countToolRows(rows: readonly CliToolRow[]): {
303
307
  }
304
308
  return { installed, updates, unverified };
305
309
  }
310
+
311
+ // ─── Failures ────────────────────────────────────────────────────────────────
312
+ //
313
+ // Every installer command that fails goes into the failure popup with why and
314
+ // the command to run by hand. Update-all used to discard them: the reload
315
+ // showed the tool still behind, with no reason anywhere.
316
+
317
+ /**
318
+ * The verb for what an Enter action did not do — "installed", "updated",
319
+ * "reinstalled". The caller writes the headline around it:
320
+ * `${tool} was not ${cliToolNotDone(kind)}`.
321
+ */
322
+ export function cliToolNotDone(kind: CliToolEnterAction["kind"]): string {
323
+ if (kind === "install") return "installed";
324
+ if (kind === "reinstall") return "reinstalled";
325
+ return "updated";
326
+ }
327
+
328
+ /** One installer command that did not succeed. */
329
+ export interface CliToolCommandFailure {
330
+ /** The tool's display name. */
331
+ tool: string;
332
+ command: Command;
333
+ /** Why, in {@link commandFailureReason}'s words. */
334
+ reason: string;
335
+ }
336
+
337
+ /** The longest reason kept from an installer's own output. */
338
+ const MAX_REASON = 160;
339
+
340
+ /**
341
+ * npm's bookkeeping lines, which never say why: the error code and syscall
342
+ * fields, and where its log went.
343
+ */
344
+ const INSTALLER_NOISE =
345
+ /^(?:code|errno|syscall|path|dest)\b|^(?:A complete log of this run|Log files were not written|You can rerun|For a full report see)\b/i;
346
+
347
+ /**
348
+ * The name a user knows a command by: `/usr/local/bin/npm` → `npm`. Which
349
+ * copy of npm the machine found is mechanism, never part of a reason.
350
+ */
351
+ function commandName(command: Command): string {
352
+ return path.basename(command.cmd) || command.cmd;
353
+ }
354
+
355
+ /** How a timeout reads, so its fix can be told apart from an ordinary failure. */
356
+ const TIMED_OUT_REASON = /^did not finish within \d+ seconds$/;
357
+
358
+ /**
359
+ * The fix for a timeout. Not "Run it yourself": the same command would take
360
+ * just as long. A slow download is the usual cause, so it names the network
361
+ * without claiming that is what happened.
362
+ */
363
+ export const RETRY_TIMEOUT_FIX =
364
+ "Try again; if it keeps timing out, check your network connection";
365
+
366
+ /**
367
+ * Why an installer command failed, as ONE line, from what `execFile` threw.
368
+ *
369
+ * The thrown message's first line is "Command failed: <the command>", which
370
+ * says nothing the fix line does not. The installer's own words are in its
371
+ * stderr: the text after an `error:` (npm, brew and cargo all say it that
372
+ * way), else its first line that is not bookkeeping. A missing installer and
373
+ * a timeout are said plainly — a missing `claude` in the words every other
374
+ * screen uses for it. No absolute binary path survives into the reason.
375
+ */
376
+ export function commandFailureReason(
377
+ command: Command,
378
+ error: unknown,
379
+ timeoutMs: number,
380
+ ): string {
381
+ if (!(error instanceof Error)) {
382
+ return withoutBinaryPaths(String(error).split("\n")[0]?.trim() || "failed");
383
+ }
384
+ const e = error as Error & {
385
+ code?: unknown;
386
+ killed?: boolean;
387
+ signal?: unknown;
388
+ stderr?: unknown;
389
+ };
390
+ const name = commandName(command);
391
+ if (e.code === "ENOENT") {
392
+ return name === "claude"
393
+ ? CLAUDE_MISSING_REASON
394
+ : `${name} is not installed on this machine`;
395
+ }
396
+ if (e.killed || e.signal === "SIGTERM") {
397
+ return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
398
+ }
399
+ const lines = String(e.stderr ?? "")
400
+ .split("\n")
401
+ .map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
402
+ .filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
403
+ const said = lines.find((line) => /\berror:\s*\S/i.test(line));
404
+ let reason = said
405
+ ? said.slice(said.toLowerCase().lastIndexOf("error:") + 6).trim()
406
+ : (lines[0] ?? "");
407
+ if (!reason) {
408
+ reason =
409
+ typeof e.code === "number"
410
+ ? `${name} exited with code ${e.code}`
411
+ : e.message
412
+ .split("\n")[0]
413
+ ?.replace(/^Command failed: .*$/, "")
414
+ .trim() || `${name} failed`;
415
+ }
416
+ reason = withoutBinaryPaths(reason);
417
+ return reason.length > MAX_REASON
418
+ ? `${reason.slice(0, MAX_REASON - 1)}…`
419
+ : reason;
420
+ }
421
+
422
+ /**
423
+ * The fix for one failed installer command.
424
+ *
425
+ * Usually the command itself, to run by hand. Not for the three causes that
426
+ * running the same command again would not cure: a timeout, no `claude` to
427
+ * run, and no network. Each keeps the fix that does help.
428
+ */
429
+ export function commandFailureFix(command: Command, reason: string): string {
430
+ if (TIMED_OUT_REASON.test(reason)) return RETRY_TIMEOUT_FIX;
431
+ const known = exceptionReason(reason);
432
+ if (known.fix) return known.fix;
433
+ return `Run it yourself: ${formatCommand(command)}`;
434
+ }
435
+
436
+ /** The popup for failed installer commands, each with the fix that helps. */
437
+ export function cliToolFailureReport(
438
+ headline: string,
439
+ failures: readonly CliToolCommandFailure[],
440
+ ): FailureReport | null {
441
+ return failureReport(
442
+ headline,
443
+ failures.map((f) => ({
444
+ subject: f.tool,
445
+ reason: f.reason,
446
+ fix: commandFailureFix(f.command, f.reason),
447
+ })),
448
+ { group: false },
449
+ );
450
+ }
451
+
452
+ /** Update-all: the tools whose update command failed. */
453
+ export function cliToolsNotUpdatedReport(
454
+ failures: readonly CliToolCommandFailure[],
455
+ ): FailureReport | null {
456
+ const n = failures.length;
457
+ return cliToolFailureReport(
458
+ `${counted(n, "tool")} ${n === 1 ? "was" : "were"} not updated`,
459
+ failures,
460
+ );
461
+ }
462
+
463
+ /**
464
+ * A conflict magus cannot resolve: the tool is installed more than one way
465
+ * and none of the installs can be removed from here.
466
+ */
467
+ export function cliToolConflictStuckReport(
468
+ tool: string,
469
+ blockers: ReadonlyArray<{ method: string; reason: string }>,
470
+ ): FailureReport | null {
471
+ return failureReport(
472
+ `${tool} is installed ${blockers.length} ways, and magus can remove none of them`,
473
+ blockers.map(({ method, reason }) => ({
474
+ subject: `${method} install`,
475
+ reason,
476
+ fix: "Remove one by hand, then press r",
477
+ })),
478
+ { group: false },
479
+ );
480
+ }
481
+
482
+ /**
483
+ * A conflict resolved only in part: each removal that failed, with the
484
+ * command to run by hand, and each install magus could never remove.
485
+ */
486
+ export function cliToolConflictPartialReport(
487
+ tool: string,
488
+ failed: ReadonlyArray<{ method: string; command: Command; reason: string }>,
489
+ leftBehind: ReadonlyArray<{ method: string; reason: string }>,
490
+ ): FailureReport | null {
491
+ return failureReport(
492
+ `${tool} is still installed more than one way`,
493
+ [
494
+ ...failed.map((f) => ({
495
+ subject: `${f.method} install`,
496
+ reason: `was not removed: ${f.reason}`,
497
+ fix: `Run it yourself: ${formatCommand(f.command)}`,
498
+ })),
499
+ ...leftBehind.map(({ method, reason }) => ({
500
+ subject: `${method} install`,
501
+ reason: `stays: ${reason}`,
502
+ fix: "Remove it by hand if you want it gone",
503
+ })),
504
+ ],
505
+ { group: false },
506
+ );
507
+ }