magus-cli 7.10.1 → 7.10.2

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.1",
3
+ "version": "7.10.2",
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.1",
69
- "magus-cli-darwin-x64": "7.10.1",
70
- "magus-cli-linux-x64": "7.10.1"
68
+ "magus-cli-darwin-arm64": "7.10.2",
69
+ "magus-cli-darwin-x64": "7.10.2",
70
+ "magus-cli-linux-x64": "7.10.2"
71
71
  }
72
72
  }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Blind tests, contract C16: a tool whose declared installer does not own the
3
+ * file PATH runs is adopted by the node installer (bun or npm) when that file
4
+ * lies in bun's global tree or an npm global prefix. Otherwise it stays
5
+ * external / not-managed with a note that names no path.
6
+ *
7
+ * Written from the spec only. Every fact reaches `probeTool` through a
8
+ * scripted fake `ToolProbeIO`.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import {
12
+ type ExecResult,
13
+ type FetchJsonResult,
14
+ type ToolProbeIO,
15
+ classifyTool,
16
+ probeTool,
17
+ } from "../../services/tool-probe.js";
18
+ import type { BinRequirement } from "../../types/index.js";
19
+
20
+ interface FakeSetup {
21
+ exec?: Record<string, Partial<ExecResult>>;
22
+ fetch?: Record<string, FetchJsonResult>;
23
+ files?: Record<string, string>;
24
+ real?: Record<string, string>;
25
+ onPath?: Record<string, string>;
26
+ env?: Record<string, string>;
27
+ }
28
+
29
+ function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
30
+ const calls: string[] = [];
31
+ return {
32
+ calls,
33
+ exec: async (command) => {
34
+ const key = [command.cmd, ...command.args].join(" ");
35
+ calls.push(key);
36
+ const h = setup.exec?.[key];
37
+ if (h === undefined) {
38
+ return {
39
+ code: null,
40
+ stdout: "",
41
+ stderr: "",
42
+ timedOut: false,
43
+ missing: true,
44
+ };
45
+ }
46
+ return {
47
+ code: 0,
48
+ stdout: "",
49
+ stderr: "",
50
+ timedOut: false,
51
+ missing: false,
52
+ ...h,
53
+ };
54
+ },
55
+ fetchJson: async (url) => {
56
+ const r = setup.fetch?.[url];
57
+ return r ?? { ok: false, reason: "returned 404" };
58
+ },
59
+ readJson: async (file) => {
60
+ const t = setup.files?.[file];
61
+ if (t === undefined) return null;
62
+ try {
63
+ return JSON.parse(t);
64
+ } catch {
65
+ return null;
66
+ }
67
+ },
68
+ readText: async (file) => setup.files?.[file] ?? null,
69
+ readlink: async () => null,
70
+ realpath: async (file) => setup.real?.[file] ?? null,
71
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
72
+ env: setup.env ?? {},
73
+ homedir: "/Users/j",
74
+ };
75
+ }
76
+
77
+ const BUN_ROOT = "/Users/j/.bun/install/global/node_modules";
78
+ const BREW_ENV = { HOMEBREW_PREFIX: "/opt/homebrew" };
79
+ const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
80
+
81
+ const distTags = (pkg: string, latest: string) => ({
82
+ [`https://registry.npmjs.org/-/package/${pkg.replace("/", "%2F")}/dist-tags`]:
83
+ { ok: true, body: { latest } } as FetchJsonResult,
84
+ });
85
+
86
+ const pkgJson = (name: string, version: string) =>
87
+ JSON.stringify({ name, version });
88
+
89
+ /** A tool whose PATH entry resolves into bun's global tree. */
90
+ function bunWorld(
91
+ bin: string,
92
+ pkg: string,
93
+ installed: string,
94
+ latest: string,
95
+ ): FakeSetup {
96
+ const pkgDir = `${BUN_ROOT}/${pkg}`;
97
+ const file = `${pkgDir}/dist/index.js`;
98
+ const onPath = `/Users/j/.bun/bin/${bin}`;
99
+ return {
100
+ env: BREW_ENV,
101
+ onPath: { [bin]: onPath },
102
+ real: {
103
+ [onPath]: file,
104
+ [file]: file,
105
+ [pkgDir]: pkgDir,
106
+ [BUN_ROOT]: BUN_ROOT,
107
+ ...CELLAR,
108
+ },
109
+ files: { [`${pkgDir}/package.json`]: pkgJson(pkg, installed) },
110
+ fetch: distTags(pkg, latest),
111
+ };
112
+ }
113
+
114
+ const MNEMEX: BinRequirement = {
115
+ name: "mnemex",
116
+ via: "brew",
117
+ formula: "madappgang/tap/mnemex",
118
+ };
119
+
120
+ describe("C16 case 1: brew spec, file in bun's global tree", () => {
121
+ test("managed, versions read, bun upgrade command, state upgrade", async () => {
122
+ const f = await probeTool(
123
+ MNEMEX,
124
+ fakeIO(bunWorld("mnemex", "mnemex", "0.36.1", "0.36.2")),
125
+ );
126
+ expect(f.managed).toBe(true);
127
+ expect(f.installed).toBe("0.36.1");
128
+ expect(f.latest).toBe("0.36.2");
129
+ expect(f.upgradeCommand).toEqual({
130
+ cmd: "bun",
131
+ args: ["install", "-g", "mnemex@latest"],
132
+ });
133
+ const s = classifyTool(MNEMEX, f);
134
+ expect(s.state).toBe("upgrade");
135
+ expect(s.target).toBe("0.36.2");
136
+ });
137
+ });
138
+
139
+ describe("C16 case 2: latest equals installed", () => {
140
+ test("state is current", async () => {
141
+ const f = await probeTool(
142
+ MNEMEX,
143
+ fakeIO(bunWorld("mnemex", "mnemex", "0.36.2", "0.36.2")),
144
+ );
145
+ expect(f.managed).toBe(true);
146
+ expect(classifyTool(MNEMEX, f).state).toBe("current");
147
+ });
148
+ });
149
+
150
+ describe("C16 case 3: scoped package under bun's tree", () => {
151
+ test("the package is @scope/tool in the upgrade command", async () => {
152
+ const spec: BinRequirement = {
153
+ name: "tool",
154
+ via: "brew",
155
+ formula: "madappgang/tap/tool",
156
+ };
157
+ const f = await probeTool(
158
+ spec,
159
+ fakeIO(bunWorld("tool", "@scope/tool", "1.0.0", "1.1.0")),
160
+ );
161
+ expect(f.managed).toBe(true);
162
+ expect(f.installed).toBe("1.0.0");
163
+ expect(f.latest).toBe("1.1.0");
164
+ expect(f.upgradeCommand).toEqual({
165
+ cmd: "bun",
166
+ args: ["install", "-g", "@scope/tool@latest"],
167
+ });
168
+ expect(classifyTool(spec, f).state).toBe("upgrade");
169
+ });
170
+ });
171
+
172
+ describe("C16 case 4: npm global prefix", () => {
173
+ test("brew spec adopted by npm under the root npm reports", async () => {
174
+ const root = "/usr/local/lib/node_modules";
175
+ const pkgDir = `${root}/claudish`;
176
+ const file = `${pkgDir}/dist/index.js`;
177
+ const spec: BinRequirement = {
178
+ name: "claudish",
179
+ via: "brew",
180
+ formula: "madappgang/tap/claudish",
181
+ };
182
+ const f = await probeTool(
183
+ spec,
184
+ fakeIO({
185
+ env: BREW_ENV,
186
+ onPath: { claudish: "/usr/local/bin/claudish" },
187
+ real: {
188
+ "/usr/local/bin/claudish": file,
189
+ [file]: file,
190
+ [pkgDir]: pkgDir,
191
+ [root]: root,
192
+ ...CELLAR,
193
+ },
194
+ files: { [`${pkgDir}/package.json`]: pkgJson("claudish", "10.0.0") },
195
+ exec: { "npm root -g": { stdout: `${root}\n` } },
196
+ fetch: distTags("claudish", "10.1.0"),
197
+ }),
198
+ );
199
+ expect(f.managed).toBe(true);
200
+ expect(f.installed).toBe("10.0.0");
201
+ expect(f.latest).toBe("10.1.0");
202
+ expect(f.upgradeCommand).toEqual({
203
+ cmd: "npm",
204
+ args: ["install", "-g", "claudish@latest"],
205
+ });
206
+ const s = classifyTool(spec, f);
207
+ expect(s.state).toBe("upgrade");
208
+ expect(s.target).toBe("10.1.0");
209
+ });
210
+ });
211
+
212
+ describe("C16 case 5: pinned npm spec, file in bun's tree", () => {
213
+ const spec: BinRequirement = {
214
+ name: "tool",
215
+ via: "npm",
216
+ package: "tool",
217
+ version: "1.0.0",
218
+ };
219
+ test("managed, target is the pin, command carries tool@1.0.0", async () => {
220
+ const f = await probeTool(
221
+ spec,
222
+ fakeIO({
223
+ ...bunWorld("tool", "tool", "0.9.0", "2.0.0"),
224
+ exec: { "npm root -g": { stdout: "/usr/local/lib/node_modules\n" } },
225
+ }),
226
+ );
227
+ expect(f.managed).toBe(true);
228
+ expect(f.installed).toBe("0.9.0");
229
+ const args = f.upgradeCommand?.args ?? [];
230
+ expect(args).toContain("tool@1.0.0");
231
+ expect(args.some((a) => a.includes("@latest"))).toBe(false);
232
+ const s = classifyTool(spec, f);
233
+ expect(s.state).toBe("upgrade");
234
+ expect(s.target).toBe("1.0.0");
235
+ });
236
+ });
237
+
238
+ describe("C16 case 6: file in no node tree, brew spec", () => {
239
+ const spec: BinRequirement = { name: "uv", via: "brew", formula: "uv" };
240
+ const world: FakeSetup = {
241
+ env: BREW_ENV,
242
+ onPath: { uv: "/Users/j/.local/bin/uv" },
243
+ real: {
244
+ "/Users/j/.local/bin/uv": "/Users/j/.local/bin/uv",
245
+ ...CELLAR,
246
+ },
247
+ };
248
+ test("external / not-managed, note names who did not install it", async () => {
249
+ const f = await probeTool(spec, fakeIO(world));
250
+ const s = classifyTool(spec, f);
251
+ expect(s.state).toBe("external");
252
+ expect(s.reason).toBe("not-managed");
253
+ expect(s.note).toBe("installed outside Homebrew");
254
+ expect(s.note?.includes("/")).toBe(false);
255
+ });
256
+ test("an npm spec says installed outside npm", async () => {
257
+ const npmSpec: BinRequirement = { name: "uv", via: "npm", package: "uv" };
258
+ const f = await probeTool(
259
+ npmSpec,
260
+ fakeIO({
261
+ ...world,
262
+ exec: { "npm root -g": { stdout: "/usr/local/lib/node_modules\n" } },
263
+ }),
264
+ );
265
+ const s = classifyTool(npmSpec, f);
266
+ expect(s.state).toBe("external");
267
+ expect(s.note).toBe("installed outside npm");
268
+ });
269
+ test("a pinned spec in that situation stays unknown", async () => {
270
+ const pinned: BinRequirement = { ...spec, version: "1.0.0" };
271
+ const f = await probeTool(pinned, fakeIO(world));
272
+ expect(classifyTool(pinned, f).state).toBe("unknown");
273
+ });
274
+ });
275
+
276
+ describe("C16 case 7: operating-system file", () => {
277
+ test("zsh in /bin is external with the operating-system note", async () => {
278
+ const spec: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
279
+ const f = await probeTool(
280
+ spec,
281
+ fakeIO({
282
+ env: BREW_ENV,
283
+ onPath: { zsh: "/bin/zsh" },
284
+ real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
285
+ }),
286
+ );
287
+ const s = classifyTool(spec, f);
288
+ expect(s.state).toBe("external");
289
+ expect(s.reason).toBe("not-managed");
290
+ expect(s.note).toBe("comes with the operating system");
291
+ });
292
+ });
293
+
294
+ describe("C16 case 8: a Homebrew keg is never adopted as an npm install", () => {
295
+ test("npm spec, file in a keg's libexec/lib/node_modules", async () => {
296
+ const keg = "/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec";
297
+ const pkgDir = `${keg}/lib/node_modules/gemini-pkg`;
298
+ const file = `${pkgDir}/bundle/gemini.js`;
299
+ const spec: BinRequirement = {
300
+ name: "gemini",
301
+ via: "npm",
302
+ package: "gemini-pkg",
303
+ };
304
+ const f = await probeTool(
305
+ spec,
306
+ fakeIO({
307
+ env: BREW_ENV,
308
+ onPath: { gemini: "/opt/homebrew/bin/gemini" },
309
+ real: {
310
+ "/opt/homebrew/bin/gemini": file,
311
+ [file]: file,
312
+ [pkgDir]: pkgDir,
313
+ ...CELLAR,
314
+ },
315
+ files: { [`${pkgDir}/package.json`]: pkgJson("gemini-pkg", "0.46.0") },
316
+ exec: { "npm root -g": { stdout: "/opt/homebrew/lib/node_modules\n" } },
317
+ fetch: distTags("gemini-pkg", "0.50.0"),
318
+ }),
319
+ );
320
+ expect(f.upgradeCommand?.cmd).not.toBe("npm");
321
+ expect(classifyTool(spec, f).state).not.toBe("upgrade");
322
+ });
323
+ });
@@ -0,0 +1,271 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ test,
8
+ } from "bun:test";
9
+ import { execFileSync } from "node:child_process";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import fs from "fs-extra";
13
+ import {
14
+ loadedVersionHere,
15
+ repairStaleLoad,
16
+ } from "../../services/claude-settings.js";
17
+ import {
18
+ type PluginApplyDeps,
19
+ applyPlugins,
20
+ } from "../../services/update-engine.js";
21
+ import type {
22
+ InstalledPluginEntry,
23
+ InstalledPluginsRegistry,
24
+ } from "../../types/index.js";
25
+
26
+ /**
27
+ * Written blind from a spec. The row Claude Code picks for `plugin update`
28
+ * already holds the target, so the update writes nothing and no row moves.
29
+ * magus must treat it as the same misdirection as when a row moves.
30
+ */
31
+
32
+ const GIT_CFG = [
33
+ "-c",
34
+ "user.name=test",
35
+ "-c",
36
+ "user.email=test@example.com",
37
+ "-c",
38
+ "commit.gpgsign=false",
39
+ "-c",
40
+ "init.defaultBranch=main",
41
+ ];
42
+ const git = (cwd: string, ...args: string[]) =>
43
+ execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
44
+
45
+ const PLUGIN = "madbench@magus";
46
+ const SAME_REPO_DETAIL =
47
+ "project → 0.5.0 · Claude Code updated another folder of this repository instead of this one — this folder now loads v0.5.0 too";
48
+
49
+ let base: string;
50
+ let mainTree: string;
51
+ let here: string;
52
+ let gone: string;
53
+ let otherRepo: string;
54
+ let root: string;
55
+ let registryFile: string;
56
+
57
+ const cacheFor = (version: string) =>
58
+ path.join(root, "cache", "magus", "madbench", version);
59
+
60
+ const row = (
61
+ over: Partial<InstalledPluginEntry> & { version: string },
62
+ ): InstalledPluginEntry => ({
63
+ scope: "project",
64
+ installPath: cacheFor(over.version),
65
+ installedAt: "2026-01-01T00:00:00.000Z",
66
+ lastUpdated: "2026-01-01T00:00:00.000Z",
67
+ ...over,
68
+ });
69
+
70
+ async function writeRegistry(rows: InstalledPluginEntry[]): Promise<void> {
71
+ const registry: InstalledPluginsRegistry = {
72
+ version: 2,
73
+ plugins: { [PLUGIN]: rows },
74
+ };
75
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
76
+ }
77
+
78
+ const readRegistry = (): Promise<InstalledPluginsRegistry> =>
79
+ fs.readJson(registryFile);
80
+
81
+ const versionsByPath = async () =>
82
+ (await readRegistry()).plugins[PLUGIN].map((e) => [e.projectPath, e.version]);
83
+
84
+ const hereVersion = async () =>
85
+ (await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
86
+ ?.version;
87
+
88
+ beforeAll(async () => {
89
+ base = await fs.realpath(
90
+ await fs.mkdtemp(path.join(os.tmpdir(), "magus-c17-")),
91
+ );
92
+ mainTree = path.join(base, "magus-src");
93
+ here = path.join(mainTree, ".claude", "worktrees", "madbench");
94
+ gone = path.join(mainTree, ".claude", "worktrees", "gone");
95
+ otherRepo = path.join(base, "unrelated");
96
+
97
+ await fs.ensureDir(mainTree);
98
+ git(mainTree, "init", "-q");
99
+ await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
100
+ git(mainTree, "add", "-A");
101
+ git(mainTree, "commit", "-qm", "initial");
102
+ git(mainTree, "worktree", "add", "-q", "-b", "madbench", here);
103
+ git(mainTree, "worktree", "add", "-q", "-b", "gone", gone);
104
+ await fs.remove(gone);
105
+
106
+ await fs.ensureDir(otherRepo);
107
+ git(otherRepo, "init", "-q");
108
+ });
109
+
110
+ afterAll(async () => {
111
+ if (base) await fs.remove(base);
112
+ });
113
+
114
+ beforeEach(async () => {
115
+ root = await fs.mkdtemp(path.join(base, "plugins-"));
116
+ registryFile = path.join(root, "installed_plugins.json");
117
+ for (const v of ["0.3.0", "0.4.0", "0.5.0", "0.6.0"]) {
118
+ await fs.ensureDir(cacheFor(v));
119
+ }
120
+ });
121
+
122
+ /** `claude plugin update` that finds its row already on the target: writes nothing. */
123
+ function deps(): PluginApplyDeps {
124
+ return {
125
+ install: async () => {},
126
+ update: async () => {},
127
+ repair: async () => {},
128
+ readInstalled: async () =>
129
+ (await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
130
+ ?.version ?? null,
131
+ saveInstalled: async () => {},
132
+ registryRows: async () => (await readRegistry()).plugins[PLUGIN],
133
+ repairStaleLoad: (id, target, projectPath) =>
134
+ repairStaleLoad(id, target, projectPath, root),
135
+ loadedVersion: (id, projectPath) =>
136
+ loadedVersionHere(id, projectPath, root),
137
+ };
138
+ }
139
+
140
+ const item = (target: string) => ({
141
+ pluginId: PLUGIN,
142
+ pinned: "latest",
143
+ installed: "0.4.0",
144
+ available: target,
145
+ target,
146
+ action: "update" as const,
147
+ reason: "behind" as const,
148
+ scopes: ["project" as const],
149
+ beforeVersions: { project: "0.4.0" },
150
+ });
151
+
152
+ function collect() {
153
+ const details: string[] = [];
154
+ return {
155
+ details,
156
+ reporter: {
157
+ begin: () => {},
158
+ finish: (_ok: boolean, _name: string, detail: string) =>
159
+ details.push(detail),
160
+ detach: <T>(work: () => T) => work(),
161
+ },
162
+ };
163
+ }
164
+
165
+ const NO_CHANGE = `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0`;
166
+
167
+ describe("a misdirected update whose row already held the target", () => {
168
+ test("1: another row of the repository is at the target — this row follows, counted as changed", async () => {
169
+ await writeRegistry([
170
+ row({ version: "0.5.0", projectPath: mainTree }),
171
+ row({ version: "0.4.0", projectPath: here }),
172
+ ]);
173
+ const { details, reporter } = collect();
174
+
175
+ const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
176
+
177
+ expect(result.ok).toBe(1);
178
+ expect(result.unchanged).toEqual([]);
179
+ expect(await versionsByPath()).toEqual([
180
+ [mainTree, "0.5.0"],
181
+ [here, "0.5.0"],
182
+ ]);
183
+ expect(details).toEqual([SAME_REPO_DETAIL]);
184
+ expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
185
+ });
186
+
187
+ test("2: several other rows at the target — still one folder, 'another folder'", async () => {
188
+ await writeRegistry([
189
+ row({ version: "0.5.0", projectPath: gone }),
190
+ row({ version: "0.5.0", projectPath: mainTree }),
191
+ row({ version: "0.4.0", projectPath: here }),
192
+ ]);
193
+ const { details, reporter } = collect();
194
+
195
+ const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
196
+
197
+ expect(result.ok).toBe(1);
198
+ expect(result.unchanged).toEqual([]);
199
+ expect(await hereVersion()).toBe("0.5.0");
200
+ expect(details).toEqual([SAME_REPO_DETAIL]);
201
+ expect(details[0]).not.toMatch(/other folders/);
202
+ });
203
+
204
+ test("3 (negative a): the only row at the target is another repository's — no follow", async () => {
205
+ await writeRegistry([
206
+ row({ version: "0.5.0", projectPath: otherRepo }),
207
+ row({ version: "0.4.0", projectPath: mainTree }),
208
+ row({ version: "0.4.0", projectPath: here }),
209
+ ]);
210
+ const { details, reporter } = collect();
211
+
212
+ const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
213
+
214
+ expect(result.unchanged).toEqual([PLUGIN]);
215
+ expect(result.ok).toBe(0);
216
+ expect(await hereVersion()).toBe("0.4.0");
217
+ expect(details).toEqual([NO_CHANGE]);
218
+ });
219
+
220
+ test("4 (negative b): other repository rows are NEWER than the target — no follow", async () => {
221
+ await writeRegistry([
222
+ row({ version: "0.6.0", projectPath: mainTree }),
223
+ row({ version: "0.4.0", projectPath: here }),
224
+ ]);
225
+ const { details, reporter } = collect();
226
+
227
+ const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
228
+
229
+ expect(result.unchanged).toEqual([PLUGIN]);
230
+ expect(await hereVersion()).toBe("0.4.0");
231
+ expect(await versionsByPath()).toEqual([
232
+ [mainTree, "0.6.0"],
233
+ [here, "0.4.0"],
234
+ ]);
235
+ expect(details).toEqual([NO_CHANGE]);
236
+ });
237
+
238
+ test("4b (negative b): other repository rows are OLDER than the target — no follow", async () => {
239
+ await writeRegistry([
240
+ row({ version: "0.3.0", projectPath: mainTree }),
241
+ row({ version: "0.4.0", projectPath: here }),
242
+ ]);
243
+ const { details, reporter } = collect();
244
+
245
+ await applyPlugins([item("0.5.0")], here, deps(), reporter);
246
+
247
+ // The older row is not the target, so this row does not follow it. (The
248
+ // stale-load repair that raises that older row to this one's version is
249
+ // older behaviour, tested on its own.)
250
+ expect(await hereVersion()).toBe("0.4.0");
251
+ expect(details.join("\n")).not.toContain("another folder");
252
+ });
253
+
254
+ test("5: the target is not downloaded — could not follow, row stays", async () => {
255
+ await writeRegistry([
256
+ row({ version: "0.5.0", projectPath: mainTree }),
257
+ row({ version: "0.4.0", projectPath: here }),
258
+ ]);
259
+ await fs.remove(cacheFor("0.5.0"));
260
+ const { details, reporter } = collect();
261
+
262
+ const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
263
+
264
+ expect(result.unchanged).toEqual([PLUGIN]);
265
+ expect(await hereVersion()).toBe("0.4.0");
266
+ expect(details).toHaveLength(1);
267
+ expect(details[0]).toContain(
268
+ `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: v0.5.0 is not downloaded — run magus install)`,
269
+ );
270
+ });
271
+ });
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Blind tests, contract C18: Homebrew owns an installed tool whose file lies
3
+ * in `<brew prefix>/Cellar/<short name>/<version>/...`, whatever installer the
4
+ * spec declares. A pinned spec is not adopted (brew takes no version).
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
+ env?: Record<string, string>;
25
+ }
26
+
27
+ function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
28
+ const calls: string[] = [];
29
+ return {
30
+ calls,
31
+ exec: async (command) => {
32
+ const key = [command.cmd, ...command.args].join(" ");
33
+ calls.push(key);
34
+ const h = setup.exec?.[key];
35
+ if (h === undefined) {
36
+ return {
37
+ code: null,
38
+ stdout: "",
39
+ stderr: "",
40
+ timedOut: false,
41
+ missing: true,
42
+ };
43
+ }
44
+ return {
45
+ code: 0,
46
+ stdout: "",
47
+ stderr: "",
48
+ timedOut: false,
49
+ missing: false,
50
+ ...h,
51
+ };
52
+ },
53
+ fetchJson: async (url) => {
54
+ const r = setup.fetch?.[url];
55
+ return r ?? { ok: false, reason: "returned 404" };
56
+ },
57
+ readJson: async (file) => {
58
+ const t = setup.files?.[file];
59
+ if (t === undefined) return null;
60
+ try {
61
+ return JSON.parse(t);
62
+ } catch {
63
+ return null;
64
+ }
65
+ },
66
+ readText: async (file) => setup.files?.[file] ?? null,
67
+ readlink: async () => null,
68
+ realpath: async (file) => setup.real?.[file] ?? null,
69
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
70
+ env: setup.env ?? {},
71
+ homedir: "/Users/j",
72
+ };
73
+ }
74
+
75
+ const PREFIX = "/opt/homebrew";
76
+ const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
77
+ const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
78
+ "brew --prefix": { stdout: `${PREFIX}\n` },
79
+ };
80
+
81
+ const OUTDATED_NONE = JSON.stringify({ formulae: [], casks: [] });
82
+ const outdatedBehind = (name: string, installed: string, current: string) =>
83
+ JSON.stringify({
84
+ formulae: [
85
+ {
86
+ name,
87
+ installed_versions: [installed],
88
+ current_version: current,
89
+ pinned: false,
90
+ },
91
+ ],
92
+ casks: [],
93
+ });
94
+
95
+ /** A tool whose PATH entry resolves into a Homebrew keg. */
96
+ function kegWorld(
97
+ bin: string,
98
+ short: string,
99
+ version: string,
100
+ outdatedKey: string,
101
+ outdatedStdout: string,
102
+ extraExec: Record<string, Partial<ExecResult>> = BREW_PREFIX_OK,
103
+ ): FakeSetup {
104
+ const file = `${PREFIX}/Cellar/${short}/${version}/bin/${bin}`;
105
+ const onPath = `${PREFIX}/bin/${bin}`;
106
+ return {
107
+ onPath: { [bin]: onPath },
108
+ real: { [onPath]: file, [file]: file, ...CELLAR },
109
+ exec: {
110
+ ...extraExec,
111
+ [`brew outdated --json=v2 ${outdatedKey}`]: { stdout: outdatedStdout },
112
+ },
113
+ };
114
+ }
115
+
116
+ const MNEMEX: BinRequirement = {
117
+ name: "mnemex",
118
+ via: "brew",
119
+ formula: "madappgang/tap/mnemex",
120
+ };
121
+
122
+ describe("C18 case 1: tap formula, brew spec, current", () => {
123
+ test("managed, version from the keg, state current", async () => {
124
+ const f = await probeTool(
125
+ MNEMEX,
126
+ fakeIO(
127
+ kegWorld(
128
+ "mnemex",
129
+ "mnemex",
130
+ "0.36.2",
131
+ "madappgang/tap/mnemex",
132
+ OUTDATED_NONE,
133
+ ),
134
+ ),
135
+ );
136
+ expect(f.managed).toBe(true);
137
+ expect(f.installed).toBe("0.36.2");
138
+ expect(classifyTool(MNEMEX, f).state).toBe("current");
139
+ });
140
+ });
141
+
142
+ describe("C18 case 2: tap formula behind", () => {
143
+ test("state upgrade, latest from brew", async () => {
144
+ const f = await probeTool(
145
+ MNEMEX,
146
+ fakeIO(
147
+ kegWorld(
148
+ "mnemex",
149
+ "mnemex",
150
+ "0.36.2",
151
+ "madappgang/tap/mnemex",
152
+ outdatedBehind("madappgang/tap/mnemex", "0.36.2", "0.37.0"),
153
+ ),
154
+ ),
155
+ );
156
+ expect(f.managed).toBe(true);
157
+ expect(f.latest).toBe("0.37.0");
158
+ expect(classifyTool(MNEMEX, f).state).toBe("upgrade");
159
+ });
160
+ });
161
+
162
+ describe("C18 case 3: bun spec, file in a Homebrew keg", () => {
163
+ const spec: BinRequirement = {
164
+ name: "claudish",
165
+ via: "bun",
166
+ package: "claudish",
167
+ };
168
+ test("adopted by brew: brew upgrade command, never bun", async () => {
169
+ const f = await probeTool(
170
+ spec,
171
+ fakeIO(
172
+ kegWorld(
173
+ "claudish",
174
+ "claudish",
175
+ "10.5.0",
176
+ "claudish",
177
+ outdatedBehind("claudish", "10.5.0", "10.6.0"),
178
+ ),
179
+ ),
180
+ );
181
+ expect(f.managed).toBe(true);
182
+ expect(f.installed).toBe("10.5.0");
183
+ expect(f.latest).toBe("10.6.0");
184
+ expect(f.upgradeCommand).toEqual({
185
+ cmd: "brew",
186
+ args: ["upgrade", "claudish"],
187
+ });
188
+ expect(f.upgradeCommand?.cmd).not.toBe("bun");
189
+ expect(classifyTool(spec, f).state).toBe("upgrade");
190
+ });
191
+ });
192
+
193
+ describe("C18 case 4: npm spec, file in a Homebrew keg", () => {
194
+ const spec: BinRequirement = {
195
+ name: "claudish",
196
+ via: "npm",
197
+ package: "claudish",
198
+ };
199
+ test("current, upgrade command is brew", async () => {
200
+ const f = await probeTool(
201
+ spec,
202
+ fakeIO(
203
+ kegWorld("claudish", "claudish", "10.5.0", "claudish", OUTDATED_NONE),
204
+ ),
205
+ );
206
+ expect(f.upgradeCommand?.cmd).toBe("brew");
207
+ expect(classifyTool(spec, f).state).toBe("current");
208
+ });
209
+ });
210
+
211
+ describe("C18 case 5: a pinned spec is not adopted by Homebrew", () => {
212
+ const spec: BinRequirement = {
213
+ name: "claudish",
214
+ via: "bun",
215
+ package: "claudish",
216
+ version: "10.4.0",
217
+ };
218
+ test("unknown, no brew command, note mentions the pin", async () => {
219
+ const f = await probeTool(
220
+ spec,
221
+ fakeIO(
222
+ kegWorld(
223
+ "claudish",
224
+ "claudish",
225
+ "10.5.0",
226
+ "claudish",
227
+ outdatedBehind("claudish", "10.5.0", "10.6.0"),
228
+ ),
229
+ ),
230
+ );
231
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
232
+ const s = classifyTool(spec, f);
233
+ expect(s.state).toBe("unknown");
234
+ expect(s.note ?? "").toContain("10.4.0");
235
+ });
236
+ });
237
+
238
+ describe("C18 case 6: a directory merely named Cellar is not Homebrew's", () => {
239
+ const spec: BinRequirement = {
240
+ name: "claudish",
241
+ via: "bun",
242
+ package: "claudish",
243
+ };
244
+ test("not adopted: external, no upgrade command", async () => {
245
+ const file = "/Users/u/projects/Cellar/claudish/1.0.0/bin/claudish";
246
+ const f = await probeTool(
247
+ spec,
248
+ fakeIO({
249
+ onPath: { claudish: file },
250
+ real: { [file]: file, ...CELLAR },
251
+ exec: BREW_PREFIX_OK,
252
+ }),
253
+ );
254
+ expect(f.managed).not.toBe(true);
255
+ expect(f.upgradeCommand).toBeUndefined();
256
+ expect(classifyTool(spec, f).state).toBe("external");
257
+ });
258
+ });
259
+
260
+ describe("C18 case 7: Homebrew unavailable", () => {
261
+ const spec: BinRequirement = {
262
+ name: "claudish",
263
+ via: "bun",
264
+ package: "claudish",
265
+ };
266
+ const world = (exec: Record<string, Partial<ExecResult>>): FakeSetup =>
267
+ kegWorld(
268
+ "claudish",
269
+ "claudish",
270
+ "10.5.0",
271
+ "claudish",
272
+ outdatedBehind("claudish", "10.5.0", "10.6.0"),
273
+ exec,
274
+ );
275
+ test("brew --prefix missing: not adopted", async () => {
276
+ const f = await probeTool(spec, fakeIO(world({})));
277
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
278
+ expect(classifyTool(spec, f).state).not.toBe("upgrade");
279
+ });
280
+ test("brew --prefix exits non-zero: not adopted", async () => {
281
+ const f = await probeTool(
282
+ spec,
283
+ fakeIO(world({ "brew --prefix": { code: 1, stderr: "boom" } })),
284
+ );
285
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
286
+ expect(classifyTool(spec, f).state).not.toBe("upgrade");
287
+ });
288
+ });
289
+
290
+ describe("C18 case 8: a non-tap brew spec still works", () => {
291
+ test("tmux current, installed 3.8", async () => {
292
+ const spec: BinRequirement = {
293
+ name: "tmux",
294
+ via: "brew",
295
+ formula: "tmux",
296
+ check: "tmux -V",
297
+ };
298
+ const f = await probeTool(
299
+ spec,
300
+ fakeIO(kegWorld("tmux", "tmux", "3.8", "tmux", OUTDATED_NONE)),
301
+ );
302
+ expect(f.managed).toBe(true);
303
+ expect(f.installed).toBe("3.8");
304
+ expect(classifyTool(spec, f).state).toBe("current");
305
+ });
306
+ });
307
+
308
+ describe("C18 case 9: bun ownership still wins over a brew spec", () => {
309
+ test("file in bun's global tree: bun command, current", async () => {
310
+ const root = "/Users/j/.bun/install/global/node_modules";
311
+ const pkgDir = `${root}/mnemex`;
312
+ const file = `${pkgDir}/dist/index.js`;
313
+ const onPath = "/Users/j/.bun/bin/mnemex";
314
+ const f = await probeTool(
315
+ MNEMEX,
316
+ fakeIO({
317
+ env: { HOMEBREW_PREFIX: PREFIX },
318
+ onPath: { mnemex: onPath },
319
+ real: {
320
+ [onPath]: file,
321
+ [file]: file,
322
+ [pkgDir]: pkgDir,
323
+ [root]: root,
324
+ ...CELLAR,
325
+ },
326
+ files: {
327
+ [`${pkgDir}/package.json`]: JSON.stringify({
328
+ name: "mnemex",
329
+ version: "0.36.2",
330
+ }),
331
+ },
332
+ exec: BREW_PREFIX_OK,
333
+ fetch: {
334
+ "https://registry.npmjs.org/-/package/mnemex/dist-tags": {
335
+ ok: true,
336
+ body: { latest: "0.36.2" },
337
+ },
338
+ },
339
+ }),
340
+ );
341
+ expect(f.managed).toBe(true);
342
+ expect(f.upgradeCommand?.cmd).toBe("bun");
343
+ expect(classifyTool(MNEMEX, f).state).toBe("current");
344
+ });
345
+ });
@@ -213,9 +213,7 @@ 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(
217
- "installed outside npm, so magus leaves it as is",
218
- );
216
+ expect(row.status?.note).toBe("installed outside npm");
219
217
  expect(row.status?.note).not.toContain("/");
