magus-cli 7.10.2 → 7.10.4

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 (39) 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 +392 -0
  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 -20
  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__/tool-adopt-brew-owner.test.ts +167 -0
  18. package/src/__tests__/tool-probe.test.ts +231 -144
  19. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  20. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  21. package/src/__tests__/tool-versions.test.ts +164 -0
  22. package/src/__tests__/toolchain.test.ts +0 -2
  23. package/src/__tests__/update-plan.test.ts +40 -9
  24. package/src/cli/update-view.ts +182 -68
  25. package/src/cli/update.ts +44 -37
  26. package/src/data/cli-tools.ts +5 -10
  27. package/src/data/tool-versions.ts +137 -0
  28. package/src/services/cli-tool-commands.ts +1 -2
  29. package/src/services/cli-tool-version.ts +10 -88
  30. package/src/services/resolver.ts +9 -15
  31. package/src/services/tool-probe.ts +557 -424
  32. package/src/services/toolchain.ts +10 -4
  33. package/src/services/update-engine.ts +9 -15
  34. package/src/services/update-plan.ts +49 -18
  35. package/src/types/index.ts +0 -2
  36. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  37. package/src/ui/renderers/cliToolRenderers.tsx +15 -10
  38. package/src/ui/state/types.ts +0 -1
  39. 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,11 +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";
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";
110
119
 
111
120
  export interface ToolFacts {
112
121
  name: string;
@@ -125,16 +134,14 @@ export interface ToolFacts {
125
134
  /** The owning installer's own verdict; null = it gave none. */
126
135
  behind: boolean | null;
127
136
  /**
128
- * The upgrade command for this spec replaces `resolvedPath` (containment in
129
- * the package's own directory, or pip adoption). false = another owner;
130
- * 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).
131
143
  */
132
144
  managed: boolean | null;
133
- /**
134
- * Where the installer keeps what it owns, for the human note — e.g.
135
- * "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
136
- */
137
- owner?: string;
138
145
  /** e.g. "held by brew pin". */
139
146
  heldBack?: string;
140
147
  /**
@@ -168,13 +175,19 @@ export interface ToolFacts {
168
175
  failure?: string;
169
176
  /** Where each fact came from, for diagnostics. */
170
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;
171
184
  }
172
185
 
173
186
  export interface ToolStatus {
174
187
  name: string;
175
188
  state: ToolState;
176
189
  reason: ToolReason;
177
- /** Set for every state except plain current/install. */
190
+ /** Set for every state except plain current/install and `external`. */
178
191
  note?: string;
179
192
  /** {@link ToolFacts.adopted}, carried when this state plans work. */
180
193
  adopted?: string;
@@ -228,68 +241,29 @@ function withFailure(text: string, failure: string | undefined): string {
228
241
  return failure ? `${text} (${failure})` : text;
229
242
  }
230
243
 
231
- /** The installer as the user knows it. */
232
- export function installerWord(via: ManagedTool["via"]): string {
233
- return via === "brew" ? "Homebrew" : via;
234
- }
235
-
236
- /** The directories the operating system itself installs programs into. */
237
- const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
238
-
239
- function isSystemPath(resolved: string | null): boolean {
240
- return (
241
- resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
242
- );
243
- }
244
-
245
- /**
246
- * The `not-managed` sentence, in the user's terms: who did not install it,
247
- * and what magus does about it. Never the path or the installer's directory:
248
- * the user acts on neither.
249
- */
250
- function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
251
- const pin = pinOf(spec);
252
- if (facts.managed === false && pin) {
253
- return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
254
- }
255
- // The KEPT chip already says magus leaves it as is; the note says only who
256
- // installed it.
257
- return facts.managed === false
258
- ? isSystemPath(facts.resolvedPath)
259
- ? "comes with the operating system"
260
- : `installed outside ${installerWord(spec.via)}`
261
- : withFailure(
262
- `could not tell whether ${installerWord(spec.via)} installed it`,
263
- facts.failure,
264
- );
265
- }
266
-
267
- /**
268
- * Another installer's file is `external`, settled, only while nothing is
269
- * asked of it. With a pin the profile declares a version magus cannot deliver
270
- * without a second copy, so that is still a warning: `unknown`.
271
- */
272
- function externalOr(pin: string | undefined): ToolState {
273
- return pin ? "unknown" : "external";
274
- }
275
-
276
244
  /**
277
245
  * THE tool classifier. Pure. The first matching rule wins, and each branch
278
246
  * returns, so reading top to bottom is reading the precedence.
279
247
  *
280
- * The vetoes (`not-managed`, `held-back`) come before every rule that can yield
281
- * `upgrade`, pinned or not: an upgrade through an installer that does not own
282
- * the running file installs a second copy PATH never runs, and one through a
283
- * held formula is a no-op. Both would be replanned forever. The pin rules that
284
- * plan no work (at-pin, unorderable, ahead-of-pin) come before the vetoes,
285
- * 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.
286
255
  *
287
256
  * One ownership answer comes earlier still: a file KNOWN to be nobody's the
288
- * spec's installer reaches (`managed === false`) is `not-managed` even when no
289
- * version could be read. That is where an unreadable version usually comes
290
- * from, and "does not own it" is the sentence the user can act on. The pin
291
- * rules are not displaced by it — they need a version to compare, and there is
292
- * 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.
293
267
  */
294
268
  export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
295
269
  const pin = pinOf(spec);
@@ -299,6 +273,29 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
299
273
  latest: facts.latest,
300
274
  ...(pin ? { pin } : {}),
301
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 };
302
299
 
