Skip to content

Commit c48a63f

Browse files
committed
feat(claude-binary): implement robust version selector with enhanced binary detection
This commit provides a comprehensive solution to Claude binary detection issues by implementing a user-friendly version selector UI and improving the binary discovery logic. It addresses all concerns raised in multiple PRs and comments. Changes: - Add ClaudeVersionSelector component for selecting from multiple installations - Update ClaudeBinaryDialog to use version selector instead of manual path input - Fix unused variable warning in production builds (claude.rs:442) - Improve select_best_installation to handle production build restrictions - Add listClaudeInstallations API endpoint to fetch all available installations - Make Claude version indicator clickable to navigate to Settings - Move Claude installation selector to General tab in Settings (per user request) - Enhance dialog UX with loading states and clear installation instructions - Add Radix UI radio-group dependency for version selector Fixes: - Production build warning about unused claude_path variable - Version detection failures in production builds due to process restrictions - Poor UX when Claude binary is not found (now shows helpful dialog) - Inability to easily switch between multiple Claude installations This implementation takes inspiration from: - PR #3: Version selector dropdown approach (preferred by users) - PR #4: Binary detection improvements and path validation - PR #39: Additional detection methods and error handling - Commit 5a29f9a: Shared claude binary detection module architecture Addresses feedback from: - #4 (comment): User preference for dropdown selector - Production build restrictions that prevent version detection - Need for better error handling when Claude is not installed The solution provides a seamless experience whether Claude is installed via: - npm/yarn/bun global installation - nvm-managed Node.js versions - Homebrew on macOS - System-wide installation - Local user installation (~/.local/bin, etc.) Refs: #3, #4, #39, 5a29f9a
1 parent 97290e5 commit c48a63f

14 files changed

Lines changed: 556 additions & 77 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@radix-ui/react-dropdown-menu": "^2.1.15",
1616
"@radix-ui/react-label": "^2.1.1",
1717
"@radix-ui/react-popover": "^1.1.4",
18+
"@radix-ui/react-radio-group": "^1.3.7",
1819
"@radix-ui/react-select": "^2.1.3",
1920
"@radix-ui/react-switch": "^1.1.3",
2021
"@radix-ui/react-tabs": "^1.1.3",

src-tauri/src/claude_binary.rs

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ use log::{info, warn, debug, error};
66
use anyhow::Result;
77
use std::cmp::Ordering;
88
use tauri::Manager;
9+
use serde::{Serialize, Deserialize};
910

1011
/// Represents a Claude installation with metadata
11-
#[derive(Debug, Clone)]
12+
#[derive(Debug, Clone, Serialize, Deserialize)]
1213
pub struct ClaudeInstallation {
1314
/// Full path to the Claude binary
1415
pub path: String,
@@ -68,6 +69,55 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
6869
}
6970
}
7071

