magus-cli 7.10.3 → 7.10.5

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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +0 -1
  3. package/src/__tests__/blackbox-parity.test.ts +2 -2
  4. package/src/__tests__/blackbox-tool-state.test.ts +8 -18
  5. package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
  6. package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
  7. package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
  8. package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
  9. package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
  10. package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +38 -35
  11. package/src/__tests__/cli-tool-commands.test.ts +2 -2
  12. package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
  13. package/src/__tests__/cli-tools-adapter.test.ts +32 -22
  14. package/src/__tests__/cli-update-view.test.ts +124 -51
  15. package/src/__tests__/fixtures/bins.ts +9 -2
  16. package/src/__tests__/npm-package-name.test.ts +3 -2
  17. package/src/__tests__/profile-worktree-include.test.ts +212 -0
  18. package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
  19. package/src/__tests__/tool-probe.test.ts +231 -144
  20. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  21. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  22. package/src/__tests__/tool-versions.test.ts +164 -0
  23. package/src/__tests__/toolchain.test.ts +0 -2
  24. package/src/__tests__/update-plan.test.ts +40 -9
  25. package/src/cli/update-view.ts +180 -76
  26. package/src/cli/update.ts +44 -37
  27. package/src/data/cli-tools.ts +5 -10
  28. package/src/data/tool-versions.ts +137 -0
  29. package/src/services/cli-tool-commands.ts +1 -2
  30. package/src/services/cli-tool-version.ts +10 -88
  31. package/src/services/profile-activate.ts +9 -0
  32. package/src/services/profile-live.ts +26 -0
  33. package/src/services/profile-skills.ts +14 -4
  34. package/src/services/profile-state.ts +18 -0
  35. package/src/services/resolver.ts +9 -15
  36. package/src/services/tool-probe.ts +537 -691
  37. package/src/services/toolchain.ts +10 -4
  38. package/src/services/update-engine.ts +9 -15
  39. package/src/services/update-plan.ts +49 -21
  40. package/src/types/index.ts +0 -2
  41. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  42. package/src/ui/renderers/cliToolRenderers.tsx +15 -21
  43. package/src/ui/state/types.ts +0 -1
  44. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
@@ -12,18 +12,26 @@
12
12
  * Now both front ends read tools through here, in two halves:
13
13
  *
14
14
  * - {@link probeTools} gathers FACTS through an injected {@link ToolProbeIO}.
15
- * Each tool is read through the installer that owns the binary PATH actually
16
- * runs, and `managed` says whether the upgrade command magus would run
17
- * replaces THAT file. brew and uv answer "is it behind?" themselves; bun, npm
18
- * and go fall back to a registry lookup.
15
+ * Each tool is read through the installer the spec names, and `managed`
16
+ * says whether the upgrade command magus would run replaces the file PATH
17
+ * actually runs. brew is ASKED (`brew list`), never second-guessed
18
+ * by rebuilding its install layout; the npm, bun, pip and go probes ask their
19
+ * installer where it keeps globals and test against that answer. brew and uv
20
+ * answer "is it behind?" themselves; bun, npm and go fall back to a registry
21
+ * lookup. A version comes from the installer's record or from magus's own
22
+ * version command (`data/tool-versions.ts`) — never guessed from files, and
23
+ * never from a plugin's `check` string.
19
24
  * - {@link classifyTool} turns facts into a state, purely. The rules are in
20
25
  * order and the vetoes that make an upgrade unsafe come before every rule
21
26
  * that can produce one.
22
27
  *
23
28
  * Asking the installer that will perform the upgrade, about the file that will
24
29
  * run, is what makes plan, upgrade and PATH agree — which is what convergence
25
- * means. A tool whose state cannot be established is `unknown`, and `unknown`
26
- * is never applied.
30
+ * means. When the spec's installer does not have the file PATH runs, the other
31
+ * installers magus drives (bun, npm, Homebrew) are asked the same way, and the
32
+ * one that has it is the owner: the update runs through IT. A tool none of them
33
+ * has is `external`: installed another way, found, and correctly left alone. A
34
+ * tool whose state cannot be established is `unknown`. Neither is ever applied.
27
35
  *
28
36
  * NOTHING HERE PRINTS, and every subprocess runs through `execFile` (argv, no
29
37
  * shell) with its stdio CAPTURED: the probe runs under the CLI's animated live
@@ -43,14 +51,11 @@ import os from "node:os";
43
51
  import path from "node:path";
44
52
  import semver from "semver";
45
53
  import { CLAUDE_CODE_NPM_PACKAGE, type CliTool } from "../data/cli-tools.js";
54
+ import { parseToolVersion, toolVersionCommand } from "../data/tool-versions.js";
46
55
  import type { BinInstaller, ManagedBin, ManagedTool } from "../types/index.js";
47
56
  import type { Command } from "../utils/command-utils.js";
48
57
  import type { InstallMethod } from "./cli-tool-commands.js";
