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:
- getAsterisk/claudia#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
This commit is contained in:
Mufeed VH
2025-06-25 02:49:24 +05:30
parent 97290e5665
commit c48a63f170
14 changed files with 556 additions and 77 deletions

View File

@@ -9,6 +9,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.1.3", "@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
@@ -286,6 +287,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="], "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.1.3", "@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",

View File

@@ -6,9 +6,10 @@ use log::{info, warn, debug, error};
use anyhow::Result; use anyhow::Result;
use std::cmp::Ordering; use std::cmp::Ordering;
use tauri::Manager; use tauri::Manager;
use serde::{Serialize, Deserialize};
/// Represents a Claude installation with metadata /// Represents a Claude installation with metadata
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeInstallation { pub struct ClaudeInstallation {
/// Full path to the Claude binary /// Full path to the Claude binary
pub path: String, pub path: String,
@@ -68,6 +69,55 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
} }
} }
/// Discovers all available Claude installations and returns them for selection
/// This allows UI to show a version selector
pub fn discover_claude_installations() -> Vec<ClaudeInstallation> {
info!("Discovering all Claude installations...");
let installations = discover_all_installations();
// Sort by version (highest first), then by source preference
let mut sorted = installations;
sorted.sort_by(|a, b| {
match (&a.version, &b.version) {
(Some(v1), Some(v2)) => {
// Compare versions in descending order (newest first)
match compare_versions(v2, v1) {
Ordering::Equal => {
// If versions are equal, prefer by source
source_preference(a).cmp(&source_preference(b))
}
other => other
}
}
(Some(_), None) => Ordering::Less, // Version comes before no version
(None, Some(_)) => Ordering::Greater,
(None, None) => source_preference(a).cmp(&source_preference(b))
}
});
sorted
}
/// Returns a preference score for installation sources (lower is better)
fn source_preference(installation: &ClaudeInstallation) -> u8 {
match installation.source.as_str() {
"which" => 1,
"homebrew" => 2,
"system" => 3,
source if source.starts_with("nvm") => 4,
"local-bin" => 5,
"claude-local" => 6,
"npm-global" => 7,
"yarn" | "yarn-global" => 8,
"bun" => 9,
"node-modules" => 10,
"home-bin" => 11,
"PATH" => 12,
_ => 13,
}
}
/// Discovers all Claude installations on the system /// Discovers all Claude installations on the system
fn discover_all_installations() -> Vec<ClaudeInstallation> { fn discover_all_installations() -> Vec<ClaudeInstallation> {
let mut installations = Vec::new(); let mut installations = Vec::new();
@@ -263,19 +313,25 @@ fn extract_version_from_output(stdout: &[u8]) -> Option<String> {
/// Select the best installation based on version /// Select the best installation based on version
fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> { fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> {
// In production builds, version information may not be retrievable because
// spawning external processes can be restricted. We therefore no longer
// discard installations that lack a detected version the mere presence
// of a readable binary on disk is enough to consider it valid. We still
// prefer binaries with version information when it is available so that
// in development builds we keep the previous behaviour of picking the
// most recent version.
installations.into_iter() installations.into_iter()
.filter(|i| {
// Prefer installations with known versions
i.version.is_some() || i.path == "claude"
})
.max_by(|a, b| { .max_by(|a, b| {
// First compare by version presence
match (&a.version, &b.version) { match (&a.version, &b.version) {
// If both have versions, compare them semantically.
(Some(v1), Some(v2)) => compare_versions(v1, v2), (Some(v1), Some(v2)) => compare_versions(v1, v2),
// Prefer the entry that actually has version information.
(Some(_), None) => Ordering::Greater, (Some(_), None) => Ordering::Greater,
(None, Some(_)) => Ordering::Less, (None, Some(_)) => Ordering::Less,
// Neither have version info: prefer the one that is not just
// the bare "claude" lookup from PATH, because that may fail
// at runtime if PATH is sandbox-stripped.
(None, None) => { (None, None) => {
// Both have no version, prefer non-PATH entries
if a.path == "claude" && b.path != "claude" { if a.path == "claude" && b.path != "claude" {
Ordering::Less Ordering::Less
} else if a.path != "claude" && b.path == "claude" { } else if a.path != "claude" && b.path == "claude" {

View File

@@ -1807,6 +1807,18 @@ pub async fn set_claude_binary_path(db: State<'_, AgentDb>, path: String) -> Res
Ok(()) Ok(())
} }
/// List all available Claude installations on the system
#[tauri::command]
pub async fn list_claude_installations() -> Result<Vec<crate::claude_binary::ClaudeInstallation>, String> {
let installations = crate::claude_binary::discover_claude_installations();
if installations.is_empty() {
return Err("No Claude Code installations found on the system".to_string());
}
Ok(installations)
}
/// Helper function to create a tokio Command with proper environment variables /// Helper function to create a tokio Command with proper environment variables
/// This ensures commands like Claude can find Node.js and other dependencies /// This ensures commands like Claude can find Node.js and other dependencies
fn create_command_with_env(program: &str) -> Command { fn create_command_with_env(program: &str) -> Command {

View File

@@ -440,6 +440,10 @@ pub async fn get_claude_settings() -> Result<ClaudeSettings, String> {
pub async fn open_new_session(app: AppHandle, path: Option<String>) -> Result<String, String> { pub async fn open_new_session(app: AppHandle, path: Option<String>) -> Result<String, String> {
log::info!("Opening new Claude Code session at path: {:?}", path); log::info!("Opening new Claude Code session at path: {:?}", path);
#[cfg(not(debug_assertions))]
let _claude_path = find_claude_binary(&app)?;
#[cfg(debug_assertions)]
let claude_path = find_claude_binary(&app)?; let claude_path = find_claude_binary(&app)?;
// In production, we can't use std::process::Command directly // In production, we can't use std::process::Command directly

View File

@@ -24,12 +24,12 @@ use commands::agents::{
init_database, list_agents, create_agent, update_agent, delete_agent, init_database, list_agents, create_agent, update_agent, delete_agent,
get_agent, execute_agent, list_agent_runs, get_agent_run, get_agent, execute_agent, list_agent_runs, get_agent_run,
get_agent_run_with_real_time_metrics, list_agent_runs_with_metrics, get_agent_run_with_real_time_metrics, list_agent_runs_with_metrics,
migrate_agent_runs_to_session_ids, list_running_sessions, kill_agent_session, list_running_sessions, kill_agent_session,
get_session_status, cleanup_finished_processes, get_session_output, get_session_status, cleanup_finished_processes, get_session_output,
get_live_session_output, stream_session_output, get_claude_binary_path, get_live_session_output, stream_session_output, get_claude_binary_path,
set_claude_binary_path, export_agent, export_agent_to_file, import_agent, set_claude_binary_path, export_agent, export_agent_to_file, import_agent,
import_agent_from_file, fetch_github_agents, fetch_github_agent_content, import_agent_from_file, fetch_github_agents, fetch_github_agent_content,
import_agent_from_github, AgentDb import_agent_from_github, list_claude_installations, AgentDb
}; };
use commands::sandbox::{ use commands::sandbox::{
list_sandbox_profiles, create_sandbox_profile, update_sandbox_profile, delete_sandbox_profile, list_sandbox_profiles, create_sandbox_profile, update_sandbox_profile, delete_sandbox_profile,
@@ -139,19 +139,11 @@ fn main() {
update_agent, update_agent,
delete_agent, delete_agent,
get_agent, get_agent,
export_agent,
export_agent_to_file,
import_agent,
import_agent_from_file,
fetch_github_agents,
fetch_github_agent_content,
import_agent_from_github,
execute_agent, execute_agent,
list_agent_runs, list_agent_runs,
get_agent_run, get_agent_run,
get_agent_run_with_real_time_metrics,
list_agent_runs_with_metrics, list_agent_runs_with_metrics,
migrate_agent_runs_to_session_ids, get_agent_run_with_real_time_metrics,
list_running_sessions, list_running_sessions,
kill_agent_session, kill_agent_session,
get_session_status, get_session_status,
@@ -161,6 +153,14 @@ fn main() {
stream_session_output, stream_session_output,
get_claude_binary_path, get_claude_binary_path,
set_claude_binary_path, set_claude_binary_path,
list_claude_installations,
export_agent,
export_agent_to_file,
import_agent,
import_agent_from_file,
fetch_github_agents,
fetch_github_agent_content,
import_agent_from_github,
list_sandbox_profiles, list_sandbox_profiles,
get_sandbox_profile, get_sandbox_profile,
create_sandbox_profile, create_sandbox_profile,

View File

@@ -70,7 +70,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
className, className,
}) => { }) => {
const [projectPath, setProjectPath] = useState(""); const [projectPath, setProjectPath] = useState("");
const [task, setTask] = useState(""); const [task, setTask] = useState(agent.default_task || "");
const [model, setModel] = useState(agent.model || "sonnet"); const [model, setModel] = useState(agent.model || "sonnet");
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]); const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
@@ -646,7 +646,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<Input <Input
value={task} value={task}
onChange={(e) => setTask(e.target.value)} onChange={(e) => setTask(e.target.value)}
placeholder={agent.default_task || "Enter the task for the agent"} placeholder="Enter the task for the agent"
disabled={isRunning} disabled={isRunning}
className="flex-1" className="flex-1"
onKeyPress={(e) => { onKeyPress={(e) => {

View File

@@ -1,9 +1,9 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { api } from "@/lib/api"; import { api, type ClaudeInstallation } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ExternalLink, FileQuestion, Terminal } from "lucide-react"; import { ExternalLink, FileQuestion, Terminal, AlertCircle, Loader2 } from "lucide-react";
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
interface ClaudeBinaryDialogProps { interface ClaudeBinaryDialogProps {
open: boolean; open: boolean;
@@ -13,18 +13,39 @@ interface ClaudeBinaryDialogProps {
} }
export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) { export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) {
const [binaryPath, setBinaryPath] = useState(""); const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const [hasInstallations, setHasInstallations] = useState(true);
const [checkingInstallations, setCheckingInstallations] = useState(true);
useEffect(() => {
if (open) {
checkInstallations();
}
}, [open]);
const checkInstallations = async () => {
try {
setCheckingInstallations(true);
const installations = await api.listClaudeInstallations();
setHasInstallations(installations.length > 0);
} catch (error) {
// If the API call fails, it means no installations found
setHasInstallations(false);
} finally {
setCheckingInstallations(false);
}
};
const handleSave = async () => { const handleSave = async () => {
if (!binaryPath.trim()) { if (!selectedInstallation) {
onError("Please enter a valid path"); onError("Please select a Claude installation");
return; return;
} }
setIsValidating(true); setIsValidating(true);
try { try {
await api.setClaudeBinaryPath(binaryPath.trim()); await api.setClaudeBinaryPath(selectedInstallation.path);
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
@@ -37,46 +58,58 @@ export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: C
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<FileQuestion className="w-5 h-5" /> <FileQuestion className="w-5 h-5" />
Couldn't locate Claude Code installation Select Claude Code Installation
</DialogTitle> </DialogTitle>
<DialogDescription className="space-y-3 mt-4"> <DialogDescription className="space-y-3 mt-4">
{checkingInstallations ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching for Claude installations...</span>
</div>
) : hasInstallations ? (
<p>
Multiple Claude Code installations were found on your system.
Please select which one you'd like to use.
</p>
) : (
<>
<p> <p>
Claude Code was not found in any of the common installation locations. Claude Code was not found in any of the common installation locations.
Please specify the path to the Claude binary manually. Please install Claude Code to continue.
</p> </p>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<AlertCircle className="w-4 h-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
<span className="font-medium">Searched locations:</span> PATH, /usr/local/bin,
/opt/homebrew/bin, ~/.nvm/versions/node/*/bin, ~/.claude/local, ~/.local/bin
</p>
</div>
</>
)}
{!checkingInstallations && (
<div className="flex items-center gap-2 p-3 bg-muted rounded-md"> <div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<Terminal className="w-4 h-4 text-muted-foreground" /> <Terminal className="w-4 h-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<span className="font-medium">Tip:</span> Run{" "} <span className="font-medium">Tip:</span> You can install Claude Code using{" "}
<code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">which claude</code>{" "} <code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">npm install -g @claude</code>
in your terminal to find the installation path
</p> </p>
</div> </div>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!checkingInstallations && hasInstallations && (
<div className="py-4"> <div className="py-4">
<Input <ClaudeVersionSelector
type="text" onSelect={(installation) => setSelectedInstallation(installation)}
placeholder="/usr/local/bin/claude" selectedPath={null}
value={binaryPath}
onChange={(e) => setBinaryPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isValidating) {
handleSave();
}
}}
autoFocus
className="font-mono text-sm"
/> />
<p className="text-xs text-muted-foreground mt-2">
Common locations: /usr/local/bin/claude, /opt/homebrew/bin/claude, ~/.claude/local/claude
</p>
</div> </div>
)}
<DialogFooter className="gap-3"> <DialogFooter className="gap-3">
<Button <Button
@@ -94,8 +127,11 @@ export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: C
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={isValidating || !binaryPath.trim()}> <Button
{isValidating ? "Validating..." : "Save Path"} onClick={handleSave}
disabled={isValidating || !selectedInstallation || !hasInstallations}
>
{isValidating ? "Validating..." : hasInstallations ? "Save Selection" : "No Installations Found"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -0,0 +1,231 @@
import React, { useEffect, useState } from "react";
import { api, type ClaudeInstallation } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Loader2, Terminal, Package, Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface ClaudeVersionSelectorProps {
/**
* Currently selected Claude installation path
*/
selectedPath?: string | null;
/**
* Callback when a Claude installation is selected
*/
onSelect: (installation: ClaudeInstallation) => void;
/**
* Optional className for styling
*/
className?: string;
/**
* Whether to show a save button (for settings page)
*/
showSaveButton?: boolean;
/**
* Callback when save button is clicked
*/
onSave?: () => void;
/**
* Whether the save operation is in progress
*/
isSaving?: boolean;
}
/**
* ClaudeVersionSelector component for selecting Claude Code installations
*
* @example
* <ClaudeVersionSelector
* selectedPath={currentPath}
* onSelect={(installation) => setSelectedInstallation(installation)}
* />
*/
export const ClaudeVersionSelector: React.FC<ClaudeVersionSelectorProps> = ({
selectedPath,
onSelect,
className,
showSaveButton = false,
onSave,
isSaving = false,
}) => {
const [installations, setInstallations] = useState<ClaudeInstallation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
useEffect(() => {
loadInstallations();
}, []);
useEffect(() => {
// Update selected installation when selectedPath changes
if (selectedPath && installations.length > 0) {
const found = installations.find(i => i.path === selectedPath);
if (found) {
setSelectedInstallation(found);
}
}
}, [selectedPath, installations]);
const loadInstallations = async () => {
try {
setLoading(true);
setError(null);
const foundInstallations = await api.listClaudeInstallations();
setInstallations(foundInstallations);
// If we have a selected path, find and select it
if (selectedPath) {
const found = foundInstallations.find(i => i.path === selectedPath);
if (found) {
setSelectedInstallation(found);
}
} else if (foundInstallations.length > 0) {
// Auto-select the first (best) installation
setSelectedInstallation(foundInstallations[0]);
onSelect(foundInstallations[0]);
}
} catch (err) {
console.error("Failed to load Claude installations:", err);
setError(err instanceof Error ? err.message : "Failed to load Claude installations");
} finally {
setLoading(false);
}
};
const handleSelect = (installation: ClaudeInstallation) => {
setSelectedInstallation(installation);
onSelect(installation);
};
const getSourceIcon = (source: string) => {
if (source.includes("nvm")) return <Package className="w-4 h-4" />;
return <Terminal className="w-4 h-4" />;
};
const getSourceLabel = (source: string) => {
if (source === "which") return "System PATH";
if (source === "homebrew") return "Homebrew";
if (source === "system") return "System";
if (source.startsWith("nvm")) return source.replace("nvm ", "NVM ");
if (source === "local-bin") return "Local bin";
if (source === "claude-local") return "Claude local";
if (source === "npm-global") return "NPM global";
if (source === "yarn" || source === "yarn-global") return "Yarn";
if (source === "bun") return "Bun";
return source;
};
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<Card className={cn("p-4", className)}>
<div className="text-sm text-destructive">{error}</div>
</Card>
);
}
if (installations.length === 0) {
return (
<Card className={cn("p-4", className)}>
<div className="text-sm text-muted-foreground">
No Claude Code installations found on your system.
</div>
</Card>
);
}
return (
<div className={cn("space-y-4", className)}>
<div>
<Label className="text-sm font-medium mb-3 block">
Select Claude Code Installation
</Label>
<RadioGroup
value={selectedInstallation?.path}
onValueChange={(value: string) => {
const installation = installations.find(i => i.path === value);
if (installation) {
handleSelect(installation);
}
}}
>
<div className="space-y-2">
{installations.map((installation) => (
<Card
key={installation.path}
className={cn(
"relative cursor-pointer transition-colors",
selectedInstallation?.path === installation.path
? "border-primary"
: "hover:border-muted-foreground/50"
)}
onClick={() => handleSelect(installation)}
>
<div className="flex items-start p-4">
<RadioGroupItem
value={installation.path}
id={installation.path}
className="mt-1"
/>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2 mb-1">
{getSourceIcon(installation.source)}
<span className="font-medium text-sm">
{getSourceLabel(installation.source)}
</span>
{installation.version && (
<Badge variant="secondary" className="text-xs">
v{installation.version}
</Badge>
)}
{selectedPath === installation.path && (
<Badge variant="default" className="text-xs">
<Check className="w-3 h-3 mr-1" />
Current
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono break-all">
{installation.path}
</p>
</div>
</div>
</Card>
))}
</div>
</RadioGroup>
</div>
{showSaveButton && onSave && (
<div className="flex justify-end">
<Button
onClick={onSave}
disabled={!selectedInstallation || isSaving}
size="sm"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Selection"
)}
</Button>
</div>
)}
</div>
);
};

View File

@@ -20,10 +20,12 @@ import { Card } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { import {
api, api,
type ClaudeSettings type ClaudeSettings,
type ClaudeInstallation
} from "@/lib/api"; } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Toast, ToastContainer } from "@/components/ui/toast"; import { Toast, ToastContainer } from "@/components/ui/toast";
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
interface SettingsProps { interface SettingsProps {
/** /**
@@ -69,12 +71,30 @@ export const Settings: React.FC<SettingsProps> = ({
// Environment variables state // Environment variables state
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]); const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
// Claude binary path state
const [currentBinaryPath, setCurrentBinaryPath] = useState<string | null>(null);
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
const [binaryPathChanged, setBinaryPathChanged] = useState(false);
// Load settings on mount // Load settings on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
loadClaudeBinaryPath();
}, []); }, []);
/**
* Loads the current Claude binary path
*/
const loadClaudeBinaryPath = async () => {
try {
const path = await api.getClaudeBinaryPath();
setCurrentBinaryPath(path);
} catch (err) {
console.error("Failed to load Claude binary path:", err);
}
};
/** /**
* Loads the current Claude settings * Loads the current Claude settings
*/ */
@@ -159,6 +179,14 @@ export const Settings: React.FC<SettingsProps> = ({
await api.saveClaudeSettings(updatedSettings); await api.saveClaudeSettings(updatedSettings);
setSettings(updatedSettings); setSettings(updatedSettings);
// Save Claude binary path if changed
if (binaryPathChanged && selectedInstallation) {
await api.setClaudeBinaryPath(selectedInstallation.path);
setCurrentBinaryPath(selectedInstallation.path);
setBinaryPathChanged(false);
}
setToast({ message: "Settings saved successfully!", type: "success" }); setToast({ message: "Settings saved successfully!", type: "success" });
} catch (err) { } catch (err) {
console.error("Failed to save settings:", err); console.error("Failed to save settings:", err);
@@ -246,6 +274,13 @@ export const Settings: React.FC<SettingsProps> = ({
setEnvVars(prev => prev.filter(envVar => envVar.id !== id)); setEnvVars(prev => prev.filter(envVar => envVar.id !== id));
}; };
/**
* Handle Claude installation selection
*/
const handleClaudeInstallationSelect = (installation: ClaudeInstallation) => {
setSelectedInstallation(installation);
setBinaryPathChanged(installation.path !== currentBinaryPath);
};
return ( return (
<div className={cn("flex flex-col h-full bg-background text-foreground", className)}> <div className={cn("flex flex-col h-full bg-background text-foreground", className)}>
@@ -391,6 +426,25 @@ export const Settings: React.FC<SettingsProps> = ({
How long to retain chat transcripts locally (default: 30 days) How long to retain chat transcripts locally (default: 30 days)
</p> </p>
</div> </div>
{/* Claude Binary Path Selector */}
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-2 block">Claude Code Installation</Label>
<p className="text-xs text-muted-foreground mb-4">
Select which Claude Code installation to use
</p>
</div>
<ClaudeVersionSelector
selectedPath={currentBinaryPath}
onSelect={handleClaudeInstallationSelect}
/>
{binaryPathChanged && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Claude binary path has been changed. Remember to save your settings.
</p>
)}
</div>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -95,6 +95,12 @@ export const Topbar: React.FC<TopbarProps> = ({
if (!versionStatus) return null; if (!versionStatus) return null;
const statusContent = ( const statusContent = (
<Button
variant="ghost"
size="sm"
className="h-auto py-1 px-2 hover:bg-accent"
onClick={onSettingsClick}
>
<div className="flex items-center space-x-2 text-xs"> <div className="flex items-center space-x-2 text-xs">
<Circle <Circle
className={cn( className={cn(
@@ -110,6 +116,7 @@ export const Topbar: React.FC<TopbarProps> = ({
: "Claude Code"} : "Claude Code"}
</span> </span>
</div> </div>
</Button>
); );
if (!versionStatus.is_installed) { if (!versionStatus.is_installed) {
@@ -124,6 +131,14 @@ export const Topbar: React.FC<TopbarProps> = ({
{versionStatus.output} {versionStatus.output}
</pre> </pre>
</div> </div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={onSettingsClick}
>
Select Claude Installation
</Button>
<a <a
href="https://www.anthropic.com/claude-code" href="https://www.anthropic.com/claude-code"
target="_blank" target="_blank"

View File

@@ -10,6 +10,7 @@ export * from "./MCPManager";
export * from "./MCPServerList"; export * from "./MCPServerList";
export * from "./MCPAddServer"; export * from "./MCPAddServer";
export * from "./MCPImportExport"; export * from "./MCPImportExport";
export * from "./ClaudeVersionSelector";
export * from "./ui/badge"; export * from "./ui/badge";
export * from "./ui/button"; export * from "./ui/button";
export * from "./ui/card"; export * from "./ui/card";

View File

@@ -0,0 +1,41 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -78,6 +78,18 @@ export interface FileEntry {
extension?: string; extension?: string;
} }
/**
* Represents a Claude installation found on the system
*/
export interface ClaudeInstallation {
/** Full path to the Claude binary */
path: string;
/** Version string if available */
version?: string;
/** Source of discovery (e.g., "nvm", "system", "homebrew", "which") */
source: string;
}
// Sandbox API types // Sandbox API types
export interface SandboxProfile { export interface SandboxProfile {
id?: number; id?: number;
@@ -1908,4 +1920,17 @@ export const api = {
throw error; throw error;
} }
}, },
/**
* List all available Claude installations on the system
* @returns Promise resolving to an array of Claude installations
*/
async listClaudeInstallations(): Promise<ClaudeInstallation[]> {
try {
return await invoke<ClaudeInstallation[]>("list_claude_installations");
} catch (error) {
console.error("Failed to list Claude installations:", error);
throw error;
}
},
}; };