fix: detect linuxbrew ai agent binaries
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user