claudeup 4.1.0 → 4.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -11,15 +11,15 @@ export const cliTools = [
11
11
  packageName: "claudeup",
12
12
  },
13
13
  {
14
- name: "claudemem",
15
- displayName: "Claudemem",
16
- description: "Local semantic code search for Claude Code with vector embeddings",
17
- installCommand: "npm install -g claude-codemem",
18
- checkCommand: "claudemem --version",
19
- website: "https://github.com/MadAppGang/claudemem",
14
+ name: "mnemex",
15
+ displayName: "Mnemex",
16
+ description: "AST-aware code search with PageRank, callers/callees, and semantic embeddings",
17
+ installCommand: "npm install -g mnemex",
18
+ checkCommand: "mnemex --version",
19
+ website: "https://github.com/MadAppGang/mnemex",
20
20
  category: "ai-coding",
21
21
  packageManager: "npm",
22
- packageName: "claude-codemem",
22
+ packageName: "mnemex",
23
23
  },
24
24
  {
25
25
  name: "claudish",
@@ -24,16 +24,16 @@ export const cliTools: CliTool[] = [
24
24
  packageName: "claudeup",
25
25
  },
26
26
  {
27
- name: "claudemem",
28
- displayName: "Claudemem",
27
+ name: "mnemex",
28
+ displayName: "Mnemex",
29
29
  description:
30
- "Local semantic code search for Claude Code with vector embeddings",
31
- installCommand: "npm install -g claude-codemem",
32
- checkCommand: "claudemem --version",
33
- website: "https://github.com/MadAppGang/claudemem",
30
+ "AST-aware code search with PageRank, callers/callees, and semantic embeddings",
31
+ installCommand: "npm install -g mnemex",
32
+ checkCommand: "mnemex --version",
33
+ website: "https://github.com/MadAppGang/mnemex",
34
34
  category: "ai-coding",
35
35
  packageManager: "npm",
36
- packageName: "claude-codemem",
36
+ packageName: "mnemex",
37
37
  },
38
38
  {
39
39
  name: "claudish",
@@ -418,6 +418,8 @@ export async function getMarketplaceAutoUpdate(marketplaceName) {
418
418
  // =============================================================================
419
419
  const OLD_MARKETPLACE_NAMES = ["mag-claude-plugins", "MadAppGang-claude-code"];
420
420
  const NEW_MARKETPLACE_NAME = "magus";
421
+ const NEW_MARKETPLACE_REPO = "MadAppGang/magus";
422
+ const OLD_MARKETPLACE_REPOS = ["MadAppGang/claude-code"];
421
423
  /**
422
424
  * Rename plugin keys in a Record from any old marketplace name to new.
423
425
  * e.g., "frontend@mag-claude-plugins" → "frontend@magus"
@@ -468,11 +470,24 @@ function migrateSettingsObject(settings) {
468
470
  const entry = settings.extraKnownMarketplaces[oldName];
469
471
  delete settings.extraKnownMarketplaces[oldName];
470
472
  if (!settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME]) {
471
- settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME] = entry;
473
+ settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME] = {
474
+ ...entry,
475
+ source: {
476
+ ...entry.source,
477
+ repo: NEW_MARKETPLACE_REPO,
478
+ },
479
+ };
472
480
  }
473
481
  total++;
474
482
  }
475
483
  }
484
+ // Fix stale repo URL on existing magus entry (e.g. key is "magus" but repo is still "MadAppGang/claude-code")
485
+ const magusEntry = settings.extraKnownMarketplaces?.[NEW_MARKETPLACE_NAME];
486
+ if (magusEntry?.source?.repo &&
487
+ OLD_MARKETPLACE_REPOS.includes(magusEntry.source.repo)) {
488
+ magusEntry.source.repo = NEW_MARKETPLACE_REPO;
489
+ total++;
490
+ }
476
491
  return total;
477
492
  }
478
493
  /**
@@ -639,6 +654,41 @@ export async function migrateMarketplaceRename(projectPath) {
639
654
  catch {
640
655
  /* skip if unreadable */
641
656
  }
657
+ // 6. Scan all known project settings (derived from ~/.claude/projects/ directory names)
658
+ try {
659
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
660
+ if (await fs.pathExists(projectsDir)) {
661
+ const entries = await fs.readdir(projectsDir);
662
+ const seenPaths = new Set();
663
+ // Current project (from step 1) already handled — skip it
664
+ const currentProject = projectPath || process.cwd();
665
+ seenPaths.add(currentProject);
666
+ for (const entry of entries) {
667
+ // Directory names encode paths: -Users-jack-dev-foo → /Users/jack/dev/foo
668
+ const decoded = entry.replace(/^-/, "/").replace(/-/g, "/");
669
+ if (seenPaths.has(decoded))
670
+ continue;
671
+ seenPaths.add(decoded);
672
+ const settingsFile = path.join(decoded, ".claude", "settings.json");
673
+ try {
674
+ if (await fs.pathExists(settingsFile)) {
675
+ const raw = await fs.readJson(settingsFile);
676
+ const count = migrateSettingsObject(raw);
677
+ if (count > 0) {
678
+ await fs.writeJson(settingsFile, raw, { spaces: 2 });
679
+ result.projectMigrated += count;
680
+ }
681
+ }
682
+ }
683
+ catch {
684
+ /* skip individual projects that fail */
685
+ }
686
+ }
687
+ }
688
+ }
689
+ catch {
690
+ /* non-fatal: cross-project scan is best-effort */
691
+ }
642
692
  return result;
643
693
  }
644
694
  /**
@@ -635,6 +635,8 @@ export interface MarketplaceRecoveryResult {
635
635
 
636
636
  const OLD_MARKETPLACE_NAMES = ["mag-claude-plugins", "MadAppGang-claude-code"];
637
637
  const NEW_MARKETPLACE_NAME = "magus";
638
+ const NEW_MARKETPLACE_REPO = "MadAppGang/magus";
639
+ const OLD_MARKETPLACE_REPOS = ["MadAppGang/claude-code"];
638
640
 
639
641
  /**
640
642
  * Rename plugin keys in a Record from any old marketplace name to new.
@@ -690,12 +692,28 @@ function migrateSettingsObject(settings: ClaudeSettings): number {
690
692
  const entry = settings.extraKnownMarketplaces[oldName];
691
693
  delete settings.extraKnownMarketplaces[oldName];
692
694
  if (!settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME]) {
693
- settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME] = entry;
695
+ settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME] = {
696
+ ...entry,
697
+ source: {
698
+ ...entry.source,
699
+ repo: NEW_MARKETPLACE_REPO,
700
+ },
701
+ };
694
702
  }
695
703
  total++;
696
704
  }
697
705
  }
698
706
 
707
+ // Fix stale repo URL on existing magus entry (e.g. key is "magus" but repo is still "MadAppGang/claude-code")
708
+ const magusEntry = settings.extraKnownMarketplaces?.[NEW_MARKETPLACE_NAME];
709
+ if (
710
+ magusEntry?.source?.repo &&
711
+ OLD_MARKETPLACE_REPOS.includes(magusEntry.source.repo)
712
+ ) {
713
+ magusEntry.source.repo = NEW_MARKETPLACE_REPO;
714
+ total++;
715
+ }
716
+
699
717
  return total;
700
718
  }
701
719
 
@@ -885,6 +903,41 @@ export async function migrateMarketplaceRename(
885
903
  /* skip if unreadable */
886
904
  }