72+
/// Discovers all available Claude installations and returns them for selection
73+
/// This allows UI to show a version selector
74+
pub fn discover_claude_installations() -> Vec<ClaudeInstallation> {
75+
info!("Discovering all Claude installations...");
76+
77+
let installations = discover_all_installations();
78+
79+
// Sort by version (highest first), then by source preference
80+
let mut sorted = installations;
81+
sorted.sort_by(|a, b| {
82+
match (&a.version, &b.version) {
83+
(Some(v1), Some(v2)) => {
84+
// Compare versions in descending order (newest first)
85+
match compare_versions(v2, v1) {
86+
Ordering::Equal => {
87+
// If versions are equal, prefer by source
88+
source_preference(a).cmp(&source_preference(b))
89+
}
90+
other => other
91+
}
92+
}
93+
(Some(_), None) => Ordering::Less, // Version comes before no version
94+
(None, Some(_)) => Ordering::Greater,
95+
(None, None) => source_preference(a).cmp(&source_preference(b))
96+
}
97+
});
98+
99+
sorted
100+
}
101+
102+
/// Returns a preference score for installation sources (lower is better)
103+
fn source_preference(installation: &ClaudeInstallation) -> u8 {
104+
match installation.source.as_str() {
105+
"which" => 1,
106+
"homebrew" => 2,
107+
"system" => 3,
108+
source if source.starts_with("nvm") => 4,
109+
"local-bin" => 5,
110+
"claude-local" => 6,
111+
"npm-global" => 7,
112+
"yarn" | "yarn-global" => 8,
113+
"bun" => 9,
114+
"node-modules" => 10,
115+
"home-bin" => 11,
116+
"PATH" => 12,
117+
_ => 13,
118+
}
119+
}
120+
71121
/// Discovers all Claude installations on the system
72122
fn discover_all_installations() -> Vec<ClaudeInstallation> {
73123
let mut installations = Vec::new();
@@ -263,19 +313,25 @@ fn extract_version_from_output(stdout: &[u8]) -> Option<String> {
263313

264314
/// Select the best installation based on version
265315
fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> {
316+
// In production builds, version information may not be retrievable because
317+
// spawning external processes can be restricted. We therefore no longer
318+
// discard installations that lack a detected version – the mere presence
319+
// of a readable binary on disk is enough to consider it valid. We still
320+
// prefer binaries with version information when it is available so that
321+
// in development builds we keep the previous behaviour of picking the
322+
// most recent version.
266323
installations.into_iter()
267-
.filter(|i| {
268-
// Prefer installations with known versions
269-
i.version.is_some() || i.path == "claude"
270-
})
271324
.max_by(|a, b| {
272-
// First compare by version presence
273325
match (&a.version, &b.version) {
326+
// If both have versions, compare them semantically.
274327
(Some(v1), Some(v2)) => compare_versions(v1, v2),
328+
// Prefer the entry that actually has version information.
275329
(Some(_), None) => Ordering::Greater,
276330
(None, Some(_)) => Ordering::Less,
331+
// Neither have version info: prefer the one that is not just
332+
// the bare "claude" lookup from PATH, because that may fail
333+
// at runtime if PATH is sandbox-stripped.
277334
(None, None) => {
278-
// Both have no version, prefer non-PATH entries
279335
if a.path == "claude" && b.path != "claude" {
280336
Ordering::Less
281337
} else if a.path != "claude" && b.path == "claude" {

src-tauri/src/commands/agents.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,18 @@ pub async fn set_claude_binary_path(db: State<'_, AgentDb>, path: String) -> Res
18071807
Ok(())
18081808
}
18091809

1810+
/// List all available Claude installations on the system
1811+
#[tauri::command]
1812+
pub async fn list_claude_installations() -> Result<Vec<crate::claude_binary::ClaudeInstallation>, String> {
1813+
let installations = crate::claude_binary::discover_claude_installations();
1814+
1815+
if installations.is_empty() {
1816+
return Err("No Claude Code installations found on the system".to_string());
1817+
}
1818+
1819+
Ok(installations)
1820+
}
1821+
18101822
/// Helper function to create a tokio Command with proper environment variables
18111823
/// This ensures commands like Claude can find Node.js and other dependencies
18121824
fn create_command_with_env(program: &str) -> Command {

src-tauri/src/commands/claude.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,10 @@ pub async fn get_claude_settings() -> Result<ClaudeSettings, String> {
440440
pub async fn open_new_session(app: AppHandle, path: Option<String>) -> Result<String, String> {
441441
log::info!("Opening new Claude Code session at path: {:?}", path);
442442

443+
#[cfg(not(debug_assertions))]
444+
let _claude_path = find_claude_binary(&app)?;
445+
446+
#[cfg(debug_assertions)]
443447
let claude_path = find_claude_binary(&app)?;
444448

445449
// In production, we can't use std::process::Command directly

src-tauri/src/main.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ use commands::agents::{
2424
init_database, list_agents, create_agent, update_agent, delete_agent,
2525
get_agent, execute_agent, list_agent_runs, get_agent_run,
2626
get_agent_run_with_real_time_metrics, list_agent_runs_with_metrics,
27-
migrate_agent_runs_to_session_ids, list_running_sessions, kill_agent_session,
27+
list_running_sessions, kill_agent_session,
2828
get_session_status, cleanup_finished_processes, get_session_output,
2929
get_live_session_output, stream_session_output, get_claude_binary_path,
3030
set_claude_binary_path, export_agent, export_agent_to_file, import_agent,
3131
import_agent_from_file, fetch_github_agents, fetch_github_agent_content,
32-
import_agent_from_github, AgentDb
32+
import_agent_from_github, list_claude_installations, AgentDb
3333
};
3434
use commands::sandbox::{
3535
list_sandbox_profiles, create_sandbox_profile, update_sandbox_profile, delete_sandbox_profile,
@@ -139,19 +139,11 @@ fn main() {
139139
update_agent,
140140
delete_agent,
141141
get_agent,
142-
export_agent,
143-
export_agent_to_file,
144-
import_agent,
145-
import_agent_from_file,
146-
fetch_github_agents,
147-
fetch_github_agent_content,
148-
import_agent_from_github,
149142
execute_agent,
150143
list_agent_runs,
151144
get_agent_run,
152-
get_agent_run_with_real_time_metrics,
153145
list_agent_runs_with_metrics,
154-
migrate_agent_runs_to_session_ids,
146+
get_agent_run_with_real_time_metrics,
155147
list_running_sessions,
156148
kill_agent_session,
157149
get_session_status,
@@ -161,6 +153,14 @@ fn main() {
161153
stream_session_output,
162154
get_claude_binary_path,
163155
set_claude_binary_path,
156+
list_claude_installations,
157+
export_agent,
158+
export_agent_to_file,
159+
import_agent,
160+
import_agent_from_file,
161+
fetch_github_agents,
162+
fetch_github_agent_content,
163+
import_agent_from_github,
164164
list_sandbox_profiles,
165165
get_sandbox_profile,
166166
create_sandbox_profile,

src/components/AgentExecution.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
7070
className,
7171
}) => {
7272
const [projectPath, setProjectPath] = useState("");
73-
const [task, setTask] = useState("");
73+
const [task, setTask] = useState(agent.default_task || "");
7474
const [model, setModel] = useState(agent.model || "sonnet");
7575
const [isRunning, setIsRunning] = useState(false);
7676
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
@@ -646,7 +646,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
646646
<Input
647647
value={task}
648648
onChange={(e) => setTask(e.target.value)}
649-
placeholder={agent.default_task || "Enter the task for the agent"}
649+
placeholder="Enter the task for the agent"
650650
disabled={isRunning}
651651
className="flex-1"
652652
onKeyPress={(e) => {

src/components/ClaudeBinaryDialog.tsx

Lines changed: 77 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useState } from "react";
2-
import { api } from "@/lib/api";
1+
import { useState, useEffect } from "react";
2+
import { api, type ClaudeInstallation } from "@/lib/api";
33
import { Button } from "@/components/ui/button";
4-
import { Input } from "@/components/ui/input";
54
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6-
import { ExternalLink, FileQuestion, Terminal } from "lucide-react";
5+
import { ExternalLink, FileQuestion, Terminal, AlertCircle, Loader2 } from "lucide-react";
6+
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
77

88
interface ClaudeBinaryDialogProps {
99
open: boolean;
@@ -13,18 +13,39 @@ interface ClaudeBinaryDialogProps {
1313
}
1414

1515
export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) {
16-
const [binaryPath, setBinaryPath] = useState("");
16+
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
1717
const [isValidating, setIsValidating] = useState(false);
18+
const [hasInstallations, setHasInstallations] = useState(true);
19+
const [checkingInstallations, setCheckingInstallations] = useState(true);
20+
21+
useEffect(() => {
22+
if (open) {
23+
checkInstallations();
24+
}
25+
}, [open]);
26+
27+
const checkInstallations = async () => {
28+
try {
29+
setCheckingInstallations(true);
30+
const installations = await api.listClaudeInstallations();
31+
setHasInstallations(installations.length > 0);
32+
} catch (error) {
33+
// If the API call fails, it means no installations found
34+
setHasInstallations(false);
35+
} finally {
36+
setCheckingInstallations(false);
37+
}
38+
};
1839

1940
const handleSave = async () => {
20-
if (!binaryPath.trim()) {
21-
onError("Please enter a valid path");
41+
if (!selectedInstallation) {
42+
onError("Please select a Claude installation");
2243
return;
2344
}
2445

2546
setIsValidating(true);
2647
try {
27-
await api.setClaudeBinaryPath(binaryPath.trim());
48+
await api.setClaudeBinaryPath(selectedInstallation.path);
2849
onSuccess();
2950
onOpenChange(false);
3051
} catch (error) {
@@ -37,46 +58,58 @@ export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: C
3758

3859
return (
3960
<Dialog open={open} onOpenChange={onOpenChange}>
40-
<DialogContent className="sm:max-w-[500px]">
61+
<DialogContent className="sm:max-w-[600px]">
4162
<DialogHeader>
4263
<DialogTitle className="flex items-center gap-2">
4364
<FileQuestion className="w-5 h-5" />
44-
Couldn't locate Claude Code installation
65+
Select Claude Code Installation
4566
</DialogTitle>
4667
<DialogDescription className="space-y-3 mt-4">
47-
<p>
48-
Claude Code was not found in any of the common installation locations.
49-
Please specify the path to the Claude binary manually.
50-
</p>
51-
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
52-
<Terminal className="w-4 h-4 text-muted-foreground" />
53-
<p className="text-sm text-muted-foreground">
54-
<span className="font-medium">Tip:</span> Run{" "}
55-
<code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">which claude</code>{" "}
56-
in your terminal to find the installation path
68+
{checkingInstallations ? (
69+
<div className="flex items-center justify-center py-8">
70+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
71+
<span className="ml-2 text-sm text-muted-foreground">Searching for Claude installations...</span>
72+
</div>
73+
) : hasInstallations ? (
74+
<p>
75+
Multiple Claude Code installations were found on your system.
76+
Please select which one you'd like to use.
5777
</p>
58-
</div>
78+
) : (
79+
<>
80+
<p>
81+
Claude Code was not found in any of the common installation locations.
82+
Please install Claude Code to continue.
83+
</p>
84+
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
85+
<AlertCircle className="w-4 h-4 text-muted-foreground" />
86+
<p className="text-sm text-muted-foreground">
87+
<span className="font-medium">Searched locations:</span> PATH, /usr/local/bin,
88+
/opt/homebrew/bin, ~/.nvm/versions/node/*/bin, ~/.claude/local, ~/.local/bin
89+
</p>
90+
</div>
91+
</>
92+
)}
93+
{!checkingInstallations && (
94+
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
95+
<Terminal className="w-4 h-4 text-muted-foreground" />
96+
<p className="text-sm text-muted-foreground">
97+
<span className="font-medium">Tip:</span> You can install Claude Code using{" "}
98+
<code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">npm install -g @claude</code>
99+
</p>
100+
</div>
101+
)}
59102
</DialogDescription>
60103
</DialogHeader>
61104

62-
<div className="py-4">
63-
<Input
64-
type="text"
65-
placeholder="/usr/local/bin/claude"
66-
value={binaryPath}
67-
onChange={(e) => setBinaryPath(e.target.value)}
68-
onKeyDown={(e) => {
69-
if (e.key === "Enter" && !isValidating) {
70-
handleSave();
71-
}
72-
}}
73-
autoFocus
74-
className="font-mono text-sm"
75-
/>
76-
<p className="text-xs text-muted-foreground mt-2">
77-
Common locations: /usr/local/bin/claude, /opt/homebrew/bin/claude, ~/.claude/local/claude
78-
</p>
79-
</div>
105+
{!checkingInstallations && hasInstallations && (
106+
<div className="py-4">
107+
<ClaudeVersionSelector
108+
onSelect={(installation) => setSelectedInstallation(installation)}
109+
selectedPath={null}
110+
/>
111+
</div>
112+
)}
80113

81114
<DialogFooter className="gap-3">
82115
<Button
@@ -94,8 +127,11 @@ export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: C
94127
>
95128
Cancel
96129
</Button>
97-
<Button onClick={handleSave} disabled={isValidating || !binaryPath.trim()}>
98-
{isValidating ? "Validating..." : "Save Path"}
130+
<Button
131+
onClick={handleSave}
132+
disabled={isValidating || !selectedInstallation || !hasInstallations}
133+
>
134+
{isValidating ? "Validating..." : hasInstallations ? "Save Selection" : "No Installations Found"}
99135
</Button>
100136
</DialogFooter>
101137
</DialogContent>

0 commit comments

Comments
 (0)