Skip to content

Commit db6dca8

Browse files
feat(cli): locate claude across native and homebrew installs
shell_env::locate_claude falls back from the login-shell PATH to the well-known install locations (~/.local/bin, ~/.claude/local, /opt/homebrew/bin, /usr/local/bin, exec bit required), so a GUI launch with an unresolved shell env still finds a standard install. The headless indexer now resolves through it.
1 parent 575822f commit db6dca8

2 files changed

Lines changed: 73 additions & 6 deletions

File tree

crates/forge-index/src/headless.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ const STDERR_SNIPPET_LEN: usize = 500;
2020
/// extracted result text. The binary resolves via the login-shell PATH so
2121
/// desktop-launched apps find the right install.
2222
pub(crate) async fn run_headless_claude(repo_root: &Path, prompt: &str) -> Result<String> {
23-
// `which` checks the login-shell PATH first, then the process PATH; a `None`
24-
// here is definitive — there is no `claude` to fall back to. Name it rather
25-
// than spawning a bare "claude" that would fail with a confusing ENOENT.
26-
let claude = forge_session::shell_env::which("claude").ok_or_else(|| {
23+
// `locate_claude` checks the login-shell PATH, then the well-known install
24+
// locations (native installer, legacy local, Homebrew); a `None` here is
25+
// definitive — there is no `claude` to fall back to. Name it rather than
26+
// spawning a bare "claude" that would fail with a confusing ENOENT.
27+
let claude = forge_session::shell_env::locate_claude().ok_or_else(|| {
2728
Error::Indexer(
28-
"Claude CLI not found on PATH — install Claude Code (https://claude.com/claude-code) \
29-
and ensure `claude` is on your login shell PATH"
29+
"Claude Code CLI not found — install it (`brew install --cask claude-code` or \
30+
`curl -fsSL https://claude.ai/install.sh | bash`) and reopen the app"
3031
.into(),
3132
)
3233
})?;

crates/forge-session/src/shell_env.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)