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
package/src/cli/update.ts CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  } from "../services/update-engine.js";
78
78
  import {
79
79
  type UpdatePlan,
80
+ binHeldBack,
80
81
  planBinUpdates,
81
82
  planHasWork,
82
83
  planIsBehind,
@@ -98,7 +99,7 @@ import {
98
99
  header,
99
100
  info,
100
101
  note as noteLine,
101
- nothingToApplyNotice,
102
+ nothingToApplyLines,
102
103
  ok as okLine,
103
104
  pendingHeadline,
104
105
  planReport,
@@ -106,7 +107,6 @@ import {
106
107
  stepRecord,
107
108
  summaryLine,
108
109
  uncheckedGateNotice,
109
- unknownToolsNotice,
110
110
  warn as warnLine,
111
111
  } from "./update-view.js";
112
112
  import { setUpOtherWorktrees } from "./worktrees.js";
@@ -447,18 +447,25 @@ async function updateProfile(
447
447
  const items = planBinUpdates(tools, facts);
448
448
  const n = (a: string) => items.filter((i) => i.action === a).length;
449
449
  const behind = n("upgrade");
450
- const unknown = n("unknown");
450
+ const elsewhere = n("external");
451
+ // A held-back tool was checked: brew says it is behind and pinned.
452
+ const held = items.filter(binHeldBack).length;
453
+ const unchecked = n("unknown") - held;
451
454
  const missing = n("install");
452
455
  steps.binaries.detail = [
453
456
  `${items.length} checked`,
454
457
  behind > 0 ? `${behind} behind` : "",
455
- unknown > 0 ? `${unknown} unknown` : "",
458
+ held > 0 ? `${held} held back` : "",
459
+ elsewhere > 0 ? `${elsewhere} installed elsewhere` : "",
460
+ unchecked > 0 ? `${unchecked} not checked` : "",
456
461
  missing > 0 ? `${missing} missing` : "",
457
462
  declaredOnly > 0 ? `${declaredOnly} managed by their declaration` : "",
458
463
  ]
459
464
  .filter(Boolean)
460
465
  .join(" · ");
461
- if (missing + unknown > 0) steps.binaries.state = "warn";
466
+ // A warning only for a tool that is missing. Installed elsewhere is not a
467
+ // problem, and a tool that could not be checked is counted, not alarmed.
468
+ if (missing > 0) steps.binaries.state = "warn";
462
469
  return facts;
463
470
  });
464
471
 
@@ -505,21 +512,11 @@ async function updateProfile(
505
512
  for (const line of planReport(plan)) console.log(line);
506
513
 
507
514
  const counts = summarizePlan(plan);
508
- const unknownToolNames = plan.bins
509
- .filter((b) => b.action === "unknown")
510
- .map((b) => b.name);
511
- const toolUnknownLine = () =>
512
- unknownToolsNotice(counts.unknownTools, unknownToolNames);
513
- // No plugin equivalent of `toolUnknownLine`. Every unverified plugin is
514
- // already a row in the table above, under a heading that names its state,
515
- // so a trailing "N plugin(s) could not be checked" restated the table in
516
- // prose and — since the grouping landed — miscounted it: the count is raw
517
- // `unknown`, which now includes rows filed under Current. A tool has no
518
- // such row, which is why that notice stays and names them.
519
- if (counts.unknownTools > 0) {
520
- console.log("");
521
- console.log(toolUnknownLine());
522
- }
515
+ // No trailing "N could not be checked" notice, for plugins or tools. Every
516
+ // unverified plugin is already a row under a heading that names its state,
517
+ // and every tool is counted by state in the CLI section's header — so such
518
+ // a notice only restated the table in prose, and for a tool installed
519
+ // another way it claimed a failure that never happened.
523
520
 
524
521
  // `--check` gates on planIsBehind, not planHasWork. An already-installed
525
522
  // skill is re-fetched on every apply because its manifest ref carries no
@@ -528,17 +525,29 @@ async function updateProfile(
528
525
  //
529
526
  // An `unknown` does not fail the gate, for plugins and tools alike (see
530
527
  // planIsBehind). The same argument applies to both: a plugin whose
531
- // marketplace publishes no version, or a tool another npm owns, is unknown
532
- // permanently, so failing on it is the skill case again — this profile's 5
533
- // unversioned official plugins made `--check` exit 1 on every run. A tool
534
- // or plugin something says is BEHIND still does fail it. Because a failed
535
- // check now passes silently by exit code, the unchecked count is printed
536
- // beside the verdict; the per-kind notices and each row's reason are above.
528
+ // marketplace publishes no version is unknown permanently, so failing on it
529
+ // is the skill case again — this profile's 5 unversioned official plugins
530
+ // made `--check` exit 1 on every run. A tool installed another way is not
531
+ // unknown at all, and never fails it; nor does a held-back one, which only
532
+ // the user can release. A tool or plugin something says is
533
+ // BEHIND still does fail it. Because a failed check now passes silently by
534
+ // exit code, the unchecked count is printed beside the verdict.
537
535
  if (flags.check) {
538
536
  console.log("");
539
537
  if (!planIsBehind(plan)) {
540
- if (counts.unknown > 0) console.log(uncheckedGateNotice(counts.unknown));
541
- console.log(okLine("Nothing missing and nothing behind."));
538
+ if (counts.unknown > 0)
539
+ console.log(
540
+ uncheckedGateNotice(counts.unknownPlugins, counts.unknownTools),
541
+ );
542
+ // A held-back tool is behind, so "nothing behind" would be false; it
543
+ // does not fail the gate because only the user can release the pin.
544
+ console.log(
545
+ okLine(
546
+ counts.heldBack > 0
547
+ ? `Nothing missing, and nothing behind except ${counts.heldBack} held back.`
548
+ : "Nothing missing and nothing behind.",
549
+ ),
550
+ );
542
551
  return 0;
543
552
  }
544
553
  console.error(
@@ -563,21 +572,17 @@ async function updateProfile(
563
572
  Date.now() - startedAt,
564
573
  0,
565
574
  counts.kept,
575
+ counts.external,
576
+ counts.heldBack,
566
577
  ))
567
578
  console.log(line);
568
579
  // `planHasWork` counts an `unknown` plugin as work, because from the
569
580
  // planner's side "we could not check this" is genuinely not "this is
570
581
  // fine". But nothing is APPLIED for an unknown — the apply loop skips it —
571
582
  // so reporting on `planHasWork` alone claimed a run had happened when it
572
- // had not. Say what is actually true instead.
573
- if (counts.unknownPlugins + counts.unknownTools > 0) {
574
- if (counts.unknownPlugins > 0) {
575
- console.log(nothingToApplyNotice(counts.unknownPlugins));
576
- }
577
- if (counts.unknownTools > 0) console.log(toolUnknownLine());
578
- return 0;
579
- }
580
- console.log(okLine("Everything is up to date."));
583
+ // had not. Say what is actually true instead: "Everything is up to date"
584
+ // only when nothing went unchecked and nothing is held back.
585
+ for (const line of nothingToApplyLines(counts)) console.log(line);
581
586
  return 0;
582
587
  }
583
588
 
@@ -697,6 +702,8 @@ async function updateProfile(
697
702
  Date.now() - startedAt,
698
703
  outcome.unchanged.length,
699
704
  counts.kept,
705
+ counts.external,
706
+ counts.heldBack,
700
707
  ))
701
708
  console.log(line);
702
709
 
@@ -21,8 +21,12 @@ interface CatalogueDisplay {
21
21
  category: "ai-coding" | "utility";
22
22
  }
23
23
 
24
+ /**
25
+ * There is no `checkCommand` field either: how magus asks a tool its version
26
+ * is `data/tool-versions.ts`, the one table, so each tool's version command
27
+ * exists once.
28
+ */
24
29
  export interface CliTool extends CatalogueDisplay {
25
- checkCommand: string;
26
30
  /**
27
31
  * Reuses `BinInstaller` rather than restating its members, so the
28
32
  * correspondence with the resolver in `services/toolchain.ts` — which is
@@ -62,7 +66,6 @@ export const cliTools: CatalogueEntry[] = [
62
66
  displayName: "magus",
63
67
  description:
64
68
  "TUI tool for managing Claude Code plugins, MCPs, and configuration",
65
- checkCommand: "magus --version",
66
69
  website: "https://github.com/MadAppGang/magus/tree/main/tools/magus",
67
70
  category: "ai-coding",
68
71
  packageManager: "bun",
@@ -91,7 +94,6 @@ export const cliTools: CatalogueEntry[] = [
91
94
  name: "claude",
92
95
  displayName: "Claude Code",
93
96
  description: "Anthropic official agentic coding tool",
94
- checkCommand: "claude --version",
95
97
  website: "https://claude.ai/code",
96
98
  category: "ai-coding",
97
99
  packageManager: "npm",
@@ -102,7 +104,6 @@ export const cliTools: CatalogueEntry[] = [
102
104
  displayName: "OpenAI Codex",
103
105
  description:
104
106
  "Lightweight coding agent from OpenAI that runs in your terminal",
105
- checkCommand: "codex --version",
106
107
  website: "https://github.com/openai/codex",
107
108
  category: "ai-coding",
108
109
  packageManager: "npm",
@@ -112,7 +113,6 @@ export const cliTools: CatalogueEntry[] = [
112
113
  name: "gemini",
113
114
  displayName: "Gemini CLI",
114
115
  description: "Google AI agent with 1M token context, free tier available",
115
- checkCommand: "gemini --version",
116
116
  website: "https://github.com/google-gemini/gemini-cli",
117
117
  category: "ai-coding",
118
118
  packageManager: "npm",
@@ -122,7 +122,6 @@ export const cliTools: CatalogueEntry[] = [
122
122
  name: "qwen",
123
123
  displayName: "Qwen Code",
124
124
  description: "Alibaba coding agent optimized for Qwen3-Coder models",
125
- checkCommand: "qwen --version",
126
125
  website: "https://github.com/QwenLM/qwen-code",
127
126
  category: "ai-coding",
128
127
  packageManager: "npm",
@@ -133,7 +132,6 @@ export const cliTools: CatalogueEntry[] = [
133
132
  displayName: "Cline",
134
133
  description:
135
134
  "Autonomous coding agent with plan & act, checkpoints, browser use",
136
- checkCommand: "cline --version",
137
135
  website: "https://cline.bot",
138
136
  category: "ai-coding",
139
137
  packageManager: "npm",
@@ -143,7 +141,6 @@ export const cliTools: CatalogueEntry[] = [
143
141
  name: "opencode",
144
142
  displayName: "OpenCode",
145
143
  description: "Open source AI coding agent trusted by 400k+ developers",
146
- checkCommand: "opencode --version",
147
144
  website: "https://opencode.ai",
148
145
  category: "ai-coding",
149
146
  packageManager: "npm",
@@ -153,7 +150,6 @@ export const cliTools: CatalogueEntry[] = [
153
150
  name: "aider",
154
151
  displayName: "Aider",
155
152
  description: "AI pair programming - works with Claude, GPT-4, local models",
156
- checkCommand: "aider --version",
157
153
  website: "https://aider.chat",
158
154
  category: "ai-coding",
159
155
  packageManager: "pip",
@@ -163,7 +159,6 @@ export const cliTools: CatalogueEntry[] = [
163
159
  name: "crush",
164
160
  displayName: "Crush",
165
161
  description: "AI coding assistant from Charm with terminal-native UX",
166
- checkCommand: "crush --version",
167
162
  website: "https://github.com/charmbracelet/crush",
168
163
  category: "ai-coding",
169
164
  packageManager: "npm",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * tool-versions.ts — how magus asks a CLI tool its version. ONE table.
3
+ *
4
+ * A version is read from exactly two places: the installer's own record (brew
5
+ * list, the npm/bun global package, uv/pipx, go build info), or the command
6
+ * below. Nothing else. magus used to guess when the installer was silent — a
7
+ * `package.json` above a `node_modules` path, a `versions/<n>` folder name, the
8
+ * first version-like token of whatever a plugin's `check` string printed — and
9
+ * a guess is how a row shows a version nobody ran.
10
+ *
11
+ * Because magus wrote every argv here, the probe may run it while PLANNING,
12
+ * including `--dry-run` and `--check`. A plugin manifest's `requires.bin[].check`
13
+ * is a string a third party wrote, and the update probe never runs it.
14
+ *
15
+ * Every entry is pinned by a test to a line of real output captured from the
16
+ * tool (`__tests__/tool-versions.test.ts`). Each pattern is anchored at both
17
+ * ends and is tested against the FIRST non-empty line only, so a trailing
18
+ * "Update available: v0.36.2" notice can never be read as the version. A tool
19
+ * whose output was never captured has no entry: it still gets a version when
20
+ * its installer's record has one, and shows none otherwise.
21
+ *
22
+ * Imports nothing: the probe, the resolver and the catalogue all read it.
23
+ */
24
+
25
+ export interface ToolVersionCommand {
26
+ /**
27
+ * Arguments after the executable. argv[0] is ALWAYS the tool's own name,
28
+ * resolved by PATH — so the version read is the version of the file PATH
29
+ * runs.
30
+ */
31
+ readonly args: readonly string[];
32
+ /**
33
+ * Tested against the first non-empty line of stdout (of stderr when stdout
34
+ * is empty). Group 1 is the version. Anchored at both ends.
35
+ */
36
+ readonly parse: RegExp;
37
+ }
38
+
39
+ /** `X.Y.Z`, with an optional prerelease suffix. */
40
+ const V = String.raw`(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)`;
41
+ const anchored = (body: string): RegExp => new RegExp(`^${body}$`);
42
+
43
+ /**
44
+ * A `Map`, never a bare object: a plugin may declare a bin named after an
45
+ * Object.prototype key ("constructor"), and that must not find a "command".
46
+ */
47
+ const TABLE: ReadonlyMap<string, ToolVersionCommand> = new Map<
48
+ string,
49
+ ToolVersionCommand
50
+ >([
51
+ // `claudish version 10.4.1`
52
+ [
53
+ "claudish",
54
+ { args: ["--version"], parse: anchored(`claudish version v?${V}`) },
55
+ ],
56
+ // `jq-1.8.2`; jq 1.6 prints `jq-1.6`. A dev build `jq-1.7.1-dirty` keeps its
57
+ // suffix, so it never compares equal to brew's 1.7.1.
58
+ [
59
+ "jq",
60
+ {
61
+ args: ["--version"],
62
+ parse: /^jq-(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)$/,
63
+ },
64
+ ],
65
+ // `mnemex v0.36.1`, then update-notice lines the first-line rule ignores.
66
+ ["mnemex", { args: ["--version"], parse: anchored(`mnemex v${V}`) }],
67
+ // `tmux 3.7c`. HEAD builds (`tmux next-3.7`, `tmux master`) give null on
68
+ // purpose: they have no release to compare with.
69
+ ["tmux", { args: ["-V"], parse: /^tmux (\d+\.\d+[a-z]?)$/ }],
70
+ // `uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)`
71
+ ["uv", { args: ["--version"], parse: anchored(`uv ${V}(?: \\(.*\\))?`) }],
72
+ // `zsh 5.9 (arm64-apple-darwin26.0)`
73
+ [
74
+ "zsh",
75
+ {
76
+ args: ["--version"],
77
+ parse: /^zsh (\d+\.\d+(?:\.\d+)*(?:-[0-9A-Za-z.]+)?) \(.+\)$/,
78
+ },
79
+ ],
80
+ // `magus v7.9.0`, then update-notice lines.
81
+ ["magus", { args: ["--version"], parse: anchored(`magus v${V}`) }],
82
+ // `2.1.293 (Claude Code)`
83
+ [
84
+ "claude",
85
+ { args: ["--version"], parse: anchored(`${V} \\(Claude Code\\)`) },
86
+ ],
87
+ // `codex-cli 0.153.2`
88
+ ["codex", { args: ["--version"], parse: anchored(`codex-cli ${V}`) }],
89
+ // `0.46.0`
90
+ ["gemini", { args: ["--version"], parse: anchored(V) }],
91
+ // `1.17.11`
92
+ ["opencode", { args: ["--version"], parse: anchored(V) }],
93
+ // `crush version v0.55.0`
94
+ ["crush", { args: ["--version"], parse: anchored(`crush version v${V}`) }],
95
+ ]);
96
+
97
+ /** The version command magus defines for `name`, or undefined when it has none. */
98
+ export function toolVersionCommand(
99
+ name: string,
100
+ ): ToolVersionCommand | undefined {
101
+ return TABLE.get(name);
102
+ }
103
+
104
+ /** The first non-empty line, trimmed; "" when there is none. */
105
+ function firstLine(text: string): string {
106
+ return (
107
+ text
108
+ .split("\n")
109
+ .find((l) => l.trim())
110
+ ?.trim() ?? ""
111
+ );
112
+ }
113
+
114
+ /**
115
+ * The version in a tool's output, or null when the first non-empty line is
116
+ * not one the pattern anchors. stdout is read; stderr only when stdout says
117
+ * nothing (tmux and some older tools print their version there).
118
+ */
119
+ export function parseToolVersion(
120
+ cmd: ToolVersionCommand,
121
+ stdout: string,
122
+ stderr: string,
123
+ ): string | null {
124
+ const line = firstLine(stdout) || firstLine(stderr);
125
+ if (!line) return null;
126
+ return cmd.parse.exec(line)?.[1] ?? null;
127
+ }
128
+
129
+ /**
130
+ * The presence check argv for a tool magus maps itself (a catalogue entry, a
131
+ * profile cliTool): its version command when the table knows it, else
132
+ * `<name> --version`. Built here so the version command exists once.
133
+ */
134
+ export function presenceArgv(name: string): string[] {
135
+ const cmd = toolVersionCommand(name);
136
+ return [name, ...(cmd ? cmd.args : ["--version"])];
137
+ }
@@ -83,7 +83,6 @@ export function ownedCliTool(
83
83
  description,
84
84
  website,
85
85
  category,
86
- checkCommand: owned.check.join(" "),
87
86
  packageManager: managed.via,
88
87
  packageName:
89
88
  managed.package ?? managed.formula ?? managed.module ?? owned.name,
@@ -159,7 +158,7 @@ export type InstallMethod =
159
158
  * Entries used to carry a literal `installCommand` string beside the
160
159
  * `packageManager` and `packageName` that describe the same thing, so the two
161
160
  * could disagree — and did. `aider` declared `packageName: "aider-chat"` and
162
- * `checkCommand: "aider --version"`, and its `installCommand` installed
161
+ * checked its version with `aider --version`, and its `installCommand` installed
163
162
  * `aider-install`: a bootstrapper whose version nothing in this screen ever
164
163
  * looked at, while the version check queried `aider-chat`. Deriving makes that
165
164
  * disagreement unrepresentable rather than merely fixed.
@@ -1,101 +1,23 @@
1
- import { readFileSync, realpathSync } from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
- /**
5
- * The version of the npm/bun package a binary on PATH belongs to.
6
- *
7
- * A global install leaves `<prefix>/bin/<tool>` as a symlink into
8
- * `<prefix>/lib/node_modules/<package>/…` (npm) or
9
- * `~/.bun/install/global/node_modules/<package>/…` (bun). The package's own
10
- * `package.json` is therefore the authority on what is installed, and reading
11
- * it needs neither an installer record nor running the tool.
12
- *
13
- * The walk starts from the binary's REAL location — the symlink is resolved
14
- * first, chain and all — because the directory the symlink sits in can belong
15
- * to some unrelated project whose `package.json` would answer wrongly. The
16
- * nearest `package.json` carrying both `name` and `version` wins; the partial
17
- * ones a build leaves in `dist/` (`{"type":"module"}`) are skipped.
18
- *
19
- * Never throws: a missing binary, a dangling link, an unreadable or unparseable
20
- * manifest, and "no package at all" are all `null`.
21
- */
22
- export function readPackageVersionForBin(binPath: string): string | null {
23
- let real: string;
24
- try {
25
- real = realpathSync(binPath);
26
- } catch {
27
- return null;
28
- }
29
-
30
- let dir = path.dirname(real);
31
- for (;;) {
32
- const version = qualifyingVersion(path.join(dir, "package.json"));
33
- if (version !== undefined) return version;
34
- const parent = path.dirname(dir);
35
- if (parent === dir) return null;
36
- dir = parent;
37
- }
38
- }
39
-
40
3
  /** A bare release number, as a versioned install directory names its files. */
41
4
  const RELEASE = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
42
5
 
43
6
  /**
44
- * The version a VERSIONED INSTALL names its binary by, or `null`.
45
- *
46
- * Some installers keep every release side by side and point PATH at one:
47
- * Claude Code's native installer links `~/.local/bin/claude` to
48
- * `~/.local/share/claude/versions/2.1.276`. There is no package.json there to
49
- * read, but the file's own name IS the version — accepted only when the real
50
- * path's parent directory is literally `versions` and the name is a bare
51
- * release number, so an ordinary binary called `1.0.0` is not misread.
7
+ * The version a VERSIONED INSTALL names its binary by, or `null`, from a path
8
+ * that is ALREADY real — pure string work, no filesystem. The probe resolves
9
+ * paths through its own IO seam and must not reach past it to the real disk.
52
10
  *
53
- * Reads no file and runs nothing; never throws.
54
- */
55
- export function readVersionDirForBin(binPath: string): string | null {
56
- let real: string;
57
- try {
58
- real = realpathSync(binPath);
59
- } catch {
60
- return null;
61
- }
62
- return versionFromVersionedPath(real);
63
- }
64
-
65
- /**
66
- * The same reading as {@link readVersionDirForBin}, from a path that is ALREADY
67
- * real — pure string work, no filesystem. The probe resolves paths through its
68
- * own IO seam and must not reach past it to the real disk.
11
+ * Claude Code's native installer keeps every release side by side and points
12
+ * PATH at one: `~/.local/bin/claude` links to
13
+ * `~/.local/share/claude/versions/2.1.276`. That installer's record IS the file
14
+ * name — accepted only when the parent directory is literally `versions` and
15
+ * the name is a bare release number, so an ordinary binary called `1.0.0` is
16
+ * not misread. The probe calls this only once it has confirmed the layout is
17
+ * that installer's own.
69
18
  */
70
19
  export function versionFromVersionedPath(realPath: string): string | null {
71
20
  const name = path.basename(realPath);
72
21
  if (path.basename(path.dirname(realPath)) !== "versions") return null;
73
22
  return RELEASE.test(name) ? name.replace(/^v/, "") : null;
74
23
  }
75
-
76
- /**
77
- * The manifest's version when it names both a package and a version.
78
- *
79
- * `undefined` means "not a package root, keep walking"; `null` means "this is
80
- * the package root, and it cannot be read" — the walk stops there rather than
81
- * letting an enclosing workspace answer for a package it does not own.
82
- */
83
- function qualifyingVersion(file: string): string | null | undefined {
84
- let text: string;
85
- try {
86
- text = readFileSync(file, "utf8");
87
- } catch {
88
- return undefined;
89
- }
90
- let manifest: unknown;
91
- try {
92
- manifest = JSON.parse(text);
93
- } catch {
94
- return null;
95
- }
96
- if (manifest === null || typeof manifest !== "object") return undefined;
97
- const { name, version } = manifest as Record<string, unknown>;
98
- if (typeof name !== "string" || name === "") return undefined;
99
- if (typeof version !== "string" || version === "") return undefined;
100
- return version;
101
- }
@@ -13,6 +13,7 @@ import semver from "semver";
13
13
  import { cliTools, isOwnedEntry } from "../data/cli-tools.js";
14
14
  import { getMarketplaceByName } from "../data/marketplaces.js";
15
15
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
16
+ import { presenceArgv } from "../data/tool-versions.js";
16
17
  import {
17
18
  type InstallOption,
18
19
  type Requirement,
@@ -258,13 +259,11 @@ export function pinOfBin(bin: SelectedBin): string | undefined {
258
259
  return option && "version" in option ? option.version : undefined;
259
260
  }
260
261
 
261
- /** The catalogue's check line as argv: split once, here, never by a shell. */
262
- const splitCheck = (line: string): string[] => line.trim().split(/\s+/);
263
-
264
262
  /**
265
263
  * Map a manifest cliTools entry (name -> version) onto a requirement: the
266
264
  * catalogue's installer as one option (`npm`/`pip`/`go` are the profile-only
267
- * kinds), its check as argv, the pin carried in the option. A tool a plugin
265
+ * kinds), magus's own version command as the check (`presenceArgv`), the pin
266
+ * carried in the option. A tool a plugin
268
267
  * owns (`ownedBy`) is that plugin's declaration, passed in as `owned`, with
269
268
  * the pin applied to its chosen option. An unknown tool installs with bun
270
269
  * under its own name.
@@ -293,18 +292,13 @@ export function cliToolToBin(
293
292
  : { ...owned, chosen: withPin(owned.chosen) };
294
293
  }
295
294
  const tool = cliTools.find((t) => t.name === name);
296
- let option: InstallOption;
297
- let check: string[];
298
- if (tool && !isOwnedEntry(tool)) {
299
- check = splitCheck(tool.checkCommand);
300
- option =
301
- tool.packageManager === "brew"
295
+ const check = presenceArgv(name);
296
+ const option: InstallOption =
297
+ tool && !isOwnedEntry(tool)
298
+ ? tool.packageManager === "brew"
302
299
  ? { via: "brew", formula: tool.packageName }
303
- : { via: tool.packageManager, package: tool.packageName };
304
- } else {
305
- check = [name, "--version"];
306
- option = { via: "bun", package: name };
307
- }
300
+ : { via: tool.packageManager, package: tool.packageName }
301
+ : { via: "bun", package: name };
308
302
  const requirement: Requirement = { name, check, install: [withPin(option)] };
309
303
  return (
310
304
  selectBins([requirement], undefined)[0] ?? {