claudeup 4.1.0 → 4.3.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.
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useCallback, useState, useRef } from "react";
2
- import { exec, execSync } from "child_process";
2
+ import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
4
  import { useApp, useModal } from "../state/AppContext.js";
5
5
  import { useDimensions } from "../state/DimensionsContext.js";
@@ -11,6 +11,7 @@ import {
11
11
  renderCliToolRow,
12
12
  renderCliToolDetail,
13
13
  type CliToolStatus,
14
+ type InstallMethod,
14
15
  } from "../renderers/cliToolRenderers.js";
15
16
 
16
17
  const execAsync = promisify(exec);
@@ -31,19 +32,105 @@ function parseVersion(versionOutput: string): string | undefined {
31
32
  return match ? match[1] : undefined;
32
33
  }
33
34
 
34
- async function isInstalledViaHomebrew(toolName: string): Promise<boolean> {
35
+ function methodFromPath(binPath: string): InstallMethod | null {
36
+ if (binPath.includes("/.bun/")) return "bun";
37
+ if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/")) return "brew";
38
+ if (binPath.includes("/.local/share/claude") || binPath.includes("/.local/bin/claude")) return "npm";
39
+ if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/")) return "npm";
40
+ if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/")) return "pnpm";
41
+ if (binPath.includes("/.yarn/")) return "yarn";
42
+ return null;
43
+ }
44
+
45
+ interface InstallInfo {
46
+ primary: InstallMethod;
47
+ all: InstallMethod[];
48
+ brewFormula?: string;
49
+ }
50
+
51
+ /** Extract brew formula name from a Cellar symlink target like ../Cellar/gemini-cli/0.35.2/bin/gemini */
52
+ function extractBrewFormula(binPath: string, linkTarget: string): string | undefined {
53
+ // Try symlink target first: ../Cellar/{formula}/{version}/...
54
+ const cellarMatch = linkTarget.match(/Cellar\/([^/]+)\//);
55
+ if (cellarMatch) return cellarMatch[1];
56
+ // Try binary path: /opt/homebrew/Cellar/{formula}/...
57
+ const pathMatch = binPath.match(/Cellar\/([^/]+)\//);
58
+ if (pathMatch) return pathMatch[1];
59
+ return undefined;
60
+ }
61
+
62
+ async function detectInstallMethods(
63
+ tool: import("../../data/cli-tools.js").CliTool,
64
+ ): Promise<InstallInfo> {
65
+ const fallback = tool.packageManager === "pip" ? "pip" : "unknown";
35
66
  try {
36
- const { stdout } = await execAsync("brew list --formula 2>/dev/null", {
37
- timeout: 2000,
67
+ // which -a returns all matching binaries across PATH
68
+ const { stdout } = await execAsync(`which -a ${tool.name} 2>/dev/null`, {
69
+ timeout: 3000,
38
70
  shell: "/bin/bash",
39
71
  });
40
- const formulas = stdout.trim().split("\n");
41
- return formulas.some((f) => f.trim() === toolName);
72
+ const paths = stdout.trim().split("\n").filter((p) => p && !p.includes("aliased"));
73
+ if (paths.length === 0) return { primary: fallback as InstallMethod, all: [] };
74
+
75
+ const methods: InstallMethod[] = [];
76
+ let brewFormula: string | undefined;
77
+
78
+ for (const binPath of paths) {
79
+ let method = methodFromPath(binPath);
80
+ let linkTarget = "";
81
+
82
+ // Check symlink target
83
+ try {
84
+ const { stdout: lt } = await execAsync(
85
+ `readlink "${binPath}" 2>/dev/null || true`,
86
+ { timeout: 2000, shell: "/bin/bash" },
87
+ );
88
+ linkTarget = lt.trim();
89
+ if (!method) method = methodFromPath(linkTarget);
90
+ } catch {
91
+ // ignore
92
+ }
93
+
94
+ // Detect brew formula name
95
+ if (method === "brew" && !brewFormula) {
96
+ brewFormula = extractBrewFormula(binPath, linkTarget);
97
+ }
98
+
99
+ if (method && !methods.includes(method)) methods.push(method);
100
+ }
101
+
102
+ if (methods.length === 0) return { primary: fallback as InstallMethod, all: [] };
103
+ return { primary: methods[0]!, all: methods, brewFormula };
42
104
  } catch {
43
- return false;
105
+ return { primary: fallback as InstallMethod, all: [] };
106
+ }
107
+ }
108
+
109
+ function getUninstallCommand(tool: import("../../data/cli-tools.js").CliTool, method: InstallMethod, brewFormula?: string): string {
110
+ switch (method) {
111
+ case "bun": return `bun remove -g ${tool.packageName}`;
112
+ case "npm": return `npm uninstall -g ${tool.packageName}`;
113
+ case "pnpm": return `pnpm remove -g ${tool.packageName}`;
114
+ case "yarn": return `yarn global remove ${tool.packageName}`;
115
+ case "brew": return `brew uninstall ${brewFormula || tool.name}`;
116
+ case "pip": return `pip uninstall -y ${tool.packageName}`;
117
+ default: return "";
44
118
  }
45
119
  }
46
120
 
121
+ function getUpdateCommand(tool: import("../../data/cli-tools.js").CliTool, method: InstallMethod, brewFormula?: string): string {
122
+ switch (method) {
123
+ case "bun": return `bun install -g ${tool.packageName}`;
124
+ case "npm": return `npm install -g ${tool.packageName}`;
125
+ case "pnpm": return `pnpm install -g ${tool.packageName}`;
126
+ case "yarn": return `yarn global add ${tool.packageName}`;
127
+ case "brew": return `brew upgrade ${brewFormula || tool.name}`;
128
+ case "pip": return tool.installCommand;
129
+ default: return tool.installCommand;
130
+ }
131
+ }
132
+
133
+
47
134
  async function getInstalledVersion(
48
135
  tool: import("../../data/cli-tools.js").CliTool,
49
136
  ): Promise<string | undefined> {
@@ -138,22 +225,26 @@ export function CliToolsScreen() {
138
225
  const fetchVersionInfo = useCallback(async () => {
139
226
  for (let i = 0; i < cliTools.length; i++) {
140
227
  const tool = cliTools[i]!;
141
- getInstalledVersion(tool).then((version) => {
228
+
229
+ // Run all checks in parallel, then update with all results
230
+ Promise.all([
231
+ getInstalledVersion(tool),
232
+ getLatestVersion(tool),
233
+ detectInstallMethods(tool),
234
+ ]).then(([version, latest, info]) => {
142
235
  updateToolStatus(i, {
143
236
  installedVersion: version,
144
237
  installed: version !== undefined,
145
- });
146
- });
147
-
148
- getLatestVersion(tool).then((latest) => {
149
- const current = statusesRef.current[i]!;
150
- updateToolStatus(i, {
151
238
  latestVersion: latest,
152
239
  checking: false,
153
240
  hasUpdate:
154
- current.installedVersion && latest
155
- ? compareVersions(current.installedVersion, latest) < 0
241
+ version && latest
242
+ ? compareVersions(version, latest) < 0
156
243
  : false,
244
+ installMethod: version ? info.primary : undefined,
245
+ allMethods: version && info.all.length > 1 ? info.all : undefined,
246
+ updateCommand: version ? getUpdateCommand(tool, info.primary, info.brewFormula) : undefined,
247
+ brewFormula: info.brewFormula,
157
248
  });
158
249
  });
159
250
  }
@@ -183,7 +274,9 @@ export function CliToolsScreen() {
183
274
  handleRefresh();
184
275
  } else if (event.name === "a") {
185
276
  handleUpdateAll();
186
- } else if (event.name === "enter") {
277
+ } else if (event.name === "c") {
278
+ handleResolveConflict();
279
+ } else if (event.name === "enter" || event.name === "return") {
187
280
  handleInstall();
188
281
  }
189
282
  });
@@ -202,34 +295,34 @@ export function CliToolsScreen() {
202
295
  fetchVersionInfo();
203
296
  };
204
297
 
298
+ const runCommand = async (command: string): Promise<{ ok: boolean; error?: string }> => {
299
+ try {
300
+ await execAsync(command, {
301
+ shell: "/bin/bash",
302
+ timeout: 60000,
303
+ });
304
+ return { ok: true };
305
+ } catch (err) {
306
+ const msg = err instanceof Error ? err.message : String(err);
307
+ return { ok: false, error: msg };
308
+ }
309
+ };
310
+
205
311
  const handleInstall = async () => {
206
312
  const status = toolStatuses[cliToolsState.selectedIndex];
207
313
  if (!status) return;
208
314
 
209
- const { tool, installed, hasUpdate } = status;
315
+ const { tool, installed, hasUpdate, updateCommand } = status;
210
316
  const action = !installed ? "Installing" : hasUpdate ? "Updating" : "Reinstalling";
211
-
212
- const viaHomebrew = installed
213
- ? await isInstalledViaHomebrew(tool.name)
214
- : false;
215
-
216
- const command = !installed
217
- ? tool.installCommand
218
- : viaHomebrew
219
- ? `brew upgrade ${tool.name}`
220
- : tool.installCommand;
317
+ const command = installed && updateCommand ? updateCommand : tool.installCommand;
221
318
 
222
319
  modal.loading(`${action} ${tool.displayName}...`);
223
- try {
224
- execSync(command, {
225
- encoding: "utf-8",
226
- stdio: "pipe",
227
- shell: "/bin/bash",
228
- });
229
- modal.hideModal();
320
+ const result = await runCommand(command);
321
+ modal.hideModal();
322
+
323
+ if (result.ok) {
230
324
  handleRefresh();
231
- } catch (error) {
232
- modal.hideModal();
325
+ } else {
233
326
  await modal.message(
234
327
  "Error",
235
328
  `Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${command}`,
@@ -238,6 +331,49 @@ export function CliToolsScreen() {
238
331
  }
239
332
  };
240
333
 
334
+ const handleResolveConflict = async () => {
335
+ const status = toolStatuses[cliToolsState.selectedIndex];
336
+ if (!status || !status.allMethods || status.allMethods.length < 2) return;
337
+
338
+ const { tool, allMethods, installMethod, brewFormula } = status;
339
+
340
+ // Let user pick which install to keep
341
+ const options = allMethods.map((method) => ({
342
+ label: `Keep ${method}${method === installMethod ? " (active)" : ""}, remove others`,
343
+ value: method,
344
+ }));
345
+
346
+ const keep = await modal.select(
347
+ `Resolve ${tool.displayName} conflict`,
348
+ `Installed via ${allMethods.join(" + ")}. Which to keep?`,
349
+ options,
350
+ );
351
+ if (keep === null) return;
352
+
353
+ const toRemove = allMethods.filter((m) => m !== keep);
354
+ modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
355
+
356
+ const errors: string[] = [];
357
+ for (const method of toRemove) {
358
+ const cmd = getUninstallCommand(tool, method, brewFormula);
359
+ if (!cmd) continue;
360
+ const result = await runCommand(cmd);
361
+ if (!result.ok) errors.push(`${method}: ${cmd}`);
362
+ }
363
+
364
+ modal.hideModal();
365
+
366
+ if (errors.length > 0) {
367
+ await modal.message(
368
+ "Partial",
369
+ `Some removals failed. Try manually:\n\n${errors.join("\n")}`,
370
+ "error",
371
+ );
372
+ }
373
+
374
+ handleRefresh();
375
+ };
376
+
241
377
  const handleUpdateAll = async () => {
242
378
  const updatable = toolStatuses.filter((s) => s.hasUpdate);
243
379
  if (updatable.length === 0) {
@@ -248,19 +384,8 @@ export function CliToolsScreen() {
248
384
  modal.loading(`Updating ${updatable.length} tool(s)...`);
249
385
 
250
386
  for (const status of updatable) {
251
- const viaHomebrew = await isInstalledViaHomebrew(status.tool.name);
252
- const command = viaHomebrew
253
- ? `brew upgrade ${status.tool.name}`
254
- : status.tool.installCommand;
255
- try {
256
- execSync(command, {
257
- encoding: "utf-8",
258
- stdio: "pipe",
259
- shell: "/bin/bash",
260
- });
261
- } catch {
262
- // Continue with other updates
263
- }
387
+ const command = status.updateCommand || status.tool.installCommand;
388
+ await runCommand(command);
264
389
  }
265
390
 
266
391
  modal.hideModal();
@@ -291,7 +416,7 @@ export function CliToolsScreen() {
291
416
  title="claudeup CLI Tools"
292
417
  currentScreen="cli-tools"
293
418
  statusLine={statusContent}
294
- footerHints="↑↓:nav │ Enter:install │ a:update all │ r:refresh"
419
+ footerHints="Enter:install │ a:update all │ c:fix conflict │ r:refresh"
295
420
  listPanel={
296
421
  <ScrollableList
297
422
  items={toolStatuses}
@@ -198,6 +198,8 @@ export function PluginsScreen() {
198
198
  handleScopeToggle("project");
199
199
  else if (event.name === "l")
200
200
  handleScopeToggle("local");
201
+ else if (event.name === "d")
202
+ handleRemoveDeprecated();
201
203
  else if (event.name === "U")
202
204
  handleUpdate();
203
205
  else if (event.name === "a")
@@ -575,6 +577,31 @@ export function PluginsScreen() {
575
577
  await modal.message("Error", `Failed: ${error}`, "error");
576
578
  }
577
579
  };
580
+ const handleRemoveDeprecated = async () => {
581
+ const item = selectableItems[pluginsState.selectedIndex];
582
+ if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned)
583
+ return;
584
+ const plugin = item.plugin;
585
+ modal.loading(`Removing ${plugin.name}...`);
586
+ try {
587
+ // Remove from all scopes — try all to clean up stale references
588
+ const scopes = ["user", "project", "local"];
589
+ for (const scope of scopes) {
590
+ try {
591
+ await cliUninstallPlugin(plugin.id, scope, state.projectPath);
592
+ }
593
+ catch {
594
+ // Ignore errors for scopes where it doesn't exist
595
+ }
596
+ }
597
+ modal.hideModal();
598
+ fetchData();
599
+ }
600
+ catch (error) {
601
+ modal.hideModal();
602
+ await modal.message("Error", `Failed to remove: ${error}`, "error");
603
+ }
604
+ };
578
605
  const handleSaveAsProfile = async () => {
579
606
  const settings = await readSettings(state.projectPath);
580
607
  const enabledPlugins = settings.enabledPlugins ?? {};
@@ -238,6 +238,7 @@ export function PluginsScreen() {
238
238
  else if (event.name === "u") handleScopeToggle("user");
239
239
  else if (event.name === "p") handleScopeToggle("project");
240
240
  else if (event.name === "l") handleScopeToggle("local");
241
+ else if (event.name === "d") handleRemoveDeprecated();
241
242
  else if (event.name === "U") handleUpdate();
242
243
  else if (event.name === "a") handleUpdateAll();
243
244
  else if (event.name === "s") handleSaveAsProfile();
@@ -696,6 +697,30 @@ export function PluginsScreen() {
696
697
  }
697
698
  };
698
699
 
700
+ const handleRemoveDeprecated = async () => {
701
+ const item = selectableItems[pluginsState.selectedIndex];
702
+ if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned) return;
703
+
704
+ const plugin = item.plugin;
705
+ modal.loading(`Removing ${plugin.name}...`);
706
+ try {
707
+ // Remove from all scopes — try all to clean up stale references
708
+ const scopes: PluginScope[] = ["user", "project", "local"];
709
+ for (const scope of scopes) {
710
+ try {
711
+ await cliUninstallPlugin(plugin.id, scope, state.projectPath);
712
+ } catch {
713
+ // Ignore errors for scopes where it doesn't exist
714
+ }
715
+ }
716
+ modal.hideModal();
717
+ fetchData();
718
+ } catch (error) {
719
+ modal.hideModal();
720
+ await modal.message("Error", `Failed to remove: ${error}`, "error");
721
+ }
722
+ };
723
+
699
724
  const handleSaveAsProfile = async () => {
700
725
  const settings = await readSettings(state.projectPath);
701
726
  const enabledPlugins = settings.enabledPlugins ?? {};