diff --git a/src-tauri/src/codex_cli.rs b/src-tauri/src/codex_cli.rs index 2995b4b8..8d2181ae 100644 --- a/src-tauri/src/codex_cli.rs +++ b/src-tauri/src/codex_cli.rs @@ -4,20 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; pub fn check_cli() -> AiAgentAvailability { - let binary = match find_codex_binary() { - Ok(binary) => binary, - Err(_) => { - return AiAgentAvailability { - installed: false, - version: None, - } - } - }; - - AiAgentAvailability { - installed: true, - version: crate::cli_agent_runtime::version_for_binary(&binary), - } + codex_availability_from_binary_result(find_codex_binary()) } pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result @@ -29,15 +16,37 @@ where } fn find_codex_binary() -> Result { - find_codex_binary_on_path() - .filter(|binary| is_usable_codex_binary(binary)) - .or_else(|| { - find_codex_binary_in_user_shell().filter(|binary| is_usable_codex_binary(binary)) - }) - .or_else(|| find_usable_codex_binary(codex_binary_candidates())) - .ok_or_else(|| { - "Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into() - }) + if let Some(binary) = find_codex_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_codex_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + codex_binary_candidates(), + "Codex CLI", + )? { + return Ok(binary); + } + + Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) +} + +fn codex_availability_from_binary_result( + binary_result: Result, +) -> AiAgentAvailability { + match binary_result { + Ok(binary) => AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } } fn find_codex_binary_on_path() -> Option { @@ -60,7 +69,7 @@ fn find_codex_binary_in_user_shell() -> Option { user_shell_candidates() .into_iter() .filter(|shell| shell.exists()) - .find_map(|shell| command_path_from_shell(&shell, "codex")) + .find_map(|shell| codex_path_from_shell(&shell)) } fn user_shell_candidates() -> Vec { @@ -75,10 +84,10 @@ fn user_shell_candidates() -> Vec { shells } -fn command_path_from_shell(shell: &Path, command: &str) -> Option { +fn codex_path_from_shell(shell: &Path) -> Option { crate::hidden_command(shell) .arg("-lc") - .arg(format!("command -v {command}")) + .arg("command -v codex") .output() .ok() .and_then(|output| path_from_successful_output(&output)) @@ -124,12 +133,16 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec { let mut candidates = vec![ home.join(".local/bin/codex"), home.join(".local/bin/codex.exe"), + home.join(".local/bin/codex.cmd"), home.join(".codex/bin/codex"), home.join(".codex/bin/codex.exe"), + home.join(".codex/bin/codex.cmd"), home.join(".local/share/mise/shims/codex"), home.join(".local/share/mise/shims/codex.exe"), + home.join(".local/share/mise/shims/codex.cmd"), home.join(".asdf/shims/codex"), home.join(".asdf/shims/codex.exe"), + home.join(".asdf/shims/codex.cmd"), home.join(".npm-global/bin/codex"), home.join(".npm-global/bin/codex.cmd"), home.join(".npm-global/bin/codex.exe"), @@ -138,22 +151,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec { home.join(".npm/bin/codex.exe"), home.join(".bun/bin/codex"), home.join(".bun/bin/codex.exe"), + home.join(".bun/bin/codex.cmd"), home.join(".linuxbrew/bin/codex"), home.join("AppData/Roaming/npm/codex.cmd"), home.join("AppData/Roaming/npm/codex.exe"), home.join("AppData/Local/pnpm/codex.cmd"), home.join("AppData/Local/pnpm/codex.exe"), + home.join("scoop/shims/codex.cmd"), home.join("scoop/shims/codex.exe"), 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"), ]; - candidates.extend(nvm_node_binary_candidates_for_home(home, "codex")); + candidates.extend(nvm_codex_binary_candidates_for_home(home)); candidates } -fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec { +fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { return Vec::new(); }; @@ -162,22 +177,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec>(); candidates.sort(); candidates } -fn find_usable_codex_binary(candidates: Vec) -> Option { - candidates - .into_iter() - .find(|binary| is_usable_codex_binary(binary)) -} - -fn is_usable_codex_binary(binary: &Path) -> bool { - crate::cli_agent_runtime::version_for_binary(binary).is_some() -} - fn run_agent_stream_with_binary( binary: &Path, request: AgentStreamRequest, @@ -202,7 +207,12 @@ where emit, codex_session_id, dispatch_codex_event, - format_codex_error, + |stderr_output, status| { + format_codex_error(CodexProcessError { + stderr_output, + status, + }) + }, ) } @@ -276,7 +286,7 @@ fn build_codex_args( "-c".into(), codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]), "-c".into(), - codex_mcp_env_config(&request.vault_path, &request.vault_paths), + codex_mcp_env_config(request), ]; if let Some(path) = last_message_path { @@ -300,11 +310,14 @@ fn codex_config_string_list(key: &str, values: &[&str]) -> String { format!("{key}=[{values}]") } -fn codex_mcp_env_config(vault_path: &str, vault_paths: &[String]) -> String { - let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths); +fn codex_mcp_env_config(request: &AgentStreamRequest) -> String { + let vault_paths = crate::cli_agent_runtime::active_vault_paths_json( + &request.vault_path, + &request.vault_paths, + ); format!( r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#, - toml_escape(vault_path), + toml_escape(&request.vault_path), toml_escape(&vault_paths) ) } @@ -442,8 +455,13 @@ fn read_codex_last_message(path: &Path) -> Option { .filter(|text| !text.is_empty()) } -fn format_codex_error(stderr_output: String, status: String) -> String { - let lower = stderr_output.to_ascii_lowercase(); +struct CodexProcessError { + stderr_output: String, + status: String, +} + +fn format_codex_error(error: CodexProcessError) -> String { + let lower = error.stderr_output.to_ascii_lowercase(); if is_codex_auth_error(&lower) { return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into(); } @@ -452,10 +470,15 @@ fn format_codex_error(stderr_output: String, status: String) -> String { return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into(); } - if stderr_output.trim().is_empty() { - format!("codex exited with status {status}") + if error.stderr_output.trim().is_empty() { + format!("codex exited with status {}", error.status) } else { - stderr_output.lines().take(3).collect::>().join("\n") + error + .stderr_output + .lines() + .take(3) + .collect::>() + .join("\n") } } @@ -956,16 +979,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa let candidates = codex_binary_candidates_for_home(&home); let expected = [ home.join(".local/bin/codex.exe"), + home.join(".local/bin/codex.cmd"), home.join(".local/share/mise/shims/codex.exe"), + home.join(".local/share/mise/shims/codex.cmd"), home.join(".asdf/shims/codex.exe"), + home.join(".asdf/shims/codex.cmd"), + home.join(".codex/bin/codex.cmd"), home.join(".npm-global/bin/codex.cmd"), home.join(".npm-global/bin/codex.exe"), home.join(".npm/bin/codex.cmd"), home.join(".npm/bin/codex.exe"), + home.join(".bun/bin/codex.cmd"), home.join("AppData/Roaming/npm/codex.cmd"), home.join("AppData/Roaming/npm/codex.exe"), home.join("AppData/Local/pnpm/codex.cmd"), home.join("AppData/Local/pnpm/codex.exe"), + home.join("scoop/shims/codex.cmd"), home.join("scoop/shims/codex.exe"), ]; @@ -978,6 +1007,16 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa } } + #[test] + fn codex_availability_reports_installed_even_when_version_probe_fails() { + let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd"); + + let availability = codex_availability_from_binary_result(Ok(binary)); + + assert!(availability.installed); + assert_eq!(availability.version, None); + } + #[test] fn codex_binary_candidates_include_nvm_managed_node_installs() { let home = tempfile::tempdir().unwrap(); @@ -990,18 +1029,6 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa assert!(candidates.contains(&codex), "missing {}", codex.display()); } - #[cfg(unix)] - #[test] - fn usable_codex_binary_skips_broken_shims() { - let dir = tempfile::tempdir().unwrap(); - let broken = executable_script(dir.path(), "broken-codex", "exit 1\n"); - let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n"); - - let found = find_usable_codex_binary(vec![broken, working.clone()]); - - assert_eq!(found, Some(working)); - } - #[test] fn first_existing_path_skips_empty_and_missing_lines() { let dir = tempfile::tempdir().unwrap(); @@ -1051,7 +1078,7 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa .unwrap(); std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex)); + assert_eq!(codex_path_from_shell(&shell), Some(codex)); } #[test] @@ -1156,10 +1183,10 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa #[test] fn format_codex_error_explains_vault_write_permission_failures() { - let message = format_codex_error( - "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(), - "exit status: 1".into(), - ); + let message = format_codex_error(CodexProcessError { + stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(), + status: "exit status: 1".into(), + }); assert!(message.contains("active vault")); assert!(message.contains("writable"));