From d53c3fa7e5ec1849e0550c7bc88787953f181b86 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 30 Apr 2026 15:01:35 +0200 Subject: [PATCH] fix: detect linuxbrew ai agent binaries --- src-tauri/src/claude_cli.rs | 34 ++++++++++-- src-tauri/src/cli_agent_runtime.rs | 85 ++++++++++++++++++++++++++++- src-tauri/src/codex_cli.rs | 26 ++++++++- src-tauri/src/gemini_discovery.rs | 29 ++++++++-- src-tauri/src/opencode_discovery.rs | 29 ++++++++-- src-tauri/src/pi_discovery.rs | 31 +++++++++-- 6 files changed, 209 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 3b7c082b..250f0227 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -64,7 +64,10 @@ pub(crate) fn find_claude_binary() -> Result { return Ok(binary); } - if let Some(binary) = find_existing_binary(claude_binary_candidates()) { + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + claude_binary_candidates(), + "Claude CLI", + )? { return Ok(binary); } @@ -156,11 +159,13 @@ fn claude_binary_candidates_for_home(home: &Path) -> Vec { home.join(".npm/bin/claude"), home.join(".npm/bin/claude.cmd"), home.join(".npm/bin/claude.exe"), + home.join(".linuxbrew/bin/claude"), home.join("AppData/Roaming/npm/claude.cmd"), home.join("AppData/Roaming/npm/claude.exe"), home.join("AppData/Local/pnpm/claude.cmd"), home.join("AppData/Local/pnpm/claude.exe"), home.join("scoop/shims/claude.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"), PathBuf::from("/opt/homebrew/bin/claude"), PathBuf::from("/usr/local/bin/claude"), ]; @@ -183,10 +188,6 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec) -> Option { - candidates.into_iter().find(|candidate| candidate.exists()) -} - // --------------------------------------------------------------------------- // Public Tauri commands // --------------------------------------------------------------------------- @@ -1397,6 +1398,17 @@ mod tests { assert_binary_candidates_include(&home, &expected); } + #[test] + fn claude_binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let expected = [ + home.join(".linuxbrew/bin/claude"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"), + ]; + + assert_binary_candidates_include(&home, &expected); + } + #[test] fn claude_binary_candidates_include_windows_exe_installs() { let home = PathBuf::from(r"C:\Users\alex"); @@ -1422,9 +1434,19 @@ mod tests { let claude = dir.path().join(".local/bin/claude.exe"); std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); std::fs::write(&claude, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + } assert_eq!( - find_existing_binary(claude_binary_candidates_for_home(dir.path())), + crate::cli_agent_runtime::find_executable_binary_candidate( + claude_binary_candidates_for_home(dir.path()), + "Claude CLI", + ) + .unwrap(), Some(claude) ); } diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs index 81bb0fdf..613d815f 100644 --- a/src-tauri/src/cli_agent_runtime.rs +++ b/src-tauri/src/cli_agent_runtime.rs @@ -1,7 +1,7 @@ use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent}; use serde::Deserialize; use std::io::BufRead; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; #[derive(Debug, Clone, Deserialize)] @@ -47,6 +47,51 @@ pub(crate) fn version_for_binary(binary: &PathBuf) -> Option { .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) } +pub(crate) fn find_executable_binary_candidate( + candidates: Vec, + agent_label: &str, +) -> Result, String> { + let mut first_unusable_candidate = None; + + for candidate in candidates { + if !candidate.exists() { + continue; + } + + if is_executable_file(&candidate) { + return Ok(Some(candidate)); + } + + if first_unusable_candidate.is_none() { + first_unusable_candidate = Some(candidate); + } + } + + match first_unusable_candidate { + Some(candidate) => Err(format!( + "{agent_label} binary found at {} but it is not executable. Fix the file permissions or reinstall the CLI.", + candidate.display() + )), + None => Ok(None), + } +} + +fn is_executable_file(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + + #[cfg(not(unix))] + { + path.is_file() + } +} + pub(crate) fn parse_json_line( line: Result, ) -> Result, String> { @@ -183,4 +228,42 @@ mod tests { let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err(); assert!(error.contains("broken pipe")); } + + #[cfg(unix)] + #[test] + fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let unusable = dir.path().join("codex-unusable"); + let executable = dir.path().join("codex"); + std::fs::write(&unusable, "#!/bin/sh\n").unwrap(); + std::fs::write(&executable, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let found = + find_executable_binary_candidate(vec![unusable, executable.clone()], "Codex CLI") + .unwrap(); + + assert_eq!(found, Some(executable)); + } + + #[cfg(unix)] + #[test] + fn executable_binary_candidate_reports_unusable_file_when_no_candidate_works() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let unusable = dir.path().join("opencode"); + std::fs::write(&unusable, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let error = + find_executable_binary_candidate(vec![unusable.clone()], "OpenCode CLI").unwrap_err(); + + assert!(error.contains("OpenCode CLI binary found")); + assert!(error.contains(&unusable.display().to_string())); + assert!(error.contains("not executable")); + } } diff --git a/src-tauri/src/codex_cli.rs b/src-tauri/src/codex_cli.rs index 99f6699a..337357e7 100644 --- a/src-tauri/src/codex_cli.rs +++ b/src-tauri/src/codex_cli.rs @@ -37,7 +37,7 @@ fn find_codex_binary() -> Result { return Ok(binary); } - if let Some(binary) = find_existing_binary(codex_binary_candidates()) { + if let Some(binary) = find_existing_binary(codex_binary_candidates())? { return Ok(binary); } @@ -114,6 +114,8 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec { home.join(".npm-global/bin/codex"), home.join(".npm/bin/codex"), home.join(".bun/bin/codex"), + home.join(".linuxbrew/bin/codex"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"), PathBuf::from("/usr/local/bin/codex"), PathBuf::from("/opt/homebrew/bin/codex"), PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), @@ -137,8 +139,8 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec) -> Option { - candidates.into_iter().find(|candidate| candidate.exists()) +fn find_existing_binary(candidates: Vec) -> Result, String> { + crate::cli_agent_runtime::find_executable_binary_candidate(candidates, "Codex CLI") } fn run_agent_stream_with_binary( @@ -490,6 +492,24 @@ exit 2 } } + #[test] + fn codex_binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let candidates = codex_binary_candidates_for_home(&home); + let expected = [ + home.join(".linuxbrew/bin/codex"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + #[test] fn codex_binary_candidates_include_nvm_managed_node_installs() { let home = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/gemini_discovery.rs b/src-tauri/src/gemini_discovery.rs index c7aed79c..94d0cae5 100644 --- a/src-tauri/src/gemini_discovery.rs +++ b/src-tauri/src/gemini_discovery.rs @@ -21,7 +21,10 @@ pub(crate) fn find_binary() -> Result { if let Some(binary) = find_binary_in_user_shell() { return Ok(binary); } - if let Some(binary) = find_existing_binary(gemini_binary_candidates()) { + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + gemini_binary_candidates(), + "Gemini CLI", + )? { return Ok(binary); } @@ -91,10 +94,6 @@ fn first_existing_path(stdout: &str) -> Option { }) } -fn find_existing_binary(candidates: Vec) -> Option { - candidates.into_iter().find(|candidate| candidate.exists()) -} - fn gemini_binary_candidates() -> Vec { dirs::home_dir() .map(|home| gemini_binary_candidates_for_home(&home)) @@ -119,11 +118,13 @@ fn gemini_binary_candidates_for_home(home: &Path) -> Vec { home.join(".npm/bin/gemini.exe"), home.join(".bun/bin/gemini"), home.join(".bun/bin/gemini.exe"), + home.join(".linuxbrew/bin/gemini"), home.join("AppData/Roaming/npm/gemini.cmd"), home.join("AppData/Roaming/npm/gemini.exe"), home.join("AppData/Local/pnpm/gemini.cmd"), home.join("AppData/Local/pnpm/gemini.exe"), home.join("scoop/shims/gemini.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/gemini"), PathBuf::from("/usr/local/bin/gemini"), PathBuf::from("/opt/homebrew/bin/gemini"), ]; @@ -173,6 +174,24 @@ mod tests { } } + #[test] + fn binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let candidates = gemini_binary_candidates_for_home(&home); + let expected = [ + home.join(".linuxbrew/bin/gemini"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/gemini"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + #[test] fn first_existing_path_skips_empty_and_missing_lines() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/opencode_discovery.rs b/src-tauri/src/opencode_discovery.rs index c6f68073..4ce69bd5 100644 --- a/src-tauri/src/opencode_discovery.rs +++ b/src-tauri/src/opencode_discovery.rs @@ -23,7 +23,10 @@ pub(crate) fn find_binary() -> Result { return Ok(binary); } - if let Some(binary) = find_existing_binary(opencode_binary_candidates()) { + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + opencode_binary_candidates(), + "OpenCode CLI", + )? { return Ok(binary); } @@ -93,10 +96,6 @@ fn first_existing_path(stdout: &str) -> Option { }) } -fn find_existing_binary(candidates: Vec) -> Option { - candidates.into_iter().find(|candidate| candidate.exists()) -} - fn opencode_binary_candidates() -> Vec { dirs::home_dir() .map(|home| opencode_binary_candidates_for_home(&home)) @@ -121,11 +120,13 @@ fn opencode_binary_candidates_for_home(home: &Path) -> Vec { home.join(".npm/bin/opencode.exe"), home.join(".bun/bin/opencode"), home.join(".bun/bin/opencode.exe"), + home.join(".linuxbrew/bin/opencode"), home.join("AppData/Roaming/npm/opencode.cmd"), home.join("AppData/Roaming/npm/opencode.exe"), home.join("AppData/Local/pnpm/opencode.cmd"), home.join("AppData/Local/pnpm/opencode.exe"), home.join("scoop/shims/opencode.exe"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/opencode"), PathBuf::from("/usr/local/bin/opencode"), PathBuf::from("/opt/homebrew/bin/opencode"), ] @@ -158,6 +159,24 @@ mod tests { } } + #[test] + fn binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let candidates = opencode_binary_candidates_for_home(&home); + let expected = [ + home.join(".linuxbrew/bin/opencode"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/opencode"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + #[test] fn binary_candidates_include_windows_npm_and_toolchain_shims() { let home = PathBuf::from(r"C:\Users\alex"); diff --git a/src-tauri/src/pi_discovery.rs b/src-tauri/src/pi_discovery.rs index 4a33db7a..40cd248c 100644 --- a/src-tauri/src/pi_discovery.rs +++ b/src-tauri/src/pi_discovery.rs @@ -23,7 +23,10 @@ pub(crate) fn find_binary() -> Result { return Ok(binary); } - if let Some(binary) = find_existing_binary(pi_binary_candidates()) { + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + pi_binary_candidates(), + "Pi CLI", + )? { return Ok(binary); } @@ -93,10 +96,6 @@ fn first_existing_path(stdout: &str) -> Option { }) } -fn find_existing_binary(candidates: Vec) -> Option { - candidates.into_iter().find(|candidate| candidate.exists()) -} - fn pi_binary_candidates() -> Vec { let mut candidates = pi_binary_candidates_from_env(); @@ -127,6 +126,7 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec { home.join(".npm/bin/pi.exe"), home.join(".bun/bin/pi"), home.join(".bun/bin/pi.exe"), + home.join(".linuxbrew/bin/pi"), home.join("AppData/Roaming/npm/pi.cmd"), home.join("AppData/Roaming/npm/pi.exe"), home.join("AppData/Local/pnpm/pi.cmd"), @@ -138,6 +138,7 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec { fn pi_global_binary_candidates() -> Vec { vec![ + PathBuf::from("/home/linuxbrew/.linuxbrew/bin/pi"), PathBuf::from("/usr/local/bin/pi"), PathBuf::from("/opt/homebrew/bin/pi"), ] @@ -215,6 +216,26 @@ mod tests { } } + #[test] + fn binary_candidates_include_linuxbrew_installs() { + let home = PathBuf::from("/home/alex"); + let home_candidates = pi_binary_candidates_for_home(&home); + let global_candidates = pi_global_binary_candidates(); + let expected_home = home.join(".linuxbrew/bin/pi"); + let expected_global = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/pi"); + + assert!( + home_candidates.contains(&expected_home), + "missing {}", + expected_home.display() + ); + assert!( + global_candidates.contains(&expected_global), + "missing {}", + expected_global.display() + ); + } + #[test] fn binary_candidates_include_windows_npm_and_toolchain_shims() { let home = PathBuf::from(r"C:\Users\alex");