claudeup 6.5.1 → 6.7.1

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 (85) 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__/conventions-manager.test.ts +45 -45
  14. package/src/__tests__/enabled-not-installed.test.ts +2 -3
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/models-presets-marketplace.test.ts +141 -0
  34. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  35. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/resolver.test.ts +3 -3
  38. package/src/__tests__/scope-action.test.ts +69 -19
  39. package/src/__tests__/shell-script-callers.test.ts +8 -8
  40. package/src/__tests__/tool-probe.test.ts +1400 -0
  41. package/src/__tests__/update-apply.test.ts +283 -1
  42. package/src/__tests__/update-plan.test.ts +773 -31
  43. package/src/__tests__/version-unknown-state.test.ts +388 -0
  44. package/src/cli/router.ts +0 -5
  45. package/src/cli/update-view.ts +92 -12
  46. package/src/cli/update.ts +114 -38
  47. package/src/data/models-presets.ts +10 -11
  48. package/src/data/predefined-profiles.ts +7 -7
  49. package/src/services/active-closure.ts +117 -0
  50. package/src/services/catalog-cache-store.ts +6 -2
  51. package/src/services/claude-settings.ts +2 -174
  52. package/src/services/community-styles.ts +1 -1
  53. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  54. package/src/services/gitignore-fixer.ts +1 -1
  55. package/src/services/marketplace-refresh.ts +30 -0
  56. package/src/services/plugin-manager.ts +200 -283
  57. package/src/services/resolver.ts +7 -2
  58. package/src/services/tool-probe.ts +1637 -0
  59. package/src/services/toolchain.ts +97 -0
  60. package/src/services/update-engine.ts +220 -32
  61. package/src/services/update-plan.ts +640 -160
  62. package/src/services/version-snapshot.ts +1 -1
  63. package/src/types/bun.d.ts +6 -0
  64. package/src/ui/App.tsx +2 -27
  65. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  66. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  67. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  68. package/src/ui/hooks/index.ts +0 -1
  69. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  70. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  71. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  72. package/src/ui/renderers/pluginRowText.ts +70 -0
  73. package/src/ui/renderers/pluginStatusText.ts +56 -0
  74. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  75. package/src/ui/screens/PluginsScreen.tsx +156 -140
  76. package/src/ui/state/types.ts +0 -7
  77. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  78. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  79. package/src/__tests__/plugin-version-check.test.ts +0 -762
  80. package/src/cli/claude.ts +0 -21
  81. package/src/prerunner/index.ts +0 -500
  82. package/src/services/plugin-version-check.ts +0 -360
  83. package/src/services/update-cache.ts +0 -78
  84. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  85. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -0,0 +1,1637 @@
