From 97eeb045a1406efc83d899cfb6fa0902862ed264 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 10:24:53 +0530 Subject: [PATCH] feat(app): show how to install Claude Code when claude is missing At launch Kriyon now checks whether the claude command a new session would run is installed. It looks on the PATH a new session's shell gets, read fresh: the person's shell PATH probed again on macOS, and on Windows the PATH from their environment settings, which the PTY library reads for every new session. So Check again sees a claude installed a minute ago without a restart. Only when nothing is found does the app show an install screen, after the welcome screen. It gives Claude Code's own recommended install command for that computer (from code.claude.com/docs/en/setup), how to confirm it worked, how sign-in happens, and a link to fixing PATH. The active session doesn't start while the screen shows. A PATH that can't be read counts as unknown, never missing, and Continue anyway hides the screen for the run. spawn::on_path only knew the Windows rule, trying each PATHEXT extension. Off Windows a name is now the file itself, and only an executable one counts, as the shell sees it. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 2 +- docs/context/runtime.md | 8 + src-tauri/src/claude_cli.rs | 92 +++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/shell_path.rs | 15 ++ src-tauri/src/spawn.rs | 89 +++++++--- src/App.tsx | 15 +- src/ipc/bridge.ts | 18 ++ src/state/claudeCli.test.ts | 44 +++++ src/state/claudeCli.ts | 42 +++++ src/welcome/InstallClaudeScreen.test.tsx | 56 +++++++ src/welcome/InstallClaudeScreen.tsx | 202 +++++++++++++++++++++++ src/welcome/WelcomeScreen.tsx | 54 +----- src/welcome/screenStyles.ts | 52 ++++++ 14 files changed, 624 insertions(+), 67 deletions(-) create mode 100644 src-tauri/src/claude_cli.rs create mode 100644 src/state/claudeCli.test.ts create mode 100644 src/state/claudeCli.ts create mode 100644 src/welcome/InstallClaudeScreen.test.tsx create mode 100644 src/welcome/InstallClaudeScreen.tsx create mode 100644 src/welcome/screenStyles.ts diff --git a/CONTEXT.md b/CONTEXT.md index 48fe282..3571d49 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,7 +53,7 @@ Session engagement, Subagent, Background shell, Main turn, Waiting for, In-fligh Boot, the per-session runtime, memory and process limits, usage numbers, stopping processes, and updates. -Boot phase, Main session lock, Session runtime, Watch route, Event subscription helper, Status-line, Usage snapshot, Plan limits, Limit reading, Session footprint, Memory budget, Session resources, Resource sweep, Process tree, Sleeping session, Sleep ledger, Usage card, Plan limits view, Session usage view, Tree stop, Surveyor, Process survey, Stop target, Signal gate, Handover, Quit intent, Daemon adoption, Orphan daemon, Detached lease, Stop cost, SWR resource, AppKit exception guard, Update channel, Update feed, Channel step-down, Skipped version, Boot time, Pty prewarm, Limit pace. +Boot phase, Claude CLI check, Main session lock, Session runtime, Watch route, Event subscription helper, Status-line, Usage snapshot, Plan limits, Limit reading, Session footprint, Memory budget, Session resources, Resource sweep, Process tree, Sleeping session, Sleep ledger, Usage card, Plan limits view, Session usage view, Tree stop, Surveyor, Process survey, Stop target, Signal gate, Handover, Quit intent, Daemon adoption, Orphan daemon, Detached lease, Stop cost, SWR resource, AppKit exception guard, Update channel, Update feed, Channel step-down, Skipped version, Boot time, Pty prewarm, Limit pace. ## [App shell and look](docs/context/ui.md) diff --git a/docs/context/runtime.md b/docs/context/runtime.md index 2cc5fd2..7934938 100644 --- a/docs/context/runtime.md +++ b/docs/context/runtime.md @@ -4,6 +4,14 @@ Boot, the per-session runtime, memory and process limits, usage numbers, stoppin Part of the Kriyon glossary. The index and the naming rule are in [`CONTEXT.md`](../../CONTEXT.md). +- **Claude CLI check** (`claude_cli.rs`, `state/claudeCli.ts`) — at launch, whether the + `claude` command a new session would run is installed: looked up on the PATH a new session's + shell gets, read fresh (the [Shell PATH] probed again on macOS; on Windows the PATH from the + person's environment settings), so "Check again" sees a `claude` installed a minute ago. Only + when nothing is found does the app show the install screen, after the welcome screen, with + Claude Code's own recommended install command for that computer; the active session doesn't + start meanwhile. A PATH that can't be read counts as unknown, never missing. Continue anyway + hides the screen for that run. _Avoid_: dependency check, prerequisites. - **Boot phase** (`BootPhase`) — where a [Session] is in launching `claude`: `idle | launching | booting | resumeFailed | live`. `launching` covers the pty opening and the launch being written. A launch from rest enters it (`open`) and always leaves it, to `booting` once diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs new file mode 100644 index 0000000..9a0ffba --- /dev/null +++ b/src-tauri/src/claude_cli.rs @@ -0,0 +1,92 @@ +//! [Claude CLI check] — whether the `claude` command a new session would run +//! is installed, so the app can show how to install it instead of a session +//! that ends in "command not found". +//! +//! It looks where a new session's shell looks: the person's [Shell PATH] read +//! fresh on macOS, and on Windows the PATH from their environment settings, +//! which is what each new session's shell gets. Fresh, so "Check again" sees a +//! `claude` installed a minute ago without restarting Kriyon. + +use std::ffi::OsString; + +use serde::Serialize; + +/// What the [Claude CLI check] found. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum ClaudeCli { + /// `claude` is on the PATH, at `path`. + Found { path: String }, + /// Nothing named `claude` is on the PATH. + Missing, + /// The PATH couldn't be read (a broken shell config). Not reported as + /// missing: telling someone to install what they have would mislead them. + Unknown, +} + +/// The check against `path`, the PATH a new session gets (`None` when it +/// couldn't be read). +#[must_use] +pub fn check(path: Option<&OsString>) -> ClaudeCli { + let Some(path) = path else { + return ClaudeCli::Unknown; + }; + match crate::spawn::on_path("claude", Some(path)).next() { + Some(found) => ClaudeCli::Found { + path: found.to_string_lossy().into_owned(), + }, + None => ClaudeCli::Missing, + } +} + +/// Run the [Claude CLI check]. Reading the shell PATH starts the person's +/// shell, so it runs off the async runtime's threads. +#[tauri::command] +pub async fn find_claude_cli() -> ClaudeCli { + tauri::async_runtime::spawn_blocking(|| check(crate::shell_path::session_path().as_ref())) + .await + .unwrap_or(ClaudeCli::Unknown) +} + +#[cfg(test)] +mod tests { + use super::{check, ClaudeCli}; + + #[test] + fn claude_on_the_path_is_found_where_it_is() { + let dir = tempfile::tempdir().expect("dir"); + let name = if cfg!(windows) { + "claude.exe" + } else { + "claude" + }; + let claude = dir.path().join(name); + std::fs::write(&claude, "").expect("claude"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)) + .expect("chmod"); + } + assert_eq!( + check(Some(&dir.path().as_os_str().to_os_string())), + ClaudeCli::Found { + path: claude.to_string_lossy().into_owned() + } + ); + } + + #[test] + fn no_claude_on_the_path_is_missing() { + let dir = tempfile::tempdir().expect("dir"); + assert_eq!( + check(Some(&dir.path().as_os_str().to_os_string())), + ClaudeCli::Missing + ); + } + + #[test] + fn an_unreadable_path_is_unknown_not_missing() { + assert_eq!(check(None), ClaudeCli::Unknown); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 394cb5f..03ba043 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod activity_store; pub mod appkit_guard; pub mod attachment_cmd; pub mod bootstrap_cmd; +pub mod claude_cli; pub mod commands; pub mod completion_cmd; pub mod daemon_client; @@ -413,6 +414,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ greet, activity_rt::activity_turns, + claude_cli::find_claude_cli, activity_rt::activity_changed_paths, activity_rt::activity_step, activity_rt::activity_stats, diff --git a/src-tauri/src/shell_path.rs b/src-tauri/src/shell_path.rs index 76662b1..47f5f6f 100644 --- a/src-tauri/src/shell_path.rs +++ b/src-tauri/src/shell_path.rs @@ -63,6 +63,21 @@ pub fn user_path() -> Option<&'static str> { .as_deref() } +/// The PATH a new session's shell starts with, read now rather than once per +/// run, so a program installed while Kriyon is open is seen: on macOS the +/// person's shell PATH probed again (`None` when it can't be read); on Windows +/// the PATH from their environment settings, which the PTY library reads for +/// every new session. +pub fn session_path() -> Option { + if cfg!(windows) { + return portable_pty::CommandBuilder::new("claude") + .get_env("PATH") + .map(std::ffi::OsStr::to_os_string); + } + let shell = user_shell(std::env::var("SHELL").ok().as_deref()); + probe(&shell, PROBE_TIMEOUT).map(std::ffi::OsString::from) +} + /// Start reading the shell PATH in the background, so the first git call or /// worktree setup does not wait for the person's shell to start. pub fn warm() { diff --git a/src-tauri/src/spawn.rs b/src-tauri/src/spawn.rs index 5880dce..44b7606 100644 --- a/src-tauri/src/spawn.rs +++ b/src-tauri/src/spawn.rs @@ -19,42 +19,73 @@ pub fn no_console(cmd: &mut std::process::Command) -> &mut std::process::Command cmd } +/// The extensions Windows tries when `PATHEXT` is unset. +const WINDOWS_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD"; + /// Every program file `name` resolves to on `path` (the app's own PATH when -/// `None`), in search order — the one rule for finding a program on Windows. +/// `None`), in search order — the one rule for finding a program. /// -/// A bare name (`pnpm`, `code`) is tried with each `PATHEXT` extension, as -/// `cmd` does: a folder can hold an extensionless shell script beside the -/// `.cmd` that Windows can actually start. A name with an extension -/// (`python.exe`) is tried as written. Callers take the first match, or skip -/// one they cannot use. +/// On Windows a bare name (`pnpm`, `code`) is tried with each `PATHEXT` +/// extension, as `cmd` does: a folder can hold an extensionless shell script +/// beside the `.cmd` that Windows can actually start. A name with an extension +/// (`python.exe`) is tried as written. Elsewhere a name is the file itself, +/// and only one the person may run counts, as their shell would see it. +/// Callers take the first match, or skip one they cannot use. pub fn on_path<'a>( name: &'a str, path: Option<&std::ffi::OsStr>, ) -> impl Iterator + 'a { - let search = path.map_or_else( + let search_path = path.map_or_else( || std::env::var_os("PATH").unwrap_or_default(), std::ffi::OsStr::to_os_string, ); - let names: Vec = if std::path::Path::new(name).extension().is_some() { - vec![name.to_string()] - } else { - std::env::var("PATHEXT") - .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) + let pathext = cfg!(windows) + .then(|| std::env::var("PATHEXT").unwrap_or_else(|_| WINDOWS_PATHEXT.to_string())); + search(name, &search_path, pathext.as_deref()) +} + +/// [`on_path`] with the platform rule passed in: `pathext` is Windows' +/// extension list, `None` off Windows. +fn search<'a>( + name: &'a str, + path: &std::ffi::OsStr, + pathext: Option<&str>, +) -> impl Iterator + 'a { + let names: Vec = match pathext { + Some(exts) if std::path::Path::new(name).extension().is_none() => exts .split(';') .filter(|ext| !ext.is_empty()) .map(|ext| format!("{name}{ext}")) - .collect() + .collect(), + _ => vec![name.to_string()], }; - std::env::split_paths(&search) + let windows = pathext.is_some(); + std::env::split_paths(path) .collect::>() .into_iter() .flat_map(move |dir| names.clone().into_iter().map(move |file| dir.join(file))) - .filter(|candidate| candidate.is_file()) + .filter(move |candidate| candidate.is_file() && (windows || runnable(candidate))) +} + +/// Whether the person may run `file`: any execute bit set. Windows decides by +/// extension instead, so every file counts there. +fn runnable(file: &std::path::Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.metadata() + .is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + let _ = file; + true + } } #[cfg(test)] mod tests { - use super::on_path; + use super::{on_path, search, WINDOWS_PATHEXT}; #[test] fn a_bare_name_finds_its_launcher_not_the_extensionless_script_beside_it() { @@ -63,22 +94,42 @@ mod tests { // Upper case, as PATHEXT spells it: a case-sensitive Linux disk only // finds it written the same way. std::fs::write(dir.path().join("code.CMD"), "@echo off").expect("launcher"); - let found: Vec<_> = on_path("code", Some(dir.path().as_os_str())) + let found: Vec<_> = search("code", dir.path().as_os_str(), Some(WINDOWS_PATHEXT)) .map(|p| p.file_name().unwrap().to_string_lossy().to_lowercase()) .collect(); assert_eq!(found, ["code.cmd"]); } + /// macOS has no PATHEXT: `claude` is the file `claude`, and only one the + /// person may run counts, as the shell would see it. + #[test] + #[cfg(unix)] + fn on_unix_a_bare_name_is_the_executable_file_itself() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("dir"); + let claude = dir.path().join("claude"); + std::fs::write(&claude, "#!/bin/sh").expect("claude"); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + std::fs::write(dir.path().join("notes"), "").expect("plain file"); + assert_eq!( + on_path("claude", Some(dir.path().as_os_str())).collect::>(), + [claude] + ); + assert_eq!(on_path("notes", Some(dir.path().as_os_str())).count(), 0); + } + #[test] fn a_name_with_an_extension_is_found_as_written_in_path_order() { let first = tempfile::tempdir().expect("first"); let second = tempfile::tempdir().expect("second"); std::fs::write(second.path().join("python.exe"), "").expect("exe"); let path = std::env::join_paths([first.path(), second.path()]).expect("path"); + let windows = Some(WINDOWS_PATHEXT); assert_eq!( - on_path("python.exe", Some(&path)).collect::>(), + search("python.exe", &path, windows).collect::>(), [second.path().join("python.exe")] ); - assert_eq!(on_path("missing", Some(&path)).count(), 0); + assert_eq!(search("missing", &path, windows).count(), 0); } } diff --git a/src/App.tsx b/src/App.tsx index c9249df..b236d6e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { } from "./state/actions.ts"; import { installSessionListeners } from "./state/backendListeners.ts"; import { installBridgeMirrors } from "./state/bridgeMirrors.ts"; +import { needsClaudeInstall, useClaudeCli } from "./state/claudeCli.ts"; import { installCommandCenterListeners } from "./state/commandCenterListeners.ts"; import { setHomeFolder } from "./state/homeRelative.ts"; import { useIntegration } from "./state/integration.ts"; @@ -63,6 +64,7 @@ import { SessionLoadingOverlay } from "./ui/SessionLoadingOverlay.tsx"; import { SessionResumeFailedOverlay } from "./ui/SessionResumeFailedOverlay.tsx"; import { installStrayCaretGuard } from "./ui/strayCaret.ts"; import { showToast } from "./ui/Toast.tsx"; +import { InstallClaudeScreen } from "./welcome/InstallClaudeScreen.tsx"; import { REFUSED_TOAST } from "./welcome/integrationCopy.ts"; import { WelcomeScreen } from "./welcome/WelcomeScreen.tsx"; @@ -249,6 +251,7 @@ function App() { const themeId = useLayout((state) => state.layout.themeId); // Nothing starts — no session, no Claude Code change — until the welcome // screen's consent (the [Integration]). + const claudeMissing = useClaudeCli(needsClaudeInstall); const consented = useIntegration( (state) => state.status?.prefs.consented === true ); @@ -288,6 +291,8 @@ function App() { }) ); ignore(useIntegration.getState().load()); + // The [Claude CLI check]: the install screen shows only if it finds no `claude`. + ignore(useClaudeCli.getState().check()); const stopIntegrationRefused = onEvent(onIntegrationRefused, (status) => { useIntegration.getState().replace(status); showToast(REFUSED_TOAST, "error"); @@ -353,15 +358,21 @@ function App() { // switch-away: the boot overlay and its timeout belong to the session, not to // this effect's lifetime (see launchActiveSession). useEffect(() => { - if (activeSessionId && consented) { + // Not while the install screen shows: with no `claude`, the launch line + // would only print "command not found". Check again launches it. + if (activeSessionId && consented && !claudeMissing) { ignore(launchActiveSession(activeSessionId)); } - }, [activeSessionId, consented]); + }, [activeSessionId, consented, claudeMissing]); if (!consented) { return ; } + if (claudeMissing) { + return ; + } + const hasSessions = sessionIds.length > 0; return ( diff --git a/src/ipc/bridge.ts b/src/ipc/bridge.ts index 1f2ecb7..f004a2f 100644 --- a/src/ipc/bridge.ts +++ b/src/ipc/bridge.ts @@ -1378,6 +1378,24 @@ export function setAutoSleep( return invoke("set_auto_sleep", { enabled, afterSecs }); } +/** + * What the [Claude CLI check] found: `claude` on the PATH a new session gets, + * nothing there, or a PATH that couldn't be read. Mirrors Rust `ClaudeCli`. + */ +export type ClaudeCli = + | { state: "found"; path: string } + | { state: "missing" } + | { state: "unknown" }; + +/** Run the [Claude CLI check]. Outside the Tauri webview there is no PATH to + * read, so it reports unknown rather than ask for an install. */ +export function findClaudeCli(): Promise { + if (!hasTauri()) { + return Promise.resolve({ state: "unknown" }); + } + return invoke("find_claude_cli"); +} + /** Resolve the user's home directory as an absolute path string. */ export function homeDir(): Promise { return invoke("home_dir"); diff --git a/src/state/claudeCli.test.ts b/src/state/claudeCli.test.ts new file mode 100644 index 0000000..5b4d9b4 --- /dev/null +++ b/src/state/claudeCli.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { findClaudeCli } from "../ipc/bridge.ts"; +import { needsClaudeInstall, useClaudeCli } from "./claudeCli.ts"; + +vi.mock("../ipc/bridge.ts", async (importOriginal) => ({ + ...(await importOriginal()), + findClaudeCli: vi.fn(), +})); + +beforeEach(() => { + useClaudeCli.setState({ checking: false, result: null, skipped: false }); +}); + +test("asks for an install only once the check has found no claude", async () => { + expect(needsClaudeInstall(useClaudeCli.getState())).toBe(false); + vi.mocked(findClaudeCli).mockResolvedValue({ state: "missing" }); + await useClaudeCli.getState().check(); + expect(needsClaudeInstall(useClaudeCli.getState())).toBe(true); +}); + +test("a found claude, or a PATH that couldn't be read, needs no install", async () => { + vi.mocked(findClaudeCli).mockResolvedValue({ + path: "/Users/me/.local/bin/claude", + state: "found", + }); + await useClaudeCli.getState().check(); + expect(needsClaudeInstall(useClaudeCli.getState())).toBe(false); + vi.mocked(findClaudeCli).mockResolvedValue({ state: "unknown" }); + await useClaudeCli.getState().check(); + expect(needsClaudeInstall(useClaudeCli.getState())).toBe(false); +}); + +test("a failed check is unknown, not missing", async () => { + vi.mocked(findClaudeCli).mockRejectedValue(new Error("no runtime")); + await useClaudeCli.getState().check(); + expect(useClaudeCli.getState().result).toEqual({ state: "unknown" }); +}); + +test("continue anyway hides the install screen for this run", async () => { + vi.mocked(findClaudeCli).mockResolvedValue({ state: "missing" }); + await useClaudeCli.getState().check(); + useClaudeCli.getState().skip(); + expect(needsClaudeInstall(useClaudeCli.getState())).toBe(false); +}); diff --git a/src/state/claudeCli.ts b/src/state/claudeCli.ts new file mode 100644 index 0000000..7cc0edb --- /dev/null +++ b/src/state/claudeCli.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; +import { type ClaudeCli, findClaudeCli } from "../ipc/bridge.ts"; + +/** + * The [Claude CLI check] as the app holds it. `result` is `null` until the + * first check returns; the app keeps working meanwhile, since `claude` is + * almost always there. + */ +interface ClaudeCliStore { + check: () => Promise; + /** A check is running (the shell PATH takes a moment to read). */ + checking: boolean; + result: ClaudeCli | null; + /** Continue anyway was chosen: no install screen for the rest of this run. */ + skip: () => void; + skipped: boolean; +} + +export const useClaudeCli = create((set) => ({ + check: async () => { + set({ checking: true }); + let result: ClaudeCli; + try { + result = await findClaudeCli(); + } catch { + // A check that failed tells nothing; asking for an install could be wrong. + result = { state: "unknown" }; + } + set({ checking: false, result }); + }, + checking: false, + result: null, + skip: () => set({ skipped: true }), + skipped: false, +})); + +/** Whether to show the install screen: only once the check found no `claude`. */ +export function needsClaudeInstall( + state: Pick +): boolean { + return state.result?.state === "missing" && !state.skipped; +} diff --git a/src/welcome/InstallClaudeScreen.test.tsx b/src/welcome/InstallClaudeScreen.test.tsx new file mode 100644 index 0000000..b73eb52 --- /dev/null +++ b/src/welcome/InstallClaudeScreen.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { findClaudeCli } from "../ipc/bridge.ts"; +import { useClaudeCli } from "../state/claudeCli.ts"; +import { pretendPlatform } from "../test/platform.ts"; +import { InstallClaudeScreen } from "./InstallClaudeScreen.tsx"; + +vi.mock("../ipc/bridge.ts", async (importOriginal) => ({ + ...(await importOriginal()), + findClaudeCli: vi.fn(), +})); + +const PS_INSTALL = /install\.ps1/; +const SH_INSTALL = /install\.sh/; +const OPEN_TERMINAL = /Open Terminal/; +const OPEN_POWERSHELL = /Open PowerShell/; + +let restorePlatform: () => void = () => undefined; +afterEach(() => restorePlatform()); +beforeEach(() => { + useClaudeCli.setState({ + checking: false, + result: { state: "missing" }, + skipped: false, + }); +}); + +test("on a Mac it gives the official Terminal install command", () => { + restorePlatform = pretendPlatform("mac"); + render(); + expect( + screen.getByText("curl -fsSL https://claude.ai/install.sh | bash") + ).toBeInTheDocument(); + expect(screen.queryByText(PS_INSTALL)).not.toBeInTheDocument(); + expect(screen.getByText(OPEN_TERMINAL)).toBeInTheDocument(); +}); + +test("on Windows it gives the official PowerShell install command", () => { + restorePlatform = pretendPlatform("windows"); + render(); + expect( + screen.getByText("irm https://claude.ai/install.ps1 | iex") + ).toBeInTheDocument(); + expect(screen.queryByText(SH_INSTALL)).not.toBeInTheDocument(); + expect(screen.getByText(OPEN_POWERSHELL)).toBeInTheDocument(); +}); + +test("check again runs the check, and continue anyway skips it", () => { + restorePlatform = pretendPlatform("mac"); + vi.mocked(findClaudeCli).mockResolvedValue({ state: "missing" }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + expect(findClaudeCli).toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Continue anyway" })); + expect(useClaudeCli.getState().skipped).toBe(true); +}); diff --git a/src/welcome/InstallClaudeScreen.tsx b/src/welcome/InstallClaudeScreen.tsx new file mode 100644 index 0000000..bfabe88 --- /dev/null +++ b/src/welcome/InstallClaudeScreen.tsx @@ -0,0 +1,202 @@ +import type { CSSProperties, ReactNode } from "react"; +import { openExternalUrl } from "../ipc/bridge.ts"; +import { isMacPlatform, thisComputer } from "../platform/os.ts"; +import { useClaudeCli } from "../state/claudeCli.ts"; +import { button, card } from "../theme/recipes.ts"; +import { tokens } from "../theme/tokens.ts"; +import { copyText } from "../ui/copyText.ts"; +import { + actionsStyle, + pageStyle, + panelStyle, + textStyle, + titleStyle, +} from "./screenStyles.ts"; + +/** + * The install screen of the [Claude CLI check], shown only when no `claude` + * is on the PATH a new session gets. The steps are Claude Code's own + * recommended native install (code.claude.com/docs/en/setup), for this + * computer only. + */ + +/** Claude Code's recommended install, per platform, as its docs give it. */ +const INSTALL = { + mac: { + command: "curl -fsSL https://claude.ai/install.sh | bash", + terminal: "Terminal", + }, + windows: { + command: "irm https://claude.ai/install.ps1 | iex", + terminal: "PowerShell", + }, +}; + +const FIX_PATH_URL = + "https://code.claude.com/docs/en/troubleshoot-install#command-not-found-claude-after-installation"; +const GIT_FOR_WINDOWS_URL = "https://git-scm.com/downloads/win"; + +const stepStyle: CSSProperties = { + ...card(), + display: "flex", + flexDirection: "column", + gap: tokens.space.sm, + padding: tokens.space.md, +}; + +const stepTitleStyle: CSSProperties = { + color: tokens.color.ink.primary, + fontSize: "13px", + fontWeight: 600, + margin: 0, +}; + +const commandRowStyle: CSSProperties = { + alignItems: "center", + display: "flex", + gap: tokens.space.sm, +}; + +const commandStyle: CSSProperties = { + background: tokens.color.chrome.window, + borderRadius: tokens.radius.md, + color: tokens.color.ink.bright, + flex: 1, + fontFamily: tokens.font.mono, + fontSize: "12px", + overflowX: "auto", + padding: `${tokens.space.sm} ${tokens.space.md}`, + whiteSpace: "nowrap", +}; + +const inlineCodeStyle: CSSProperties = { + fontFamily: tokens.font.mono, + fontSize: "12px", +}; + +const linkStyle: CSSProperties = { + background: "none", + border: "none", + color: tokens.color.accent, + cursor: "pointer", + font: "inherit", + padding: 0, + textDecoration: "underline", +}; + +function Step({ children, title }: { children: ReactNode; title: string }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Link({ children, url }: { children: ReactNode; url: string }) { + return ( + + ); +} + +export function InstallClaudeScreen() { + const checking = useClaudeCli((state) => state.checking); + const check = useClaudeCli((state) => state.check); + const skip = useClaudeCli((state) => state.skip); + const mac = isMacPlatform(); + const install = mac ? INSTALL.mac : INSTALL.windows; + + return ( +
+
+

+ Install Claude Code +

+

+ Kriyon runs your own Claude Code, and the{" "} + claude command isn't on{" "} + {thisComputer()} yet. Install it, then check again. +

+ + +
+ {install.command} + +
+

+ It shows no progress while it downloads. When it finishes, open a + new {install.terminal} window and run{" "} + claude --version: a working + install prints a version number. +

+ {mac ? null : ( +

+ Git for Windows is + recommended, so Claude Code can use its Bash tool. +

+ )} +
+ + +

+ Your first Kriyon session starts{" "} + claude, which opens your + browser to log in. Claude Code needs a Pro, Max, Team, Enterprise or + Console account. +

+
+ +

+ Installed, but claude still isn't + found? Fix your PATH. +

+ +
+ + +
+
+
+ ); +} diff --git a/src/welcome/WelcomeScreen.tsx b/src/welcome/WelcomeScreen.tsx index cb32782..60509db 100644 --- a/src/welcome/WelcomeScreen.tsx +++ b/src/welcome/WelcomeScreen.tsx @@ -10,10 +10,18 @@ import { thisComputer } from "../platform/os.ts"; import { ON_OFF_OPTIONS } from "../settings/settingsOptions.ts"; import { useIntegration } from "../state/integration.ts"; import { type SessionView, useLayout } from "../state/layout.ts"; -import { button, card, overlayPanel } from "../theme/recipes.ts"; +import { button, card } from "../theme/recipes.ts"; import { tokens } from "../theme/tokens.ts"; import { Segmented } from "../ui/Segmented.tsx"; import { companionHint, hooksHint, liveUsageHint } from "./integrationCopy.ts"; +import { + actionsStyle, + errorStyle, + pageStyle, + panelStyle, + textStyle, + titleStyle, +} from "./screenStyles.ts"; /** * The first-run consent screen: plainly what Kriyon adds to Claude Code, before @@ -21,39 +29,6 @@ import { companionHint, hooksHint, liveUsageHint } from "./integrationCopy.ts"; * quits, leaving Claude Code exactly as it was. */ -// The panel centres itself with auto margins, not align-items: a flex-centred -// child taller than the window overflows upward, past where it can scroll. -const pageStyle: CSSProperties = { - background: tokens.color.chrome.window, - display: "flex", - height: "100%", - overflowY: "auto", - padding: tokens.space.xl, -}; - -const panelStyle: CSSProperties = { - ...overlayPanel(), - gap: tokens.space.lg, - margin: "auto", - maxWidth: "560px", - padding: tokens.space.xl, - width: "100%", -}; - -const titleStyle: CSSProperties = { - color: tokens.color.ink.bright, - fontSize: "18px", - fontWeight: 700, - margin: 0, -}; - -const textStyle: CSSProperties = { - color: tokens.color.ink.muted, - fontSize: "12.5px", - lineHeight: 1.55, - margin: 0, -}; - const itemStyle: CSSProperties = { ...card(), display: "flex", @@ -81,17 +56,6 @@ const tagStyle: CSSProperties = { whiteSpace: "nowrap", }; -const actionsStyle: CSSProperties = { - display: "flex", - gap: tokens.space.sm, - justifyContent: "flex-end", -}; - -const errorStyle: CSSProperties = { - ...textStyle, - color: tokens.color.status.error, -}; - /** One thing Kriyon adds: its name, its switch (or why there is none), what it does. */ function Item({ children, diff --git a/src/welcome/screenStyles.ts b/src/welcome/screenStyles.ts new file mode 100644 index 0000000..caba155 --- /dev/null +++ b/src/welcome/screenStyles.ts @@ -0,0 +1,52 @@ +import type { CSSProperties } from "react"; +import { overlayPanel } from "../theme/recipes.ts"; +import { tokens } from "../theme/tokens.ts"; + +/** + * The look the full-window setup screens share — the welcome screen and the + * install screen of the [Claude CLI check]: one panel on the window colour. + */ + +// The panel centres itself with auto margins, not align-items: a flex-centred +// child taller than the window overflows upward, past where it can scroll. +export const pageStyle: CSSProperties = { + background: tokens.color.chrome.window, + display: "flex", + height: "100%", + overflowY: "auto", + padding: tokens.space.xl, +}; + +export const panelStyle: CSSProperties = { + ...overlayPanel(), + gap: tokens.space.lg, + margin: "auto", + maxWidth: "560px", + padding: tokens.space.xl, + width: "100%", +}; + +export const titleStyle: CSSProperties = { + color: tokens.color.ink.bright, + fontSize: "18px", + fontWeight: 700, + margin: 0, +}; + +export const textStyle: CSSProperties = { + color: tokens.color.ink.muted, + fontSize: "12.5px", + lineHeight: 1.55, + margin: 0, +}; + +export const actionsStyle: CSSProperties = { + display: "flex", + gap: tokens.space.sm, + justifyContent: "flex-end", +}; + +export const errorStyle: CSSProperties = { + ...textStyle, + color: tokens.color.status.error, +};