magus-cli 7.8.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 (78) 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-780-cli-tools.test.ts +36 -48
  11. package/src/__tests__/blind-780-review2.test.ts +0 -25
  12. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  13. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  14. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  15. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  16. package/src/__tests__/deps-adapter.test.ts +191 -0
  17. package/src/__tests__/deps-check-hook.test.ts +220 -0
  18. package/src/__tests__/deps-plan.test.ts +896 -0
  19. package/src/__tests__/deps-report.test.ts +197 -0
  20. package/src/__tests__/doctor-bins.test.ts +134 -36
  21. package/src/__tests__/doctor-json.test.ts +261 -0
  22. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  23. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  24. package/src/__tests__/fixtures/bins.ts +55 -0
  25. package/src/__tests__/install-plan.test.ts +15 -1
  26. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  27. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  28. package/src/__tests__/npm-package-name.test.ts +6 -9
  29. package/src/__tests__/plugin-requires.test.ts +138 -153
  30. package/src/__tests__/requires-probe.test.ts +858 -0
  31. package/src/__tests__/resolver.test.ts +129 -52
  32. package/src/__tests__/run.test.ts +108 -0
  33. package/src/__tests__/tool-probe.test.ts +1 -1
  34. package/src/__tests__/toolchain.test.ts +123 -52
  35. package/src/__tests__/update-plan.test.ts +1 -1
  36. package/src/cli/ansi.ts +2 -7
  37. package/src/cli/deps-view.ts +213 -0
  38. package/src/cli/doctor.ts +169 -24
  39. package/src/cli/install.ts +51 -66
  40. package/src/cli/profile.ts +20 -2
  41. package/src/cli/router.ts +6 -2
  42. package/src/cli/update.ts +28 -7
  43. package/src/data/cli-tools.ts +24 -9
  44. package/src/services/active-closure.ts +3 -3
  45. package/src/services/claude-settings.ts +4 -348
  46. package/src/services/cli-tool-commands.ts +104 -3
  47. package/src/services/deps-plan.ts +962 -0
  48. package/src/services/deps-report.ts +265 -0
  49. package/src/services/doctor-bins.ts +115 -43
  50. package/src/services/install-plan.ts +11 -1
  51. package/src/services/local-marketplace.ts +12 -206
  52. package/src/services/marketplace-refresh.ts +4 -4
  53. package/src/services/plugin-manager.ts +4 -18
  54. package/src/services/plugin-requires.ts +93 -162
  55. package/src/services/resolver.ts +168 -58
  56. package/src/services/tool-probe.ts +18 -25
  57. package/src/services/toolchain.ts +117 -111
  58. package/src/services/update-engine.ts +19 -14
  59. package/src/services/update-plan.ts +3 -3
  60. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  61. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  62. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  63. package/src/types/index.ts +53 -16
  64. package/src/ui/App.tsx +6 -40
  65. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  66. package/src/ui/adapters/depsAdapter.ts +129 -0
  67. package/src/ui/adapters/startupReports.ts +0 -22
  68. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  69. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  70. package/src/ui/screens/PluginsScreen.tsx +12 -48
  71. package/src/utils/command-utils.ts +4 -5
  72. package/src/utils/run.ts +123 -52
  73. package/src/utils/string-utils.ts +9 -0
  74. package/src/__tests__/go-module.test.ts +0 -43
  75. package/src/__tests__/plugin-setup.test.ts +0 -1016
  76. package/src/__tests__/shell-script-callers.test.ts +0 -92
  77. package/src/services/plugin-setup.ts +0 -965
  78. package/src/utils/go-module.ts +0 -36
@@ -1,4 +1,9 @@
1
1
  import type { ModelsConfig } from "../services/models-core.js";
