Skip to content

Commit e2ec28f

Browse files
Merge pull request #3 from Swofty-Developments/feat/claude-detect
feat: claude CLI detection for native and homebrew installs
2 parents 575822f + 41f0d62 commit e2ec28f

8 files changed

Lines changed: 241 additions & 8 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
}

crates/tauri-app/frontend/src/ipc.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
2525
import type {
2626
AgentEventPayload,
2727
BranchInfo,
28+
ClaudeCliStatus,
2829
DaemonStatus,
2930
DiffByFeature,
3031
Feature,
@@ -177,6 +178,11 @@ export function savePastedImage(dataBase64: string, mime: string): Promise<strin
177178
return invoke("save_pasted_image", { dataBase64, mime });
178179
}
179180

181+
/** Claude CLI health check: locate (PATH + well-known installs) and run --version. */
182+
export function claudeCliStatus(): Promise<ClaudeCliStatus> {
183+
return invoke("claude_cli_status");
184+
}
185+
180186
// ── Terminals (FZ-3) ─────────────────────────────────────────────────────────
181187

182188
/** Spawn a PTY rooted at `cwd` (the active worktree). Returns its terminal id. */

crates/tauri-app/frontend/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export interface DocUpdatedPayload {
8888
detail: string;
8989
}
9090