303
300
  // 1. Not on PATH.
304
301
  if (!facts.present) {
@@ -310,23 +307,35 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
310
307
  };
311
308
  }
312
309
 
313
- // 2. Present, owned by nobody the spec's installer is — and no version read.
314
- // The ownership answer is the actionable one: the version is unreadable
315
- // BECAUSE no installer's record covers the file (and check commands are off
316
- // while planning), so "could not read its version" would hide the reason.
317
- // 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.
318
315
  const installed = facts.installed;
319
316
  if (installed === null && facts.managed === false) {
320
317
  return {
321
318
  ...base,
322
- state: externalOr(pin),
323
- reason: "not-managed",
319
+ state: "external",
320
+ reason: "installed-elsewhere",
324
321
  target: null,
325
- note: notManagedNote(spec, facts),
326
322
  };
327
323
  }
328
324
 
329
- // 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.
330
339
  if (installed === null) {
331
340
  return {
332
341
  ...base,
@@ -334,13 +343,13 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
334
343
  reason: "version-unreadable",
335
344
  target: null,
336
345
  note: withFailure(
337
- `on PATH${facts.resolvedPath ? ` at ${facts.resolvedPath}` : ""}, but its version could not be read`,
346
+ "installed, but its version could not be read",
338
347
  facts.failure,
339
348
  ),
340
349
  };
341
350
  }
342
351
 
343
- // 4–6. Pin rules that plan no work.
352
+ // 5–7. Pin rules that plan no work.
344
353
  const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
345
354
  if (pin && vsPin === 0) {
346
355
  return { ...base, state: "current", reason: "at-pin", target: null };
@@ -365,34 +374,38 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
365
374
  };
366
375
  }
367
376
 
368
- // 7. The veto: the upgrade would not replace the file PATH runs. Known to be
369
- // another installer's file: `external`. Not known either way: `unknown`.
370
- 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) {
371
380
  return {
372
381
  ...base,
373
- state: facts.managed === false ? externalOr(pin) : "unknown",
374
- reason: "not-managed",
382
+ state: "external",
383
+ reason: "installed-elsewhere",
375
384
  target: null,
376
- note: notManagedNote(spec, facts),
377
385
  };
378
386
  }