887
905
 
906
+ // 6. Scan all known project settings (derived from ~/.claude/projects/ directory names)
907
+ try {
908
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
909
+ if (await fs.pathExists(projectsDir)) {
910
+ const entries = await fs.readdir(projectsDir);
911
+ const seenPaths = new Set<string>();
912
+ // Current project (from step 1) already handled — skip it
913
+ const currentProject = projectPath || process.cwd();
914
+ seenPaths.add(currentProject);
915
+
916
+ for (const entry of entries) {
917
+ // Directory names encode paths: -Users-jack-dev-foo → /Users/jack/dev/foo
918
+ const decoded = entry.replace(/^-/, "/").replace(/-/g, "/");
919
+ if (seenPaths.has(decoded)) continue;
920
+ seenPaths.add(decoded);
921
+
922
+ const settingsFile = path.join(decoded, ".claude", "settings.json");
923
+ try {
924
+ if (await fs.pathExists(settingsFile)) {
925
+ const raw = await fs.readJson(settingsFile);
926
+ const count = migrateSettingsObject(raw);
927
+ if (count > 0) {
928
+ await fs.writeJson(settingsFile, raw, { spaces: 2 });
929
+ result.projectMigrated += count;
930
+ }
931
+ }
932
+ } catch {
933
+ /* skip individual projects that fail */
934
+ }
935
+ }
936
+ }
937
+ } catch {
938
+ /* non-fatal: cross-project scan is best-effort */
939
+ }
940
+
888
941
  return result;
889
942
  }
890
943
 
@@ -1,33 +1,54 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import { theme } from "../theme.js";
3
+ // ─── Helpers ────────────────────────────────────────────────────────────────
4
+ function getUninstallHint(tool, method, brewFormula) {
5
+ switch (method) {
6
+ case "bun": return `bun remove -g ${tool.packageName}`;
7
+ case "npm": return `npm uninstall -g ${tool.packageName}`;
8
+ case "pnpm": return `pnpm remove -g ${tool.packageName}`;
9
+ case "yarn": return `yarn global remove ${tool.packageName}`;
10
+ case "brew": return `brew uninstall ${brewFormula || tool.name}`;
11
+ case "pip": return `pip uninstall ${tool.packageName}`;
12
+ default: return "";
13
+ }
14
+ }
3
15
  // ─── Row renderer ──────────────────────────────────────────────────────────────
