@@ -67,6 +67,47 @@ pub fn which(cmd: &str) -> Option<PathBuf> {
6767 None
6868}
6969
70+ /// Locate the Claude Code CLI: the resolved PATH first, then the well-known
71+ /// install locations PATH misses when the login-shell probe fails or the
72+ /// profile doesn't export them — the native installer (`~/.local/bin`), the
73+ /// legacy local install (`~/.claude/local`), and Homebrew (Apple-silicon and
74+ /// Intel prefixes).
75+ pub fn locate_claude ( ) -> Option < PathBuf > {
76+ if let Some ( found) = which ( "claude" ) {
77+ return Some ( found) ;
78+ }
79+ well_known_claude_paths ( ) . into_iter ( ) . find ( |p| is_executable ( p) )
80+ }
81+
82+ /// Candidate `claude` install locations outside PATH, best-first.
83+ fn well_known_claude_paths ( ) -> Vec < PathBuf > {
84+ let mut candidates = Vec :: new ( ) ;
85+ let home = std:: env:: var_os ( "HOME" ) . or_else ( || std:: env:: var_os ( "USERPROFILE" ) ) ;
86+ if let Some ( home) = home {
87+ let home = PathBuf :: from ( home) ;
88+ candidates. push ( home. join ( ".local/bin/claude" ) ) ;
89+ candidates. push ( home. join ( ".claude/local/claude" ) ) ;
90+ }
91+ candidates. push ( PathBuf :: from ( "/opt/homebrew/bin/claude" ) ) ;
92+ candidates. push ( PathBuf :: from ( "/usr/local/bin/claude" ) ) ;
93+ candidates
94+ }
95+
96+ /// A regular file with an exec bit (symlinks followed, so Homebrew's Cellar
97+ /// links qualify). Non-unix: existence is the best available signal.
98+ #[ cfg( unix) ]
99+ fn is_executable ( path : & std:: path:: Path ) -> bool {
100+ use std:: os:: unix:: fs:: PermissionsExt ;
101+ path. metadata ( )
102+ . map ( |m| m. is_file ( ) && m. permissions ( ) . mode ( ) & 0o111 != 0 )
103+ . unwrap_or ( false )
104+ }
105+
106+ #[ cfg( not( unix) ) ]
107+ fn is_executable ( path : & std:: path:: Path ) -> bool {
108+ path. is_file ( )
109+ }
110+
70111/// Resolve the user's login-shell environment by running:
71112/// `$SHELL -l -i -c 'env -0'` (preferred, NUL-separated)
72113/// `$SHELL -l -c 'env'` (fallback)
@@ -141,4 +182,29 @@ mod tests {
141182 fn which_finds_sh ( ) {
142183 assert ! ( which( "sh" ) . is_some( ) ) ;
143184 }
185+
186+ #[ cfg( unix) ]
187+ #[ test]
188+ fn executable_probe_requires_exec_bit ( ) {
189+ use std:: os:: unix:: fs:: PermissionsExt ;
190+ let tmp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
191+ let plain = tmp. path ( ) . join ( "claude" ) ;
192+ std:: fs:: write ( & plain, "#!/bin/sh\n " ) . expect ( "write" ) ;
193+ std:: fs:: set_permissions ( & plain, std:: fs:: Permissions :: from_mode ( 0o644 ) ) . expect ( "chmod" ) ;
194+ assert ! ( !is_executable( & plain) , "no exec bit" ) ;
195+ std:: fs:: set_permissions ( & plain, std:: fs:: Permissions :: from_mode ( 0o755 ) ) . expect ( "chmod" ) ;
196+ assert ! ( is_executable( & plain) , "exec bit set" ) ;
197+ assert ! ( !is_executable( & tmp. path( ) . join( "missing" ) ) , "missing file" ) ;
198+ }
199+
200+ #[ test]
201+ fn well_known_paths_cover_native_and_homebrew ( ) {
202+ let paths = well_known_claude_paths ( ) ;
203+ let rendered: Vec < String > =
204+ paths. iter ( ) . map ( |p| p. to_string_lossy ( ) . into_owned ( ) ) . collect ( ) ;
205+ assert ! ( rendered. iter( ) . any( |p| p. ends_with( ".local/bin/claude" ) ) , "native installer" ) ;
206+ assert ! ( rendered. iter( ) . any( |p| p. ends_with( ".claude/local/claude" ) ) , "legacy local" ) ;
207+ assert ! ( rendered. contains( & "/opt/homebrew/bin/claude" . to_string( ) ) , "brew arm64" ) ;
208+ assert ! ( rendered. contains( & "/usr/local/bin/claude" . to_string( ) ) , "brew intel" ) ;
209+ }
144210}
0 commit comments