1
+ /**
2
+ * tool-probe.ts — the ONE place a CLI tool's state is read and decided.
3
+ *
4
+ * `claudeup update` planned every present, unpinned tool as an UPGRADE on every
5
+ * run, because nothing could tell it what version was installed or whether a
6
+ * newer one existed — only present/absent. The TUI's CLI Tools screen had its
7
+ * own private probe (a shell `which`, an `npm view` per tool, and a third
8
+ * version comparator that read "1.2.3-beta" as "1.2.3"). The two front ends
9
+ * disagreed, and the CLI never converged: the second run planned exactly what
10
+ * the first had just "done".
11
+ *
12
+ * Now both front ends read tools through here, in two halves:
13
+ *
14
+ * - {@link probeTools} gathers FACTS through an injected {@link ToolProbeIO}.
15
+ * Each tool is read through the installer that owns the binary PATH actually
16
+ * runs, and `managed` says whether the upgrade command claudeup would run
17
+ * replaces THAT file. brew and uv answer "is it behind?" themselves; bun, npm
18
+ * and go fall back to a registry lookup.
19
+ * - {@link classifyTool} turns facts into a state, purely. The rules are in
20
+ * order and the vetoes that make an upgrade unsafe come before every rule
21
+ * that can produce one.
22
+ *
23
+ * Asking the installer that will perform the upgrade, about the file that will
24
+ * run, is what makes plan, upgrade and PATH agree — which is what convergence
25
+ * means. A tool whose state cannot be established is `unknown`, and `unknown`
26
+ * is never applied.
27
+ *
28
+ * NOTHING HERE PRINTS, and every subprocess runs through `execFile` (argv, no
29
+ * shell) with its stdio CAPTURED: the probe runs under the CLI's animated live
30
+ * region and inside the TUI, and an inherited stdio would corrupt both.
31
+ */
32
+
33
+ import { execFile } from "node:child_process";
34
+ import { constants } from "node:fs";
35
+ import {
36
+ access as fsAccess,
37
+ readlink as fsReadlink,
38
+ realpath as fsRealpath,
39
+ readFile,
40
+ stat,
41
+ } from "node:fs/promises";
42
+ import os from "node:os";
43
+ import path from "node:path";
44
+ import semver from "semver";
45
+ import type { CliTool } from "../data/cli-tools.js";
46
+ import type {
47
+ BinInstaller,
48
+ BinRequirement,
49
+ ResolvedBin,
50
+ } from "../types/index.js";
51
+ import type { Command } from "../utils/command-utils.js";
52
+ import type { InstallMethod } from "./cli-tool-commands.js";
53
+
54
+ // ── Types ────────────────────────────────────────────────────────────────────
55
+
56
+ export type ToolState = "install" | "current" | "upgrade" | "unknown";
57
+
58
+ export type ToolReason =
59
+ | "absent"
60
+ | "up-to-date"
61
+ | "behind"
62
+ | "at-pin"
63
+ | "behind-pin"
64
+ | "ahead-of-pin"
65
+ | "pin-not-installable"
66
+ | "held-back"
67
+ | "not-managed"
68
+ | "version-unreadable"
69
+ | "latest-unavailable"
70
+ | "unorderable"
71
+ | "not-probed";
72
+
73
+ export type ToolFactSource =
74
+ | "path"
75
+ | "brew"
76
+ | "uv"
77
+ | "uv-receipt"
78
+ /** managed only because uv's `--force` would overwrite a foreign file. */
79
+ | "uv-adopted"
80
+ | "pipx"
81
+ | "bun-global"
82
+ | "npm-global"
83
+ | "go-buildinfo"
84
+ | "pypi"
85
+ | "npm-registry"
86
+ | "go-proxy"
87
+ | "check-command";
88
+
89
+ export interface ToolFacts {
90
+ name: string;
91
+ /**
92
+ * Resolvable, executable, on PATH. Before PATH has been read (a probe that
93
+ * was refused, or one that hit the deadline first) this is TRUE: an
94
+ * unestablished presence must never be planned as an install.
95
+ */
96
+ present: boolean;
97
+ /** realpath of the PATH binary; null when absent/unresolvable. */
98
+ resolvedPath: string | null;
99
+ /** Version of THAT file, from its owner's record; null = could not be read. */
100
+ installed: string | null;
101
+ /** null = not queried / unavailable. */
102
+ latest: string | null;
103
+ /** The owning installer's own verdict; null = it gave none. */
104
+ behind: boolean | null;
105
+ /**
106
+ * The upgrade command for this spec replaces `resolvedPath` (containment in
107
+ * the package's own directory, or pip adoption). false = another owner;
108
+ * null = the owner directory could not be determined.
109
+ */
110
+ managed: boolean | null;
111
+ /**
112
+ * Where the installer keeps what it owns, for the human note — e.g.
113
+ * "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
114
+ */
115
+ owner?: string;
116
+ /** e.g. "held by brew pin". */
117
+ heldBack?: string;
118
+ /**
119
+ * Set when `managed` is true only because the upgrade command would
120
+ * OVERWRITE a file another installer put there (today: a binary in uv's bin
121
+ * dir). The adoption is deliberate — `uv tool install --force` replaces the
122
+ * very file PATH runs, so the state converges — but it changes the tool's
123
+ * owner, which a plain `upgrade` row does not say. This is that sentence,
124
+ * carried to both front ends.
125
+ */
126
+ adopted?: string;
127
+ /** e.g. "timed out after 8s", "registry returned 404". */
128
+ failure?: string;
129
+ /** Where each fact came from, for diagnostics. */
130
+ sources: ToolFactSource[];
131
+ }
132
+
133
+ export interface ToolStatus {
134
+ name: string;
135
+ state: ToolState;
136
+ reason: ToolReason;
137
+ /** Set for every state except plain current/install. */
138
+ note?: string;
139
+ /** {@link ToolFacts.adopted}, carried when this state plans work. */
140
+ adopted?: string;
141
+ installed: string | null;
142
+ latest: string | null;
143
+ target: string | null;
144
+ pin?: string;
145
+ }
146
+
147
+ // ── Version comparison ───────────────────────────────────────────────────────
148
+
149
+ /** A strict semver string for `v`, padding a 1–2 part numeric version, or null. */
150
+ function toSemver(v: string): string | null {
151
+ const s = v.trim().replace(/^v/i, "");
152
+ const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$/.exec(s);
153
+ if (!m) return null;
154
+ const candidate =
155
+ m[3] === undefined ? `${m[1]}.${m[2] ?? "0"}.0${m[4] ?? ""}` : s;
156
+ return semver.valid(candidate);
157
+ }
158
+
159
+ /**
160
+ * -1 | 0 | 1, or null when the pair has no shared ordering.
161
+ *
162
+ * Deliberately STRICTER than the plugin comparator `isBehind`. Plugin opaque
163
+ * ids are catalog-published shas, where "different" means "not what is
164
+ * published". Tool version strings are free-form, and treating two different
165
+ * opaque tool strings as "behind" is how an upgrade loop on a prerelease
166
+ * starts. So: semver when both sides are semver (after stripping a leading `v`
167
+ * and padding "1.2" to "1.2.0"), equal strings are equal, and anything else is
168
+ * unorderable.
169
+ */
170
+ export function compareToolVersions(a: string, b: string): -1 | 0 | 1 | null {
171
+ const sa = toSemver(a);
172
+ const sb = toSemver(b);
173
+ if (sa && sb) return semver.compare(sa, sb);
174
+ const ra = a.trim().replace(/^v/i, "");
175
+ const rb = b.trim().replace(/^v/i, "");
176
+ return ra === rb ? 0 : null;
177
+ }
178
+
179
+ // ── The classifier ───────────────────────────────────────────────────────────
180
+
181
+ /** The exact pin, or undefined for an unpinned (or "latest") requirement. */
182
+ function pinOf(spec: BinRequirement): string | undefined {
183
+ const v = spec.version?.trim();
184
+ return v && v !== "latest" ? v : undefined;
185
+ }
186
+
187
+ function withFailure(text: string, failure: string | undefined): string {
188
+ return failure ? `${text} (${failure})` : text;
189
+ }
190
+
191
+ /**
192
+ * THE tool classifier. Pure. The first matching rule wins, and each branch
193
+ * returns, so reading top to bottom is reading the precedence.
194
+ *
195
+ * The vetoes (`not-managed`, `held-back`) come before every rule that can yield
196
+ * `upgrade`, pinned or not: an upgrade through an installer that does not own
197
+ * the running file installs a second copy PATH never runs, and one through a
198
+ * held formula is a no-op. Both would be replanned forever. The pin rules that
199
+ * plan no work (at-pin, unorderable, ahead-of-pin) come before the vetoes,
200
+ * because ownership cannot make a "nothing to do" answer wrong.
201
+ */
202
+ export function classifyTool(
203
+ spec: BinRequirement,
204
+ facts: ToolFacts,
205
+ ): ToolStatus {
206
+ const pin = pinOf(spec);
207
+ const base = {
208
+ name: spec.name,
209
+ installed: facts.installed,
210
+ latest: facts.latest,
211
+ ...(pin ? { pin } : {}),
212
+ };
213
+
214
+ // 1. Not on PATH.
215
+ if (!facts.present) {
216
+ return {
217
+ ...base,
218
+ state: "install",
219
+ reason: "absent",
220
+ target: pin ?? facts.latest,
221
+ };
222
+ }
223
+
224
+ // 2. Present, but we could not read what version runs.
225
+ const installed = facts.installed;
226
+ if (installed === null) {
227
+ return {
228
+ ...base,
229
+ state: "unknown",
230
+ reason: "version-unreadable",
231
+ target: null,
232
+ note: withFailure(
233
+ `on PATH${facts.resolvedPath ? ` at ${facts.resolvedPath}` : ""}, but its version could not be read`,
234
+ facts.failure,
235
+ ),
236
+ };
237
+ }
238
+
239
+ // 3–5. Pin rules that plan no work.
240
+ const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
241
+ if (pin && vsPin === 0) {
242
+ return { ...base, state: "current", reason: "at-pin", target: null };
243
+ }
244
+ if (pin && vsPin === null) {
245
+ return {
246
+ ...base,
247
+ state: "unknown",
248
+ reason: "unorderable",
249
+ target: null,
250
+ note: `installed ${installed} cannot be compared with the pin ${pin}`,
251
+ };
252
+ }
253
+ if (pin && vsPin === 1) {
254
+ // Forward-only, the same rule as plugins.
255
+ return {
256
+ ...base,
257
+ state: "unknown",
258
+ reason: "ahead-of-pin",
259
+ target: null,
260
+ note: `pinned ${pin}; installed ${installed} is newer and claudeup never downgrades`,
261
+ };
262
+ }
263
+
264
+ // 6. The veto: the upgrade would not replace the file PATH runs.
265
+ if (facts.managed !== true) {
266
+ return {
267
+ ...base,
268
+ state: "unknown",
269
+ reason: "not-managed",
270
+ target: null,
271
+ note:
272
+ facts.managed === false
273
+ ? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
274
+ : withFailure(
275
+ `could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
276
+ facts.failure,
277
+ ),
278
+ };
279
+ }
280
+
281
+ // 7. The veto: the installer will refuse to move it.
282
+ if (facts.heldBack) {
283
+ return {
284
+ ...base,
285
+ state: "unknown",
286
+ reason: "held-back",
287
+ target: null,
288
+ note: facts.heldBack,
289
+ };
290
+ }
291
+
292
+ // 8–9. Pinned and behind the pin (every other pinned case returned above).
293
+ // From here on a row can plan work, so an adoption must travel with it.
294
+ const adopted = facts.adopted ? { adopted: facts.adopted } : {};
295
+ if (pin) {
296
+ if (spec.via === "brew") {
297
+ return {
298
+ ...base,
299
+ state: "unknown",
300
+ reason: "pin-not-installable",
301
+ target: null,
302
+ note: `pinned ${pin}; installed ${installed} — brew installs only its newest version, so the pin cannot be installed`,
303
+ };
304
+ }
305
+ return {
306
+ ...base,
307
+ ...adopted,
308
+ state: "upgrade",
309
+ reason: "behind-pin",
310
+ target: pin,
311
+ note: `installed ${installed} is below the pin ${pin}`,
312
+ };
313
+ }
314
+
315
+ // 10–11. The owning installer's own verdict.
316
+ if (facts.behind === true) {
317
+ return {
318
+ ...base,
319
+ ...adopted,
320
+ state: "upgrade",
321
+ reason: "behind",
322
+ target: facts.latest,
323
+ note: facts.latest
324
+ ? `${facts.latest} is available`
325
+ : "a newer version is available",
326
+ };
327
+ }
328
+ if (facts.behind === false) {
329
+ return { ...base, state: "current", reason: "up-to-date", target: null };
330
+ }
331
+
332
+ // 12. Nothing to compare against.
333
+ const latest = facts.latest;
334
+ if (latest === null) {
335
+ return {
336
+ ...base,
337
+ state: "unknown",
338
+ reason: "latest-unavailable",
339
+ target: null,
340
+ note: withFailure("the latest version could not be read", facts.failure),
341
+ };
342
+ }
343
+
344
+ // 13. Our own comparison.
345
+ const cmp = compareToolVersions(installed, latest);
346
+ if (cmp === null) {
347
+ return {
348
+ ...base,
349
+ state: "unknown",
350
+ reason: "unorderable",
351
+ target: null,
352
+ note: `installed ${installed} cannot be compared with the latest ${latest}`,
353
+ };
354
+ }
355
+ if (cmp < 0) {
356
+ return {
357
+ ...base,
358
+ ...adopted,
359
+ state: "upgrade",
360
+ reason: "behind",
361
+ target: latest,
362
+ note: `${latest} is available`,
363
+ };
364
+ }
365
+ return { ...base, state: "current", reason: "up-to-date", target: null };
366
+ }
367
+
368
+ // ── Parsers (pure; tested against captured real output) ──────────────────────
369
+
370
+ /** `brew list --versions <f>` → the last version token ("tmux 3.7c" → "3.7c"). */
371
+ export function parseBrewListVersions(stdout: string): string | null {
372
+ const line = stdout.split("\n").find((l) => l.trim().length > 0);
373
+ if (!line) return null;
374
+ const tokens = line.trim().split(/\s+/);
375
+ return tokens.length >= 2 ? (tokens[tokens.length - 1] ?? null) : null;
376
+ }
377
+
378
+ /**
379
+ * `brew outdated --json=v2 <formula>`. `formulae: []` means not behind. null =
380
+ * unparseable. A tap formula's `name` is its full name ("user/tap/f").
381
+ */
382
+ export function parseBrewOutdatedJson(
383
+ stdout: string,
384
+ formula: string,
385
+ ): {
386
+ behind: boolean;
387
+ installed: string | null;
388
+ latest: string | null;
389
+ pinned: boolean;
390
+ } | null {
391
+ let body: unknown;
392
+ try {
393
+ body = JSON.parse(stdout);
394
+ } catch {
395
+ return null;
396
+ }
397
+ const formulae = (body as { formulae?: unknown } | null)?.formulae;
398
+ if (!Array.isArray(formulae)) return null;
399
+ const entry = formulae.find((f: unknown) => {
400
+ const name = (f as { name?: unknown } | null)?.name;
401
+ return (
402
+ typeof name === "string" &&
403
+ (name === formula || name.endsWith(`/${formula}`))
404
+ );
405
+ }) as
406
+ | {
407
+ installed_versions?: unknown;
408
+ current_version?: unknown;
409
+ pinned?: unknown;
410
+ }
411
+ | undefined;
412
+ if (!entry) {
413
+ return { behind: false, installed: null, latest: null, pinned: false };
414
+ }
415
+ const versions = Array.isArray(entry.installed_versions)
416
+ ? entry.installed_versions.filter(
417
+ (v: unknown): v is string => typeof v === "string",
418
+ )
419
+ : [];
420
+ return {
421
+ behind: true,
422
+ installed: versions[versions.length - 1] ?? null,
423
+ latest:
424
+ typeof entry.current_version === "string" ? entry.current_version : null,
425
+ pinned: entry.pinned === true,
426
+ };
427
+ }
428
+
429
+ /**
430
+ * The keg version from a realpath under `<cellar>/<formula>/<version>/`
431
+ * (".../Cellar/tmux/3.7c/bin/tmux" → "3.7c"). This is the LINKED version, which
432
+ * `brew list --versions` cannot give when several kegs are kept.
433
+ */
434
+ export function brewKegVersion(
435
+ realPath: string,
436
+ cellar: string,
437
+ formula: string,
438
+ ): string | null {
439
+ const prefix = `${path.join(cellar, formula)}${path.sep}`;
440
+ if (!realPath.startsWith(prefix)) return null;
441
+ const segment = realPath.slice(prefix.length).split(path.sep)[0];
442
+ return segment ? segment : null;
443
+ }
444
+
445
+ /** PEP 503 normalisation: lowercase, runs of `-_.` collapse to `-`. */
446
+ export function normalizePythonPackageName(name: string): string {
447
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
448
+ }
449
+
450
+ /**
451
+ * `uv tool list` / `uv tool list --outdated`. Package lines are `name vX.Y`,
452
+ * optionally followed by `[latest: Z]`; lines starting `- ` are executables.
453
+ * Keys are PEP 503-normalised. An unrecognised format yields an empty map,
454
+ * which degrades to PyPI, never to an upgrade.
455
+ */
456
+ export function parseUvToolList(
457
+ stdout: string,
458
+ ): Map<string, { installed: string; latest: string | null }> {
459
+ const out = new Map<string, { installed: string; latest: string | null }>();
460
+ for (const raw of stdout.split("\n")) {
461
+ const line = raw.trimEnd();
462
+ if (!line || line.startsWith("-") || line.startsWith(" ")) continue;
463
+ const m = /^(\S+)\s+v(\S+)(.*)$/.exec(line);
464
+ if (!m?.[1] || !m[2]) continue;
465
+ const latest = /\[latest:\s*([^\]\s]+)\s*\]/.exec(m[3] ?? "")?.[1] ?? null;
466
+ out.set(normalizePythonPackageName(m[1]), { installed: m[2], latest });
467
+ }
468
+ return out;
469
+ }
470
+
471
+ export type UvRequirementSource =
472
+ | "index"
473
+ | "index-constrained"
474
+ | "git"
475
+ | "directory"
476
+ | "path"
477
+ | "url"
478
+ | "editable";
479
+
480
+ function parseToml(text: string): unknown {
481
+ const toml = typeof Bun !== "undefined" ? Bun.TOML : undefined;
482
+ if (!toml || typeof toml.parse !== "function") return null;
483
+ try {
484
+ return toml.parse(text);
485
+ } catch {
486
+ return null;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * The package's requirement kind from `uv-receipt.toml`; null =
492
+ * unreadable/unparseable/absent.
493
+ *
494
+ * Only a requirement carrying nothing but `name` (and `extras`) is `index`: that
495
+ * is the one shape where uv's silence in `--outdated` may be read as "current".
496
+ */
497
+ export function parseUvReceipt(
498
+ toml: string,
499
+ pkg: string,
500
+ ): UvRequirementSource | null {
501
+ const body = parseToml(toml) as {
502
+ tool?: { requirements?: unknown };
503
+ } | null;
504
+ const reqs = body?.tool?.requirements;
505
+ if (!Array.isArray(reqs)) return null;
506
+ const want = normalizePythonPackageName(pkg);
507
+ const req = reqs.find((r: unknown) => {
508
+ const name = (r as { name?: unknown } | null)?.name;
509
+ return (
510
+ typeof name === "string" && normalizePythonPackageName(name) === want
511
+ );
512
+ }) as Record<string, unknown> | undefined;
513
+ if (!req) return null;
514
+ for (const kind of ["git", "editable", "directory", "path", "url"] as const) {
515
+ if (kind in req) return kind;
516
+ }
517
+ const extra = Object.keys(req).filter((k) => k !== "name" && k !== "extras");
518
+ return extra.length === 0 ? "index" : "index-constrained";
519
+ }
520
+
521
+ /** `pipx list --json` → normalised package → version. */
522
+ export function parsePipxListJson(body: unknown): Map<string, string> {
523
+ const out = new Map<string, string>();
524
+ const venvs = (body as { venvs?: unknown } | null)?.venvs;
525
+ if (!venvs || typeof venvs !== "object") return out;
526
+ for (const [venv, value] of Object.entries(
527
+ venvs as Record<string, unknown>,
528
+ )) {
529
+ const main = (
530
+ value as {
531
+ metadata?: {
532
+ main_package?: { package?: unknown; package_version?: unknown };
533
+ };
534
+ } | null
535
+ )?.metadata?.main_package;
536
+ if (typeof main?.package_version !== "string") continue;
537
+ const key = typeof main.package === "string" ? main.package : venv;
538
+ out.set(normalizePythonPackageName(key), main.package_version);
539
+ }
540
+ return out;
541
+ }
542
+
543
+ /**
544
+ * `go version -m <binary>` → its main module and version, plus the main
545
+ * package path when printed.
546
+ */
547
+ export function parseGoVersionM(
548
+ stdout: string,
549
+ ): { module: string; version: string; path?: string } | null {
550
+ let pkgPath: string | undefined;
551
+ for (const line of stdout.split("\n")) {
552
+ const p = /^\s*path\s+(\S+)/.exec(line);
553
+ if (p?.[1]) pkgPath = p[1];
554
+ const m = /^\s*mod\s+(\S+)\s+(\S+)/.exec(line);
555
+ if (m?.[1] && m[2]) {
556
+ return {
557
+ module: m[1],
558
+ version: m[2],
559
+ ...(pkgPath ? { path: pkgPath } : {}),
560
+ };
561
+ }
562
+ }
563
+ return null;
564
+ }
565
+
566
+ /**
567
+ * Pick the version out of a PyPI JSON API body.
568
+ *
569
+ * Split out from the fetch so it is testable without a network: the parsing is
570
+ * the part that can be wrong, and the part that must not throw.
571
+ */
572
+ export function versionFromPypiJson(body: unknown): string | undefined {
573
+ const info = (body as { info?: { version?: unknown } } | null)?.info;
574
+ return typeof info?.version === "string" ? info.version : undefined;
575
+ }
576
+
577
+ /** An npm registry dist-tags body (`{ latest: "x", … }`) → "x". */
578
+ export function versionFromNpmDistTags(body: unknown): string | undefined {
579
+ const latest = (body as { latest?: unknown } | null)?.latest;
580
+ return typeof latest === "string" ? latest : undefined;
581
+ }
582
+
583
+ /**
584
+ * The first version-like token in a `--version` output ("tmux 3.7c" → "3.7c",
585
+ * "2.1.268 (Claude Code)" → "2.1.268", "v2.0.0" → "2.0.0"). Needs at least one
586
+ * dot, so a bare number (a year, a count) is not mistaken for a version.
587
+ */
588
+ export function parseVersionToken(output: string): string | null {
589
+ const m = /(?:^|[^\w.])v?(\d+(?:\.\d+)+[0-9A-Za-z.+-]*)/.exec(output);
590
+ if (!m?.[1]) return null;
591
+ return m[1].replace(/[.+-]+$/, "");
592
+ }
593
+
594
+ /** true when `resolved` lies inside `ownerDir` (compared with a trailing separator). */
595
+ export function isInside(resolved: string, ownerDir: string): boolean {
596
+ const dir = path.resolve(ownerDir);
597
+ const file = path.resolve(resolved);
598
+ return file.startsWith(dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`);
599
+ }
600
+
601
+ // ── IO seam ──────────────────────────────────────────────────────────────────
602
+
603
+ export interface ExecResult {
604
+ /** null when killed. */
605
+ code: number | null;
606
+ stdout: string;
607
+ stderr: string;
608
+ timedOut: boolean;
609
+ /** Executable not found (ENOENT). */
610
+ missing: boolean;
611
+ }
612
+
613
+ export type FetchJsonResult =
614
+ | { ok: true; body: unknown }
615
+ | { ok: false; reason: string };
616
+
617
+ export interface ToolProbeIO {
618
+ /** argv only, captured stdio, never a shell, never throws. */
619
+ exec(
620
+ command: Command,
621
+ opts: { timeoutMs: number; env?: Readonly<Record<string, string>> },
622
+ ): Promise<ExecResult>;
623
+ fetchJson(url: string, opts: { timeoutMs: number }): Promise<FetchJsonResult>;
624
+ /** null when absent/unreadable. */
625
+ readJson(file: string): Promise<unknown | null>;
626
+ /** null when absent/unreadable (uv receipts). */
627
+ readText(file: string): Promise<string | null>;
628
+ readlink(file: string): Promise<string | null>;
629
+ realpath(file: string): Promise<string | null>;
630
+ /**
631
+ * The first executable `name` on PATH, AS FOUND ON PATH (not resolved), or
632
+ * null. A dangling symlink is not executable and does not count.
633
+ */
634
+ resolveExecutable(name: string): Promise<string | null>;
635
+ env: Readonly<Record<string, string | undefined>>;
636
+ homedir: string;
637
+ }
638
+
639
+ async function isExecutable(file: string): Promise<boolean> {
640
+ try {
641
+ // Both follow symlinks, so a dangling link fails here.
642
+ if (!(await stat(file)).isFile()) return false;
643
+ await fsAccess(file, constants.X_OK);
644
+ return true;
645
+ } catch {
646
+ return false;
647
+ }
648
+ }
649
+
650
+ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
651
+ exec: (command, opts) =>
652
+ new Promise((resolve) => {
653
+ execFile(
654
+ command.cmd,
655
+ [...command.args],
656
+ {
657
+ timeout: opts.timeoutMs,
658
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
659
+ maxBuffer: 16 * 1024 * 1024,
660
+ encoding: "utf8",
661
+ windowsHide: true,
662
+ },
663
+ (error, stdout, stderr) => {
664
+ if (!error) {
665
+ resolve({
666
+ code: 0,
667
+ stdout,
668
+ stderr,
669
+ timedOut: false,
670
+ missing: false,
671
+ });
672
+ return;
673
+ }
674
+ const e = error as NodeJS.ErrnoException & {
675
+ code?: unknown;
676
+ killed?: boolean;
677
+ signal?: string | null;
678
+ };
679
+ // A maxBuffer overflow also arrives killed, with a string code.
680
+ // Calling that "timed out" would put a wrong cause in the note
681
+ // of output that was merely too large.
682
+ const overflowed = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
683
+ resolve({
684
+ code: typeof e.code === "number" ? e.code : null,
685
+ stdout: stdout ?? "",
686
+ stderr: overflowed
687
+ ? `${command.cmd} printed more than 16MB`
688
+ : (stderr ?? ""),
689
+ timedOut: e.killed === true && !overflowed && e.code !== "ENOENT",
690
+ missing: e.code === "ENOENT",
691
+ });
692
+ },
693
+ );
694
+ }),
695
+ fetchJson: async (url, opts) => {
696
+ try {
697
+ // No credentials: a public registry GET, nothing else.
698
+ const res = await fetch(url, {
699
+ signal: AbortSignal.timeout(opts.timeoutMs),
700
+ headers: { accept: "application/json" },
701
+ });
702
+ if (!res.ok) return { ok: false, reason: `returned ${res.status}` };
703
+ return { ok: true, body: await res.json() };
704
+ } catch (error) {
705
+ const name = error instanceof Error ? error.name : "";
706
+ return {
707
+ ok: false,
708
+ reason:
709
+ name === "TimeoutError" || name === "AbortError"
710
+ ? `timed out after ${Math.round(opts.timeoutMs / 1000)}s`
711
+ : error instanceof Error
712
+ ? error.message
713
+ : String(error),
714
+ };
715
+ }
716
+ },
717
+ readJson: async (file) => {
718
+ try {
719
+ return JSON.parse(await readFile(file, "utf8"));
720
+ } catch {
721
+ return null;
722
+ }
723
+ },
724
+ readText: async (file) => {
725
+ try {
726
+ return await readFile(file, "utf8");
727
+ } catch {
728
+ return null;
729
+ }
730
+ },
731
+ readlink: async (file) => {
732
+ try {
733
+ return await fsReadlink(file);
734
+ } catch {
735
+ return null;
736
+ }
737
+ },
738
+ realpath: async (file) => {
739
+ try {
740
+ return await fsRealpath(file);
741
+ } catch {
742
+ return null;
743
+ }
744
+ },
745
+ resolveExecutable: async (name) => {
746
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
747
+ if (!dir) continue;
748
+ const candidate = path.join(dir, name);
749
+ if (await isExecutable(candidate)) return candidate;
750
+ }
751
+ return null;
752
+ },
753
+ env: process.env,
754
+ homedir: os.homedir(),
755
+ };
756
+
757
+ export const PROBE_TIMEOUT_MS = 8000 as const;
758
+ export const PROBE_DEADLINE_MS = 20000 as const;
759
+ export const PROBE_CONCURRENCY = 4 as const;
760
+
761
+ export interface ProbeProgress {
762
+ index: number;
763
+ settled: number;
764
+ total: number;
765
+ }
766
+
767
+ export interface ProbeOptions {
768
+ timeoutMs?: number;
769
+ deadlineMs?: number;
770
+ concurrency?: number;
771
+ /**
772
+ * Fires as each tool settles, in completion order. `index` is the spec's
773
+ * index; `settled` is monotonic (1..total) and is what a progress meter
774
+ * must use.
775
+ */
776
+ onResult?: (facts: ToolFacts, progress: ProbeProgress) => void;
777
+ /**
778
+ * Run a spec's `check` command when no installer record can give the
779
+ * version. Default FALSE, and every planning caller leaves it so: `check`
780
+ * is a string a third-party `plugin.json` supplied, and planning — which
781
+ * includes `--dry-run` and `--check` — executes nothing a catalogue wrote.
782
+ * See {@link versionFromCheck} for what is still bounded when it is on.
783
+ */
784
+ allowCheckCommand?: boolean;
785
+ }
786
+
787
+ // ── Probe session: memoised batch and location queries ──────────────────────
788
+
789
+ /** Extra env for brew: never mutate brew state, and keep output plain. */
790
+ const BREW_ENV = {
791
+ HOMEBREW_NO_AUTO_UPDATE: "1",
792
+ HOMEBREW_NO_ENV_HINTS: "1",
793
+ NO_COLOR: "1",
794
+ } as const;
795
+ const PLAIN_ENV = { NO_COLOR: "1" } as const;
796
+
797
+ type Located = { dir: string } | { failure: string };
798
+
799
+ type UvOutdated =
800
+ | { ok: true; map: Map<string, { installed: string; latest: string | null }> }
801
+ | { ok: false; unsupported: boolean; failure: string };
802
+
803
+ function firstLine(text: string): string {
804
+ return (
805
+ text
806
+ .split("\n")
807
+ .find((l) => l.trim())
808
+ ?.trim() ?? ""
809
+ );
810
+ }
811
+
812
+ function execFailure(cmd: string, r: ExecResult, timeoutMs: number): string {
813
+ if (r.missing) return `${cmd} is not installed`;
814
+ if (r.timedOut)
815
+ return `${cmd} timed out after ${Math.round(timeoutMs / 1000)}s`;
816
+ return firstLine(r.stderr) || `${cmd} exited ${r.code ?? "abnormally"}`;
817
+ }
818
+
819
+ /**
820
+ * One per {@link probeTools} call. Each location or batch query is a memoised
821
+ * promise, run only if some spec needs it — N pip tools cost the same four uv
822
+ * subprocesses as one.
823
+ */
824
+ class ProbeSession {
825
+ private readonly memo = new Map<string, Promise<unknown>>();
826
+
827
+ constructor(
828
+ readonly io: ToolProbeIO,
829
+ readonly timeoutMs: number,
830
+ /** See {@link versionFromCheck}. Off unless the caller opted in. */
831
+ readonly allowCheckCommand: boolean = false,
832
+ ) {}
833
+
834
+ once<T>(key: string, work: () => Promise<T>): Promise<T> {
835
+ const hit = this.memo.get(key);
836
+ if (hit) return hit as Promise<T>;
837
+ const p = work();
838
+ this.memo.set(key, p);
839
+ return p;
840
+ }
841
+
842
+ run(
843
+ cmd: string,
844
+ args: string[],
845
+ env?: Readonly<Record<string, string>>,
846
+ ): Promise<ExecResult> {
847
+ return this.io.exec(
848
+ { cmd, args },
849
+ { timeoutMs: this.timeoutMs, ...(env ? { env } : {}) },
850
+ );
851
+ }
852
+
853
+ /** stdout's first line of a location query. */
854
+ private locate(key: string, cmd: string, args: string[]): Promise<Located> {
855
+ return this.once(key, async () => {
856
+ const r = await this.run(cmd, args, PLAIN_ENV);
857
+ const dir = r.code === 0 ? firstLine(r.stdout) : "";
858
+ return dir ? { dir } : { failure: execFailure(cmd, r, this.timeoutMs) };
859
+ });
860
+ }
861
+
862
+ brewPrefix(): Promise<Located> {
863
+ const fromEnv = this.io.env.HOMEBREW_PREFIX;
864
+ if (fromEnv) return Promise.resolve({ dir: fromEnv });
865
+ return this.locate("brew --prefix", "brew", ["--prefix"]);
866
+ }
867
+
868
+ uvToolDir(): Promise<Located & { missing?: boolean }> {
869
+ return this.once("uv tool dir", async () => {
870
+ const r = await this.run("uv", ["tool", "dir"], PLAIN_ENV);
871
+ const dir = r.code === 0 ? firstLine(r.stdout) : "";
872
+ if (dir) return { dir };
873
+ return {
874
+ failure: execFailure("uv", r, this.timeoutMs),
875
+ missing: r.missing,
876
+ };
877
+ });
878
+ }
879
+
880
+ uvBinDir(): Promise<Located> {
881
+ return this.locate("uv tool dir --bin", "uv", ["tool", "dir", "--bin"]);
882
+ }
883
+
884
+ uvList(): Promise<Map<string, { installed: string; latest: string | null }>> {
885
+ return this.once("uv tool list", async () => {
886
+ const r = await this.run("uv", ["tool", "list"], PLAIN_ENV);
887
+ return r.code === 0 ? parseUvToolList(r.stdout) : new Map();
888
+ });
889
+ }
890
+
891
+ uvOutdated(): Promise<UvOutdated> {
892
+ return this.once("uv tool list --outdated", async () => {
893
+ const r = await this.run("uv", ["tool", "list", "--outdated"], PLAIN_ENV);
894
+ if (r.code === 0) return { ok: true, map: parseUvToolList(r.stdout) };
895
+ return {
896
+ ok: false,
897
+ // Older uv rejects the flag; that is "ask PyPI", not "index down".
898
+ unsupported: /unexpected argument/i.test(r.stderr),
899
+ failure: execFailure("uv", r, this.timeoutMs),
900
+ };
901
+ });
902
+ }
903
+
904
+ pipxList(): Promise<Map<string, string> | null> {
905
+ return this.once("pipx list --json", async () => {
906
+ const r = await this.run("pipx", ["list", "--json"], PLAIN_ENV);
907
+ if (r.code !== 0) return null;
908
+ try {
909
+ return parsePipxListJson(JSON.parse(r.stdout));
910
+ } catch {
911
+ return new Map<string, string>();
912
+ }
913
+ });
914
+ }
915
+
916
+ npmRoot(): Promise<Located> {
917
+ return this.locate("npm root -g", "npm", ["root", "-g"]);
918
+ }
919
+
920
+ goBin(): Promise<Located> {
921
+ return this.once("go env GOBIN GOPATH", async () => {
922
+ const r = await this.run("go", ["env", "GOBIN", "GOPATH"], PLAIN_ENV);
923
+ if (r.code !== 0)
924
+ return { failure: execFailure("go", r, this.timeoutMs) };
925
+ const [gobin = "", gopath = ""] = r.stdout
926
+ .split("\n")
927
+ .map((l) => l.trim());
928
+ if (gobin) return { dir: gobin };
929
+ const first = gopath.split(path.delimiter)[0];
930
+ return first
931
+ ? { dir: path.join(first, "bin") }
932
+ : { failure: "go reported no GOBIN or GOPATH" };
933
+ });
934
+ }
935
+ }
936
+
937
+ // ── Probing one tool ─────────────────────────────────────────────────────────
938
+
939
+ /** Characters that make a check command something only a shell could run. */
940
+ const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
941
+
942
+ /**
943
+ * Refuse a name that could be read as an option, carries a NUL, or (for a
944
+ * value joined into a path) climbs out of its directory. Null when fine.
945
+ */
946
+ function refusal(spec: BinRequirement): string | null {
947
+ const bad = (v: string | undefined, isPath: boolean): boolean =>
948
+ v !== undefined &&
949
+ (v.startsWith("-") ||
950
+ v.includes("\0") ||
951
+ (isPath && v.split("/").some((s) => s === ".." || s === ".")));
952
+ if (bad(spec.name, true) || spec.name.includes("/") || !spec.name)
953
+ return "refused: name is not a valid name";
954
+ if (bad(spec.package, true)) return "refused: package is not a valid name";
955
+ if (bad(spec.formula, false)) return "refused: formula is not a valid name";
956
+ if (bad(spec.module, false)) return "refused: module is not a valid name";
957
+ return null;
958
+ }
959
+
960
+ /** A mutable facts record: whatever is known when the deadline hits survives. */
961
+ function blankFacts(name: string): ToolFacts {
962
+ return {
963
+ name,
964
+ present: true,
965
+ resolvedPath: null,
966
+ installed: null,
967
+ latest: null,
968
+ behind: null,
969
+ managed: null,
970
+ sources: [],
971
+ };
972
+ }
973
+
974
+ function addSource(f: ToolFacts, s: ToolFactSource): void {
975
+ if (!f.sources.includes(s)) f.sources.push(s);
976
+ }
977
+
978
+ function noteFailure(f: ToolFacts, failure: string): void {
979
+ if (!f.failure) f.failure = failure;
980
+ }
981
+
982
+ /** The version a real, resolved directory's package.json declares. */
983
+ async function packageJsonVersion(
984
+ io: ToolProbeIO,
985
+ dir: string,
986
+ ): Promise<string | null> {
987
+ const pkg = (await io.readJson(path.join(dir, "package.json"))) as {
988
+ version?: unknown;
989
+ } | null;
990
+ return typeof pkg?.version === "string" ? pkg.version : null;
991
+ }
992
+
993
+ /**
994
+ * Last resort for the installed version: run the tool's own check command and
995
+ * take the first version token.
996
+ *
997
+ * NOT REACHED WHILE PLANNING. `spec.check` is copied verbatim out of a
998
+ * marketplace plugin's `plugin.json` (`plugin-requires.ts`), so it is a string
999
+ * a third party wrote. Before this probe existed nothing in claudeup ever ran
1000
+ * it; running it to PLAN would have meant `claudeup update`, `--dry-run`,
1001
+ * `--check` in CI and merely opening the TUI's Tools screen each executing a
1002
+ * catalogue-supplied argv — and `--dry-run` is documented as writing nothing.
1003
+ * So the planner does not opt in: a version that could only come from a check
1004
+ * command stays null, which classifies as `unknown / version-unreadable`,
1005
+ * never as an upgrade. The one caller that opts in is the post-install
1006
+ * read-back (`update-engine.ts`), where the user has already consented to run
1007
+ * that tool's installer.
1008
+ *
1009
+ * Where it does run it is bounded on every axis:
1010
+ * - argv only, split on whitespace, never a shell (a `SHELL_META` character
1011
+ * refuses the whole string);
1012
+ * - argv[0] must be the tool itself — its name, its path on PATH, or the
1013
+ * realpath behind it. The probe's purpose is "ask the running file its
1014
+ * version", so anything else is refused unrun, and the version reported can
1015
+ * only have come from the binary `resolvedPath` names;
1016
+ * - the session's `timeoutMs` (8s by default), through the injected IO seam,
1017
+ * which captures stdio rather than inheriting the terminal.
1018
+ */
1019
+ async function versionFromCheck(
1020
+ s: ProbeSession,
1021
+ spec: BinRequirement,
1022
+ f: ToolFacts,
1023
+ onPath: string,
1024
+ ): Promise<void> {
1025
+ if (!s.allowCheckCommand) {
1026
+ noteFailure(f, "no installer record, and check commands are not run here");
1027
+ return;
1028
+ }
1029
+ const check = spec.check?.trim() || `${spec.name} --version`;
1030
+ if (SHELL_META.test(check)) {
1031
+ noteFailure(f, "check command needs a shell; not run");
1032
+ return;
1033
+ }
1034
+ const [cmd, ...args] = check.split(/\s+/);
1035
+ if (!cmd) return;
1036
+ if (cmd !== spec.name && cmd !== onPath && cmd !== f.resolvedPath) {
1037
+ noteFailure(f, `check command does not run ${spec.name}; not run`);
1038
+ return;
1039
+ }
1040
+ const r = await s.run(cmd, args, PLAIN_ENV);
1041
+ if (r.missing || r.timedOut) {
1042
+ noteFailure(f, execFailure(cmd, r, s.timeoutMs));
1043
+ return;
1044
+ }
1045
+ const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
1046
+ if (v) {
1047
+ f.installed = v;
1048
+ addSource(f, "check-command");
1049
+ }
1050
+ }
1051
+
1052
+ /** The npm registry's `latest` dist-tag for a package. */
1053
+ async function npmLatest(
1054
+ s: ProbeSession,
1055
+ pkg: string,
1056
+ f: ToolFacts,
1057
+ ): Promise<void> {
1058
+ const registry = (
1059
+ s.io.env.npm_config_registry ?? "https://registry.npmjs.org"
1060
+ ).replace(/\/+$/, "");
1061
+ // Keep a scope's `@`, encode its `/` as %2F (verified against the real
1062
+ // registry for @openai/codex).
1063
+ const encoded = encodeURIComponent(pkg).replace(/^%40/, "@");
1064
+ const r = await s.io.fetchJson(`${registry}/-/package/${encoded}/dist-tags`, {
1065
+ timeoutMs: s.timeoutMs,
1066
+ });
1067
+ if (!r.ok) {
1068
+ noteFailure(f, `npm registry ${r.reason}`);
1069
+ return;
1070
+ }
1071
+ const latest = versionFromNpmDistTags(r.body);
1072
+ if (latest) {
1073
+ f.latest = latest;
1074
+ addSource(f, "npm-registry");
1075
+ } else noteFailure(f, "npm registry published no latest tag");
1076
+ }
1077
+
1078
+ async function pypiLatest(
1079
+ s: ProbeSession,
1080
+ pkg: string,
1081
+ f: ToolFacts,
1082
+ ): Promise<void> {
1083
+ const r = await s.io.fetchJson(
1084
+ `https://pypi.org/pypi/${encodeURIComponent(pkg)}/json`,
1085
+ { timeoutMs: s.timeoutMs },
1086
+ );
1087
+ if (!r.ok) {
1088
+ noteFailure(f, `PyPI ${r.reason}`);
1089
+ return;
1090
+ }
1091
+ const latest = versionFromPypiJson(r.body);
1092
+ if (latest) {
1093
+ f.latest = latest;
1094
+ addSource(f, "pypi");
1095
+ } else noteFailure(f, "PyPI published no version");
1096
+ }
1097
+
1098
+ /** Ownership by realpath containment in `ownerDir` (itself realpath'd). */
1099
+ async function containedIn(
1100
+ s: ProbeSession,
1101
+ resolvedPath: string,
1102
+ ownerDir: string,
1103
+ ): Promise<{ inside: boolean; real: string | null }> {
1104
+ const real = await s.io.realpath(ownerDir);
1105
+ return { inside: real !== null && isInside(resolvedPath, real), real };
1106
+ }
1107
+
1108
+ async function probeBrew(
1109
+ s: ProbeSession,
1110
+ spec: BinRequirement,
1111
+ f: ToolFacts,
1112
+ resolvedPath: string,
1113
+ ): Promise<void> {
1114
+ const formula = spec.formula ?? spec.name;
1115
+ const prefix = await s.brewPrefix();
1116
+ if ("failure" in prefix) {
1117
+ f.managed = null;
1118
+ noteFailure(f, prefix.failure);
1119
+ return;
1120
+ }
1121
+ const cellar =
1122
+ (await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
1123
+ path.join(prefix.dir, "Cellar");
1124
+ const ownerDir = path.join(cellar, formula);
1125
+ f.owner = `brew (Cellar ${ownerDir})`;
1126
+ f.managed = isInside(resolvedPath, ownerDir);
1127
+ if (!f.managed) return;
1128
+ f.installed = brewKegVersion(resolvedPath, cellar, formula);
1129
+ if (f.installed) addSource(f, "brew");
1130
+
1131
+ const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
1132
+ if (r.code !== 0) {
1133
+ noteFailure(f, execFailure("brew", r, s.timeoutMs));
1134
+ return;
1135
+ }
1136
+ const parsed = parseBrewOutdatedJson(r.stdout, formula);
1137
+ if (!parsed) {
1138
+ noteFailure(f, "brew outdated printed something unreadable");
1139
+ return;
1140
+ }
1141
+ addSource(f, "brew");
1142
+ f.behind = parsed.behind;
1143
+ if (parsed.latest) f.latest = parsed.latest;
1144
+ if (parsed.pinned) f.heldBack = "held by brew pin";
1145
+ }
1146
+
1147
+ async function probePip(
1148
+ s: ProbeSession,
1149
+ spec: BinRequirement,
1150
+ f: ToolFacts,
1151
+ onPath: string,
1152
+ resolvedPath: string,
1153
+ pinned: boolean,
1154
+ ): Promise<void> {
1155
+ const pkg = spec.package ?? spec.name;
1156
+ const norm = normalizePythonPackageName(pkg);
1157
+
1158
+ const uvDir = await s.uvToolDir();
1159
+ if ("dir" in uvDir) {
1160
+ const ownerDir = path.join(uvDir.dir, norm);
1161
+ f.owner = `uv (tool dir ${uvDir.dir})`;
1162
+ const { inside, real } = await containedIn(s, resolvedPath, ownerDir);
1163
+ // Adoption: `uv tool install --force` overwrites the very file PATH runs
1164
+ // when it sits in uv's bin dir, whoever put it there.
1165
+ const bin = await s.uvBinDir();
1166
+ const adopted =
1167
+ "dir" in bin &&
1168
+ path.resolve(path.dirname(onPath)) === path.resolve(bin.dir);
1169
+ f.managed = inside || adopted;
1170
+ if (adopted && !inside) {
1171
+ // uv did not install this file, but `--force` will overwrite it. The
1172
+ // plan row is an ordinary upgrade, so without this it never says that
1173
+ // the owning installer changes — and pipx, say, goes on claiming a
1174
+ // venv nothing runs until its next `upgrade-all` writes back over it.
1175
+ f.adopted = `installed by another tool in uv's bin dir (${bin.dir}); upgrading adopts it into uv`;
1176
+ addSource(f, "uv-adopted");
1177
+ }
1178
+
1179
+ if (inside) {
1180
+ const listed = (await s.uvList()).get(norm);
1181
+ if (listed) {
1182
+ f.installed = listed.installed;
1183
+ addSource(f, "uv");
1184
+ }
1185
+ }
1186
+ if (!f.managed || pinned) return;
1187
+
1188
+ const outdated = await s.uvOutdated();
1189
+ if (outdated.ok) {
1190
+ const hit = outdated.map.get(norm);
1191
+ if (hit) {
1192
+ f.behind = true;
1193
+ f.latest = hit.latest;
1194
+ addSource(f, "uv");
1195
+ return;
1196
+ }
1197
+ const receiptText = await s.io.readText(
1198
+ path.join(real ?? ownerDir, "uv-receipt.toml"),
1199
+ );
1200
+ const kind =
1201
+ inside && receiptText !== null
1202
+ ? parseUvReceipt(receiptText, pkg)
1203
+ : null;
1204
+ if (kind) addSource(f, "uv-receipt");
1205
+ if (kind === "index") {
1206
+ f.behind = false;
1207
+ return;
1208
+ }
1209
+ if (
1210
+ kind === "git" ||
1211
+ kind === "directory" ||
1212
+ kind === "path" ||
1213
+ kind === "url" ||
1214
+ kind === "editable"
1215
+ ) {
1216
+ f.behind = null;
1217
+ f.latest = null;
1218
+ noteFailure(f, `installed from ${kind}, not a package index`);
1219
+ return;
1220
+ }
1221
+ // Constrained, or a receipt we could not read: ask PyPI.
1222
+ await pypiLatest(s, pkg, f);
1223
+ return;
1224
+ }
1225
+ if (!outdated.unsupported) noteFailure(f, outdated.failure);
1226
+ await pypiLatest(s, pkg, f);
1227
+ return;
1228
+ }
1229
+
1230
+ if (!uvDir.missing) {
1231
+ // uv exists but would not say where it keeps tools.
1232
+ f.managed = null;
1233
+ noteFailure(f, uvDir.failure);
1234
+ if (!pinned) await pypiLatest(s, pkg, f);
1235
+ return;
1236
+ }
1237
+
1238
+ const pipx = await s.pipxList();
1239
+ if (pipx !== null) {
1240
+ const home =
1241
+ s.io.env.PIPX_HOME ?? path.join(s.io.homedir, ".local", "pipx");
1242
+ const binDir =
1243
+ s.io.env.PIPX_BIN_DIR ?? path.join(s.io.homedir, ".local", "bin");
1244
+ const ownerDir = path.join(home, "venvs", norm);
1245
+ f.owner = `pipx (venvs ${path.join(home, "venvs")})`;
1246
+ const { inside } = await containedIn(s, resolvedPath, ownerDir);
1247
+ f.managed =
1248
+ inside || path.resolve(path.dirname(onPath)) === path.resolve(binDir);
1249
+ const v = inside ? pipx.get(norm) : undefined;
1250
+ if (v) {
1251
+ f.installed = v;
1252
+ addSource(f, "pipx");
1253
+ }
1254
+ if (f.managed && !pinned) await pypiLatest(s, pkg, f);
1255
+ return;
1256
+ }
1257
+
1258
+ // `python3 -m pip --user` only: its location is not queried.
1259
+ f.managed = null;
1260
+ noteFailure(f, "neither uv nor pipx is installed");
1261
+ if (!pinned) await pypiLatest(s, pkg, f);
1262
+ }
1263
+
1264
+ async function probeNodeGlobal(
1265
+ s: ProbeSession,
1266
+ spec: BinRequirement,
1267
+ f: ToolFacts,
1268
+ resolvedPath: string,
1269
+ pinned: boolean,
1270
+ ): Promise<void> {
1271
+ const pkg = spec.package ?? spec.name;
1272
+ let root: string;
1273
+ if (spec.via === "bun") {
1274
+ root = path.join(
1275
+ s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
1276
+ "install",
1277
+ "global",
1278
+ "node_modules",
1279
+ );
1280
+ f.owner = `bun (global ${root})`;
1281
+ } else {
1282
+ const npmRoot = await s.npmRoot();
1283
+ if ("failure" in npmRoot) {
1284
+ f.managed = null;
1285
+ noteFailure(f, npmRoot.failure);
1286
+ return;
1287
+ }
1288
+ root = npmRoot.dir;
1289
+ f.owner = `npm (root ${root})`;
1290
+ }
1291
+ const { inside, real } = await containedIn(
1292
+ s,
1293
+ resolvedPath,
1294
+ path.join(root, pkg),
1295
+ );
1296
+ f.managed = inside;
1297
+ if (!inside || !real) return;
1298
+ f.installed = await packageJsonVersion(s.io, real);
1299
+ if (f.installed)
1300
+ addSource(f, spec.via === "bun" ? "bun-global" : "npm-global");
1301
+ if (!pinned) await npmLatest(s, pkg, f);
1302
+ }
1303
+
1304
+ async function probeGo(
1305
+ s: ProbeSession,
1306
+ spec: BinRequirement,
1307
+ f: ToolFacts,
1308
+ resolvedPath: string,
1309
+ pinned: boolean,
1310
+ ): Promise<void> {
1311
+ const want = spec.module ?? spec.name;
1312
+ const r = await s.run("go", ["version", "-m", resolvedPath], PLAIN_ENV);
1313
+ const info = r.code === 0 ? parseGoVersionM(r.stdout) : null;
1314
+ if (!info) {
1315
+ f.managed = null;
1316
+ noteFailure(
1317
+ f,
1318
+ r.code === 0
1319
+ ? "no Go build info in the binary"
1320
+ : execFailure("go", r, s.timeoutMs),
1321
+ );
1322
+ return;
1323
+ }
1324
+ f.installed = info.version;
1325
+ addSource(f, "go-buildinfo");
1326
+
1327
+ const gobin = await s.goBin();
1328
+ if ("failure" in gobin) {
1329
+ f.managed = null;
1330
+ noteFailure(f, gobin.failure);
1331
+ return;
1332
+ }
1333
+ const realBin = (await s.io.realpath(gobin.dir)) ?? gobin.dir;
1334
+ f.owner = `go (GOBIN ${realBin})`;
1335
+ const sameModule =
1336
+ want === info.module ||
1337
+ want === info.path ||
1338
+ want.startsWith(`${info.module}/`);
1339
+ f.managed =
1340
+ path.resolve(path.dirname(resolvedPath)) === path.resolve(realBin) &&
1341
+ sameModule;
1342
+ if (!f.managed || pinned) return;
1343
+
1344
+ const latest = await s.run(
1345
+ "go",
1346
+ ["list", "-m", "-f", "{{.Version}}", `${info.module}@latest`],
1347
+ PLAIN_ENV,
1348
+ );
1349
+ const v = latest.code === 0 ? firstLine(latest.stdout) : "";
1350
+ if (v) {
1351
+ f.latest = v;
1352
+ addSource(f, "go-proxy");
1353
+ } else noteFailure(f, execFailure("go", latest, s.timeoutMs));
1354
+ }
1355
+
1356
+ /** Probe one tool into `f`, mutating it as facts arrive. Never throws. */
1357
+ async function probeInto(
1358
+ s: ProbeSession,
1359
+ spec: BinRequirement,
1360
+ f: ToolFacts,
1361
+ ): Promise<void> {
1362
+ const refused = refusal(spec);
1363
+ if (refused) {
1364
+ f.failure = refused;
1365
+ return;
1366
+ }
1367
+
1368
+ const onPath = await s.io.resolveExecutable(spec.name);
1369
+ const resolvedPath = onPath ? await s.io.realpath(onPath) : null;
1370
+ addSource(f, "path");
1371
+ if (!onPath || !resolvedPath) {
1372
+ f.present = false;
1373
+ return;
1374
+ }
1375
+ f.present = true;
1376
+ f.resolvedPath = resolvedPath;
1377
+
1378
+ const pinned = pinOf(spec) !== undefined;
1379
+ const via: BinInstaller = spec.via;
1380
+ switch (via) {
1381
+ case "brew":
1382
+ await probeBrew(s, spec, f, resolvedPath);
1383
+ break;
1384
+ case "pip":
1385
+ await probePip(s, spec, f, onPath, resolvedPath, pinned);
1386
+ break;
1387
+ case "bun":
1388
+ case "npm":
1389
+ await probeNodeGlobal(s, spec, f, resolvedPath, pinned);
1390
+ break;
1391
+ case "go":
1392
+ await probeGo(s, spec, f, resolvedPath, pinned);
1393
+ break;
1394
+ }
1395
+
1396
+ // The method's own record was silent: read the running file itself — but
1397
+ // only where the caller opted into running a check command (see there).
1398
+ if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
1399
+ }
1400
+
1401
+ /** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
1402
+ function timedOut(f: ToolFacts, deadlineMs: number): ToolFacts {
1403
+ return {
1404
+ ...f,
1405
+ sources: [...f.sources],
1406
+ latest: null,
1407
+ behind: null,
1408
+ failure: `timed out after ${Math.round(deadlineMs / 1000)}s`,
1409
+ };
1410
+ }
1411
+
1412
+ async function probeWithDeadline(
1413
+ s: ProbeSession,
1414
+ spec: BinRequirement,
1415
+ deadlineAt: number,
1416
+ deadlineMs: number,
1417
+ ): Promise<ToolFacts> {
1418
+ const f = blankFacts(spec.name);
1419
+ const remaining = deadlineAt - Date.now();
1420
+ if (remaining <= 0) return timedOut(f, deadlineMs);
1421
+ let timer: ReturnType<typeof setTimeout> | undefined;
1422
+ const deadline = new Promise<"deadline">((resolve) => {
1423
+ timer = setTimeout(() => resolve("deadline"), remaining);
1424
+ });
1425
+ try {
1426
+ const outcome = await Promise.race([
1427
+ probeInto(s, spec, f).then(
1428
+ () => "done" as const,
1429
+ (error: unknown) => {
1430
+ noteFailure(
1431
+ f,
1432
+ error instanceof Error ? error.message : String(error),
1433
+ );
1434
+ return "done" as const;
1435
+ },
1436
+ ),
1437
+ deadline,
1438
+ ]);
1439
+ return outcome === "deadline"
1440
+ ? timedOut(f, deadlineMs)
1441
+ : { ...f, sources: [...f.sources] };
1442
+ } finally {
1443
+ if (timer) clearTimeout(timer);
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * Facts for every spec, in spec order. Never rejects: every failure becomes
1449
+ * facts with `failure` set, which classify as `unknown`, never `upgrade`.
1450
+ */
1451
+ export async function probeTools(
1452
+ specs: readonly BinRequirement[],
1453
+ io: ToolProbeIO = REAL_TOOL_PROBE_IO,
1454
+ opts: ProbeOptions = {},
1455
+ ): Promise<ToolFacts[]> {
1456
+ const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
1457
+ const deadlineMs = opts.deadlineMs ?? PROBE_DEADLINE_MS;
1458
+ const concurrency = Math.max(1, opts.concurrency ?? PROBE_CONCURRENCY);
1459
+ const session = new ProbeSession(
1460
+ io,
1461
+ timeoutMs,
1462
+ opts.allowCheckCommand === true,
1463
+ );
1464
+ const deadlineAt = Date.now() + deadlineMs;
1465
+ const results: ToolFacts[] = new Array(specs.length);
1466
+ let next = 0;
1467
+ let settled = 0;
1468
+
1469
+ const worker = async (): Promise<void> => {
1470
+ while (next < specs.length) {
1471
+ const index = next++;
1472
+ const spec = specs[index];
1473
+ if (!spec) {
1474
+ // Unreachable from today's callers, and it must stay that way in
1475
+ // the RESULT too: a hole in the array would make
1476
+ // `planBinUpdates`' `new Map(facts.map(…))` throw, instead of
1477
+ // degrading to the `unknown / not-probed` the contract promises.
1478
+ results[index] = { ...blankFacts(""), failure: "not probed" };
1479
+ continue;
1480
+ }
1481
+ const facts = await probeWithDeadline(
1482
+ session,
1483
+ spec,
1484
+ deadlineAt,
1485
+ deadlineMs,
1486
+ );
1487
+ results[index] = facts;
1488
+ settled++;
1489
+ try {
1490
+ opts.onResult?.(facts, { index, settled, total: specs.length });
1491
+ } catch {
1492
+ // A progress callback must not turn a probe into a rejection.
1493
+ }
1494
+ }
1495
+ };
1496
+
1497
+ await Promise.all(
1498
+ Array.from({ length: Math.min(concurrency, specs.length) }, worker),
1499
+ );
1500
+ return results;
1501
+ }
1502
+
1503
+ /** One tool, with its own probe session. */
1504
+ export async function probeTool(
1505
+ spec: BinRequirement,
1506
+ io: ToolProbeIO = REAL_TOOL_PROBE_IO,
1507
+ opts: Omit<ProbeOptions, "concurrency" | "onResult"> = {},
1508
+ ): Promise<ToolFacts> {
1509
+ const [facts] = await probeTools([spec], io, { ...opts, concurrency: 1 });
1510
+ return facts ?? { ...blankFacts(spec.name), failure: "not probed" };
1511
+ }
1512
+
1513
+ // ── Catalogue bridge + install-method detection ──────────────────────────────
1514
+
1515
+ export interface InstallInfo {
1516
+ primary: InstallMethod;
1517
+ all: InstallMethod[];
1518
+ brewFormula?: string;
1519
+ }
1520
+
1521
+ const BIN_INSTALLER_METHODS: ReadonlySet<InstallMethod> = new Set([
1522
+ "npm",
1523
+ "bun",
1524
+ "brew",
1525
+ "pip",
1526
+ ]);
1527
+
1528
+ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
1529
+ return BIN_INSTALLER_METHODS.has(m);
1530
+ }
1531
+
1532
+ /**
1533
+ * The spec a catalogue tool is probed through, in order:
1534
+ *
1535
+ * 1. the active profile's `ResolvedBin` of the same name — the same pin and
1536
+ * the same installer `claudeup update` uses, so the two agree;
1537
+ * 2. the DETECTED installer, when it is one claudeup models — "how it IS
1538
+ * installed", the rule `cliToolUpdate` follows, so the row's state and the
1539
+ * command Enter offers name the same installer;
1540
+ * 3. the catalogue's own mapping. For a pnpm/yarn install this probes a
1541
+ * manager that does not own the file, which is reported as `not-managed`.
1542
+ */
1543
+ export function specForCatalogTool(
1544
+ tool: CliTool,
1545
+ closureBins: readonly ResolvedBin[] | null,
1546
+ install: InstallInfo | undefined,
1547
+ ): BinRequirement {
1548
+ const declared = closureBins?.find((b) => b.name === tool.name);
1549
+ if (declared) return declared;
1550
+ if (install && isBinInstaller(install.primary)) {
1551
+ return {
1552
+ name: tool.name,
1553
+ via: install.primary,
1554
+ package: tool.packageName,
1555
+ check: tool.checkCommand,
1556
+ ...(install.brewFormula ? { formula: install.brewFormula } : {}),
1557
+ };
1558
+ }
1559
+ return {
1560
+ name: tool.name,
1561
+ via: tool.packageManager,
1562
+ package: tool.packageName,
1563
+ check: tool.checkCommand,
1564
+ };
1565
+ }
1566
+
1567
+ function methodFromPath(binPath: string): InstallMethod | null {
1568
+ if (binPath.includes("/.bun/")) return "bun";
1569
+ if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
1570
+ return "brew";
1571
+ if (
1572
+ binPath.includes("/.local/share/claude") ||
1573
+ binPath.includes("/.local/bin/claude")
1574
+ )
1575
+ return "npm";
1576
+ if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
1577
+ return "npm";
1578
+ if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
1579
+ return "pnpm";
1580
+ if (binPath.includes("/.yarn/")) return "yarn";
1581
+ return null;
1582
+ }
1583
+
1584
+ /** The brew formula from a Cellar path like ../Cellar/gemini-cli/0.35.2/bin/gemini. */
1585
+ function extractBrewFormula(
1586
+ binPath: string,
1587
+ linkTarget: string,
1588
+ ): string | undefined {
1589
+ const cellarMatch = linkTarget.match(/Cellar\/([^/]+)\//);
1590
+ if (cellarMatch) return cellarMatch[1];
1591
+ const pathMatch = binPath.match(/Cellar\/([^/]+)\//);
1592
+ if (pathMatch) return pathMatch[1];
1593
+ return undefined;
1594
+ }
1595
+
1596
+ /**
1597
+ * How a catalogue tool IS installed, from every copy on PATH (`which -a`) and
1598
+ * each one's symlink target. Moved from the CLI Tools screen unchanged in
1599
+ * behaviour, except that `which` and `readlink` now run as argv through the IO
1600
+ * seam instead of a `/bin/bash` pipeline.
1601
+ */
1602
+ export async function detectInstallMethods(
1603
+ tool: CliTool,
1604
+ io: ToolProbeIO = REAL_TOOL_PROBE_IO,
1605
+ ): Promise<InstallInfo> {
1606
+ const fallback: InstallMethod =
1607
+ tool.packageManager === "pip" ? "pip" : "unknown";
1608
+ if (!tool.name || tool.name.startsWith("-") || tool.name.includes("/"))
1609
+ return { primary: fallback, all: [] };
1610
+ const r = await io.exec(
1611
+ { cmd: "which", args: ["-a", tool.name] },
1612
+ { timeoutMs: 3000 },
1613
+ );
1614
+ if (r.code !== 0) return { primary: fallback, all: [] };
1615
+ const paths = r.stdout
1616
+ .trim()
1617
+ .split("\n")
1618
+ .map((p) => p.trim())
1619
+ .filter((p) => p && !p.includes("aliased"));
1620
+ if (paths.length === 0) return { primary: fallback, all: [] };
1621
+
1622
+ const methods: InstallMethod[] = [];
1623
+ let brewFormula: string | undefined;
1624
+ for (const binPath of paths) {
1625
+ let method = methodFromPath(binPath);
1626
+ const linkTarget = (await io.readlink(binPath)) ?? "";
1627
+ if (!method && linkTarget) method = methodFromPath(linkTarget);
1628
+ if (method === "brew" && !brewFormula) {
1629
+ brewFormula = extractBrewFormula(binPath, linkTarget);
1630
+ }
1631
+ if (method && !methods.includes(method)) methods.push(method);
1632
+ }
1633
+
1634
+ const primary = methods[0];
1635
+ if (primary === undefined) return { primary: fallback, all: [] };
1636
+ return { primary, all: methods, ...(brewFormula ? { brewFormula } : {}) };
1637
+ }