4
16
  export function renderCliToolRow(status, _index, isSelected) {
5
- const { tool, installed, installedVersion, hasUpdate, checking } = status;
17
+ const { tool, installed, installedVersion, hasUpdate, checking, allMethods } = status;
18
+ const hasConflict = allMethods && allMethods.length > 1;
6
19
  let icon;
7
20
  let iconColor;
8
21
  if (!installed) {
9
22
  icon = "○";
10
23
  iconColor = theme.colors.muted;
11
24
  }
25
+ else if (hasConflict) {
26
+ icon = "!";
27
+ iconColor = theme.colors.danger;
28
+ }
12
29
  else if (hasUpdate) {
13
- icon = "";
30
+ icon = "*";
14
31
  iconColor = theme.colors.warning;
15
32
  }
16
33
  else {
17
34
  icon = "●";
18
35
  iconColor = theme.colors.success;
19
36
  }
20
- const versionText = installedVersion ? `v${installedVersion}` : "";
37
+ const versionText = installedVersion ? ` v${installedVersion}` : "";
38
+ const methodTag = installed && allMethods?.length
39
+ ? ` ${allMethods.join("+")}`
40
+ : "";
21
41
  if (isSelected) {
22
- return (_jsxs("text", { bg: theme.selection.bg, fg: theme.selection.fg, children: [" ", icon, " ", tool.displayName, " ", versionText, checking ? "..." : "", " "] }));
42
+ return (_jsxs("text", { bg: theme.selection.bg, fg: theme.selection.fg, children: [" ", icon, " ", tool.displayName, versionText, methodTag, checking ? " ..." : "", " "] }));
23
43
  }
24
- return (_jsxs("text", { children: [_jsx("span", { fg: iconColor, children: icon }), _jsxs("span", { fg: theme.colors.text, children: [" ", tool.displayName] }), versionText ? _jsxs("span", { fg: theme.colors.success, children: [" ", versionText] }) : null, checking ? _jsx("span", { fg: theme.colors.muted, children: "..." }) : null] }));
44
+ return (_jsxs("text", { children: [_jsxs("span", { fg: iconColor, children: [" ", icon] }), _jsxs("span", { fg: theme.colors.text, children: [" ", tool.displayName] }), versionText ? _jsx("span", { fg: theme.colors.success, children: versionText }) : null, methodTag ? _jsx("span", { fg: hasConflict ? theme.colors.danger : theme.colors.dim, children: methodTag }) : null, checking ? _jsx("span", { fg: theme.colors.muted, children: " ..." }) : null] }));
25
45
  }
26
46
  // ─── Detail renderer ───────────────────────────────────────────────────────────
27
47
  export function renderCliToolDetail(status) {
28
48
  if (!status) {
29
49
  return (_jsx("box", { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: _jsx("text", { fg: theme.colors.muted, children: "Select a tool to see details" }) }));
30
50
  }
31
- const { tool, installed, installedVersion, latestVersion, hasUpdate, checking } = status;
32
- return (_jsxs("box", { flexDirection: "column", children: [_jsxs("box", { marginBottom: 1, children: [_jsx("text", { fg: theme.colors.info, children: _jsxs("strong", { children: ["⚙ ", tool.displayName] }) }), hasUpdate ? _jsx("text", { fg: theme.colors.warning, children: " \u2B06" }) : null] }), _jsx("text", { fg: theme.colors.muted, children: tool.description }), _jsxs("box", { marginTop: 1, flexDirection: "column", children: [_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Status " }), !installed ? (_jsx("text", { fg: theme.colors.muted, children: "○ Not installed" })) : checking ? (_jsx("text", { fg: theme.colors.success, children: "● Checking..." })) : hasUpdate ? (_jsx("text", { fg: theme.colors.warning, children: "● Update available" })) : (_jsx("text", { fg: theme.colors.success, children: "● Up to date" }))] }), installedVersion ? (_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Installed " }), _jsxs("text", { fg: theme.colors.success, children: ["v", installedVersion] })] })) : null, latestVersion ? (_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Latest " }), _jsxs("text", { fg: theme.colors.text, children: ["v", latestVersion] })] })) : null, _jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Website " }), _jsx("text", { fg: theme.colors.link, children: tool.website })] })] }), _jsx("box", { marginTop: 2, children: !installed ? (_jsxs("box", { children: [_jsxs("text", { bg: theme.colors.success, fg: "black", children: [" ", "Enter", " "] }), _jsx("text", { fg: theme.colors.muted, children: " Install" })] })) : hasUpdate ? (_jsxs("box", { children: [_jsxs("text", { bg: theme.colors.warning, fg: "black", children: [" ", "Enter", " "] }), _jsxs("text", { fg: theme.colors.muted, children: [" Update to v", latestVersion] })] })) : (_jsxs("box", { children: [_jsxs("text", { bg: theme.colors.muted, fg: "white", children: [" ", "Enter", " "] }), _jsx("text", { fg: theme.colors.muted, children: " Reinstall" })] })) })] }));
51
+ const { tool, installed, installedVersion, latestVersion, hasUpdate, checking, installMethod, allMethods, updateCommand, brewFormula } = status;
52
+ const hasConflict = allMethods && allMethods.length > 1;
53
+ return (_jsxs("box", { flexDirection: "column", children: [_jsxs("box", { marginBottom: 1, children: [_jsx("text", { fg: theme.colors.info, children: _jsxs("strong", { children: ["⚙ ", tool.displayName] }) }), hasUpdate ? _jsx("text", { fg: theme.colors.warning, children: " \u2B06" }) : null, hasConflict ? _jsx("text", { fg: theme.colors.danger, children: " !" }) : null] }), _jsx("text", { fg: theme.colors.muted, children: tool.description }), _jsxs("box", { marginTop: 1, flexDirection: "column", children: [_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Status " }), !installed ? (_jsx("text", { fg: theme.colors.muted, children: "○ Not installed" })) : checking ? (_jsx("text", { fg: theme.colors.success, children: "● Checking..." })) : hasUpdate ? (_jsx("text", { fg: theme.colors.warning, children: "● Update available" })) : (_jsx("text", { fg: theme.colors.success, children: "● Up to date" }))] }), installedVersion ? (_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Version " }), _jsxs("text", { children: [_jsxs("span", { fg: theme.colors.success, children: ["v", installedVersion] }), latestVersion && hasUpdate ? (_jsxs("span", { fg: theme.colors.warning, children: [" \u2192 v", latestVersion] })) : null] })] })) : latestVersion ? (_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Latest " }), _jsxs("text", { fg: theme.colors.text, children: ["v", latestVersion] })] })) : null, installed && updateCommand ? (_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Update " }), _jsx("text", { fg: theme.colors.accent, children: updateCommand })] })) : !installed ? (_jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Install " }), _jsx("text", { fg: theme.colors.accent, children: tool.installCommand })] })) : null, _jsxs("box", { children: [_jsx("text", { fg: theme.colors.muted, children: "Website " }), _jsx("text", { fg: theme.colors.link, children: tool.website })] })] }), hasConflict ? (_jsxs("box", { marginTop: 1, flexDirection: "column", children: [_jsx("box", { children: _jsx("text", { bg: theme.colors.danger, fg: "white", children: _jsxs("strong", { children: [" ", "Conflict: installed via ", allMethods.join(" + "), " "] }) }) }), _jsx("box", { marginTop: 1, children: _jsxs("text", { fg: theme.colors.muted, children: ["Multiple installs can cause version mismatches.", "\n", "Keep one, remove the rest:"] }) }), allMethods.map((method, i) => (_jsx("box", { children: _jsxs("text", { children: [_jsx("span", { fg: i === 0 ? theme.colors.success : theme.colors.danger, children: i === 0 ? " ● keep " : " ○ remove " }), _jsx("span", { fg: theme.colors.warning, children: method }), i > 0 ? (_jsx("span", { fg: theme.colors.dim, children: ` ${getUninstallHint(tool, method, brewFormula)}` })) : (_jsx("span", { fg: theme.colors.dim, children: " (active in PATH)" }))] }) }, method))), _jsxs("box", { marginTop: 1, children: [_jsxs("text", { bg: theme.colors.danger, fg: "white", children: [" ", "c", " "] }), _jsx("text", { fg: theme.colors.muted, children: " Resolve \u2014 pick which to keep" })] })] })) : null, _jsx("box", { marginTop: 2, flexDirection: "column", children: !installed ? (_jsxs("box", { children: [_jsxs("text", { bg: theme.colors.success, fg: "black", children: [" ", "Enter", " "] }), _jsx("text", { fg: theme.colors.muted, children: " Install" }), _jsxs("text", { fg: theme.colors.dim, children: [" ", tool.installCommand] })] })) : hasUpdate ? (_jsxs("box", { children: [_jsxs("text", { bg: theme.colors.warning, fg: "black", children: [" ", "Enter", " "] }), _jsxs("text", { fg: theme.colors.muted, children: [" Update to v", latestVersion] })] })) : (_jsxs("box", { children: [_jsxs("text", { bg: theme.colors.muted, fg: "white", children: [" ", "Enter", " "] }), _jsx("text", { fg: theme.colors.muted, children: " Reinstall" })] })) })] }));
33
54
  }