2
+ import type {
3
+ InstallOption,
4
+ Requirement,
5
+ Via,
6
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
2
7
 
3
8
  export interface McpServer {
4
9
  name: string;
@@ -409,14 +414,52 @@ export interface ProfileManifest {
409
414
  profiles: Record<string, ProfileManifestEntry>;
410
415
  }
411
416
 
412
- // ─── Plugin binary requirements (plugin.json `requires.bin`) ────────────────────
417
+ // ─── Binary requirements (profile cliTools, and plugin requires) ────────────────
413
418
 
414
- /** How a binary dependency is installed. */
415
- export type BinInstaller = "bun" | "brew" | "npm" | "pip" | "go";
419
+ /**
420
+ * What a plugin or a profile needs on the machine. One shape for both: a
421
+ * plugin's `requires` entry as requires-probe parses it, and a profile
422
+ * cliTools entry mapped onto the profile-only `npm`/`pip`/`go` kinds (or
423
+ * `bun`/`brew`) by `cliToolToBin`.
424
+ */
425
+ export type BinRequirement = Requirement;
426
+ export type BinInstallOption = InstallOption;
427
+
428
+ /**
429
+ * A requirement with the install option chosen for this machine — selected
430
+ * once, where the requirement is resolved — and the plugin directory a
431
+ * `${CLAUDE_PLUGIN_ROOT}` check or a plugin-relative script resolves against
432
+ * (absent for a profile cliTool).
433
+ */
434
+ export interface SelectedBin extends Requirement {
435
+ /** Null when no option can be used here (`whyUnselectable` says why). */
436
+ chosen: InstallOption | null;
437
+ root?: string;
438
+ /**
439
+ * Profile pins the chosen option cannot install at: a tool a plugin owns
440
+ * and installs from a release, Homebrew or apt, whose version is the
441
+ * declaration's. Never applied, never dropped: `planDeps` reports each as
442
+ * unfixable and `magus update` names it.
443
+ */
444
+ refusedPins?: string[];
445
+ }
446
+
447
+ /** A selected requirement with its provenance: `plugin:<id>` / `profile:<name>.cliTools`. */
448
+ export interface ResolvedBin extends SelectedBin {
449
+ sources: string[];
450
+ }
451
+
452
+ /** The install kinds whose installed version magus tracks and updates. */
453
+ export type BinInstaller = Extract<Via, "bun" | "brew" | "npm" | "pip" | "go">;
416
454
 
417
- /** A single binary dependency a plugin (or profile) declares. */
418
- export interface BinRequirement {
419
- /** The executable name expected on PATH (also the dedup key). */
455
+ /**
456
+ * A requirement whose chosen option is one of the {@link BinInstaller} kinds,
457
+ * in the shape `magus update` and the CLI Tools screen track versions in.
458
+ * Derived from a {@link SelectedBin} by `managedTool` (services/toolchain.ts)
459
+ * or built from a catalogue entry; never declared by hand.
460
+ */
461
+ export interface ManagedTool {
462
+ /** The executable name expected on PATH. */
420
463
  name: string;
421
464
  via: BinInstaller;
422
465
  /** Package id for bun/npm/pip (defaults to `name`). */
@@ -425,25 +468,19 @@ export interface BinRequirement {
425
468
  formula?: string;
426
469
  /** Module path for `go install` (defaults to `name`). */
427
470
  module?: string;
428
- /** Command that verifies presence, e.g. "tmux -V" (defaults to `<name> --version`). */
471
+ /** The check, as one display line, e.g. "tmux -V" (defaults to `<name> --version`). */
429
472
  check?: string;
430
473
  /** Optional version pin (real for bun/npm/pip; advisory for brew/go). */
431
474
  version?: string;
432
475
  }
433
476
 
434
- /** The `requires` block a plugin may declare in its plugin.json. */
435
- export interface PluginRequires {
436
- bin?: BinRequirement[];
477
+ /** A {@link ManagedTool} with its provenance. */
478
+ export interface ManagedBin extends ManagedTool {
479
+ sources: string[];
437
480
  }
438
481
 
439
482
  // ─── Resolved install closure (resolver output) ─────────────────────────────────
440
483
 
441
- /** A binary requirement after dedup + pin precedence, with provenance. */
442
- export interface ResolvedBin extends BinRequirement {
443
- /** Where this requirement came from, for diagnostics. */
444
- sources: string[];
445
- }
446
-
447
484
  /**
448
485
  * The fully-resolved set of things to install for a profile (or the union of
449
486
  * all profiles). Produced by the resolver; consumed by `magus install`.
package/src/ui/App.tsx CHANGED
@@ -3,8 +3,6 @@ import { activePluginPins } from "../services/active-closure.js";
3
3
  import { isClaudeAvailable } from "../services/claude-cli.js";
4
4
  import {
5
5
  findStaleLoads,
6
- migrateMarketplaceRename,
7
- recoverMarketplaceSettings,
8
6
  repairAllStaleLoads,
9
7
  } from "../services/claude-settings.js";
10
8
  import {
@@ -12,7 +10,6 @@ import {
12
10
  startupGitignoreNeed,
13
11
  } from "../services/gitignore-check.js";
14
12
  import { applyStartupFixes } from "../services/gitignore-service.js";
15
- import { repairAllMarketplaces } from "../services/local-marketplace.js";
16
13
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
17
14
  import {
18
15
  type ProfileStartupNeed,
@@ -26,7 +23,6 @@ import {
26
23
  } from "../services/version-check.js";
27
24
  import {
28
25
  gitignoreStartupReport,
29
- marketplaceRefreshReport,
30
26
  profileStartupReport,
31
27
  staleRepairReport,
32
28
  } from "./adapters/startupReports.js";
@@ -565,14 +561,11 @@ function AppContent({ onExit }: AppContentProps) {
565
561
  const noRefresh = process.argv.includes("--no-refresh");
566
562
  if (noRefresh) return;
567
563
 
568
- dispatch({
569
- type: "SHOW_PROGRESS",
570
- state: { message: "Scanning marketplaces..." },
571
- });
572
-
573
- // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
574
- migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
575
-
564
+ // Opening magus reads Claude Code's files and never writes them: the
565
+ // marketplace registry, its clones, installed_plugins.json and every
566
+ // settings.json are Claude Code's. The one change made here goes through
567
+ // Claude Code's own CLI.
568
+ //
576
569
  // Auto-add marketplaces referenced by enabledPlugins but missing from
577
570
  // the local registry. Fixes the "fresh clone of a project whose
578
571
  // .claude/settings.json enables @magus plugins but this machine has
@@ -585,34 +578,7 @@ function AppContent({ onExit }: AppContentProps) {
585
578
  // non-fatal: user can still install marketplaces manually
586
579
  }
587
580
  })();
588
-
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.
593
- recoverMarketplaceSettings()
594
- .then(async (recovery) => {
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
- });
602
- }
603
- queueReports(marketplaceRefreshReport(failed));
604
- })
605
- .catch(() => {}); // the recovery itself: non-fatal
606
-
607
- repairAllMarketplaces()
608
- .then(async () => {
609
- dispatch({ type: "HIDE_PROGRESS" });
610
- dispatch({ type: "DATA_REFRESH_COMPLETE" });
611
- })
612
- .catch(() => {
613
- dispatch({ type: "HIDE_PROGRESS" });
614
- });
615
- }, [dispatch, queueReports]);
581
+ }, []);
616
582
 
617
583
  return (
618
584
  <DimensionsProvider
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import path from "node:path";
21
- import type { CliTool } from "../../data/cli-tools.js";
21
+ import type { CliTool, OwnedCatalogueEntry } from "../../data/cli-tools.js";
22
22
  import {
23
23
  cliToolInstall,
24
24
  cliToolUpdate,
@@ -31,8 +31,14 @@ import {
31
31
  classifyTool,
32
32
  } from "../../services/tool-probe.js";
33
33
  import { binCommandForState } from "../../services/toolchain.js";
34
- import type { BinRequirement, ResolvedBin } from "../../types/index.js";
34
+ import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
35
+ import type {
36
+ ManagedBin,
37
+ ManagedTool,
38
+ ResolvedBin,
39
+ } from "../../types/index.js";
35
40
  import { type Command, formatCommand } from "../../utils/command-utils.js";
41
+ import type { RunResult } from "../../utils/run.js";
36
42
  import type { StatusTone } from "../renderers/pluginStatusText.js";
37
43
  import type { FailureReport } from "../state/types.js";
38
44
  import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
@@ -41,7 +47,7 @@ import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
41
47
  export interface CliToolRow {
42
48
  tool: CliTool;
43
49
  /** The requirement the tool was probed through (closure → detected → catalogue). */
44
- spec: BinRequirement;
50
+ spec: ManagedTool;
45
51
  /** True until facts arrive. */
46
52
  checking: boolean;
47
53
  /** Null while checking. */
@@ -56,25 +62,65 @@ export interface CliToolRow {
56
62
  updateCommand?: Command;
57
63
  /** What Enter runs for a tool that is not installed. */
58
64
  installCommand: Command;
65
+ /**
66
+ * For a tool a plugin owns: that plugin's requirement. Enter on a missing
67
+ * one installs it through the deps executor, like `magus doctor --fix`.
68
+ */
69
+ owned?: ResolvedBin;
70
+ }
71
+
72
+ /**
73
+ * A catalogue tool its plugin installs with an option magus does not version
74
+ * (a release, a Homebrew cask, apt): read-only. No Enter action, no update,
75
+ * no conflict fix — the row says which plugin manages it and whether it is
76
+ * here, from the same probe `magus doctor` runs.
77
+ */
78
+ export interface PluginManagedRow {
79
+ /** The plugin that installs it: `<plugin>@<marketplace>`. */
80
+ managedBy: string;
81
+ entry: OwnedCatalogueEntry;
82
+ owned: ResolvedBin;
83
+ /** Null while checking. */
84
+ presence: Presence | null;
85
+ /** The plugin's install line, for display; `magus doctor --fix` runs it. */
86
+ installText: string;
87
+ }
88
+
89
+ /** A CLI Tools list row. */
90
+ export type CliToolsRow = CliToolRow | PluginManagedRow;
91
+
92
+ export const isPluginManagedRow = (row: CliToolsRow): row is PluginManagedRow =>
93
+ "managedBy" in row;
94
+
95
+ /** The tool's catalogue name, whichever kind of row. */
96
+ export function rowName(row: CliToolsRow): string {
97
+ return isPluginManagedRow(row) ? row.entry.name : row.tool.name;
98
+ }
99
+
100
+ /** On PATH or in a user prefix, as far as the row knows. */
101
+ function rowInstalled(row: CliToolsRow): boolean {
102
+ if (!isPluginManagedRow(row)) return isInstalled(row.status);
103
+ const status = row.presence?.status;
104
+ return status === "present" || status === "off-path";
59
105
  }
60
106
 
61
107
  /**
62
108
  * Did this spec come from the resolver's closure — i.e. does the active profile
63
109
  * declare this tool?
64
110
  *
65
- * `ResolvedBin` is `BinRequirement` plus `sources`, and only the resolver adds
111
+ * `ManagedBin` is `ManagedTool` plus `sources`, and only the resolver adds
66
112
  * them, so the provenance travels with the spec itself rather than being
67
113
  * re-derived here (which would be a second copy of `specForCatalogTool`'s
68
114
  * branch 1, free to disagree with it).
69
115
  */
70
- function declaredSpec(spec: BinRequirement): ResolvedBin | null {
71
- const candidate = spec as ResolvedBin;
116
+ function declaredSpec(spec: ManagedTool): ManagedBin | null {
117
+ const candidate = spec as ManagedBin;
72
118
  return Array.isArray(candidate.sources) ? candidate : null;
73
119
  }
74
120
 
75
121
  export function buildToolRow(
76
122
  tool: CliTool,
77
- spec: BinRequirement,
123
+ spec: ManagedTool,
78
124
  facts: ToolFacts | undefined,
79
125
  install?: InstallInfo,
80
126
  ): CliToolRow {
@@ -122,7 +168,7 @@ export function buildToolRow(
122
168
  */
123
169
  function updateCommandFor(
124
170
  tool: CliTool,
125
- declared: ResolvedBin | null,
171
+ declared: ManagedBin | null,
126
172
  status: ToolStatus,
127
173
  facts: ToolFacts,
128
174
  install: InstallInfo | undefined,
@@ -237,7 +283,7 @@ export interface CliToolStatusText {
237
283
  * within 24. Keyed by reason alone — plus the spec's installer, which is a
238
284
  * word, never a path.
239
285
  */
240
- function unknownLabel(reason: ToolReason, via: BinRequirement["via"]): string {
286
+ function unknownLabel(reason: ToolReason, via: ManagedTool["via"]): string {
241
287
  switch (reason) {
242
288
  case "not-managed":
243
289
  return `not managed by ${via}`;
@@ -292,7 +338,7 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
292
338
  }
293
339
 
294
340
  /** Header counts, from the rows' states. */
295
- export function countToolRows(rows: readonly CliToolRow[]): {
341
+ export function countToolRows(rows: readonly CliToolsRow[]): {
296
342
  installed: number;
297
343
  updates: number;
298
344
  unverified: number;
@@ -301,7 +347,11 @@ export function countToolRows(rows: readonly CliToolRow[]): {
301
347
  let updates = 0;
302
348
  let unverified = 0;
303
349
  for (const row of rows) {
304
- if (isInstalled(row.status)) installed++;
350
+ if (rowInstalled(row)) installed++;
351
+ if (isPluginManagedRow(row)) {
352
+ if (row.presence?.status === "unknown") unverified++;
353
+ continue;
354
+ }
305
355
  if (row.status?.state === "upgrade") updates++;
306
356
  if (row.status?.state === "unknown") unverified++;
307
357
  }
@@ -364,39 +414,29 @@ export const RETRY_TIMEOUT_FIX =
364
414
  "Try again; if it keeps timing out, check your network connection";
365
415
 
366
416
  /**
367
- * Why an installer command failed, as ONE line, from what `execFile` threw.
417
+ * Why an installer command failed, as ONE line, from its {@link RunResult}.
368
418
  *
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.
419
+ * The installer's own words are in its output: the text after an `error:`
420
+ * (npm, brew and cargo all say it that way), else its first line that is not
421
+ * bookkeeping. A missing installer and a timeout are said plainly — a missing
422
+ * `claude` in the words every other screen uses for it. No absolute binary
423
+ * path survives into the reason.
375
424
  */
376
425
  export function commandFailureReason(
377
426
  command: Command,
378
- error: unknown,
427
+ result: RunResult,
379
428
  timeoutMs: number,
380
429
  ): 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
430
  const name = commandName(command);
391
- if (e.code === "ENOENT") {
431
+ if (result.spawnError?.includes("ENOENT")) {
392
432
  return name === "claude"
393
433
  ? CLAUDE_MISSING_REASON
394
434
  : `${name} is not installed on this machine`;
395
435
  }
396
- if (e.killed || e.signal === "SIGTERM") {
436
+ if (result.timedOut) {
397
437
  return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
398
438
  }
399
- const lines = String(e.stderr ?? "")
439
+ const lines = result.outputTail
400
440
  .split("\n")
401
441
  .map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
402
442
  .filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
@@ -406,12 +446,10 @@ export function commandFailureReason(
406
446
  : (lines[0] ?? "");
407
447
  if (!reason) {
408
448
  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`;
449
+ result.spawnError ??
450
+ (result.exitCode !== null
451
+ ? `${name} exited with code ${result.exitCode}`
452
+ : `${name} failed`);
415
453
  }
416
454
  reason = withoutBinaryPaths(reason);
417
455
  return reason.length > MAX_REASON
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Plugin dependencies in the TUI: the same plan and the same `ApplyOutcome[]`
3
+ * `magus doctor --fix` renders as text, rendered as a confirm modal and a
4
+ * failure popup.
5
+ *
6
+ * `installBinsInTui` is the one flow the Plugins screen (a plugin just
7
+ * installed) and the CLI Tools screen (a tool a plugin owns) both run:
8
+ * probe → `planDeps` → confirm listing every step → `applyDepsPlan` → popup.
9
+ */
10
+
11
+ import { homedir } from "node:os";
12
+ import {
13
+ type ApplyOutcome,
14
+ type DepsPlan,
15
+ applyDepsPlan,
16
+ defaultLockPath,
17
+ planLines,
18
+ } from "../../services/deps-plan.js";
19
+ import { assessBins } from "../../services/deps-report.js";
20
+ import { machineEnv } from "../../services/doctor-bins.js";
21
+ import type { ResolvedBin } from "../../types/index.js";
22
+ import type { FailureReport, FailureRow } from "../state/types.js";
23
+ import { counted, failureReport } from "./failureReport.js";
24
+
25
+ const STATUS_REASON: Record<ApplyOutcome["status"], string> = {
26
+ installed: "installed",
27
+ "off-path": "installed, but not on the PATH Claude Code starts with",
28
+ failed: "the installer failed",
29
+ "needs-password": "needs your sudo password",
30
+ "needs-root": "needs root, and sudo is not available",
31
+ blocked: "what it needs was not installed",
32
+ };
33
+
34
+ /**
35
+ * What did not install, each with why and the exact command; then what can
36
+ * never be installed here; then the PATH lines. Null when there is nothing to
37
+ * say. The headline counts what needs the user.
38
+ */
39
+ export function depsFailureReport(
40
+ subject: string,
41
+ outcomes: readonly ApplyOutcome[],
42
+ plan: Pick<DepsPlan, "unfixable" | "pathAdvice">,
43
+ ): FailureReport | null {
44
+ const rows: FailureRow[] = [];
45
+ for (const o of outcomes) {
46
+ if (o.status === "installed") continue;
47
+ const last = o.outputTail.trim().split("\n").pop()?.trim();
48
+ rows.push({
49
+ subject: o.name,
50
+ reason:
51
+ o.status === "failed" && last
52
+ ? `${STATUS_REASON[o.status]}: ${last}`
53
+ : o.dir
54
+ ? `${STATUS_REASON[o.status]}: it is in ${o.dir}`
55
+ : STATUS_REASON[o.status],
56
+ ...(o.remedy ? { fix: o.remedy } : {}),
57
+ });
58
+ }
59
+ for (const u of plan.unfixable) {
60
+ rows.push({
61
+ subject: u.name,
62
+ reason: u.why,
63
+ });
64
+ }
65
+ for (const a of plan.pathAdvice) {
66
+ rows.push({
67
+ subject: a.prefix,
68
+ reason: "not on the PATH Claude Code starts with",
69
+ fix: `add ${a.rcLine} to ${a.rcFile}, then restart Claude Code from a new terminal`,
70
+ });
71
+ }
72
+ return failureReport(
73
+ `${counted(rows.length, "dependency of", "dependencies of")} ${subject} ${
74
+ rows.length === 1 ? "needs" : "need"
75
+ } you`,
76
+ rows,
77
+ { group: false },
78
+ );
79
+ }
80
+
81
+ /** The part of the modal API the flow uses. */
82
+ export interface DepsModal {
83
+ confirm(title: string, message: string): Promise<boolean>;
84
+ loading(message: string): void;
85
+ hideModal(): void;
86
+ failures(report: FailureReport): Promise<void>;
87
+ }
88
+
89
+ /** Probe, plan, ask once, install, and report — for `bins` of `subject`. */
90
+ export async function installBinsInTui(
91
+ subject: string,
92
+ bins: ResolvedBin[],
93
+ modal: DepsModal,
94
+ ): Promise<void> {
95
+ const env = machineEnv(
96
+ process.env.HOME || homedir(),
97
+ process.env.PATH ?? "",
98
+ process.env.SHELL ?? "",
99
+ );
100
+ if (!env || bins.length === 0) return;
101
+ const { plan } = await assessBins(
102
+ { plugins: [subject], bins, problems: [] },
103
+ env,
104
+ );
105
+ let outcomes: ApplyOutcome[] = [];
106
+ if (plan.steps.length > 0) {
107
+ const ok = await modal.confirm(
108
+ "Install Dependencies?",
109
+ `${subject} needs:\n\n${planLines(plan, env.os).join("\n")}\n\nInstall now?`,
110
+ );
111
+ if (!ok) return;
112
+ modal.loading("Installing dependencies...");
113
+ try {
114
+ outcomes = await applyDepsPlan(plan, {
115
+ yes: true,
116
+ confirm: async () => true,
117
+ env,
118
+ lockPath: defaultLockPath(env.home, process.env.XDG_CACHE_HOME),
119
+ onEvent: (e) => {
120
+ if (e.type === "step-start") modal.loading(`Installing ${e.name}...`);
121
+ },
122
+ });
123
+ } finally {
124
+ modal.hideModal();
125
+ }
126
+ }
127
+ const report = depsFailureReport(subject, outcomes, plan);
128
+ if (report) await modal.failures(report);
129
+ }
@@ -212,28 +212,6 @@ export function stepFailureReport(
212
212
  };
213
213
  }
214
214
 
215
- /**
216
- * M8: marketplaces whose refresh failed after startup re-registered them.
217
- * Until one refreshes, its plugins are read from a copy that may be old.
218
- */
219
- export function marketplaceRefreshReport(
220
- failures: ReadonlyArray<{ marketplace: string; error: unknown }>,
221
- ): FailureReport | null {
222
- return failureReport(
223
- `${counted(failures.length, "marketplace")} could not be refreshed`,
224
- failures.map(({ marketplace, error }) => {
225
- const e = exceptionReason(
226
- error instanceof Error ? error.message : String(error),
227
- );
228
- return {
229
- subject: marketplace,
230
- reason: e.reason,
231
- ...(e.fix ? { fix: e.fix } : {}),
232
- };
233
- }),
234
- );
235
- }
236
-
237
215
  /**
238
216
  * M9: generated files the startup gitignore fix could not untrack, each with
239
217
  * git's own reason.