magus-cli 7.10.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.10.2",
3
+ "version": "7.10.3",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.10.2",
69
- "magus-cli-darwin-x64": "7.10.2",
70
- "magus-cli-linux-x64": "7.10.2"
68
+ "magus-cli-darwin-arm64": "7.10.3",
69
+ "magus-cli-darwin-x64": "7.10.3",
70
+ "magus-cli-linux-x64": "7.10.3"
71
71
  }
72
72
  }
@@ -250,10 +250,10 @@ describe("C16 case 6: file in no node tree, brew spec", () => {
250
250
  const s = classifyTool(spec, f);
251
251
  expect(s.state).toBe("external");
252
252
  expect(s.reason).toBe("not-managed");
253
- expect(s.note).toBe("installed outside Homebrew");
253
+ expect(s.note).toBe("installed by an installer magus does not update");
254
254
  expect(s.note?.includes("/")).toBe(false);
255
255
  });
256
- test("an npm spec says installed outside npm", async () => {
256
+ test("an npm spec gets the same note: it names who installed it, never the spec installer", async () => {
257
257
  const npmSpec: BinRequirement = { name: "uv", via: "npm", package: "uv" };
258
258
  const f = await probeTool(
259
259
  npmSpec,
@@ -264,7 +264,7 @@ describe("C16 case 6: file in no node tree, brew spec", () => {
264
264
  );
265
265
  const s = classifyTool(npmSpec, f);
266
266
  expect(s.state).toBe("external");
267
- expect(s.note).toBe("installed outside npm");
267
+ expect(s.note).toBe("installed by an installer magus does not update");
268
268
  });
269
269
  test("a pinned spec in that situation stays unknown", async () => {
270
270
  const pinned: BinRequirement = { ...spec, version: "1.0.0" };
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Blind tests, contract C19: a KEPT row names who installed the file, shows its
3
+ * version whenever it can be read, drives a self-updating standalone installer
4
+ * (uv's receipt), and reports a newer copy hidden later on PATH.
5
+ *
6
+ * Written from the spec only, through a scripted fake `ToolProbeIO`.
7
+ */
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ type ExecResult,
11
+ type FetchJsonResult,
12
+ type ToolProbeIO,
13
+ classifyTool,
14
+ probeTool,
15
+ } from "../../services/tool-probe.js";
16
+ import type { BinRequirement } from "../../types/index.js";
17
+
18
+ interface FakeSetup {
19
+ exec?: Record<string, Partial<ExecResult>>;
20
+ fetch?: Record<string, FetchJsonResult>;
21
+ files?: Record<string, string>;
22
+ real?: Record<string, string>;
23
+ onPath?: Record<string, string>;
24
+ allOnPath?: Record<string, string[]>;
25
+ env?: Record<string, string>;
26
+ noAll?: boolean;
27
+ }
28
+
29
+ const HOME = "/home/u";
30
+
31
+ function fakeIO(
32
+ setup: FakeSetup,
33
+ ): ToolProbeIO & { calls: string[]; reads: string[] } {
34
+ const calls: string[] = [];
35
+ const reads: string[] = [];
36
+ const io: ToolProbeIO & { calls: string[]; reads: string[] } = {
37
+ calls,
38
+ reads,
39
+ exec: async (command) => {
40
+ const key = [command.cmd, ...command.args].join(" ");
41
+ calls.push(key);
42
+ const h = setup.exec?.[key];
43
+ if (h === undefined) {
44
+ return {
45
+ code: null,
46
+ stdout: "",
47
+ stderr: "",
48
+ timedOut: false,
49
+ missing: true,
50
+ };
51
+ }
52
+ return {
53
+ code: 0,
54
+ stdout: "",
55
+ stderr: "",
56
+ timedOut: false,
57
+ missing: false,
58
+ ...h,
59
+ };
60
+ },
61
+ fetchJson: async (url) =>
62
+ setup.fetch?.[url] ?? { ok: false, reason: "returned 404" },
63
+ readJson: async (file) => {
64
+ reads.push(file);
65
+ const t = setup.files?.[file];
66
+ if (t === undefined) return null;
67
+ try {
68
+ return JSON.parse(t);
69
+ } catch {
70
+ return null;
71
+ }
72
+ },
73
+ readText: async (file) => setup.files?.[file] ?? null,
74
+ readlink: async () => null,
75
+ realpath: async (file) => setup.real?.[file] ?? null,
76
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
77
+ env: setup.env ?? {},
78
+ homedir: HOME,
79
+ };
80
+ if (!setup.noAll) {
81
+ io.resolveAllExecutables = async (name: string) =>
82
+ setup.allOnPath?.[name] ??
83
+ (setup.onPath?.[name] !== undefined
84
+ ? [setup.onPath[name] as string]
85
+ : []);
86
+ }
87
+ return io;
88
+ }
89
+
90
+ const PREFIX = "/opt/homebrew";
91
+ const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
92
+ "brew --prefix": { stdout: `${PREFIX}\n` },
93
+ };
94
+ const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
95
+
96
+ const UV: BinRequirement = { name: "uv", via: "brew", formula: "uv" };
97
+ const UV_FILE = "/home/u/.local/bin/uv";
98
+ const UV_RECEIPT_PATH = "/home/u/.config/uv/uv-receipt.json";
99
+ const uvReceipt = (prefix = "/home/u/.local/bin", version = "0.11.15") =>
100
+ JSON.stringify({
101
+ binaries: ["uv", "uvx"],
102
+ install_prefix: prefix,
103
+ source: { app_name: "uv", name: "uv", owner: "astral-sh" },
104
+ version,
105
+ });
106
+ const pypi = (v: string): Record<string, FetchJsonResult> => ({
107
+ "https://pypi.org/pypi/uv/json": { ok: true, body: { info: { version: v } } },
108
+ });
109
+
110
+ function uvWorld(opts: {
111
+ latest?: string;
112
+ receiptPrefix?: string;
113
+ receiptAt?: string;
114
+ env?: Record<string, string>;
115
+ extra?: Partial<FakeSetup>;
116
+ }): FakeSetup {
117
+ return {
118
+ onPath: { uv: UV_FILE },
119
+ real: { [UV_FILE]: UV_FILE },
120
+ files: {
121
+ [opts.receiptAt ?? UV_RECEIPT_PATH]: uvReceipt(opts.receiptPrefix),
122
+ },
123
+ fetch: pypi(opts.latest ?? "0.13.0"),
124
+ env: opts.env ?? {},
125
+ ...opts.extra,
126
+ };
127
+ }
128
+
129
+ describe("C19 A: uv's own installer is a driven owner", () => {
130
+ test("1. receipt, PyPI newer: managed, upgrade, uv self update", async () => {
131
+ const f = await probeTool(UV, fakeIO(uvWorld({})));
132
+ expect(f.installed).toBe("0.11.15");
133
+ expect(f.managed).toBe(true);
134
+ expect(f.latest).toBe("0.13.0");
135
+ expect(f.upgradeCommand).toEqual({ cmd: "uv", args: ["self", "update"] });
136
+ const s = classifyTool(UV, f);
137
+ expect(s.state).toBe("upgrade");
138
+ expect(s.target).toBe("0.13.0");
139
+ });
140
+
141
+ test("2. PyPI equals installed: current", async () => {
142
+ const f = await probeTool(UV, fakeIO(uvWorld({ latest: "0.11.15" })));
143
+ expect(classifyTool(UV, f).state).toBe("current");
144
+ });
145
+
146
+ test("3. pinned spec: upgrade to the pin with a versioned command", async () => {
147
+ const spec: BinRequirement = { ...UV, version: "0.12.0" };
148
+ const f = await probeTool(spec, fakeIO(uvWorld({})));
149
+ expect(f.upgradeCommand).toEqual({
150
+ cmd: "uv",
151
+ args: ["self", "update", "0.12.0"],
152
+ });
153
+ const s = classifyTool(spec, f);
154
+ expect(s.state).toBe("upgrade");
155
+ expect(s.target).toBe("0.12.0");
156
+ });
157
+
158
+ test("4. receipt for a different install_prefix is ignored", async () => {
159
+ const f = await probeTool(
160
+ UV,
161
+ fakeIO(uvWorld({ receiptPrefix: "/somewhere/else" })),
162
+ );
163
+ expect(f.managed).not.toBe(true);
164
+ expect(f.upgradeCommand).toBeUndefined();
165
+ expect(classifyTool(UV, f).state).not.toBe("upgrade");
166
+ });
167
+
168
+ test("5. XDG_CONFIG_HOME relocates the receipt", async () => {
169
+ const io = fakeIO(
170
+ uvWorld({
171
+ env: { XDG_CONFIG_HOME: "/xdg" },
172
+ extra: {
173
+ // a decoy at the default place must not be the one used
174
+ files: {
175
+ "/xdg/uv/uv-receipt.json": uvReceipt(),
176
+ [UV_RECEIPT_PATH]: uvReceipt("/home/u/.local/bin", "9.9.9"),
177
+ },
178
+ },
179
+ }),
180
+ );
181
+ const f = await probeTool(UV, io);
182
+ expect(f.installed).toBe("0.11.15");
183
+ expect(f.managed).toBe(true);
184
+ expect(io.reads).toContain("/xdg/uv/uv-receipt.json");
185
+ expect(io.reads).not.toContain(UV_RECEIPT_PATH);
186
+ });
187
+
188
+ test("6. receipt of an installer magus cannot update: external, versioned, named", async () => {
189
+ const spec: BinRequirement = { name: "ruff", via: "brew", formula: "ruff" };
190
+ const file = "/home/u/.local/bin/ruff";
191
+ const f = await probeTool(
192
+ spec,
193
+ fakeIO({
194
+ onPath: { ruff: file },
195
+ real: { [file]: file },
196
+ files: {
197
+ "/home/u/.config/ruff/ruff-receipt.json": JSON.stringify({
198
+ binaries: ["ruff"],
199
+ install_prefix: "/home/u/.local/bin",
200
+ source: { app_name: "ruff", name: "ruff", owner: "astral-sh" },
201
+ version: "0.9.1",
202
+ }),
203
+ },
204
+ exec: BREW_PREFIX_OK,
205
+ }),
206
+ );
207
+ expect(f.installed).toBe("0.9.1");
208
+ expect(f.upgradeCommand).toBeUndefined();
209
+ const s = classifyTool(spec, f);
210
+ expect(s.state).toBe("external");
211
+ expect(s.note).toBe("installed by ruff's own installer");
212
+ });
213
+ });
214
+
215
+ describe("C19 B: KEPT notes name who installed it", () => {
216
+ const spec = (
217
+ name: string,
218
+ extra: Partial<BinRequirement> = {},
219
+ ): BinRequirement => ({
220
+ name,
221
+ via: "brew",
222
+ formula: name,
223
+ ...extra,
224
+ });
225
+ const world = (
226
+ name: string,
227
+ file: string,
228
+ env: Record<string, string> = {},
229
+ ): FakeSetup => ({
230
+ onPath: { [name]: file },
231
+ real: { [file]: file, ...CELLAR },
232
+ exec: BREW_PREFIX_OK,
233
+ env,
234
+ });
235
+
236
+ test("7. Homebrew cask", async () => {
237
+ const sp = spec("madbench");
238
+ const file = "/opt/homebrew/Caskroom/madbench/0.43.2/madbench";
239
+ const f = await probeTool(sp, fakeIO(world("madbench", file)));
240
+ const s = classifyTool(sp, f);
241
+ expect(s.state).toBe("external");
242
+ expect(s.note).toBe("installed by a Homebrew cask");
243
+ });
244
+
245
+ test("8a. cargo bin under the home directory", async () => {
246
+ const sp = spec("rg");
247
+ const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
248
+ const s = classifyTool(sp, f);
249
+ expect(s.state).toBe("external");
250
+ expect(s.note).toBe("installed by cargo");
251
+ });
252
+
253
+ test("8b. cargo bin under CARGO_HOME", async () => {
254
+ const sp = spec("rg");
255
+ const f = await probeTool(
256
+ sp,
257
+ fakeIO(world("rg", "/opt/cargo/bin/rg", { CARGO_HOME: "/opt/cargo" })),
258
+ );
259
+ const s = classifyTool(sp, f);
260
+ expect(s.state).toBe("external");
261
+ expect(s.note).toBe("installed by cargo");
262
+ });
263
+
264
+ test("9. unrecognised place, never says 'outside'", async () => {
265
+ const sp = spec("foo");
266
+ const f = await probeTool(
267
+ sp,
268
+ fakeIO(world("foo", "/home/u/tools/bin/foo")),
269
+ );
270
+ const s = classifyTool(sp, f);
271
+ expect(s.state).toBe("external");
272
+ expect(s.note).toBe("installed by an installer magus does not update");
273
+ expect(s.note ?? "").not.toContain("outside");
274
+ });
275
+
276
+ test("10. pinned spec with a cargo file", async () => {
277
+ const sp = spec("rg", { version: "1.0.0" });
278
+ const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
279
+ const s = classifyTool(sp, f);
280
+ expect(s.state).toBe("unknown");
281
+ expect(s.note).toBe(
282
+ "installed by cargo, so magus cannot move it to the pin 1.0.0",
283
+ );
284
+ });
285
+ });
286
+
287
+ describe("C19 C: operating-system programs show their version", () => {
288
+ const zsh: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
289
+ const world = (zshExec?: Partial<ExecResult>): FakeSetup => ({
290
+ onPath: { zsh: "/bin/zsh" },
291
+ real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
292
+ exec: {
293
+ ...BREW_PREFIX_OK,
294
+ ...(zshExec ? { "/bin/zsh --version": zshExec } : {}),
295
+ },
296
+ });
297
+
298
+ test("11. version read from --version", async () => {
299
+ const f = await probeTool(
300
+ zsh,
301
+ fakeIO(world({ stdout: "zsh 5.9 (arm64-apple-darwin25.0)\n" })),
302
+ );
303
+ expect(f.installed).toBe("5.9");
304
+ const s = classifyTool(zsh, f);
305
+ expect(s.state).toBe("external");
306
+ expect(s.note).toBe("comes with the operating system");
307
+ });
308
+
309
+ test("12a. --version exits non-zero: still external, installed null", async () => {
310
+ const f = await probeTool(
311
+ zsh,
312
+ fakeIO(world({ code: 1, stdout: "", stderr: "" })),
313
+ );
314
+ expect(f.installed).toBeNull();
315
+ const s = classifyTool(zsh, f);
316
+ expect(s.state).toBe("external");
317
+ expect(s.note).toBe("comes with the operating system");
318
+ });
319
+
320
+ test("12b. --version missing: still external, installed null", async () => {
321
+ const f = await probeTool(zsh, fakeIO(world()));
322
+ expect(f.installed).toBeNull();
323
+ const s = classifyTool(zsh, f);
324
+ expect(s.state).toBe("external");
325
+ expect(s.note).toBe("comes with the operating system");
326
+ });
327
+ });
328
+
329
+ describe("C19 D: a newer copy later on PATH is reported", () => {
330
+ const BREW_BIN = "/opt/homebrew/bin/uv";
331
+ const cellarFile = (v: string) => `/opt/homebrew/Cellar/uv/${v}/bin/uv`;
332
+ const world = (second: string, secondReal: string): FakeSetup =>
333
+ uvWorld({
334
+ extra: {
335
+ allOnPath: { uv: [UV_FILE, second] },
336
+ real: { [UV_FILE]: UV_FILE, [second]: secondReal, ...CELLAR },
337
+ exec: BREW_PREFIX_OK,
338
+ env: { HOMEBREW_PREFIX: PREFIX },
339
+ },
340
+ });
341
+ const NOTE =
342
+ "Homebrew also has 0.13.0, unused: ~/.local/bin comes first on PATH";
343
+
344
+ test("13. newer Homebrew copy: pathDir, otherCopies, shadowed", async () => {
345
+ const f = await probeTool(
346
+ UV,
347
+ fakeIO(world(BREW_BIN, cellarFile("0.13.0"))),
348
+ );
349
+ expect(f.pathDir).toBe("~/.local/bin");
350
+ expect(f.otherCopies).toEqual([
351
+ { owner: "Homebrew", version: "0.13.0", dir: "/opt/homebrew/bin" },
352
+ ]);
353
+ expect(classifyTool(UV, f).shadowed).toBe(NOTE);
354
+ });
355
+
356
+ test("14. older later copy: listed, not shadowed", async () => {
357
+ const f = await probeTool(
358
+ UV,
359
+ fakeIO(world(BREW_BIN, cellarFile("0.10.0"))),
360
+ );
361
+ expect(f.otherCopies).toEqual([
362
+ { owner: "Homebrew", version: "0.10.0", dir: "/opt/homebrew/bin" },
363
+ ]);
364
+ expect(classifyTool(UV, f).shadowed).toBeUndefined();
365
+ });
366
+
367
+ test("15. a symlink to the running file is not another copy", async () => {
368
+ const f = await probeTool(UV, fakeIO(world("/usr/local/bin/uv", UV_FILE)));
369
+ expect(f.otherCopies ?? []).toEqual([]);
370
+ expect(classifyTool(UV, f).shadowed).toBeUndefined();
371
+ });
372
+
373
+ test("16. IO without resolveAllExecutables: nothing reported, nothing throws", async () => {
374
+ const w = world(BREW_BIN, cellarFile("0.13.0"));
375
+ w.noAll = true;
376
+ const f = await probeTool(UV, fakeIO(w));
377
+ expect(f.otherCopies ?? []).toEqual([]);
378
+ expect(classifyTool(UV, f).shadowed).toBeUndefined();
379
+ });
380
+
381
+ test("17. shadowed is set on a current row too", async () => {
382
+ const w = world(BREW_BIN, cellarFile("0.13.0"));
383
+ w.fetch = pypi("0.11.15");
384
+ const f = await probeTool(UV, fakeIO(w));
385
+ const s = classifyTool(UV, f);
386
+ expect(s.state).toBe("current");
387
+ expect(s.shadowed).toBe(NOTE);
388
+ });
389
+ });
@@ -213,7 +213,9 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
213
213
  expect(row.status?.state).toBe("external");
214
214
  expect(row.status?.reason).toBe("not-managed");
215
215
  // No path and no installer directory: the user acts on neither.
216
- expect(row.status?.note).toBe("installed outside npm");
216
+ expect(row.status?.note).toBe(
217
+ "installed by an installer magus does not update",
218
+ );
217
219
  expect(row.status?.note).not.toContain("/");
218
220
  // Enter still follows how it IS installed (pnpm).
219
221
  expect(row.updateCommand?.cmd).toBe("pnpm");
@@ -238,7 +238,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
238
238
  }),
239
239
  );
240
240
  expect(s).toMatchObject({ state: "external", reason: "not-managed" });
241
- expect(s.note).toBe("installed outside npm");
241
+ expect(s.note).toBe("installed by an installer magus does not update");
242
242
  expect(s.note).not.toContain("/");
243
243
  });
