fix: detect linuxbrew ai agent binaries

This commit is contained in:
lucaronin
2026-04-30 15:01:35 +02:00
parent 3c14d60be7
commit d53c3fa7e5
6 changed files with 209 additions and 25 deletions

View File

@@ -64,7 +64,10 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
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<PathBuf> {
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<Pa
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
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)
);
}

View File

@@ -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<String> {
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn find_executable_binary_candidate(
candidates: Vec<PathBuf>,
agent_label: &str,
) -> Result<Option<PathBuf>, 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<String, std::io::Error>,
) -> Result<Option<serde_json::Value>, 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"));
}
}

View File

@@ -37,7 +37,7 @@ fn find_codex_binary() -> Result<PathBuf, String> {
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<PathBuf> {
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<Pa
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
fn find_existing_binary(candidates: Vec<PathBuf>) -> Result<Option<PathBuf>, String> {
crate::cli_agent_runtime::find_executable_binary_candidate(candidates, "Codex CLI")
}
fn run_agent_stream_with_binary<F>(
@@ -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();

View File

@@ -21,7 +21,10 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
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<PathBuf> {
})
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn gemini_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| gemini_binary_candidates_for_home(&home))
@@ -119,11 +118,13 @@ fn gemini_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
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();

View File

@@ -23,7 +23,10 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
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<PathBuf> {
})
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn opencode_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| opencode_binary_candidates_for_home(&home))
@@ -121,11 +120,13 @@ fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
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");

View File

@@ -23,7 +23,10 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
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<PathBuf> {
})
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn pi_binary_candidates() -> Vec<PathBuf> {
let mut candidates = pi_binary_candidates_from_env();
@@ -127,6 +126,7 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
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<PathBuf> {
fn pi_global_binary_candidates() -> Vec<PathBuf> {
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");