49
- import {
50
- readPackageVersionForBin,
51
- readVersionDirForBin,
52
- versionFromVersionedPath,
53
- } from "./cli-tool-version.js";
58
+ import { versionFromVersionedPath } from "./cli-tool-version.js";
54
59
  import {
55
60
  binPinCommand,
56
61
  binUpgradeCommand,
@@ -61,10 +66,10 @@ import {
61
66
  // ── Types ────────────────────────────────────────────────────────────────────
62
67
 
63
68
  /**
64
- * `external` is a determined fact, not a failure to check: the tool is on
65
- * PATH, and something other than the installer magus would use put it there
66
- * (bun, an official installer, the OS). magus leaves it as is, so it is
67
- * neither work nor "could not be checked".
69
+ * `external`: on PATH, and no installer magus drives has it — or the one that
70
+ * does has a different copy than the one PATH runs. Installed another way,
71
+ * found, and left alone. Never work, never a warning, never "could not be
72
+ * checked".
68
73
  */
69
74
  export type ToolState =
70
75
  | "install"
@@ -82,7 +87,10 @@ export type ToolReason =
82
87
  | "ahead-of-pin"
83
88
  | "pin-not-installable"
84
89
  | "held-back"
85
- | "not-managed"
90
+ /** `external`: the spec's installer does not own the file PATH runs. */
91
+ | "installed-elsewhere"
92
+ /** `unknown`: whether the spec's installer owns it could not be determined. */
93
+ | "owner-unknown"
86
94
  | "version-unreadable"
87
95
  | "latest-unavailable"
88
96
  | "unorderable"
@@ -102,24 +110,12 @@ export type ToolFactSource =
102
110
  | "pypi"
103
111
  | "npm-registry"
104
112
  | "go-proxy"
105
- /** the package.json of the npm/bun package the PATH binary lives in. */
106
- | "package-json"
107
- /** the file name of a versioned install (`…/versions/2.1.276`). */
113
+ /** the file name of Claude Code's native install (`…/versions/2.1.276`). */
108
114
  | "version-dir"
109
- | "check-command"
110
- /** an installer's own receipt (`~/.config/uv/uv-receipt.json`). */
111
- | "installer-receipt"
112
- /** `--version` of a program the operating system ships. */
113
- | "os-version";
114
-
115
- /** Another copy of the tool on PATH, behind the one that runs. */
116
- export interface OtherCopy {
117
- /** The installer as the user knows it: "Homebrew", "uv's own installer". */
118
- owner: string;
119
- version: string | null;
120
- /** The PATH directory it was found in, with the home folder as `~`. */
121
- dir: string;
122
- }
115
+ /** magus's own version command for the tool (`data/tool-versions.ts`). */
116
+ | "version-command"
117
+ /** a standalone installer's own receipt (`~/.config/uv/uv-receipt.json`). */
118
+ | "installer-receipt";
123
119
 
124
120
  export interface ToolFacts {
125
121
  name: string;
@@ -138,26 +134,14 @@ export interface ToolFacts {
138
134
  /** The owning installer's own verdict; null = it gave none. */
139
135
  behind: boolean | null;
140
136
  /**
141
- * The upgrade command for this spec replaces `resolvedPath` (containment in
142
- * the package's own directory, or pip adoption). false = another owner;
143
- * null = the owner directory could not be determined.
137
+ * The upgrade command for this spec replaces the file PATH runs. For brew:
138
+ * the file PATH runs is one `brew list <formula>` prints, and (when magus
139
+ * can read the tool's version) one of brew's versions is the one PATH runs. For npm/bun/pip/go: containment in
140
+ * the directory the installer reports, or pip adoption. false = the spec's
141
+ * installer does not have it, or has another copy (`external`); null = it
142
+ * could not be determined (the installer timed out or failed).
144
143
  */
145
144
  managed: boolean | null;
146
- /**
147
- * Where the installer keeps what it owns, for the human note — e.g.
148
- * "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
149
- */
150
- owner?: string;
151
- /**
152
- * Who installed the file PATH runs, as the user knows it ("uv's own
153
- * installer", "a Homebrew cask"), when that is not the spec's installer and
154
- * magus could name it. Read by the not-managed note.
155
- */
156
- ownerName?: string;
157
- /** The PATH directory the running copy was found in, home as `~`. */
158
- pathDir?: string;
159
- /** Other copies of the tool later on PATH, which nothing runs. */
160
- otherCopies?: OtherCopy[];
161
145
  /** e.g. "held by brew pin". */
162
146
  heldBack?: string;
163
147
  /**
@@ -191,22 +175,22 @@ export interface ToolFacts {
191
175
  failure?: string;
192
176
  /** Where each fact came from, for diagnostics. */
193
177
  sources: ToolFactSource[];
178
+ /**
179
+ * How many OTHER files of this name sit later on PATH, which nothing runs.
180
+ * A count only: telling which copy is whose, or which is newer, would mean
181
+ * reading installers' layouts or running files magus did not choose.
182
+ */
183
+ otherCopies?: number;
194
184
  }
195
185
 
196
186
  export interface ToolStatus {
197
187
  name: string;
198
188
  state: ToolState;
199
189
  reason: ToolReason;
200
- /** Set for every state except plain current/install. */
190
+ /** Set for every state except plain current/install and `external`. */
201
191
  note?: string;
202
192
  /** {@link ToolFacts.adopted}, carried when this state plans work. */
203
193
  adopted?: string;
204
- /**
205
- * Set in every state when a NEWER copy sits later on PATH, unused: e.g.
206
- * "Homebrew also has 0.13.0, unused: ~/.local/bin comes first on PATH".
207
- * A warning, because only the user can decide which copy to keep.
208
- */
209
- shadowed?: string;
210
194
  installed: string | null;
211
195
  latest: string | null;
212
196
  target: string | null;
@@ -257,96 +241,31 @@ function withFailure(text: string, failure: string | undefined): string {
257
241
  return failure ? `${text} (${failure})` : text;
258
242
  }
259
243
 
260
- /** The installer as the user knows it. */
261
- export function installerWord(via: ManagedTool["via"]): string {
262
- return via === "brew" ? "Homebrew" : via;
263
- }
264
-
265
- /** The directories the operating system itself installs programs into. */
266
- const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
267
-
268
- function isSystemPath(resolved: string | null): boolean {
269
- return (
270
- resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
271
- );
272
- }
273
-
274
- /**
275
- * The `not-managed` sentence, in the user's terms: who did not install it,
276
- * and what magus does about it. Never the path or the installer's directory:
277
- * the user acts on neither.
278
- */
279
- function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
280
- if (facts.managed !== false) {
281
- return withFailure(
282
- `could not tell whether ${installerWord(spec.via)} installed it`,
283
- facts.failure,
284
- );
285
- }
286
- // Who DID install it. "Outside Homebrew" named the installer magus would
287
- // have used, which says nothing about the copy the user has.
288
- const who = isSystemPath(facts.resolvedPath)
289
- ? "comes with the operating system"
290
- : facts.ownerName
291
- ? `installed by ${facts.ownerName}`
292
- : "installed by an installer magus does not update";
293
- const pin = pinOf(spec);
294
- // The KEPT chip already says magus leaves it as is; the note says only who
295
- // installed it, and what that means for a pin.
296
- return pin ? `${who}, so magus cannot move it to the pin ${pin}` : who;
297
- }
298
-
299
- /**
300
- * The warning for a newer copy later on PATH, or undefined. Only a NEWER one
301
- * (or any, when the running version is unknown) is worth a line: an older
302
- * copy changes nothing the user runs.
303
- */
304
- function shadowNote(facts: ToolFacts): string | undefined {
305
- const running = facts.installed;
306
- const newer = (facts.otherCopies ?? []).find(
307
- (copy) =>
308
- copy.version !== null &&
309
- (running === null || compareToolVersions(copy.version, running) === 1),
310
- );
311
- if (!newer?.version) return undefined;
312
- const first = facts.pathDir ? `${facts.pathDir} comes first on PATH` : "";
313
- return `${newer.owner} also has ${newer.version}, unused${first ? `: ${first}` : ""}`;
314
- }
315
-
316
- /**
317
- * Another installer's file is `external`, settled, only while nothing is
318
- * asked of it. With a pin the profile declares a version magus cannot deliver
319
- * without a second copy, so that is still a warning: `unknown`.
320
- */
321
- function externalOr(pin: string | undefined): ToolState {
322
- return pin ? "unknown" : "external";
323
- }
324
-
325
244
  /**
326
245
  * THE tool classifier. Pure. The first matching rule wins, and each branch
327
246
  * returns, so reading top to bottom is reading the precedence.
328
247
  *
329
- * The vetoes (`not-managed`, `held-back`) come before every rule that can yield
330
- * `upgrade`, pinned or not: an upgrade through an installer that does not own
331
- * the running file installs a second copy PATH never runs, and one through a
332
- * held formula is a no-op. Both would be replanned forever. The pin rules that
333
- * plan no work (at-pin, unorderable, ahead-of-pin) come before the vetoes,
334
- * because ownership cannot make a "nothing to do" answer wrong.
248
+ * The vetoes (installed elsewhere, owner unknown, `held-back`) come before
249
+ * every rule that can yield `upgrade`, pinned or not: an upgrade through an
250
+ * installer that does not own the running file installs a second copy PATH
251
+ * never runs, and one through a held formula is a no-op. Both would be
252
+ * replanned forever. The pin rules that plan no work (at-pin, unorderable,
253
+ * ahead-of-pin) come before the vetoes, because ownership cannot make a
254
+ * "nothing to do" answer wrong.
335
255
  *
336
256
  * One ownership answer comes earlier still: a file KNOWN to be nobody's the
337
- * spec's installer reaches (`managed === false`) is `not-managed` even when no
338
- * version could be read. That is where an unreadable version usually comes
339
- * from, and "does not own it" is the sentence the user can act on. The pin
340
- * rules are not displaced by it — they need a version to compare, and there is
341
- * none.
257
+ * spec's installer reaches (`managed === false`) is `external` even when no
258
+ * version could be read. It was found and is correctly left alone, so a
259
+ * missing version is not a finding about it. The pin rules are not displaced by
260
+ * it — they need a version to compare, and there is none.
261
+ *
262
+ * The other answer that needs no version: an unpinned tool its installer owns
263
+ * (`managed === true`) and has judged itself (`behind` not null) — a brew dev
264
+ * build whose output does not parse, or several kegs with no table entry. The
265
+ * veto `held-back` still comes first, and a pinned tool still needs a version:
266
+ * without one it cannot be compared with the pin, so it plans nothing.
342
267
  */
343
268
  export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
344
- const status = classifyState(spec, facts);
345
- const shadowed = facts.present ? shadowNote(facts) : undefined;
346
- return shadowed ? { ...status, shadowed } : status;
347
- }
348
-
349
- function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
350
269
  const pin = pinOf(spec);
351
270
  const base = {
352
271
  name: spec.name,
@@ -354,6 +273,29 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
354
273
  latest: facts.latest,
355
274
  ...(pin ? { pin } : {}),
356
275
  };
276
+ // A row that can plan work carries its adoption with it.
277
+ const adopted = facts.adopted ? { adopted: facts.adopted } : {};
278
+ const heldBack = (note: string): ToolStatus => ({
279
+ ...base,
280
+ state: "unknown",
281
+ reason: "held-back",
282
+ target: null,
283
+ note,
284
+ });
285
+ /** The owning installer's own verdict, which needs no version of ours. */
286
+ const ownerVerdict = (behind: boolean): ToolStatus =>
287
+ behind
288
+ ? {
289
+ ...base,
290
+ ...adopted,
291
+ state: "upgrade",
292
+ reason: "behind",
293
+ target: facts.latest,
294
+ note: facts.latest
295
+ ? `${facts.latest} is available`
296
+ : "a newer version is available",
297
+ }
298
+ : { ...base, state: "current", reason: "up-to-date", target: null };
357
299
 
358
300
  // 1. Not on PATH.
359
301
  if (!facts.present) {
@@ -365,23 +307,35 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
365
307
  };
366
308
  }
367
309
 
368
- // 2. Present, owned by nobody the spec's installer is — and no version read.
369
- // The ownership answer is the actionable one: the version is unreadable
370
- // BECAUSE no installer's record covers the file (and check commands are off
371
- // while planning), so "could not read its version" would hide the reason.
372
- // Only a DEFINITE `false`; an undetermined owner (null) is not a finding.
310
+ // 2. Present, installed another way — and no version read. The version is
311
+ // unreadable BECAUSE no installer's record covers the file and magus has no
312
+ // version command for it; "could not read its version" would misreport a
313
+ // tool that is simply someone else's. Only a DEFINITE `false`; an
314
+ // undetermined owner (null) is not this answer.
373
315
  const installed = facts.installed;
374
316
  if (installed === null && facts.managed === false) {
375
317
  return {
376
318
  ...base,
377
- state: externalOr(pin),
378
- reason: "not-managed",
319
+ state: "external",
320
+ reason: "installed-elsewhere",
379
321
  target: null,
380
- note: notManagedNote(spec, facts),
381
322
  };
382
323
  }
383
324
 
384
- // 3. Present, but we could not read what version runs.
325
+ // 3. Present and owned, no version read, but the owner judged it itself and
326
+ // there is no pin to compare: the owner's verdict, behind the held-back veto.
327
+ if (
328
+ installed === null &&
329
+ facts.managed === true &&
330
+ !pin &&
331
+ facts.behind !== null
332
+ ) {
333
+ if (facts.heldBack) return heldBack(facts.heldBack);
334
+ return ownerVerdict(facts.behind);
335
+ }
336
+
337
+ // 4. Present, but we could not read what version runs. Every note states
338
+ // the tool's state, never where it lives or how magus looked.
385
339
  if (installed === null) {
386
340
  return {
387
341
  ...base,
@@ -389,13 +343,13 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
389
343
  reason: "version-unreadable",
390
344
  target: null,
391
345
  note: withFailure(
392
- `on PATH${facts.resolvedPath ? ` at ${facts.resolvedPath}` : ""}, but its version could not be read`,
346
+ "installed, but its version could not be read",
393
347
  facts.failure,
394
348
  ),
395
349
  };
396
350
  }
397
351
 
398
- // 4–6. Pin rules that plan no work.
352
+ // 5–7. Pin rules that plan no work.
399
353
  const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
400
354
  if (pin && vsPin === 0) {
401
355
  return { ...base, state: "current", reason: "at-pin", target: null };
@@ -420,36 +374,37 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
420
374
  };
421
375
  }
