diff --git a/scripts/screenshot/specs/onboarding-prerequisites.spec.tsx b/scripts/screenshot/specs/onboarding-prerequisites.spec.tsx
new file mode 100644
index 000000000..f39138d7d
--- /dev/null
+++ b/scripts/screenshot/specs/onboarding-prerequisites.spec.tsx
@@ -0,0 +1,102 @@
+import * as React from "react";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import userEvent from "@testing-library/user-event";
+import { it, onTestFinished } from "vitest";
+import { Dashboard } from "../../../src/components/Dashboard";
+import { render, screen, within } from "../../../test/test-utils";
+import { createTestRepo, openRepo } from "../../../test/utils";
+import { captureDocument } from "../capture";
+
+const FAKE_TOOL = path.resolve(
+ __dirname,
+ "../../../test/fake-agent/fake-agent.sh",
+);
+
+/**
+ * Points the backend's tool check at a directory holding only `tools` (plus a
+ * link to the system `which`, which the lookup runs from the same PATH).
+ */
+function useSearchPathWith(tools: string[]) {
+ const searchDir = fs.mkdtempSync(path.join(os.tmpdir(), "treq-prereq-"));
+ const which = (process.env.PATH ?? "")
+ .split(path.delimiter)
+ .map((dir) => path.join(dir, "which"))
+ .find((candidate) => fs.existsSync(candidate));
+ if (!which) throw new Error("`which` must be on PATH");
+ fs.symlinkSync(which, path.join(searchDir, "which"));
+ for (const tool of tools) {
+ fs.symlinkSync(FAKE_TOOL, path.join(searchDir, tool));
+ }
+ process.env.TREQ_TEST_PREREQ_PATH = searchDir;
+ onTestFinished(() => {
+ delete process.env.TREQ_TEST_PREREQ_PATH;
+ fs.rmSync(searchDir, { recursive: true, force: true });
+ });
+}
+
+const findChecklist = () =>
+ screen.findByRole("region", { name: "Setup checklist" });
+
+it("captures the setup checklist with Git and one agent installed", async () => {
+ useSearchPathWith(["git", "claude"]);
+ window.history.replaceState({}, "", "/");
+ render();
+
+ const checklist = await findChecklist();
+ await within(
+ await within(checklist).findByRole("listitem", { name: "Git" }),
+ ).findByText("Installed");
+ await within(checklist).findByRole("button", { name: "Install Codex" });
+
+ await captureDocument(document, {
+ name: "onboarding-prerequisites-01-checklist",
+ expectations: [
+ "Below the 'Open Repository' button, a 'Before you start' list shows Git and Claude Code with green checks and 'Installed'.",
+ "Codex, Cursor Agent, GitHub Copilot CLI and GitHub CLI each show an 'Install' link instead of 'Installed'.",
+ "The rows sit under 'Required', 'Agent CLI (install at least one)' and 'Optional' headings, and GitHub CLI notes 'Needed for pull requests and issues'.",
+ ],
+ });
+});
+
+it("captures the tool checklist on the Application settings tab", async () => {
+ useSearchPathWith(["git", "codex", "gh"]);
+ const { repoPath } = createTestRepo(false);
+ openRepo(repoPath);
+ const user = userEvent.setup();
+ render();
+
+ await user.click(await screen.findByLabelText("Settings"));
+ await user.click(await screen.findByRole("tab", { name: /application/i }));
+ const checklist = await findChecklist();
+ await within(
+ await within(checklist).findByRole("listitem", { name: "Codex" }),
+ ).findByText("Installed");
+
+ await captureDocument(document, {
+ name: "onboarding-prerequisites-03-settings",
+ viewport: { width: 1440, height: 1100 },
+ expectations: [
+ "The Application settings tab shows a 'Command-line tools' checklist below the Default Agent picker.",
+ "Git, Codex and GitHub CLI show green checks with 'Installed'; the other agents show 'Install' links.",
+ ],
+ });
+});
+
+it("captures the setup checklist with nothing installed", async () => {
+ useSearchPathWith([]);
+ window.history.replaceState({}, "", "/");
+ render();
+
+ const checklist = await findChecklist();
+ await within(checklist).findByRole("button", { name: "Install Git" });
+
+ await captureDocument(document, {
+ name: "onboarding-prerequisites-02-nothing-installed",
+ expectations: [
+ "Git shows a red X icon and an 'Install' link.",
+ "The 'Agent CLI (install at least one)' heading is red, and every agent row shows an 'Install' link.",
+ ],
+ });
+});
diff --git a/src-tauri/src/binary_paths.rs b/src-tauri/src/binary_paths.rs
index c5a948725..5271d7ba4 100644
--- a/src-tauri/src/binary_paths.rs
+++ b/src-tauri/src/binary_paths.rs
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::env;
+use std::ffi::OsStr;
use std::process::Command;
use std::sync::OnceLock;
@@ -30,21 +31,7 @@ pub fn get_extended_path() -> String {
return all_paths.join(";");
}
- // Common binary locations to add
- let additional_paths = [
- "/opt/homebrew/bin", // macOS ARM Homebrew
- "/usr/local/bin", // macOS Intel Homebrew, common
- "~/.cargo/bin", // Rust tools
- "/usr/bin", // System binaries
- "/bin", // System binaries
- ];
-
- // Expand ~ to home directory
- let home = env::var("HOME").unwrap_or_default();
- let expanded_paths: Vec = additional_paths
- .iter()
- .map(|p| p.replace('~', &home))
- .collect();
+ let expanded_paths = unix_extra_bin_dirs(&env::var("HOME").unwrap_or_default());
// Combine existing PATH with additional paths (deduplicating)
let mut all_paths: Vec = current_path
@@ -73,6 +60,22 @@ pub fn get_extended_path() -> String {
all_paths.join(":")
}
+/// Install locations searched after PATH on Unix, with `~` expanded to
+/// `home`. An app opened from the macOS Finder gets a PATH that misses them.
+fn unix_extra_bin_dirs(home: &str) -> Vec {
+ [
+ "/opt/homebrew/bin", // macOS ARM Homebrew
+ "/usr/local/bin", // macOS Intel Homebrew, common
+ "~/.cargo/bin", // Rust tools
+ "~/.local/bin", // Claude Code and Cursor Agent installers
+ "/usr/bin", // System binaries
+ "/bin", // System binaries
+ ]
+ .iter()
+ .map(|p| p.replace('~', home))
+ .collect()
+}
+
/// Get the directory containing the running treq executable
pub fn get_exe_dir() -> Option {
let exe_path = std::env::current_exe().ok()?;
@@ -91,37 +94,28 @@ pub fn detect_binary(name: &str) -> Option {
// `which` isn't available by default on Windows, and `get_extended_path`
// joins entries with `:` (a Unix path separator), so neither applies here.
// `where` uses the process's own PATH and is present on all supported Windows versions.
- let output = Command::new("where").arg(name).output().ok()?;
-
- if output.status.success() {
- let stdout = String::from_utf8(output.stdout).ok()?;
- let path = stdout.lines().next().unwrap_or("").trim().to_string();
- if !path.is_empty() {
- return Some(path);
- }
- }
-
- return None;
+ return first_output_line(Command::new("where").arg(name));
}
- let extended_path = get_extended_path();
+ detect_binary_in(name, get_extended_path())
+}
- // Try using `which` with extended PATH
- let output = Command::new("which")
- .arg(name)
- .env("PATH", extended_path)
- .output()
- .ok()?;
+/// Finds `name` on `search_path` (a PATH-style list) and nowhere else, with
+/// the same `which` (Unix) or `where` (Windows) lookup as [`detect_binary`].
+pub fn detect_binary_in(name: &str, search_path: impl AsRef) -> Option {
+ let finder = if cfg!(windows) { "where" } else { "which" };
+ first_output_line(Command::new(finder).arg(name).env("PATH", search_path))
+}
- if output.status.success() {
- let path = String::from_utf8(output.stdout).ok()?;
- let path = path.trim().to_string();
- if !path.is_empty() {
- return Some(path);
- }
+/// The first line a successful lookup printed: the binary's path.
+fn first_output_line(command: &mut Command) -> Option {
+ let output = command.output().ok()?;
+ if !output.status.success() {
+ return None;
}
-
- None
+ let stdout = String::from_utf8(output.stdout).ok()?;
+ let path = stdout.lines().next()?.trim();
+ (!path.is_empty()).then(|| path.to_string())
}
/// Initialize binary paths cache with detected paths
@@ -162,3 +156,15 @@ pub fn init_editor_apps_cache(apps: HashMap) {
pub fn get_editor_apps_cache() -> Option> {
EDITOR_APPS_CACHE.get().cloned()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn extra_bin_dirs_include_user_local_bin() {
+ let dirs = unix_extra_bin_dirs("/home/dev");
+
+ assert!(dirs.contains(&"/home/dev/.local/bin".to_string()));
+ }
+}
diff --git a/src-tauri/src/commands/binary.rs b/src-tauri/src/commands/binary.rs
index 3aef37417..f0ba794f3 100644
--- a/src-tauri/src/commands/binary.rs
+++ b/src-tauri/src/commands/binary.rs
@@ -88,6 +88,14 @@ pub fn detect_editor_apps(state: State<'_, AppState>) -> Result Result, String> {
+ tauri::async_runtime::spawn_blocking(crate::core::check_prerequisites)
+ .await
+ .map_err(|e| format!("Failed to join check_prerequisites task: {e}"))
+}
+
/// Return the directory containing the running treq binary
#[tauri::command]
pub fn get_treq_bin_dir() -> Result {
diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs
index 547b964f7..f95090ec8 100644
--- a/src-tauri/src/core/mod.rs
+++ b/src-tauri/src/core/mod.rs
@@ -12,6 +12,7 @@ pub mod feature_preview;
pub mod files;
pub mod github_issues;
pub mod idempotency_store;
+pub mod prerequisites;
pub mod pty_remote_supervisor;
pub mod remote;
pub mod remote_control_plane;
@@ -41,6 +42,7 @@ pub use checks::*;
pub use checks_logs::*;
pub use commits::*;
pub use files::*;
+pub use prerequisites::*;
pub use repo::*;
pub use resolve::*;
pub use skills::*;
diff --git a/src-tauri/src/core/prerequisites.rs b/src-tauri/src/core/prerequisites.rs
new file mode 100644
index 000000000..2b8810b8c
--- /dev/null
+++ b/src-tauri/src/core/prerequisites.rs
@@ -0,0 +1,120 @@
+//! Reports which command-line tools Treq relies on are installed, for the
+//! first-run checklist.
+
+use serde::Serialize;
+
+/// Executables the checklist reports on, in display order.
+pub const PREREQUISITE_BINARIES: [&str; 6] =
+ ["git", "claude", "codex", "cursor-agent", "copilot", "gh"];
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct PrerequisiteStatus {
+ pub binary: String,
+ pub installed: bool,
+}
+
+/// Looks up every prerequisite on the user's PATH and the usual install
+/// directories, the same way Treq finds `gh` and the agent CLIs.
+pub fn check_prerequisites() -> Vec {
+ #[cfg(feature = "tauri-test")]
+ if let Some(search_path) = std::env::var_os("TREQ_TEST_PREREQ_PATH") {
+ return check_prerequisites_with(|name| {
+ crate::binary_paths::detect_binary_in(name, &search_path)
+ });
+ }
+ check_prerequisites_with(crate::binary_paths::detect_binary)
+}
+
+/// Looks up every prerequisite with `find`, which returns a tool's path.
+pub fn check_prerequisites_with(find: impl Fn(&str) -> Option) -> Vec {
+ PREREQUISITE_BINARIES
+ .iter()
+ .map(|binary| PrerequisiteStatus {
+ binary: binary.to_string(),
+ installed: find(binary).is_some(),
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::binary_paths::detect_binary_in;
+ use tempfile::TempDir;
+
+ fn installed(statuses: &[PrerequisiteStatus], binary: &str) -> bool {
+ statuses
+ .iter()
+ .find(|status| status.binary == binary)
+ .unwrap_or_else(|| panic!("no status reported for {binary}"))
+ .installed
+ }
+
+ /// A whole search PATH, holding a link to the `which` the lookup runs.
+ #[cfg(unix)]
+ fn search_dir() -> TempDir {
+ let dir = TempDir::new().expect("temp dir");
+ let which = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
+ .map(|entry| entry.join("which"))
+ .find(|candidate| candidate.is_file())
+ .expect("`which` must be on PATH");
+ std::os::unix::fs::symlink(which, dir.path().join("which")).expect("link which");
+ dir
+ }
+
+ #[cfg(unix)]
+ fn add_tool(dir: &TempDir, name: &str, mode: u32) {
+ use std::os::unix::fs::PermissionsExt;
+ let path = dir.path().join(name);
+ std::fs::write(&path, "#!/bin/sh\n").expect("write fake tool");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn reports_tools_on_the_search_path_as_installed() {
+ let dir = search_dir();
+ add_tool(&dir, "claude", 0o755);
+ add_tool(&dir, "gh", 0o755);
+
+ let statuses = check_prerequisites_with(|name| detect_binary_in(name, dir.path()));
+
+ assert!(installed(&statuses, "claude"));
+ assert!(installed(&statuses, "gh"));
+ assert!(!installed(&statuses, "git"));
+ assert!(!installed(&statuses, "codex"));
+ assert!(!installed(&statuses, "cursor-agent"));
+ assert!(!installed(&statuses, "copilot"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn reports_non_executable_files_as_missing() {
+ let dir = search_dir();
+ add_tool(&dir, "git", 0o644);
+
+ let statuses = check_prerequisites_with(|name| detect_binary_in(name, dir.path()));
+
+ assert!(!installed(&statuses, "git"));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn reports_exe_files_on_the_search_path_as_installed() {
+ let dir = TempDir::new().expect("temp dir");
+ std::fs::write(dir.path().join("codex.exe"), "").expect("write fake tool");
+
+ let statuses = check_prerequisites_with(|name| detect_binary_in(name, dir.path()));
+
+ assert!(installed(&statuses, "codex"));
+ assert!(!installed(&statuses, "git"));
+ }
+
+ #[test]
+ fn reports_every_tool_in_checklist_order() {
+ let statuses = check_prerequisites_with(|_| None);
+
+ let binaries: Vec<&str> = statuses.iter().map(|s| s.binary.as_str()).collect();
+ assert_eq!(binaries, PREREQUISITE_BINARIES);
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index ca6860118..75d3385d0 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -731,6 +731,7 @@ pub fn run() {
commands::acknowledge_agent_dispatch,
commands::detect_editor_apps,
commands::get_treq_bin_dir,
+ commands::check_prerequisites,
commands::get_workspaces,
commands::create_workspace,
commands::open_or_create_workspace_from_pr,
diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx
index 0d83d1cb6..8c685c109 100644
--- a/src/components/Onboarding.tsx
+++ b/src/components/Onboarding.tsx
@@ -1,4 +1,5 @@
import { FolderOpen, Server } from "lucide-react";
+import { PrerequisiteChecklist } from "./PrerequisiteChecklist";
import { Button } from "./ui/button";
import {
Card,
@@ -43,6 +44,9 @@ export const Onboarding: React.FC = ({
)}
+
+
+
);
diff --git a/src/components/PrerequisiteChecklist.tsx b/src/components/PrerequisiteChecklist.tsx
new file mode 100644
index 000000000..f91b3d425
--- /dev/null
+++ b/src/components/PrerequisiteChecklist.tsx
@@ -0,0 +1,131 @@
+import { openUrl } from "@tauri-apps/plugin-opener";
+import {
+ CheckCircle2,
+ Circle,
+ ExternalLink,
+ RefreshCw,
+ XCircle,
+} from "lucide-react";
+import useSWR from "swr";
+import { checkPrerequisites } from "../lib/api";
+import {
+ PREREQUISITE_TOOLS,
+ type PrerequisiteGroup,
+ type PrerequisiteTool,
+} from "../lib/prerequisites";
+import { cn } from "../lib/utils";
+import { Button } from "./ui/button";
+
+interface ToolRowProps {
+ tool: PrerequisiteTool;
+ /** `undefined` while the check is still running. */
+ installed: boolean | undefined;
+}
+
+const StatusIcon = ({ tool, installed }: ToolRowProps) => {
+ if (installed) {
+ return ;
+ }
+ if (installed === false && tool.group === "required") {
+ return ;
+ }
+ return ;
+};
+
+const ToolRow = ({ tool, installed }: ToolRowProps) => (
+
+
+
+
{tool.label}
+ {tool.note && (
+
{tool.note}
+ )}
+
+ {installed === undefined && (
+ Checking...
+ )}
+ {installed === true && (
+ Installed
+ )}
+ {installed === false && (
+
+ )}
+
+);
+
+const GROUP_LABELS: Record = {
+ required: "Required",
+ agent: "Agent CLI (install at least one)",
+ optional: "Optional",
+};
+
+/**
+ * Shows which of Git, the agent CLIs and the GitHub CLI are installed, with an
+ * install link for each missing one.
+ */
+export const PrerequisiteChecklist = ({
+ title = "Before you start",
+}: {
+ title?: string;
+}) => {
+ const { data, isValidating, mutate } = useSWR(
+ ["prerequisites"],
+ checkPrerequisites,
+ );
+ const installed = data && new Map(data.map((s) => [s.binary, s.installed]));
+ const noAgent =
+ installed !== undefined &&
+ !PREREQUISITE_TOOLS.some(
+ (t) => t.group === "agent" && installed.get(t.binary),
+ );
+
+ return (
+
+
+
{title}
+
+
+ {(Object.keys(GROUP_LABELS) as PrerequisiteGroup[]).map((group) => (
+
+
+ {GROUP_LABELS[group]}
+
+
+ {PREREQUISITE_TOOLS.filter((t) => t.group === group).map((tool) => (
+
+ ))}
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx
index a27648116..e00223179 100644
--- a/src/components/SettingsPage.tsx
+++ b/src/components/SettingsPage.tsx
@@ -41,6 +41,7 @@ import { Slider } from "./ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { useToast } from "./ui/toast";
import { AgentOptions } from "./AgentOptions";
+import { PrerequisiteChecklist } from "./PrerequisiteChecklist";
type TabValue =
| "application"
@@ -338,6 +339,8 @@ export const SettingsPage: React.FC = ({
+
+