244
244
 
@@ -248,13 +248,13 @@ describe("classifyTool — every rule of §4.3, in order", () => {
248
248
  ).toMatchObject({ state: "unknown", reason: "not-managed" });
249
249
  });
250
250
 
251
- test("6. brew is named as the user knows it", () => {
252
- expect(
253
- classifyTool(
254
- spec({ via: "brew" }),
255
- facts({ managed: false, behind: true }),
256
- ).note,
257
- ).toBe("installed outside Homebrew");
251
+ test("6. the note names who DID install it, never the spec's installer", () => {
252
+ const note = classifyTool(
253
+ spec({ via: "brew" }),
254
+ facts({ managed: false, behind: true, ownerName: "cargo" }),
255
+ ).note;
256
+ expect(note).toBe("installed by cargo");
257
+ expect(note).not.toContain("Homebrew");
258
258
  });
259
259
 
260
260
  test("7. held back → unknown, with the hold as the note", () => {
@@ -435,6 +435,16 @@ function pluginDetail(item: PluginUpdateItem): string {
435
435
  * pin is shown only when there is one.
436
436
  */
437
437
  function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
438
+ const detail = binStateDetail(item, plan);
439
+ // A newer copy later on PATH is the one thing here only the user can settle.
440
+ return item.shadowed
441
+ ? [detail, fg(brand.warning, `⚠ ${item.shadowed}`)]
442
+ .filter(Boolean)
443
+ .join(" ")
444
+ : detail;
445
+ }
446
+
447
+ function binStateDetail(item: BinUpdateItem, plan: UpdatePlan): string {
438
448
  const spec = plan.binSpecs.get(item.name);
439
449
  // Rendered for the reader's eyes only — this row says what WILL run, while
440
450
  // applyBins runs the argv itself. Same builder, so they cannot differ.
@@ -106,7 +106,20 @@ export type ToolFactSource =
106
106
  | "package-json"
107
107
  /** the file name of a versioned install (`…/versions/2.1.276`). */
108
108
  | "version-dir"
109
- | "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
+ }
110
123
 
111
124
  export interface ToolFacts {
112
125
  name: string;
@@ -135,6 +148,16 @@ export interface ToolFacts {
135
148
  * "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
136
149
  */
137
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[];
138
161
  /** e.g. "held by brew pin". */
139
162
  heldBack?: string;
140
163
  /**
@@ -178,6 +201,12 @@ export interface ToolStatus {
178
201
  note?: string;
179
202
  /** {@link ToolFacts.adopted}, carried when this state plans work. */
180
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;
181
210
  installed: string | null;
182
211
  latest: string | null;
183
212
  target: string | null;
@@ -248,20 +277,40 @@ function isSystemPath(resolved: string | null): boolean {
248
277
  * the user acts on neither.
249
278
  */
250
279
  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}`;
280
+ if (facts.managed !== false) {
281
+ return withFailure(
282
+ `could not tell whether ${installerWord(spec.via)} installed it`,
283
+ facts.failure,
284
+ );
254
285
  }
286
+ // Who DID install it. "Outside Homebrew" named the installer magus would
287
+ // have used, which says nothing about the copy the user has.
288
+ const who = isSystemPath(facts.resolvedPath)
289
+ ? "comes with the operating system"
290
+ : facts.ownerName
291
+ ? `installed by ${facts.ownerName}`
292
+ : "installed by an installer magus does not update";
293
+ const pin = pinOf(spec);
255
294
  // 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
- );
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}` : ""}`;
265
314
  }
266
315
 
267
316
  /**
@@ -292,6 +341,12 @@ function externalOr(pin: string | undefined): ToolState {
292
341
  * none.
293
342
  */
294
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 {
295
350
  const pin = pinOf(spec);
296
351
  const base = {
297
352
  name: spec.name,
@@ -392,7 +447,10 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
392
447
  // From here on a row can plan work, so an adoption must travel with it.
393
448
  const adopted = facts.adopted ? { adopted: facts.adopted } : {};
394
449
  if (pin) {
395
- 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) {
396
454
  return {
397
455
  ...base,
398
456
  state: "unknown",
@@ -758,6 +816,11 @@ export interface ToolProbeIO {
758
816
  * null. A dangling symlink is not executable and does not count.
759
817
  */
760
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[]>;
761
824
  env: Readonly<Record<string, string | undefined>>;
762
825
  homedir: string;
763
826
  }
@@ -876,6 +939,16 @@ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
876
939
  }
877
940
  return null;
878
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
+ },
879
952
  env: process.env,
880
953
  homedir: os.homedir(),
881
954
  };
@@ -1527,6 +1600,207 @@ async function adoptActualOwner(
1527
1600
  : binUpgradeCommand(actual));
1528
1601
  }
1529
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
+
1530
1804
  async function probeNodeGlobal(
1531
1805
  s: ProbeSession,
1532
1806
  spec: ManagedTool,
@@ -1794,14 +2068,27 @@ async function probeInto(
1794
2068
  // to ask), but the path names bun's, npm's or Homebrew's tree: then that is
1795
2069
  // the owner, and it can upgrade the file PATH runs.
1796
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);
1797
2073
 
1798
2074
  // The method's own record was silent. Before anything runs, read what the
1799
2075
  // installed files already say about themselves.
1800
2076
  if (f.installed === null) versionFromFiles(f, resolvedPath);
1801
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
+
1802
2081
  // Still nothing: read the running file itself — but only where the caller
1803
2082
  // opted into running a check command (see there).
1804
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);
1805
2092
  }
1806
2093
 
1807
2094
  /**
@@ -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}>