diff --git a/src-tauri/src/claude_binary.rs b/src-tauri/src/claude_binary.rs new file mode 100644 index 0000000..4751e1f --- /dev/null +++ b/src-tauri/src/claude_binary.rs @@ -0,0 +1,360 @@ +/// Shared module for detecting Claude Code binary installations +/// Supports NVM installations, aliased paths, and version-based selection +use std::path::PathBuf; +use std::process::Command; +use log::{info, warn, debug, error}; +use anyhow::Result; +use std::cmp::Ordering; +use tauri::Manager; + +/// Represents a Claude installation with metadata +#[derive(Debug, Clone)] +pub struct ClaudeInstallation { + /// Full path to the Claude binary + pub path: String, + /// Version string if available + pub version: Option, + /// Source of discovery (e.g., "nvm", "system", "homebrew", "which") + pub source: String, +} + +/// Main function to find the Claude binary +/// Checks database first, then discovers all installations and selects the best one +pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result { + info!("Searching for claude binary..."); + + // First check if we have a stored path in the database + if let Ok(app_data_dir) = app_handle.path().app_data_dir() { + let db_path = app_data_dir.join("agents.db"); + if db_path.exists() { + if let Ok(conn) = rusqlite::Connection::open(&db_path) { + if let Ok(stored_path) = conn.query_row( + "SELECT value FROM app_settings WHERE key = 'claude_binary_path'", + [], + |row| row.get::<_, String>(0), + ) { + info!("Found stored claude path in database: {}", stored_path); + let path_buf = PathBuf::from(&stored_path); + if path_buf.exists() && path_buf.is_file() { + return Ok(stored_path); + } else { + warn!("Stored claude path no longer exists: {}", stored_path); + } + } + } + } + } + + // Discover all available installations + let installations = discover_all_installations(); + + if installations.is_empty() { + error!("Could not find claude binary in any location"); + return Err("Claude Code not found. Please ensure it's installed in one of these locations: PATH, /usr/local/bin, /opt/homebrew/bin, ~/.nvm/versions/node/*/bin, ~/.claude/local, ~/.local/bin".to_string()); + } + + // Log all found installations + for installation in &installations { + info!("Found Claude installation: {:?}", installation); + } + + // Select the best installation (highest version) + if let Some(best) = select_best_installation(installations) { + info!("Selected Claude installation: path={}, version={:?}, source={}", + best.path, best.version, best.source); + Ok(best.path) + } else { + Err("No valid Claude installation found".to_string()) + } +} + +/// Discovers all Claude installations on the system +fn discover_all_installations() -> Vec { + let mut installations = Vec::new(); + + // 1. Try 'which' command first (now works in production) + if let Some(installation) = try_which_command() { + installations.push(installation); + } + + // 2. Check NVM paths + installations.extend(find_nvm_installations()); + + // 3. Check standard paths + installations.extend(find_standard_installations()); + + // Remove duplicates by path + let mut unique_paths = std::collections::HashSet::new(); + installations.retain(|install| unique_paths.insert(install.path.clone())); + + installations +} + +/// Try using the 'which' command to find Claude +fn try_which_command() -> Option { + debug!("Trying 'which claude' to find binary..."); + + match Command::new("which").arg("claude").output() { + Ok(output) if output.status.success() => { + let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if output_str.is_empty() { + return None; + } + + // Parse aliased output: "claude: aliased to /path/to/claude" + let path = if output_str.starts_with("claude:") && output_str.contains("aliased to") { + output_str.split("aliased to") + .nth(1) + .map(|s| s.trim().to_string()) + } else { + Some(output_str) + }?; + + debug!("'which' found claude at: {}", path); + + // Verify the path exists + if !PathBuf::from(&path).exists() { + warn!("Path from 'which' does not exist: {}", path); + return None; + } + + // Get version + let version = get_claude_version(&path).ok().flatten(); + + Some(ClaudeInstallation { + path, + version, + source: "which".to_string(), + }) + } + _ => None, + } +} + +/// Find Claude installations in NVM directories +fn find_nvm_installations() -> Vec { + let mut installations = Vec::new(); + + if let Ok(home) = std::env::var("HOME") { + let nvm_dir = PathBuf::from(&home).join(".nvm").join("versions").join("node"); + + debug!("Checking NVM directory: {:?}", nvm_dir); + + if let Ok(entries) = std::fs::read_dir(&nvm_dir) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let claude_path = entry.path().join("bin").join("claude"); + + if claude_path.exists() && claude_path.is_file() { + let path_str = claude_path.to_string_lossy().to_string(); + let node_version = entry.file_name().to_string_lossy().to_string(); + + debug!("Found Claude in NVM node {}: {}", node_version, path_str); + + // Get Claude version + let version = get_claude_version(&path_str).ok().flatten(); + + installations.push(ClaudeInstallation { + path: path_str, + version, + source: format!("nvm ({})", node_version), + }); + } + } + } + } + } + + installations +} + +/// Check standard installation paths +fn find_standard_installations() -> Vec { + let mut installations = Vec::new(); + + // Common installation paths for claude + let mut paths_to_check: Vec<(String, String)> = vec![ + ("/usr/local/bin/claude".to_string(), "system".to_string()), + ("/opt/homebrew/bin/claude".to_string(), "homebrew".to_string()), + ("/usr/bin/claude".to_string(), "system".to_string()), + ("/bin/claude".to_string(), "system".to_string()), + ]; + + // Also check user-specific paths + if let Ok(home) = std::env::var("HOME") { + paths_to_check.extend(vec![ + (format!("{}/.claude/local/claude", home), "claude-local".to_string()), + (format!("{}/.local/bin/claude", home), "local-bin".to_string()), + (format!("{}/.npm-global/bin/claude", home), "npm-global".to_string()), + (format!("{}/.yarn/bin/claude", home), "yarn".to_string()), + (format!("{}/.bun/bin/claude", home), "bun".to_string()), + (format!("{}/bin/claude", home), "home-bin".to_string()), + // Check common node_modules locations + (format!("{}/node_modules/.bin/claude", home), "node-modules".to_string()), + (format!("{}/.config/yarn/global/node_modules/.bin/claude", home), "yarn-global".to_string()), + ]); + } + + // Check each path + for (path, source) in paths_to_check { + let path_buf = PathBuf::from(&path); + if path_buf.exists() && path_buf.is_file() { + debug!("Found claude at standard path: {} ({})", path, source); + + // Get version + let version = get_claude_version(&path).ok().flatten(); + + installations.push(ClaudeInstallation { + path, + version, + source, + }); + } + } + + // Also check if claude is available in PATH (without full path) + if let Ok(output) = Command::new("claude").arg("--version").output() { + if output.status.success() { + debug!("claude is available in PATH"); + let version = extract_version_from_output(&output.stdout); + + installations.push(ClaudeInstallation { + path: "claude".to_string(), + version, + source: "PATH".to_string(), + }); + } + } + + installations +} + +/// Get Claude version by running --version command +fn get_claude_version(path: &str) -> Result, String> { + match Command::new(path).arg("--version").output() { + Ok(output) => { + if output.status.success() { + Ok(extract_version_from_output(&output.stdout)) + } else { + Ok(None) + } + } + Err(e) => { + warn!("Failed to get version for {}: {}", path, e); + Ok(None) + } + } +} + +/// Extract version string from command output +fn extract_version_from_output(stdout: &[u8]) -> Option { + let output_str = String::from_utf8_lossy(stdout); + + // Extract version: first token before whitespace that looks like a version + output_str.split_whitespace() + .find(|token| { + // Version usually contains dots and numbers + token.chars().any(|c| c == '.') && + token.chars().any(|c| c.is_numeric()) + }) + .map(|s| s.to_string()) +} + +/// Select the best installation based on version +fn select_best_installation(installations: Vec) -> Option { + installations.into_iter() + .filter(|i| { + // Prefer installations with known versions + i.version.is_some() || i.path == "claude" + }) + .max_by(|a, b| { + // First compare by version presence + match (&a.version, &b.version) { + (Some(v1), Some(v2)) => compare_versions(v1, v2), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => { + // Both have no version, prefer non-PATH entries + if a.path == "claude" && b.path != "claude" { + Ordering::Less + } else if a.path != "claude" && b.path == "claude" { + Ordering::Greater + } else { + Ordering::Equal + } + } + } + }) +} + +/// Compare two version strings +fn compare_versions(a: &str, b: &str) -> Ordering { + // Simple semantic version comparison + let a_parts: Vec = a.split('.') + .filter_map(|s| { + // Handle versions like "1.0.17-beta" by taking only numeric part + s.chars() + .take_while(|c| c.is_numeric()) + .collect::() + .parse() + .ok() + }) + .collect(); + + let b_parts: Vec = b.split('.') + .filter_map(|s| { + s.chars() + .take_while(|c| c.is_numeric()) + .collect::() + .parse() + .ok() + }) + .collect(); + + // Compare each part + for i in 0..std::cmp::max(a_parts.len(), b_parts.len()) { + let a_val = a_parts.get(i).unwrap_or(&0); + let b_val = b_parts.get(i).unwrap_or(&0); + match a_val.cmp(b_val) { + Ordering::Equal => continue, + other => return other, + } + } + + Ordering::Equal +} + +/// Helper function to create a Command with proper environment variables +/// This ensures commands like Claude can find Node.js and other dependencies +pub fn create_command_with_env(program: &str) -> Command { + let mut cmd = Command::new(program); + + // Inherit essential environment variables from parent process + for (key, value) in std::env::vars() { + // Pass through PATH and other essential environment variables + if key == "PATH" || key == "HOME" || key == "USER" + || key == "SHELL" || key == "LANG" || key == "LC_ALL" || key.starts_with("LC_") + || key == "NODE_PATH" || key == "NVM_DIR" || key == "NVM_BIN" + || key == "HOMEBREW_PREFIX" || key == "HOMEBREW_CELLAR" { + debug!("Inheriting env var: {}={}", key, value); + cmd.env(&key, &value); + } + } + + // Add NVM support if the program is in an NVM directory + if program.contains("/.nvm/versions/node/") { + if let Some(node_bin_dir) = std::path::Path::new(program).parent() { + // Ensure the Node.js bin directory is in PATH + let current_path = std::env::var("PATH").unwrap_or_default(); + let node_bin_str = node_bin_dir.to_string_lossy(); + if !current_path.contains(&node_bin_str.as_ref()) { + let new_path = format!("{}:{}", node_bin_str, current_path); + debug!("Adding NVM bin directory to PATH: {}", node_bin_str); + cmd.env("PATH", new_path); + } + } + } + + cmd +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5168cc4..98d350e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod commands; pub mod sandbox; pub mod checkpoint; pub mod process; +pub mod claude_binary; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 94669ec..fd8d14e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod commands; mod sandbox; mod checkpoint; mod process; +mod claude_binary; use tauri::Manager; use commands::claude::{