Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions docs/context/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions src-tauri/src/claude_cli.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/shell_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::ffi::OsString> {
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() {
Expand Down
89 changes: 70 additions & 19 deletions src-tauri/src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = std::path::PathBuf> + '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<String> = 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<Item = std::path::PathBuf> + 'a {
let names: Vec<String> = 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::<Vec<_>>()
.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() {
Expand All @@ -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::<Vec<_>>(),
[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::<Vec<_>>(),
search("python.exe", &path, windows).collect::<Vec<_>>(),
[second.path().join("python.exe")]
);
assert_eq!(on_path("missing", Some(&path)).count(), 0);
assert_eq!(search("missing", &path, windows).count(), 0);
}
}
15 changes: 13 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 <WelcomeScreen />;
}

if (claudeMissing) {
return <InstallClaudeScreen />;
}

const hasSessions = sessionIds.length > 0;

return (
Expand Down
18 changes: 18 additions & 0 deletions src/ipc/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClaudeCli> {
if (!hasTauri()) {
return Promise.resolve({ state: "unknown" });
}
return invoke<ClaudeCli>("find_claude_cli");
}

/** Resolve the user's home directory as an absolute path string. */
export function homeDir(): Promise<string> {
return invoke<string>("home_dir");
Expand Down
Loading