422
376
 
423
- // 7. The veto: the upgrade would not replace the file PATH runs. Known to be
424
- // another installer's file: `external`. Not known either way: `unknown`.
425
- if (facts.managed !== true) {
377
+ // 8. The vetoes: the upgrade would not replace the file PATH runs, or
378
+ // whether it would could not be told.
379
+ if (facts.managed === false) {
426
380
  return {
427
381
  ...base,
428
- state: facts.managed === false ? externalOr(pin) : "unknown",
429
- reason: "not-managed",
382
+ state: "external",
383
+ reason: "installed-elsewhere",
430
384
  target: null,
431
- note: notManagedNote(spec, facts),
432
385
  };
433
386
  }
434
-
435
- // 8. The veto: the installer will refuse to move it.
436
- if (facts.heldBack) {
387
+ if (facts.managed === null) {
437
388
  return {
438
389
  ...base,
439
390
  state: "unknown",
440
- reason: "held-back",
391
+ reason: "owner-unknown",
441
392
  target: null,
442
- note: facts.heldBack,
393
+ note: withFailure(
394
+ "could not tell which installer owns it",
395
+ facts.failure,
396
+ ),
443
397
  };
444
398
  }
445
399
 
446
- // 9–10. Pinned and behind the pin (every other pinned case returned above).
447
- // From here on a row can plan work, so an adoption must travel with it.
448
- const adopted = facts.adopted ? { adopted: facts.adopted } : {};
400
+ // 9. The veto: the installer will refuse to move it.
401
+ if (facts.heldBack) return heldBack(facts.heldBack);
402
+
403
+ // 10–11. Pinned and behind the pin (every other pinned case returned above).
449
404
  if (pin) {
450
- // Homebrew cannot install a chosen version, but the spec's installer is
451
- // not always the owner: an owner's own command (`uv self update <pin>`)
452
- // is already aimed at the pin.
405
+ // Homebrew cannot install a chosen version — but the spec's installer
406
+ // is not always the owner, and an owner's own command (`uv self update
407
+ // <pin>`, `bun install -g <pkg>@<pin>`) is already aimed at the pin.
453
408
  if (spec.via === "brew" && !facts.upgradeCommand) {
454
409
  return {
455
410
  ...base,
@@ -469,22 +424,8 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
469
424
  };
470
425
  }
471
426
 
472
- // 11–12. The owning installer's own verdict.
473
- if (facts.behind === true) {
474
- return {
475
- ...base,
476
- ...adopted,
477
- state: "upgrade",
478
- reason: "behind",
479
- target: facts.latest,
480
- note: facts.latest
481
- ? `${facts.latest} is available`
482
- : "a newer version is available",
483
- };
484
- }
485
- if (facts.behind === false) {
486
- return { ...base, state: "current", reason: "up-to-date", target: null };
487
- }
427
+ // 12. The owning installer's own verdict.
428
+ if (facts.behind !== null) return ownerVerdict(facts.behind);
488
429
 
489
430
  // 13. Nothing to compare against.
490
431
  const latest = facts.latest;
@@ -524,12 +465,25 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
524
465
 
525
466
  // ── Parsers (pure; tested against captured real output) ──────────────────────
526
467
 
527
- /** `brew list --versions <f>` → the last version token ("tmux 3.7c" → "3.7c"). */
528
- export function parseBrewListVersions(stdout: string): string | null {
468
+ /**
469
+ * `brew list --versions <f>` → EVERY version brew still has on disk
470
+ * ("tmux 3.7b 3.7c" → ["3.7b", "3.7c"]). With cleanup off brew keeps old kegs,
471
+ * and the line does not say which one is linked, so none is dropped: the
472
+ * caller matches the version PATH runs against all of them. [] = none listed.
473
+ */
474
+ export function parseBrewListVersions(stdout: string): string[] {
529
475
  const line = stdout.split("\n").find((l) => l.trim().length > 0);
530
- if (!line) return null;
531
- const tokens = line.trim().split(/\s+/);
532
- return tokens.length >= 2 ? (tokens[tokens.length - 1] ?? null) : null;
476
+ if (!line) return [];
477
+ return line.trim().split(/\s+/).slice(1);
478
+ }
479
+
480
+ /**
481
+ * A brew version without its leading `v` or its `_N` rebuild suffix: brew
482
+ * appends `_1` when it rebuilds a formula (`1.8.2_1`), while the tool itself
483
+ * prints `1.8.2`.
484
+ */
485
+ function stripBrewRevision(v: string): string {
486
+ return v.trim().replace(/^v/i, "").replace(/_\d+$/, "");
533
487
  }
534
488
 
535
489
  /**
@@ -583,22 +537,6 @@ export function parseBrewOutdatedJson(
583
537
  };
584
538
  }
585
539
 
586
- /**
587
- * The keg version from a realpath under `<cellar>/<formula>/<version>/`
588
- * (".../Cellar/tmux/3.7c/bin/tmux" → "3.7c"). This is the LINKED version, which
589
- * `brew list --versions` cannot give when several kegs are kept.
590
- */
591
- export function brewKegVersion(
592
- realPath: string,
593
- cellar: string,
594
- formula: string,
595
- ): string | null {
596
- const prefix = `${path.join(cellar, formula)}${path.sep}`;
597
- if (!realPath.startsWith(prefix)) return null;
598
- const segment = realPath.slice(prefix.length).split(path.sep)[0];
599
- return segment ? segment : null;
600
- }
601
-
602
540
  /** PEP 503 normalisation: lowercase, runs of `-_.` collapse to `-`. */
603
541
  export function normalizePythonPackageName(name: string): string {
604
542
  return name.toLowerCase().replace(/[-_.]+/g, "-");
@@ -737,17 +675,6 @@ export function versionFromNpmDistTags(body: unknown): string | undefined {
737
675
  return typeof latest === "string" ? latest : undefined;
738
676
  }
739
677
 
740
- /**
741
- * The first version-like token in a `--version` output ("tmux 3.7c" → "3.7c",
742
- * "2.1.268 (Claude Code)" → "2.1.268", "v2.0.0" → "2.0.0"). Needs at least one
743
- * dot, so a bare number (a year, a count) is not mistaken for a version.
744
- */
745
- export function parseVersionToken(output: string): string | null {
746
- const m = /(?:^|[^\w.])v?(\d+(?:\.\d+)+[0-9A-Za-z.+-]*)/.exec(output);
747
- if (!m?.[1]) return null;
748
- return m[1].replace(/[.+-]+$/, "");
749
- }
750
-
751
678
  /** true when `resolved` lies inside `ownerDir` (compared with a trailing separator). */
752
679
  export function isInside(resolved: string, ownerDir: string): boolean {
753
680
  const dir = path.resolve(ownerDir);
@@ -973,14 +900,6 @@ export interface ProbeOptions {
973
900
  * must use.
974
901
  */
975
902
  onResult?: (facts: ToolFacts, progress: ProbeProgress) => void;
976
- /**
977
- * Run a spec's `check` command when no installer record can give the
978
- * version. Default FALSE, and every planning caller leaves it so: `check`
979
- * is a string a third-party `plugin.json` supplied, and planning — which
980
- * includes `--dry-run` and `--check` — executes nothing a catalogue wrote.
981
- * See {@link versionFromCheck} for what is still bounded when it is on.
982
- */
983
- allowCheckCommand?: boolean;
984
903
  }
985
904
 
986
905
  // ── Probe session: memoised batch and location queries ──────────────────────
@@ -1008,11 +927,17 @@ function firstLine(text: string): string {
1008
927
  );
1009
928
  }
1010
929
 
930
+ /**
931
+ * What went wrong, in words. Never the installer's own stderr: brew's names
932
+ * lock files, Cellar folders and its own internal calls, and this text reaches
933
+ * every front end as a note.
934
+ */
1011
935
  function execFailure(cmd: string, r: ExecResult, timeoutMs: number): string {
1012
936
  if (r.missing) return `${cmd} is not installed`;
1013
937
  if (r.timedOut)
1014
938
  return `${cmd} timed out after ${Math.round(timeoutMs / 1000)}s`;
1015
- return firstLine(r.stderr) || `${cmd} exited ${r.code ?? "abnormally"}`;
939
+ if (r.stderr.trim()) return `${cmd} reported an error`;
940
+ return `${cmd} exited ${r.code ?? "abnormally"}`;
1016
941
  }
1017
942
 
1018
943
  /**
@@ -1026,8 +951,6 @@ class ProbeSession {
1026
951
  constructor(
1027
952
  readonly io: ToolProbeIO,
1028
953
  readonly timeoutMs: number,
1029
- /** See {@link versionFromCheck}. Off unless the caller opted in. */
1030
- readonly allowCheckCommand: boolean = false,
1031
954
  ) {}
1032
955
 
1033
956
  once<T>(key: string, work: () => Promise<T>): Promise<T> {
@@ -1058,12 +981,6 @@ class ProbeSession {
1058
981
  });
1059
982
  }
1060
983
 
1061
- brewPrefix(): Promise<Located> {
1062
- const fromEnv = this.io.env.HOMEBREW_PREFIX;
1063
- if (fromEnv) return Promise.resolve({ dir: fromEnv });
1064
- return this.locate("brew --prefix", "brew", ["--prefix"]);
1065
- }
1066
-
1067
984
  uvToolDir(): Promise<Located & { missing?: boolean }> {
1068
985
  return this.once("uv tool dir", async () => {
1069
986
  const r = await this.run("uv", ["tool", "dir"], PLAIN_ENV);
@@ -1135,9 +1052,6 @@ class ProbeSession {
1135
1052
 
1136
1053
  // ── Probing one tool ─────────────────────────────────────────────────────────
1137
1054
 
1138
- /** Characters that make a check command something only a shell could run. */
1139
- const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
1140
-
1141
1055
  /**
1142
1056
  * Refuse a name that could be read as an option, carries a NUL, or (for a
1143
1057
  * value joined into a path) climbs out of its directory. Null when fine.
@@ -1190,62 +1104,45 @@ async function packageJsonVersion(
1190
1104
  }
1191
1105
 
1192
1106
  /**
1193
- * Last resort for the installed version: run the tool's own check command and
1194
- * take the first version token.
1107
+ * The version of the file PATH runs, from magus's OWN version command for the
1108
+ * tool (`data/tool-versions.ts`), or null — when the table has no entry, the
1109
+ * command could not run, or its first line is not one the pattern anchors.
1195
1110
  *
1196
- * NOT REACHED WHILE PLANNING. `spec.check` is copied verbatim out of a
1197
- * marketplace plugin's `plugin.json` (`plugin-requires.ts`), so it is a string
1198
- * a third party wrote. Before this probe existed nothing in magus ever ran
1199
- * it; running it to PLAN would have meant `magus update`, `--dry-run`,
1200
- * `--check` in CI and merely opening the TUI's Tools screen each executing a
1201
- * catalogue-supplied argv — and `--dry-run` is documented as writing nothing.
1202
- * So the planner does not opt in: a version that could only come from a check
1203
- * command stays null, which classifies as `unknown / version-unreadable`,
1204
- * never as an upgrade. The one caller that opts in is the post-install
1205
- * read-back (`update-engine.ts`), where the user has already consented to run
1206
- * that tool's installer.
1111
+ * Runs while planning, `--dry-run` and `--check` included, because magus wrote
1112
+ * the argv: argv[0] is the bare `spec.name` (which {@link refusal} has already
1113
+ * vetted), resolved by the same PATH `resolveExecutable` reads, so it asks the
1114
+ * very file `resolvedPath` names. argv only, never a shell, under the session's
1115
+ * timeout, with stdio captured through the IO seam. A plugin's `check` string
1116
+ * is never consulted.
1207
1117
  *
1208
- * Where it does run it is bounded on every axis:
1209
- * - argv only, split on whitespace, never a shell (a `SHELL_META` character
1210
- * refuses the whole string);
1211
- * - argv[0] must be the tool itself — its name, its path on PATH, or the
1212
- * realpath behind it. The probe's purpose is "ask the running file its
1213
- * version", so anything else is refused unrun, and the version reported can
1214
- * only have come from the binary `resolvedPath` names;
1215
- * - the session's `timeoutMs` (8s by default), through the injected IO seam,
1216
- * which captures stdio rather than inheriting the terminal.
1118
+ * Memoised per tool, so the brew cross-check and the fallback in
1119
+ * {@link probeInto} run it at most once.
1217
1120
  */
1218
- async function versionFromCheck(
1121
+ function versionFromTable(
1219
1122
  s: ProbeSession,
1220
1123
  spec: ManagedTool,
1221
1124
  f: ToolFacts,
1222
- onPath: string,
1223
- ): Promise<void> {
1224
- if (!s.allowCheckCommand) {
1225
- noteFailure(f, "no installer record, and check commands are not run here");
1226
- return;
1227
- }
1228
- const check = spec.check?.trim() || `${spec.name} --version`;
1229
- if (SHELL_META.test(check)) {
1230
- noteFailure(f, "check command needs a shell; not run");
1231
- return;
1232
- }
1233
- const [cmd, ...args] = check.split(/\s+/);
1234
- if (!cmd) return;
1235
- if (cmd !== spec.name && cmd !== onPath && cmd !== f.resolvedPath) {
1236
- noteFailure(f, `check command does not run ${spec.name}; not run`);
1237
- return;
1238
- }
1239
- const r = await s.run(cmd, args, PLAIN_ENV);
1240
- if (r.missing || r.timedOut) {
1241
- noteFailure(f, execFailure(cmd, r, s.timeoutMs));
1242
- return;
1243
- }
1244
- const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
1245
- if (v) {
1246
- f.installed = v;
1247
- addSource(f, "check-command");
1248
- }
1125
+ ): Promise<string | null> {
1126
+ const cmd = toolVersionCommand(spec.name);
1127
+ if (!cmd) return Promise.resolve(null);
1128
+ return s
1129
+ .once<{ failure: string } | { version: string | null }>(
1130
+ `version ${spec.name}`,
1131
+ async () => {
1132
+ const r = await s.run(spec.name, [...cmd.args], PLAIN_ENV);
1133
+ if (r.missing || r.timedOut)
1134
+ return { failure: execFailure(spec.name, r, s.timeoutMs) };
1135
+ return { version: parseToolVersion(cmd, r.stdout, r.stderr) };
1136
+ },
1137
+ )
1138
+ .then((out) => {
1139
+ if ("failure" in out) {
1140
+ noteFailure(f, out.failure);
1141
+ return null;
1142
+ }
1143
+ if (out.version) addSource(f, "version-command");
1144
+ return out.version;
1145
+ });
1249
1146
  }
1250
1147
 
1251
1148
  /** The npm registry's `latest` dist-tag for a package. */
@@ -1304,31 +1201,127 @@ async function containedIn(
1304
1201
  return { inside: real !== null && isInside(resolvedPath, real), real };
1305
1202
  }
1306
1203
 
1204
+ /**
1205
+ * brew's own answer about THE FILE: `brew list <formula>` prints the
1206
+ * formula's installed files, one absolute path per line. True when the PATH
1207
+ * entry or its realpath is one of them; false when brew lists neither; null
1208
+ * (with the failure noted) when brew could not answer.
1209
+ */
1210
+ async function brewListsFile(
1211
+ s: ProbeSession,
1212
+ formula: string,
1213
+ onPath: string,
1214
+ resolvedPath: string,
1215
+ f?: ToolFacts,
1216
+ ): Promise<boolean | null> {
1217
+ const r = await s.run("brew", ["list", formula], BREW_ENV);
1218
+ if (r.timedOut || r.missing || r.code !== 0) {
1219
+ if (f) noteFailure(f, execFailure("brew", r, s.timeoutMs));
1220
+ return null;
1221
+ }
1222
+ const files = new Set(
1223
+ r.stdout
1224
+ .split("\n")
1225
+ .map((line) => line.trim())
1226
+ .filter(Boolean),
1227
+ );
1228
+ return files.has(resolvedPath) || files.has(onPath);
1229
+ }
1230
+
1231
+ /**
1232
+ * Ask brew whether it has the formula, and at which versions — never compute
1233
+ * where it installs. magus used to build `<prefix>/Cellar/<formula>` itself and
1234
+ * test the PATH binary's realpath against it, which is wrong for a tap formula
1235
+ * (`madappgang/tap/claudish` lives under `Cellar/claudish`) and reimplements a
1236
+ * layout that is brew's to define.
1237
+ *
1238
+ * - `brew` not installed: brew cannot have it — installed elsewhere. A Mac
1239
+ * without Homebrew must not report every declared tool as "not checked".
1240
+ * - exit 1 with nothing on stdout or stderr: brew does not have it (measured:
1241
+ * `zsh`, `madappgang/tap/claudish` when not installed).
1242
+ * - exit 1 that says anything, `Error: No available formula …` for an
1243
+ * untapped tap among them: brew failed, so the owner could not be
1244
+ * determined. brew exits 1 for almost every error, so silence is the only
1245
+ * exit 1 that means "not installed".
1246
+ * - a timeout or any other exit: the owner could not be determined.
1247
+ *
1248
+ * When brew has it and magus can read the tool's version, the version PATH
1249
+ * runs must be one of brew's versions; otherwise PATH runs another copy, and
1250
+ * upgrading brew's would not move it. A matching version is still not
1251
+ * ownership — a same-version copy elsewhere is another copy too. brew owns the
1252
+ * tool only if the file PATH runs is one brew lists as the formula's: `brew
1253
+ * list <formula>` prints its installed files, one absolute path per line, and
1254
+ * the PATH entry or its realpath must be among them ({@link brewListsFile}).
1255
+ * Comparing a path against the list brew printed asks brew; magus builds no
1256
+ * Cellar path. When the version cannot be read (no table entry, or output the
1257
+ * pattern does not anchor), brew listing the file stands: managed, judged by
1258
+ * brew's own `outdated` verdict, its version shown only when brew lists exactly
1259
+ * one and the table does not know the tool.
1260
+ */
1307
1261
  async function probeBrew(
1308
1262
  s: ProbeSession,
1309
1263
  spec: ManagedTool,
1310
1264
  f: ToolFacts,
1265
+ pathEntry: string,
1311
1266
  resolvedPath: string,
1312
1267
  ): Promise<void> {
1313
1268
  const formula = spec.formula ?? spec.name;
1314
- // A tap formula ("user/tap/f") keeps its keg under the short name, so the
1315
- // full name never matched and a Homebrew mnemex read as installed outside it.
1316
- const keg = formula.slice(formula.lastIndexOf("/") + 1);
1317
- const prefix = await s.brewPrefix();
1318
- if ("failure" in prefix) {
1269
+ const list = await s.run("brew", ["list", "--versions", formula], BREW_ENV);
1270
+ if (list.missing) {
1271
+ f.managed = false;
1272
+ return;
1273
+ }
1274
+ const silent = !list.stdout.trim() && !list.stderr.trim();
1275
+ if (
1276
+ list.timedOut ||
1277
+ (list.code !== 0 && list.code !== 1) ||
1278
+ (list.code === 1 && !silent)
1279
+ ) {
1319
1280
  f.managed = null;
1320
- noteFailure(f, prefix.failure);
1281
+ noteFailure(f, execFailure("brew", list, s.timeoutMs));
1321
1282
  return;
1322
1283
  }
1323
- const cellar =
1324
- (await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
1325
- path.join(prefix.dir, "Cellar");
1326
- const ownerDir = path.join(cellar, keg);
1327
- f.owner = `brew (Cellar ${ownerDir})`;
1328
- f.managed = isInside(resolvedPath, ownerDir);
1329
- if (!f.managed) return;
1330
- f.installed = brewKegVersion(resolvedPath, cellar, keg);
1331
- if (f.installed) addSource(f, "brew");
1284
+ const kegs = list.code === 0 ? parseBrewListVersions(list.stdout) : [];
1285
+ if (kegs.length === 0) {
1286
+ f.managed = false;
1287
+ return;
1288
+ }
1289
+ addSource(f, "brew");
1290
+
1291
+ const inTable = toolVersionCommand(spec.name) !== undefined;
1292
+ const onPath = inTable ? await versionFromTable(s, spec, f) : null;
1293
+ if (onPath !== null) {
1294
+ f.installed = onPath;
1295
+ const hit = kegs.some(
1296
+ (k) => compareToolVersions(stripBrewRevision(k), onPath) === 0,
1297
+ );
1298
+ // A version none of brew's is already another copy: no file list needed.
1299
+ if (!hit) {
1300
+ f.managed = false;
1301
+ return;
1302
+ }
1303
+ }
1304
+
1305
+ // A matching version is not ownership; brew listing the file is.
1306
+ const owns = await brewListsFile(s, formula, pathEntry, resolvedPath, f);
1307
+ if (owns !== true) {
1308
+ f.managed = owns;
1309
+ return;
1310
+ }
1311
+
1312
+ if (onPath !== null) {
1313
+ f.managed = true;
1314
+ } else {
1315
+ // brew's answer stands, and its `outdated` verdict below says whether it
1316
+ // is behind. The version is shown only when the table does not know the
1317
+ // tool and brew lists exactly one: with several kegs kept, brew list
1318
+ // cannot say which is linked, and a tool whose own answer does not parse
1319
+ // (`tmux next-3.7` from a `HEAD-…` keg) is not the version brew names.
1320
+ // Never guess.
1321
+ f.managed = true;
1322
+ const only = !inTable && kegs.length === 1 ? kegs[0] : undefined;
1323
+ f.installed = only ? stripBrewRevision(only) : null;
1324
+ }
1332
1325
 
1333
1326
  const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
1334
1327
  if (r.code !== 0) {
@@ -1360,7 +1353,6 @@ async function probePip(
1360
1353
  const uvDir = await s.uvToolDir();
1361
1354
  if ("dir" in uvDir) {
1362
1355
  const ownerDir = path.join(uvDir.dir, norm);
1363
- f.owner = `uv (tool dir ${uvDir.dir})`;
1364
1356
  const { inside, real } = await containedIn(s, resolvedPath, ownerDir);
1365
1357
  // Adoption: `uv tool install --force` overwrites the very file PATH runs
1366
1358
  // when it sits in uv's bin dir, whoever put it there.
@@ -1374,7 +1366,7 @@ async function probePip(
1374
1366
  // plan row is an ordinary upgrade, so without this it never says that
1375
1367
  // the owning installer changes — and pipx, say, goes on claiming a
1376
1368
  // venv nothing runs until its next `upgrade-all` writes back over it.
1377
- f.adopted = `installed by another tool in uv's bin dir (${bin.dir}); upgrading adopts it into uv`;
1369
+ f.adopted = "installed another way; upgrading through uv takes it over";
1378
1370
  addSource(f, "uv-adopted");
1379
1371
  }
1380
1372
 
@@ -1444,7 +1436,6 @@ async function probePip(
1444
1436
  const binDir =
1445
1437
  s.io.env.PIPX_BIN_DIR ?? path.join(s.io.homedir, ".local", "bin");
1446
1438
  const ownerDir = path.join(home, "venvs", norm);
1447
- f.owner = `pipx (venvs ${path.join(home, "venvs")})`;
1448
1439
  const { inside } = await containedIn(s, resolvedPath, ownerDir);
1449
1440
  f.managed =
1450
1441
  inside || path.resolve(path.dirname(onPath)) === path.resolve(binDir);
@@ -1477,330 +1468,6 @@ async function readNodePackage(
1477
1468
  if (!pinned) await npmLatest(s, pkg, f);
1478
1469
  }
1479
1470
 
1480
- /** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
1481
- function bunGlobalRoot(io: ToolProbeIO): string {
1482
- return path.join(
1483
- io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
1484
- "install",
1485
- "global",
1486
- "node_modules",
1487
- );
1488
- }
1489
-
1490
- /** The package a path names, from its segments under `node_modules`. */
1491
- function packageFromSegments(rel: string): string | null {
1492
- const [first, second] = rel.split(path.sep);
1493
- if (!first || first.startsWith(".")) return null;
1494
- if (!first.startsWith("@")) return first;
1495
- return second ? `${first}/${second}` : null;
1496
- }
1497
-
1498
- /**
1499
- * The bun or npm global package that holds `resolvedPath`, read from the path
1500
- * alone. Null when the file lies in neither tree (a Homebrew keg included:
1501
- * `npmGlobalPrefixOf` refuses one).
1502
- */
1503
- async function nodeGlobalOwner(
1504
- s: ProbeSession,
1505
- resolvedPath: string,
1506
- ): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
1507
- const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
1508
- if (bunRoot && isInside(resolvedPath, bunRoot)) {
1509
- const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
1510
- return pkg ? { via: "bun", pkg } : null;
1511
- }
1512
- const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
1513
- const at = resolvedPath.lastIndexOf(marker);
1514
- if (at < 0) return null;
1515
- const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
1516
- return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
1517
- ? { via: "npm", pkg }
1518
- : null;
1519
- }
1520
-
1521
- /**
1522
- * The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
1523
- * read from the path alone. `probeBrew` then checks that this Cellar is brew's.
1524
- */
1525
- function kegFormulaOf(resolvedPath: string): string | null {
1526
- const parts = resolvedPath.split(path.sep);
1527
- const at = parts.lastIndexOf("Cellar");
1528
- return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
1529
- }
1530
-
1531
- /**
1532
- * The spec as the installer that actually holds `resolvedPath` would state it,
1533
- * or null when that is the spec's own installer or none magus drives.
1534
- */
1535
- async function actualOwnerSpec(
1536
- s: ProbeSession,
1537
- spec: ManagedTool,
1538
- resolvedPath: string,
1539
- ): Promise<ManagedTool | null> {
1540
- const base = {
1541
- name: spec.name,
1542
- ...(spec.check ? { check: spec.check } : {}),
1543
- ...(spec.version ? { version: spec.version } : {}),
1544
- };
1545
- const node = await nodeGlobalOwner(s, resolvedPath);
1546
- if (node) {
1547
- return node.via === spec.via
1548
- ? null
1549
- : { ...base, via: node.via, package: node.pkg };
1550
- }
1551
- const formula = kegFormulaOf(resolvedPath);
1552
- // Homebrew takes no version, so it cannot honour a pin: such a tool stays
1553
- // "installed outside", which the classifier reports as unknown.
1554
- if (!formula || spec.via === "brew" || spec.version) return null;
1555
- return { ...base, via: "brew", formula };
1556
- }
1557
-
1558
- /**
1559
- * The spec's installer does not own the file PATH runs, but bun's or npm's
1560
- * global tree or a Homebrew keg does: re-read the tool through THAT installer.
1561
- *
1562
- * A plugin's `requires` lists its install options in preference order for a
1563
- * FRESH install, and magus plans through the first. That order says how to
1564
- * install a missing tool, not which owner an installed one must have. A
1565
- * mnemex from `bun add -g` or from Homebrew has a version, a registry and an
1566
- * upgrade command like any other. Reading it as "installed outside" the
1567
- * first option's installer showed a settled, upgradeable tool as one magus
1568
- * could not touch.
1569
- *
1570
- * The catalogue's tools already follow how a tool IS installed
1571
- * (`specForCatalogTool`); this gives a plugin's requirements the same rule.
1572
- * The upgrade command is carried on the facts, so `magus update` and the TUI
1573
- * run the owner's command rather than the spec's.
1574
- */
1575
- async function adoptActualOwner(
1576
- s: ProbeSession,
1577
- spec: ManagedTool,
1578
- f: ToolFacts,
1579
- resolvedPath: string,
1580
- ): Promise<void> {
1581
- const actual = await actualOwnerSpec(s, spec, resolvedPath);
1582
- if (!actual) return;
1583
- const read = blankFacts(spec.name);
1584
- if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
1585
- else await probeNodeGlobal(s, actual, read, resolvedPath);
1586
- if (read.managed !== true) return;
1587
- f.managed = true;
1588
- f.owner = read.owner;
1589
- f.installed = read.installed;
1590
- f.latest = read.latest;
1591
- f.behind = read.behind;
1592
- // What the spec's installer could not answer is moot once the owner is
1593
- // known: only the owner's own failure describes this tool.
1594
- f.failure = read.failure;
1595
- for (const source of read.sources) addSource(f, source);
1596
- f.upgradeCommand =
1597
- read.upgradeCommand ??
1598
- (spec.version
1599
- ? (binPinCommand(actual) ?? undefined)
1600
- : binUpgradeCommand(actual));
1601
- }
1602
-
1603
- /**
1604
- * Standalone installers that update their own copy in place, keyed by the
1605
- * app their receipt names: the arguments after the program, and the PyPI
1606
- * project that publishes it. uv's own installer writes a receipt and ships
1607
- * `uv self update`, so a uv from it is as upgradeable as one from Homebrew.
1608
- */
1609
- const SELF_UPDATERS: Readonly<
1610
- Record<string, { args: readonly string[]; pypi: string }>
1611
- > = {
1612
- uv: { args: ["self", "update"], pypi: "uv" },
1613
- };
1614
-
1615
- interface InstallerReceipt {
1616
- app: string;
1617
- version: string;
1618
- file: string;
1619
- }
1620
-
1621
- /**
1622
- * The receipt a standalone installer (cargo-dist's format, which uv's uses)
1623
- * left for `name`, when it covers `resolvedPath`:
1624
- * `$XDG_CONFIG_HOME/<name>/<name>-receipt.json`, whose `install_prefix` holds
1625
- * the file and whose `binaries` list it. A receipt for another folder is a
1626
- * different copy and says nothing about this one.
1627
- */
1628
- async function installerReceipt(
1629
- s: ProbeSession,
1630
- name: string,
1631
- resolvedPath: string,
1632
- ): Promise<InstallerReceipt | null> {
1633
- const config = s.io.env.XDG_CONFIG_HOME || path.join(s.io.homedir, ".config");
1634
- const file = path.join(config, name, `${name}-receipt.json`);
1635
- const body = await s.io.readJson(file);
1636
- if (!body || typeof body !== "object") return null;
1637
- const r = body as {
1638
- version?: unknown;
1639
- install_prefix?: unknown;
1640
- binaries?: unknown;
1641
- source?: { app_name?: unknown } | null;
1642
- };
1643
- if (typeof r.version !== "string" || typeof r.install_prefix !== "string")
1644
- return null;
1645
- if (
1646
- !Array.isArray(r.binaries) ||
1647
- !r.binaries.includes(path.basename(resolvedPath))
1648
- )
1649
- return null;
1650
- const prefix = (await s.io.realpath(r.install_prefix)) ?? r.install_prefix;
1651
- if (!isInside(resolvedPath, prefix)) return null;
1652
- const app = typeof r.source?.app_name === "string" ? r.source.app_name : name;
1653
- return { app, version: r.version, file };
1654
- }
1655
-
1656
- /**
1657
- * The file is a standalone installer's: its receipt gives the version, and
1658
- * when that installer updates itself ({@link SELF_UPDATERS}) it is the owner
1659
- * `magus update` drives. Otherwise the tool stays KEPT, but its row can say
1660
- * who installed it and which version runs.
1661
- */
1662
- async function adoptOwnInstaller(
1663
- s: ProbeSession,
1664
- spec: ManagedTool,
1665
- f: ToolFacts,
1666
- resolvedPath: string,
1667
- ): Promise<void> {
1668
- const receipt = await installerReceipt(s, spec.name, resolvedPath);
1669
- if (!receipt) return;
1670
- const ownerName = `${receipt.app}'s own installer`;
1671
- f.installed = receipt.version;
1672
- addSource(f, "installer-receipt");
1673
- const updater = SELF_UPDATERS[receipt.app];
1674
- if (!updater) {
1675
- f.managed = false;
1676
- f.ownerName = ownerName;
1677
- return;
1678
- }
1679
- f.managed = true;
1680
- f.owner = `${ownerName} (receipt ${receipt.file})`;
1681
- f.latest = null;
1682
- f.behind = null;
1683
- // What the spec's installer could not answer is moot: the owner is known.
1684
- f.failure = undefined;
1685
- const pin = pinOf(spec);
1686
- f.upgradeCommand = {
1687
- cmd: spec.name,
1688
- args: [...updater.args, ...(pin ? [pin] : [])],
1689
- };
1690
- if (!pin) await pypiLatest(s, updater.pypi, f);
1691
- }
1692
-
1693
- /**
1694
- * Who installed a file no installer magus drives owns, as the user knows it,
1695
- * or undefined when the path does not say.
1696
- */
1697
- async function nameOwner(
1698
- s: ProbeSession,
1699
- resolvedPath: string,
1700
- ): Promise<string | undefined> {
1701
- if (resolvedPath.split(path.sep).includes("Caskroom"))
1702
- return "a Homebrew cask";
1703
- if (kegFormulaOf(resolvedPath)) return "Homebrew";
1704
- const node = await nodeGlobalOwner(s, resolvedPath);
1705
- if (node) return node.via;
1706
- const cargoHome = s.io.env.CARGO_HOME || path.join(s.io.homedir, ".cargo");
1707
- if (isInside(resolvedPath, path.join(cargoHome, "bin"))) return "cargo";
1708
- return undefined;
1709
- }
1710
-
1711
- /**
1712
- * Where an operating-system program is safe to run for `--version`. Not
1713
- * macOS's /usr/bin: several programs there are Xcode stubs that open an
1714
- * install dialog when run on a machine without the developer tools.
1715
- */
1716
- function osVersionDirs(): string[] {
1717
- return process.platform === "darwin" ? ["/bin", "/sbin"] : SYSTEM_DIRS;
1718
- }
1719
-
1720
- /**
1721
- * The version of a program the operating system ships, from its own
1722
- * `--version`. No installer keeps a record of these, and the file is the OS
1723
- * vendor's, not a string a catalogue wrote, so running it is safe.
1724
- */
1725
- async function versionFromOs(
1726
- s: ProbeSession,
1727
- f: ToolFacts,
1728
- resolvedPath: string,
1729
- ): Promise<void> {
1730
- if (!osVersionDirs().some((dir) => isInside(resolvedPath, dir))) return;
1731
- const r = await s.run(resolvedPath, ["--version"], PLAIN_ENV);
1732
- if (r.missing || r.timedOut) return;
1733
- const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
1734
- if (v) {
1735
- f.installed = v;
1736
- addSource(f, "os-version");
1737
- }
1738
- }
1739
-
1740
- /** `dir` with the home folder written as `~`, for a line the user reads. */
1741
- function homeShort(io: ToolProbeIO, dir: string): string {
1742
- return isInside(dir, io.homedir)
1743
- ? path.join("~", path.relative(io.homedir, dir))
1744
- : dir;
1745
- }
1746
-
1747
- /** Owner and version of a copy PATH does not run, read from its path. */
1748
- async function describeCopy(
1749
- s: ProbeSession,
1750
- name: string,
1751
- real: string,
1752
- ): Promise<{ owner: string; version: string | null }> {
1753
- const parts = real.split(path.sep);
1754
- for (const [marker, owner] of [
1755
- ["Cellar", "Homebrew"],
1756
- ["Caskroom", "a Homebrew cask"],
1757
- ] as const) {
1758
- const at = parts.lastIndexOf(marker);
1759
- if (at >= 0 && parts.length > at + 2)
1760
- return { owner, version: parts[at + 2] ?? null };
1761
- }
1762
- const receipt = await installerReceipt(s, name, real);
1763
- if (receipt)
1764
- return {
1765
- owner: `${receipt.app}'s own installer`,
1766
- version: receipt.version,
1767
- };
1768
- const node = await nodeGlobalOwner(s, real);
1769
- if (node) return { owner: node.via, version: readPackageVersionForBin(real) };
1770
- if (isSystemPath(real))
1771
- return { owner: "the operating system", version: null };
1772
- return { owner: "another installer", version: null };
1773
- }
1774
-
1775
- /**
1776
- * The other copies of the tool on PATH. The first one wins, and whatever
1777
- * comes later is never run: a newer copy there is a surprise only the user
1778
- * can settle, by removing one or reordering PATH.
1779
- */
1780
- async function findOtherCopies(
1781
- s: ProbeSession,
1782
- name: string,
1783
- f: ToolFacts,
1784
- onPath: string,
1785
- resolvedPath: string,
1786
- ): Promise<void> {
1787
- f.pathDir = homeShort(s.io, path.dirname(onPath));
1788
- if (!s.io.resolveAllExecutables) return;
1789
- const seen = new Set([resolvedPath]);
1790
- const copies: OtherCopy[] = [];
1791
- for (const candidate of await s.io.resolveAllExecutables(name)) {
1792
- if (candidate === onPath) continue;
1793
- const real = await s.io.realpath(candidate);
1794
- if (!real || seen.has(real)) continue;
1795
- seen.add(real);
1796
- copies.push({
1797
- ...(await describeCopy(s, name, real)),
1798
- dir: homeShort(s.io, path.dirname(candidate)),
1799
- });
1800
- }
1801
- if (copies.length > 0) f.otherCopies = copies;
1802
- }
1803
-
1804
1471
  async function probeNodeGlobal(
1805
1472
  s: ProbeSession,
1806
1473
  spec: ManagedTool,
@@ -1812,7 +1479,6 @@ async function probeNodeGlobal(
1812
1479
  const pinned = pin !== undefined;
1813
1480
  if (spec.via === "bun") {
1814
1481
  const root = bunGlobalRoot(s.io);
1815
- f.owner = `bun (global ${root})`;
1816
1482
  const { inside, real } = await containedIn(
1817
1483
  s,
1818
1484
  resolvedPath,
@@ -1827,7 +1493,6 @@ async function probeNodeGlobal(
1827
1493
  // The root the npm first on PATH installs into.
1828
1494
  const npmRoot = await s.npmRoot();
1829
1495
  if ("dir" in npmRoot) {
1830
- f.owner = `npm (root ${npmRoot.dir})`;
1831
1496
  const { inside, real } = await containedIn(
1832
1497
  s,
1833
1498
  resolvedPath,
@@ -1842,7 +1507,7 @@ async function probeNodeGlobal(
1842
1507
 
1843
1508
  // Not under that root — but perhaps under ANOTHER npm's. The file's own
1844
1509
  // path names the prefix that owns it, and `--prefix` upgrades exactly that
1845
- // tree, so this is npm-managed, never the "second copy" veto.
1510
+ // tree, so this is npm-managed, never installed elsewhere.
1846
1511
  const prefix = npmGlobalPrefixOf(resolvedPath, pkg);
1847
1512
  if (prefix === null) {
1848
1513
  if ("failure" in npmRoot) {
@@ -1852,7 +1517,6 @@ async function probeNodeGlobal(
1852
1517
  return;
1853
1518
  }
1854
1519
  f.managed = true;
1855
- f.owner = `npm (prefix ${prefix})`;
1856
1520
  // Reached with the default root only when containment could not resolve the
1857
1521
  // package directory; the plain command is right then, and --prefix is noise.
1858
1522
  if (!(await isNpmDefaultPrefix(s, npmRoot, prefix))) {
@@ -1926,7 +1590,7 @@ async function isNativeLauncher(
1926
1590
  * on running the other file at the old version. Every run would find it behind
1927
1591
  * and plan the same no-op again. So the check is on `onPath`, the unresolved
1928
1592
  * entry, and a copy that is not the launcher falls through to the spec's own
1929
- * installer — which reports it as a second copy it does not own.
1593
+ * installer — which reports it installed elsewhere.
1930
1594
  *
1931
1595
  * True when it recognised the layout; `f` then holds the whole answer.
1932
1596
  */
@@ -1959,7 +1623,6 @@ async function probeClaudeNative(
1959
1623
 
1960
1624
  const pin = pinOf(spec);
1961
1625
  f.managed = true;
1962
- f.owner = `Claude Code's native installer (${dir})`;
1963
1626
  f.installed = version;
1964
1627
  addSource(f, "version-dir");
1965
1628
  f.upgradeCommand = claudeNativeUpgradeCommand(pin);
@@ -1997,7 +1660,6 @@ async function probeGo(
1997
1660
  return;
1998
1661
  }
1999
1662
  const realBin = (await s.io.realpath(gobin.dir)) ?? gobin.dir;
2000
- f.owner = `go (GOBIN ${realBin})`;
2001
1663
  const sameModule =
2002
1664
  want === info.module ||
2003
1665
  want === info.path ||
@@ -2050,7 +1712,7 @@ async function probeInto(
2050
1712
  const via: BinInstaller = spec.via;
2051
1713
  switch (via) {
2052
1714
  case "brew":
2053
- await probeBrew(s, spec, f, resolvedPath);
1715
+ await probeBrew(s, spec, f, onPath, resolvedPath);
2054
1716
  break;
2055
1717
  case "pip":
2056
1718
  await probePip(s, spec, f, onPath, resolvedPath, pinned);
@@ -2064,65 +1726,255 @@ async function probeInto(
2064
1726
  break;
2065
1727
  }
2066
1728
 
2067
- // Not the spec's installer's file, or it could not tell (npm with no `npm`
2068
- // to ask), but the path names bun's, npm's or Homebrew's tree: then that is
2069
- // the owner, and it can upgrade the file PATH runs.
2070
- if (f.managed !== true) await adoptActualOwner(s, spec, f, resolvedPath);
2071
- // Or a standalone installer's, which may update itself (`uv self update`).
2072
- if (f.managed !== true) await adoptOwnInstaller(s, spec, f, resolvedPath);
1729
+ // Not the spec's installer's file, or it could not tell: perhaps another
1730
+ // installer magus drives has it, and then that installer updates it.
1731
+ if (f.managed !== true)
1732
+ await adoptActualOwner(s, spec, f, onPath, resolvedPath);
1733
+
1734
+ await countOtherCopies(s, spec.name, f, onPath, resolvedPath);
1735
+
1736
+ // The installer's own record was silent — usually because the tool on PATH
1737
+ // came some other way (a pnpm global, a system copy, its own installer).
1738
+ // Ask the running file, through magus's own version command. A tool magus
1739
+ // has no command for shows no version: never a guess.
1740
+ if (f.installed === null) f.installed = await versionFromTable(s, spec, f);
1741
+ }
1742
+
1743
+ // ── Adoption: the installer that actually has the tool ──────────────────────
1744
+ //
1745
+ // A plugin's `requires` lists install options in preference order for a FRESH
1746
+ // install, and magus plans through the first. That order says how to install a
1747
+ // missing tool, not which installer an installed one must have: a mnemex from
1748
+ // `bun add -g` or from Homebrew has a version, a registry and an upgrade
1749
+ // command like any other. So when the spec's installer does not have the file
1750
+ // PATH runs, magus asks the other installers it can drive, each the same way
1751
+ // its own probe does, and plans the update through the one that has it. Only
1752
+ // a tool none of them has is `external`.
2073
1753
 
2074
- // The method's own record was silent. Before anything runs, read what the
2075
- // installed files already say about themselves.
2076
- if (f.installed === null) versionFromFiles(f, resolvedPath);
1754
+ /** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
1755
+ function bunGlobalRoot(io: ToolProbeIO): string {
1756
+ return path.join(
1757
+ io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
1758
+ "install",
1759
+ "global",
1760
+ "node_modules",
1761
+ );
1762
+ }
2077
1763
 
2078
- // A program the operating system ships keeps no record anywhere: ask it.
2079
- if (f.installed === null) await versionFromOs(s, f, resolvedPath);
1764
+ /** The package directory `rel` (relative to a `node_modules`) starts with. */
1765
+ function packageFromSegments(rel: string): string | null {
1766
+ const [first, second] = rel.split(path.sep);
1767
+ if (!first || first.startsWith(".") || first === "..") return null;
1768
+ if (!first.startsWith("@")) return first;
1769
+ return second ? `${first}/${second}` : null;
1770
+ }
2080
1771
 
2081
- // Still nothing: read the running file itself — but only where the caller
2082
- // opted into running a check command (see there).
2083
- if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
1772
+ /** bun's global tree (realpath'd) when `resolvedPath` is inside it, else null. */
1773
+ async function inBunGlobalTree(
1774
+ s: ProbeSession,
1775
+ resolvedPath: string,
1776
+ ): Promise<string | null> {
1777
+ const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
1778
+ return bunRoot && isInside(resolvedPath, bunRoot) ? bunRoot : null;
1779
+ }
2084
1780
 
2085
- // A KEPT row names who installed it, not the installer magus would use.
2086
- if (f.managed === false && !f.ownerName) {
2087
- const who = await nameOwner(s, resolvedPath);
2088
- if (who) f.ownerName = who;
1781
+ /**
1782
+ * The bun or npm spec whose global tree holds `resolvedPath`, or null. bun's
1783
+ * tree is the one bun installs globals into; an npm prefix is recognised the
1784
+ * way the npm probe recognises one ({@link npmGlobalPrefixOf}, which refuses a
1785
+ * Homebrew keg's private `node_modules`). Either way the package's own
1786
+ * `package.json` is then read by {@link probeNodeGlobal}, as for any bun or
1787
+ * npm spec.
1788
+ */
1789
+ async function nodeOwnerSpec(
1790
+ s: ProbeSession,
1791
+ spec: ManagedTool,
1792
+ resolvedPath: string,
1793
+ ): Promise<ManagedTool | null> {
1794
+ const base = {
1795
+ name: spec.name,
1796
+ ...(spec.version ? { version: spec.version } : {}),
1797
+ };
1798
+ const bunRoot = await inBunGlobalTree(s, resolvedPath);
1799
+ if (bunRoot) {
1800
+ const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
1801
+ return pkg && spec.via !== "bun"
1802
+ ? { ...base, via: "bun", package: pkg }
1803
+ : null;
2089
1804
  }
1805
+ if (spec.via === "npm") return null;
1806
+ const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
1807
+ const at = resolvedPath.lastIndexOf(marker);
1808
+ if (at < 0) return null;
1809
+ const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
1810
+ return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
1811
+ ? { ...base, via: "npm", package: pkg }
1812
+ : null;
1813
+ }
1814
+
1815
+ /**
1816
+ * Standalone installers that update their own copy in place, keyed by the app
1817
+ * their receipt names: the arguments after the program, and the PyPI project
1818
+ * that publishes it. uv's own installer writes a receipt and ships
1819
+ * `uv self update`, so a uv from it is as upgradeable as one from Homebrew.
1820
+ */
1821
+ const SELF_UPDATERS: Readonly<
1822
+ Record<string, { args: readonly string[]; pypi: string }>
1823
+ > = {
1824
+ uv: { args: ["self", "update"], pypi: "uv" },
1825
+ };
2090
1826
 
2091
- await findOtherCopies(s, spec.name, f, onPath, resolvedPath);
1827
+ /**
1828
+ * The receipt a standalone installer (cargo-dist's format, which uv's uses)
1829
+ * left for `name`, when it covers `resolvedPath`. The receipt is that
1830
+ * installer's OWN record: it names the folder it installed into
1831
+ * (`install_prefix`) and the programs it put there (`binaries`). It is the
1832
+ * only record such an installer keeps — there is no command to ask — so magus
1833
+ * reads it, and tests the file against the folder the receipt names, exactly
1834
+ * as the npm probe tests against `npm root -g`. A receipt for another folder
1835
+ * is another copy and says nothing about this one.
1836
+ */
1837
+ async function installerReceipt(
1838
+ s: ProbeSession,
1839
+ name: string,
1840
+ resolvedPath: string,
1841
+ ): Promise<{ app: string; version: string } | null> {
1842
+ const config = s.io.env.XDG_CONFIG_HOME || path.join(s.io.homedir, ".config");
1843
+ const body = await s.io.readJson(
1844
+ path.join(config, name, `${name}-receipt.json`),
1845
+ );
1846
+ if (!body || typeof body !== "object") return null;
1847
+ const r = body as {
1848
+ version?: unknown;
1849
+ install_prefix?: unknown;
1850
+ binaries?: unknown;
1851
+ source?: { app_name?: unknown } | null;
1852
+ };
1853
+ if (typeof r.version !== "string" || typeof r.install_prefix !== "string")
1854
+ return null;
1855
+ if (
1856
+ !Array.isArray(r.binaries) ||
1857
+ !r.binaries.includes(path.basename(resolvedPath))
1858
+ )
1859
+ return null;
1860
+ const prefix = (await s.io.realpath(r.install_prefix)) ?? r.install_prefix;
1861
+ if (!isInside(resolvedPath, prefix)) return null;
1862
+ const app = typeof r.source?.app_name === "string" ? r.source.app_name : name;
1863
+ return { app, version: r.version };
2092
1864
  }
2093
1865
 
2094
1866
  /**
2095
- * The installed version, from files next to the binary — never by running it.
2096
- *
2097
- * The method's record is keyed to where THIS spec's installer keeps things, so
2098
- * it is silent whenever the tool on PATH came some other way: magus installed
2099
- * with bun while the catalogue says npm, a pnpm or yarn global. (An npm global
2100
- * under another prefix and Claude Code's native installer are recognised as
2101
- * OWNED before this runs.) Each of those still says what it is. A binary inside
2102
- * `node_modules` belongs to a package whose `package.json` carries the version;
2103
- * a versioned install names the file after it. Neither says who OWNS the file,
2104
- * so `managed` is left as the method found it — the classifier still refuses to
2105
- * plan an upgrade it cannot land — but the row can show a version instead of
2106
- * "could not be read".
2107
- *
2108
- * Reads the real filesystem rather than the IO seam: `resolvedPath` already
2109
- * came through the seam's `realpath`, and a path a test invented does not
2110
- * exist, so this answers null there exactly as a missing record does.
1867
+ * The file is a standalone installer's: its receipt gives the version, and
1868
+ * when that installer updates itself ({@link SELF_UPDATERS}) it is the owner
1869
+ * `magus update` drives — latest from PyPI, `uv self update [<pin>]`.
1870
+ * Otherwise the tool is installed elsewhere, with the receipt's version.
1871
+ * True when a receipt answered, so no other installer is asked.
2111
1872
  */
2112
- function versionFromFiles(f: ToolFacts, resolvedPath: string): void {
2113
- if (resolvedPath.split(path.sep).includes("node_modules")) {
2114
- const version = readPackageVersionForBin(resolvedPath);
2115
- if (version) {
2116
- f.installed = version;
2117
- addSource(f, "package-json");
2118
- return;
2119
- }
1873
+ async function adoptOwnInstaller(
1874
+ s: ProbeSession,
1875
+ spec: ManagedTool,
1876
+ f: ToolFacts,
1877
+ resolvedPath: string,
1878
+ ): Promise<boolean> {
1879
+ const receipt = await installerReceipt(s, spec.name, resolvedPath);
1880
+ if (!receipt) return false;
1881
+ f.installed = receipt.version;
1882
+ addSource(f, "installer-receipt");
1883
+ const updater = SELF_UPDATERS[receipt.app];
1884
+ if (!updater) {
1885
+ f.managed = false;
1886
+ return true;
2120
1887
  }
2121
- const version = readVersionDirForBin(resolvedPath);
2122
- if (version) {
2123
- f.installed = version;
2124
- addSource(f, "version-dir");
1888
+ f.managed = true;
1889
+ f.latest = null;
1890
+ f.behind = null;
1891
+ // What the spec's installer could not answer is moot: the owner is known.
1892
+ f.failure = undefined;
1893
+ const pin = pinOf(spec);
1894
+ f.upgradeCommand = {
1895
+ cmd: spec.name,
1896
+ args: [...updater.args, ...(pin ? [pin] : [])],
1897
+ };
1898
+ if (!pin) await pypiLatest(s, updater.pypi, f);
1899
+ return true;
1900
+ }
1901
+
1902
+ /**
1903
+ * How many other files of this name come later on PATH (distinct real files;
1904
+ * a link to the one that runs is the same copy). Only counted: the CLI header
1905
+ * says how many tools have one, and nothing is guessed about whose they are.
1906
+ */
1907
+ async function countOtherCopies(
1908
+ s: ProbeSession,
1909
+ name: string,
1910
+ f: ToolFacts,
1911
+ onPath: string,
1912
+ resolvedPath: string,
1913
+ ): Promise<void> {
1914
+ if (!s.io.resolveAllExecutables) return;
1915
+ const seen = new Set([resolvedPath]);
1916
+ for (const candidate of await s.io.resolveAllExecutables(name)) {
1917
+ if (candidate === onPath) continue;
1918
+ const real = await s.io.realpath(candidate);
1919
+ if (!real || seen.has(real)) continue;
1920
+ seen.add(real);
2125
1921
  }
1922
+ if (seen.size > 1) f.otherCopies = seen.size - 1;
1923
+ }
1924
+
1925
+ /**
1926
+ * Re-read the tool through the installer that has it — bun's or npm's global
1927
+ * tree, else Homebrew — and, when one does, carry its answer and its upgrade
1928
+ * command onto `f`. `magus update` and the TUI then run that installer's
1929
+ * command rather than the spec's.
1930
+ *
1931
+ * Homebrew is ASKED, exactly as for a brew spec ({@link probeBrew}, by the
1932
+ * tool's name): it is credited only when the file PATH runs is one brew lists
1933
+ * as the formula's (`brew list <formula>`) and the version PATH runs, read with
1934
+ * magus's own version command, is one of brew's. A same-version copy brew does
1935
+ * not list is another copy. Not for a pinned spec: brew installs only its
1936
+ * newest version. Not for a file in bun's global tree either: that file is
1937
+ * bun's, whichever package holds it, so brew is never asked about it.
1938
+ */
1939
+ async function adoptActualOwner(
1940
+ s: ProbeSession,
1941
+ spec: ManagedTool,
1942
+ f: ToolFacts,
1943
+ onPath: string,
1944
+ resolvedPath: string,
1945
+ ): Promise<void> {
1946
+ let actual = await nodeOwnerSpec(s, spec, resolvedPath);
1947
+ if (!actual && (await adoptOwnInstaller(s, spec, f, resolvedPath))) return;
1948
+ const read = blankFacts(spec.name);
1949
+ if (actual) {
1950
+ await probeNodeGlobal(s, actual, read, resolvedPath);
1951
+ } else if (
1952
+ spec.via !== "brew" &&
1953
+ pinOf(spec) === undefined &&
1954
+ (await inBunGlobalTree(s, resolvedPath)) === null &&
1955
+ // Without a version command nothing could confirm PATH runs brew's
1956
+ // copy, so brew is not even asked.
1957
+ toolVersionCommand(spec.name) !== undefined
1958
+ ) {
1959
+ actual = { name: spec.name, via: "brew", formula: spec.name };
1960
+ await probeBrew(s, actual, read, onPath, resolvedPath);
1961
+ if (!read.sources.includes("version-command")) return;
1962
+ }
1963
+ if (!actual || read.managed !== true) return;
1964
+ f.managed = true;
1965
+ f.installed = read.installed;
1966
+ f.latest = read.latest;
1967
+ f.behind = read.behind;
1968
+ if (read.heldBack) f.heldBack = read.heldBack;
1969
+ // What the spec's installer could not answer is moot once the owner is
1970
+ // known: only the owner's own failure describes this tool.
1971
+ f.failure = read.failure;
1972
+ for (const source of read.sources) addSource(f, source);
1973
+ f.upgradeCommand =
1974
+ read.upgradeCommand ??
1975
+ (pinOf(spec)
1976
+ ? (binPinCommand(actual) ?? undefined)
1977
+ : binUpgradeCommand(actual));
2126
1978
  }
2127
1979
 
2128
1980
  /** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
@@ -2183,11 +2035,7 @@ export async function probeTools(
2183
2035
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
2184
2036
  const deadlineMs = opts.deadlineMs ?? PROBE_DEADLINE_MS;
2185
2037
  const concurrency = Math.max(1, opts.concurrency ?? PROBE_CONCURRENCY);
2186
- const session = new ProbeSession(
2187
- io,
2188
- timeoutMs,
2189
- opts.allowCheckCommand === true,
2190
- );
2038
+ const session = new ProbeSession(io, timeoutMs);
2191
2039
  const deadlineAt = Date.now() + deadlineMs;
2192
2040
  const results: ToolFacts[] = new Array(specs.length);
2193
2041
  let next = 0;
@@ -2265,7 +2113,7 @@ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
2265
2113
  * installed", the rule `cliToolUpdate` follows, so the row's state and the
2266
2114
  * command Enter offers name the same installer;
2267
2115
  * 3. the catalogue's own mapping. For a pnpm/yarn install this probes a
2268
- * manager that does not own the file, which is reported as `not-managed`.
2116
+ * manager that does not own the file, which is reported as `external`.
2269
2117
  */
2270
2118
  export function specForCatalogTool(
2271
2119
  tool: CliTool,
@@ -2279,7 +2127,6 @@ export function specForCatalogTool(
2279
2127
  name: tool.name,
2280
2128
  via: install.primary,
2281
2129
  package: tool.packageName,
2282
- check: tool.checkCommand,
2283
2130
  ...(install.brewFormula ? { formula: install.brewFormula } : {}),
2284
2131
  };
2285
2132
  }
@@ -2287,7 +2134,6 @@ export function specForCatalogTool(
2287
2134
  name: tool.name,
2288
2135
  via: tool.packageManager,
2289
2136
  package: tool.packageName,
2290
- check: tool.checkCommand,
2291
2137
  };
2292
2138
  }
2293
2139