magus-cli 7.10.1 → 7.10.3

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.
@@ -52,6 +52,8 @@ import {
52
52
  versionFromVersionedPath,
53
53
  } from "./cli-tool-version.js";
54
54
  import {
55
+ binPinCommand,
56
+ binUpgradeCommand,
55
57
  claudeNativeUpgradeCommand,
56
58
  npmPrefixInstallCommand,
57
59
  } from "./toolchain.js";
@@ -104,7 +106,20 @@ export type ToolFactSource =
104
106
  | "package-json"
105
107
  /** the file name of a versioned install (`…/versions/2.1.276`). */
106
108
  | "version-dir"
107
- | "check-command";
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
+ }
108
123
 
109
124
  export interface ToolFacts {
110
125
  name: string;
@@ -133,6 +148,16 @@ export interface ToolFacts {
133
148
  * "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
134
149
  */
135
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[];
136
161
  /** e.g. "held by brew pin". */
137
162
  heldBack?: string;
138
163
  /**
@@ -176,6 +201,12 @@ export interface ToolStatus {
176
201
  note?: string;
177
202
  /** {@link ToolFacts.adopted}, carried when this state plans work. */
178
203
  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;
179
210
  installed: string | null;
180
211
  latest: string | null;
181
212
  target: string | null;
@@ -231,22 +262,55 @@ export function installerWord(via: ManagedTool["via"]): string {
231
262
  return via === "brew" ? "Homebrew" : via;
232
263
  }
233
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
+
234
274
  /**
235
275
  * The `not-managed` sentence, in the user's terms: who did not install it,
236
276
  * and what magus does about it. Never the path or the installer's directory:
237
277
  * the user acts on neither.
238
278
  */
239
279
  function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
240
- const pin = pinOf(spec);
241
- if (facts.managed === false && pin) {
242
- return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
280
+ if (facts.managed !== false) {
281
+ return withFailure(
282
+ `could not tell whether ${installerWord(spec.via)} installed it`,
283
+ facts.failure,
284
+ );
243
285
  }
244
- return facts.managed === false
245
- ? `installed outside ${installerWord(spec.via)}, so magus leaves it as is`
246
- : withFailure(
247
- `could not tell whether ${installerWord(spec.via)} installed it`,
248
- facts.failure,
249
- );
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}` : ""}`;
250
314
  }
251
315
 
252
316
  /**
@@ -277,6 +341,12 @@ function externalOr(pin: string | undefined): ToolState {
277
341
  * none.
278
342
  */
279
343
  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 {
280
350
  const pin = pinOf(spec);
281
351
  const base = {
282
352
  name: spec.name,
@@ -377,7 +447,10 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
377
447
  // From here on a row can plan work, so an adoption must travel with it.
378
448
  const adopted = facts.adopted ? { adopted: facts.adopted } : {};
379
449
  if (pin) {
380
- if (spec.via === "brew") {
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.
453
+ if (spec.via === "brew" && !facts.upgradeCommand) {
381
454
  return {
382
455
  ...base,
383
456
  state: "unknown",
@@ -743,6 +816,11 @@ export interface ToolProbeIO {
743
816
  * null. A dangling symlink is not executable and does not count.
744
817
  */
745
818
  resolveExecutable(name: string): Promise<string | null>;
819
+ /**
820
+ * Every executable `name` on PATH, in PATH order, as found. Optional: an IO
821
+ * without it reports no other copies.
822
+ */
823
+ resolveAllExecutables?(name: string): Promise<string[]>;
746
824
  env: Readonly<Record<string, string | undefined>>;
747
825
  homedir: string;
748
826
  }
@@ -861,6 +939,16 @@ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
861
939
  }
862
940
  return null;
863
941
  },
942
+ resolveAllExecutables: async (name) => {
943
+ const found: string[] = [];
944
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
945
+ if (!dir) continue;
946
+ const candidate = path.join(dir, name);
947
+ if (!found.includes(candidate) && (await isExecutable(candidate)))
948
+ found.push(candidate);
949
+ }
950
+ return found;
951
+ },
864
952
  env: process.env,
865
953
  homedir: os.homedir(),
866
954
  };
@@ -1223,6 +1311,9 @@ async function probeBrew(
1223
1311
  resolvedPath: string,
1224
1312
  ): Promise<void> {
1225
1313
  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);
1226
1317
  const prefix = await s.brewPrefix();
1227
1318
  if ("failure" in prefix) {
1228
1319
  f.managed = null;
@@ -1232,11 +1323,11 @@ async function probeBrew(
1232
1323
  const cellar =
1233
1324
  (await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
1234
1325
  path.join(prefix.dir, "Cellar");
1235
- const ownerDir = path.join(cellar, formula);
1326
+ const ownerDir = path.join(cellar, keg);
1236
1327
  f.owner = `brew (Cellar ${ownerDir})`;
1237
1328
  f.managed = isInside(resolvedPath, ownerDir);
1238
1329
  if (!f.managed) return;
1239
- f.installed = brewKegVersion(resolvedPath, cellar, formula);
1330
+ f.installed = brewKegVersion(resolvedPath, cellar, keg);
1240
1331
  if (f.installed) addSource(f, "brew");
1241
1332
 
1242
1333
  const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
@@ -1386,6 +1477,330 @@ async function readNodePackage(
1386
1477
  if (!pinned) await npmLatest(s, pkg, f);
1387
1478
  }
1388
1479
 
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
+
1389
1804
  async function probeNodeGlobal(
1390
1805
  s: ProbeSession,
1391
1806
  spec: ManagedTool,
@@ -1396,12 +1811,7 @@ async function probeNodeGlobal(
1396
1811
  const pin = pinOf(spec);
1397
1812
  const pinned = pin !== undefined;
1398
1813
  if (spec.via === "bun") {
1399
- const root = path.join(
1400
- s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
1401
- "install",
1402
- "global",
1403
- "node_modules",
1404
- );
1814
+ const root = bunGlobalRoot(s.io);
1405
1815
  f.owner = `bun (global ${root})`;
1406
1816
  const { inside, real } = await containedIn(
1407
1817
  s,
@@ -1654,13 +2064,31 @@ async function probeInto(
1654
2064
  break;
1655
2065
  }
1656
2066
 
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);
2073
+
1657
2074
  // The method's own record was silent. Before anything runs, read what the
1658
2075
  // installed files already say about themselves.
1659
2076
  if (f.installed === null) versionFromFiles(f, resolvedPath);
1660
2077
 
2078
+ // A program the operating system ships keeps no record anywhere: ask it.
2079
+ if (f.installed === null) await versionFromOs(s, f, resolvedPath);
2080
+
1661
2081
  // Still nothing: read the running file itself — but only where the caller
1662
2082
  // opted into running a check command (see there).
1663
2083
  if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
2084
+
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;
2089
+ }
2090
+
2091
+ await findOtherCopies(s, spec.name, f, onPath, resolvedPath);
1664
2092
  }
1665
2093
 
1666
2094
  /**
@@ -397,7 +397,7 @@ async function readRows(
397
397
  * The row Claude Code moved is left as it is: it belongs to this repository,
398
398
  * and bringing this repository's rows forward is what the repair does anyway.
399
399
  * Null when there was nothing to follow — this project's row moved (2.1.280),
400
- * or no row of this repository moved at all.
400
+ * or no row of this repository moved at all and none already holds `target`.
401
401
  */
402
402
  async function followMisdirectedUpdate(
403
403
  deps: PluginApplyDeps,
@@ -422,14 +422,32 @@ async function followMisdirectedUpdate(
422
422
  scope,
423
423
  projectPath: scope === "user" ? undefined : projectPath,
424
424
  });
425
- if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
426
- return null;
427
- }
428
- const version = moved
425
+ if (moved.some((row) => rowKey(row) === mine)) return null;
426
+ // The row Claude Code picked may ALREADY hold the target — an earlier run
427
+ // from another checkout put it there. Then the update writes nothing, so
428
+ // nothing moves, and this project's row stays behind on every run (seen on
429
+ // 2.1.296: "the installer exited 0 but mnemex@magus is still 1.1.0" while
430
+ // tools/claudeup held 1.1.1). A row of this repository at exactly the
431
+ // target is the same misdirection, already landed.
432
+ const landed =
433
+ moved.length > 0 || !target
434
+ ? moved
435
+ : after.filter(
436
+ (row) =>
437
+ row.version === target &&
438
+ rowKey(row) !== mine &&
439
+ inThisRepository(row, projectPath),
440
+ );
441
+ if (landed.length === 0) return null;
442
+ const version = landed
429
443
  .map((row) => row.version)
430
444
  .reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
431
445
  if (!isRepairablyBehind(version, now)) return null;
432
- const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
446
+ // Already landed, every checkout at the target matches, but Claude Code
447
+ // wrote to one of them: report one folder, not all of them.
448
+ const elsewhere = (moved.length > 0 ? landed : landed.slice(0, 1)).map(
449
+ (row) => row.projectPath ?? `${row.scope} scope`,
450
+ );
433
451
  if (target && version !== target) {
434
452
  return {
435
453
  elsewhere,
@@ -1034,6 +1034,8 @@ export interface BinUpdateItem {
1034
1034
  * the same value the TUI's row runs on Enter.
1035
1035
  */
1036
1036
  upgradeCommand?: Command;
1037
+ /** {@link ToolStatus.shadowed}: a newer copy later on PATH, unused. */
1038
+ shadowed?: string;
1037
1039
  /** The exact pin, when the profile declares one. */
1038
1040
  version?: string;
1039
1041
  /** The version PATH runs, or null when absent / unreadable. */
@@ -1089,6 +1091,7 @@ export function planBinUpdates(
1089
1091
  };
1090
1092
  if (status.note) item.note = status.note;
1091
1093
  if (status.adopted) item.adopted = status.adopted;
1094
+ if (status.shadowed) item.shadowed = status.shadowed;
1092
1095
  if (f?.upgradeCommand) item.upgradeCommand = f.upgradeCommand;
1093
1096
  if (bin.version) item.version = bin.version;
1094
1097
  return item;
@@ -397,6 +397,17 @@ export function renderCliToolDetail(
397
397
  </text>
398
398
  </box>
399
399
  ) : null}
400
+ {/* A newer copy later on PATH, which nothing runs. */}
401
+ {status?.shadowed ? (
402
+ <box flexDirection="row">
403
+ <text fg={theme.colors.muted} flexShrink={0}>
404
+ {"Warning "}
405
+ </text>
406
+ <text fg={theme.colors.warning} minWidth={0}>
407
+ {wrapValue(`⚠ ${status.shadowed}`, width)}
408
+ </text>
409
+ </box>
410
+ ) : null}
400
411
  {status?.installed ? (
401
412
  <box flexDirection="row">
402
413
  <text fg={theme.colors.muted} flexShrink={0}>