220
218
  // Enter still follows how it IS installed (pnpm).
221
219
  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, so magus leaves it as is");
241
+ expect(s.note).toBe("installed outside npm");
242
242
  expect(s.note).not.toContain("/");
243
243
  });
244
244
 
@@ -254,7 +254,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
254
254
  spec({ via: "brew" }),
255
255
  facts({ managed: false, behind: true }),
256
256
  ).note,
257
- ).toBe("installed outside Homebrew, so magus leaves it as is");
257
+ ).toBe("installed outside Homebrew");
258
258
  });
259
259
 
260
260
  test("7. held back → unknown, with the hold as the note", () => {
@@ -458,6 +458,10 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
458
458
  item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
459
459
  }`;
460
460
  case "external":
461
+ // Kept as is: a missing version is not a finding, so no "—" stands in.
462
+ return [item.installed, item.note ? dim(`(${item.note})`) : ""]
463
+ .filter(Boolean)
464
+ .join(" ");
461
465
  case "unknown":
462
466
  return `${item.installed ?? "—"}${
463
467
  item.note ? ` ${dim(`(${item.note})`)}` : ""
@@ -52,6 +52,8 @@ import {
52
52
  versionFromVersionedPath,
53
53
  } from "./cli-tool-version.js";
54
54
  import {
55
+ binPinCommand,
56
+ binUpgradeCommand,
55
57
  claudeNativeUpgradeCommand,
56
58
  npmPrefixInstallCommand,
57
59
  } from "./toolchain.js";
@@ -231,6 +233,15 @@ export function installerWord(via: ManagedTool["via"]): string {
231
233
  return via === "brew" ? "Homebrew" : via;
232
234
  }
233
235
 
236
+ /** The directories the operating system itself installs programs into. */
237
+ const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
238
+
239
+ function isSystemPath(resolved: string | null): boolean {
240
+ return (
241
+ resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
242
+ );
243
+ }
244
+
234
245
  /**
235
246
  * The `not-managed` sentence, in the user's terms: who did not install it,
236
247
  * and what magus does about it. Never the path or the installer's directory:
@@ -241,8 +252,12 @@ function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
241
252
  if (facts.managed === false && pin) {
242
253
  return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
243
254
  }
255
+ // The KEPT chip already says magus leaves it as is; the note says only who
256
+ // installed it.
244
257
  return facts.managed === false
245
- ? `installed outside ${installerWord(spec.via)}, so magus leaves it as is`
258
+ ? isSystemPath(facts.resolvedPath)
259
+ ? "comes with the operating system"
260
+ : `installed outside ${installerWord(spec.via)}`
246
261
  : withFailure(
247
262
  `could not tell whether ${installerWord(spec.via)} installed it`,
248
263
  facts.failure,
@@ -1223,6 +1238,9 @@ async function probeBrew(
1223
1238
  resolvedPath: string,
1224
1239
  ): Promise<void> {
1225
1240
  const formula = spec.formula ?? spec.name;
1241
+ // A tap formula ("user/tap/f") keeps its keg under the short name, so the
1242
+ // full name never matched and a Homebrew mnemex read as installed outside it.
1243
+ const keg = formula.slice(formula.lastIndexOf("/") + 1);
1226
1244
  const prefix = await s.brewPrefix();
1227
1245
  if ("failure" in prefix) {
1228
1246
  f.managed = null;
@@ -1232,11 +1250,11 @@ async function probeBrew(
1232
1250
  const cellar =
1233
1251
  (await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
1234
1252
  path.join(prefix.dir, "Cellar");
1235
- const ownerDir = path.join(cellar, formula);
1253
+ const ownerDir = path.join(cellar, keg);
1236
1254
  f.owner = `brew (Cellar ${ownerDir})`;
1237
1255
  f.managed = isInside(resolvedPath, ownerDir);
1238
1256
  if (!f.managed) return;
1239
- f.installed = brewKegVersion(resolvedPath, cellar, formula);
1257
+ f.installed = brewKegVersion(resolvedPath, cellar, keg);
1240
1258
  if (f.installed) addSource(f, "brew");
1241
1259
 
1242
1260
  const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
@@ -1386,6 +1404,129 @@ async function readNodePackage(
1386
1404
  if (!pinned) await npmLatest(s, pkg, f);
1387
1405
  }
1388
1406
 
1407
+ /** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
1408
+ function bunGlobalRoot(io: ToolProbeIO): string {
1409
+ return path.join(
1410
+ io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
1411
+ "install",
1412
+ "global",
1413
+ "node_modules",
1414
+ );
1415
+ }
1416
+
1417
+ /** The package a path names, from its segments under `node_modules`. */
1418
+ function packageFromSegments(rel: string): string | null {
1419
+ const [first, second] = rel.split(path.sep);
1420
+ if (!first || first.startsWith(".")) return null;
1421
+ if (!first.startsWith("@")) return first;
1422
+ return second ? `${first}/${second}` : null;
1423
+ }
1424
+
1425
+ /**
1426
+ * The bun or npm global package that holds `resolvedPath`, read from the path
1427
+ * alone. Null when the file lies in neither tree (a Homebrew keg included:
1428
+ * `npmGlobalPrefixOf` refuses one).
1429
+ */
1430
+ async function nodeGlobalOwner(
1431
+ s: ProbeSession,
1432
+ resolvedPath: string,
1433
+ ): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
1434
+ const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
1435
+ if (bunRoot && isInside(resolvedPath, bunRoot)) {
1436
+ const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
1437
+ return pkg ? { via: "bun", pkg } : null;
1438
+ }
1439
+ const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
1440
+ const at = resolvedPath.lastIndexOf(marker);
1441
+ if (at < 0) return null;
1442
+ const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
1443
+ return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
1444
+ ? { via: "npm", pkg }
1445
+ : null;
1446
+ }
1447
+
1448
+ /**
1449
+ * The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
1450
+ * read from the path alone. `probeBrew` then checks that this Cellar is brew's.
1451
+ */
1452
+ function kegFormulaOf(resolvedPath: string): string | null {
1453
+ const parts = resolvedPath.split(path.sep);
1454
+ const at = parts.lastIndexOf("Cellar");
1455
+ return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
1456
+ }
1457
+
1458
+ /**
1459
+ * The spec as the installer that actually holds `resolvedPath` would state it,
1460
+ * or null when that is the spec's own installer or none magus drives.
1461
+ */
1462
+ async function actualOwnerSpec(
1463
+ s: ProbeSession,
1464
+ spec: ManagedTool,
1465
+ resolvedPath: string,
1466
+ ): Promise<ManagedTool | null> {
1467
+ const base = {
1468
+ name: spec.name,
1469
+ ...(spec.check ? { check: spec.check } : {}),
1470
+ ...(spec.version ? { version: spec.version } : {}),
1471
+ };
1472
+ const node = await nodeGlobalOwner(s, resolvedPath);
1473
+ if (node) {
1474
+ return node.via === spec.via
1475
+ ? null
1476
+ : { ...base, via: node.via, package: node.pkg };
1477
+ }
1478
+ const formula = kegFormulaOf(resolvedPath);
1479
+ // Homebrew takes no version, so it cannot honour a pin: such a tool stays
1480
+ // "installed outside", which the classifier reports as unknown.
1481
+ if (!formula || spec.via === "brew" || spec.version) return null;
1482
+ return { ...base, via: "brew", formula };
1483
+ }
1484
+
1485
+ /**
1486
+ * The spec's installer does not own the file PATH runs, but bun's or npm's
1487
+ * global tree or a Homebrew keg does: re-read the tool through THAT installer.
1488
+ *
1489
+ * A plugin's `requires` lists its install options in preference order for a
1490
+ * FRESH install, and magus plans through the first. That order says how to
1491
+ * install a missing tool, not which owner an installed one must have. A
1492
+ * mnemex from `bun add -g` or from Homebrew has a version, a registry and an
1493
+ * upgrade command like any other. Reading it as "installed outside" the
1494
+ * first option's installer showed a settled, upgradeable tool as one magus
1495
+ * could not touch.
1496
+ *
1497
+ * The catalogue's tools already follow how a tool IS installed
1498
+ * (`specForCatalogTool`); this gives a plugin's requirements the same rule.
1499
+ * The upgrade command is carried on the facts, so `magus update` and the TUI
1500
+ * run the owner's command rather than the spec's.
1501
+ */
1502
+ async function adoptActualOwner(
1503
+ s: ProbeSession,
1504
+ spec: ManagedTool,
1505
+ f: ToolFacts,
1506
+ resolvedPath: string,
1507
+ ): Promise<void> {
1508
+ const actual = await actualOwnerSpec(s, spec, resolvedPath);
1509
+ if (!actual) return;
1510
+ const read = blankFacts(spec.name);
1511
+ if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
1512
+ else await probeNodeGlobal(s, actual, read, resolvedPath);
1513
+ if (read.managed !== true) return;
1514
+ f.managed = true;
1515
+ f.owner = read.owner;
1516
+ f.installed = read.installed;
1517
+ f.latest = read.latest;
1518
+ f.behind = read.behind;
1519
+ // What the spec's installer could not answer is moot once the owner is
1520
+ // known: only the owner's own failure describes this tool.
1521
+ f.failure = read.failure;
1522
+ for (const source of read.sources) addSource(f, source);
1523
+ f.upgradeCommand =
1524
+ read.upgradeCommand ??
1525
+ (spec.version
1526
+ ? (binPinCommand(actual) ?? undefined)
1527
+ : binUpgradeCommand(actual));
1528
+ }
1529
+
1389
1530
  async function probeNodeGlobal(
1390
1531
  s: ProbeSession,
1391
1532
  spec: ManagedTool,
@@ -1396,12 +1537,7 @@ async function probeNodeGlobal(
1396
1537
  const pin = pinOf(spec);
1397
1538
  const pinned = pin !== undefined;
1398
1539
  if (spec.via === "bun") {
1399
- const root = path.join(
1400
- s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
1401
- "install",
1402
- "global",
1403
- "node_modules",
1404
- );
1540
+ const root = bunGlobalRoot(s.io);
1405
1541
  f.owner = `bun (global ${root})`;
1406
1542
  const { inside, real } = await containedIn(
1407
1543
  s,
@@ -1654,6 +1790,11 @@ async function probeInto(
1654
1790
  break;
1655
1791
  }
1656
1792
 
1793
+ // Not the spec's installer's file, or it could not tell (npm with no `npm`
1794
+ // to ask), but the path names bun's, npm's or Homebrew's tree: then that is
1795
+ // the owner, and it can upgrade the file PATH runs.
1796
+ if (f.managed !== true) await adoptActualOwner(s, spec, f, resolvedPath);
1797
+
1657
1798
  // The method's own record was silent. Before anything runs, read what the
1658
1799
  // installed files already say about themselves.
1659
1800
  if (f.installed === null) versionFromFiles(f, resolvedPath);
@@ -397,7 +397,7 @@ async function readRows(
397
397
  * The row Claude Code moved is left as it is: it belongs to this repository,
398
398
  * and bringing this repository's rows forward is what the repair does anyway.
399
399
  * Null when there was nothing to follow — this project's row moved (2.1.280),
400
- * or no row of this repository moved at all.
400
+ * or no row of this repository moved at all and none already holds `target`.
401
401
  */
402
402
  async function followMisdirectedUpdate(
403
403
  deps: PluginApplyDeps,
@@ -422,14 +422,32 @@ async function followMisdirectedUpdate(
422
422
  scope,
423
423
  projectPath: scope === "user" ? undefined : projectPath,
424
424
  });
425
- if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
426
- return null;
427
- }
428
- const version = moved
425
+ if (moved.some((row) => rowKey(row) === mine)) return null;
426
+ // The row Claude Code picked may ALREADY hold the target — an earlier run
427
+ // from another checkout put it there. Then the update writes nothing, so
428
+ // nothing moves, and this project's row stays behind on every run (seen on
429
+ // 2.1.296: "the installer exited 0 but mnemex@magus is still 1.1.0" while
430
+ // tools/claudeup held 1.1.1). A row of this repository at exactly the
431
+ // target is the same misdirection, already landed.
432
+ const landed =
433
+ moved.length > 0 || !target
434
+ ? moved
435
+ : after.filter(
436
+ (row) =>
437
+ row.version === target &&
438
+ rowKey(row) !== mine &&
439
+ inThisRepository(row, projectPath),
440
+ );
441
+ if (landed.length === 0) return null;
442
+ const version = landed
429
443
  .map((row) => row.version)
430
444
  .reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
431
445
  if (!isRepairablyBehind(version, now)) return null;
432
- const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
446
+ // Already landed, every checkout at the target matches, but Claude Code
447
+ // wrote to one of them: report one folder, not all of them.
448
+ const elsewhere = (moved.length > 0 ? landed : landed.slice(0, 1)).map(
449
+ (row) => row.projectPath ?? `${row.scope} scope`,
450
+ );
433
451
  if (target && version !== target) {
434
452
  return {
435
453
  elsewhere,