@@ -4,6 +4,8 @@ import { theme } from "../theme.js";
4
4
 
5
5
  // ─── Status type ───────────────────────────────────────────────────────────────
6
6
 
7
+ export type InstallMethod = "npm" | "bun" | "pnpm" | "yarn" | "brew" | "pip" | "unknown";
8
+
7
9
  export interface CliToolStatus {
8
10
  tool: CliTool;
9
11
  installed: boolean;
@@ -11,6 +13,24 @@ export interface CliToolStatus {
11
13
  latestVersion?: string;
12
14
  hasUpdate?: boolean;
13
15
  checking: boolean;
16
+ installMethod?: InstallMethod;
17
+ allMethods?: InstallMethod[];
18
+ updateCommand?: string;
19
+ brewFormula?: string;
20
+ }
21
+
22
+ // ─── Helpers ────────────────────────────────────────────────────────────────
23
+
24
+ function getUninstallHint(tool: CliTool, method: InstallMethod, brewFormula?: string): string {
25
+ switch (method) {
26
+ case "bun": return `bun remove -g ${tool.packageName}`;
27
+ case "npm": return `npm uninstall -g ${tool.packageName}`;
28
+ case "pnpm": return `pnpm remove -g ${tool.packageName}`;
29
+ case "yarn": return `yarn global remove ${tool.packageName}`;
30
+ case "brew": return `brew uninstall ${brewFormula || tool.name}`;
31
+ case "pip": return `pip uninstall ${tool.packageName}`;
32
+ default: return "";
33
+ }
14
34
  }
15
35
 
16
36
  // ─── Row renderer ──────────────────────────────────────────────────────────────
@@ -20,7 +40,8 @@ export function renderCliToolRow(
20
40
  _index: number,
21
41
  isSelected: boolean,
22
42
  ): React.ReactNode {
23
- const { tool, installed, installedVersion, hasUpdate, checking } = status;
43
+ const { tool, installed, installedVersion, hasUpdate, checking, allMethods } = status;
44
+ const hasConflict = allMethods && allMethods.length > 1;
24
45
 
25
46
  let icon: string;
26
47
  let iconColor: string;
@@ -28,32 +49,38 @@ export function renderCliToolRow(
28
49
  if (!installed) {
29
50
  icon = "○";
30
51
  iconColor = theme.colors.muted;
52
+ } else if (hasConflict) {
53
+ icon = "!";
54
+ iconColor = theme.colors.danger;
31
55
  } else if (hasUpdate) {
32
- icon = "";
56
+ icon = "*";
33
57
  iconColor = theme.colors.warning;
34
58
  } else {
35
59
  icon = "●";
36
60
  iconColor = theme.colors.success;
37
61
  }
38
62
 
39
- const versionText = installedVersion ? `v${installedVersion}` : "";
63
+ const versionText = installedVersion ? ` v${installedVersion}` : "";
64
+ const methodTag = installed && allMethods?.length
65
+ ? ` ${allMethods.join("+")}`
66
+ : "";
40
67
 
41
68
  if (isSelected) {
42
69
  return (
43
70
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
44
- {" "}
45
- {icon} {tool.displayName} {versionText}
46
- {checking ? "..." : ""}{" "}
71
+ {" "}{icon} {tool.displayName}{versionText}{methodTag}
72
+ {checking ? " ..." : ""}{" "}
47
73
  </text>
48
74
  );
49
75
  }
50
76
 
51
77
  return (
52
78
  <text>
53
- <span fg={iconColor}>{icon}</span>
79
+ <span fg={iconColor}> {icon}</span>
54
80
  <span fg={theme.colors.text}> {tool.displayName}</span>
55
- {versionText ? <span fg={theme.colors.success}> {versionText}</span> : null}
56
- {checking ? <span fg={theme.colors.muted}>{"..."}</span> : null}
81
+ {versionText ? <span fg={theme.colors.success}>{versionText}</span> : null}
82
+ {methodTag ? <span fg={hasConflict ? theme.colors.danger : theme.colors.dim}>{methodTag}</span> : null}
83
+ {checking ? <span fg={theme.colors.muted}>{" ..."}</span> : null}
57
84
  </text>
58
85
  );
59
86
  }
@@ -76,9 +103,11 @@ export function renderCliToolDetail(
76
103
  );
77
104
  }
78
105
 
79
- const { tool, installed, installedVersion, latestVersion, hasUpdate, checking } =
106
+ const { tool, installed, installedVersion, latestVersion, hasUpdate, checking, installMethod, allMethods, updateCommand, brewFormula } =
80
107
  status;
81
108
 
109
+ const hasConflict = allMethods && allMethods.length > 1;
110
+
82
111
  return (
83
112
  <box flexDirection="column">
84
113
  <box marginBottom={1}>
@@ -86,13 +115,14 @@ export function renderCliToolDetail(
86
115
  <strong>{"⚙ "}{tool.displayName}</strong>
87
116
  </text>
88
117
  {hasUpdate ? <text fg={theme.colors.warning}> ⬆</text> : null}
118
+ {hasConflict ? <text fg={theme.colors.danger}> !</text> : null}
89
119
  </box>
90
120
 
91
121
  <text fg={theme.colors.muted}>{tool.description}</text>
92
122
 
93
123
  <box marginTop={1} flexDirection="column">
94
124
  <box>
95
- <text fg={theme.colors.muted}>{"Status "}</text>
125
+ <text fg={theme.colors.muted}>{"Status "}</text>
96
126
  {!installed ? (
97
127
  <text fg={theme.colors.muted}>{"○ Not installed"}</text>
98
128
  ) : checking ? (
@@ -105,45 +135,89 @@ export function renderCliToolDetail(
105
135
  </box>
106
136
  {installedVersion ? (
107
137
  <box>
108
- <text fg={theme.colors.muted}>{"Installed "}</text>
109
- <text fg={theme.colors.success}>v{installedVersion}</text>
138
+ <text fg={theme.colors.muted}>{"Version "}</text>
139
+ <text>
140
+ <span fg={theme.colors.success}>v{installedVersion}</span>
141
+ {latestVersion && hasUpdate ? (
142
+ <span fg={theme.colors.warning}> → v{latestVersion}</span>
143
+ ) : null}
144
+ </text>
145
+ </box>
146
+ ) : latestVersion ? (
147
+ <box>
148
+ <text fg={theme.colors.muted}>{"Latest "}</text>
149
+ <text fg={theme.colors.text}>v{latestVersion}</text>
110
150
  </box>
111
151
  ) : null}
112
- {latestVersion ? (
152
+ {installed && updateCommand ? (
113
153
  <box>
114
- <text fg={theme.colors.muted}>{"Latest "}</text>
115
- <text fg={theme.colors.text}>v{latestVersion}</text>
154
+ <text fg={theme.colors.muted}>{"Update "}</text>
155
+ <text fg={theme.colors.accent}>{updateCommand}</text>
156
+ </box>
157
+ ) : !installed ? (
158
+ <box>
159
+ <text fg={theme.colors.muted}>{"Install "}</text>
160
+ <text fg={theme.colors.accent}>{tool.installCommand}</text>
116
161
  </box>
117
162
  ) : null}
118
163
  <box>
119
- <text fg={theme.colors.muted}>{"Website "}</text>
164
+ <text fg={theme.colors.muted}>{"Website "}</text>
120
165
  <text fg={theme.colors.link}>{tool.website}</text>
121
166
  </box>
122
167
  </box>
123
168
 
124
- <box marginTop={2}>
125
- {!installed ? (
169
+ {/* Conflict warning */}
170
+ {hasConflict ? (
171
+ <box marginTop={1} flexDirection="column">
126
172
  <box>
127
- <text bg={theme.colors.success} fg="black">
128
- {" "}
129
- Enter{" "}
173
+ <text bg={theme.colors.danger} fg="white">
174
+ <strong>{" "}Conflict: installed via {allMethods.join(" + ")}{" "}</strong>
175
+ </text>
176
+ </box>
177
+ <box marginTop={1}>
178
+ <text fg={theme.colors.muted}>
179
+ Multiple installs can cause version mismatches.
180
+ {"\n"}Keep one, remove the rest:
130
181
  </text>
182
+ </box>
183
+ {allMethods.map((method, i) => (
184
+ <box key={method}>
185
+ <text>
186
+ <span fg={i === 0 ? theme.colors.success : theme.colors.danger}>
187
+ {i === 0 ? " ● keep " : " ○ remove "}
188
+ </span>
189
+ <span fg={theme.colors.warning}>{method}</span>
190
+ {i > 0 ? (
191
+ <span fg={theme.colors.dim}>{` ${getUninstallHint(tool, method, brewFormula)}`}</span>
192
+ ) : (
193
+ <span fg={theme.colors.dim}> (active in PATH)</span>
194
+ )}
195
+ </text>
196
+ </box>
197
+ ))}
198
+ <box marginTop={1}>
199
+ <text bg={theme.colors.danger} fg="white">{" "}c{" "}</text>
200
+ <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
201
+ </box>
202
+ </box>
203
+ ) : null}
204
+
205
+ {/* Actions */}
206
+ <box marginTop={2} flexDirection="column">
207
+ {!installed ? (
208
+ <box>
209
+ <text bg={theme.colors.success} fg="black">{" "}Enter{" "}</text>
131
210
  <text fg={theme.colors.muted}> Install</text>
211
+ <text fg={theme.colors.dim}> {tool.installCommand}</text>
132
212
  </box>
133
213
  ) : hasUpdate ? (
134
214
  <box>
135
- <text bg={theme.colors.warning} fg="black">
136
- {" "}
137
- Enter{" "}
138
- </text>
215
+ <text bg={theme.colors.warning} fg="black">{" "}Enter{" "}</text>
139
216
  <text fg={theme.colors.muted}> Update to v{latestVersion}</text>
140
217
  </box>
141
218
  ) : (
142
219
  <box>
143
- <text bg={theme.colors.muted} fg="white">
144
- {" "}
145
- Enter{" "}
146
- </text>
220
+ <text bg={theme.colors.muted} fg="white">{" "}Enter{" "}</text>
147
221
  <text fg={theme.colors.muted}> Reinstall</text>
148
222
  </box>
149
223
  )}
@@ -82,7 +82,7 @@ function pluginDetail(item) {
82
82
  plugin.localScope?.enabled;
83
83
  // Orphaned/deprecated plugin
84
84
  if (plugin.isOrphaned) {
85
- return (_jsxs("box", { flexDirection: "column", children: [_jsx("box", { justifyContent: "center", children: _jsx("text", { bg: theme.colors.warning, fg: "black", children: _jsxs("strong", { children: [" ", plugin.name, " \u2014 DEPRECATED "] }) }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: theme.colors.warning, children: "This plugin is no longer in the marketplace." }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: theme.colors.muted, children: "It was removed from the marketplace but still referenced in your settings. Press d to uninstall and clean up." }) }), isInstalled ? (_jsx(ActionHints, { hints: [{ key: "d", label: "Uninstall (recommended)", tone: "danger" }] })) : null] }));
85
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx("box", { justifyContent: "center", children: _jsx("text", { bg: theme.colors.warning, fg: "black", children: _jsxs("strong", { children: [" ", plugin.name, " \u2014 DEPRECATED "] }) }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: theme.colors.warning, children: "This plugin is no longer in the marketplace." }) }), _jsx("box", { marginTop: 1, children: _jsx("text", { fg: theme.colors.muted, children: "It was removed from the marketplace but still referenced in your settings. Press d to uninstall and clean up." }) }), _jsx(ActionHints, { hints: [{ key: "d", label: isInstalled ? "Remove from all scopes" : "Clean up stale reference", tone: "danger" }] })] }));
86
86
  }
87
87
  // Build component counts
88
88
  const components = [];
@@ -191,11 +191,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
191
191
  uninstall and clean up.
192
192
  </text>
193
193
  </box>
194
- {isInstalled ? (
195
- <ActionHints
196
- hints={[{ key: "d", label: "Uninstall (recommended)", tone: "danger" }]}
197
- />
198
- ) : null}
194
+ <ActionHints
195
+ hints={[{ key: "d", label: isInstalled ? "Remove from all scopes" : "Clean up stale reference", tone: "danger" }]}
196
+ />
199
197
  </box>
200
198
  );
201
199
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
2
  import { useEffect, useCallback, useState, useRef } from "react";
3
- import { exec, execSync } from "child_process";
3
+ import { exec } from "child_process";
4
4
  import { promisify } from "util";
5
5
  import { useApp, useModal } from "../state/AppContext.js";
6
6
  import { useDimensions } from "../state/DimensionsContext.js";
@@ -22,17 +22,94 @@ function parseVersion(versionOutput) {
22
22
  const match = versionOutput.match(/v?(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
23
23
  return match ? match[1] : undefined;
24
24
  }
25
- async function isInstalledViaHomebrew(toolName) {
25
+ function methodFromPath(binPath) {
26
+ if (binPath.includes("/.bun/"))
27
+ return "bun";
28
+ if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
29
+ return "brew";
30
+ if (binPath.includes("/.local/share/claude") || binPath.includes("/.local/bin/claude"))
31
+ return "npm";
32
+ if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
33
+ return "npm";
34
+ if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
35
+ return "pnpm";
36
+ if (binPath.includes("/.yarn/"))
37
+ return "yarn";
38
+ return null;
39
+ }
40
+ /** Extract brew formula name from a Cellar symlink target like ../Cellar/gemini-cli/0.35.2/bin/gemini */
41
+ function extractBrewFormula(binPath, linkTarget) {
42
+ // Try symlink target first: ../Cellar/{formula}/{version}/...
43
+ const cellarMatch = linkTarget.match(/Cellar\/([^/]+)\//);
44
+ if (cellarMatch)
45
+ return cellarMatch[1];
46
+ // Try binary path: /opt/homebrew/Cellar/{formula}/...
47
+ const pathMatch = binPath.match(/Cellar\/([^/]+)\//);
48
+ if (pathMatch)
49
+ return pathMatch[1];
50
+ return undefined;
51
+ }
52
+ async function detectInstallMethods(tool) {
53
+ const fallback = tool.packageManager === "pip" ? "pip" : "unknown";
26
54
  try {
27
- const { stdout } = await execAsync("brew list --formula 2>/dev/null", {
28
- timeout: 2000,
55
+ // which -a returns all matching binaries across PATH
56
+ const { stdout } = await execAsync(`which -a ${tool.name} 2>/dev/null`, {
57
+ timeout: 3000,
29
58
  shell: "/bin/bash",
30
59
  });
31
- const formulas = stdout.trim().split("\n");
32
- return formulas.some((f) => f.trim() === toolName);
60
+ const paths = stdout.trim().split("\n").filter((p) => p && !p.includes("aliased"));
61
+ if (paths.length === 0)
62
+ return { primary: fallback, all: [] };
63
+ const methods = [];
64
+ let brewFormula;
65
+ for (const binPath of paths) {
66
+ let method = methodFromPath(binPath);
67
+ let linkTarget = "";
68
+ // Check symlink target
69
+ try {
70
+ const { stdout: lt } = await execAsync(`readlink "${binPath}" 2>/dev/null || true`, { timeout: 2000, shell: "/bin/bash" });
71
+ linkTarget = lt.trim();
72
+ if (!method)
73
+ method = methodFromPath(linkTarget);
74
+ }
75
+ catch {
76
+ // ignore
77
+ }
78
+ // Detect brew formula name
79
+ if (method === "brew" && !brewFormula) {
80
+ brewFormula = extractBrewFormula(binPath, linkTarget);
81
+ }
82
+ if (method && !methods.includes(method))
83
+ methods.push(method);
84
+ }
85
+ if (methods.length === 0)
86
+ return { primary: fallback, all: [] };
87
+ return { primary: methods[0], all: methods, brewFormula };
33
88
  }
34
89
  catch {
35
- return false;
90
+ return { primary: fallback, all: [] };
91
+ }
92
+ }
93
+ function getUninstallCommand(tool, method, brewFormula) {
94
+ switch (method) {
95
+ case "bun": return `bun remove -g ${tool.packageName}`;
96
+ case "npm": return `npm uninstall -g ${tool.packageName}`;
97
+ case "pnpm": return `pnpm remove -g ${tool.packageName}`;
98
+ case "yarn": return `yarn global remove ${tool.packageName}`;
99
+ case "brew": return `brew uninstall ${brewFormula || tool.name}`;
100
+ case "pip": return `pip uninstall -y ${tool.packageName}`;
101
+ default: return "";
102
+ }
103
+ }
104
+ function getUpdateCommand(tool, method, brewFormula) {
105
+ switch (method) {
106
+ case "bun": return `bun install -g ${tool.packageName}`;
107
+ case "npm": return `npm install -g ${tool.packageName}`;
108
+ case "pnpm": return `pnpm install -g ${tool.packageName}`;
109
+ case "yarn": return `yarn global add ${tool.packageName}`;
110
+ case "brew": return `brew upgrade ${brewFormula || tool.name}`;
111
+ case "pip": return tool.installCommand;
112
+ default: return tool.installCommand;
36
113
  }
37
114
  }
38
115
  async function getInstalledVersion(tool) {
@@ -109,20 +186,24 @@ export function CliToolsScreen() {
109
186
  const fetchVersionInfo = useCallback(async () => {
110
187
  for (let i = 0; i < cliTools.length; i++) {
111
188
  const tool = cliTools[i];
112
- getInstalledVersion(tool).then((version) => {
189
+ // Run all checks in parallel, then update with all results
190
+ Promise.all([
191
+ getInstalledVersion(tool),
192
+ getLatestVersion(tool),
193
+ detectInstallMethods(tool),
194
+ ]).then(([version, latest, info]) => {
113
195
  updateToolStatus(i, {
114
196
  installedVersion: version,
115
197
  installed: version !== undefined,
116
- });
117
- });
118
- getLatestVersion(tool).then((latest) => {
119
- const current = statusesRef.current[i];
120
- updateToolStatus(i, {
121
198
  latestVersion: latest,
122
199
  checking: false,
123
- hasUpdate: current.installedVersion && latest
124
- ? compareVersions(current.installedVersion, latest) < 0
200
+ hasUpdate: version && latest
201
+ ? compareVersions(version, latest) < 0
125
202
  : false,
203
+ installMethod: version ? info.primary : undefined,
204
+ allMethods: version && info.all.length > 1 ? info.all : undefined,
205
+ updateCommand: version ? getUpdateCommand(tool, info.primary, info.brewFormula) : undefined,
206
+ brewFormula: info.brewFormula,
126
207
  });
127
208
  });
128
209
  }
@@ -151,7 +232,10 @@ export function CliToolsScreen() {
151
232
  else if (event.name === "a") {
152
233
  handleUpdateAll();
153
234
  }
154
- else if (event.name === "enter") {
235
+ else if (event.name === "c") {
236
+ handleResolveConflict();
237
+ }
238
+ else if (event.name === "enter" || event.name === "return") {
155
239
  handleInstall();
156
240
  }
157
241
  });
@@ -166,35 +250,66 @@ export function CliToolsScreen() {
166
250
  cacheInitialized = false;
167
251
  fetchVersionInfo();
168
252
  };
253
+ const runCommand = async (command) => {
254
+ try {
255
+ await execAsync(command, {
256
+ shell: "/bin/bash",
257
+ timeout: 60000,
258
+ });
259
+ return { ok: true };
260
+ }
261
+ catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ return { ok: false, error: msg };
264
+ }
265
+ };
169
266
  const handleInstall = async () => {
170
267
  const status = toolStatuses[cliToolsState.selectedIndex];
171
268
  if (!status)
172
269
  return;
173
- const { tool, installed, hasUpdate } = status;
270
+ const { tool, installed, hasUpdate, updateCommand } = status;
174
271
  const action = !installed ? "Installing" : hasUpdate ? "Updating" : "Reinstalling";
175
- const viaHomebrew = installed
176
- ? await isInstalledViaHomebrew(tool.name)
177
- : false;
178
- const command = !installed
179
- ? tool.installCommand
180
- : viaHomebrew
181
- ? `brew upgrade ${tool.name}`
182
- : tool.installCommand;
272
+ const command = installed && updateCommand ? updateCommand : tool.installCommand;
183
273
  modal.loading(`${action} ${tool.displayName}...`);
184
- try {
185
- execSync(command, {
186
- encoding: "utf-8",
187
- stdio: "pipe",
188
- shell: "/bin/bash",
189
- });
190
- modal.hideModal();
274
+ const result = await runCommand(command);
275
+ modal.hideModal();
276
+ if (result.ok) {
191
277
  handleRefresh();
192
278
  }
193
- catch (error) {
194
- modal.hideModal();
279
+ else {
195
280
  await modal.message("Error", `Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${command}`, "error");
196
281
  }
197
282
  };
283
+ const handleResolveConflict = async () => {
284
+ const status = toolStatuses[cliToolsState.selectedIndex];
285
+ if (!status || !status.allMethods || status.allMethods.length < 2)
286
+ return;
287
+ const { tool, allMethods, installMethod, brewFormula } = status;
288
+ // Let user pick which install to keep
289
+ const options = allMethods.map((method) => ({
290
+ label: `Keep ${method}${method === installMethod ? " (active)" : ""}, remove others`,
291
+ value: method,
292
+ }));
293
+ const keep = await modal.select(`Resolve ${tool.displayName} conflict`, `Installed via ${allMethods.join(" + ")}. Which to keep?`, options);
294
+ if (keep === null)
295
+ return;
296
+ const toRemove = allMethods.filter((m) => m !== keep);
297
+ modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
298
+ const errors = [];
299
+ for (const method of toRemove) {
300
+ const cmd = getUninstallCommand(tool, method, brewFormula);
301
+ if (!cmd)
302
+ continue;
303
+ const result = await runCommand(cmd);
304
+ if (!result.ok)
305
+ errors.push(`${method}: ${cmd}`);
306
+ }
307
+ modal.hideModal();
308
+ if (errors.length > 0) {
309
+ await modal.message("Partial", `Some removals failed. Try manually:\n\n${errors.join("\n")}`, "error");
310
+ }
311
+ handleRefresh();
312
+ };
198
313
  const handleUpdateAll = async () => {
199
314
  const updatable = toolStatuses.filter((s) => s.hasUpdate);
200
315
  if (updatable.length === 0) {
@@ -203,20 +318,8 @@ export function CliToolsScreen() {
203
318
  }
204
319
  modal.loading(`Updating ${updatable.length} tool(s)...`);
205
320
  for (const status of updatable) {
206
- const viaHomebrew = await isInstalledViaHomebrew(status.tool.name);
207
- const command = viaHomebrew
208
- ? `brew upgrade ${status.tool.name}`
209
- : status.tool.installCommand;
210
- try {
211
- execSync(command, {
212
- encoding: "utf-8",
213
- stdio: "pipe",
214
- shell: "/bin/bash",
215
- });
216
- }
217
- catch {
218
- // Continue with other updates
219
- }
321
+ const command = status.updateCommand || status.tool.installCommand;
322
+ await runCommand(command);
220
323
  }
221
324
  modal.hideModal();
222
325
  handleRefresh();
@@ -225,6 +328,6 @@ export function CliToolsScreen() {
225
328
  const installedCount = toolStatuses.filter((s) => s.installed).length;
226
329
  const updateCount = toolStatuses.filter((s) => s.hasUpdate).length;
227
330
  const statusContent = (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Installed: " }), _jsxs("span", { fg: "cyan", children: [installedCount, "/", toolStatuses.length] }), updateCount > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { fg: "gray", children: " \u2502 Updates: " }), _jsx("span", { fg: "yellow", children: updateCount })] }))] }));
228
- return (_jsx(ScreenLayout, { title: "claudeup CLI Tools", currentScreen: "cli-tools", statusLine: statusContent, footerHints: "\u2191\u2193:nav \u2502 Enter:install \u2502 a:update all \u2502 r:refresh", listPanel: _jsx(ScrollableList, { items: toolStatuses, selectedIndex: cliToolsState.selectedIndex, renderItem: renderCliToolRow, maxHeight: dimensions.listPanelHeight }), detailPanel: renderCliToolDetail(selectedStatus) }));
331
+ return (_jsx(ScreenLayout, { title: "claudeup CLI Tools", currentScreen: "cli-tools", statusLine: statusContent, footerHints: "Enter:install \u2502 a:update all \u2502 c:fix conflict \u2502 r:refresh", listPanel: _jsx(ScrollableList, { items: toolStatuses, selectedIndex: cliToolsState.selectedIndex, renderItem: renderCliToolRow, maxHeight: dimensions.listPanelHeight }), detailPanel: renderCliToolDetail(selectedStatus) }));
229
332
  }
230
333
  export default CliToolsScreen;
@@ -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 ?? {};