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
@@ -1,4 +1,4 @@
1
- import { exec, execFile } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import React, { useEffect, useCallback, useState, useRef } from "react";
4
4
  import { ScreenLayout } from "../components/layout/index.js";
@@ -7,221 +7,76 @@ import { useApp, useModal } from "../state/AppContext.js";
7
7
  import { useDimensions } from "../state/DimensionsContext.js";
8
8
 
9
9
  import { cliTools } from "../../data/cli-tools.js";
10
+ import { loadActiveClosure } from "../../services/active-closure.js";
11
+ import { cliToolUninstall } from "../../services/cli-tool-commands.js";
10
12
  import {
11
- type InstallMethod,
12
- cliToolInstall,
13
- cliToolUninstall,
14
- cliToolUpdate,
15
- } from "../../services/cli-tool-commands.js";
13
+ type InstallInfo,
14
+ PROBE_CONCURRENCY,
15
+ REAL_TOOL_PROBE_IO,
16
+ detectInstallMethods,
17
+ probeTools,
18
+ specForCatalogTool,
19
+ } from "../../services/tool-probe.js";
16
20
  import type { Command } from "../../utils/command-utils.js";
17
21
  import { formatCommand } from "../../utils/command-utils.js";
22
+ import {
23
+ type CliToolRow,
24
+ buildToolRow,
25
+ countToolRows,
26
+ isInstalled,
27
+ } from "../adapters/cliToolsAdapter.js";
18
28
  import { ScrollableList } from "../components/ScrollableList.js";
19
29
  import {
20
- type CliToolStatus,
21
30
  renderCliToolDetail,
22
31
  renderCliToolRow,
23
32
  } from "../renderers/cliToolRenderers.js";
24
33
  import { theme } from "../theme.js";
25
34
 
26
- const execAsync = promisify(exec);
27
35
  const execFileAsync = promisify(execFile);
28
36
 
29
- // ─── Version helpers ───────────────────────────────────────────────────────────
37
+ // ─── Session cache ─────────────────────────────────────────────────────────────
38
+ //
39
+ // The probe itself lives in services/tool-probe.ts — the same one `claudeup
40
+ // update` uses — so this screen and the CLI cannot disagree about a tool. It
41
+ // used to carry a private copy: a `/bin/bash` `which`, an `npm view` per tool,
42
+ // its own version comparator, and a string-form `exec` of each check command.
30
43
 
31
- // Session-level cache
32
- let cachedToolStatuses: CliToolStatus[] | null = null;
44
+ let cachedRows: CliToolRow[] | null = null;
33
45
  let cacheInitialized = false;
46
+ /** Bumped by every fetch, so a refresh discards the answers of the one before. */
47
+ let fetchGeneration = 0;
34
48
 
35
49
  function clearCliToolsCache(): void {
36
- cachedToolStatuses = null;
50
+ cachedRows = null;
37
51
  cacheInitialized = false;
38
52
  }
39
53
 
