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,669 @@
1
+ /**
2
+ * BLACK-BOX: R3 (one tool probe), R4 (no unconditional upgrade), R5 (pins are
3
+ * checked). Written from requirements.md, baseline-tools.md and §8.2/§8.3 only.
4
+ *
5
+ * Probe IO is a scripted fake `ToolProbeIO` that answers commands BY PATTERN
6
+ * (the exact argv the probe issues is an implementation detail). Output bodies
7
+ * are the real captures in fixtures/tool-probe/ and baseline-tools.md.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import {
13
+ type ExecResult,
14
+ type FetchJsonResult,
15
+ type ProbeProgress,
16
+ type ToolFacts,
17
+ type ToolProbeIO,
18
+ classifyTool,
19
+ compareToolVersions,
20
+ probeTool,
21
+ probeTools,
22
+ } from "../services/tool-probe.js";
23
+ import { planWorkItems } from "../services/update-engine.js";
24
+ import {
25
+ type BinUpdateItem,
26
+ type PluginUpdateItem,
27
+ type UpdatePlan,
28
+ binNeedsWork,
29
+ planBinUpdates,
30
+ planHasWork,
31
+ planIsBehind,
32
+ summarizePlan,
33
+ } from "../services/update-plan.js";
34
+ import type { BinRequirement, ResolvedBin } from "../types/index.js";
35
+ import type { Command } from "../utils/command-utils.js";
36
+
37
+ // ── helpers ────────────────────────────────────────────────────────────────
38
+
39
+ function facts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
40
+ return {
41
+ name,
42
+ present: true,
43
+ resolvedPath: `/usr/local/bin/${name}`,
44
+ installed: "1.0.0",
45
+ latest: null,
46
+ behind: null,
47
+ managed: true,
48
+ sources: ["path"],
49
+ ...over,
50
+ };
51
+ }
52
+
53
+ function bin(name: string, over: Partial<ResolvedBin> = {}): ResolvedBin {
54
+ return { name, via: "npm", sources: ["test"], ...over };
55
+ }
56
+
57
+ function binItem(name: string, action: BinUpdateItem["action"]): BinUpdateItem {
58
+ return {
59
+ name,
60
+ action,
61
+ reason: action === "unknown" ? "latest-unavailable" : "up-to-date",
62
+ installed: "1.0.0",
63
+ latest: null,
64
+ target: null,
65
+ sources: ["test"],
66
+ };
67
+ }
68
+
69
+ function planOf(
70
+ bins: BinUpdateItem[],
71
+ plugins: PluginUpdateItem[] = [],
72
+ ): UpdatePlan {
73
+ return {
74
+ profileId: "default",
75
+ plugins,
76
+ bins,
77
+ skills: [],
78
+ binSpecs: new Map(bins.map((b) => [b.name, bin(b.name)])),
79
+ };
80
+ }
81
+
82
+ const FIXTURES = join(import.meta.dir, "fixtures", "tool-probe");
83
+ function capture(file: string): string {
84
+ return readFileSync(join(FIXTURES, file), "utf8")
85
+ .split("\n")
86
+ .filter((l) => !l.startsWith("#"))
87
+ .join("\n");
88
+ }
89
+
90
+ const OK = (stdout: string): ExecResult => ({
91
+ code: 0,
92
+ stdout,
93
+ stderr: "",
94
+ timedOut: false,
95
+ missing: false,
96
+ });
97
+ const NOPE: ExecResult = {
98
+ code: 1,
99
+ stdout: "",
100
+ stderr: "fake: no scripted answer",
101
+ timedOut: false,
102
+ missing: false,
103
+ };
104
+ const MISSING: ExecResult = {
105
+ code: null,
106
+ stdout: "",
107
+ stderr: "",
108
+ timedOut: false,
109
+ missing: true,
110
+ };
111
+ const TIMED_OUT: ExecResult = {
112
+ code: null,
113
+ stdout: "",
114
+ stderr: "",
115
+ timedOut: true,
116
+ missing: false,
117
+ };
118
+
119
+ interface FakeIOSpec {
120
+ exec?: (argv: string[]) => ExecResult | Promise<ExecResult> | undefined;
121
+ paths?: Record<string, string>; // executable name → PATH location
122
+ realpaths?: Record<string, string>;
123
+ texts?: Record<string, string>;
124
+ env?: Record<string, string>;
125
+ fetch?: (url: string) => FetchJsonResult | undefined;
126
+ }
127
+
128
+ function fakeIO(spec: FakeIOSpec): ToolProbeIO & { calls: string[] } {
129
+ const calls: string[] = [];
130
+ return {
131
+ calls,
132
+ async exec(command: Command) {
133
+ const argv = [command.cmd, ...command.args];
134
+ calls.push(argv.join(" "));
135
+ const base = command.cmd.split("/").pop() ?? command.cmd;
136
+ if (spec.paths && !(base in spec.paths) && !command.cmd.includes("/"))
137
+ return MISSING;
138
+ return (await spec.exec?.([base, ...command.args])) ?? NOPE;
139
+ },
140
+ async fetchJson(url: string) {
141
+ calls.push(`fetch ${url}`);
142
+ return spec.fetch?.(url) ?? { ok: false, reason: "fake: offline" };
143
+ },
144
+ async readJson() {
145
+ return null;
146
+ },
147
+ async readText(file: string) {
148
+ return spec.texts?.[file] ?? null;
149
+ },
150
+ async readlink() {
151
+ return null;
152
+ },
153
+ async realpath(file: string) {
154
+ return spec.realpaths?.[file] ?? file;
155
+ },
156
+ async resolveExecutable(name: string) {
157
+ return spec.paths?.[name] ?? null;
158
+ },
159
+ env: {
160
+ PATH: "/opt/homebrew/bin:/usr/bin:/bin",
161
+ HOME: "/Users/test",
162
+ ...spec.env,
163
+ },
164
+ homedir: "/Users/test",
165
+ };
166
+ }
167
+
168
+ // ── classifyTool ───────────────────────────────────────────────────────────
169
+
170
+ describe("classifyTool — unpinned", () => {
171
+ const spec: BinRequirement = { name: "tmux", via: "brew" };
172
+
173
+ test("not on PATH: install", () => {
174
+ const got = classifyTool(
175
+ spec,
176
+ facts("tmux", {
177
+ present: false,
178
+ resolvedPath: null,
179
+ installed: null,
180
+ managed: null,
181
+ }),
182
+ );
183
+ expect(got.state).toBe("install");
184
+ });
185
+
186
+ test("installer says not behind (baseline tmux 3.7c): current", () => {
187
+ const got = classifyTool(
188
+ spec,
189
+ facts("tmux", { installed: "3.7c", behind: false }),
190
+ );
191
+ expect(got.state).toBe("current");
192
+ });
193
+
194
+ test("installer says behind, and the upgrade would replace the file: upgrade with a reason note", () => {
195
+ const got = classifyTool(
196
+ spec,
197
+ facts("tmux", { installed: "3.7b", latest: "3.7c", behind: true }),
198
+ );
199
+ expect({ state: got.state, reason: got.reason }).toEqual({
200
+ state: "upgrade",
201
+ reason: "behind",
202
+ });
203
+ expect(got.note).toBeTruthy();
204
+ });
205
+
206
+ test("installed version below the latest with no installer verdict: upgrade to latest", () => {
207
+ const got = classifyTool(
208
+ { name: "codex", via: "npm" },
209
+ facts("codex", { installed: "0.40.0", latest: "0.41.0" }),
210
+ );
211
+ expect({ state: got.state, target: got.target }).toEqual({
212
+ state: "upgrade",
213
+ target: "0.41.0",
214
+ });
215
+ });
216
+
217
+ test("installed equal to latest: current", () => {
218
+ const got = classifyTool(
219
+ { name: "claudish", via: "bun" },
220
+ facts("claudish", { installed: "9.3.0", latest: "9.3.0" }),
221
+ );
222
+ expect(got.state).toBe("current");
223
+ });
224
+
225
+ test("no latest and no verdict: unknown with a reason — not current, not upgrade", () => {
226
+ const got = classifyTool(
227
+ { name: "gemini", via: "npm" },
228
+ facts("gemini", { installed: "0.5.0" }),
229
+ );
230
+ expect(got.state).toBe("unknown");
231
+ expect(got.note).toBeTruthy();
232
+ });
233
+
234
+ test("installed version unreadable: unknown / version-unreadable", () => {
235
+ const got = classifyTool(
236
+ { name: "x", via: "npm" },
237
+ facts("x", { installed: null, latest: "2.0.0" }),
238
+ );
239
+ expect({ state: got.state, reason: got.reason }).toEqual({
240
+ state: "unknown",
241
+ reason: "version-unreadable",
242
+ });
243
+ });
244
+
245
+ test("probe timed out: unknown, never upgrade", () => {
246
+ const got = classifyTool(
247
+ { name: "gemini", via: "npm" },
248
+ facts("gemini", { installed: "0.5.0", failure: "timed out after 8s" }),
249
+ );
250
+ expect(got.state).toBe("unknown");
251
+ });
252
+
253
+ test("behind, but the upgrade would not replace the file PATH runs: not upgrade / not-managed", () => {
254
+ const got = classifyTool(
255
+ { name: "crush", via: "npm" },
256
+ facts("crush", {
257
+ installed: "0.7.0",
258
+ latest: "0.8.0",
259
+ behind: true,
260
+ managed: false,
261
+ }),
262
+ );
263
+ expect(got.state).not.toBe("upgrade");
264
+ expect(got.reason).toBe("not-managed");
265
+ });
266
+
267
+ test("behind, but held back by the installer: not upgrade / held-back", () => {
268
+ const got = classifyTool(
269
+ spec,
270
+ facts("tmux", {
271
+ installed: "3.7b",
272
+ latest: "3.7c",
273
+ behind: true,
274
+ heldBack: "held by brew pin",
275
+ }),
276
+ );
277
+ expect(got.state).not.toBe("upgrade");
278
+ expect(got.reason).toBe("held-back");
279
+ });
280
+ });
281
+
282
+ describe("classifyTool — pinned (R5)", () => {
283
+ const pinned = (version: string): BinRequirement => ({
284
+ name: "mnemex",
285
+ via: "bun",
286
+ version,
287
+ });
288
+
289
+ test("installed at the pin: current / at-pin", () => {
290
+ const got = classifyTool(
291
+ pinned("1.0.0"),
292
+ facts("mnemex", { installed: "1.0.0", latest: "1.2.0" }),
293
+ );
294
+ expect({ state: got.state, reason: got.reason }).toEqual({
295
+ state: "current",
296
+ reason: "at-pin",
297
+ });
298
+ });
299
+
300
+ test("installed behind the pin: upgrade / behind-pin, aiming at the pin not at latest", () => {
301
+ const got = classifyTool(
302
+ pinned("1.0.0"),
303
+ facts("mnemex", { installed: "0.9.0", latest: "1.2.0" }),
304
+ );
305
+ expect({
306
+ state: got.state,
307
+ reason: got.reason,
308
+ target: got.target,
309
+ }).toEqual({
310
+ state: "upgrade",
311
+ reason: "behind-pin",
312
+ target: "1.0.0",
313
+ });
314
+ });
315
+
316
+ test("installed ahead of the pin: not current, and no upgrade planned", () => {
317
+ const got = classifyTool(
318
+ pinned("1.0.0"),
319
+ facts("mnemex", { installed: "1.1.0", latest: "1.2.0" }),
320
+ );
321
+ expect(got.state).not.toBe("current");
322
+ expect(got.state).not.toBe("upgrade");
323
+ });
324
+
325
+ test("installed version unorderable against the pin: not current", () => {
326
+ const got = classifyTool(
327
+ pinned("1.0.0"),
328
+ facts("mnemex", { installed: "3ea32df27be7" }),
329
+ );
330
+ expect(got.state).not.toBe("current");
331
+ });
332
+
333
+ test("go tool pinned v2.0.0 with v2.0.0 installed (tmux-mcp): current / at-pin", () => {
334
+ const got = classifyTool(
335
+ {
336
+ name: "tmux-mcp",
337
+ via: "go",
338
+ module: "github.com/MadAppGang/tmux-mcp/v2",
339
+ version: "v2.0.0",
340
+ },
341
+ facts("tmux-mcp", { installed: "v2.0.0" }),
342
+ );
343
+ expect({ state: got.state, reason: got.reason }).toEqual({
344
+ state: "current",
345
+ reason: "at-pin",
346
+ });
347
+ });
348
+ });
349
+
350
+ describe("compareToolVersions", () => {
351
+ test("orders semver pairs and refuses a commit sha against a semver", () => {
352
+ expect(compareToolVersions("1.0.0", "1.0.1")).toBe(-1);
353
+ expect(compareToolVersions("2.0.0", "1.9.9")).toBe(1);
354
+ expect(compareToolVersions("1.2.3", "1.2.3")).toBe(0);
355
+ expect(compareToolVersions("3ea32df27be7", "1.0.0")).toBeNull();
356
+ });
357
+ });
358
+
359
+ // ── plan predicates ────────────────────────────────────────────────────────
360
+
361
+ describe("planBinUpdates and the one work predicate", () => {
362
+ test("a bin with no facts is unknown / not-probed, never upgrade", () => {
363
+ const [item] = planBinUpdates([bin("orphan-tool")], []);
364
+ expect({ action: item?.action, reason: item?.reason }).toEqual({
365
+ action: "unknown",
366
+ reason: "not-probed",
367
+ });
368
+ });
369
+
370
+ test("the exact pin is carried on the item; the three baseline tools plan current", () => {
371
+ const items = planBinUpdates(
372
+ [
373
+ bin("tmux", { via: "brew" }),
374
+ bin("browser-use", { via: "pip" }),
375
+ bin("mcp", { via: "pip" }),
376
+ bin("mnemex", { via: "bun", version: "1.0.0" }),
377
+ ],
378
+ [
379
+ facts("tmux", { installed: "3.7c", behind: false }),
380
+ facts("browser-use", { installed: "0.13.10", behind: false }),
381
+ facts("mcp", { installed: "2.2.0", behind: false }),
382
+ facts("mnemex", { installed: "1.0.0" }),
383
+ ],
384
+ );
385
+ expect(items.map((i) => [i.name, i.action])).toEqual([
386
+ ["tmux", "current"],
387
+ ["browser-use", "current"],
388
+ ["mcp", "current"],
389
+ ["mnemex", "current"],
390
+ ]);
391
+ expect(items.find((i) => i.name === "mnemex")?.version).toBe("1.0.0");
392
+ });
393
+
394
+ test("binNeedsWork, planWorkItems, planHasWork and planIsBehind agree for every action", () => {
395
+ const table: Array<[BinUpdateItem["action"], boolean]> = [
396
+ ["install", true],
397
+ ["upgrade", true],
398
+ ["current", false],
399
+ ["unknown", false],
400
+ ];
401
+ for (const [action, work] of table) {
402
+ const it = binItem(`t-${action}`, action);
403
+ const plan = planOf([it]);
404
+ expect({ action, needsWork: binNeedsWork(it) }).toEqual({
405
+ action,
406
+ needsWork: work,
407
+ });
408
+ expect({ action, inWork: planWorkItems(plan).includes(it.name) }).toEqual(
409
+ { action, inWork: work },
410
+ );
411
+ expect({ action, hasWork: planHasWork(plan) }).toEqual({
412
+ action,
413
+ hasWork: work,
414
+ });
415
+ expect({ action, behind: planIsBehind(plan) }).toEqual({
416
+ action,
417
+ behind: work,
418
+ });
419
+ }
420
+ });
421
+
422
+ test("summarizePlan splits unknown into plugins and tools and sums them", () => {
423
+ const unknownPlugin: PluginUpdateItem = {
424
+ pluginId: "code-review@claude-plugins-official",
425
+ pinned: "latest",
426
+ installed: "3ea32df27be7",
427
+ available: null,
428
+ target: null,
429
+ action: "unknown",
430
+ reason: "no-published-version",
431
+ scopes: [],
432
+ beforeVersions: {},
433
+ note: "n",
434
+ };
435
+ const s = summarizePlan(
436
+ planOf(
437
+ [
438
+ binItem("gemini", "unknown"),
439
+ binItem("x", "unknown"),
440
+ binItem("tmux", "current"),
441
+ ],
442
+ [unknownPlugin],
443
+ ),
444
+ );
445
+ expect({
446
+ unknownPlugins: s.unknownPlugins,
447
+ unknownTools: s.unknownTools,
448
+ unknown: s.unknown,
449
+ }).toEqual({
450
+ unknownPlugins: 1,
451
+ unknownTools: 2,
452
+ unknown: 3,
453
+ });
454
+ });
455
+ });
456
+
457
+ // ── the probe, through injected IO ─────────────────────────────────────────
458
+
459
+ function brewIO(opts: { installed: string; outdatedJson: string }) {
460
+ return fakeIO({
461
+ paths: { tmux: "/opt/homebrew/bin/tmux", brew: "/opt/homebrew/bin/brew" },
462
+ realpaths: {
463
+ "/opt/homebrew/bin/tmux": `/opt/homebrew/Cellar/tmux/${opts.installed}/bin/tmux`,
464
+ },
465
+ env: {
466
+ HOMEBREW_PREFIX: "/opt/homebrew",
467
+ HOMEBREW_CELLAR: "/opt/homebrew/Cellar",
468
+ },
469
+ exec: ([cmd, ...args]) => {
470
+ if (cmd === "brew") {
471
+ if (args.includes("outdated")) return OK(opts.outdatedJson);
472
+ if (args.includes("list")) return OK(`tmux ${opts.installed}\n`);
473
+ if (args.includes("--cellar")) return OK("/opt/homebrew/Cellar\n");
474
+ if (args.includes("--prefix"))
475
+ return OK(
476
+ args.length > 1 ? "/opt/homebrew/opt/tmux\n" : "/opt/homebrew\n",
477
+ );
478
+ if (args.includes("--repository")) return OK("/opt/homebrew\n");
479
+ }
480
+ if (cmd === "tmux") return OK(`tmux ${opts.installed}\n`);
481
+ return undefined;
482
+ },
483
+ });
484
+ }
485
+
486
+ describe("probeTool through injected IO (no real brew/uv, no network)", () => {
487
+ test("real brew output: tmux 3.7c, `brew outdated` empty → current (V5 baseline)", async () => {
488
+ const io = brewIO({
489
+ installed: "3.7c",
490
+ outdatedJson: capture("brew-outdated-tmux-current.txt"),
491
+ });
492
+ const spec: BinRequirement = {
493
+ name: "tmux",
494
+ via: "brew",
495
+ check: "tmux -V",
496
+ };
497
+
498
+ const f = await probeTool(spec, io, { timeoutMs: 500, deadlineMs: 2000 });
499
+ const got = classifyTool(spec, f);
500
+
501
+ expect({ state: got.state, facts: f, calls: io.calls }).toMatchObject({
502
+ state: "current",
503
+ });
504
+ expect(f.present).toBe(true);
505
+ });
506
+
507
+ test("brew outdated lists tmux 3.7b → 3.7c: upgrade", async () => {
508
+ const outdated = JSON.stringify({
509
+ formulae: [
510
+ {
511
+ name: "tmux",
512
+ installed_versions: ["3.7b"],
513
+ current_version: "3.7c",
514
+ pinned: false,
515
+ pinned_version: null,
516
+ },
517
+ ],
518
+ casks: [],
519
+ });
520
+ const io = brewIO({ installed: "3.7b", outdatedJson: outdated });
521
+ const spec: BinRequirement = {
522
+ name: "tmux",
523
+ via: "brew",
524
+ check: "tmux -V",
525
+ };
526
+
527
+ const f = await probeTool(spec, io, { timeoutMs: 500, deadlineMs: 2000 });
528
+ const got = classifyTool(spec, f);
529
+
530
+ expect({ state: got.state, facts: f, calls: io.calls }).toMatchObject({
531
+ state: "upgrade",
532
+ });
533
+ });
534
+
535
+ test("real uv output: browser-use absent from `uv tool list --outdated` → current (V5 baseline)", async () => {
536
+ const toolDir = "/Users/test/.local/share/uv/tools";
537
+ const io = fakeIO({
538
+ paths: {
539
+ "browser-use": "/Users/test/.local/bin/browser-use",
540
+ uv: "/opt/homebrew/bin/uv",
541
+ },
542
+ realpaths: {
543
+ "/Users/test/.local/bin/browser-use": `${toolDir}/browser-use/bin/browser-use`,
544
+ },
545
+ texts: {
546
+ [`${toolDir}/browser-use/uv-receipt.toml`]: capture(
547
+ "uv-receipt-browser-use.toml",
548
+ ),
549
+ },
550
+ exec: ([cmd, ...args]) => {
551
+ if (cmd !== "uv") return undefined;
552
+ if (args[0] === "--version")
553
+ return OK("uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)\n");
554
+ if (args[0] === "tool" && args[1] === "dir") return OK(`${toolDir}\n`);
555
+ if (
556
+ args[0] === "tool" &&
557
+ args[1] === "list" &&
558
+ args.includes("--outdated")
559
+ )
560
+ return OK(capture("uv-tool-list-outdated.txt"));
561
+ if (args[0] === "tool" && args[1] === "list")
562
+ return OK(capture("uv-tool-list.txt"));
563
+ return undefined;
564
+ },
565
+ });
566
+ const spec: BinRequirement = { name: "browser-use", via: "pip" };
567
+
568
+ const f = await probeTool(spec, io, { timeoutMs: 500, deadlineMs: 2000 });
569
+ const got = classifyTool(spec, f);
570
+
571
+ expect({ state: got.state, facts: f, calls: io.calls }).toMatchObject({
572
+ state: "current",
573
+ });
574
+ });
575
+
576
+ test("every command times out: facts resolve and the tool is unknown, never upgrade", async () => {
577
+ const io = fakeIO({
578
+ paths: { tmux: "/opt/homebrew/bin/tmux", brew: "/opt/homebrew/bin/brew" },
579
+ exec: () => TIMED_OUT,
580
+ });
581
+ const spec: BinRequirement = { name: "tmux", via: "brew" };
582
+
583
+ const f = await probeTool(spec, io, { timeoutMs: 50, deadlineMs: 500 });
584
+ const got = classifyTool(spec, f);
585
+
586
+ expect(got.state).toBe("unknown");
587
+ });
588
+
589
+ test("a command that never settles is bounded by the deadline; probeTools never rejects", async () => {
590
+ const io = fakeIO({
591
+ paths: { codex: "/usr/local/bin/codex", npm: "/usr/local/bin/npm" },
592
+ exec: () => new Promise<ExecResult>(() => {}),
593
+ });
594
+ const spec: BinRequirement = {
595
+ name: "codex",
596
+ via: "npm",
597
+ package: "@openai/codex",
598
+ };
599
+ const started = Date.now();
600
+
601
+ const guard = new Promise<"guard">((r) =>
602
+ setTimeout(() => r("guard"), 3000),
603
+ );
604
+ const result = await Promise.race([
605
+ probeTools([spec], io, { timeoutMs: 50, deadlineMs: 200 }),
606
+ guard,
607
+ ]);
608
+
609
+ expect(result).not.toBe("guard");
610
+ const [f] = result as ToolFacts[];
611
+ expect(Date.now() - started).toBeLessThan(3000);
612
+ expect(f?.name).toBe("codex");
613
+ expect(f?.failure).toBeTruthy();
614
+ expect(classifyTool(spec, f as ToolFacts).state).toBe("unknown");
615
+ });
616
+
617
+ test("probeTools returns results in spec order; onResult reports a monotonic settled count", async () => {
618
+ const delays: Record<string, number> = { slow: 60, mid: 30, fast: 0 };
619
+ const io = fakeIO({
620
+ paths: {
621
+ slow: "/usr/local/bin/slow",
622
+ mid: "/usr/local/bin/mid",
623
+ fast: "/usr/local/bin/fast",
624
+ },
625
+ // `undefined` ("no scripted answer") is synchronous only — the contract
626
+ // has no async undefined — so the delay wraps just the scripted reply.
627
+ exec: ([cmd]) => {
628
+ const d = delays[cmd ?? ""];
629
+ if (d === undefined) return undefined;
630
+ return new Promise<ExecResult>((r) =>
631
+ setTimeout(() => r(OK(`${cmd} 1.0.0\n`)), d),
632
+ );
633
+ },
634
+ });
635
+ const specs: BinRequirement[] = ["slow", "mid", "fast"].map((n) => ({
636
+ name: n,
637
+ via: "npm",
638
+ check: `${n} --version`,
639
+ }));
640
+ const seen: Array<{ name: string; p: ProbeProgress }> = [];
641
+
642
+ const out = await probeTools(specs, io, {
643
+ timeoutMs: 1000,
644
+ deadlineMs: 3000,
645
+ onResult: (f, p) => seen.push({ name: f.name, p }),
646
+ });
647
+
648
+ expect(out.map((f) => f.name)).toEqual(["slow", "mid", "fast"]);
649
+ expect(seen.map((s) => s.p.settled)).toEqual([1, 2, 3]);
650
+ for (const s of seen) {
651
+ expect(s.p.total).toBe(3);
652
+ expect(specs[s.p.index]?.name).toBe(s.name);
653
+ }
654
+ });
655
+
656
+ test("a tool not on PATH is reported absent and classifies install", async () => {
657
+ const io = fakeIO({ paths: {}, exec: () => MISSING });
658
+ const spec: BinRequirement = {
659
+ name: "qwen",
660
+ via: "npm",
661
+ package: "@qwen-code/qwen-code",
662
+ };
663
+
664
+ const f = await probeTool(spec, io, { timeoutMs: 50, deadlineMs: 500 });
665
+
666
+ expect(f.present).toBe(false);
667
+ expect(classifyTool(spec, f).state).toBe("install");
668
+ });
669
+ });
@@ -34,7 +34,9 @@ function item(over: Partial<PluginUpdateItem> = {}): PluginUpdateItem {
34
34
  available: "2.0.0",
35
35
  target: "2.0.0",
36
36
  action: "update",
37
+ reason: "behind",
37
38
  scopes: ["project"],
39
+ beforeVersions: { project: "1.0.0" },
38
40
  ...over,
39
41
  };
40
42
  }
@@ -32,12 +32,12 @@ import {
32
32
  cliToolUninstall,
33
33
  cliToolUpdate,
34
34
  } from "../services/cli-tool-commands.js";
35
+ import { versionFromPypiJson } from "../services/tool-probe.js";
35
36
  import {
36
37
  PYTHON_CLI_INSTALLERS,
37
38
  type PythonCliInstaller,
38
39
  resolvePythonCliInstaller,
39
40
  } from "../services/toolchain.js";
40
- import { versionFromPypiJson } from "../ui/screens/CliToolsScreen.js";
41
41
  import { type Command, formatCommand } from "../utils/command-utils.js";
42
42
 
43
43
  const byProbe = (probe: string): PythonCliInstaller => {