379
-
380
- // 8. The veto: the installer will refuse to move it.
381
- if (facts.heldBack) {
387
+ if (facts.managed === null) {
382
388
  return {
383
389
  ...base,
384
390
  state: "unknown",
385
- reason: "held-back",
391
+ reason: "owner-unknown",
386
392
  target: null,
387
- note: facts.heldBack,
393
+ note: withFailure(
394
+ "could not tell which installer owns it",
395
+ facts.failure,
396
+ ),
388
397
  };
389
398
  }
390
399
 
391
- // 9–10. Pinned and behind the pin (every other pinned case returned above).
392
- // From here on a row can plan work, so an adoption must travel with it.
393
- 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).
394
404
  if (pin) {
395
- if (spec.via === "brew") {
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.
408
+ if (spec.via === "brew" && !facts.upgradeCommand) {
396
409
  return {
397
410
  ...base,
398
411
  state: "unknown",
@@ -411,22 +424,8 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
411
424
  };
412
425
  }
413
426
 
414
- // 11–12. The owning installer's own verdict.
415
- if (facts.behind === true) {
416
- return {
417
- ...base,
418
- ...adopted,
419
- state: "upgrade",
420
- reason: "behind",
421
- target: facts.latest,
422
- note: facts.latest
423
- ? `${facts.latest} is available`
424
- : "a newer version is available",
425
- };
426
- }
427
- if (facts.behind === false) {
428
- return { ...base, state: "current", reason: "up-to-date", target: null };
429
- }
427
+ // 12. The owning installer's own verdict.
428
+ if (facts.behind !== null) return ownerVerdict(facts.behind);
430
429
 
431
430
  // 13. Nothing to compare against.
432
431
  const latest = facts.latest;
@@ -466,12 +465,25 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
466
465
 
467
466
  // ── Parsers (pure; tested against captured real output) ──────────────────────
468
467
 
469
- /** `brew list --versions <f>` → the last version token ("tmux 3.7c" → "3.7c"). */
470
- 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[] {
471
475
  const line = stdout.split("\n").find((l) => l.trim().length > 0);
472
- if (!line) return null;
473
- const tokens = line.trim().split(/\s+/);
474
- 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+$/, "");
475
487
  }
476
488
 
477
489
  /**
@@ -525,22 +537,6 @@ export function parseBrewOutdatedJson(
525
537
  };
526
538
  }
527
539
 
528
- /**
529
- * The keg version from a realpath under `<cellar>/<formula>/<version>/`
530
- * (".../Cellar/tmux/3.7c/bin/tmux" → "3.7c"). This is the LINKED version, which
531
- * `brew list --versions` cannot give when several kegs are kept.
532
- */
533
- export function brewKegVersion(
534
- realPath: string,
535
- cellar: string,
536
- formula: string,
537
- ): string | null {
538
- const prefix = `${path.join(cellar, formula)}${path.sep}`;
539
- if (!realPath.startsWith(prefix)) return null;
540
- const segment = realPath.slice(prefix.length).split(path.sep)[0];
541
- return segment ? segment : null;
542
- }
543
-
544
540
  /** PEP 503 normalisation: lowercase, runs of `-_.` collapse to `-`. */
545
541
  export function normalizePythonPackageName(name: string): string {
546
542
  return name.toLowerCase().replace(/[-_.]+/g, "-");
@@ -679,17 +675,6 @@ export function versionFromNpmDistTags(body: unknown): string | undefined {
679
675
  return typeof latest === "string" ? latest : undefined;
680
676
  }
681
677
 
682
- /**
683
- * The first version-like token in a `--version` output ("tmux 3.7c" → "3.7c",
684
- * "2.1.268 (Claude Code)" → "2.1.268", "v2.0.0" → "2.0.0"). Needs at least one
685
- * dot, so a bare number (a year, a count) is not mistaken for a version.
686
- */
687
- export function parseVersionToken(output: string): string | null {
688
- const m = /(?:^|[^\w.])v?(\d+(?:\.\d+)+[0-9A-Za-z.+-]*)/.exec(output);
689
- if (!m?.[1]) return null;
690
- return m[1].replace(/[.+-]+$/, "");
691
- }
692
-
693
678
  /** true when `resolved` lies inside `ownerDir` (compared with a trailing separator). */
694
679
  export function isInside(resolved: string, ownerDir: string): boolean {
695
680
  const dir = path.resolve(ownerDir);
@@ -758,6 +743,11 @@ export interface ToolProbeIO {
758
743
  * null. A dangling symlink is not executable and does not count.
759
744
  */
760
745
  resolveExecutable(name: string): Promise<string | null>;
746
+ /**
747
+ * Every executable `name` on PATH, in PATH order, as found. Optional: an IO
748
+ * without it reports no other copies.
749
+ */
750
+ resolveAllExecutables?(name: string): Promise<string[]>;
761
751
  env: Readonly<Record<string, string | undefined>>;
762
752
  homedir: string;
763
753
  }
@@ -876,6 +866,16 @@ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
876
866
  }
877
867
  return null;
878
868
  },
869
+ resolveAllExecutables: async (name) => {
870
+ const found: string[] = [];
871
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
872
+ if (!dir) continue;
873
+ const candidate = path.join(dir, name);
874
+ if (!found.includes(candidate) && (await isExecutable(candidate)))
875
+ found.push(candidate);
876
+ }
877
+ return found;
878
+ },
879
879
  env: process.env,
880
880
  homedir: os.homedir(),
881
881
  };