40
- function parseVersion(versionOutput: string): string | undefined {
41
- const match = versionOutput.match(/v?(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
42
- return match ? match[1] : undefined;
43
- }
44
-
45
- function methodFromPath(binPath: string): InstallMethod | null {
46
- if (binPath.includes("/.bun/")) return "bun";
47
- if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
48
- return "brew";
49
- if (
50
- binPath.includes("/.local/share/claude") ||
51
- binPath.includes("/.local/bin/claude")
52
- )
53
- return "npm";
54
- if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
55
- return "npm";
56
- if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
57
- return "pnpm";
58
- if (binPath.includes("/.yarn/")) return "yarn";
59
- return null;
60
- }
61
-
62
- interface InstallInfo {
63
- primary: InstallMethod;
64
- all: InstallMethod[];
65
- brewFormula?: string;
66
- }
67
-
68
- /** Extract brew formula name from a Cellar symlink target like ../Cellar/gemini-cli/0.35.2/bin/gemini */
69
- function extractBrewFormula(
70
- binPath: string,
71
- linkTarget: string,
72
- ): string | undefined {
73
- // Try symlink target first: ../Cellar/{formula}/{version}/...
74
- const cellarMatch = linkTarget.match(/Cellar\/([^/]+)\//);
75
- if (cellarMatch) return cellarMatch[1];
76
- // Try binary path: /opt/homebrew/Cellar/{formula}/...
77
- const pathMatch = binPath.match(/Cellar\/([^/]+)\//);
78
- if (pathMatch) return pathMatch[1];
79
- return undefined;
54
+ /** Rows before anything is known: the catalogue mapping, checking. */
55
+ function initialRows(): CliToolRow[] {
56
+ return cliTools.map((tool) =>
57
+ buildToolRow(tool, specForCatalogTool(tool, null, undefined), undefined),
58
+ );
80
59
  }
81
60
 
82
- async function detectInstallMethods(
83
- tool: import("../../data/cli-tools.js").CliTool,
84
- ): Promise<InstallInfo> {
85
- const fallback = tool.packageManager === "pip" ? "pip" : "unknown";
86
- try {
87
- // which -a returns all matching binaries across PATH
88
- const { stdout } = await execAsync(`which -a ${tool.name} 2>/dev/null`, {
89
- timeout: 3000,
90
- shell: "/bin/bash",
91
- });
92
- const paths = stdout
93
- .trim()
94
- .split("\n")
95
- .filter((p) => p && !p.includes("aliased"));
96
- if (paths.length === 0)
97
- return { primary: fallback as InstallMethod, all: [] };
98
-
99
- const methods: InstallMethod[] = [];
100
- let brewFormula: string | undefined;
101
-
102
- for (const binPath of paths) {
103
- let method = methodFromPath(binPath);
104
- let linkTarget = "";
105
-
106
- // Check symlink target
107
- try {
108
- const { stdout: lt } = await execAsync(
109
- `readlink "${binPath}" 2>/dev/null || true`,
110
- { timeout: 2000, shell: "/bin/bash" },
111
- );
112
- linkTarget = lt.trim();
113
- if (!method) method = methodFromPath(linkTarget);
114
- } catch {
115
- // ignore
116
- }
117
-
118
- // Detect brew formula name
119
- if (method === "brew" && !brewFormula) {
120
- brewFormula = extractBrewFormula(binPath, linkTarget);
121
- }
122
-
123
- if (method && !methods.includes(method)) methods.push(method);
61
+ /** `fn` over `items`, at most `limit` at a time, results in input order. */
62
+ async function mapLimited<T, R>(
63
+ items: readonly T[],
64
+ limit: number,
65
+ fn: (item: T) => Promise<R>,
66
+ ): Promise<R[]> {
67
+ const out: R[] = new Array(items.length);
68
+ let next = 0;
69
+ const worker = async () => {
70
+ while (next < items.length) {
71
+ const i = next++;
72
+ const item = items[i];
73
+ if (item !== undefined) out[i] = await fn(item);
124
74
  }
125
-
126
- const primary = methods[0];
127
- if (primary === undefined)
128
- return { primary: fallback as InstallMethod, all: [] };
129
- return { primary, all: methods, brewFormula };
130
- } catch {
131
- return { primary: fallback as InstallMethod, all: [] };
132
- }
133
- }
134
-
135
- async function getInstalledVersion(
136
- tool: import("../../data/cli-tools.js").CliTool,
137
- ): Promise<string | undefined> {
138
- try {
139
- const { stdout } = await execAsync(tool.checkCommand, { timeout: 5000 });
140
- return parseVersion(stdout.trim());
141
- } catch {
142
- return undefined;
143
- }
144
- }
145
-
146
- async function getLatestNpmVersion(
147
- packageName: string,
148
- ): Promise<string | undefined> {
149
- try {
150
- const { stdout } = await execAsync(
151
- `npm view ${packageName} version 2>/dev/null`,
152
- { timeout: 10000 },
153
- );
154
- return stdout.trim() || undefined;
155
- } catch {
156
- return undefined;
157
- }
158
- }
159
-
160
- /**
161
- * Pick the version out of a PyPI JSON API body.
162
- *
163
- * Split out from the fetch so it is testable without a network: the parsing is
164
- * the part that can be wrong, and the part that must not throw.
165
- */
166
- export function versionFromPypiJson(body: unknown): string | undefined {
167
- const info = (body as { info?: { version?: unknown } } | null)?.info;
168
- return typeof info?.version === "string" ? info.version : undefined;
169
- }
170
-
171
- /**
172
- * Latest published version of a Python package, straight from PyPI.
173
- *
174
- * Was `pip index versions <pkg> | head -1` through a shell, which had three
175
- * independent problems and only needed one to fail: `pip` is often not an
176
- * executable at all (the bug this whole change exists for), `pip index` is an
177
- * experimental subcommand upstream reserves the right to change, and a machine
178
- * managing its Python tools with uv or pipx may carry no `pip` whatsoever.
179
- *
180
- * An HTTP GET needs none of them. Failure mode is unchanged — undefined, so the
181
- * row simply shows no update badge — because a version check is advisory and
182
- * must never take the screen down with it.
183
- */
184
- async function getLatestPipVersion(
185
- packageName: string,
186
- ): Promise<string | undefined> {
187
- try {
188
- const res = await fetch(
189
- `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`,
190
- { signal: AbortSignal.timeout(10000) },
191
- );
192
- if (!res.ok) return undefined;
193
- return versionFromPypiJson(await res.json());
194
- } catch {
195
- return undefined;
196
- }
197
- }
198
-
199
- async function getLatestVersion(
200
- tool: import("../../data/cli-tools.js").CliTool,
201
- ): Promise<string | undefined> {
202
- switch (tool.packageManager) {
203
- // bun packages are published to the npm registry, so `npm view` resolves them.
204
- case "bun":
205
- case "npm":
206
- return getLatestNpmVersion(tool.packageName);
207
- case "pip":
208
- return getLatestPipVersion(tool.packageName);
209
- // brew/go have no uniform remote version query here — skip the check.
210
- default:
211
- return undefined;
212
- }
213
- }
214
-
215
- function compareVersions(v1: string, v2: string): number {
216
- const parts1 = v1.split(/[-.]/).map((p) => Number.parseInt(p, 10) || 0);
217
- const parts2 = v2.split(/[-.]/).map((p) => Number.parseInt(p, 10) || 0);
218
- for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
219
- const p1 = parts1[i] || 0;
220
- const p2 = parts2[i] || 0;
221
- if (p1 < p2) return -1;
222
- if (p1 > p2) return 1;
223
- }
224
- return 0;
75
+ };
76
+ await Promise.all(
77
+ Array.from({ length: Math.min(limit, items.length) }, worker),
78
+ );
79
+ return out;
225
80
  }
226
81
 
227
82
  // ─── Component ─────────────────────────────────────────────────────────────────
@@ -232,68 +87,80 @@ export function CliToolsScreen() {
232
87
  const modal = useModal();
233
88
  const dimensions = useDimensions();
234
89
 
235
- const [toolStatuses, setToolStatuses] = useState<CliToolStatus[]>(() => {
236
- return (
237
- cachedToolStatuses ||
238
- cliTools.map((tool) => ({
239
- tool,
240
- installed: false,
241
- installedVersion: undefined,
242
- checking: true,
243
- }))
244
- );
245
- });
90
+ const [rows, setRows] = useState<CliToolRow[]>(
91
+ () => cachedRows || initialRows(),
92
+ );
246
93
 
247
- const statusesRef = useRef(toolStatuses);
248
- statusesRef.current = toolStatuses;
94
+ const rowsRef = useRef(rows);
95
+ rowsRef.current = rows;
249
96
 
250
- const updateToolStatus = useCallback(
251
- (index: number, updates: Partial<CliToolStatus>) => {
252
- setToolStatuses((prev) => {
253
- const newStatuses = [...prev];
254
- newStatuses[index] = { ...newStatuses[index], ...updates };
255
- cachedToolStatuses = newStatuses;
256
- return newStatuses;
97
+ const setAndCache = useCallback(
98
+ (update: (prev: CliToolRow[]) => CliToolRow[]) => {
99
+ setRows((prev) => {
100
+ const next = update(prev);
101
+ cachedRows = next;
102
+ return next;
257
103
  });
258
104
  },
259
105
  [],
260
106
  );
261
107
 
108
+ /**
109
+ * Detection first — it decides which installer each tool is probed through
110
+ * (§4.5 of the design: the closure's spec, else the detected installer, else
111
+ * the catalogue) — then one shared probe, rows filling in as tools settle.
112
+ */
262
113
  const fetchVersionInfo = useCallback(async () => {
263
- for (let i = 0; i < cliTools.length; i++) {
264
- const tool = cliTools[i];
265
-
266
- // Run all checks in parallel, then update with all results
267
- Promise.all([
268
- getInstalledVersion(tool),
269
- getLatestVersion(tool),
270
- detectInstallMethods(tool),
271
- ]).then(([version, latest, info]) => {
272
- updateToolStatus(i, {
273
- installedVersion: version,
274
- installed: version !== undefined,
275
- latestVersion: latest,
276
- checking: false,
277
- hasUpdate:
278
- version && latest ? compareVersions(version, latest) < 0 : false,
279
- installMethod: version ? info.primary : undefined,
280
- allMethods: version && info.all.length > 1 ? info.all : undefined,
281
- updateCommand: version
282
- ? (cliToolUpdate(tool, info.primary, info.brewFormula) ?? undefined)
283
- : undefined,
284
- brewFormula: info.brewFormula,
285
- });
286
- });
287
- }
288
- }, [updateToolStatus]);
114
+ const generation = ++fetchGeneration;
115
+ const projectPath = state.projectPath || process.cwd();
116
+ const [active, installs] = await Promise.all([
117
+ // Full resolution, not `resolveBins: false`: a catalogue tool may be
118
+ // declared by a PLUGIN's requires.bin, and parity needs the spec the
119
+ // CLI plans with. `resolveBins: false` would still keep the profile's
120
+ // own cliTools, but lose every plugin-declared bin and its pin.
121
+ loadActiveClosure(projectPath),
122
+ mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
123
+ detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
124
+ ),
125
+ ]);
126
+ if (generation !== fetchGeneration) return;
127
+
128
+ const closureBins = active?.closure.bins ?? null;
129
+ const planned = cliTools.map((tool, i) => ({
130
+ tool,
131
+ install: installs[i],
132
+ spec: specForCatalogTool(tool, closureBins, installs[i]),
133
+ }));
134
+ setAndCache(() =>
135
+ planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
136
+ );
137
+
138
+ await probeTools(
139
+ planned.map((p) => p.spec),
140
+ REAL_TOOL_PROBE_IO,
141
+ {
142
+ onResult: (facts, { index }) => {
143
+ if (generation !== fetchGeneration) return;
144
+ const p = planned[index];
145
+ if (!p) return;
146
+ const row = buildToolRow(p.tool, p.spec, facts, p.install);
147
+ setAndCache((prev) => {
148
+ const next = [...prev];
149
+ next[index] = row;
150
+ return next;
151
+ });
152
+ },
153
+ },
154
+ );
155
+ }, [setAndCache, state.projectPath]);
289
156
 
290
157
  useEffect(() => {
291
158
  if (!cacheInitialized) {
292
159
  cacheInitialized = true;
293
- cachedToolStatuses = toolStatuses;
294
- fetchVersionInfo();
160
+ cachedRows = rows;
161
+ void fetchVersionInfo();
295
162
  }
296
- }, [fetchVersionInfo, toolStatuses]);
163
+ }, [fetchVersionInfo, rows]);
297
164
 
298
165
  useKeyboard((event) => {
299
166
  if (state.isSearching || state.modal) return;
@@ -303,7 +170,7 @@ export function CliToolsScreen() {
303
170
  dispatch({ type: "CLITOOLS_SELECT", index: newIndex });
304
171
  } else if (event.name === "down" || event.name === "j") {
305
172
  const newIndex = Math.min(
306
- toolStatuses.length - 1,
173
+ rows.length - 1,
307
174
  cliToolsState.selectedIndex + 1,
308
175
  );
309
176
  dispatch({ type: "CLITOOLS_SELECT", index: newIndex });
@@ -320,16 +187,9 @@ export function CliToolsScreen() {
320
187
 
321
188
  const handleRefresh = () => {
322
189
  clearCliToolsCache();
323
- setToolStatuses(
324
- cliTools.map((tool) => ({
325
- tool,
326
- installed: false,
327
- installedVersion: undefined,
328
- checking: true,
329
- })),
330
- );
331
- cacheInitialized = false;
332
- fetchVersionInfo();
190
+ setRows(initialRows());
191
+ cacheInitialized = true;
192
+ void fetchVersionInfo();
333
193
  };
334
194
 
335
195
  /**
@@ -354,17 +214,22 @@ export function CliToolsScreen() {
354
214
  };
355
215
 
356
216
  const handleInstall = async () => {
357
- const status = toolStatuses[cliToolsState.selectedIndex];
358
- if (!status) return;
359
-
360
- const { tool, installed, hasUpdate, updateCommand } = status;
217
+ const row = rows[cliToolsState.selectedIndex];
218
+ if (!row || row.checking) return;
219
+
220
+ const { tool, status, updateCommand, installCommand } = row;
221
+ const installed = isInstalled(status);
222
+ // Enter on an `unknown` row still offers the detected-method command:
223
+ // an explicit user action, not a plan. The detail panel shows why the
224
+ // row is unverified beside it.
361
225
  const action = !installed
362
226
  ? "Installing"
363
- : hasUpdate
227
+ : status?.state === "upgrade" || status?.state === "unknown"
364
228
  ? "Updating"
365
229
  : "Reinstalling";
366
- const command =
367
- installed && updateCommand ? updateCommand : cliToolInstall(tool);
230
+ // Both sides come from the row, which built them from the SAME rule its
231
+ // state came from a declared tool's pin included.
232
+ const command = installed && updateCommand ? updateCommand : installCommand;
368
233
 
369
234
  modal.loading(`${action} ${tool.displayName}...`);
370
235
  const result = await runCommand(command);
@@ -382,14 +247,16 @@ export function CliToolsScreen() {
382
247
  };
383
248
 
384
249
  const handleResolveConflict = async () => {
385
- const status = toolStatuses[cliToolsState.selectedIndex];
386
- if (!status || !status.allMethods || status.allMethods.length < 2) return;
250
+ const row = rows[cliToolsState.selectedIndex];
251
+ const install: InstallInfo | undefined = row?.install;
252
+ if (!row || !install || install.all.length < 2) return;
387
253
 
388
- const { tool, allMethods, installMethod, brewFormula } = status;
254
+ const { tool } = row;
255
+ const allMethods = install.all;
389
256
 
390
257
  // Let user pick which install to keep
391
258
  const options = allMethods.map((method) => ({
392
- label: `Keep ${method}${method === installMethod ? " (active)" : ""}, remove others`,
259
+ label: `Keep ${method}${method === install.primary ? " (active)" : ""}, remove others`,
393
260
  value: method,
394
261
  }));
395
262
 
@@ -405,7 +272,7 @@ export function CliToolsScreen() {
405
272
 
406
273
  const errors: string[] = [];
407
274
  for (const method of toRemove) {
408
- const cmd = cliToolUninstall(tool, method, brewFormula);
275
+ const cmd = cliToolUninstall(tool, method, install.brewFormula);
409
276
  if (!cmd) continue;
410
277
  const result = await runCommand(cmd);
411
278
  if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
@@ -425,11 +292,14 @@ export function CliToolsScreen() {
425
292
  };
426
293
 
427
294
  const handleUpdateAll = async () => {
428
- const updatable = toolStatuses.filter((s) => s.hasUpdate);
295
+ // Only rows the shared classifier says are behind. An `unknown` is never
296
+ // swept up in a bulk action — that is exactly the "cannot tell, so run
297
+ // it anyway" the CLI no longer does either.
298
+ const updatable = rows.filter((r) => r.status?.state === "upgrade");
429
299
  if (updatable.length === 0) {
430
300
  await modal.message(
431
301
  "Up to Date",
432
- "All tools are already up to date.",
302
+ "No tool is known to be behind.",
433
303
  "info",
434
304
  );
435
305
  return;
@@ -437,29 +307,33 @@ export function CliToolsScreen() {
437
307
 
438
308
  modal.loading(`Updating ${updatable.length} tool(s)...`);
439
309
 
440
- for (const status of updatable) {
441
- const command = status.updateCommand || cliToolInstall(status.tool);
442
- await runCommand(command);
310
+ for (const row of updatable) {
311
+ await runCommand(row.updateCommand ?? row.installCommand);
443
312
  }
444
313
 
445
314
  modal.hideModal();
446
315
  handleRefresh();
447
316
  };
448
317
 
449
- const selectedStatus = toolStatuses[cliToolsState.selectedIndex];
318
+ const selectedRow = rows[cliToolsState.selectedIndex];
450
319
 
451
- const installedCount = toolStatuses.filter((s) => s.installed).length;
452
- const updateCount = toolStatuses.filter((s) => s.hasUpdate).length;
320
+ const counts = countToolRows(rows);
453
321
  const statusContent = (
454
322
  <text fg={theme.colors.text}>
455
323
  <span fg={theme.colors.muted}>Installed: </span>
456
324
  <span fg={theme.colors.info}>
457
- {installedCount}/{toolStatuses.length}
325
+ {counts.installed}/{rows.length}
458
326
  </span>
459
- {updateCount > 0 && (
327
+ {counts.updates > 0 && (
460
328
  <>
461
329
  <span fg={theme.colors.muted}> │ Updates: </span>
462
- <span fg={theme.colors.warning}>{updateCount}</span>
330
+ <span fg={theme.colors.warning}>{counts.updates}</span>
331
+ </>
332
+ )}
333
+ {counts.unverified > 0 && (
334
+ <>
335
+ <span fg={theme.colors.muted}> │ Unverified: </span>
336
+ <span fg={theme.colors.warning}>{counts.unverified}</span>
463
337
  </>
464
338
  )}
465
339
  </text>
@@ -478,14 +352,14 @@ export function CliToolsScreen() {
478
352
  ]}
479
353
  listPanel={
480
354
  <ScrollableList
481
- items={toolStatuses}
355
+ items={rows}
482
356
  selectedIndex={cliToolsState.selectedIndex}
483
357
  renderItem={renderCliToolRow}
484
358
  maxHeight={dimensions.listPanelHeight}
485
- getKey={(status) => status.tool.name}
359
+ getKey={(row) => row.tool.name}
486
360
  />
487
361
  }
488
- detailPanel={renderCliToolDetail(selectedStatus)}
362
+ detailPanel={renderCliToolDetail(selectedRow)}
489
363
  />
490
364
  );
491
365
  }