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 = ({

    + +