@@ -900,14 +900,6 @@ export interface ProbeOptions {
900
900
  * must use.
901
901
  */
902
902
  onResult?: (facts: ToolFacts, progress: ProbeProgress) => void;
903
- /**
904
- * Run a spec's `check` command when no installer record can give the
905
- * version. Default FALSE, and every planning caller leaves it so: `check`
906
- * is a string a third-party `plugin.json` supplied, and planning — which
907
- * includes `--dry-run` and `--check` — executes nothing a catalogue wrote.
908
- * See {@link versionFromCheck} for what is still bounded when it is on.
909
- */
910
- allowCheckCommand?: boolean;
911
903
  }
912
904
 
913
905
  // ── Probe session: memoised batch and location queries ──────────────────────
@@ -935,11 +927,17 @@ function firstLine(text: string): string {
935
927
  );
936
928
  }
937
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
+ */
938
935
  function execFailure(cmd: string, r: ExecResult, timeoutMs: number): string {
939
936
  if (r.missing) return `${cmd} is not installed`;
940
937
  if (r.timedOut)
941
938
  return `${cmd} timed out after ${Math.round(timeoutMs / 1000)}s`;
942
- 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"}`;
943
941
  }
944
942
 
945
943
  /**
@@ -953,8 +951,6 @@ class ProbeSession {
953
951
  constructor(
954
952
  readonly io: ToolProbeIO,
955
953
  readonly timeoutMs: number,
956
- /** See {@link versionFromCheck}. Off unless the caller opted in. */
957
- readonly allowCheckCommand: boolean = false,
958
954
  ) {}
959
955
 
960
956
  once<T>(key: string, work: () => Promise<T>): Promise<T> {
@@ -985,12 +981,6 @@ class ProbeSession {
985
981
  });
986
982
  }
987
983
 