91+
/** Claude CLI health verdict from `claude_cli_status` (tagged, no conflation). */
92+
export type ClaudeCliStatus =
93+
| { state: "ok"; path: string; version: string; shellEnvResolved: boolean }
94+
| { state: "broken"; path: string; detail: string; shellEnvResolved: boolean }
95+
| { state: "notFound"; shellEnvResolved: boolean };
96+
9197
interface TimelineEventBase {
9298
id: number;
9399
ts: string; // RFC3339

crates/tauri-app/frontend/src/views/Welcome.tsx

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
/* Welcome view — open-repo CTA wired to the dialog plugin + open_repo command. */
1+
/* Welcome view — open-repo CTA wired to the dialog plugin + open_repo command,
2+
* plus the Claude CLI health check (ok / broken / notFound, named states). */
23

3-
import { Show } from "solid-js";
4+
import { Show, Switch, Match, createSignal, onMount } from "solid-js";
45
import { open } from "@tauri-apps/plugin-dialog";
56
import { appStore } from "../stores/app-store";
7+
import { claudeCliStatus } from "../ipc";
8+
import type { ClaudeCliStatus } from "../types";
69

710
export function Welcome() {
811
const { store } = appStore;
12+
const [cli, setCli] = createSignal<ClaudeCliStatus | null>(null);
13+
14+
onMount(() => {
15+
claudeCliStatus()
16+
.then(setCli)
17+
.catch((e) => appStore.pushError(`Claude CLI check failed: ${String(e)}`));
18+
});
919

1020
async function pickRepo() {
1121
const dir = await open({ directory: true, multiple: false, title: "Open a repository" });
@@ -45,6 +55,43 @@ export function Welcome() {
4555
<Show when={store.lastError}>
4656
<div class="welcome-error">{store.lastError}</div>
4757
</Show>
58+
59+
<Switch>
60+
<Match when={cli()?.state === "ok" && cli()}>
61+
{(ok) => {
62+
const s = ok() as Extract<ClaudeCliStatus, { state: "ok" }>;
63+
return <div class="welcome-cli-ok">{s.version} · {s.path}</div>;
64+
}}
65+
</Match>
66+
<Match when={cli()?.state === "notFound" && cli()}>
67+
{(missing) => (
68+
<div class="welcome-cli-warn">
69+
<div class="welcome-cli-warn-title">Claude Code CLI not found</div>
70+
<div>Sessions and indexing need it. Install with either:</div>
71+
<code>brew install --cask claude-code</code>
72+
<code>curl -fsSL https://claude.ai/install.sh | bash</code>
73+
<Show when={!missing().shellEnvResolved}>
74+
<div class="welcome-cli-warn-note">
75+
Your login-shell PATH could not be read, so an existing install outside the
76+
standard locations may also be invisible to CodeForge.
77+
</div>
78+
</Show>
79+
</div>
80+
)}
81+
</Match>
82+
<Match when={cli()?.state === "broken" && cli()}>
83+
{(broken) => {
84+
const s = broken() as Extract<ClaudeCliStatus, { state: "broken" }>;
85+
return (
86+
<div class="welcome-cli-warn">
87+
<div class="welcome-cli-warn-title">Claude Code CLI found but not runnable</div>
88+
<code>{s.path}</code>
89+
<div class="welcome-cli-warn-note">{s.detail}</div>
90+
</div>
91+
);
92+
}}
93+
</Match>
94+
</Switch>
4895
</div>
4996

5097
<style>{`
@@ -121,6 +168,31 @@ export function Welcome() {
121168
word-break: break-word;
122169
user-select: text; -webkit-user-select: text;
123170
}
171+
.welcome-cli-ok {
172+
margin-top: var(--space-5);
173+
font-size: 10px; font-family: var(--font-mono);
174+
color: var(--text-tertiary);
175+
}
176+
.welcome-cli-warn {
177+
margin-top: var(--space-5);
178+
text-align: left;
179+
display: flex; flex-direction: column; gap: 6px;
180+
font-size: 11px;
181+
padding: 10px 12px; border-radius: var(--radius-sm);
182+
background: rgba(var(--amber-rgb), 0.08);
183+
border: 1px solid rgba(var(--amber-rgb), 0.2);
184+
color: var(--text-secondary);
185+
}
186+
.welcome-cli-warn-title { color: var(--amber); font-weight: 600; }
187+
.welcome-cli-warn code {
188+
font-family: var(--font-mono); font-size: 10.5px;
189+
padding: 3px 6px; border-radius: var(--radius-sm);
190+
background: var(--bg-muted); border: 1px solid var(--border);
191+
color: var(--text);
192+
user-select: text; -webkit-user-select: text;
193+
width: fit-content;
194+
}
195+
.welcome-cli-warn-note { color: var(--text-tertiary); word-break: break-word; }
124196
`}</style>
125197
</div>
126198
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//! Claude Code CLI health check surfaced on the welcome screen.
2+
3+
use serde::Serialize;
4+
5+
/// Named CLI states — found-and-runs, found-but-broken, and not-found are
6+
/// distinct verdicts, never conflated. `shell_env_resolved` says whether the
7+
/// login-shell PATH probe worked, so a miss can explain its cause.
8+
#[derive(Debug, Clone, Serialize)]
9+
#[serde(tag = "state", rename_all = "camelCase", rename_all_fields = "camelCase")]
10+
pub enum ClaudeCliStatus {
11+
Ok { path: String, version: String, shell_env_resolved: bool },
12+
Broken { path: String, detail: String, shell_env_resolved: bool },
13+
NotFound { shell_env_resolved: bool },
14+
}
15+
16+
/// Locate `claude` (login-shell PATH, then native-installer / legacy-local /
17+
/// Homebrew locations) and prove the install actually executes by running
18+
/// `claude --version`.
19+
#[tauri::command]
20+
pub async fn claude_cli_status() -> Result<ClaudeCliStatus, String> {
21+
let shell_env_resolved = forge_session::shell_env::is_resolved();
22+
let Some(path) = forge_session::shell_env::locate_claude() else {
23+
return Ok(ClaudeCliStatus::NotFound { shell_env_resolved });
24+
};
25+
let display = path.to_string_lossy().into_owned();
26+
27+
let mut cmd = tokio::process::Command::new(&path);
28+
cmd.arg("--version").stdin(std::process::Stdio::null());
29+
forge_session::shell_env::apply(&mut cmd);
30+
31+
let status = match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await
32+
{
33+
Err(_) => ClaudeCliStatus::Broken {
34+
path: display,
35+
detail: "`claude --version` timed out after 10s".into(),
36+
shell_env_resolved,
37+
},
38+
Ok(Err(e)) => ClaudeCliStatus::Broken {
39+
path: display,
40+
detail: format!("failed to spawn: {e}"),
41+
shell_env_resolved,
42+
},
43+
Ok(Ok(out)) if !out.status.success() => ClaudeCliStatus::Broken {
44+
path: display,
45+
detail: format!(
46+
"`--version` exited with {}: {}",
47+
out.status,
48+
String::from_utf8_lossy(&out.stderr).trim()
49+
),
50+
shell_env_resolved,
51+
},
52+
Ok(Ok(out)) => ClaudeCliStatus::Ok {
53+
path: display,
54+
version: String::from_utf8_lossy(&out.stdout).trim().to_string(),
55+
shell_env_resolved,
56+
},
57+
};
58+
Ok(status)
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
#[test]
66+
fn status_serializes_to_the_tagged_camel_case_shape() {
67+
let ok = ClaudeCliStatus::Ok {
68+
path: "/opt/homebrew/bin/claude".into(),
69+
version: "2.1.0".into(),
70+
shell_env_resolved: true,
71+
};
72+
let json = serde_json::to_string(&ok).unwrap();
73+
assert!(json.contains("\"state\":\"ok\""));
74+
assert!(json.contains("\"shellEnvResolved\":true"));
75+
76+
let missing = ClaudeCliStatus::NotFound { shell_env_resolved: false };
77+
let json = serde_json::to_string(&missing).unwrap();
78+
assert!(json.contains("\"state\":\"notFound\""));
79+
}
80+
}

crates/tauri-app/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! `Result<T, String>` (errors formatted with `format!("{e:#}")` for anyhow
33
//! chains); IDs and paths cross IPC as strings.
44
5+
pub mod cli;
56
pub mod features;
67
pub mod repo;
78
pub mod sessions;

crates/tauri-app/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ fn main() {
6262
.plugin(tauri_plugin_process::init())
6363
.manage(app_state)
6464
.invoke_handler(tauri::generate_handler![
65+
commands::cli::claude_cli_status,
6566
commands::repo::open_repo,
6667
commands::repo::init_repo,
6768
commands::repo::close_repo,

0 commit comments

Comments
 (0)