diff --git a/CHANGELOG.md b/CHANGELOG.md
index 902c0b4..2ae238c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 1.5.12 - 2026-05-29
+
+- Settings → Codex CLI path gains an **Auto-detect** button next to "Change". Unlike the existing path self-check (which trusts the cached / override path), it force-rescans every common install location plus PATH and verifies each candidate is actually runnable via `codex --version`. A lone runnable hit is applied immediately; several open the dialog with the verified candidates to pick from; none falls back to the manual dialog. Targets the two cases the self-check can't: auto-detection landed on a wrong / stale path, or the user doesn't know where to point it. Backed by a new `redetect_codex_cli_path` command that runs on the blocking pool (each candidate probe spawns a child) with a per-candidate timeout so a hung binary can't wedge the scan. macOS + Windows symmetric.
+
## 1.5.11 - 2026-05-16
- Added experimental Linux x86_64 build to the release pipeline. Tagged releases now publish `.deb` (Debian/Ubuntu) and `.AppImage` (generic portable) artifacts alongside the existing macOS / Windows ones. Built on `ubuntu-22.04` (glibc 2.35) so binaries run on Ubuntu 22.04+ / Debian 12+ and equivalent distros. UI, profile switching, and plan / quota readout work as on the other platforms; Linux-native paths for Codex CLI discovery and `codex login` spawning are not separately adapted yet (the non-macOS code branch is currently reused), so feedback issues are welcome.
diff --git a/README.md b/README.md
index c81808c..6f7b99f 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
- **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。
- **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。
- **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。
-- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。
+- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。
- **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。
- **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。
@@ -143,7 +143,7 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *.
### 没有 Codex CLI 怎么办
-应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。
+应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。也可以点设置页「Codex CLI 路径」行的「自动检测」按钮强制重新扫描,并用 `codex --version` 验证候选——比启动时的自检更主动,适合自动定位出错或不清楚 `codex` 装在哪的情况。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。
### 切换账号会丢失原账号的 sessions / 历史吗
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c81808c..6f7b99f 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -28,7 +28,7 @@
- **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。
- **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。
- **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。
-- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。
+- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。
- **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。
- **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。
@@ -143,7 +143,7 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *.
### 没有 Codex CLI 怎么办
-应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。
+应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。也可以点设置页「Codex CLI 路径」行的「自动检测」按钮强制重新扫描,并用 `codex --version` 验证候选——比启动时的自检更主动,适合自动定位出错或不清楚 `codex` 装在哪的情况。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。
### 切换账号会丢失原账号的 sessions / 历史吗
diff --git a/src-tauri/mac/front/index.html b/src-tauri/mac/front/index.html
index 371656b..4c8502f 100644
--- a/src-tauri/mac/front/index.html
+++ b/src-tauri/mac/front/index.html
@@ -151,6 +151,7 @@
Profiles
Codex CLI path
--
+
diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs
index 2d28aa5..ece196d 100644
--- a/src-tauri/mac/runtime/process.rs
+++ b/src-tauri/mac/runtime/process.rs
@@ -9,6 +9,7 @@ use std::time::Duration;
use crate::errors::{AppError, AppResult};
use crate::platform::hooks::PlatformHooks;
use crate::shared::codex_app_server::{fetch_account_snapshot, AppServerSnapshot};
+use crate::models::CodexCliCandidate;
use crate::shared::codex_cli_path::CodexPathResolver;
pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource};
use crate::shared::login_cancel::wait_for_login_or_cancel;
@@ -81,15 +82,14 @@ fn discover_real_codex_cli_from_shell(managed_shim_path: Option<&Path>) -> Optio
let managed_shim_text = managed_shim_path
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
- let output = Command::new(&resolver_path)
- .arg(managed_shim_text)
- .output()
- .ok()?;
- if !output.status.success() {
- return None;
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut command = Command::new(&resolver_path);
+ command.arg(managed_shim_text);
+ // Bounded like the other shell spawns: even this first-party resolver
+ // script sources the login environment, which can stall on a hung rc.
+ let stdout = crate::shared::codex_cli_path::run_capturing_stdout_with_timeout(
+ command,
+ LOGIN_SHELL_PROBE_TIMEOUT,
+ )?;
let resolved = stdout
.lines()
.map(str::trim)
@@ -252,6 +252,10 @@ impl CodexPathResolver for MacosCodexPathResolver {
fn suggested_paths(&self, codex_home: &Path) -> Vec {
suggested_codex_cli_paths(Some(codex_home))
}
+
+ fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec {
+ redetect_runnable_codex_cli_paths(Some(codex_home))
+ }
}
/// Soft cap on how long the PATH walk can spend stat'ing entries
@@ -301,6 +305,119 @@ pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec {
suggestions
}
+/// How long a single `codex --version` probe may run before we kill it
+/// and treat the candidate as unusable. Keeps a hung or input-waiting
+/// binary from wedging the auto-detect scan; a healthy codex answers
+/// well under this.
+const RUNNABLE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// Upper bound on how many candidates the auto-detect scan will probe.
+/// Each probe spawns a child (up to `RUNNABLE_PROBE_TIMEOUT`), so without
+/// a cap a pathological PATH with many `codex` entries could stall the
+/// scan. Realistic machines have 1-3 candidates.
+const MAX_PROBE_CANDIDATES: usize = 12;
+
+/// Login shells source the user's full profile chain (nvm / asdf / brew
+/// shellenv / network-y rc files), which can be slower than a bare
+/// `--version`, so give the login-shell resolve a more generous budget —
+/// but still hard-bounded so a hung profile can't wedge the whole scan.
+const LOGIN_SHELL_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
+
+/// Probe whether `path` is a runnable codex CLI and capture its version.
+/// `Some(version)` (possibly empty) means it's a file that ran and exited
+/// 0; `None` means not-a-file, couldn't spawn, exited non-zero, or timed
+/// out. The failure is logged so a broken install leaves a diagnostic
+/// trail instead of looking identical to "not found".
+fn probe_codex_version(path: &Path) -> Option {
+ if !path.is_file() {
+ return None;
+ }
+ let mut command = Command::new(path);
+ command.arg("--version");
+ let result =
+ crate::shared::codex_cli_path::probe_version_with_timeout(command, RUNNABLE_PROBE_TIMEOUT);
+ if result.is_none() {
+ eprintln!(
+ "codex probe: {} is not a runnable codex (spawn / non-zero exit / timeout)",
+ path.display()
+ );
+ }
+ result
+}
+
+/// Resolve codex through the user's login shell, so installs on the
+/// shell's PATH (nvm / asdf / brew / fnm / any rc-managed location) are
+/// found even when the app was launched from Finder with the narrow
+/// launchd PATH. A non-interactive login shell + `command -v` avoids
+/// loading the user's `codex` shell *function* (if any), so we resolve to
+/// the real binary; the result is verified to be an absolute file.
+fn discover_codex_via_login_shell(managed_shim_path: Option<&Path>) -> Option {
+ let shell = env::var_os("SHELL")?;
+ let mut command = Command::new(&shell);
+ command.args(["-lc", "command -v codex"]);
+ // Bounded via the shared helper: a slow / hung login profile (nvm,
+ // asdf, network-y rc files) must NOT wedge the scan the way an
+ // unbounded `.output()` would — this runs first and synchronously.
+ let stdout = crate::shared::codex_cli_path::run_capturing_stdout_with_timeout(
+ command,
+ LOGIN_SHELL_PROBE_TIMEOUT,
+ )?;
+ // `command -v` prints the resolved path last — after any banner a noisy
+ // profile may have echoed to stdout — so take the last non-empty line.
+ let resolved = stdout
+ .lines()
+ .map(str::trim)
+ .rev()
+ .find(|value| !value.is_empty())?;
+ let candidate = PathBuf::from(resolved);
+ // `command -v` of a function/alias returns a bare name, not a path —
+ // require an absolute path to a real file so we never feed that back.
+ if !candidate.is_absolute() || !candidate.is_file() {
+ return None;
+ }
+ if managed_shim_path.is_some_and(|managed| managed == candidate.as_path()) {
+ return None;
+ }
+ Some(candidate)
+}
+
+/// Force a fresh scan for the Settings auto-detect button: gather every
+/// candidate the discovery + suggestion paths know about (login-shell
+/// resolution, managed-shim resolver, Codex.app bundle, fixed install
+/// locations, PATH), then keep only those that pass the runnable probe,
+/// capturing each one's version. Ignores the cached/override path so a
+/// wrong saved path can be corrected.
+pub fn redetect_runnable_codex_cli_paths(codex_home: Option<&Path>) -> Vec {
+ let managed_shim = codex_home.map(managed_shim_path);
+ let mut candidates: Vec = Vec::new();
+
+ // Login-shell resolution first — catches nvm / asdf / brew / fnm
+ // installs on the user's PATH even under Finder's narrow launchd PATH.
+ if let Some(path) = discover_codex_via_login_shell(managed_shim.as_deref()) {
+ push_candidate(&mut candidates, path);
+ }
+ // The managed-shim resolver (present when codex_switch installed its
+ // shim) and the suggestion list (Codex.app bundle, fixed locations,
+ // bounded PATH walk) fill in the rest.
+ if let Some(shell_path) = discover_real_codex_cli_from_shell(managed_shim.as_deref()) {
+ push_candidate(&mut candidates, shell_path);
+ }
+ for path in suggested_codex_cli_paths(codex_home) {
+ push_candidate(&mut candidates, path);
+ }
+
+ candidates
+ .into_iter()
+ .take(MAX_PROBE_CANDIDATES)
+ .filter_map(|path| {
+ probe_codex_version(&path).map(|version| CodexCliCandidate {
+ path: path.to_string_lossy().into_owned(),
+ version: (!version.is_empty()).then_some(version),
+ })
+ })
+ .collect()
+}
+
fn codex_app_candidates() -> Vec {
let mut candidates = vec![PathBuf::from("/Applications/Codex.app")];
if let Some(home) = env::var_os("HOME") {
diff --git a/src-tauri/shared/commands/actions.rs b/src-tauri/shared/commands/actions.rs
index 581a023..1b86cd6 100644
--- a/src-tauri/shared/commands/actions.rs
+++ b/src-tauri/shared/commands/actions.rs
@@ -1,8 +1,8 @@
use crate::errors::CommandError;
use crate::models::{
- ActionResponse, AddProfilePayload, CodexCliStatus, OpenUrlPayload, ProfilePayload,
- RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload, UpdateCheckResponse,
- UpdateProfileBaseUrlPayload,
+ ActionResponse, AddProfilePayload, CodexCliRedetectResult, CodexCliStatus, OpenUrlPayload,
+ ProfilePayload, RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload,
+ UpdateCheckResponse, UpdateProfileBaseUrlPayload,
};
#[cfg(target_os = "macos")]
@@ -222,6 +222,28 @@ pub fn clear_codex_cli_path() -> Result {
))
}
+/// Force a fresh codex CLI detection scan for the Settings auto-detect
+/// button. Runs on the blocking pool because it probes each candidate
+/// with `codex --version`, which can take a second or two per path and
+/// would otherwise stall the UI thread.
+#[tauri::command]
+pub async fn redetect_codex_cli_path() -> Result {
+ tauri::async_runtime::spawn_blocking(|| {
+ let codex_home = platform_runtime::paths::get_codex_home();
+ crate::shared::codex_cli_path::redetect_codex_cli_path(
+ platform_runtime::codex_cli_resolver(),
+ &codex_home,
+ )
+ })
+ .await
+ .map_err(|error| {
+ CommandError::new(
+ "CODEX_CLI_REDETECT_FAILED",
+ format!("Redetect task failed: {error}"),
+ )
+ })
+}
+
#[tauri::command]
pub fn cancel_codex_login() -> Result {
Ok(crate::shared::login_cancel::cancel_login_in_progress())
diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts
index eebba20..873ccc7 100644
--- a/src-tauri/shared/front/actions.ts
+++ b/src-tauri/shared/front/actions.ts
@@ -34,12 +34,13 @@ import {
refreshActiveProfileQuotaSilent,
refreshAllOauthProfilePlansSilent,
refreshProfile,
+ redetectCodexCliPath,
renameProfile,
setCodexCliPath,
switchProfile,
updateProfileBaseUrl,
} from "@front-shared/tauri";
-import type { CodexCliStatus } from "@front-shared/types";
+import type { CodexCliCandidate, CodexCliRedetectResult, CodexCliStatus } from "@front-shared/types";
import {
applyLocale,
elements,
@@ -678,7 +679,7 @@ function codexCliSourceLabel(source: CodexCliStatus["source"]): string {
}
}
-function renderCodexCliStatus(status: CodexCliStatus): void {
+function renderCodexCliStatus(status: CodexCliStatus, detected?: CodexCliCandidate[]): void {
if (status.resolved_path) {
elements.codexCliCurrentValue.textContent = status.resolved_path;
elements.codexCliCurrentSource.textContent = ` (${codexCliSourceLabel(status.source)})`;
@@ -691,8 +692,22 @@ function renderCodexCliStatus(status: CodexCliStatus): void {
elements.clearCodexCliButton.hidden = true;
}
+ // When auto-detect routes here with verified-runnable candidates, show
+ // those (with versions) instead of the raw common-location hints, so
+ // the user only picks from installs that actually ran.
+ const showingDetected = detected !== undefined && detected.length > 0;
+ elements.codexCliSuggestionsHeading.textContent = showingDetected
+ ? t(state.locale, "codexCliDetectedHeading")
+ : t(state.locale, "codexCliSuggestionsHeading");
+
+ // Normalise both sources to { path, version } so one render loop serves
+ // detected candidates (with versions) and plain suggestion hints.
+ const chips: CodexCliCandidate[] = showingDetected
+ ? detected
+ : status.suggested_paths.map((path) => ({ path, version: null }));
+
elements.codexCliSuggestions.replaceChildren();
- if (status.suggested_paths.length === 0) {
+ if (chips.length === 0) {
const empty = document.createElement("p");
empty.className = "codex-cli-suggestions-empty";
empty.textContent = t(state.locale, "codexCliSuggestionsEmpty");
@@ -700,13 +715,15 @@ function renderCodexCliStatus(status: CodexCliStatus): void {
return;
}
- for (const path of status.suggested_paths) {
+ for (const candidate of chips) {
const button = document.createElement("button");
button.type = "button";
button.className = "codex-cli-suggestion";
- button.textContent = path;
+ button.textContent = candidate.version
+ ? `${candidate.path} · ${candidate.version}`
+ : candidate.path;
button.addEventListener("click", () => {
- elements.codexCliInput.value = path;
+ elements.codexCliInput.value = candidate.path;
elements.codexCliInput.focus();
elements.codexCliInput.select();
clearDialogError(elements.codexCliDialogError);
@@ -715,7 +732,10 @@ function renderCodexCliStatus(status: CodexCliStatus): void {
}
}
-async function openCodexCliDialog(onSavedRetry?: () => Promise): Promise {
+async function openCodexCliDialog(
+ onSavedRetry?: () => Promise,
+ detectedCandidates?: CodexCliCandidate[],
+): Promise {
pendingLoginRetry = onSavedRetry ?? null;
clearDialogError(elements.codexCliDialogError);
elements.codexCliInput.value = "";
@@ -734,19 +754,87 @@ async function openCodexCliDialog(onSavedRetry?: () => Promise): Promise 0;
+ elements.codexCliDialogCopy.textContent = hasDetected
+ ? t(state.locale, "codexCliDetectPickCopy")
+ : t(state.locale, "codexCliDialogCopy");
+
+ // Prefill the first detected candidate; otherwise the resolved path.
+ if (detectedCandidates !== undefined && detectedCandidates.length > 0) {
+ elements.codexCliInput.value = detectedCandidates[0].path;
+ } else if (status.resolved_path) {
elements.codexCliInput.value = status.resolved_path;
}
elements.submitCodexCliButton.textContent = onSavedRetry
? t(state.locale, "codexCliRetryLogin")
: t(state.locale, "save");
- renderCodexCliStatus(status);
+ renderCodexCliStatus(status, detectedCandidates);
elements.codexCliDialog.showModal();
elements.codexCliInput.focus();
elements.codexCliInput.select();
}
+/// Settings "auto-detect" button: force a fresh runnable scan and act on
+/// the result — apply a lone hit, let the user pick when several survive,
+/// or fall back to the manual dialog when none do.
+async function handleDetectCodexCli(): Promise {
+ const button = elements.settingsCodexCliDetectButton;
+ if (button.disabled) {
+ return;
+ }
+ button.disabled = true;
+ button.textContent = t(state.locale, "settingsCodexCliDetecting");
+ try {
+ const result: CodexCliRedetectResult = await redetectCodexCliPath();
+ if (result.candidates.length === 1) {
+ // Lone runnable hit → apply it straight away (the small-user
+ // fallback: one click, done). If the backend's set/validate then
+ // rejects it (managed shim, or the file vanished between probe and
+ // set), don't dump the raw error — fall back to the dialog with the
+ // candidate so the user can adjust.
+ const only = result.candidates[0];
+ try {
+ const status = await setCodexCliPath(only.path);
+ applyCodexCliSettingsDisplay(status);
+ showToast(t(state.locale, "codexCliDetectApplied", { path: only.path }));
+ } catch {
+ applyCodexCliSettingsDisplay(result.status);
+ void openCodexCliDialog(undefined, result.candidates);
+ }
+ } else if (result.candidates.length === 0) {
+ // Nothing runnable. Distinguish "no codex anywhere" from "codex
+ // exists on disk but none would run" (a broken install, not a
+ // missing one) via the on-disk suggestions in the refreshed status.
+ applyCodexCliSettingsDisplay(result.status);
+ const foundButBroken = result.status.suggested_paths.length > 0;
+ showToast(
+ t(state.locale, foundButBroken ? "codexCliDetectFoundButBroken" : "codexCliDetectNone"),
+ true,
+ );
+ void openCodexCliDialog();
+ } else {
+ // Several runnable hits → let the user choose in the dialog.
+ applyCodexCliSettingsDisplay(result.status);
+ showToast(
+ t(state.locale, "codexCliDetectMultiple", { count: String(result.candidates.length) }),
+ );
+ void openCodexCliDialog(undefined, result.candidates);
+ }
+ } catch (error) {
+ showToast(
+ error instanceof Error ? error.message : t(state.locale, "codexCliDetectFailed"),
+ true,
+ );
+ } finally {
+ button.disabled = false;
+ button.textContent = t(state.locale, "settingsCodexCliDetect");
+ }
+}
+
function closeCodexCliDialog(): void {
pendingLoginRetry = null;
elements.codexCliDialog.close();
@@ -933,6 +1021,9 @@ export function bootstrap(): void {
elements.codexCliForm.addEventListener("submit", (event) => {
void handleSubmitCodexCliPath(event as SubmitEvent);
});
+ elements.settingsCodexCliDetectButton.addEventListener("click", () => {
+ void handleDetectCodexCli();
+ });
elements.settingsCodexCliButton.addEventListener("click", () => {
void openCodexCliDialog();
});
diff --git a/src-tauri/shared/front/base.css b/src-tauri/shared/front/base.css
index ad69c09..e734a19 100644
--- a/src-tauri/shared/front/base.css
+++ b/src-tauri/shared/front/base.css
@@ -988,6 +988,14 @@ p {
min-width: 0;
}
+/* Two action buttons now share this row with the path value (auto-detect
+ + change). Drop the fixed min-width so they size to their label and
+ leave the flexible remainder to the (ellipsised) path value. */
+.settings-cli-inline .settings-action-button {
+ min-width: auto;
+ flex: none;
+}
+
.settings-value--inline {
flex: 1;
min-width: 0;
diff --git a/src-tauri/shared/front/i18n.ts b/src-tauri/shared/front/i18n.ts
index 48e8f70..cf4a369 100644
--- a/src-tauri/shared/front/i18n.ts
+++ b/src-tauri/shared/front/i18n.ts
@@ -242,6 +242,17 @@ const enMessages = {
settingsCodexCli: "Codex CLI path",
settingsCodexCliChange: "Change",
settingsCodexCliEmpty: "Not detected",
+ settingsCodexCliDetect: "Auto-detect",
+ settingsCodexCliDetecting: "Detecting…",
+ codexCliDetectedHeading: "Detected (verified runnable)",
+ codexCliDetectApplied: "Detected and set: {path}",
+ codexCliDetectNone: "Couldn't auto-detect codex. Set the path manually below.",
+ codexCliDetectFoundButBroken:
+ "Found codex on disk, but none of them would run. Reinstall codex or set a working path below.",
+ codexCliDetectPickCopy:
+ "Found multiple runnable codex installs — pick the one to use, or set a path manually below.",
+ codexCliDetectMultiple: "Found {count} runnable codex binaries — pick one.",
+ codexCliDetectFailed: "Auto-detect failed.",
} as const;
export type MessageKey = keyof typeof enMessages;
@@ -488,6 +499,17 @@ const messages: Record = {
settingsCodexCli: "Codex CLI 路径",
settingsCodexCliChange: "更改",
settingsCodexCliEmpty: "未检测到",
+ settingsCodexCliDetect: "自动检测",
+ settingsCodexCliDetecting: "检测中…",
+ codexCliDetectedHeading: "已检测到(已验证可运行)",
+ codexCliDetectApplied: "已检测并设置:{path}",
+ codexCliDetectNone: "未能自动检测到 codex,请在下方手动设置路径。",
+ codexCliDetectFoundButBroken:
+ "找到了 codex,但都无法运行。请重装 codex 或在下方指定一个可用路径。",
+ codexCliDetectPickCopy:
+ "检测到多个可运行的 codex,选择要使用的那个;也可以在下方手动指定。",
+ codexCliDetectMultiple: "检测到 {count} 个可运行的 codex,请选择一个。",
+ codexCliDetectFailed: "自动检测失败。",
},
};
diff --git a/src-tauri/shared/front/render.ts b/src-tauri/shared/front/render.ts
index 86dd902..6669e6e 100644
--- a/src-tauri/shared/front/render.ts
+++ b/src-tauri/shared/front/render.ts
@@ -116,6 +116,7 @@ export const elements = {
settingsCodexCliLabel: requiredElement("settings-codex-cli-label"),
settingsCodexCliValue: requiredElement("settings-codex-cli-value"),
settingsCodexCliButton: requiredElement("settings-codex-cli-button"),
+ settingsCodexCliDetectButton: requiredElement("settings-codex-cli-detect-button"),
codexCliDialog: requiredElement("codex-cli-dialog"),
codexCliForm: requiredElement("codex-cli-form"),
codexCliDialogTitle: requiredElement("codex-cli-dialog-title"),
@@ -758,6 +759,7 @@ export function applyLocale(): void {
elements.submitCodexCliButton.textContent = t(state.locale, "save");
elements.settingsCodexCliLabel.textContent = t(state.locale, "settingsCodexCli");
elements.settingsCodexCliButton.textContent = t(state.locale, "settingsCodexCliChange");
+ elements.settingsCodexCliDetectButton.textContent = t(state.locale, "settingsCodexCliDetect");
// Version label is locale-independent but lives next to the i18n
// settings rows; set it here so a single render pass paints both.
// `__CODEX_APP_VERSION__` is injected by Vite from `package.json` so
diff --git a/src-tauri/shared/front/tauri.ts b/src-tauri/shared/front/tauri.ts
index d62675e..1ff0af9 100644
--- a/src-tauri/shared/front/tauri.ts
+++ b/src-tauri/shared/front/tauri.ts
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type {
ActionResponse,
+ CodexCliRedetectResult,
CodexCliStatus,
CommandError,
CurrentCard,
@@ -334,6 +335,15 @@ async function invokeCommand(command: string, args?: Record)
source: command === "set_codex_cli_path" ? "user_override" : "discovery",
suggested_paths: ["/preview/codex", "/preview/usr/local/bin/codex"],
}) as Promise;
+ case "redetect_codex_cli_path":
+ return Promise.resolve({
+ candidates: [{ path: "/preview/codex", version: "codex-cli 0.133.0" }],
+ status: {
+ resolved_path: "/preview/codex",
+ source: "user_override",
+ suggested_paths: ["/preview/codex", "/preview/usr/local/bin/codex"],
+ },
+ }) as Promise;
case "cancel_codex_login":
return Promise.resolve(true) as Promise;
case "open_profile_folder":
@@ -464,6 +474,10 @@ export function clearCodexCliPath(): Promise {
return invokeCommand("clear_codex_cli_path");
}
+export function redetectCodexCliPath(): Promise {
+ return invokeCommand("redetect_codex_cli_path");
+}
+
export function cancelCodexLogin(): Promise {
return invokeCommand("cancel_codex_login");
}
diff --git a/src-tauri/shared/front/types.ts b/src-tauri/shared/front/types.ts
index 17c52e0..5314c1f 100644
--- a/src-tauri/shared/front/types.ts
+++ b/src-tauri/shared/front/types.ts
@@ -98,4 +98,18 @@ export interface CodexCliStatus {
suggested_paths: string[];
}
+export interface CodexCliCandidate {
+ /** Absolute path to the verified-runnable codex binary. */
+ path: string;
+ /** `codex --version` line (e.g. "codex-cli 0.133.0"), or null if it ran but printed nothing parseable. */
+ version: string | null;
+}
+
+export interface CodexCliRedetectResult {
+ /** Candidates verified runnable by the forced scan, deduped, best-first. */
+ candidates: CodexCliCandidate[];
+ /** Refreshed status snapshot so the Settings row can update in step. */
+ status: CodexCliStatus;
+}
+
export type ShellRoute = "dashboard" | "profiles" | "settings" | "guide";
diff --git a/src-tauri/shared/runtime/codex_cli_path.rs b/src-tauri/shared/runtime/codex_cli_path.rs
index 91a697c..8986abd 100644
--- a/src-tauri/shared/runtime/codex_cli_path.rs
+++ b/src-tauri/shared/runtime/codex_cli_path.rs
@@ -1,6 +1,7 @@
-//! Shared `InstallState` schema + `CodexPathResolver` trait + the four
+//! Shared `InstallState` schema + `CodexPathResolver` trait + the
//! Tauri-command helpers (`get_codex_cli_status` / `set_codex_cli_path`
-//! / `clear_codex_cli_path` / `build_codex_cli_status`).
+//! / `clear_codex_cli_path` / `redetect_codex_cli_path` /
+//! `build_codex_cli_status`).
//!
//! Before this module each platform (`mac/runtime/process.rs` +
//! `mac/runtime/profile_actions.rs` and the Windows mirrors) carried
@@ -12,12 +13,16 @@
//! managed-shim filtering — are kept per-platform and reached through
//! the `CodexPathResolver` trait so this shared layer is OS-agnostic.
+use std::io::Read;
use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::thread;
+use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::errors::AppResult;
-use crate::models::CodexCliStatus;
+use crate::models::{CodexCliCandidate, CodexCliRedetectResult, CodexCliStatus};
/// Persistent install metadata. Both mac and Windows used to declare
/// this struct independently; consolidating here keeps the on-disk
@@ -77,6 +82,14 @@ pub trait CodexPathResolver {
/// Common install locations that exist on disk right now. Frontend
/// renders these as click-to-fill chips in the dialog.
fn suggested_paths(&self, codex_home: &Path) -> Vec;
+
+ /// Force a fresh scan that ignores the cached/override path and
+ /// returns every candidate verified runnable via `codex --version`
+ /// (deduped, best-first, with the version string captured). Backs the
+ /// Settings "auto-detect" button: `resolve_with_source` trusts a
+ /// previously-saved path, so when that path is wrong — or there are
+ /// several installs — the user needs this to rescan from scratch.
+ fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec;
}
/// Build the snapshot the front-end consumes. Used by both
@@ -129,6 +142,125 @@ pub fn clear_codex_cli_path(
build_codex_cli_status(resolver, codex_home)
}
+/// Force a fresh detection scan and report which candidates are
+/// runnable, alongside a refreshed status snapshot. Backs the Settings
+/// "auto-detect" button — the front-end auto-applies a lone candidate
+/// and lets the user pick when several survive the probe.
+pub fn redetect_codex_cli_path(
+ resolver: &dyn CodexPathResolver,
+ codex_home: &Path,
+) -> CodexCliRedetectResult {
+ CodexCliRedetectResult {
+ candidates: resolver.redetect_runnable_paths(codex_home),
+ status: build_codex_cli_status(resolver, codex_home),
+ }
+}
+
+/// Run a configured `codex --version` `command`, bounded by `timeout`,
+/// and return its trimmed first stdout line on a successful exit. This is
+/// the one-shot "is this a real, runnable codex, and which version" probe
+/// behind auto-detect: `Some(version)` means it ran and exited 0 (the
+/// string may be empty if it printed nothing parseable); `None` means it
+/// couldn't spawn, exited non-zero, errored, or overran the timeout (the
+/// child is then killed so a hung / input-waiting binary can't wedge the
+/// scan). Shared so mac + Windows reuse the poll/kill/capture logic; each
+/// platform only builds the `Command` (console hiding, extension res).
+pub fn probe_version_with_timeout(command: Command, timeout: Duration) -> Option {
+ run_capturing_stdout_with_timeout(command, timeout).map(|stdout| {
+ stdout
+ .lines()
+ .map(str::trim)
+ .find(|line| !line.is_empty())
+ .unwrap_or("")
+ .to_owned()
+ })
+}
+
+/// After the probed child exits, how long to wait for its stdout to finish
+/// draining before giving up on capturing it. A child that left a
+/// background process holding the pipe never yields EOF, so this bounds
+/// that case instead of blocking forever.
+const STDOUT_DRAIN_GRACE: Duration = Duration::from_secs(1);
+
+/// Run `command` bounded by `timeout`, draining stdout on a reader thread
+/// so a chatty child can't backpressure its own pipe and deadlock. Returns
+/// the full captured stdout on a successful (exit-0) run; `None` if it
+/// couldn't spawn, exited non-zero, errored, or overran the timeout (the
+/// child is then killed). Shared by the `codex --version` probe (caller
+/// takes the first line = version) and the login-shell resolver (caller
+/// takes the last line = path), so the poll / kill / drain logic — and the
+/// hard timeout that keeps an arbitrary user login shell from wedging the
+/// scan — lives in exactly one place.
+pub fn run_capturing_stdout_with_timeout(
+ mut command: Command,
+ timeout: Duration,
+) -> Option {
+ command
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ let mut child = match command.spawn() {
+ Ok(child) => child,
+ Err(error) => {
+ eprintln!("run_capturing_stdout_with_timeout: spawn failed: {error}");
+ return None;
+ }
+ };
+ // Drain stdout on a thread that posts to a channel — then wait with a
+ // bounded recv_timeout, NOT an unbounded join. Two hazards this guards:
+ // (1) a child that writes more than the pipe buffer (~64 KB) before
+ // exiting would block on write() while we poll try_wait; (2) a child
+ // that exits but leaves a background process holding the stdout pipe
+ // never yields EOF, so read_to_string — and an unbounded join() after
+ // it — would block forever and defeat the timeout. We abandon the
+ // (detached) reader after STDOUT_DRAIN_GRACE in that case.
+ let (tx, rx) = std::sync::mpsc::channel();
+ let has_reader = child
+ .stdout
+ .take()
+ .map(|mut stdout| {
+ thread::spawn(move || {
+ let mut buf = String::new();
+ let _ = stdout.read_to_string(&mut buf);
+ let _ = tx.send(buf);
+ });
+ })
+ .is_some();
+ let deadline = Instant::now() + timeout;
+ loop {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ if !status.success() {
+ return None;
+ }
+ let stdout = if has_reader {
+ rx.recv_timeout(STDOUT_DRAIN_GRACE).unwrap_or_default()
+ } else {
+ String::new()
+ };
+ return Some(stdout);
+ }
+ Ok(None) => {
+ if Instant::now() >= deadline {
+ let _ = child.kill();
+ let _ = child.wait();
+ return None;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ Err(error) => {
+ // A real try_wait failure (EINTR, ECHILD, a Windows handle
+ // error) is indistinguishable from a clean timeout to the
+ // caller — both return None — so leave a diagnostic trail.
+ eprintln!("run_capturing_stdout_with_timeout: try_wait failed: {error}");
+ let _ = child.kill();
+ let _ = child.wait();
+ return None;
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -148,6 +280,10 @@ mod tests {
// return Err(that AppError) to test ? propagation.
set_error: RefCell