988
- brewPrefix(): Promise<Located> {
989
- const fromEnv = this.io.env.HOMEBREW_PREFIX;
990
- if (fromEnv) return Promise.resolve({ dir: fromEnv });
991
- return this.locate("brew --prefix", "brew", ["--prefix"]);
992
- }
993
-
994
984
  uvToolDir(): Promise<Located & { missing?: boolean }> {
995
985
  return this.once("uv tool dir", async () => {
996
986
  const r = await this.run("uv", ["tool", "dir"], PLAIN_ENV);
@@ -1062,9 +1052,6 @@ class ProbeSession {
1062
1052
 
1063
1053
  // ── Probing one tool ─────────────────────────────────────────────────────────
1064
1054
 
1065
- /** Characters that make a check command something only a shell could run. */
1066
- const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
1067
-
1068
1055
  /**
1069
1056
  * Refuse a name that could be read as an option, carries a NUL, or (for a
1070
1057
  * value joined into a path) climbs out of its directory. Null when fine.
@@ -1117,62 +1104,45 @@ async function packageJsonVersion(
1117
1104
  }
1118
1105
 
1119
1106
  /**
1120
- * Last resort for the installed version: run the tool's own check command and
1121
- * 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.
1122
1110
  *
1123
- * NOT REACHED WHILE PLANNING. `spec.check` is copied verbatim out of a
1124
- * marketplace plugin's `plugin.json` (`plugin-requires.ts`), so it is a string
1125
- * a third party wrote. Before this probe existed nothing in magus ever ran
1126
- * it; running it to PLAN would have meant `magus update`, `--dry-run`,
1127
- * `--check` in CI and merely opening the TUI's Tools screen each executing a
1128
- * catalogue-supplied argv — and `--dry-run` is documented as writing nothing.
1129
- * So the planner does not opt in: a version that could only come from a check
1130
- * command stays null, which classifies as `unknown / version-unreadable`,
1131
- * never as an upgrade. The one caller that opts in is the post-install
1132
- * read-back (`update-engine.ts`), where the user has already consented to run
1133
- * 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.
1134
1117
  *
1135
- * Where it does run it is bounded on every axis:
1136
- * - argv only, split on whitespace, never a shell (a `SHELL_META` character
1137
- * refuses the whole string);
1138
- * - argv[0] must be the tool itself — its name, its path on PATH, or the
1139
- * realpath behind it. The probe's purpose is "ask the running file its
1140
- * version", so anything else is refused unrun, and the version reported can
1141
- * only have come from the binary `resolvedPath` names;
1142
- * - the session's `timeoutMs` (8s by default), through the injected IO seam,
1143
- * 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.
1144
1120
  */
1145
- async function versionFromCheck(
1121
+ function versionFromTable(
1146
1122
  s: ProbeSession,
1147
1123
  spec: ManagedTool,
1148
1124
  f: ToolFacts,
1149
- onPath: string,
1150
- ): Promise<void> {
1151
- if (!s.allowCheckCommand) {
1152
- noteFailure(f, "no installer record, and check commands are not run here");
1153
- return;
1154
- }
1155
- const check = spec.check?.trim() || `${spec.name} --version`;
1156
- if (SHELL_META.test(check)) {
1157
- noteFailure(f, "check command needs a shell; not run");
1158
- return;
1159
- }
1160
- const [cmd, ...args] = check.split(/\s+/);
1161
- if (!cmd) return;
1162
- if (cmd !== spec.name && cmd !== onPath && cmd !== f.resolvedPath) {
1163
- noteFailure(f, `check command does not run ${spec.name}; not run`);
1164
- return;
1165
- }
1166
- const r = await s.run(cmd, args, PLAIN_ENV);
1167
- if (r.missing || r.timedOut) {
1168
- noteFailure(f, execFailure(cmd, r, s.timeoutMs));
1169
- return;
1170
- }
1171
- const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
1172
- if (v) {
1173
- f.installed = v;
1174
- addSource(f, "check-command");
1175
- }
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
+ });
1176
1146
  }
1177
1147
 
1178
1148
  /** The npm registry's `latest` dist-tag for a package. */
@@ -1231,31 +1201,127 @@ async function containedIn(
1231
1201
  return { inside: real !== null && isInside(resolvedPath, real), real };
1232
1202
  }
1233
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
+ */
1234
1261
  async function probeBrew(
1235
1262
  s: ProbeSession,
1236
1263
  spec: ManagedTool,
1237
1264
  f: ToolFacts,
1265
+ pathEntry: string,
1238
1266
  resolvedPath: string,
1239
1267
  ): Promise<void> {
1240
1268
  const formula = spec.formula ?? spec.name;
1241
- // A tap formula ("user/tap/f") keeps its keg under the short name, so the
1242
- // full name never matched and a Homebrew mnemex read as installed outside it.
1243
- const keg = formula.slice(formula.lastIndexOf("/") + 1);
1244
- const prefix = await s.brewPrefix();
1245
- 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
+ ) {
1246
1280
  f.managed = null;
1247
- noteFailure(f, prefix.failure);
1281
+ noteFailure(f, execFailure("brew", list, s.timeoutMs));
1248
1282
  return;
1249
1283
  }
1250
- const cellar =
1251
- (await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
1252
- path.join(prefix.dir, "Cellar");
1253
- const ownerDir = path.join(cellar, keg);
1254
- f.owner = `brew (Cellar ${ownerDir})`;
1255
- f.managed = isInside(resolvedPath, ownerDir);
1256
- if (!f.managed) return;
1257
- f.installed = brewKegVersion(resolvedPath, cellar, keg);
1258
- 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
+ }
1259
1325
 
1260
1326
  const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
1261
1327
  if (r.code !== 0) {
@@ -1287,7 +1353,6 @@ async function probePip(
1287
1353
  const uvDir = await s.uvToolDir();
1288
1354
  if ("dir" in uvDir) {
1289
1355
  const ownerDir = path.join(uvDir.dir, norm);
1290
- f.owner = `uv (tool dir ${uvDir.dir})`;
1291
1356
  const { inside, real } = await containedIn(s, resolvedPath, ownerDir);
1292
1357
  // Adoption: `uv tool install --force` overwrites the very file PATH runs
1293
1358
  // when it sits in uv's bin dir, whoever put it there.
@@ -1301,7 +1366,7 @@ async function probePip(
1301
1366
  // plan row is an ordinary upgrade, so without this it never says that
1302
1367
  // the owning installer changes — and pipx, say, goes on claiming a
1303
1368
  // venv nothing runs until its next `upgrade-all` writes back over it.
1304
- 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";
1305
1370
  addSource(f, "uv-adopted");
1306
1371
  }
1307
1372
 
@@ -1371,7 +1436,6 @@ async function probePip(
1371
1436
  const binDir =
1372
1437
  s.io.env.PIPX_BIN_DIR ?? path.join(s.io.homedir, ".local", "bin");
1373
1438
  const ownerDir = path.join(home, "venvs", norm);
1374
- f.owner = `pipx (venvs ${path.join(home, "venvs")})`;
1375
1439
  const { inside } = await containedIn(s, resolvedPath, ownerDir);
1376
1440
  f.managed =
1377
1441
  inside || path.resolve(path.dirname(onPath)) === path.resolve(binDir);
@@ -1404,129 +1468,6 @@ async function readNodePackage(
1404
1468
  if (!pinned) await npmLatest(s, pkg, f);
1405
1469
  }
1406
1470
 
1407
- /** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
1408
- function bunGlobalRoot(io: ToolProbeIO): string {
1409
- return path.join(
1410
- io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
1411
- "install",
1412
- "global",
1413
- "node_modules",
1414
- );
1415
- }
1416
-
1417
- /** The package a path names, from its segments under `node_modules`. */
1418
- function packageFromSegments(rel: string): string | null {
1419
- const [first, second] = rel.split(path.sep);
1420
- if (!first || first.startsWith(".")) return null;
1421
- if (!first.startsWith("@")) return first;
1422
- return second ? `${first}/${second}` : null;
1423
- }
1424
-
1425
- /**
1426
- * The bun or npm global package that holds `resolvedPath`, read from the path
1427
- * alone. Null when the file lies in neither tree (a Homebrew keg included:
1428
- * `npmGlobalPrefixOf` refuses one).
1429
- */
1430
- async function nodeGlobalOwner(
1431
- s: ProbeSession,
1432
- resolvedPath: string,
1433
- ): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
1434
- const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
1435
- if (bunRoot && isInside(resolvedPath, bunRoot)) {
1436
- const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
1437
- return pkg ? { via: "bun", pkg } : null;
1438
- }
1439
- const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
1440
- const at = resolvedPath.lastIndexOf(marker);
1441
- if (at < 0) return null;
1442
- const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
1443
- return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
1444
- ? { via: "npm", pkg }
1445
- : null;
1446
- }
1447
-
1448
- /**
1449
- * The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
1450
- * read from the path alone. `probeBrew` then checks that this Cellar is brew's.
1451
- */
1452
- function kegFormulaOf(resolvedPath: string): string | null {
1453
- const parts = resolvedPath.split(path.sep);
1454
- const at = parts.lastIndexOf("Cellar");
1455
- return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
1456
- }
1457
-
1458
- /**
1459
- * The spec as the installer that actually holds `resolvedPath` would state it,
1460
- * or null when that is the spec's own installer or none magus drives.
1461
- */
1462
- async function actualOwnerSpec(
1463
- s: ProbeSession,
1464
- spec: ManagedTool,
1465
- resolvedPath: string,
1466
- ): Promise<ManagedTool | null> {
1467
- const base = {
1468
- name: spec.name,
1469
- ...(spec.check ? { check: spec.check } : {}),
1470
- ...(spec.version ? { version: spec.version } : {}),
1471
- };
1472
- const node = await nodeGlobalOwner(s, resolvedPath);
1473
- if (node) {
1474
- return node.via === spec.via
1475
- ? null
1476
- : { ...base, via: node.via, package: node.pkg };
1477
- }
1478
- const formula = kegFormulaOf(resolvedPath);
1479
- // Homebrew takes no version, so it cannot honour a pin: such a tool stays
1480
- // "installed outside", which the classifier reports as unknown.
1481
- if (!formula || spec.via === "brew" || spec.version) return null;
1482
- return { ...base, via: "brew", formula };
1483
- }
1484
-
1485
- /**
1486
- * The spec's installer does not own the file PATH runs, but bun's or npm's
1487
- * global tree or a Homebrew keg does: re-read the tool through THAT installer.
1488
- *
1489
- * A plugin's `requires` lists its install options in preference order for a
1490
- * FRESH install, and magus plans through the first. That order says how to
1491
- * install a missing tool, not which owner an installed one must have. A
1492
- * mnemex from `bun add -g` or from Homebrew has a version, a registry and an
1493
- * upgrade command like any other. Reading it as "installed outside" the
1494
- * first option's installer showed a settled, upgradeable tool as one magus
1495
- * could not touch.
1496
- *
1497
- * The catalogue's tools already follow how a tool IS installed
1498
- * (`specForCatalogTool`); this gives a plugin's requirements the same rule.
1499
- * The upgrade command is carried on the facts, so `magus update` and the TUI
1500
- * run the owner's command rather than the spec's.
1501
- */
1502
- async function adoptActualOwner(
1503
- s: ProbeSession,
1504
- spec: ManagedTool,
1505
- f: ToolFacts,
1506
- resolvedPath: string,
1507
- ): Promise<void> {
1508
- const actual = await actualOwnerSpec(s, spec, resolvedPath);
1509
- if (!actual) return;
1510
- const read = blankFacts(spec.name);
1511
- if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
1512
- else await probeNodeGlobal(s, actual, read, resolvedPath);
1513
- if (read.managed !== true) return;
1514
- f.managed = true;
1515
- f.owner = read.owner;
1516
- f.installed = read.installed;
1517
- f.latest = read.latest;
1518
- f.behind = read.behind;
1519
- // What the spec's installer could not answer is moot once the owner is
1520
- // known: only the owner's own failure describes this tool.
1521
- f.failure = read.failure;
1522
- for (const source of read.sources) addSource(f, source);
1523
- f.upgradeCommand =
1524
- read.upgradeCommand ??
1525
- (spec.version
1526
- ? (binPinCommand(actual) ?? undefined)
1527
- : binUpgradeCommand(actual));
1528
- }
1529
-
1530
1471
  async function probeNodeGlobal(
1531
1472
  s: ProbeSession,
1532
1473
  spec: ManagedTool,
@@ -1538,7 +1479,6 @@ async function probeNodeGlobal(
1538
1479
  const pinned = pin !== undefined;
1539
1480
  if (spec.via === "bun") {
1540
1481
  const root = bunGlobalRoot(s.io);
1541
- f.owner = `bun (global ${root})`;
1542
1482
  const { inside, real } = await containedIn(
1543
1483
  s,
1544
1484
  resolvedPath,
@@ -1553,7 +1493,6 @@ async function probeNodeGlobal(
1553
1493
  // The root the npm first on PATH installs into.
1554
1494
  const npmRoot = await s.npmRoot();
1555
1495
  if ("dir" in npmRoot) {
1556
- f.owner = `npm (root ${npmRoot.dir})`;
1557
1496
  const { inside, real } = await containedIn(
1558
1497
  s,
1559
1498
  resolvedPath,
@@ -1568,7 +1507,7 @@ async function probeNodeGlobal(
1568
1507
 
1569
1508
  // Not under that root — but perhaps under ANOTHER npm's. The file's own
1570
1509
  // path names the prefix that owns it, and `--prefix` upgrades exactly that
1571
- // tree, so this is npm-managed, never the "second copy" veto.
1510
+ // tree, so this is npm-managed, never installed elsewhere.
1572
1511
  const prefix = npmGlobalPrefixOf(resolvedPath, pkg);
1573
1512
  if (prefix === null) {
1574
1513
  if ("failure" in npmRoot) {
@@ -1578,7 +1517,6 @@ async function probeNodeGlobal(
1578
1517
  return;
1579
1518
  }
1580
1519
  f.managed = true;
1581
- f.owner = `npm (prefix ${prefix})`;
1582
1520
  // Reached with the default root only when containment could not resolve the
1583
1521
  // package directory; the plain command is right then, and --prefix is noise.
1584
1522
  if (!(await isNpmDefaultPrefix(s, npmRoot, prefix))) {
@@ -1652,7 +1590,7 @@ async function isNativeLauncher(
1652
1590
  * on running the other file at the old version. Every run would find it behind
1653
1591
  * and plan the same no-op again. So the check is on `onPath`, the unresolved
1654
1592
  * entry, and a copy that is not the launcher falls through to the spec's own
1655
- * installer — which reports it as a second copy it does not own.
1593
+ * installer — which reports it installed elsewhere.
1656
1594
  *
1657
1595
  * True when it recognised the layout; `f` then holds the whole answer.
1658
1596
  */
@@ -1685,7 +1623,6 @@ async function probeClaudeNative(
1685
1623
 
1686
1624
  const pin = pinOf(spec);
1687
1625
  f.managed = true;
1688
- f.owner = `Claude Code's native installer (${dir})`;
1689
1626
  f.installed = version;
1690
1627
  addSource(f, "version-dir");
1691
1628
  f.upgradeCommand = claudeNativeUpgradeCommand(pin);
@@ -1723,7 +1660,6 @@ async function probeGo(
1723
1660
  return;
1724
1661
  }
1725
1662
  const realBin = (await s.io.realpath(gobin.dir)) ?? gobin.dir;
1726
- f.owner = `go (GOBIN ${realBin})`;
1727
1663
  const sameModule =
1728
1664
  want === info.module ||
1729
1665
  want === info.path ||
@@ -1776,7 +1712,7 @@ async function probeInto(
1776
1712
  const via: BinInstaller = spec.via;
1777
1713
  switch (via) {
1778
1714
  case "brew":
1779
- await probeBrew(s, spec, f, resolvedPath);
1715
+ await probeBrew(s, spec, f, onPath, resolvedPath);
1780
1716
  break;
1781
1717
  case "pip":
1782
1718
  await probePip(s, spec, f, onPath, resolvedPath, pinned);
@@ -1790,52 +1726,255 @@ async function probeInto(
1790
1726
  break;
1791
1727
  }
1792
1728
 
1793
- // Not the spec's installer's file, or it could not tell (npm with no `npm`
1794
- // to ask), but the path names bun's, npm's or Homebrew's tree: then that is
1795
- // the owner, and it can upgrade the file PATH runs.
1796
- if (f.managed !== true) await adoptActualOwner(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);
1797
1733
 
1798
- // The method's own record was silent. Before anything runs, read what the
1799
- // installed files already say about themselves.
1800
- if (f.installed === null) versionFromFiles(f, resolvedPath);
1734
+ await countOtherCopies(s, spec.name, f, onPath, resolvedPath);
1801
1735
 
1802
- // Still nothing: read the running file itself — but only where the caller
1803
- // opted into running a check command (see there).
1804
- if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
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`.
1753
+
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
+ }
1763
+
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
+ }
1771
+
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;
1805
1779
  }
1806
1780
 
1807
1781
  /**
1808
- * The installed version, from files next to the binary — never by running it.
1809
- *
1810
- * The method's record is keyed to where THIS spec's installer keeps things, so
1811
- * it is silent whenever the tool on PATH came some other way: magus installed
1812
- * with bun while the catalogue says npm, a pnpm or yarn global. (An npm global
1813
- * under another prefix and Claude Code's native installer are recognised as
1814
- * OWNED before this runs.) Each of those still says what it is. A binary inside
1815
- * `node_modules` belongs to a package whose `package.json` carries the version;
1816
- * a versioned install names the file after it. Neither says who OWNS the file,
1817
- * so `managed` is left as the method found it — the classifier still refuses to
1818
- * plan an upgrade it cannot land — but the row can show a version instead of
1819
- * "could not be read".
1820
- *
1821
- * Reads the real filesystem rather than the IO seam: `resolvedPath` already
1822
- * came through the seam's `realpath`, and a path a test invented does not
1823
- * exist, so this answers null there exactly as a missing record does.
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.
1824
1788
  */
1825
- function versionFromFiles(f: ToolFacts, resolvedPath: string): void {
1826
- if (resolvedPath.split(path.sep).includes("node_modules")) {
1827
- const version = readPackageVersionForBin(resolvedPath);
1828
- if (version) {
1829
- f.installed = version;
1830
- addSource(f, "package-json");
1831
- return;
1832
- }
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;
1833
1804
  }
1834
- const version = readVersionDirForBin(resolvedPath);
1835
- if (version) {
1836
- f.installed = version;
1837
- addSource(f, "version-dir");
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
+ };
1826
+
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 };
1864
+ }
1865
+
1866
+ /**
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.
1872
+ */
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;
1887
+ }
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);
1838
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));
1839
1978
  }
1840
1979
 
1841
1980
  /** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
@@ -1896,11 +2035,7 @@ export async function probeTools(
1896
2035
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
1897
2036
  const deadlineMs = opts.deadlineMs ?? PROBE_DEADLINE_MS;
1898
2037
  const concurrency = Math.max(1, opts.concurrency ?? PROBE_CONCURRENCY);
1899
- const session = new ProbeSession(
1900
- io,
1901
- timeoutMs,
1902
- opts.allowCheckCommand === true,
1903
- );
2038
+ const session = new ProbeSession(io, timeoutMs);
1904
2039
  const deadlineAt = Date.now() + deadlineMs;
1905
2040
  const results: ToolFacts[] = new Array(specs.length);
1906
2041
  let next = 0;
@@ -1978,7 +2113,7 @@ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
1978
2113
  * installed", the rule `cliToolUpdate` follows, so the row's state and the
1979
2114
  * command Enter offers name the same installer;
1980
2115
  * 3. the catalogue's own mapping. For a pnpm/yarn install this probes a
1981
- * 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`.
1982
2117
  */
1983
2118
  export function specForCatalogTool(
1984
2119
  tool: CliTool,
@@ -1992,7 +2127,6 @@ export function specForCatalogTool(
1992
2127
  name: tool.name,
1993
2128
  via: install.primary,
1994
2129
  package: tool.packageName,
1995
- check: tool.checkCommand,
1996
2130
  ...(install.brewFormula ? { formula: install.brewFormula } : {}),
1997
2131
  };
1998
2132
  }
@@ -2000,7 +2134,6 @@ export function specForCatalogTool(
2000
2134
  name: tool.name,
2001
2135
  via: tool.packageManager,
2002
2136
  package: tool.packageName,
2003
- check: tool.checkCommand,
2004
2137
  };
2005
2138
  }
2006
2139