Skip to content

Commit 5a29f9a

Browse files
committed
feat: add shared claude binary detection module
Introduces a dedicated module for detecting Claude Code binary installations with support for multiple sources including NVM, aliased paths, and version-based selection. This shared module improves the reliability of finding Claude installations across different environments. Key features: - Supports multiple installation sources (NVM, system, homebrew, npm, yarn, bun) - Handles aliased paths from 'which' command - Version-aware selection to prefer latest installations - Database caching of discovered binary paths - Comprehensive logging for debugging - Environment variable preservation for NVM compatibility The implementation is inspired by: - #3 - #4
1 parent 0c73263 commit 5a29f9a

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

src-tauri/src/claude_binary.rs

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
/// Shared module for detecting Claude Code binary installations
2+
/// Supports NVM installations, aliased paths, and version-based selection
3+
use std::path::PathBuf;
4+
use std::process::Command;
5+
use log::{info, warn, debug, error};
6+
use anyhow::Result;
7+
use std::cmp::Ordering;
8+
use tauri::Manager;
9+
10+
/// Represents a Claude installation with metadata
11+
#[derive(Debug, Clone)]
12+
pub struct ClaudeInstallation {
13+
/// Full path to the Claude binary
14+
pub path: String,
15+
/// Version string if available
16+
pub version: Option<String>,
17+
/// Source of discovery (e.g., "nvm", "system", "homebrew", "which")
18+
pub source: String,
19+
}
20+
21+
/// Main function to find the Claude binary
22+
/// Checks database first, then discovers all installations and selects the best one
23+
pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, String> {
24+
info!("Searching for claude binary...");
25+
26+
// First check if we have a stored path in the database
27+
if let Ok(app_data_dir) = app_handle.path().app_data_dir() {
28+
let db_path = app_data_dir.join("agents.db");
29+
if db_path.exists() {
30+
if let Ok(conn) = rusqlite::Connection::open(&db_path) {
31+
if let Ok(stored_path) = conn.query_row(
32+
"SELECT value FROM app_settings WHERE key = 'claude_binary_path'",
33+
[],
34+
|row| row.get::<_, String>(0),
35+
) {
36+
info!("Found stored claude path in database: {}", stored_path);
37+
let path_buf = PathBuf::from(&stored_path);
38+
if path_buf.exists() && path_buf.is_file() {
39+
return Ok(stored_path);
40+
} else {
41+
warn!("Stored claude path no longer exists: {}", stored_path);
42+
}
43+
}
44+
}
45+
}
46+
}
47+
48+
// Discover all available installations
49+
let installations = discover_all_installations();
50+
51+
if installations.is_empty() {
52+
error!("Could not find claude binary in any location");
53+
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());
54+
}
55+
56+
// Log all found installations
57+
for installation in &installations {
58+
info!("Found Claude installation: {:?}", installation);
59+
}
60+
61+
// Select the best installation (highest version)
62+
if let Some(best) = select_best_installation(installations) {
63+
info!("Selected Claude installation: path={}, version={:?}, source={}",
64+
best.path, best.version, best.source);
65+
Ok(best.path)
66+
} else {
67+
Err("No valid Claude installation found".to_string())
68+
}
69+
}
70+
71+
/// Discovers all Claude installations on the system
72+
fn discover_all_installations() -> Vec<ClaudeInstallation> {
73+
let mut installations = Vec::new();
74+
75+
// 1. Try 'which' command first (now works in production)
76+
if let Some(installation) = try_which_command() {
77+
installations.push(installation);
78+
}
79+
80+
// 2. Check NVM paths
81+
installations.extend(find_nvm_installations());
82+
83+
// 3. Check standard paths
84+
installations.extend(find_standard_installations());
85+
86+
// Remove duplicates by path
87+
let mut unique_paths = std::collections::HashSet::new();
88+
installations.retain(|install| unique_paths.insert(install.path.clone()));
89+
90+
installations
91+
}
92+
93+
/// Try using the 'which' command to find Claude
94+
fn try_which_command() -> Option<ClaudeInstallation> {
95+
debug!("Trying 'which claude' to find binary...");
96+
97+
match Command::new("which").arg("claude").output() {
98+
Ok(output) if output.status.success() => {
99+
let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
100+
101+
if output_str.is_empty() {
102+
return None;
103+
}
104+
105+
// Parse aliased output: "claude: aliased to /path/to/claude"
106+
let path = if output_str.starts_with("claude:") && output_str.contains("aliased to") {
107+
output_str.split("aliased to")
108+
.nth(1)
109+
.map(|s| s.trim().to_string())
110+
} else {
111+
Some(output_str)
112+
}?;
113+
114+
debug!("'which' found claude at: {}", path);
115+
116+
// Verify the path exists
117+
if !PathBuf::from(&path).exists() {
118+
warn!("Path from 'which' does not exist: {}", path);
119+
return None;
120+
}
121+
122+
// Get version
123+
let version = get_claude_version(&path).ok().flatten();
124+
125+
Some(ClaudeInstallation {
126+
path,
127+
version,
128+
source: "which".to_string(),
129+
})
130+
}
131+
_ => None,
132+
}
133+
}
134+
135+
/// Find Claude installations in NVM directories
136+
fn find_nvm_installations() -> Vec<ClaudeInstallation> {
137+
let mut installations = Vec::new();
138+
139+
if let Ok(home) = std::env::var("HOME") {
140+
let nvm_dir = PathBuf::from(&home).join(".nvm").join("versions").join("node");
141+
142+
debug!("Checking NVM directory: {:?}", nvm_dir);
143+
144+
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
145+
for entry in entries.flatten() {
146+
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
147+
let claude_path = entry.path().join("bin").join("claude");
148+
149+
if claude_path.exists() && claude_path.is_file() {
150+
let path_str = claude_path.to_string_lossy().to_string();
151+
let node_version = entry.file_name().to_string_lossy().to_string();
152+
153+
debug!("Found Claude in NVM node {}: {}", node_version, path_str);
154+
155+
// Get Claude version
156+
let version = get_claude_version(&path_str).ok().flatten();
157+
158+
installations.push(ClaudeInstallation {
159+
path: path_str,
160+
version,
161+
source: format!("nvm ({})", node_version),
162+
});
163+
}
164+
}
165+
}
166+
}
167+
}
168+
169+
installations
170+
}
171+
172+
/// Check standard installation paths
173+
fn find_standard_installations() -> Vec<ClaudeInstallation> {
174+
let mut installations = Vec::new();
175+
176+
// Common installation paths for claude
177+
let mut paths_to_check: Vec<(String, String)> = vec![
178+
("/usr/local/bin/claude".to_string(), "system".to_string()),
179+
("/opt/homebrew/bin/claude".to_string(), "homebrew".to_string()),
180+
("/usr/bin/claude".to_string(), "system".to_string()),
181+
("/bin/claude".to_string(), "system".to_string()),
182+
];
183+
184+
// Also check user-specific paths
185+
if let Ok(home) = std::env::var("HOME") {
186+
paths_to_check.extend(vec![
187+
(format!("{}/.claude/local/claude", home), "claude-local".to_string()),
188+
(format!("{}/.local/bin/claude", home), "local-bin".to_string()),
189+
(format!("{}/.npm-global/bin/claude", home), "npm-global".to_string()),
190+
(format!("{}/.yarn/bin/claude", home), "yarn".to_string()),
191+
(format!("{}/.bun/bin/claude", home), "bun".to_string()),
192+
(format!("{}/bin/claude", home), "home-bin".to_string()),
193+
// Check common node_modules locations
194+
(format!("{}/node_modules/.bin/claude", home), "node-modules".to_string()),
195+
(format!("{}/.config/yarn/global/node_modules/.bin/claude", home), "yarn-global".to_string()),
196+
]);
197+
}
198+
199+
// Check each path
200+
for (path, source) in paths_to_check {
201+
let path_buf = PathBuf::from(&path);
202+
if path_buf.exists() && path_buf.is_file() {
203+
debug!("Found claude at standard path: {} ({})", path, source);
204+
205+
// Get version
206+
let version = get_claude_version(&path).ok().flatten();
207+
208+
installations.push(ClaudeInstallation {
209+
path,
210+
version,
211+
source,
212+
});
213+
}
214+
}
215+
216+
// Also check if claude is available in PATH (without full path)
217+
if let Ok(output) = Command::new("claude").arg("--version").output() {
218+
if output.status.success() {
219+
debug!("claude is available in PATH");
220+
let version = extract_version_from_output(&output.stdout);
221+
222+
installations.push(ClaudeInstallation {
223+
path: "claude".to_string(),
224+
version,
225+
source: "PATH".to_string(),
226+
});
227+
}
228+
}
229+
230+
installations
231+
}
232+
233+
/// Get Claude version by running --version command
234+
fn get_claude_version(path: &str) -> Result<Option<String>, String> {
235+
match Command::new(path).arg("--version").output() {
236+
Ok(output) => {
237+
if output.status.success() {
238+
Ok(extract_version_from_output(&output.stdout))
239+
} else {
240+
Ok(None)
241+
}
242+
}
243+
Err(e) => {
244+
warn!("Failed to get version for {}: {}", path, e);
245+
Ok(None)
246+
}
247+
}
248+
}
249+
250+
/// Extract version string from command output
251+
fn extract_version_from_output(stdout: &[u8]) -> Option<String> {
252+
let output_str = String::from_utf8_lossy(stdout);
253+
254+
// Extract version: first token before whitespace that looks like a version
255+
output_str.split_whitespace()
256+
.find(|token| {
257+
// Version usually contains dots and numbers
258+
token.chars().any(|c| c == '.') &&
259+
token.chars().any(|c| c.is_numeric())
260+
})
261+
.map(|s| s.to_string())
262+
}
263+
264+
/// Select the best installation based on version
265+
fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> {
266+
installations.into_iter()
267+
.filter(|i| {
268+
// Prefer installations with known versions
269+
i.version.is_some() || i.path == "claude"
270+
})
271+
.max_by(|a, b| {
272+
// First compare by version presence
273+
match (&a.version, &b.version) {
274+
(Some(v1), Some(v2)) => compare_versions(v1, v2),
275+
(Some(_), None) => Ordering::Greater,
276+
(None, Some(_)) => Ordering::Less,
277+
(None, None) => {
278+
// Both have no version, prefer non-PATH entries
279+
if a.path == "claude" && b.path != "claude" {
280+
Ordering::Less
281+
} else if a.path != "claude" && b.path == "claude" {
282+
Ordering::Greater
283+
} else {
284+
Ordering::Equal
285+
}
286+
}
287+
}
288+
})
289+
}
290+
291+
/// Compare two version strings
292+
fn compare_versions(a: &str, b: &str) -> Ordering {
293+
// Simple semantic version comparison
294+
let a_parts: Vec<u32> = a.split('.')
295+
.filter_map(|s| {
296+
// Handle versions like "1.0.17-beta" by taking only numeric part
297+
s.chars()
298+
.take_while(|c| c.is_numeric())
299+
.collect::<String>()
300+
.parse()
301+
.ok()
302+
})
303+
.collect();
304+
305+
let b_parts: Vec<u32> = b.split('.')
306+
.filter_map(|s| {
307+
s.chars()
308+
.take_while(|c| c.is_numeric())
309+
.collect::<String>()
310+
.parse()
311+
.ok()
312+
})
313+
.collect();
314+
315+
// Compare each part
316+
for i in 0..std::cmp::max(a_parts.len(), b_parts.len()) {
317+
let a_val = a_parts.get(i).unwrap_or(&0);
318+
let b_val = b_parts.get(i).unwrap_or(&0);
319+
match a_val.cmp(b_val) {
320+
Ordering::Equal => continue,
321+
other => return other,
322+
}
323+
}
324+
325+
Ordering::Equal
326+
}
327+
328+
/// Helper function to create a Command with proper environment variables
329+
/// This ensures commands like Claude can find Node.js and other dependencies
330+
pub fn create_command_with_env(program: &str) -> Command {
331+
let mut cmd = Command::new(program);
332+
333+
// Inherit essential environment variables from parent process
334+
for (key, value) in std::env::vars() {
335+
// Pass through PATH and other essential environment variables
336+
if key == "PATH" || key == "HOME" || key == "USER"
337+
|| key == "SHELL" || key == "LANG" || key == "LC_ALL" || key.starts_with("LC_")
338+
|| key == "NODE_PATH" || key == "NVM_DIR" || key == "NVM_BIN"
339+
|| key == "HOMEBREW_PREFIX" || key == "HOMEBREW_CELLAR" {
340+
debug!("Inheriting env var: {}={}", key, value);
341+
cmd.env(&key, &value);
342+
}
343+
}
344+
345+
// Add NVM support if the program is in an NVM directory
346+
if program.contains("/.nvm/versions/node/") {
347+
if let Some(node_bin_dir) = std::path::Path::new(program).parent() {
348+
// Ensure the Node.js bin directory is in PATH
349+
let current_path = std::env::var("PATH").unwrap_or_default();
350+
let node_bin_str = node_bin_dir.to_string_lossy();
351+
if !current_path.contains(&node_bin_str.as_ref()) {
352+
let new_path = format!("{}:{}", node_bin_str, current_path);
353+
debug!("Adding NVM bin directory to PATH: {}", node_bin_str);
354+
cmd.env("PATH", new_path);
355+
}
356+
}
357+
}
358+
359+
cmd
360+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod commands;
55
pub mod sandbox;
66
pub mod checkpoint;
77
pub mod process;
8+
pub mod claude_binary;
89

910
#[cfg_attr(mobile, tauri::mobile_entry_point)]
1011
pub fn run() {

src-tauri/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod commands;
55
mod sandbox;
66
mod checkpoint;
77
mod process;
8+
mod claude_binary;
89

910
use tauri::Manager;
1011
use commands::claude::{

0 commit comments

Comments
 (0)