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+ }
0 commit comments