Skip to content

Commit 41f0d62

Browse files
feat(welcome): claude CLI health check with install hints
claude_cli_status locates the CLI and proves it runs via --version, returning ok/broken/notFound as named states. The welcome view shows the version+path when healthy, and a warning with brew and native install commands (plus the unresolved-shell-env cause) when not.
1 parent db6dca8 commit 41f0d62

6 files changed

Lines changed: 168 additions & 2 deletions

File tree

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)