603 lines
22 KiB
Rust
603 lines
22 KiB
Rust
use anyhow::Result;
|
||
use log::{debug, error, info, warn};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::cmp::Ordering;
|
||
/// 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 tauri::Manager;
|
||
|
||
/// Type of Claude installation
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
pub enum InstallationType {
|
||
/// System-installed binary
|
||
System,
|
||
/// Custom path specified by user
|
||
Custom,
|
||
}
|
||
|
||
/// Represents a Claude installation with metadata
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ClaudeInstallation {
|
||
/// Full path to the Claude binary
|
||
pub path: String,
|
||
/// Version string if available
|
||
pub version: Option<String>,
|
||
/// Source of discovery (e.g., "nvm", "system", "homebrew", "which")
|
||
pub source: String,
|
||
/// Type of installation
|
||
pub installation_type: InstallationType,
|
||
}
|
||
|
||
/// Main function to find the Claude binary
|
||
/// Checks database first for stored path and preference, then prioritizes accordingly
|
||
pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, String> {
|
||
info!("Searching for claude binary...");
|
||
|
||
// First check if we have a stored path and preference 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) {
|
||
// Check for stored path first
|
||
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);
|
||
|
||
// Check if the path still exists and works
|
||
let mut final_path = stored_path.clone();
|
||
let mut path_buf = PathBuf::from(&stored_path);
|
||
|
||
// On Windows, if stored path exists but is not executable (shell script), try .cmd version
|
||
#[cfg(target_os = "windows")]
|
||
if path_buf.exists() && !stored_path.ends_with(".cmd") && !stored_path.ends_with(".exe") {
|
||
// Test if the current path works by trying to get version
|
||
if let Err(_) = get_claude_version(&stored_path) {
|
||
// If it fails, try the .cmd version
|
||
let cmd_path = format!("{}.cmd", stored_path);
|
||
let cmd_path_buf = PathBuf::from(&cmd_path);
|
||
if cmd_path_buf.exists() {
|
||
if let Ok(_) = get_claude_version(&cmd_path) {
|
||
final_path = cmd_path.clone();
|
||
path_buf = cmd_path_buf;
|
||
info!("Using .cmd version instead of shell script: {}", cmd_path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if path_buf.exists() && path_buf.is_file() {
|
||
return Ok(final_path);
|
||
} else {
|
||
warn!("Stored claude path no longer exists: {}", stored_path);
|
||
}
|
||
}
|
||
|
||
// Check user preference
|
||
let preference = conn.query_row(
|
||
"SELECT value FROM app_settings WHERE key = 'claude_installation_preference'",
|
||
[],
|
||
|row| row.get::<_, String>(0),
|
||
).unwrap_or_else(|_| "system".to_string());
|
||
|
||
info!("User preference for Claude installation: {}", preference);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Discover all available system installations
|
||
let installations = discover_system_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 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 mut installations = discover_system_installations();
|
||
|
||
// Sort by version (highest first), then by source preference
|
||
installations.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)),
|
||
}
|
||
});
|
||
|
||
installations
|
||
}
|
||
|
||
/// 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
|
||
fn discover_system_installations() -> Vec<ClaudeInstallation> {
|
||
let mut installations = Vec::new();
|
||
|
||
// 1. Try system command first (now works in production and can return multiple installations)
|
||
installations.extend(find_which_installations());
|
||
|
||
// 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 command to find Claude installations
|
||
/// Returns multiple installations if found (Windows 'where' can return multiple paths)
|
||
fn find_which_installations() -> Vec<ClaudeInstallation> {
|
||
debug!("Trying to find claude binary...");
|
||
|
||
// Use 'where' on Windows, 'which' on Unix
|
||
#[cfg(target_os = "windows")]
|
||
let command_name = "where";
|
||
#[cfg(not(target_os = "windows"))]
|
||
let command_name = "which";
|
||
|
||
let mut installations = Vec::new();
|
||
|
||
match Command::new(command_name).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 installations;
|
||
}
|
||
|
||
// Process each line (Windows 'where' can return multiple paths)
|
||
for line in output_str.lines() {
|
||
let mut path = line.trim().to_string();
|
||
|
||
if path.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Parse aliased output: "claude: aliased to /path/to/claude"
|
||
if path.starts_with("claude:") && path.contains("aliased to") {
|
||
if let Some(aliased_path) = path.split("aliased to").nth(1) {
|
||
path = aliased_path.trim().to_string();
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Convert Unix-style path to Windows path if needed
|
||
#[cfg(target_os = "windows")]
|
||
let path = {
|
||
if path.starts_with("/c/") {
|
||
// Convert /c/path to C:\path
|
||
let windows_path = path.replace("/c/", "C:\\").replace("/", "\\");
|
||
windows_path
|
||
} else if path.starts_with("/") && path.len() > 3 && path.chars().nth(2) == Some('/') {
|
||
// Convert /X/path to X:\path where X is drive letter
|
||
let drive = path.chars().nth(1).unwrap();
|
||
let rest = &path[3..];
|
||
format!("{}:\\{}", drive.to_uppercase(), rest.replace("/", "\\"))
|
||
} else {
|
||
path
|
||
}
|
||
};
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let path = path;
|
||
|
||
debug!("'{}' found claude at: {}", command_name, path);
|
||
|
||
// On Windows, prefer .cmd files over shell scripts
|
||
#[cfg(target_os = "windows")]
|
||
let final_path = {
|
||
if !path.ends_with(".cmd") && !path.ends_with(".exe") {
|
||
// Check if there's a .cmd file alongside
|
||
let cmd_path = format!("{}.cmd", path);
|
||
if PathBuf::from(&cmd_path).exists() {
|
||
// Only use .cmd if the original doesn't work
|
||
if let Err(_) = get_claude_version(&path) {
|
||
cmd_path
|
||
} else {
|
||
path
|
||
}
|
||
} else {
|
||
path
|
||
}
|
||
} else {
|
||
path
|
||
}
|
||
};
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let final_path = path;
|
||
|
||
// Verify the path exists
|
||
if !PathBuf::from(&final_path).exists() {
|
||
warn!("Path from '{}' does not exist: {}", command_name, final_path);
|
||
continue;
|
||
}
|
||
|
||
// Get version
|
||
let version = get_claude_version(&final_path).ok().flatten();
|
||
|
||
installations.push(ClaudeInstallation {
|
||
path: final_path,
|
||
version,
|
||
source: command_name.to_string(),
|
||
installation_type: InstallationType::System,
|
||
});
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
installations
|
||
}
|
||
|
||
/// Find Claude installations in NVM directories
|
||
fn find_nvm_installations() -> Vec<ClaudeInstallation> {
|
||
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),
|
||
installation_type: InstallationType::System,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
installations
|
||
}
|
||
|
||
/// Check standard installation paths
|
||
fn find_standard_installations() -> Vec<ClaudeInstallation> {
|
||
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,
|
||
installation_type: InstallationType::System,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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");
|
||
// Combine stdout and stderr for robust version extraction
|
||
let mut combined: Vec<u8> = Vec::with_capacity(output.stdout.len() + output.stderr.len() + 1);
|
||
combined.extend_from_slice(&output.stdout);
|
||
if !output.stderr.is_empty() {
|
||
combined.extend_from_slice(b"\n");
|
||
combined.extend_from_slice(&output.stderr);
|
||
}
|
||
let version = extract_version_from_output(&combined);
|
||
|
||
installations.push(ClaudeInstallation {
|
||
path: "claude".to_string(),
|
||
version,
|
||
source: "PATH".to_string(),
|
||
installation_type: InstallationType::System,
|
||
});
|
||
}
|
||
}
|
||
|
||
installations
|
||
}
|
||
|
||
/// Get Claude version by running --version command
|
||
fn get_claude_version(path: &str) -> Result<Option<String>, String> {
|
||
match Command::new(path).arg("--version").output() {
|
||
Ok(output) => {
|
||
if output.status.success() {
|
||
// Combine stdout and stderr for robust version extraction
|
||
let mut combined: Vec<u8> = Vec::with_capacity(output.stdout.len() + output.stderr.len() + 1);
|
||
combined.extend_from_slice(&output.stdout);
|
||
if !output.stderr.is_empty() {
|
||
combined.extend_from_slice(b"\n");
|
||
combined.extend_from_slice(&output.stderr);
|
||
}
|
||
Ok(extract_version_from_output(&combined))
|
||
} 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<String> {
|
||
let output_str = String::from_utf8_lossy(stdout);
|
||
|
||
// Debug log the raw output
|
||
debug!("Raw version output: {:?}", output_str);
|
||
|
||
// Use regex to directly extract version pattern (e.g., "1.0.41")
|
||
// This pattern matches:
|
||
// - One or more digits, followed by
|
||
// - A dot, followed by
|
||
// - One or more digits, followed by
|
||
// - A dot, followed by
|
||
// - One or more digits
|
||
// - Optionally followed by pre-release/build metadata
|
||
let version_regex = regex::Regex::new(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)").ok()?;
|
||
|
||
if let Some(captures) = version_regex.captures(&output_str) {
|
||
if let Some(version_match) = captures.get(1) {
|
||
let version = version_match.as_str().to_string();
|
||
debug!("Extracted version: {:?}", version);
|
||
return Some(version);
|
||
}
|
||
}
|
||
|
||
debug!("No version found in output");
|
||
None
|
||
}
|
||
|
||
/// Select the best installation based on version
|
||
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().max_by(|a, b| {
|
||
match (&a.version, &b.version) {
|
||
// If both have versions, compare them semantically.
|
||
(Some(v1), Some(v2)) => compare_versions(v1, v2),
|
||
// Prefer the entry that actually has version information.
|
||
(Some(_), None) => Ordering::Greater,
|
||
(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 modified.
|
||
(None, None) => {
|
||
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<u32> = 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::<String>()
|
||
.parse()
|
||
.ok()
|
||
})
|
||
.collect();
|
||
|
||
let b_parts: Vec<u32> = b
|
||
.split('.')
|
||
.filter_map(|s| {
|
||
s.chars()
|
||
.take_while(|c| c.is_numeric())
|
||
.collect::<String>()
|
||
.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);
|
||
|
||
info!("Creating command for: {}", 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"
|
||
// Add proxy environment variables (only uppercase)
|
||
|| key == "HTTP_PROXY"
|
||
|| key == "HTTPS_PROXY"
|
||
|| key == "NO_PROXY"
|
||
|| key == "ALL_PROXY"
|
||
{
|
||
debug!("Inheriting env var: {}={}", key, value);
|
||
cmd.env(&key, &value);
|
||
}
|
||
}
|
||
|
||
// Log proxy-related environment variables for debugging
|
||
info!("Command will use proxy settings:");
|
||
if let Ok(http_proxy) = std::env::var("HTTP_PROXY") {
|
||
info!(" HTTP_PROXY={}", http_proxy);
|
||
}
|
||
if let Ok(https_proxy) = std::env::var("HTTPS_PROXY") {
|
||
info!(" HTTPS_PROXY={}", https_proxy);
|
||
}
|
||
|
||
// 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
|
||
}
|