Files
CC-Switch/src-tauri/src/commands/misc.rs
T

5330 lines
214 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(non_snake_case)]
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
let url = if url.starts_with("http://") || url.starts_with("https://") {
url
} else {
format!("https://{url}")
};
app.opener()
.open_url(&url, None::<String>)
.map_err(|e| format!("打开链接失败: {e}"))?;
Ok(true)
}
#[tauri::command]
pub async fn copy_text_to_clipboard(text: String) -> Result<bool, String> {
// Use spawn_blocking to avoid blocking the async runtime
// Clipboard access can block on some platforms and may have thread/loop constraints
tokio::task::spawn_blocking(move || {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("访问系统剪贴板失败: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("写入系统剪贴板失败: {e}"))?;
Ok(true)
})
.await
.map_err(|e| format!("剪贴板任务执行失败: {e}"))?
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {e}"))?;
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// 获取应用启动阶段的初始化错误(若有)。
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
#[tauri::command]
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
Ok(crate::init_status::get_init_error())
}
/// 获取 JSON→SQLite 迁移结果(若有)。
/// 只返回一次 true,之后返回 false,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
Ok(crate::init_status::take_skills_migration_result())
}
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
version: Option<String>,
latest_version: Option<String>, // 新增字段:最新版本
error: Option<String>,
/// 已定位到可执行文件、但 `--version` 报错退出(装了却跑不起来,如 Node 版本不达标)。
/// 供前端区分"未安装"与"已安装·无法运行",无需匹配 error 文案反推语义。
installed_but_broken: bool,
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
env_type: String,
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 6] = [
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
];
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WslShellPreferenceInput {
#[serde(default)]
pub wsl_shell: Option<String>,
#[serde(default)]
pub wsl_shell_flag: Option<String>,
}
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
#[cfg(target_os = "windows")]
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
if let Some(distro) = wsl_distro_for_tool(tool) {
("wsl".to_string(), Some(distro))
} else {
("windows".to_string(), None)
}
}
#[cfg(target_os = "macos")]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("macos".to_string(), None)
}
#[cfg(target_os = "linux")]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("linux".to_string(), None)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("unknown".to_string(), None)
}
#[tauri::command]
pub async fn get_tool_versions(
tools: Option<Vec<String>>,
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
) -> Result<Vec<ToolVersion>, String> {
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
VALID_TOOLS
.iter()
.copied()
.filter(|t| set.contains(t))
.collect()
} else {
VALID_TOOLS.to_vec()
};
let mut results = Vec::new();
for tool in requested {
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
}
Ok(results)
}
#[tauri::command]
pub async fn run_tool_lifecycle_action(
tools: Vec<String>,
action: String,
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
) -> Result<(), String> {
let action = ToolLifecycleAction::from_str(&action)?;
let requested = normalize_requested_tools(&tools);
if requested.is_empty() {
return Err("No supported tools selected".to_string());
}
let label = match action {
ToolLifecycleAction::Install => "tool_install",
ToolLifecycleAction::Update => "tool_update",
};
// build 阶段含锚定探测(对每个工具跑 `--version` 定位命令行实际命中那处),
// 与执行一并放进 blocking 线程,避免阻塞 async runtime。
tokio::task::spawn_blocking(move || {
let command_line =
build_tool_lifecycle_command(&requested, action, wsl_shell_by_tool.as_ref())?;
run_tool_lifecycle_silently(&command_line, label)
})
.await
.map_err(|e| format!("tool lifecycle task join error: {e}"))?
}
/// 静默执行工具安装/更新脚本:直接捕获子进程输出并阻塞到命令真正结束,
/// 不再弹出可见终端窗口(与 `launch_terminal_running` 的"开窗即返回"形成对比,
/// 后者仍保留给 provider 切换等需要交互式终端的场景)。
/// 失败时回传 stderr/stdout 末尾若干行,供前端 toast 提示。
#[cfg(not(target_os = "windows"))]
fn run_tool_lifecycle_silently(command_line: &str, _label: &str) -> Result<(), String> {
use std::process::Command;
// command_line 是 bash 风格脚本(含 `set -e` 与多行命令);强制用 bash 执行,
// 避免用户默认 shell 为 fish/zsh 时 `set -e` 等语义不一致。
let output = Command::new("bash")
.arg("-c")
.arg(command_line)
.output()
.map_err(|e| format!("启动安装进程失败: {e}"))?;
finish_lifecycle_output(&output)
}
/// Windows 静默执行:command_line 是 .bat 内容(@echo off + call/wsl 行,CRLF 分隔),
/// 写临时 .bat 后用 `cmd /C` 执行,`CREATE_NO_WINDOW` 抑制 console 窗口。
#[cfg(target_os = "windows")]
fn run_tool_lifecycle_silently(command_line: &str, label: &str) -> Result<(), String> {
use std::os::windows::process::CommandExt;
use std::process::Command;
let bat_file =
std::env::temp_dir().join(format!("cc_switch_{}_{}.bat", label, std::process::id()));
std::fs::write(&bat_file, command_line).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let output = Command::new("cmd")
.arg("/C")
.arg(&bat_file)
.creation_flags(CREATE_NO_WINDOW)
.output();
let _ = std::fs::remove_file(&bat_file);
finish_lifecycle_output(&output.map_err(|e| format!("启动安装进程失败: {e}"))?)
}
/// 把子进程退出结果转成 `Result`:成功返回 `Ok`;失败提取 stderr(空则回退 stdout
/// 的末尾若干行作为错误详情,避免把整段安装日志塞进 toast。
fn finish_lifecycle_output(output: &std::process::Output) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = decode_command_output(&output.stderr);
let stdout = decode_command_output(&output.stdout);
let raw = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
let detail = last_lines(raw, 8);
Err(if detail.is_empty() {
format!("命令执行失败 (exit code: {:?})", output.status.code())
} else {
detail
})
}
/// 取文本末尾最多 `n` 行(npm / pip 的关键错误通常出现在输出尾部)。
fn last_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
fn decode_command_output(bytes: &[u8]) -> String {
#[cfg(target_os = "windows")]
{
decode_windows_command_output(bytes)
}
#[cfg(not(target_os = "windows"))]
{
String::from_utf8_lossy(bytes).into_owned()
}
}
#[cfg(target_os = "windows")]
fn decode_windows_command_output(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_string();
}
use windows_sys::Win32::Globalization::{GetACP, GetOEMCP, MultiByteToWideChar};
fn decode_codepage(bytes: &[u8], codepage: u32) -> Option<String> {
if codepage == 0 {
return None;
}
let input_len = i32::try_from(bytes.len()).ok()?;
unsafe {
let wide_len = MultiByteToWideChar(
codepage,
0,
bytes.as_ptr(),
input_len,
std::ptr::null_mut(),
0,
);
if wide_len <= 0 {
return None;
}
let mut wide = vec![0u16; wide_len as usize];
let written = MultiByteToWideChar(
codepage,
0,
bytes.as_ptr(),
input_len,
wide.as_mut_ptr(),
wide_len,
);
if written <= 0 {
return None;
}
Some(String::from_utf16_lossy(&wide[..written as usize]))
}
}
let oem_cp = unsafe { GetOEMCP() };
if let Some(decoded) = decode_codepage(bytes, oem_cp) {
return decoded;
}
let ansi_cp = unsafe { GetACP() };
if ansi_cp != oem_cp {
if let Some(decoded) = decode_codepage(bytes, ansi_cp) {
return decoded;
}
}
String::from_utf8_lossy(bytes).into_owned()
}
fn normalize_requested_tools(tools: &[String]) -> Vec<&'static str> {
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
VALID_TOOLS
.iter()
.copied()
.filter(|tool| set.contains(tool))
.collect()
}
#[derive(Debug, Clone, Copy)]
enum ToolLifecycleAction {
Install,
Update,
}
impl FromStr for ToolLifecycleAction {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"install" => Ok(Self::Install),
"update" => Ok(Self::Update),
_ => Err(format!("Unsupported tool action: {value}")),
}
}
}
fn build_tool_lifecycle_command(
tools: &[&str],
action: ToolLifecycleAction,
wsl_shell_by_tool: Option<&HashMap<String, WslShellPreferenceInput>>,
) -> Result<String, String> {
let mut lines = Vec::new();
#[cfg(not(target_os = "windows"))]
{
// set -e 让任一步失败即中止;set -o pipefail 保留为管道命令的兜底防线。
// 当前官方 installer 路径已避免 `curl | bash`,但未来若新增管道命令,
// 仍应让管道前段失败参与整条脚本判定。
lines.push("set -e".to_string());
lines.push("set -o pipefail".to_string());
}
#[cfg(target_os = "windows")]
lines.push("@echo off".to_string());
for tool in tools {
let label = tool_display_name(tool);
lines.push(format!("echo ========== {label} =========="));
let pref = wsl_shell_by_tool.and_then(|m| m.get(*tool));
let line = build_tool_action_line(
tool,
action,
pref.and_then(|p| p.wsl_shell.as_deref()),
pref.and_then(|p| p.wsl_shell_flag.as_deref()),
)?;
lines.push(line);
#[cfg(target_os = "windows")]
lines.push("if errorlevel 1 exit /b %errorlevel%".to_string());
#[cfg(not(target_os = "windows"))]
lines.push(String::new());
}
Ok(lines.join(if cfg!(target_os = "windows") {
"\r\n"
} else {
"\n"
}))
}
fn tool_display_name(tool: &str) -> &'static str {
match tool {
"claude" => "Claude Code",
"codex" => "Codex",
"gemini" => "Gemini CLI",
"opencode" => "OpenCode",
"openclaw" => "OpenClaw",
"hermes" => "Hermes",
_ => "Unknown",
}
}
/// 官方 shell installer 都不用 `curl | bash` 这种 pipe 形式(仍然用 curl 下载,
/// 只是先落到临时文件再交给 bash 执行):WSL 分支会在
/// `wsl.exe ... -- sh -c "<cmd>"` 子 shell 里执行命令,外层脚本的 `set -o pipefail`
/// 不会继承进去;而 WSL 默认 shell 可能是 dash/ash,也不能假设支持 `set -o pipefail`。
/// 先下载到 mktemp 文件再交给 bash,能让 curl 失败稳定变成整条命令失败。
const CLAUDE_INSTALL_UNIX: &str =
"bash -c 'tmp=$(mktemp) && curl -fsSL https://claude.ai/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
const OPENCODE_INSTALL_UNIX: &str =
"bash -c 'tmp=$(mktemp) && curl -fsSL https://opencode.ai/install -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
/// Hermes 官方安装器会自带/选择合适的 Python 运行时。不要再用
/// `python3 -m pip ... || python -m pip ...`:Hermes PyPI 包要求 Python >=3.11,
/// 但 macOS 系统 `python3` 常是 3.9,而 pyenv 下 `python` shim 还可能不存在,会把
/// 真正的 Python 版本问题盖成 "python command exists in these Python versions"。
const HERMES_INSTALL_UNIX: &str =
"bash -c 'tmp=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
const HERMES_UPDATE_UNIX: &str =
"hermes update || bash -c 'tmp=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
#[cfg(target_os = "windows")]
const HERMES_INSTALL_WINDOWS_SCRIPT: &str =
"irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex";
#[cfg(target_os = "windows")]
fn powershell_encoded_command(script: &str) -> String {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let mut bytes = Vec::with_capacity(script.len() * 2);
for unit in script.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
STANDARD.encode(bytes)
}
#[cfg(target_os = "windows")]
fn hermes_install_windows_command() -> String {
format!(
"powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {}",
powershell_encoded_command(HERMES_INSTALL_WINDOWS_SCRIPT)
)
}
#[cfg(target_os = "windows")]
fn hermes_update_windows_command() -> String {
// fallback 是 powershell.exe,不是 .cmd/.bat;这里不需要 `call`。PowerShell 的
// `irm | iex` 已被 EncodedCommand 收进单一参数,避免 `cmd.exe` 解析管道符。
format!("hermes update || {}", hermes_install_windows_command())
}
#[derive(Debug, Clone, Copy)]
enum LifecycleCommandShell {
Posix,
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
WindowsBatch,
}
fn npm_install_command_for(tool: &str) -> Option<&'static str> {
match tool {
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
"codex" => Some("npm i -g @openai/codex@latest"),
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
"opencode" => Some("npm i -g opencode-ai@latest"),
"openclaw" => Some("npm i -g openclaw@latest"),
_ => None,
}
}
fn official_update_args(tool: &str) -> Option<&'static str> {
match tool {
"claude" | "codex" | "hermes" => Some("update"),
"openclaw" => Some("update --yes"),
"opencode" => Some("upgrade"),
_ => None,
}
}
fn bare_official_update_command(tool: &str) -> Option<String> {
official_update_args(tool).map(|args| format!("{tool} {args}"))
}
fn chain_update_commands(
primary: String,
fallback: String,
shell: LifecycleCommandShell,
) -> String {
if fallback.trim().is_empty() {
return primary;
}
match shell {
LifecycleCommandShell::Posix => format!("{primary} || {fallback}"),
// 这段最终会被外层再包成 `call <command>`。fallback 若是 npm.cmd/pnpm.cmd,
// `||` 右侧也必须显式 `call`,否则批处理会转移控制权并跳过后续工具。
LifecycleCommandShell::WindowsBatch => format!("{primary} || call {fallback}"),
}
}
fn tool_action_shell_command_for_shell(
tool: &str,
action: ToolLifecycleAction,
shell: LifecycleCommandShell,
) -> Option<String> {
if tool == "hermes" {
return Some(
match (action, shell) {
(ToolLifecycleAction::Install, LifecycleCommandShell::Posix) => HERMES_INSTALL_UNIX,
(ToolLifecycleAction::Update, LifecycleCommandShell::Posix) => HERMES_UPDATE_UNIX,
#[cfg(target_os = "windows")]
(ToolLifecycleAction::Install, LifecycleCommandShell::WindowsBatch) => {
return Some(hermes_install_windows_command());
}
#[cfg(target_os = "windows")]
(ToolLifecycleAction::Update, LifecycleCommandShell::WindowsBatch) => {
return Some(hermes_update_windows_command());
}
#[cfg(not(target_os = "windows"))]
(_, LifecycleCommandShell::WindowsBatch) => return None,
}
.to_string(),
);
}
let install = npm_install_command_for(tool)?;
match action {
ToolLifecycleAction::Install => Some(install.to_string()),
ToolLifecycleAction::Update => match prefers_official_update(tool, shell)
.then(|| bare_official_update_command(tool))
.flatten()
{
Some(update) => Some(chain_update_commands(update, install.to_string(), shell)),
None => Some(install.to_string()),
},
}
}
fn tool_action_shell_command(tool: &str, action: ToolLifecycleAction) -> Option<String> {
#[cfg(target_os = "windows")]
let shell = LifecycleCommandShell::WindowsBatch;
#[cfg(not(target_os = "windows"))]
let shell = LifecycleCommandShell::Posix;
tool_action_shell_command_for_shell(tool, action, shell)
}
/// Windows host 上的 WSL 分支专用:`tool_action_shell_command` 在 Windows target 编译
/// 出的版本会包含 Windows batch 语义(例如 `|| call npm ...`)且 hermes 会返回
/// Windows PowerShell installer,但跨 `wsl.exe` 边界后跑的是 Linux。这个 wrapper
/// 强制生成 POSIX 版命令。
#[cfg(target_os = "windows")]
fn wsl_tool_action_shell_command(tool: &str, action: ToolLifecycleAction) -> Option<String> {
match action {
ToolLifecycleAction::Install => {
let command = posix_install_command_for(tool);
if command.is_empty() {
None
} else {
Some(command)
}
}
ToolLifecycleAction::Update => {
tool_action_shell_command_for_shell(tool, action, LifecycleCommandShell::Posix)
}
}
}
fn build_tool_action_line(
tool: &str,
action: ToolLifecycleAction,
wsl_shell: Option<&str>,
wsl_shell_flag: Option<&str>,
) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
// ① WSL 工具(override 是 UNC `\\wsl$\<distro>\...`):锚定的绝对路径是 Windows
// 主机路径,跨 wsl.exe 进入 distro 文件系统后无效;且 enumerate 不参与 WSL。
// install 走 POSIX 安装优先级,update 走 POSIX 静态/官方 update 命令,
// 再通过 wsl.exe -d distro -- sh 包一层。
// **必须用 wsl_tool_action_shell_command 而非 tool_action_shell_command**:
// 后者在 Windows target 给 hermes 返回 PowerShell installer,且 Windows batch
// 语义也不适合跨 wsl.exe;这里统一替换为 POSIX 版安装/更新命令。
if let Some(distro) = wsl_distro_for_tool(tool) {
let command = wsl_tool_action_shell_command(tool, action)
.ok_or_else(|| format!("Unsupported tool action target: {tool}"))?;
return build_wsl_tool_action_line(&distro, &command, wsl_shell, wsl_shell_flag);
}
// ② Windows 原生 update 锚定;install 走静态(install.sh 是 bash 脚本,Windows
// 无意义)。**`enumerate_tool_installations` 在这里 per-tool 重做、与前端
// probe 阶段算过的结果不共享是 by design**:run_tool_lifecycle_action 是
// 独立 IPC 调用,不信任前端回传的命令字符串(避免命令注入面扩大);前端是
// 逐工具触发 lifecycle,batch 化会破坏"逐工具独立成败"的 UX。
let command = match action {
ToolLifecycleAction::Update => {
let installs = enumerate_tool_installations(tool);
installs_anchored_command(tool, &installs)
.unwrap_or_else(|| static_fallback_command(tool))
}
ToolLifecycleAction::Install => {
static_fallback_command_for(tool, ToolLifecycleAction::Install)
}
};
if command.is_empty() {
return Err(format!("Unsupported tool action target: {tool}"));
}
// .bat 调用 .cmd/.bat 必须用 `call` 否则当前脚本被替换、后续 `if errorlevel`
// 行被跳过;对 .exe 加 call 无害(等同直接调用)。锚定命令头部可能是 .cmd
// (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等——
// 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch`
// 加上双引号,call 对带引号的路径解析正常。
return Ok(format!("call {command}"));
}
#[cfg(not(target_os = "windows"))]
{
let _ = (wsl_shell, wsl_shell_flag);
// update 锚定到命令行实际命中的那处(写回同一个 node / brew / 原生安装器),
// 而非裸 `npm` 落到 PATH 第一个 npminstall 走「上游推荐 || npm 兜底」短路链
// (有 native installer 的工具如 claude/opencode/hermes),其余仍裸 npm。
let command = match action {
ToolLifecycleAction::Update => {
let installs = enumerate_tool_installations(tool);
installs_anchored_command(tool, &installs)
.unwrap_or_else(|| static_fallback_command(tool))
}
ToolLifecycleAction::Install => install_command_for(tool),
};
if command.is_empty() {
return Err(format!("Unsupported tool action target: {tool}"));
}
Ok(command)
}
}
#[cfg(target_os = "windows")]
fn build_wsl_tool_action_line(
distro: &str,
command: &str,
force_shell: Option<&str>,
force_shell_flag: Option<&str>,
) -> Result<String, String> {
if !is_valid_wsl_distro_name(distro) {
return Err(format!("Invalid WSL distro name: {distro}"));
}
let shell = force_shell
.map(|s| s.rsplit('/').next().unwrap_or(s))
.unwrap_or("sh");
if !is_valid_shell(shell) {
return Err(format!("Invalid WSL shell: {shell}"));
}
let flag = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return Err(format!("Invalid WSL shell flag: {flag}"));
}
flag
} else {
default_flag_for_shell(shell)
};
Ok(format!(
"wsl.exe -d {distro} -- {shell} {flag} {}",
windows_cmd_double_quote_arg(command)
))
}
/// Windows 双引号包裹基础原语:无条件加引号 + 内部 `"` 转义为 `\"`。
/// `windows_cmd_double_quote_arg`(给 wsl.exe 传 bash 命令字符串用)与
/// `win_quote_path_for_batch`(给锚定路径用)都基于它,避免两份 quoter 各自演化、
/// 未来对同一路径产生不一致引用形态。镜像 POSIX 侧 `shell_single_quote` 与
/// `quote_path_if_spaced` 的"重量基础 + 轻量条件包装"两层结构。
#[cfg(target_os = "windows")]
fn win_double_quote(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\\\""))
}
#[cfg(target_os = "windows")]
fn windows_cmd_double_quote_arg(value: &str) -> String {
win_double_quote(value)
}
/// 获取单个工具的版本信息(内部实现)
async fn get_single_tool_version_impl(
tool: &str,
wsl_shell: Option<&str>,
wsl_shell_flag: Option<&str>,
) -> ToolVersion {
debug_assert!(
VALID_TOOLS.contains(&tool),
"unexpected tool name in get_single_tool_version_impl: {tool}"
);
// 判断该工具的运行环境 & WSL distro(如有)
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
// 1. 获取本地版本
let probe = if let Some(distro) = wsl_distro.as_deref() {
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
} else {
#[cfg(target_os = "windows")]
{
// Windows 上只执行已经定位到的真实可执行文件,避免 `cmd /C tool`
// 误触发 App Execution Alias 或协议处理器。
scan_cli_version(tool)
}
#[cfg(not(target_os = "windows"))]
{
// PATH 第一个命令优先;只有它确实没装(NotFound)才去常见目录兜底扫描。
match try_get_version(tool) {
ShellProbe::NotFound(_) => scan_cli_version(tool),
found => found,
}
}
};
let (local_version, local_error, installed_but_broken) = match probe {
ShellProbe::Found(v) => (Some(v), None, false),
ShellProbe::FoundButFailed(e) => (None, Some(e), true),
ShellProbe::NotFound(e) => (None, Some(e), false),
};
// 2. 获取远程最新版本(npm 工具在本地领先 latest 时会按预发布通道补查,见
// fetch_npm_latest_for_tool / npm_prerelease_tags
let local = local_version.as_deref();
let latest_version = match tool {
"claude" => {
fetch_npm_latest_for_tool(&client, "@anthropic-ai/claude-code", tool, local).await
}
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
"opencode" => {
if let Some(version) =
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
{
Some(version)
} else {
fetch_github_latest_version(&client, "anomalyco/opencode").await
}
}
"openclaw" => fetch_npm_latest_for_tool(&client, "openclaw", tool, local).await,
"hermes" => fetch_pypi_latest_version(&client, "hermes-agent").await,
_ => None,
};
ToolVersion {
name: tool.to_string(),
version: local_version,
latest_version,
error: local_error,
installed_but_broken,
env_type,
wsl_distro,
}
}
/// 该工具在 npm 上的预发布通道 tag(靠前者优先)。仅当本地版本已**严格领先**
/// `latest` 时才会被补查 —— 让主动在抢先通道的用户(如走 Claude Code 的 `next`)
/// 看到与所在通道对齐的"最新版本",同时绝不把稳定通道用户暴露给预发布版。
/// 返回空切片表示该工具只看 `latest`、不补查。
///
/// 为何不通用覆盖所有工具:各家预发布 tag 命名互不统一(codex=alpha/beta/native、
/// gemini=nightly/preview、openclaw=alpha/beta),且 codex 的 beta/native 是
/// `0.1.x` 时间戳式版本、gemini 有误发的 `false` tag —— 这些脏值虽会被
/// `pick_latest_version` 的版本比较挡掉,但维护成本与误报风险不值当,故暂只为
/// Claude Code 启用。
fn npm_prerelease_tags(tool: &str) -> &'static [&'static str] {
match tool {
"claude" => &["next"],
_ => &[],
}
}
/// 解析 "2.1.156" / "2.1.156-beta.1" → (主版本三段, 预发布段)。无法解析返回 None。
/// 与前端 `src/lib/version.ts` 的 parseVersion 语义对称(跨语言各实现一份)。
/// patch 用 u64 以容纳 codex 的 `0.1.2505172116` 时间戳式版本而不溢出。
fn parse_semver(v: &str) -> Option<([u64; 3], Vec<String>)> {
// 忽略 `+build` 元数据,再以首个 `-` 切出预发布段。
let core_and_pre = v.trim().split('+').next().unwrap_or("");
let (core, pre) = match core_and_pre.split_once('-') {
Some((c, p)) => (c, Some(p)),
None => (core_and_pre, None),
};
let mut parts = core.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch = parts.next()?.parse::<u64>().ok()?;
if parts.next().is_some() {
return None; // 多于三段,非法
}
let pre_segments = pre
.map(|p| p.split('.').map(|s| s.to_string()).collect())
.unwrap_or_default();
Some(([major, minor, patch], pre_segments))
}
/// 比较两个版本号(遵循 semver:主版本三段优先;core 相等时有预发布 < 无预发布;
/// 预发布段逐段比 —— 数字段按数值、数字段 < 非数字段、非数字段按 ASCII、前缀相同
/// 则段更多者更大)。任一无法解析返回 None,调用方据此保守处理。
fn compare_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
use std::cmp::Ordering;
let (ac, ap) = parse_semver(a)?;
let (bc, bp) = parse_semver(b)?;
for i in 0..3 {
match ac[i].cmp(&bc[i]) {
Ordering::Equal => continue,
other => return Some(other),
}
}
match (ap.is_empty(), bp.is_empty()) {
(true, true) => return Some(Ordering::Equal),
(true, false) => return Some(Ordering::Greater),
(false, true) => return Some(Ordering::Less),
(false, false) => {}
}
for (x, y) in ap.iter().zip(bp.iter()) {
let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(xv), Ok(yv)) => xv.cmp(&yv),
(Ok(_), Err(_)) => Ordering::Less, // 数字段 < 非数字段
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => x.as_str().cmp(y.as_str()),
};
if ord != Ordering::Equal {
return Some(ord);
}
}
Some(ap.len().cmp(&bp.len()))
}
/// 从一次 registry 请求得到的完整 dist-tags 出发,挑选要展示的"最新版本"。
///
/// 规则:默认就是 `latest`;仅当本地版本已**严格领先** `latest`(说明用户主动在
/// 抢先通道)时,才把 `prerelease_tags` 指向的版本纳入比较,取其中能被解析、且
/// 高于 `latest` 的最高者。无法解析或不高于 latest 的脏 tag 一律落选。
fn pick_latest_version(
dist_tags: &serde_json::Map<String, serde_json::Value>,
prerelease_tags: &[&str],
local_version: Option<&str>,
) -> Option<String> {
use std::cmp::Ordering;
let latest = dist_tags.get("latest").and_then(|v| v.as_str())?;
// 本地是否严格领先 latest;任一无法解析则按"未领先"保守处理(只看 latest)。
let local_ahead = local_version
.and_then(|local| compare_semver(local, latest))
.map(|ord| ord == Ordering::Greater)
.unwrap_or(false);
if prerelease_tags.is_empty() || !local_ahead {
return Some(latest.to_string());
}
let mut best = latest.to_string();
for tag in prerelease_tags {
if let Some(candidate) = dist_tags.get(*tag).and_then(|v| v.as_str()) {
if compare_semver(candidate, &best) == Some(Ordering::Greater) {
best = candidate.to_string();
}
}
}
Some(best)
}
/// 拉取 npm 包的完整 dist-tags(单次请求即含 latest/next/beta/...)。
async fn fetch_npm_dist_tags(
client: &reqwest::Client,
package: &str,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let url = format!("https://registry.npmjs.org/{package}");
let resp = client.get(&url).send().await.ok()?;
let json = resp.json::<serde_json::Value>().await.ok()?;
json.get("dist-tags")?.as_object().cloned()
}
/// 查询某 npm 工具要展示的"最新版本":取 `latest`,并在本地版本领先时按工具的
/// 预发布通道(见 `npm_prerelease_tags`)补查 —— 复用同一次 registry 响应,无额外请求。
async fn fetch_npm_latest_for_tool(
client: &reqwest::Client,
package: &str,
tool: &str,
local_version: Option<&str>,
) -> Option<String> {
let dist_tags = fetch_npm_dist_tags(client, package).await?;
pick_latest_version(&dist_tags, npm_prerelease_tags(tool), local_version)
}
/// Helper function to fetch latest version from GitHub releases
async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
match client
.get(&url)
.header("User-Agent", "cc-switch")
.header("Accept", "application/vnd.github+json")
.send()
.await
{
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.strip_prefix('v').unwrap_or(s).to_string())
} else {
None
}
}
Err(_) => None,
}
}
/// Helper function to fetch latest version from PyPI
async fn fetch_pypi_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
let url = format!("https://pypi.org/pypi/{package}/json");
match client.get(&url).send().await {
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("info")
.and_then(|info| info.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
Err(_) => None,
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
VERSION_RE
.find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
/// 工具未安装时的统一错误文案;WSL 路径会再拼上 `[WSL:{distro}] ` 前缀。
const NOT_INSTALLED: &str = "not installed or not executable";
/// CLI 版本探测的三态结果,跨平台统一各 probe`try_get_version` /
/// `try_get_version_wsl` / `scan_cli_version`)的返回,进而在 `ToolVersion` 上给出
/// 结构化的 `installed_but_broken` 信号——避免前端靠匹配错误文案反推语义。
///
/// 关键区分"没装"与"装了但 `--version` 自身报错退出"(如工具要求更高的 Node 版本):
/// 后者必须如实上报、不去别处捞旧版掩盖,否则"升级到新版却跑不起来"会被旧版盖住,
/// 表现为"升级成功但版本号不变"。
enum ShellProbe {
/// 成功拿到版本号
Found(String),
/// 可执行存在、但 `--version` 非零退出(携带诊断信息,如 stderr 末尾若干行)
FoundButFailed(String),
/// 没找到该命令(携带描述性消息,供 UI 展示)
NotFound(String),
}
/// 在非 Windows 平台用用户 shell 执行 `{tool} --version` 探测版本。
///
/// Windows 不走此路径:`cmd /C {tool}` 可能误触发 App Execution Alias /
/// 协议处理器(曾导致 Windows 版整体被禁用),那里改由 `scan_cli_version`
/// 只执行已定位到的真实可执行文件。
#[cfg(not(target_os = "windows"))]
fn try_get_version(tool: &str) -> ShellProbe {
use std::process::Command;
let output = {
let shell = std::env::var("SHELL")
.ok()
.filter(|s| is_valid_shell(s))
.unwrap_or_else(|| "sh".to_string());
let flag = default_flag_for_shell(&shell);
Command::new(shell)
.arg(flag)
.arg(format!("{tool} --version"))
.output()
};
match output {
Ok(out) => {
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
ShellProbe::NotFound(NOT_INSTALLED.to_string())
} else {
ShellProbe::Found(extract_version(raw))
}
} else {
// exit 127 = shell 找不到命令(可放心 fallback 到搜索路径);其它非零码
// = 命令存在但 --version 自身报错退出,须如实上报、不 fallback 掩盖。
let err = if stderr.is_empty() { stdout } else { stderr };
if out.status.code() == Some(127) || err.is_empty() {
ShellProbe::NotFound(NOT_INSTALLED.to_string())
} else {
ShellProbe::FoundButFailed(last_lines(err.trim(), 4))
}
}
}
Err(_) => ShellProbe::NotFound(NOT_INSTALLED.to_string()),
}
}
/// 校验 WSL 发行版名称是否合法
/// WSL 发行版名称只允许字母、数字、连字符和下划线
#[cfg(target_os = "windows")]
fn is_valid_wsl_distro_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
/// Validate that the given shell name is one of the allowed shells.
fn is_valid_shell(shell: &str) -> bool {
matches!(
shell.rsplit('/').next().unwrap_or(shell),
"sh" | "bash" | "zsh" | "fish" | "dash"
)
}
/// Validate that the given shell flag is one of the allowed flags.
#[cfg(target_os = "windows")]
fn is_valid_shell_flag(flag: &str) -> bool {
matches!(flag, "-c" | "-lc" | "-lic")
}
/// Return the default invocation flag for the given shell.
fn default_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
"fish" => "-lc",
_ => "-lic",
}
}
fn fallback_user_shell() -> &'static str {
if cfg!(target_os = "macos") {
"/bin/zsh"
} else {
"/bin/bash"
}
}
fn valid_user_shell_path(shell: &str) -> bool {
if shell.is_empty()
|| !shell.starts_with('/')
|| !is_valid_shell(shell)
|| shell.chars().any(char::is_control)
{
return false;
}
let path = std::path::Path::new(shell);
path.is_file() && is_executable_file(path)
}
#[cfg(unix)]
fn is_executable_file(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.metadata()
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(path: &std::path::Path) -> bool {
path.is_file()
}
/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。
fn get_user_shell() -> String {
std::env::var("SHELL")
.ok()
.filter(|shell| valid_user_shell_path(shell))
.unwrap_or_else(|| fallback_user_shell().to_string())
}
/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。
fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
let quoted_shell = shell_single_quote(shell);
match shell.rsplit('/').next().unwrap_or(shell) {
"zsh" => cwd
.map(|dir| {
let command = format!(
"cd {} || exit 1; exec {} -i",
shell_single_quote(&dir.to_string_lossy()),
quoted_shell
);
format!("exec {} -lc {}", quoted_shell, shell_single_quote(&command))
})
.unwrap_or_else(|| format!("exec {quoted_shell} -l")),
_ => format!("exec {quoted_shell}"),
}
}
/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。
fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String {
let claude_command = format!("claude --settings {}", shell_single_quote(config_path));
let command = cwd
.map(|dir| {
format!(
"cd {} && {}",
shell_single_quote(&dir.to_string_lossy()),
claude_command
)
})
.unwrap_or(claude_command);
format!(
"{} {} {}",
shell_single_quote(shell),
provider_command_flag_for_shell(shell),
shell_single_quote(&command)
)
}
fn provider_command_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
"zsh" => "-lic",
_ => "-ic",
}
}
fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String {
if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") {
return String::new();
}
cwd.map(|dir| {
format!(
"cd {} || exit 1\n",
shell_single_quote(&dir.to_string_lossy())
)
})
.unwrap_or_default()
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(
tool: &str,
distro: &str,
force_shell: Option<&str>,
force_shell_flag: Option<&str>,
) -> ShellProbe {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
debug_assert!(VALID_TOOLS.contains(&tool), "unexpected tool name: {tool}");
// 校验 distro 名称,防止命令注入
if !is_valid_wsl_distro_name(distro) {
return ShellProbe::NotFound(format!("[WSL:{distro}] invalid distro name"));
}
// 构建 Shell 脚本检测逻辑
let (shell, flag, cmd) = if let Some(shell) = force_shell {
// Defensive validation: never allow an arbitrary executable name here.
if !is_valid_shell(shell) {
return ShellProbe::NotFound(format!("[WSL:{distro}] invalid shell: {shell}"));
}
let shell = shell.rsplit('/').next().unwrap_or(shell);
let flag = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return ShellProbe::NotFound(format!("[WSL:{distro}] invalid shell flag: {flag}"));
}
flag
} else {
default_flag_for_shell(shell)
};
(shell.to_string(), flag, format!("{tool} --version"))
} else {
let cmd = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return ShellProbe::NotFound(format!("[WSL:{distro}] invalid shell flag: {flag}"));
}
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
} else {
// 兜底:自动尝试 -lic, -lc, -c
format!(
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
)
};
("sh".to_string(), "-c", cmd)
};
let output = Command::new("wsl.exe")
.args(["-d", distro, "--", &shell, flag, &cmd])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
Ok(out) => {
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
ShellProbe::NotFound(format!("[WSL:{distro}] {NOT_INSTALLED}"))
} else {
ShellProbe::Found(extract_version(raw))
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
// wsl.exe 透传的退出码不总可靠,故同时用 exit 127 与 "command not found"
// 文本兜底判别"没装";其余非零退出视作"装了但 --version 报错"。
let not_found = err.is_empty()
|| out.status.code() == Some(127)
|| err.contains("command not found")
|| err.contains("not found");
if not_found {
ShellProbe::NotFound(format!("[WSL:{distro}] {NOT_INSTALLED}"))
} else {
ShellProbe::FoundButFailed(format!(
"[WSL:{distro}] {}",
last_lines(err.trim(), 4)
))
}
}
}
Err(e) => ShellProbe::NotFound(format!("[WSL:{distro}] exec failed: {e}")),
}
}
/// 非 Windows 平台的 WSL 版本检测存根
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(
_tool: &str,
_distro: &str,
_force_shell: Option<&str>,
_force_shell_flag: Option<&str>,
) -> ShellProbe {
ShellProbe::NotFound("WSL check not supported on this platform".to_string())
}
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if path.as_os_str().is_empty() {
return;
}
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn push_env_single_dir(paths: &mut Vec<std::path::PathBuf>, value: Option<std::ffi::OsString>) {
if let Some(raw) = value {
push_unique_path(paths, std::path::PathBuf::from(raw));
}
}
fn extend_from_path_list(
paths: &mut Vec<std::path::PathBuf>,
value: Option<std::ffi::OsString>,
suffix: Option<&str>,
) {
if let Some(raw) = value {
for p in std::env::split_paths(&raw) {
let dir = match suffix {
Some(s) => p.join(s),
None => p,
};
push_unique_path(paths, dir);
}
}
}
fn extend_from_cli_path_env(
paths: &mut Vec<std::path::PathBuf>,
value: Option<std::ffi::OsString>,
) {
if let Some(raw) = value {
for p in std::env::split_paths(&raw) {
if should_skip_cli_path_env_dir(&p) {
continue;
}
push_unique_path(paths, p);
}
}
}
fn should_skip_cli_path_env_dir(path: &Path) -> bool {
#[cfg(target_os = "windows")]
{
is_windows_app_execution_alias_dir(path)
}
#[cfg(not(target_os = "windows"))]
{
let _ = path;
false
}
}
#[cfg(target_os = "windows")]
fn is_windows_app_execution_alias_dir(path: &Path) -> bool {
let normalized = path
.to_string_lossy()
.replace('/', "\\")
.to_ascii_lowercase();
normalized
.trim_end_matches('\\')
.ends_with("\\microsoft\\windowsapps")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn push_env_child_dir(
paths: &mut Vec<std::path::PathBuf>,
value: Option<std::ffi::OsString>,
child: &str,
) {
if let Some(raw) = value {
push_unique_path(paths, std::path::PathBuf::from(raw).join(child));
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn extend_existing_child_search_paths(
paths: &mut Vec<std::path::PathBuf>,
base: &Path,
suffix: Option<&str>,
) {
if !base.exists() {
return;
}
if let Ok(entries) = std::fs::read_dir(base) {
for entry in entries.flatten() {
let path = match suffix {
Some(suffix) => entry.path().join(suffix),
None => entry.path(),
};
if path.exists() {
push_unique_path(paths, path);
}
}
}
}
#[cfg(target_os = "windows")]
fn extend_windows_cli_manager_search_paths(paths: &mut Vec<std::path::PathBuf>, home: &Path) {
push_env_single_dir(paths, std::env::var_os("PNPM_HOME"));
push_env_child_dir(paths, std::env::var_os("VOLTA_HOME"), "bin");
push_env_single_dir(paths, std::env::var_os("NVM_SYMLINK"));
push_env_child_dir(paths, std::env::var_os("SCOOP"), "shims");
push_env_child_dir(paths, std::env::var_os("SCOOP_GLOBAL"), "shims");
if let Some(nvm_home) = std::env::var_os("NVM_HOME") {
let nvm_home = std::path::PathBuf::from(nvm_home);
push_unique_path(paths, nvm_home.clone());
extend_existing_child_search_paths(paths, &nvm_home, None);
}
if let Some(appdata) = dirs::data_dir() {
let nvm_home = appdata.join("nvm");
push_unique_path(paths, nvm_home.clone());
extend_existing_child_search_paths(paths, &nvm_home, None);
}
if !home.as_os_str().is_empty() {
push_unique_path(paths, home.join("scoop").join("shims"));
}
if let Some(local_data) = dirs::data_local_dir() {
push_unique_path(paths, local_data.join("pnpm"));
push_unique_path(paths, local_data.join("Volta").join("bin"));
push_unique_path(paths, local_data.join("Yarn").join("bin"));
}
let program_data = std::env::var_os("ProgramData")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("C:\\ProgramData"));
push_unique_path(paths, program_data.join("scoop").join("shims"));
}
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README:
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
/// 额外扫描 Bun 默认全局安装路径(~/.bun/bin
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
fn opencode_extra_search_paths(
home: &Path,
opencode_install_dir: Option<std::ffi::OsString>,
xdg_bin_dir: Option<std::ffi::OsString>,
gopath: Option<std::ffi::OsString>,
) -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
push_env_single_dir(&mut paths, opencode_install_dir);
push_env_single_dir(&mut paths, xdg_bin_dir);
if !home.as_os_str().is_empty() {
push_unique_path(&mut paths, home.join("bin"));
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
push_unique_path(&mut paths, home.join(".bun").join("bin"));
push_unique_path(&mut paths, home.join("go").join("bin"));
}
extend_from_path_list(&mut paths, gopath, Some("bin"));
paths
}
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
let extensionless = dir.join(tool);
let mut candidates = vec![
dir.join(format!("{tool}.cmd")),
dir.join(format!("{tool}.exe")),
];
if windows_runnable_sibling_for_extensionless_tool(&extensionless).is_none() {
candidates.push(extensionless);
}
candidates
}
#[cfg(not(target_os = "windows"))]
{
vec![dir.join(tool)]
}
}
fn extend_mise_node_search_paths(paths: &mut Vec<std::path::PathBuf>, home: &Path) {
if home.as_os_str().is_empty() {
return;
}
let mise_base = home.join(".local/share/mise");
push_unique_path(paths, mise_base.join("shims"));
let node_installs = mise_base.join("installs").join("node");
if node_installs.exists() {
if let Ok(entries) = std::fs::read_dir(&node_installs) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
push_unique_path(paths, bin_path);
}
}
}
}
}
/// 构建某工具的候选搜索目录(原生安装优先,PATH 兜底)。
/// 单探兜底 (`scan_cli_version`) 与全量枚举 (`enumerate_tool_installations`) 共用,
/// 确保两条路径看到的是同一组安装位置。
fn build_tool_search_paths(tool: &str) -> Vec<std::path::PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
// 常见的安装路径(原生安装优先)
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
if !home.as_os_str().is_empty() {
push_unique_path(&mut search_paths, home.join(".local/bin"));
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
push_unique_path(&mut search_paths, home.join("n/bin"));
push_unique_path(&mut search_paths, home.join(".volta/bin"));
extend_mise_node_search_paths(&mut search_paths, &home);
}
#[cfg(target_os = "macos")]
{
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/opt/homebrew/bin"),
);
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/usr/local/bin"),
);
if tool == "hermes" {
let python_base = home.join("Library").join("Python");
if python_base.exists() {
if let Ok(entries) = std::fs::read_dir(&python_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
push_unique_path(&mut search_paths, bin_path);
}
}
}
}
}
}
#[cfg(target_os = "linux")]
{
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/usr/local/bin"),
);
push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin"));
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = dirs::data_dir() {
push_unique_path(&mut search_paths, appdata.join("npm"));
if tool == "hermes" {
let python_base = appdata.join("Python");
if python_base.exists() {
if let Ok(entries) = std::fs::read_dir(&python_base) {
for entry in entries.flatten() {
let scripts_path = entry.path().join("Scripts");
if scripts_path.exists() {
push_unique_path(&mut search_paths, scripts_path);
}
}
}
}
}
}
if tool == "hermes" {
if let Some(local_data) = dirs::data_local_dir() {
let programs_python = local_data.join("Programs").join("Python");
if programs_python.exists() {
if let Ok(entries) = std::fs::read_dir(&programs_python) {
for entry in entries.flatten() {
let scripts_path = entry.path().join("Scripts");
if scripts_path.exists() {
push_unique_path(&mut search_paths, scripts_path);
}
}
}
}
}
}
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("C:\\Program Files\\nodejs"),
);
extend_windows_cli_manager_search_paths(&mut search_paths, &home);
}
let fnm_base = home.join(".local/state/fnm_multishells");
if fnm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
push_unique_path(&mut search_paths, bin_path);
}
}
}
}
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
push_unique_path(&mut search_paths, bin_path);
}
}
}
}
if tool == "opencode" {
let extra_paths = opencode_extra_search_paths(
&home,
std::env::var_os("OPENCODE_INSTALL_DIR"),
std::env::var_os("XDG_BIN_DIR"),
std::env::var_os("GOPATH"),
);
for path in extra_paths {
push_unique_path(&mut search_paths, path);
}
}
let path_env = std::env::var_os("PATH");
extend_from_cli_path_env(&mut search_paths, path_env);
search_paths
}
#[cfg(target_os = "windows")]
fn is_windows_command_script(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"))
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn windows_runnable_sibling_for_extensionless_tool(path: &Path) -> Option<std::path::PathBuf> {
if path.extension().is_some() {
return None;
}
["cmd", "exe"]
.iter()
.map(|ext| path.with_extension(ext))
.find(|candidate| candidate.is_file())
}
#[cfg(target_os = "windows")]
fn run_windows_tool_version_command(
tool_path: &Path,
new_path: &str,
) -> std::io::Result<std::process::Output> {
use std::process::Command;
if is_windows_command_script(tool_path) {
let path = tool_path.to_string_lossy();
let command = format!("call {} --version", win_quote_path_for_batch(&path));
let mut cmd = Command::new("cmd");
return cmd
.args(["/D", "/S", "/C"])
.raw_arg(&command)
.env("PATH", new_path)
.creation_flags(CREATE_NO_WINDOW)
.output();
}
Command::new(tool_path)
.arg("--version")
.env("PATH", new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
}
/// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。
fn scan_cli_version(tool: &str) -> ShellProbe {
#[cfg(not(target_os = "windows"))]
use std::process::Command;
let search_paths = build_tool_search_paths(tool);
let current_path = std::env::var_os("PATH")
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
// 记录"可执行文件存在、但 `--version` 非零退出"时的首个诊断信息。
// 典型场景:工具已安装但当前环境跑不起来(如 openclaw 要求 Node v22.19+)。
// 这类信息比笼统的 "not installed" 有用得多,循环结束未探到版本时回传。
let mut exec_diagnostic: Option<String> = None;
for path in &search_paths {
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
for tool_path in tool_executable_candidates(tool, path) {
if !tool_path.exists() {
continue;
}
#[cfg(target_os = "windows")]
let output = run_windows_tool_version_command(&tool_path, &new_path);
#[cfg(not(target_os = "windows"))]
let output = {
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output()
};
if let Ok(out) = output {
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if !raw.is_empty() {
return ShellProbe::Found(extract_version(raw));
}
} else if exec_diagnostic.is_none() {
let detail = if stderr.is_empty() { stdout } else { stderr };
let detail = detail.trim();
if !detail.is_empty() {
exec_diagnostic = Some(last_lines(detail, 4));
}
}
}
}
}
// 有诊断 = 找到了可执行文件但 --version 报错(装了跑不起来);否则视作未安装。
match exec_diagnostic {
Some(detail) => ShellProbe::FoundButFailed(detail),
None => ShellProbe::NotFound(NOT_INSTALLED.to_string()),
}
}
/// 单个工具在系统中的一处安装,用于"多处安装互相打架"的冲突诊断。
/// 字段保持 snake_case(与 `ToolVersion` 一致),前端按同名字段读取。
#[derive(Debug, serde::Serialize)]
pub struct ToolInstallation {
/// 候选入口路径(用户实际在 PATH 里看到/输入的那个,未解析软链)。
path: String,
/// `--version` 成功时解析出的版本号。
version: Option<String>,
/// `--version` 是否 exit 0(装了且能在当前环境跑起来)。
runnable: bool,
/// 跑不起来时的诊断信息末尾若干行。
error: Option<String>,
/// 由路径前缀推断的安装来源(nvm/homebrew/...),驱动 UI 徽章。
source: String,
/// 是否为 PATH 解析到的那处(= 命令行默认,也是升级会作用的目标)。
is_path_default: bool,
/// canonicalize 解析后的真身路径(brew 形如 `Cellar/<formula>/...`、claude 原生形如
/// `~/.local/share/claude/versions/...`),用于 `anchored_command_from_paths` 的真身
/// 判定。`enumerate_tool_installations` 已经为去重算过一次,这里复用避免上游
/// `installs_anchored_command` 再 canonicalize 一遍——消除冗余 syscall + 闭合
/// "enumerate 与 anchor 看到同一真身"的一致性边界(否则两次 canonicalize 之间
/// symlink 被换会让锚定指向不同真身)。`#[serde(skip)]` 不外露给前端。
#[serde(skip)]
real: std::path::PathBuf,
}
/// 由可执行文件路径前缀推断安装来源。纯字符串匹配、无副作用。
/// 顺序敏感:Homebrew 的 Cellar 真身要先于通用规则命中。
fn infer_install_source(path: &Path) -> &'static str {
let s = path
.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase();
if s.contains("/.nvm/") {
"nvm"
} else if s.contains("/homebrew/") || s.contains("/cellar/") {
"homebrew"
// `.volta` 是 macOS/Linux 默认安装(`~/.volta/bin`),`/volta/` 兜底覆盖
// Windows 的 `%LOCALAPPDATA%\Volta\bin` / `%VOLTA_HOME%\bin`(无前导点)。
} else if s.contains("/.volta/") || s.contains("/volta/") {
"volta"
} else if s.contains("fnm_multishells") {
"fnm"
} else if s.contains("/mise/") {
"mise"
} else if s.contains("/.bun/") {
"bun"
// pnpm 全局包目录: macOS 一般 `~/.local/share/pnpm`(已 normalize 到 `/pnpm/`)
// 与 Windows `%LOCALAPPDATA%\pnpm` / `%PNPM_HOME%` 都命中 `/pnpm/`。
} else if s.contains("/pnpm/") {
"pnpm"
} else if s.contains("/scoop/") {
"scoop"
} else if s.contains("/library/python")
|| s.contains("/scripts/")
|| s.contains("/site-packages/")
{
"pip"
} else {
"system"
}
}
/// 从 shell 输出里挑出第一个绝对路径行(trim 后以 `/` 开头),跳过交互式登录 shell
/// `-lic`)里 .zshrc 打印的欢迎语/提示符等噪音。canonicalize 由调用方做(碰 FS)。
#[cfg(not(target_os = "windows"))]
fn first_abs_path_line(raw: &str) -> Option<&str> {
raw.lines().map(str::trim).find(|l| l.starts_with('/'))
}
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
#[cfg(not(target_os = "windows"))]
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
use std::process::Command;
let shell = std::env::var("SHELL")
.ok()
.filter(|s| is_valid_shell(s))
.unwrap_or_else(|| "sh".to_string());
let flag = default_flag_for_shell(&shell);
let out = Command::new(shell)
.arg(flag)
.arg(format!("command -v {tool}"))
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = decode_command_output(&out.stdout);
// 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"),
// command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。
let first = first_abs_path_line(&raw)?;
std::fs::canonicalize(first).ok()
}
#[cfg(target_os = "windows")]
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
use std::os::windows::process::CommandExt;
use std::process::Command;
let out = Command::new("cmd")
.args(["/C", &format!("where {tool}")])
.creation_flags(CREATE_NO_WINDOW)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = decode_command_output(&out.stdout);
let first = raw.lines().next()?.trim();
if first.is_empty() {
return None;
}
let path = Path::new(first);
let preferred =
windows_runnable_sibling_for_extensionless_tool(path).unwrap_or_else(|| path.to_path_buf());
std::fs::canonicalize(preferred).ok()
}
/// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用
/// `build_tool_search_paths`,但不在首个命中处停止——而是对每个去重后的真实
/// 可执行文件都跑一次 `--version`,从而能发现"升级写入 A 处、PATH 实际用 B 处"。
fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
#[cfg(not(target_os = "windows"))]
use std::process::Command;
let search_paths = build_tool_search_paths(tool);
let current_path = std::env::var_os("PATH")
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let path_default = resolve_path_default(tool);
let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let mut installs: Vec<ToolInstallation> = Vec::new();
for dir in &search_paths {
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", dir.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", dir.display(), current_path);
for tool_path in tool_executable_candidates(tool, dir) {
if !tool_path.exists() {
continue;
}
// canonicalize 解析软链后去重:/opt/homebrew/bin/x → Cellar/...、nvm shim 等
// 多个入口可能指向同一真实文件,只算一处安装。
let real = std::fs::canonicalize(&tool_path).unwrap_or_else(|_| tool_path.clone());
if !seen.insert(real.clone()) {
continue;
}
#[cfg(target_os = "windows")]
let output = run_windows_tool_version_command(&tool_path, &new_path);
#[cfg(not(target_os = "windows"))]
let output = Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output();
let (version, runnable, error) = match output {
Ok(out) if out.status.success() => {
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
let raw = if stdout.is_empty() { stderr } else { stdout };
(Some(extract_version(&raw)), true, None)
}
Ok(out) => {
let stderr = decode_command_output(&out.stderr).trim().to_string();
let stdout = decode_command_output(&out.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
let detail = detail.trim();
let error = if detail.is_empty() {
None
} else {
Some(last_lines(detail, 4))
};
(None, false, error)
}
Err(e) => (None, false, Some(e.to_string())),
};
let is_path_default = path_default.as_ref() == Some(&real);
let path_str = tool_path.display().to_string();
let source = infer_install_source(&tool_path);
installs.push(ToolInstallation {
path: path_str,
version,
runnable,
error,
source: source.to_string(),
is_path_default,
// 复用上面 line ~1357 已 canonicalize 的真身,避免下游
// installs_anchored_command 再 canonicalize 一遍同一文件。
real: real.clone(),
});
}
}
// PATH 默认那处排最前,UI 一眼看到"命令行默认用的是哪处"。
installs.sort_by_key(|i| std::cmp::Reverse(i.is_path_default));
installs
}
/// 工具对应的 npm 包名(hermes 走自己的 CLI/installer,不在此表)。锚定升级据此拼 `npm i -g`。
/// 全平台共用一张表——Windows 锚定层(`anchored_command_from_paths` 的 windows 版)也读这里。
fn npm_package_for(tool: &str) -> Option<&'static str> {
match tool {
"claude" => Some("@anthropic-ai/claude-code"),
"codex" => Some("@openai/codex"),
"gemini" => Some("@google/gemini-cli"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
_ => None,
}
}
/// 取路径的父目录(纯字符串截断,不碰 fs):`/a/b/npm` → `/a/b`、`C:\a\b\npm.cmd`
/// → `C:\a\b`、混合分隔符 `C:\a/b\npm` → `C:\a/b`。无父目录返回空串。
///
/// 平台无关:`\` 和 `/` 都识别,取两者最右出现位置。`Option<usize>` 的 Ord 让
/// `None < Some(_)`,所以 `rfind('\\').max(rfind('/'))` 自动取存在的那个、两者都
/// 存在时取靠右的——比 `or_else` 优先取一种正确(混合分隔符不会拿错父目录)。
/// 跨平台 fs separator 在两侧均接受,使 macOS/Linux 上的 cargo test 也能跑 Windows
/// 路径用例(`parent_dir_cases::mixed_separators_takes_rightmost`)。空串语义由上游
/// `sibling_bin` 的 `is_empty()` 检查转成 None → 锚定整体退化到静态兜底。
fn parent_dir(p: &str) -> String {
match p.rfind('\\').max(p.rfind('/')) {
Some(i) if i > 0 => p[..i].to_string(),
_ => String::new(),
}
}
/// 从 canonicalize 后的真身路径提取 Homebrew formula 名:
/// `/opt/homebrew/Cellar/gemini-cli/0.13.0/...` → `Some("gemini-cli")`。
/// 非 Cellar 路径(= 不是 formula,可能是 Homebrew 的 node 装的 npm 全局包)返回 None。
/// 关键区分:formula 即便内部用 node,真身也落在 `Cellar/<formula>/` 下;而 Homebrew
/// npm 全局包落在 `/opt/homebrew/lib/node_modules`(不含 Cellar)。两者升级命令不同。
#[cfg(not(target_os = "windows"))]
fn brew_formula_from_path(real: &str) -> Option<String> {
let mut segs = real.split('/');
while let Some(seg) = segs.next() {
if seg.eq_ignore_ascii_case("Cellar") {
return segs.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
}
}
None
}
/// 含空格才用 POSIX 单引号包一层,否则保持裸路径——命令展示更干净。
/// claude / brew / volta / bun / npm 五个锚定分支共用,避免"含空格"判定漂移。
///
/// **仅按空格判定,不防其他 shell 元字符**(`$` / `` ` `` / `'` / `"` / `;` 等)。
/// 调用方传入的是探测得到的可执行路径(`enumerate_tool_installations` 里来源于
/// `Path::display()`),实际 macOS/Linux 上 home dir 名几乎不允许这类字符、
/// npm/brew/volta/bun 也不会装到含这类字符的路径,与 diff 前内联在 npm 分支里的
/// `if npm.contains(' ')` 实现等价。若未来要扩广,改成 `shell_single_quote` 无条件
/// 包裹即可,但会失去"无空格时的清洁展示"。
#[cfg(not(target_os = "windows"))]
fn quote_path_if_spaced(p: &str) -> String {
if p.contains(' ') {
shell_single_quote(p)
} else {
p.to_string()
}
}
/// 锚定路径走 `.bat` 文件且**被 `call` 调用**,需要为 batch 特殊字符做两层防御:
///
/// **(1) `%` 经历两轮 percent expansion → 用 4 个 `%` 转义**。.bat 中字面 `%` 的
/// 标准转义是 `%%`,但 `call` 命令(Microsoft `call /?`:"percent (%) expansion is
/// performed on each parameter")**在 batch parser 处理完 `%%` → `%` 后自己再做一轮**。
/// 所以源 .bat 里写 `%%FOO%%`,batch 一轮变 `%FOO%`,call 二轮当成 variable reference
/// 又展开一次——要让最终 call 看到字面 `%FOO%` 必须写 `%%%%FOO%%%%`(一轮 → `%%FOO%%`,
/// 二轮 → `%FOO%` 字面)。这是 cmd 唯一**引号无法保护**的字符:引号内的 `%` 仍参与
/// 两轮 expansion。
///
/// **(2) token 边界 / escape 字符触发外层双引号**:`' '` `'&'` `'('` `')'` `'^'`
/// `';'` `'<'` `'>'` `'|'` `','` 任一出现即包引号。NTFS 允许这些字符出现在路径中,
/// 不包会让 cmd 把路径切成多 token、`^` 又会触发 escape;引号内它们是字面意义,
/// 而且 call 二次解析对引号内的它们也不会做特殊处理(`^` 在引号内失去 escape 作用,
/// token 边界字符在引号内是字面)。
///
/// `!`(delayed expansion)只在 `setlocal enabledelayedexpansion` 下生效——我们
/// .bat 头只有 `@echo off`、没开,所以不需要处理。`'` 在 cmd 中无特殊意义。
///
/// 镜像 POSIX `quote_path_if_spaced` 的"轻量条件包装"语义:不含任何特殊字符就保持
/// 裸路径(命令展示更干净),否则用 `win_double_quote` 包并做必要转义。
#[cfg(target_os = "windows")]
fn win_quote_path_for_batch(p: &str) -> String {
// `%` 经历两轮 expansion:.bat parser 一轮 + `call` 二轮(Microsoft `call /?`:
// "percent (%) expansion is performed on each parameter")。要让 call 最终看到
// 字面 `%` 需要 4 个 → `%%%%`(batch 一轮 → `%%`,call 二轮 → `%` 字面)。
// 引号内仍参与两轮 expansion,所以这一步独立于外层引号、必须无条件做。
let escaped = if p.contains('%') {
p.replace('%', "%%%%")
} else {
p.to_string()
};
// 注:`needs_quote` 基于**原路径** `p` 判断,不能用 `escaped`——后者引入的 `%`
// 字符不算"特殊触发字符",否则含 `%` 的路径会被错误地额外加引号。
let needs_quote = p
.chars()
.any(|c| matches!(c, ' ' | '&' | '(' | ')' | '^' | ';' | '<' | '>' | '|' | ','));
if needs_quote {
win_double_quote(&escaped)
} else {
escaped
}
}
/// Windows 版 sibling 推导:在 `<bin_path 父目录>` 下按 `ext_candidates` 顺序找
/// 第一个存在的 `<exe_basename>.<ext>` 文件,返回该绝对路径。
///
/// **与 POSIX `sibling_bin` 的关键区别:这里碰 fs**——Windows 上 npm/pnpm 的入口
/// 实际扩展名可能是 `.cmd` 也可能是 `.exe`(Node.js installer 装的是 `npm.cmd`、
/// 部分 pnpm 是 `pnpm.exe`),纯字符串拼接无法知道哪个真的存在,猜错会拼出
/// "GUI 执行时 file not found" 的命令。fs 检查放进 helper、单测用 tempdir 覆盖,
/// 让上层 `anchored_command_from_paths` 仍保持"接收已锚定路径"的接口形态。
///
/// **TOCTOU 是 by design**:预检 `is_file` 是为了让确认对话框展示真实命令字符串;
/// 检查到执行之间被外部进程(卸载器 / nvm switch / 杀软隔离)移走文件 → cmd /C
/// 报 ENOENT,toast 显示错误。不要在执行前再做二次预检——双重 syscall 也解决不了 race。
///
/// 候选扩展名顺序按工具 idiom:npm/pnpm 优先 `.cmd`(node 装的),volta 优先 `.exe`
/// (Volta 是 Rust 写的 native binary)。
///
/// **不用 `which::which_in` 的理由**:per-tool 扩展名优先级(volta 偏 `.exe`、npm/pnpm
/// 偏 `.cmd`)与 PATHEXT 的固定顺序不一致,而且只为这一处加 `which` 依赖收益不抵 audit
/// surface。`PathBuf::join` 让 separator 选择交给 std,避免 `format!("{dir}\\...")`
/// 硬编码 `\\` 在混合分隔符 bin_path 下产出丑陋路径。
///
/// 空 dir 或所有候选都不存在 → None,上游退化到静态命令,与 POSIX 路径同款语义。
#[cfg(target_os = "windows")]
fn sibling_bin_with_ext(
bin_path: &str,
exe_basename: &str,
ext_candidates: &[&str],
) -> Option<String> {
let dir = parent_dir(bin_path);
if dir.is_empty() {
return None;
}
let dir = std::path::PathBuf::from(dir);
for ext in ext_candidates {
let candidate = dir.join(format!("{exe_basename}.{ext}"));
if candidate.is_file() {
return Some(candidate.to_string_lossy().into_owned());
}
}
None
}
/// 返回 `<bin_path 同目录>/<exe>` 的绝对路径。bin_path 是命令行命中的入口
/// (如 `/opt/homebrew/bin/gemini`、`~/.volta/bin/codex`),`exe` 是与之共处一个
/// bin 目录的另一个可执行(`brew` / `volta` / `bun` / `npm`)——这些包管理器
/// 都把自己的 cli 跟它们安装的命令并列放在同一个 bin 目录,所以"同目录推导"
/// 是可靠的绝对路径来源。
///
/// **dir 为空(bin_path 不含 `/`) → 返回 None**:此时无法推导出绝对路径,让上游
/// `anchored_command_from_paths` 整体退化为 None,调用方落到静态命令兜底——而非
/// 悄悄拼出 `npm i -g <pkg>` 这种依赖 PATH 的指令,违背"必须绝对路径"不变量。
/// 实际从 `enumerate_tool_installations` 走的 bin_path 都是 `Path::display()` 出
/// 来的绝对路径,这条防线不期望被触发,但闭合了 helper 与函数文档的语义一致。
#[cfg(not(target_os = "windows"))]
fn sibling_bin(bin_path: &str, exe: &str) -> Option<String> {
let dir = parent_dir(bin_path);
if dir.is_empty() {
None
} else {
Some(format!("{dir}/{exe}"))
}
}
#[cfg(not(target_os = "windows"))]
fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String> {
official_update_args(tool).map(|args| format!("{} {args}", quote_path_if_spaced(bin_path)))
}
#[cfg(target_os = "windows")]
fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String> {
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
}
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
///
/// **codex 刻意不在此列**`codex update` 在 npm 安装上只是裸 `npm install -g
/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、
/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖
/// `@openai/codex-<triple>` 漏装时它仍 **exit 0 假成功**,使外层 `||` 兜底被短路、损坏被
/// 成功 toast 掩盖(用户报告的 “Missing optional dependency” 即源于此)。因此 codex 一律走
/// npm 锚定升级;真正损坏(`runnable=false`)时由 `installs_anchored_command` 的门控改用
/// `codex_repair_command` 的 uninstall+install 自愈,而非交给 codex 自身的 self-update。
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
match shell {
LifecycleCommandShell::Posix => {
matches!(tool, "claude" | "opencode" | "openclaw")
}
LifecycleCommandShell::WindowsBatch => {
matches!(
tool,
// OpenCode 的 Windows `upgrade` 在 anomalyco/opencode#17295 修复前可能因
// 安装方式探测失败弹交互 promptspawn npm.cmd 没传 shell:true);静默
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
// 再把 opencode 加回这里。
"claude" | "openclaw"
)
}
}
}
/// Codex 平台分发包损坏的自愈命令。Codex 的 npm 包是「主包 `@openai/codex`(纯 JS
/// launcher+ 平台二进制 optional 依赖 `@openai/codex-<triple>`」的分发模式(同 esbuild/swc)。
/// 当平台二进制缺失时 codex 跑不起来——`enumerate_tool_installations` 跑 `--version` 会拿到
/// “Missing optional dependency” 的非 0 退出,标记 `runnable=false`。此状态下普通
/// `npm i -g @pkg@latest` 是 **no-op**npm 视 optional 依赖缺失为非致命,reify 又认为主包已是
/// 最新(外加半损坏留下的空 nested `node_modules` 残骸强化「tree 已满足」判断),不会补回平台
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
/// (实测输出 `added 2 packages`)。
///
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
///
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
/// 全局安装各有自己的二进制分发与修复方式,无脑套 npm uninstall+install 会出错——Homebrew
/// formulareal 在 `Cellar/`)本应 `brew upgrade codex`npm 够不到它反而旁路装第二份 npm
/// 全局 codexVolta/Bun 本应 `volta install`/`bun add`,且 `~/.bun/bin` 下没有 npm、
/// `sibling_bin` 会拼出不存在的路径;system/未知来源无可靠 sibling npm。这些来源一律返回
/// None,让上游继续走 source-specific 的 `anchored_command_from_paths`。白名单与
/// `package_manager_anchored_command_from_paths` 的 sibling-npm 分支对齐。
/// 刻意**不**额外用 `inst.error` 文本确认「确系缺二进制」:enumerate 只保留 stderr 末尾 4 行,
/// 而 codex.js 抛错的 "Missing optional dependency" 行会被尾部 node stack `at ...` 行挤出窗口
/// (实测用户原始错误即如此),强加该条件反而漏修真实缺包;对 npm 全局安装,uninstall+install
/// 对各类损坏都是合理且不会更糟的修复。
#[cfg(not(target_os = "windows"))]
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
// brew formulareal 在 Cellar)→ 不归 npm 管,交回 anchored 走 brew upgrade。
if brew_formula_from_path(real).is_some() {
return None;
}
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
if !matches!(
infer_install_source(Path::new(bin_path)),
"nvm" | "fnm" | "mise" | "homebrew"
) {
return None;
}
let npm = sibling_bin(bin_path, "npm")?;
let npm = quote_path_if_spaced(&npm);
let pkg = "@openai/codex";
Some(format!(
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
))
}
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
/// 残留,见 openai/codex#21872、#19824),且 `.bat` 链的错误处理与 POSIX `set -e` 语义不同,
/// 需要单独设计;先在本问题实际发生的 POSIX 平台落地。返回 None → 上游走正常锚定命令。
#[cfg(target_os = "windows")]
fn codex_repair_command(_bin_path: &str, _real: &str) -> Option<String> {
None
}
#[cfg(not(target_os = "windows"))]
fn package_manager_anchored_command_from_paths(
tool: &str,
bin_path: &str,
real_target: &str,
) -> Option<String> {
if let Some(formula) = brew_formula_from_path(real_target) {
let brew = sibling_bin(bin_path, "brew")?;
return Some(format!("{} upgrade {formula}", quote_path_if_spaced(&brew)));
}
let pkg = npm_package_for(tool)?;
match infer_install_source(Path::new(bin_path)) {
"volta" => {
let volta = sibling_bin(bin_path, "volta")?;
return Some(format!("{} install {pkg}", quote_path_if_spaced(&volta)));
}
"bun" => {
let bun = sibling_bin(bin_path, "bun")?;
return Some(format!(
"{} add -g {pkg}@latest",
quote_path_if_spaced(&bun)
));
}
// 自带同级 npm 的 node 管理器:落到下面锚定到那处的 npm。
"nvm" | "fnm" | "mise" | "homebrew" => {}
// system / 未知来源通常没有同级 npm,不能拼 `<dir>/npm`。若工具有官方
// self-update,上层会直接锚到 CLI 自身;否则返回 None 走静态兜底。
_ => return None,
}
let npm = sibling_bin(bin_path, "npm")?;
Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm)))
}
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
/// 推断"写回同一处"的锚定升级命令。**POSIX 版是纯函数(不碰 FS**——真实 canonicalize
/// 由调用方做(`installs_anchored_command` 复用 enumerate 时算出的 `inst.real`),
/// 便于单测覆盖各包管理器分支。Windows 版同名函数因 sibling 扩展名歧义必须读 fs,
/// 是刻意保留的平台差异(详见 Windows 版本 doc)。
///
/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。
/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行——
/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含**
/// `~/.local/bin` / `/opt/homebrew/bin` / `~/.volta/bin` 等用户级 bin 目录;而探测
/// 阶段 `try_get_version` 用的是 `$SHELL -lic`(登录+交互式,会读 .zshrc/.zprofile),
/// 两者 PATH 不对称。裸 `claude update` / `brew upgrade ...` 在 GUI 进程里大概率
/// `command not found`(exit 127)→ `set -e` 中止 → 用户看到失败 toast,锚定决策却
/// 已展示给用户"将写回原生那处"——欺骗性故障。
///
/// 判定顺序(命中即返回):
/// ① Hermes → `<bin_path 绝对> update`;Hermes CLI 自己知道安装环境,避免 cc-switch
/// 猜系统 `python3`/`python` 时撞上 Python 版本或 pyenv shim 问题。
/// ② Claude 原生安装器(`~/.local/share/claude/versions/`)→ `<bin_path 绝对> update`
/// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管,
/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
/// ③ Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`;
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
#[cfg(not(target_os = "windows"))]
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
let real_lower = real_target.to_ascii_lowercase();
if tool == "hermes" {
return anchored_official_update_command(tool, bin_path);
}
if tool == "claude"
&& (real_lower.contains("/.local/share/claude/")
|| real_lower.contains("/claude/versions/"))
{
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
if brew_formula_from_path(real_target).is_some() {
return package_command;
}
if prefers_official_update(tool, LifecycleCommandShell::Posix) {
let update = anchored_official_update_command(tool, bin_path)?;
return Some(match package_command {
Some(fallback) => chain_update_commands(update, fallback, LifecycleCommandShell::Posix),
None => update,
});
}
package_command
}
#[cfg(target_os = "windows")]
fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Option<String> {
let pkg = npm_package_for(tool)?;
match infer_install_source(Path::new(bin_path)) {
"volta" => {
let volta = sibling_bin_with_ext(bin_path, "volta", &["exe", "cmd"])?;
Some(format!(
"{} install {pkg}",
win_quote_path_for_batch(&volta)
))
}
"pnpm" => {
let pnpm = sibling_bin_with_ext(bin_path, "pnpm", &["cmd", "exe"])?;
Some(format!(
"{} add -g {pkg}@latest",
win_quote_path_for_batch(&pnpm)
))
}
// 兜底 = npm 类:Scoop / Chocolatey / winget / nvm-windows / MS Store nodejs /
// system / 任何识别不到专属来源的 → sibling npm.cmd。
_ => {
let npm = sibling_bin_with_ext(bin_path, "npm", &["cmd", "exe"])?;
Some(format!(
"{} i -g {pkg}@latest",
win_quote_path_for_batch(&npm)
))
}
}
}
/// Windows 版锚定命令生成。对平台确认可静默运行的工具优先使用官方 CLI 自升级;
/// 对 npm/Volta/pnpm 这类可确认写回位置的安装,再接一个包管理器 fallback。不存在 brew/bun/claude-native
/// (Windows 没 Homebrew、Bun for Windows 仍 preview、claude.ai/install.sh 是 bash 脚本)。
/// Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类——它们都只是"如何装
/// node"的不同入口,全局包真正的 idiom 仍是 sibling `npm.cmd`。
///
/// **与 POSIX 版的语义差异**:POSIX 版是纯函数(不碰 fs),Windows 版通过
/// `sibling_bin_with_ext` 读 fs 来探明扩展名(`.cmd` vs `.exe`)——Node installer
/// 装 `.cmd`、Volta 装 `.exe`,纯字符串拼接无法消歧。这一平台差异**被刻意保留**:
/// 测试用 tempdir 隔离 fs,生产侧 TOCTOU 是 by design(见 `sibling_bin_with_ext` doc)。
///
/// `_real_target` 占位维持与 POSIX 版的签名对称——Windows 上未观测到需要真身路径
/// 区分的等价类(无 Cellar、无 claude-native installer)。若未来加 Scoop persist 锚定
/// (scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),从这里启用 `_real_target`。
///
/// **关键不变量同 POSIX 版:返回的命令必须用绝对路径,不依赖 PATH**。Windows GUI
/// 进程 PATH 由 Service Control Manager / explorer.exe 给,通常不含用户 `%LOCALAPPDATA%`
/// 下的 Volta/pnpm 路径;`$SHELL -lic` 的探测时 PATH 与执行时 PATH 不对称。
///
/// 判定顺序(命中即返回):
/// ① hermes → `<bin_path> update`;Hermes CLI 自己处理安装环境。
/// ② 支持官方自升级且 Windows 可安全静默执行的工具 → `<bin_path> update/upgrade || call <包管理器 fallback>`。
/// ③ 其余 npm 工具 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest。
///
/// 包管理器 fallback 的 sibling 探测都通过 `sibling_bin_with_ext`(碰 fs):该处无候选
/// 扩展名存在时,支持官方自升级的工具仍返回 `<bin_path> update/upgrade`,其余工具
/// 才返 None 让上游兜回静态命令、`anchored=false`。
#[cfg(target_os = "windows")]
fn anchored_command_from_paths(tool: &str, bin_path: &str, _real_target: &str) -> Option<String> {
if tool == "hermes" {
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
let update = anchored_official_update_command(tool, bin_path)?;
return Some(match package_command {
Some(fallback) => {
chain_update_commands(update, fallback, LifecycleCommandShell::WindowsBatch)
}
None => update,
});
}
package_command
}
/// 从枚举结果里取"命令行实际命中的那处":优先 `is_path_default`;否则(解析不到
/// PATH 默认、但只有一处)取唯一那处;多处且无默认标记 → None(无从锚定)。
///
/// 全平台共用——POSIX 和 Windows 版的 `anchored_command_from_paths` 都通过
/// `installs_anchored_command` 调它,取默认那处再 canonicalize 拿真身。
fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
installs.iter().find(|i| i.is_path_default).or_else(|| {
if installs.len() == 1 {
installs.first()
} else {
None
}
})
}
/// 基于已枚举的安装列表生成锚定升级命令(复用 enumerate 结果,避免二次探测)。
/// 读取 enumerate 时已 canonicalize 写入的 `inst.real`,**不再二次 canonicalize**——
/// 既消除冗余 syscall,也闭合"enumerate 与 anchor 看到同一真身"的一致性边界
/// (两次 canonicalize 之间 symlink 被换会让锚定指向不同真身)。
///
/// 全平台共用——`anchored_command_from_paths` 自身是 cfg 二选一(POSIX 五分支 /
/// Windows 三分支),这里只负责取默认那处 + 转发。
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
let inst = default_install(installs)?;
let real = inst.real.to_string_lossy();
// Codex 平台分发包损坏自愈:主包在但平台二进制缺失时 codex 跑不起来
// runnable=false),此时正常锚定的 `npm i -g @latest` 是 no-op 修不好——改用
// uninstall+install 重装补回平台二进制。**但仅限会锚定到 sibling npm 的 node 管理器
// 来源**codex_repair_command 内按 source/real 收窄,brew/volta/bun/system 交回下方
// source-specific 锚定,避免误用 npm 重装)。runnable=true 的正常升级也走下方普通锚定
// 路径(且因 codex 不在 prefers_official_update,不会再跑会假成功掩盖损坏的 `codex update`)。
if tool == "codex" && !inst.runnable {
if let Some(cmd) = codex_repair_command(&inst.path, &real) {
return Some(cmd);
}
}
anchored_command_from_paths(tool, &inst.path, &real)
}
/// 静态命令(= 平台可安全静默执行的官方 CLI 自升级 || `npm i -g <pkg>@latest` /
/// 官方 installer)。锚定探不到默认安装时回退到它;npm fallback 仍等同于
/// "装到 PATH 第一个 npm"的旧行为。
fn static_fallback_command_for(tool: &str, action: ToolLifecycleAction) -> String {
tool_action_shell_command(tool, action).unwrap_or_default()
}
fn static_fallback_command(tool: &str) -> String {
static_fallback_command_for(tool, ToolLifecycleAction::Update)
}
/// 新装(install)的命令:对有官方 installer 的工具走「上游推荐 || npm 兜底」短路链,
/// 其余工具透传到 install 静态命令。update fallback 会在平台可安全静默执行时
/// 优先跑官方 CLI 自升级,但 install 端不能先跑 `tool update`,
/// 否则“未安装时安装”的路径会多一次无效失败。
///
/// 设计理由:
/// - install 没有锚点可言(从无到有),但**有"上游推荐方式"这一事实** ——
/// Anthropic 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为传统方式。
/// 把这层认知补进来,让 install 表与 update 端的锚定决策树共用同一份"上游事实"。
/// - Hermes 使用官方 installer,避免用系统 Python/pip 安装时踩 Python >=3.11 与 pyenv
/// `python` shim 问题;更新路径若能锚定已安装 CLI,则走 `<hermes> update`。
/// **Hermes 没有 npm 包,install 端不享受 `||` 降级**——上游 installer 不可达就只能等。
/// - 对**有 npm 包**的工具(claude/opencode),短路链(POSIX `||`)保证官方脚本不可达/
/// 防火墙拦截时仍能装上,降级到裸 `npm i -g`。官方脚本本身不用 pipe,
/// 所以这条路径在 WSL 的 `sh -c` 子 shell 中也不依赖外层 `pipefail`。
/// - Windows 原生不启用:claude.ai/install.sh、opencode.ai/install 都是 bash 脚本,
/// Windows 原生继续走 `tool_action_shell_command` 的 npm/PowerShell 命令;WSL 作为
/// Linux 环境复用这套 POSIX 安装优先级。
fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
match npm_install_command_for(tool) {
Some(npm) => chain_update_commands(
installer.to_string(),
npm.to_string(),
LifecycleCommandShell::Posix,
),
None => installer.to_string(),
}
}
fn posix_install_command_for(tool: &str) -> String {
match tool {
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
"hermes" => HERMES_INSTALL_UNIX.to_string(),
_ => static_fallback_command_for(tool, ToolLifecycleAction::Install),
}
}
#[cfg(not(target_os = "windows"))]
fn install_command_for(tool: &str) -> String {
posix_install_command_for(tool)
}
/// 计算某工具的升级命令与"是否需确认"。全平台共用一份:
/// - **Windows + WSL 工具**(override 是 `\\wsl$\<distro>\...` UNC 路径)的升级规划
/// 始终走 POSIX 静态命令、不锚定:锚定命令是 Windows 主机绝对路径,跨 `wsl.exe`
/// 边界进入 distro 文件系统后完全无效;且 `enumerate_tool_installations` 不参与
/// WSL 文件系统、锚定无锚点。这一类显式短路到 `(unix_static, false, false)`,
/// 前端不会弹确认。
/// **必须用 `wsl_tool_action_shell_command`(unix 版)而非 `static_fallback_command`**
/// ——后者读 `tool_action_shell_command`,Windows target 给 hermes 返回 PowerShell
/// installer,跨 wsl.exe 后不适用;`build_tool_action_line` 的 WSL 分支也用同一 wrapper,
/// 保证 plan 展示给前端的命令与实际执行落 .bat 的命令一致。
/// - 其他平台与 Windows 原生工具走 `installs_anchored_command`:命中 → 锚定;
/// None(无默认 / sibling 不存在等)→ 静态兜底、`anchored=false`,
/// 前端据此给"默认入口无法确定"诚实文案。
fn plan_command_for(tool: &str, installs: &[ToolInstallation]) -> (String, bool, bool) {
#[cfg(target_os = "windows")]
{
if wsl_distro_for_tool(tool).is_some() {
let cmd = wsl_tool_action_shell_command(tool, ToolLifecycleAction::Update)
.unwrap_or_default();
return (cmd, false, false);
}
}
match installs_anchored_command(tool, installs) {
Some(command) => (command, installs.len() >= 2, true),
None => (static_fallback_command(tool), installs.len() >= 2, false),
}
}
/// 多处安装是否构成"真冲突":≥2 处,且(版本分歧 或 有的能跑有的跑不起来)。
/// 同版本装两份且都能跑不算冲突(不打扰用户)。诊断展示据此判定。
fn is_conflicting(installs: &[ToolInstallation]) -> bool {
if installs.len() < 2 {
return false;
}
let distinct_versions: std::collections::HashSet<&Option<String>> =
installs.iter().map(|i| &i.version).collect();
let runnable_mixed =
installs.iter().any(|i| i.runnable) && installs.iter().any(|i| !i.runnable);
distinct_versions.len() > 1 || runnable_mixed
}
/// 一次"探测工具安装分布"的结果:枚举到的所有安装 + 各项衍生判定。同时服务两条
/// 路径——诊断展示(`is_conflict`)与升级确认(`needs_confirmation`/`command`/`anchored`)。
/// 字段保持 snake_case(与 `ToolInstallation` 一致),前端按同名读取。
#[derive(Debug, serde::Serialize)]
pub struct ToolInstallationReport {
tool: String,
/// 该工具枚举到的所有安装。
installs: Vec<ToolInstallation>,
/// 严阈值:≥2 且(版本分歧或运行态混合)。诊断按钮/自动补诊据此展示冲突。
is_conflict: bool,
/// 宽阈值:≥2 处。升级确认据此弹窗(升级只动一处,任何多处都该让用户知情)。
needs_confirmation: bool,
/// 锚定后将执行的升级命令(仅展示;真正执行时后端会重新生成,不信任前端回传)。
command: String,
/// 是否成功锚定到某处具体安装。false = 退到裸 fallback 命令(无法确定命令行实际
/// 命中哪处,或该处无同级 npm);前端据此给出"默认入口无法确定"的诚实文案。
anchored: bool,
}
/// 探测各工具的安装分布:枚举所有安装、标记冲突、生成锚定升级命令。只读、无副作用。
/// 诊断按钮、升级前确认、升级后补诊共用此命令,各取所需字段——避免对同一份枚举结果
/// 散落多套下游判定。
#[tauri::command]
pub async fn probe_tool_installations(
tools: Vec<String>,
) -> Result<Vec<ToolInstallationReport>, String> {
let requested = normalize_requested_tools(&tools);
if requested.is_empty() {
return Err("No supported tools selected".to_string());
}
tokio::task::spawn_blocking(move || {
requested
.into_iter()
.map(|tool| {
let installs = enumerate_tool_installations(tool);
let (command, needs_confirmation, anchored) = plan_command_for(tool, &installs);
let is_conflict = is_conflicting(&installs);
ToolInstallationReport {
tool: tool.to_string(),
installs,
is_conflict,
needs_confirmation,
command,
anchored,
}
})
.collect()
})
.await
.map_err(|e| format!("probe task join error: {e}"))
}
#[cfg(target_os = "windows")]
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
"openclaw" => crate::settings::get_openclaw_override_dir(),
"hermes" => crate::settings::get_hermes_override_dir(),
_ => None,
}?;
wsl_distro_from_path(&override_dir)
}
/// 从 UNC 路径中提取 WSL 发行版名称
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
#[cfg(target_os = "windows")]
fn wsl_distro_from_path(path: &Path) -> Option<String> {
use std::path::{Component, Prefix};
let Some(Component::Prefix(prefix)) = path.components().next() else {
return None;
};
match prefix.kind() {
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
let server_name = server.to_string_lossy();
if server_name.eq_ignore_ascii_case("wsl$")
|| server_name.eq_ignore_ascii_case("wsl.localhost")
{
let distro = share.to_string_lossy().to_string();
if !distro.is_empty() {
return Some(distro);
}
}
None
}
_ => None,
}
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
cwd: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
let launch_cwd = resolve_launch_cwd(cwd)?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
let provider = providers
.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
.map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();
let Some(obj) = config.as_object() else {
return env_vars;
};
// 处理 env 字段(Claude/Gemini 通用)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
if let Some(key) = base_url_key {
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
env_vars.push((key.to_string(), url_str.to_string()));
}
}
}
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
if *app_type == AppType::Codex {
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
}
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
if *app_type == AppType::Gemini {
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
}
}
env_vars
}
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
return Ok(None);
};
if raw_path.contains('\n') || raw_path.contains('\r') {
return Err("目录路径包含非法换行符".to_string());
}
let path = Path::new(&raw_path);
if !path.exists() {
return Err(format!("目录不存在: {raw_path}"));
}
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
if !resolved.is_dir() {
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
}
// Strip Windows extended-length prefix that canonicalize produces,
// as it can break batch scripts and other shell commands.
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
#[cfg(target_os = "windows")]
let resolved = {
let s = resolved.to_string_lossy();
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{unc}"))
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
resolved
}
};
Ok(Some(resolved))
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
cwd: Option<&Path>,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
"claude_{}_{}.json",
provider_id,
std::process::id()
));
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
/// 写入 claude 配置文件
fn write_claude_config(
config_file: &std::path::Path,
env_vars: &[(String, String)],
) -> Result<(), String> {
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();
for (key, value) in env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
let config_json =
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
let shell = get_user_shell();
let exec_line = build_exec_line(&shell, cwd);
let final_cd_command = build_final_shell_cd_command(&shell, cwd);
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let provider_command = build_provider_command_line(&shell, &config_path, cwd);
// Write the shell script to a temp file
// 脚本使用 POSIX sh 语法确保可移植性,exec 行切换到用户交互式 shell
let script_content = format!(
r#"#!/usr/bin/env sh
trap 'rm -f "{config_path}" "{script_file}"' EXIT
echo "Using provider-specific claude config:"
echo "{config_path}"
{provider_command}
{final_cd_command}
{exec_line}
"#,
config_path = config_path,
script_file = script_file.display(),
provider_command = provider_command,
final_cd_command = final_cd_command,
exec_line = exec_line,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
// Make script executable
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Try the preferred terminal first, fall back to Terminal.app if it fails
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"warp" => launch_macos_warp(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_ghostty(&script_file),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
_ => launch_macos_terminal_app(&script_file),
};
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
/// Escape a value as an AppleScript string literal.
#[cfg(target_os = "macos")]
fn applescript_string_literal(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Build the launcher command literal used by AppleScript.
#[cfg(target_os = "macos")]
fn applescript_launcher_command(script_file: &std::path::Path) -> String {
applescript_string_literal(&format!(
"sh {}",
shell_single_quote(&script_file.to_string_lossy())
))
}
/// Build a launcher command that replaces the terminal-created shell session.
#[cfg(target_os = "macos")]
fn applescript_exec_launcher_command(script_file: &std::path::Path) -> String {
applescript_string_literal(&format!(
"exec sh {}",
shell_single_quote(&script_file.to_string_lossy())
))
}
/// macOS: Terminal.app AppleScript.
/// A cold `activate` creates a default empty window before `do script` opens the command session.
/// Use `launch` for cold starts so `do script` can create the only new session without reusing restored windows.
#[cfg(target_os = "macos")]
fn build_macos_terminal_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to {launcher}
set was_running to application "Terminal" is running
tell application "Terminal"
if was_running then
activate
do script launcher_script
else
launch
do script launcher_script
activate
end if
end tell"#,
launcher = applescript_exec_launcher_command(script_file)
)
}
/// Run AppleScript through `osascript -e` with shared error handling.
#[cfg(target_os = "macos")]
fn run_terminal_osascript(applescript: &str, terminal_label: &str) -> Result<(), String> {
use std::process::Command;
let output = Command::new("osascript")
.arg("-e")
.arg(applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"{terminal_label} 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
run_terminal_osascript(
&build_macos_terminal_applescript(script_file),
"Terminal.app",
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to {launcher}
set was_running to application "iTerm" is running
tell application "iTerm"
if was_running then
activate
if (count of windows) = 0 then
create window with default profile
else
tell current window
create tab with default profile
end tell
end if
else
activate
set waited to 0
repeat while (count of windows) = 0
delay 0.1
set waited to waited + 1
if waited >= 30 then exit repeat
end repeat
if (count of windows) = 0 then
create window with default profile
end if
end if
tell current session of current window
write text launcher_script
end tell
end tell"#,
launcher = applescript_exec_launcher_command(script_file)
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2")
}
/// Keep the launcher path inside a `sh -c` string.
/// A bare `.sh` passed through `open --args` may also be opened as a document.
#[cfg(target_os = "macos")]
fn build_macos_dash_c_command(script_file: &std::path::Path) -> String {
format!(
"exec sh {}",
shell_single_quote(&script_file.to_string_lossy())
)
}
/// macOS: Ghostty.
/// Warm starts use AppleScript to create one command window.
/// Cold starts use `initial-command` so the first default surface runs the launcher.
/// Do not use `initial-window=false` plus `new window`: cold launch can still create the default window first.
#[cfg(target_os = "macos")]
fn build_macos_ghostty_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_command to {launcher}
set was_running to application "Ghostty" is running
if was_running then
tell application "Ghostty"
new window with configuration {{command:launcher_command}}
end tell
else
do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)
end if
"#,
launcher = applescript_launcher_command(script_file)
)
}
/// macOS: Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> {
match run_terminal_osascript(&build_macos_ghostty_applescript(script_file), "Ghostty") {
Ok(()) => Ok(()),
Err(applescript_error) => {
log::warn!(
"Ghostty AppleScript launch failed, falling back to open -na: {applescript_error}"
);
launch_macos_open_app("Ghostty", script_file, true)
}
}
}
/// macOS: 使用 open -na 启动支持 --args 参数的终端(Alacritty/Kitty/WezTerm/Kaku
#[cfg(target_os = "macos")]
fn launch_macos_open_app(
app_name: &str,
script_file: &std::path::Path,
use_e_flag: bool,
) -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-na").arg(app_name).arg("--args");
if use_e_flag {
cmd.arg("-e");
}
// Keep the script path inside `sh -c`; a trailing bare `.sh` can be opened as a document.
cmd.arg("sh")
.arg("-c")
.arg(build_macos_dash_c_command(script_file));
let output = cmd
.output()
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
app_name,
output.status.code(),
stderr
));
}
Ok(())
}
#[cfg(target_os = "macos")]
fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg("Warp");
// Warp URI scheme cannot work well with script_file, because:
//
// 1. script_file's name ends up with .sh, so Warp would open the file rather than execute it
// 2. script_file has no execution permission, so we need to add one more indirection
let mut second_script_file = tempfile::Builder::new()
.disable_cleanup(true)
.permissions(std::fs::Permissions::from_mode(0o755))
.tempfile()
.map_err(|e| format!("Failed to create temporary script file: {e}"))?;
writeln!(
&mut second_script_file,
r#"#!/usr/bin/env sh
rm -- "$0"
exec sh {quoted_script}
"#,
quoted_script = shell_single_quote(&script_file.to_string_lossy()),
)
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
warp_url
.query_pairs_mut()
.append_pair("path", &second_script_file.path().to_string_lossy());
let warp_url = warp_url.to_string();
cmd.arg(warp_url);
let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Warp 启动失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
let shell = get_user_shell();
let exec_line = build_exec_line(&shell, cwd);
let final_cd_command = build_final_shell_cd_command(&shell, cwd);
// Default terminal list with their arguments
let default_terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
("mate-terminal", vec!["--"]),
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
// Create temp script file
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let provider_command = build_provider_command_line(&shell, &config_path, cwd);
let script_content = format!(
r#"#!/usr/bin/env sh
trap 'rm -f "{config_path}" "{script_file}"' EXIT
echo "Using provider-specific claude config:"
echo "{config_path}"
{provider_command}
{final_cd_command}
{exec_line}
"#,
config_path = config_path,
script_file = script_file.display(),
provider_command = provider_command,
final_cd_command = final_cd_command,
exec_line = exec_line,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Build terminal list: preferred terminal first (if specified), then defaults
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
// Find the preferred terminal's args from default list
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.to_vec())
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
let mut list = vec![(pref.as_str(), pref_args)];
// Add remaining terminals as fallbacks
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.to_vec()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.to_vec()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try {
// Check if terminal exists in common paths
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|| which_command(terminal);
if terminal_exists {
let result = Command::new(terminal)
.args(&args)
.arg("sh")
.arg(script_file.to_string_lossy().as_ref())
.spawn();
match result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
}
}
}
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
Err(last_error)
}
/// Check if a command exists using `which`
#[cfg(target_os = "linux")]
fn which_command(cmd: &str) -> bool {
use std::process::Command;
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Windows: 根据用户首选终端启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
cwd: Option<&Path>,
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
let cwd_command = build_windows_cwd_command(cwd);
let content = format!(
"@echo off
{cwd_command}
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
",
config_path_for_batch,
config_path_for_batch,
config_path_for_batch,
cwd_command = cwd_command,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
// Try the preferred terminal first
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
}
result
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn is_windows_unc_path(path: &str) -> bool {
path.starts_with(r"\\")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn build_windows_cwd_command_str(path: &str) -> String {
let escaped = escape_windows_batch_value(path);
if is_windows_unc_path(path) {
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
format!("pushd \"{escaped}\" || exit /b 1\r\n")
} else {
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
}
}
#[cfg(target_os = "windows")]
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
.unwrap_or_default()
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_batch_value(value: &str) -> String {
value
.replace('^', "^^")
.replace('%', "%%")
.replace('&', "^&")
.replace('|', "^|")
.replace('<', "^<")
.replace('>', "^>")
.replace('(', "^(")
.replace(')', "^)")
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
use std::process::Command;
let mut full_args = vec!["/C", "start"];
full_args.extend(args);
let output = Command::new("cmd")
.args(&full_args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
terminal_name,
output.status.code(),
stderr
));
}
Ok(())
}
/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -r` / `pause`
/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command
/// not found` / `ModuleNotFoundError` 这类诊断信息。
///
/// **Security**`command_line` 会被原样拼进 shell/batch 脚本,调用方必须
/// 保证它是可信字符串(当前只由后端硬编码调用)。
pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
#[cfg(any(target_os = "macos", target_os = "linux"))]
let (script_file, script_content) = {
let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid));
let content = format!(
r#"#!/usr/bin/env sh
trap 'rm -f "{script_path}"' EXIT
echo "[cc-switch] Starting: {label}"
echo ""
{cmd}
echo ""
echo "[cc-switch] Command exited. Press Enter to close."
read -r _
"#,
script_path = file.display(),
label = label,
cmd = command_line,
);
(file, content)
};
#[cfg(target_os = "macos")]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&script_file, &script_content)
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"warp" => launch_macos_warp(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_ghostty(&script_file),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
_ => launch_macos_terminal_app(&script_file),
};
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
std::fs::write(&script_file, &script_content)
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let preferred = crate::settings::get_preferred_terminal();
let default_terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
("mate-terminal", vec!["--"]),
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.to_vec())
.unwrap_or_else(|| vec!["-e"]);
let mut list = vec![(pref.as_str(), pref_args)];
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.to_vec()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.to_vec()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try {
let terminal_exists = which_command(terminal)
|| ["/usr/bin", "/bin", "/usr/local/bin"]
.iter()
.any(|dir| std::path::Path::new(&format!("{}/{}", dir, terminal)).exists());
if terminal_exists {
let spawn_result = Command::new(terminal)
.args(&args)
.arg("sh")
.arg(script_file.to_string_lossy().as_ref())
.spawn();
match spawn_result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
}
}
}
let _ = std::fs::remove_file(&script_file);
Err(last_error)
}
#[cfg(target_os = "windows")]
{
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_{}_{}.bat", label, pid));
let content = format!(
"@echo off\r\necho [cc-switch] Starting: {label}\r\necho.\r\n{cmd}\r\necho.\r\necho [cc-switch] Command exited. Press any key to close.\r\npause >nul\r\ndel \"%~f0\" >nul 2>&1\r\n",
label = label,
cmd = command_line,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"),
};
let final_result = if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
run_windows_start_command(&["cmd", "/K", &bat_path], "cmd")
} else {
result
};
// The .bat self-deletes (`del "%~f0"`) after it runs, but that only
// fires if *some* terminal actually launched it. If every attempt
// failed, sweep the temp file ourselves to avoid pollution.
if final_result.is_err() {
let _ = std::fs::remove_file(&bat_file);
}
final_result
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = (temp_dir, pid, command_line, label);
Err("不支持的操作系统".to_string())
}
}
/// 设置窗口主题(Windows/macOS 标题栏颜色)
/// theme: "dark" | "light" | "system"
#[tauri::command]
pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {
use tauri::Theme;
let tauri_theme = match theme.as_str() {
"dark" => Some(Theme::Dark),
"light" => Some(Theme::Light),
_ => None, // system default
};
window.set_theme(tauri_theme).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[cfg(unix)]
fn set_test_executable(path: &Path, executable: bool) {
use std::os::unix::fs::PermissionsExt;
let mode = if executable { 0o755 } else { 0o644 };
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.expect("fixture permissions should be set");
}
#[test]
fn test_build_exec_line() {
assert_eq!(build_exec_line("/bin/zsh", None), "exec '/bin/zsh' -l");
assert_eq!(build_exec_line("/bin/bash", None), "exec '/bin/bash'");
assert_eq!(
build_exec_line("/opt/homebrew dir/bin/fish", None),
"exec '/opt/homebrew dir/bin/fish'"
);
assert_eq!(build_exec_line("/bin/sh", None), "exec '/bin/sh'");
assert_eq!(
build_exec_line("/tmp/shell'quote/zsh", None),
"exec '/tmp/shell'\"'\"'quote/zsh' -l"
);
assert_eq!(
build_exec_line("/bin/zsh", Some(Path::new("/tmp/project"))),
r#"exec '/bin/zsh' -lc 'cd '"'"'/tmp/project'"'"' || exit 1; exec '"'"'/bin/zsh'"'"' -i'"#
);
}
#[test]
fn test_build_provider_command_line_uses_user_shell_environment() {
assert_eq!(
build_provider_command_line("/bin/zsh", "/tmp/claude config.json", None),
"'/bin/zsh' -lic 'claude --settings '\"'\"'/tmp/claude config.json'\"'\"''"
);
assert_eq!(
build_provider_command_line(
"/bin/bash",
"/tmp/claude config.json",
Some(Path::new("/tmp/project"))
),
r#"'/bin/bash' -ic 'cd '"'"'/tmp/project'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
);
assert_eq!(
build_provider_command_line(
"/bin/sh",
"/tmp/claude config.json",
Some(Path::new("/tmp/project O'Brien"))
),
r#"'/bin/sh' -c 'cd '"'"'/tmp/project O'"'"'"'"'"'"'"'"'Brien'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
);
}
#[test]
fn test_build_final_shell_cd_command() {
assert_eq!(build_final_shell_cd_command("/bin/zsh", None), "");
assert_eq!(
build_final_shell_cd_command("/bin/zsh", Some(Path::new("/tmp/project"))),
""
);
assert_eq!(
build_final_shell_cd_command("/bin/bash", Some(Path::new("/tmp/project O'Brien"))),
"cd '/tmp/project O'\"'\"'Brien' || exit 1\n"
);
}
#[cfg(unix)]
#[test]
fn test_get_user_shell_fallback() {
// $SHELL 未设置时应按平台 fallback
// 此测试验证 fallback 逻辑,但不验证环境变量值(取决于运行环境)
let shell = get_user_shell();
// 至少应返回一个合法的绝对路径
assert!(valid_user_shell_path(&shell));
// basename 应为合法 shell 名
let basename = shell.rsplit('/').next().unwrap_or("sh");
assert!(["sh", "bash", "zsh", "fish", "dash"].contains(&basename));
}
#[cfg(unix)]
#[test]
fn test_valid_user_shell_path() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let executable_zsh = temp.path().join("zsh");
std::fs::write(&executable_zsh, "#!/usr/bin/env sh\n")
.expect("shell fixture should be written");
set_test_executable(&executable_zsh, true);
let executable_fish_dir = temp.path().join("homebrew dir/bin");
std::fs::create_dir_all(&executable_fish_dir)
.expect("shell fixture directory should be created");
let executable_fish = executable_fish_dir.join("fish");
std::fs::write(&executable_fish, "#!/usr/bin/env sh\n")
.expect("shell fixture should be written");
set_test_executable(&executable_fish, true);
let non_executable_bash = temp.path().join("bash");
std::fs::write(&non_executable_bash, "#!/usr/bin/env sh\n")
.expect("shell fixture should be written");
set_test_executable(&non_executable_bash, false);
assert!(valid_user_shell_path(&executable_zsh.to_string_lossy()));
assert!(valid_user_shell_path(&executable_fish.to_string_lossy()));
assert!(!valid_user_shell_path(""));
assert!(!valid_user_shell_path("zsh"));
assert!(!valid_user_shell_path(
&temp.path().join("missing/zsh").to_string_lossy()
));
assert!(!valid_user_shell_path(
&non_executable_bash.to_string_lossy()
));
assert!(!valid_user_shell_path(
&temp.path().join("zsh; rm -rf /").to_string_lossy()
));
assert!(!valid_user_shell_path(&format!(
"{}\n/bin/bash",
executable_zsh.to_string_lossy()
)));
assert!(!valid_user_shell_path("/usr/bin/powershell"));
}
#[test]
fn test_extract_version() {
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
assert_eq!(extract_version("no version here"), "no version here");
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
assert_eq!(
compare_semver("2.1.156", "2.1.154"),
Some(Ordering::Greater)
);
assert_eq!(compare_semver("2.1.154", "2.1.156"), Some(Ordering::Less));
assert_eq!(compare_semver("2.1.156", "2.1.156"), Some(Ordering::Equal));
// 预发布 < 同核心正式版
assert_eq!(
compare_semver("2.1.156-beta.1", "2.1.156"),
Some(Ordering::Less)
);
// core 更高的预发布仍高于较低的正式版(gemini nightly 场景)
assert_eq!(
compare_semver("0.45.0-nightly.1", "0.44.1"),
Some(Ordering::Greater)
);
// 大 patchcodex 时间戳式)不溢出
assert_eq!(
compare_semver("0.1.2505172116", "0.135.0"),
Some(Ordering::Less)
);
// 无法解析返回 Nonegemini 的 `false` 脏 tag
assert_eq!(compare_semver("false", "1.0.0"), None);
}
#[test]
fn test_pick_latest_version() {
use serde_json::json;
let tags = json!({
"latest": "2.1.154",
"next": "2.1.156",
"stable": "2.1.145"
});
let map = tags.as_object().unwrap();
// 本地领先 latest(在 next 通道)→ 补查到 next,数字对齐
assert_eq!(
pick_latest_version(map, &["next"], Some("2.1.156")),
Some("2.1.156".to_string())
);
// 本地等于 latest → 不补查,仍显示 latest
assert_eq!(
pick_latest_version(map, &["next"], Some("2.1.154")),
Some("2.1.154".to_string())
);
// 本地落后 latest(稳定通道用户)→ 不补查,不被推向预发布版
assert_eq!(
pick_latest_version(map, &["next"], Some("2.1.145")),
Some("2.1.154".to_string())
);
// 无预发布白名单 → 永远只看 latest(不解析 local,避免脏 local 触发)
assert_eq!(
pick_latest_version(map, &[], Some("2.1.156")),
Some("2.1.154".to_string())
);
// 本地版本未知 → 保守只看 latest
assert_eq!(
pick_latest_version(map, &["next"], None),
Some("2.1.154".to_string())
);
}
#[test]
fn test_pick_latest_version_filters_dirty_prerelease() {
use serde_json::json;
// 模拟 codexbeta 是低于 latest 的时间戳式脏版本
let tags = json!({
"latest": "0.135.0",
"beta": "0.1.2505172116"
});
let map = tags.as_object().unwrap();
// 即便本地领先 latest,低于 latest 的脏 beta 也不会被选
assert_eq!(
pick_latest_version(map, &["beta"], Some("0.200.0")),
Some("0.135.0".to_string())
);
}
/// `parent_dir` 是锚定层"由 bin 路径推导同目录绝对路径"的基石,跨平台共用——
/// 这里固化 `\`/`/`/混合分隔符/根边界四种情况,避免未来重构悄悄改语义。
mod parent_dir_cases {
use super::super::*;
#[test]
fn unix_path() {
assert_eq!(
parent_dir("/Users/me/.volta/bin/codex"),
"/Users/me/.volta/bin"
);
}
#[test]
fn windows_backslash() {
assert_eq!(
parent_dir("C:\\Users\\me\\AppData\\Local\\Volta\\bin\\codex.exe"),
"C:\\Users\\me\\AppData\\Local\\Volta\\bin"
);
}
#[test]
fn mixed_separators_takes_rightmost() {
// Windows 上 `Path::join` 与字符串拼接可能产出混合分隔符;取**两种之中最右
// 出现**的位置,而非"优先 `\`"——后者在混合时会取错父目录。
assert_eq!(
parent_dir("C:\\Users\\me/Code/openclaw\\codex.cmd"),
"C:\\Users\\me/Code/openclaw"
);
}
#[test]
fn no_separator_returns_empty() {
// 无父目录 → 空串,锚定层据此返 None、回退静态命令。
assert_eq!(parent_dir("codex"), "");
}
#[test]
fn separator_at_root_returns_empty() {
// `/codex`:根目录是 index 0,`i > 0` 不满足 → 空串。同款行为对 Windows
// 上的 `\codex` 也成立(实际不会出现,但语义对齐)。
assert_eq!(parent_dir("/codex"), "");
assert_eq!(parent_dir("\\codex"), "");
}
}
/// Windows-only 锚定升级回归(等价类压缩到 3 种 idiom:volta/pnpm/npm)。整块通过
/// `cfg(target_os = "windows")` gate,在 macOS/Linux 上不参与 cargo test;Windows
/// CI 跑全套验证。tempdir 模拟 sibling 入口存在/不存在,锁定"扩展名顺序优先级 +
/// 含空格路径自动加双引号 + 探不到 sibling → None 退静态"三件事。
#[cfg(target_os = "windows")]
mod anchored_upgrade_windows {
use super::super::*;
/// 在 tempdir 下创建子目录 `subdir`(空字符串则用 tempdir 根),放入 `entry`
/// 与若干 `siblings` 假文件。返回 `(TempDir, 子目录, 入口绝对路径)`——TempDir
/// 必须保活,否则析构后 fs 文件消失、`is_file()` 失败,测试假绿。
fn setup_sibling(
subdir: &str,
entry: &str,
siblings: &[&str],
) -> (tempfile::TempDir, std::path::PathBuf, String) {
let dir = tempfile::tempdir().unwrap();
let sub = if subdir.is_empty() {
dir.path().to_path_buf()
} else {
dir.path().join(subdir)
};
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join(entry), "").unwrap();
for s in siblings {
std::fs::write(sub.join(s), "").unwrap();
}
let bin_path = sub.join(entry).to_string_lossy().to_string();
(dir, sub, bin_path)
}
/// **必须与 `win_quote_path_for_batch` 主体保持镜像**——给 anchored 测试动态算
/// expected,让用例在 temp 根目录含空格 / `&` / `(` / `%` 等特殊字符的开发机上
/// 也能通过(默认 Windows `%TEMP%` = `C:\Users\<user>\AppData\Local\Temp`,
/// 用户名带空格的机器整条 path 含空格、生产代码会正确加引号、测试硬编码无引号
/// expected 会假失败)。
///
/// 镜像引入"两边必须同步"的隐性依赖——回归防护层是 `win_quote_*` 那 7 个独立
/// 单测,它们用硬编码字面值锁住 quoting 规则本身,即便此镜像漂移也会被那一组
/// 测试 catch;反之亦然。
fn expect_quoted_path(p: &str) -> String {
let escaped = p.replace('%', "%%%%");
let needs_quote = p
.chars()
.any(|c| matches!(c, ' ' | '&' | '(' | ')' | '^' | ';' | '<' | '>' | '|' | ','));
if needs_quote {
format!("\"{escaped}\"")
} else {
escaped
}
}
#[test]
fn volta_windows_uses_volta_install() {
// tempdir 路径里不含 "volta" 子串,所以在 tempdir 下手建一个 `Volta` 子目录
// 才能让 `infer_install_source` 通过路径 normalize 后命中 `/volta/` 分支。
// sibling 候选顺序 `[exe, cmd]`——Volta 是 Rust 写的 native binary,首选 .exe。
// expected 通过 `expect_quoted_path` 算出,以适应 temp 根目录含特殊字符的环境。
let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let volta_full = format!("{}\\volta.exe", sub.to_string_lossy());
let expected = format!(
"{} update || call {} install @openai/codex",
expect_quoted_path(&bin_path),
expect_quoted_path(&volta_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn pnpm_windows_uses_pnpm_add() {
// bin_path 落 `%LOCALAPPDATA%\pnpm\codex.cmd`,sibling 有 `pnpm.cmd` → 锚定到
// `<dir>\pnpm.cmd add -g @openai/codex@latest`。用 add+@latest 而非 update,
// 兼容"之前没通过 pnpm 装过"的幂等性场景。
let (_dir, sub, bin_path) = setup_sibling("pnpm", "codex.cmd", &["pnpm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
let expected = format!(
"{} update || call {} add -g @openai/codex@latest",
expect_quoted_path(&bin_path),
expect_quoted_path(&pnpm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn opencode_windows_uses_package_fallback_without_official_upgrade() {
let (_dir, sub, bin_path) = setup_sibling("pnpm", "opencode.cmd", &["pnpm.cmd"]);
let cmd = anchored_command_from_paths("opencode", &bin_path, &bin_path);
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
let expected = format!(
"{} add -g opencode-ai@latest",
expect_quoted_path(&pnpm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn opencode_windows_static_fallback_skips_official_upgrade() {
let cmd = static_fallback_command("opencode");
assert_eq!(cmd, "npm i -g opencode-ai@latest");
assert!(!cmd.contains("opencode upgrade"));
}
#[test]
fn npm_windows_default_branch() {
// 任意 system 类路径(不命中 volta/pnpm)→ 兜底 sibling npm.cmd 锚定。
// 模拟 nvm-windows 的实际形态:`<NVM_HOME>\v22.0.0\codex.cmd`。
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
// sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。
let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn hermes_windows_uses_cli_update() {
// Hermes 自带 `hermes update`,不要再回退到 py/python/pip。即便同目录有
// npm.cmd,也不应走 npm 分支。
let (_dir, _sub, bin_path) = setup_sibling("", "hermes.exe", &["npm.cmd"]);
let cmd = anchored_command_from_paths("hermes", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn hermes_windows_static_fallback_uses_powershell_installer_without_pip() {
let install = static_fallback_command_for("hermes", ToolLifecycleAction::Install);
assert!(
install
.starts_with("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "),
"should use PowerShell EncodedCommand installer: {install}"
);
let encoded = install
.split_once("-EncodedCommand ")
.map(|(_, encoded)| encoded)
.expect("installer should include encoded command");
assert_eq!(
encoded,
powershell_encoded_command(HERMES_INSTALL_WINDOWS_SCRIPT)
);
let install_prefix = install
.split_once("-EncodedCommand ")
.map(|(prefix, _)| prefix)
.expect("installer should include encoded command");
assert!(
!install_prefix.contains("|")
&& !install_prefix.contains("-Command")
&& !install_prefix.contains("python")
&& !install_prefix.contains("pip"),
"should hide PowerShell pipe from cmd.exe and avoid system Python/pip: {install}"
);
let update = static_fallback_command("hermes");
assert!(
update.starts_with(
"hermes update || powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "
),
"should try CLI update before PowerShell installer: {update}"
);
let fallback = update
.split_once("||")
.map(|(_, fallback)| fallback)
.expect("update should include a fallback command");
let fallback_prefix = fallback
.split_once("-EncodedCommand ")
.map(|(prefix, _)| prefix)
.expect("fallback should include encoded command");
assert!(
!fallback_prefix.contains('|')
&& !fallback_prefix.contains("-Command")
&& !update.contains("call powershell")
&& !fallback_prefix.contains("python")
&& !fallback_prefix.contains("pip"),
"PowerShell fallback should be encoded, not called like a batch file or use pip: {update}"
);
}
#[test]
fn windows_path_with_space_is_double_quoted() {
// 含空格的路径(`C:\Program Files\...`)在生成命令时必须用双引号包,否则
// bat / cmd /C 解析会把第一个空格当 token 分隔符,后续参数串错。**精确等值断言
// 锁定引号位置**(starts_with+contains 会放过"双引号位置错了但仍能命中"的回归)。
let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn windows_full_batch_line_for_percent_path_uses_quadruple_escape() {
// **完整生成的 batch 行**(`call ` + anchored cmd)对含字面 `%` 的路径必须
// 4 倍转义 `%foo%` → `%%%%foo%%%%`:.bat parser 一轮还原为 `%%foo%%`,call
// 二轮再还原为 `%foo%` 字面。helper 单测验证的是 `win_quote_path_for_batch`
// 内部转义,这条 integration 测验证 anchored_command_from_paths 输出 + call
// 包装后,**最终落到 .bat 的字符串**仍然闭合两轮 expansion。
let (_dir, sub, bin_path) = setup_sibling("path%foo%", "codex.cmd", &["npm.cmd"]);
let anchored = anchored_command_from_paths("codex", &bin_path, &bin_path).unwrap();
// build_tool_action_line Windows 分支最终拼的就是 `call <anchored>`(中间
// 没有其他变换),这里直接用 format! 复刻那一步,无需暴露内部 API。
let batch_line = format!("call {anchored}");
// 用 `expect_quoted_path` 算 npm 全路径的期望 quoting,**同时覆盖 temp 根
// 含空格的环境**(否则 sub 本身含空格 + 子目录 `path%foo%` 触发 4 倍 `%` 转义
// 会让 expected 漏引号、假失败)。
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
let expected = format!(
"call {} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
expect_quoted_path(&npm_full)
);
assert_eq!(batch_line, expected);
// 双重锁定:确认 4 倍转义子串存在 + 不出现"残留的二倍转义或字面 `%foo%`"。
assert!(
batch_line.contains("%%%%foo%%%%"),
"batch 行应含 4 倍转义 `%%%%foo%%%%`: {batch_line}"
);
assert!(
!batch_line.contains("path%foo%"),
"batch 行不应含未转义的字面 `%foo%`(会被 call 二次解析展开): {batch_line}"
);
}
}
/// Windows-only helpers 单测——在 macOS/Linux 上整块通过 cfg 排除,不参与 `cargo test`。
/// Windows CI(或本机 Windows 跑 cargo test)会激活这些用例。覆盖:①双引号
/// quoting 镜像 POSIX 版;②sibling_bin_with_ext 在 fs 上按 ext 顺序探到第一个存在的、
/// 全部不存在/空 dir 时返 None。tempdir 提供干净 fs 沙盒。
#[cfg(target_os = "windows")]
mod windows_helpers {
use super::super::*;
#[test]
fn win_quote_clean_path_stays_bare() {
// 普通路径不含特殊字符 → 不加引号,命令展示干净。
assert_eq!(
win_quote_path_for_batch("C:\\Users\\me\\npm.cmd"),
"C:\\Users\\me\\npm.cmd"
);
}
#[test]
fn win_quote_spaced_path_gets_quoted() {
assert_eq!(
win_quote_path_for_batch("C:\\Program Files\\nodejs\\npm.cmd"),
"\"C:\\Program Files\\nodejs\\npm.cmd\""
);
}
#[test]
fn win_quote_ampersand_path_gets_quoted() {
// `&` 是 cmd 命令分隔符,NTFS 允许在路径中出现;没有引号会让 `call C:\A&B\npm.cmd`
// 被解析为 `call C:\A` + `B\npm.cmd` 两条命令,执行错乱。
assert_eq!(
win_quote_path_for_batch("C:\\Tools&Dev\\npm.cmd"),
"\"C:\\Tools&Dev\\npm.cmd\""
);
}
#[test]
fn win_quote_parens_path_gets_quoted() {
// `(` / `)` 在 .bat 中是代码块语义,引号内才是字面意义。
assert_eq!(
win_quote_path_for_batch("C:\\Foo(x86)\\npm.cmd"),
"\"C:\\Foo(x86)\\npm.cmd\""
);
}
#[test]
fn win_quote_caret_path_gets_quoted() {
// `^` 是 cmd 的 escape character;包引号后是字面意义。
assert_eq!(
win_quote_path_for_batch("C:\\foo^bar\\npm.cmd"),
"\"C:\\foo^bar\\npm.cmd\""
);
}
#[test]
fn win_quote_percent_is_escaped_to_quadruple_percent() {
// `%` 经历 .bat 一轮 + call 二轮 expansion,要让 call 最终看到字面 `%FOO%`
// 需要源 .bat 里写 `%%%%FOO%%%%`(一轮 → `%%FOO%%`,二轮 → `%FOO%` 字面)。
// 用 `%%` 二倍转义只在 echo / 直接执行场景对,call 调用时会被还原成 variable
// reference 进而被替换。**这一条用例锁住"call 二次解析"必须被 4 倍转义闭合**。
assert_eq!(
win_quote_path_for_batch("C:\\path%foo%\\npm.cmd"),
"C:\\path%%%%foo%%%%\\npm.cmd"
);
}
#[test]
fn win_quote_percent_with_space_gets_both() {
// `%` 4 倍转义与外层引号正交——含空格触发引号、含 `%` 触发 `%%%%` 转义,叠加。
assert_eq!(
win_quote_path_for_batch("C:\\my %dir%\\npm.cmd"),
"\"C:\\my %%%%dir%%%%\\npm.cmd\""
);
}
#[test]
fn win_quote_needs_quote_uses_original_path() {
// 回归 guard:`needs_quote` 判定基于**原路径**,不能用 escape 后字符串——
// 否则原本无 token 边界字符的路径(如 `C:\path%foo%\npm.cmd`)在 escape
// 引入更多 `%` 后被错误识别成"需要引号"。这是实现 bug 的隐性入口。
// 入参不含任何 token 边界字符 → 不应加外层引号、只做 `%` 4 倍转义。
let out = win_quote_path_for_batch("C:\\foo%bar%\\npm.cmd");
assert!(!out.starts_with('"'), "纯 `%` 路径不应加外层引号: {out}");
}
#[test]
fn sibling_bin_picks_first_existing_extension() {
// 同目录同时存在 `npm.cmd` 和 `npm.exe` 时,候选顺序 `[cmd, exe]` 应取 .cmd——
// 这是 Node.js 官方 installer 装出来的 idiom(.cmd 是入口、.exe 是 wrapper)。
let dir = tempfile::tempdir().unwrap();
let cmd_path = dir.path().join("npm.cmd");
let exe_path = dir.path().join("npm.exe");
std::fs::write(&cmd_path, "").unwrap();
std::fs::write(&exe_path, "").unwrap();
let codex = dir.path().join("codex.cmd").to_string_lossy().to_string();
let found = sibling_bin_with_ext(&codex, "npm", &["cmd", "exe"]).unwrap();
assert_eq!(found, cmd_path.to_string_lossy());
}
#[test]
fn sibling_bin_volta_prefers_exe() {
// Volta 是 Rust 写的 native binary,扩展名顺序应是 [exe, cmd]——若只有 .exe
// 存在(常见情形),探到的就是它。
let dir = tempfile::tempdir().unwrap();
let exe_path = dir.path().join("volta.exe");
std::fs::write(&exe_path, "").unwrap();
let codex = dir.path().join("codex.exe").to_string_lossy().to_string();
let found = sibling_bin_with_ext(&codex, "volta", &["exe", "cmd"]).unwrap();
assert_eq!(found, exe_path.to_string_lossy());
}
#[test]
fn sibling_bin_returns_none_when_none_exist() {
// 同目录下没有任何候选 → None,锚定层据此退到静态命令。
let dir = tempfile::tempdir().unwrap();
let codex = dir.path().join("codex.cmd").to_string_lossy().to_string();
assert!(sibling_bin_with_ext(&codex, "npm", &["cmd", "exe"]).is_none());
}
#[test]
fn sibling_bin_returns_none_when_no_parent() {
// bin_path 没有目录部分(纯文件名) → parent_dir 空串 → 返 None。
assert!(sibling_bin_with_ext("codex.cmd", "npm", &["cmd"]).is_none());
}
#[test]
fn wsl_hermes_command_uses_unix_installer_not_powershell_or_pip() {
// 跨 wsl.exe 边界后跑的是 Linux,Windows PowerShell installer 不适用;
// 也不要再走 python3/python pip 链,避免 Python 版本/pyenv shim 问题。
let update_cmd =
wsl_tool_action_shell_command("hermes", ToolLifecycleAction::Update).unwrap();
assert!(
update_cmd.starts_with("hermes update || bash -c 'tmp=$(mktemp) && curl -fsSL "),
"WSL hermes 更新应先尝试 CLI 自更新再回退官方 installer,得到: {update_cmd}"
);
let fallback = update_cmd
.split_once("||")
.map(|(_, fallback)| fallback)
.expect("update should include installer fallback");
assert!(
!fallback.contains('|')
&& fallback.contains(" -o $tmp && bash $tmp")
&& !update_cmd.contains("powershell")
&& !update_cmd.contains("pip"),
"WSL hermes fallback 不能依赖 pipefail/Windows installer/pip,得到: {update_cmd}"
);
let install_cmd =
wsl_tool_action_shell_command("hermes", ToolLifecycleAction::Install).unwrap();
assert!(
install_cmd.starts_with("bash -c 'tmp=$(mktemp) && curl -fsSL "),
"WSL hermes 安装应直接走官方 Unix installer,得到: {install_cmd}"
);
assert!(
!install_cmd.contains('|') && install_cmd.contains(" -o $tmp && bash $tmp"),
"WSL hermes 安装不应依赖 pipefail,得到: {install_cmd}"
);
}
#[test]
fn wsl_hermes_install_line_does_not_depend_on_outer_pipefail() {
let line = build_wsl_tool_action_line("Ubuntu", HERMES_INSTALL_UNIX, None, None)
.expect("valid WSL command line");
assert!(line.starts_with("wsl.exe -d Ubuntu -- sh -c "));
assert!(
!line.contains("| bash") && line.contains(" -o $tmp && bash $tmp"),
"WSL 子 shell 内不能出现 curl 管道安装器: {line}"
);
}
#[test]
fn wsl_install_uses_posix_install_priority() {
let claude =
wsl_tool_action_shell_command("claude", ToolLifecycleAction::Install).unwrap();
assert!(
claude.starts_with("bash -c 'tmp=$(mktemp) && curl -fsSL https://claude.ai/install.sh ")
&& claude.contains(" || npm i -g @anthropic-ai/claude-code@latest"),
"WSL claude install should prefer native POSIX installer with npm fallback: {claude}"
);
assert!(!claude.contains("| bash"));
let opencode =
wsl_tool_action_shell_command("opencode", ToolLifecycleAction::Install).unwrap();
assert!(
opencode.starts_with(
"bash -c 'tmp=$(mktemp) && curl -fsSL https://opencode.ai/install "
) && opencode.contains(" || npm i -g opencode-ai@latest"),
"WSL opencode install should prefer native POSIX installer with npm fallback: {opencode}"
);
assert!(!opencode.contains("| bash"));
let codex =
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
assert_eq!(codex, "npm i -g @openai/codex@latest");
}
#[test]
fn wsl_npm_tools_use_posix_update_chain_without_batch_call() {
// WSL 内跑的是 POSIX shell,不能带 Windows batch 的 `call`。同时 update
// fallback 仍应先尝试官方 CLI 自升级。
let cmd = wsl_tool_action_shell_command("claude", ToolLifecycleAction::Update).unwrap();
assert_eq!(
cmd,
"claude update || npm i -g @anthropic-ai/claude-code@latest"
);
}
}
/// `infer_install_source` 是判定锚定 idiom 的入口——nvm/homebrew/volta/pnpm/...
/// 各对应不同的升级命令形态。函数内部已 `replace('\\','/').to_ascii_lowercase()`
/// 归一化,Windows 反斜杠 + 大小写差异在此处不需要分平台。这里固化"哪条路径
/// 算哪种来源"的归类断言,避免未来调整子串顺序时静默改变分类。
mod install_source_classification {
use super::super::*;
use std::path::Path;
#[test]
fn macos_volta_with_dot_prefix() {
assert_eq!(
infer_install_source(Path::new("/Users/me/.volta/bin/codex")),
"volta"
);
}
#[test]
fn windows_volta_localappdata_no_dot() {
// `%LOCALAPPDATA%\Volta\bin\codex.exe` —— 没有前导点,靠兜底的 `/volta/`
// 命中(归一化后小写)。如果只识别 `/.volta/`,Windows 这一类会落到 system。
assert_eq!(
infer_install_source(Path::new(
"C:\\Users\\me\\AppData\\Local\\Volta\\bin\\codex.exe"
)),
"volta"
);
}
#[test]
fn windows_pnpm_localappdata() {
// `%LOCALAPPDATA%\pnpm\codex.cmd` —— pnpm 全局 bin 目录,识别为 pnpm 后
// 锚定命令走 `pnpm add -g <pkg>@latest`,而不是 sibling npm。
assert_eq!(
infer_install_source(Path::new("C:\\Users\\me\\AppData\\Local\\pnpm\\codex.cmd")),
"pnpm"
);
}
#[test]
fn windows_nvm_falls_back_to_system() {
// nvm-windows 安装的工具路径不含 `.nvm`(它通常装在 `%APPDATA%\nvm` 或
// `C:\Program Files\nodejs` symlink),刻意不识别成专属 source——锚定层
// 会按 system → sibling npm.cmd 处理,跟 nvm-windows 的实际 idiom 一致
// (它的全局包就是当前选中的 node 的 npm 装的)。
assert_eq!(
infer_install_source(Path::new(
"C:\\Users\\me\\AppData\\Roaming\\nvm\\v22.0.0\\codex.cmd"
)),
"system"
);
}
#[test]
fn windows_scoop_still_identified() {
// Scoop 已有 `/scoop/` 分支;我们的 6 个工具都不是 scoop formula,所以这条
// 实际不影响锚定决策(锚定层会用 sibling npm.cmd),但归类保留方便未来。
assert_eq!(
infer_install_source(Path::new("C:\\Users\\me\\scoop\\shims\\codex.cmd")),
"scoop"
);
}
}
/// 锚定升级命令生成:用真实勘察到的安装路径固化为回归断言——
/// 一台机器上 4 个工具恰好对应 4 种升级方式(原生 self-update / brew / nvm npm /
/// homebrew npm),任何改动若打破其中一种都会立刻被这些用例拦下。
#[cfg(not(target_os = "windows"))]
mod anchored_upgrade {
use super::super::*;
use std::path::Path;
fn inst(path: &str, is_default: bool) -> ToolInstallation {
ToolInstallation {
path: path.to_string(),
version: None,
runnable: true,
error: None,
source: infer_install_source(Path::new(path)).to_string(),
is_path_default: is_default,
// 测试场景下不需要走 fs canonicalize——POSIX 锚定测试关心的是
// path/real 都被传给 anchored_command_from_paths 的纯字符串判定,
// 已有用例(brew_formula_extraction / claude_native_*)是直接
// 调 anchored_command_from_paths,不通过 installs_anchored_command,
// 这里 real 是给上层 default_install + read 用,填同值即可。
real: std::path::PathBuf::from(path),
}
}
#[test]
fn claude_native_installer_uses_self_update() {
// ~/.local/bin/claude → 真身在 ~/.local/share/claude/versions/,自带 self-update;
// 它不归 npm 管,且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级纯属白装。
// **绝对路径调用 launcher** 避免 GUI 非登录 `bash -c` 时 PATH 没有
// ~/.local/bin 导致 `claude: not found`(exit 127)而失败。
let cmd = anchored_command_from_paths(
"claude",
"/Users/me/.local/bin/claude",
"/Users/me/.local/share/claude/versions/2.1.146",
);
assert_eq!(cmd.as_deref(), Some("/Users/me/.local/bin/claude update"));
}
#[test]
fn gemini_homebrew_formula_uses_brew_upgrade() {
// /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包,
// 且 formula 名(gemini-cli) ≠ npm 包名(@google/gemini-cli)。
// **brew 与 formula 入口同目录**,用 `<dir>/brew` 绝对路径调用,避免 GUI
// 非登录 `bash -c` 时 PATH 没有 /opt/homebrew/bin 导致 `brew: not found`。
let cmd = anchored_command_from_paths(
"gemini",
"/opt/homebrew/bin/gemini",
"/opt/homebrew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
);
assert_eq!(
cmd.as_deref(),
Some("/opt/homebrew/bin/brew upgrade gemini-cli")
);
}
#[test]
fn codex_homebrew_formula_uses_brew_not_self_update() {
// Homebrew formula 归 brew 管理;即使 Codex 有 self-update,也不先改动
// Cellar 内的安装内容。
let cmd = anchored_command_from_paths(
"codex",
"/opt/homebrew/bin/codex",
"/opt/homebrew/Cellar/codex/1.2.3/bin/codex",
);
assert_eq!(cmd.as_deref(), Some("/opt/homebrew/bin/brew upgrade codex"));
}
#[test]
fn gemini_nvm_anchors_to_npm_without_cli_update() {
let cmd = anchored_command_from_paths(
"gemini",
"/Users/me/.nvm/versions/node/v22.14.0/bin/gemini",
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@google/gemini-cli/dist/index.js",
);
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @google/gemini-cli@latest"
)
);
}
#[test]
fn codex_nvm_anchors_to_that_npm() {
// Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
// 却会假成功掩盖平台二进制漏装)——直接锚定到同一个 node 的 npm,而非 PATH
// 第一个 npm。损坏时的 uninstall+install 自愈见 codex_missing_platform_binary_*。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@openai/codex/bin/codex.js",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
#[test]
fn homebrew_npm_global_package_anchors_not_brew() {
// openclaw 装在 Homebrew node 的全局目录(lib/node_modules,非 Cellar)
// 是 npm 全局包,官方 update 失败后走 npm 锚定而非 brew upgrade。
let cmd = anchored_command_from_paths(
"openclaw",
"/opt/homebrew/bin/openclaw",
"/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs",
);
assert_eq!(
cmd.as_deref(),
Some("/opt/homebrew/bin/openclaw update --yes || /opt/homebrew/bin/npm i -g openclaw@latest")
);
}
#[test]
fn volta_self_update_chain_anchors_to_volta() {
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
// 用 openclaw(仍在 prefers_official_update)覆盖 volta 分支的 self-update 链;
// codex 已改为不 self-update(见 codex_volta_anchors_to_volta_install)。
let cmd = anchored_command_from_paths(
"openclaw",
"/Users/me/.volta/bin/openclaw",
"/Users/me/.volta/tools/image/packages/openclaw/lib/node_modules/openclaw",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/openclaw update --yes || /Users/me/.volta/bin/volta install openclaw")
);
}
#[test]
fn codex_volta_anchors_to_volta_install() {
// codex 锚定到命令行命中的那份 volta,但不 self-update:纯 `volta install`。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.volta/bin/codex",
"/Users/me/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
#[test]
fn bun_uses_bun_add() {
// OpenCode 先跑官方 upgrade;失败后 bun 同 volta:绝对路径写回原安装源。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/me/.bun/bin/opencode",
"/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.bun/bin/opencode upgrade || /Users/me/.bun/bin/bun add -g opencode-ai@latest")
);
}
#[test]
fn volta_path_with_space_is_quoted() {
// volta 分支用 `<dir>/volta`,目录含空格时同样要 POSIX 引号包裹。
let cmd = anchored_command_from_paths(
"codex",
"/Users/my name/.volta/bin/codex",
"/Users/my name/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
);
}
#[test]
fn bun_path_with_space_is_quoted() {
// bun 分支与 volta 共享 sibling_bin + quote_path_if_spaced,
// 这条用例锁住 `bun add -g` 命令头部的引号包裹形态。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/my name/.bun/bin/opencode",
"/Users/my name/.bun/install/global/node_modules/opencode-ai/bin/opencode",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.bun/bin/opencode' upgrade || '/Users/my name/.bun/bin/bun' add -g opencode-ai@latest")
);
}
#[test]
fn hermes_uses_cli_update_anchor() {
// Hermes 自带 `hermes update`;锚定到命令行默认那处 CLI,避免 cc-switch 猜
// 系统 Python/pip 时撞上 Python >=3.11 或 pyenv shim 问题。
let cmd = anchored_command_from_paths(
"hermes",
"/usr/local/bin/hermes",
"/usr/local/bin/hermes",
);
assert_eq!(cmd.as_deref(), Some("/usr/local/bin/hermes update"));
}
#[test]
fn opencode_native_install_uses_cli_upgrade_without_package_fallback() {
// opencode install.sh 装到 ~/.opencode/bin(独立二进制、无同级 npm):
// 不能锚定到 `<dir>/npm`(必失败),但可以锚定到 CLI 自身跑官方 upgrade。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/me/.opencode/bin/opencode",
"/Users/me/.opencode/bin/opencode",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.opencode/bin/opencode upgrade")
);
}
#[test]
fn go_bin_opencode_uses_cli_upgrade_without_package_fallback() {
// ~/go/bin 同理:无同级 npm,但 OpenCode 官方 upgrade 可由 CLI 自己处理。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/me/go/bin/opencode",
"/Users/me/go/bin/opencode",
);
assert_eq!(cmd.as_deref(), Some("/Users/me/go/bin/opencode upgrade"));
}
#[test]
fn fnm_install_anchors_to_that_npm() {
// fnm 是自带同级 npm 的 node 管理器 → 锚定到那处的 npm。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex",
"/Users/me/.local/share/fnm_multishells/12345_abc/lib/node_modules/@openai/codex/bin/codex.js",
);
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
)
);
}
#[test]
fn path_with_space_is_quoted() {
let cmd = anchored_command_from_paths(
"codex",
"/Users/my name/.nvm/versions/node/v22/bin/codex",
"/Users/my name/.nvm/versions/node/v22/lib/node_modules/@openai/codex/bin/codex.js",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
);
}
#[test]
fn claude_native_path_with_space_is_quoted() {
// claude 分支同样要 POSIX 引号包裹含空格的 bin_path,
// 否则 `/Users/my name/.local/bin/claude update` 会被 shell 拆词。
let cmd = anchored_command_from_paths(
"claude",
"/Users/my name/.local/bin/claude",
"/Users/my name/.local/share/claude/versions/2.1.146",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.local/bin/claude' update")
);
}
#[test]
fn brew_path_with_space_is_quoted() {
// brew 分支用 `<bin_path 同目录>/brew`,目录含空格时同样要引号包裹。
let cmd = anchored_command_from_paths(
"gemini",
"/opt/my brew/bin/gemini",
"/opt/my brew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
);
assert_eq!(
cmd.as_deref(),
Some("'/opt/my brew/bin/brew' upgrade gemini-cli")
);
}
#[test]
fn brew_formula_extraction() {
assert_eq!(
brew_formula_from_path("/opt/homebrew/Cellar/gemini-cli/0.13.0/bin/gemini")
.as_deref(),
Some("gemini-cli")
);
// node 全局包不在 Cellar 下 → 不是 formula。
assert_eq!(
brew_formula_from_path("/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs"),
None
);
assert_eq!(
brew_formula_from_path("/Users/me/.nvm/versions/node/v22/lib/node_modules/x"),
None
);
}
#[test]
fn sibling_bin_returns_none_when_bin_path_has_no_directory() {
// bin_path 不含 `/` → parent_dir 返回空 → sibling_bin 不能拼出绝对路径
// → None,让上游 anchored_command_from_paths 整体退化为静态命令兜底,
// 而不是悄悄拼出 `npm i -g <pkg>` 这种依赖 PATH 的指令(违背"必须绝对路径"
// 不变量)。实际从 enumerate_tool_installations 走的 bin_path 都是绝对路径,
// 这条防线不期望被触发,但闭合了 helper 与函数文档的语义一致。
assert_eq!(sibling_bin("codex", "npm"), None);
assert_eq!(sibling_bin("", "brew"), None);
// 含 `/` 即可拼出绝对路径——这是常规路径。
assert_eq!(
sibling_bin("/opt/homebrew/bin/gemini", "brew").as_deref(),
Some("/opt/homebrew/bin/brew")
);
}
#[test]
fn default_install_prefers_path_default() {
let installs = vec![
inst("/opt/homebrew/bin/openclaw", false),
inst("/Users/me/.nvm/versions/node/v22/bin/openclaw", true),
];
assert_eq!(
default_install(&installs).map(|i| i.path.as_str()),
Some("/Users/me/.nvm/versions/node/v22/bin/openclaw")
);
}
#[test]
fn default_install_falls_back_to_sole_entry() {
let installs = vec![inst("/opt/homebrew/bin/gemini", false)];
assert_eq!(
default_install(&installs).map(|i| i.path.as_str()),
Some("/opt/homebrew/bin/gemini")
);
}
#[test]
fn default_install_none_when_ambiguous() {
let installs = vec![
inst("/opt/homebrew/bin/openclaw", false),
inst("/Users/me/.nvm/versions/node/v22/bin/openclaw", false),
];
assert!(default_install(&installs).is_none());
}
#[test]
fn codex_missing_platform_binary_self_heals_via_uninstall_install() {
// 平台二进制缺失 → `codex --version` 报 "Missing optional dependency" 退出非 0
// → enumerate 标记 runnable=false。此状态下普通 `npm i -g @latest` 是 no-op 修不好,
// 升级路径改用 uninstall+install 重装补回平台二进制(`|| true` 让 uninstall 在
// set -e 下对半损坏包返回非 0 时仍继续 install)。
let mut broken = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
#[test]
fn codex_runnable_uses_plain_npm_not_self_heal() {
// 正常(runnable=true)的 codex 升级:锚定 npm,既不重装、也不跑会假成功
// 掩盖损坏的 `codex update`。
let healthy = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
let cmd = installs_anchored_command("codex", &[healthy]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("uninstall"));
}
#[test]
fn codex_broken_homebrew_formula_uses_brew_not_npm_repair() {
// brew formula 装的坏 codexreal 在 Cellar):自愈门控必须收窄放行,回落到
// `brew upgrade codex`——若误走 npm 重装,npm 够不到 Cellar 那份、反而旁路
// 装第二份 npm 全局 codex 制造双安装。
let broken = ToolInstallation {
path: "/opt/homebrew/bin/codex".to_string(),
version: None,
runnable: false,
error: None,
source: "homebrew".to_string(),
is_path_default: true,
real: std::path::PathBuf::from("/opt/homebrew/Cellar/codex/1.2.3/bin/codex"),
};
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/opt/homebrew/bin/brew upgrade codex")
);
}
#[test]
fn codex_broken_volta_uses_volta_install_not_npm_repair() {
// volta 装的坏 codex:回落到 `volta install`,不走 npm 重装。
let mut broken = inst("/Users/me/.volta/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
#[test]
fn codex_broken_bun_uses_bun_add_not_phantom_npm() {
// bun 装的坏 codex:回落到 `bun add`,且**绝不**拼出 `~/.bun/bin/npm`
// (bun 目录下没有 npm,那条路径不存在、执行会直接失败)。
let mut broken = inst("/Users/me/.bun/bin/codex", true);
broken.runnable = false;
let cmd = installs_anchored_command("codex", &[broken]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.bun/bin/bun add -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("npm"));
}
#[test]
fn first_abs_path_line_skips_shell_noise() {
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
// command -v 的真实路径在其后 → 跳过噪音取真路径。
assert_eq!(
first_abs_path_line("🚀 Welcome back!\n/Users/me/.local/bin/claude\n"),
Some("/Users/me/.local/bin/claude")
);
// 无噪音时取第一行。
assert_eq!(
first_abs_path_line("/opt/homebrew/bin/gemini\n"),
Some("/opt/homebrew/bin/gemini")
);
// 输出里没有任何绝对路径 → None。
assert_eq!(first_abs_path_line("welcome\nbye\n"), None);
}
#[test]
fn is_conflicting_thresholds() {
let make = |version: Option<&str>, runnable: bool| ToolInstallation {
path: "/x".to_string(),
version: version.map(str::to_string),
runnable,
error: None,
source: "nvm".to_string(),
is_path_default: false,
real: std::path::PathBuf::from("/x"),
};
// 单处 → 不冲突。
assert!(!is_conflicting(&[make(Some("1.0.0"), true)]));
// 两处同版本、都能跑 → 不冲突(同版本装两遍不打扰)。
assert!(!is_conflicting(&[
make(Some("1.0.0"), true),
make(Some("1.0.0"), true)
]));
// 版本分歧 → 冲突。
assert!(is_conflicting(&[
make(Some("1.0.0"), true),
make(Some("2.0.0"), true)
]));
// 同版本但运行态混合(一个能跑、一个跑不起来)→ 冲突。
assert!(is_conflicting(&[
make(Some("1.0.0"), true),
make(Some("1.0.0"), false)
]));
}
}
/// install 端的"上游推荐 || npm 兜底"短路链:把工具→官方安装方式这一上游事实
/// 固化为回归断言。任何方案改动若打破短路链结构或 URL,都会被这些用例拦下。
#[cfg(not(target_os = "windows"))]
mod install_strategy {
use super::super::*;
#[test]
fn claude_install_prefers_native_with_npm_fallback() {
// Anthropic 现在主推 native installer(claude.ai/install.sh),
// 网络不通时短路到 npm 仍能装上;两段都得在,顺序也得对。
let cmd = install_command_for("claude");
assert!(
cmd.contains("https://claude.ai/install.sh"),
"should include official installer URL: {cmd}"
);
assert!(
cmd.contains("@anthropic-ai/claude-code@latest"),
"should keep npm package as fallback: {cmd}"
);
let parts: Vec<&str> = cmd.split("||").collect();
assert_eq!(parts.len(), 2, "should be a two-step short-circuit chain");
assert!(parts[0].contains("install.sh"), "native first: {cmd}");
assert!(
!parts[0].contains('|'),
"native installer should avoid pipe: {cmd}"
);
assert!(parts[1].contains("npm i -g"), "npm second: {cmd}");
}
#[test]
fn opencode_install_prefers_native_with_npm_fallback() {
// SST 自家 install.sh 与 claude 同形态:bash 脚本、网络下载、装到 ~/.opencode/bin。
let cmd = install_command_for("opencode");
assert!(
cmd.contains("https://opencode.ai/install"),
"should include official installer URL: {cmd}"
);
assert!(
cmd.contains("opencode-ai@latest"),
"should keep npm package as fallback: {cmd}"
);
assert!(cmd.contains("||"), "should chain fallback: {cmd}");
assert!(
!cmd.split("||").next().unwrap_or_default().contains('|'),
"native installer should avoid pipe: {cmd}"
);
}
#[test]
fn codex_install_keeps_static_npm() {
// OpenAI 暂无独立 native installer,保持原裸 npm,不引入兜底链(无东西可兜底)。
let cmd = install_command_for("codex");
assert_eq!(cmd, "npm i -g @openai/codex@latest");
assert!(!cmd.contains("||"));
}
#[test]
fn gemini_install_keeps_static_npm() {
// Google 文档同时支持 brew/npm,但本表保持与 update fallback 一致的 npm。
// 用户若已装 brew gemini-cli,update 路径的锚定会识别 formula → brew upgrade,
// 所以 install 端不强行替用户决策"用 brew 还是 npm"。
let cmd = install_command_for("gemini");
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
}
#[test]
fn openclaw_install_keeps_static_npm() {
let cmd = install_command_for("openclaw");
assert_eq!(cmd, "npm i -g openclaw@latest");
}
#[test]
fn update_fallbacks_use_official_cli_only_when_supported() {
assert_eq!(
static_fallback_command("claude"),
"claude update || npm i -g @anthropic-ai/claude-code@latest"
);
assert_eq!(
static_fallback_command("codex"),
"npm i -g @openai/codex@latest"
);
assert!(!static_fallback_command("codex").contains("codex update"));
assert_eq!(
static_fallback_command("gemini"),
"npm i -g @google/gemini-cli@latest"
);
assert!(!static_fallback_command("gemini").contains("gemini update"));
assert_eq!(
static_fallback_command("opencode"),
"opencode upgrade || npm i -g opencode-ai@latest"
);
assert_eq!(
static_fallback_command("openclaw"),
"openclaw update --yes || npm i -g openclaw@latest"
);
}
#[test]
fn hermes_install_uses_official_installer() {
// Hermes 官方 installer 会处理 Python 3.11+/uv 等运行时;不要再从 cc-switch
// 里走 `python3 || python` pip 链。
let cmd = install_command_for("hermes");
assert!(
cmd.starts_with("bash -c 'tmp=$(mktemp) && curl -fsSL ")
&& cmd.contains("install.sh -o $tmp && bash $tmp"),
"should use official installer: {cmd}"
);
assert!(
!cmd.contains('|') && !cmd.contains("python") && !cmd.contains("pip"),
"should not depend on pipefail or system Python/pip: {cmd}"
);
}
#[test]
fn hermes_update_fallback_uses_cli_update_then_installer() {
// 锚定失败时也不回退 pip:先让 PATH 上的 hermes 自更新,找不到/失败再跑官方
// installer。这样 pyenv 的 `python` shim 不会参与错误路径。
let cmd = static_fallback_command("hermes");
assert!(
cmd.starts_with("hermes update || bash -c 'tmp=$(mktemp) && curl -fsSL "),
"should try CLI update before official installer: {cmd}"
);
let fallback = cmd
.split_once("||")
.map(|(_, fallback)| fallback)
.expect("update should include installer fallback");
assert!(
!fallback.contains('|') && !cmd.contains("python") && !cmd.contains("pip"),
"should not depend on pipefail or system Python/pip: {cmd}"
);
}
}
#[cfg(target_os = "windows")]
mod wsl_helpers {
use super::super::*;
#[test]
fn test_is_valid_shell() {
assert!(is_valid_shell("bash"));
assert!(is_valid_shell("zsh"));
assert!(is_valid_shell("sh"));
assert!(is_valid_shell("fish"));
assert!(is_valid_shell("dash"));
assert!(is_valid_shell("/usr/bin/bash"));
assert!(is_valid_shell("/bin/zsh"));
assert!(!is_valid_shell("powershell"));
assert!(!is_valid_shell("cmd"));
assert!(!is_valid_shell(""));
}
#[test]
fn test_is_valid_shell_flag() {
assert!(is_valid_shell_flag("-c"));
assert!(is_valid_shell_flag("-lc"));
assert!(is_valid_shell_flag("-lic"));
assert!(!is_valid_shell_flag("-x"));
assert!(!is_valid_shell_flag(""));
assert!(!is_valid_shell_flag("--login"));
}
#[test]
fn test_default_flag_for_shell() {
assert_eq!(default_flag_for_shell("sh"), "-c");
assert_eq!(default_flag_for_shell("dash"), "-c");
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
assert_eq!(default_flag_for_shell("fish"), "-lc");
assert_eq!(default_flag_for_shell("bash"), "-lic");
assert_eq!(default_flag_for_shell("zsh"), "-lic");
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
}
#[test]
fn test_is_valid_wsl_distro_name() {
assert!(is_valid_wsl_distro_name("Ubuntu"));
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
assert!(is_valid_wsl_distro_name("my_distro"));
assert!(!is_valid_wsl_distro_name(""));
assert!(!is_valid_wsl_distro_name("distro with spaces"));
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
}
}
#[test]
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
let home = PathBuf::from("/home/tester");
let install_dir = Some(std::ffi::OsString::from("/custom/opencode/bin"));
let xdg_bin_dir = Some(std::ffi::OsString::from("/xdg/bin"));
let gopath =
std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok();
let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath);
assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin"));
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
}
#[test]
fn opencode_extra_search_paths_deduplicates_repeated_entries() {
let home = PathBuf::from("/home/tester");
let same_dir = Some(std::ffi::OsString::from("/same/path"));
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
let count = paths
.iter()
.filter(|path| path.as_path() == Path::new("/same/path"))
.count();
assert_eq!(count, 1);
}
#[test]
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
let home = PathBuf::from("/home/tester");
let paths = opencode_extra_search_paths(&home, None, None, None);
let count = paths
.iter()
.filter(|path| path.as_path() == Path::new("/home/tester/.bun/bin"))
.count();
assert_eq!(count, 1);
}
#[test]
fn cli_path_env_search_paths_include_path_entries_and_dedupe() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let first = temp.path().join("first");
let second = temp.path().join("second");
std::fs::create_dir_all(&first).expect("first dir should be created");
std::fs::create_dir_all(&second).expect("second dir should be created");
let path_env = std::env::join_paths([first.clone(), second.clone(), first.clone()])
.expect("test path env should be joinable");
let mut paths = vec![first.clone()];
extend_from_cli_path_env(&mut paths, Some(path_env));
assert!(paths.contains(&second));
assert_eq!(paths.iter().filter(|path| *path == &first).count(), 1);
}
#[test]
fn child_search_paths_include_existing_children_with_suffix() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let base = temp.path().join("node");
let bin = base.join("25.8.0").join("bin");
std::fs::create_dir_all(&bin).expect("version bin should be created");
let mut paths = Vec::new();
extend_existing_child_search_paths(&mut paths, &base, Some("bin"));
assert!(paths.contains(&bin));
}
#[test]
fn env_child_dir_appends_child_and_dedupes() {
let base = std::ffi::OsString::from("/custom/toolchain");
let mut paths = Vec::new();
push_env_child_dir(&mut paths, Some(base.clone()), "bin");
push_env_child_dir(&mut paths, Some(base), "bin");
assert_eq!(paths, vec![PathBuf::from("/custom/toolchain").join("bin")]);
}
#[cfg(target_os = "windows")]
#[test]
fn cli_path_env_skips_windows_apps_alias_dir() {
assert!(is_windows_app_execution_alias_dir(Path::new(
r"C:\Users\tester\AppData\Local\Microsoft\WindowsApps"
)));
assert!(!is_windows_app_execution_alias_dir(Path::new(
r"C:\Users\tester\AppData\Roaming\npm"
)));
}
#[test]
fn mise_node_search_paths_include_shims_and_installed_node_bins() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let home = temp.path();
let node_bin = home
.join(".local/share/mise/installs/node/25.8.0")
.join("bin");
std::fs::create_dir_all(&node_bin).expect("node bin should be created");
let mut paths = Vec::new();
extend_mise_node_search_paths(&mut paths, home);
assert!(paths.contains(&home.join(".local/share/mise/shims")));
assert!(paths.contains(&node_bin));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
let dir = PathBuf::from("/usr/local/bin");
let candidates = tool_executable_candidates("opencode", &dir);
assert_eq!(candidates, vec![PathBuf::from("/usr/local/bin/opencode")]);
}
#[cfg(target_os = "windows")]
#[test]
fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() {
let dir = PathBuf::from("C:\\tools");
let candidates = tool_executable_candidates("opencode", &dir);
assert_eq!(
candidates,
vec![
PathBuf::from("C:\\tools\\opencode.cmd"),
PathBuf::from("C:\\tools\\opencode.exe"),
PathBuf::from("C:\\tools\\opencode"),
]
);
}
#[cfg(target_os = "windows")]
#[test]
fn tool_executable_candidates_windows_skips_shadowed_npm_unix_shim() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let extensionless = dir.path().join("codex");
let cmd = dir.path().join("codex.cmd");
std::fs::write(&extensionless, "").expect("extensionless shim should be created");
std::fs::write(&cmd, "").expect("cmd shim should be created");
let candidates = tool_executable_candidates("codex", dir.path());
assert_eq!(candidates, vec![cmd.clone(), dir.path().join("codex.exe")]);
assert!(!candidates.contains(&extensionless));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_runnable_sibling_prefers_cmd_over_extensionless_tool() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let extensionless = dir.path().join("codex");
let cmd = dir.path().join("codex.cmd");
std::fs::write(&extensionless, "").expect("extensionless shim should be created");
std::fs::write(&cmd, "").expect("cmd shim should be created");
let preferred = windows_runnable_sibling_for_extensionless_tool(&extensionless);
assert_eq!(preferred.as_deref(), Some(cmd.as_path()));
}
#[test]
fn resolve_launch_cwd_accepts_existing_directory() {
let resolved =
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
.expect("temp dir should resolve")
.expect("temp dir should be present");
assert!(resolved.is_dir());
}
#[test]
fn resolve_launch_cwd_rejects_missing_directory() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
.expect_err("missing directory should fail");
assert!(error.contains("目录不存在"));
}
#[cfg(target_os = "macos")]
#[test]
fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() {
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
let cold_start_branch = script
.split("else\n activate")
.nth(1)
.expect("cold start branch should be present")
.split(" end if\n tell current session")
.next()
.expect("cold start branch should end before writing command");
assert!(cold_start_branch.contains("repeat while (count of windows) = 0"));
assert!(cold_start_branch.contains("create window with default profile"));
assert!(!cold_start_branch.contains("tell current window"));
assert!(!cold_start_branch.contains("create tab with default profile"));
}
#[cfg(target_os = "macos")]
#[test]
fn iterm2_applescript_keeps_new_tab_behavior_for_existing_windows() {
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
let running_branch = script
.split("if was_running then")
.nth(1)
.expect("already-running branch should be present")
.split("else\n activate")
.next()
.expect("already-running branch should end before cold start branch");
assert!(running_branch.contains("if (count of windows) = 0 then"));
assert!(running_branch.contains("create window with default profile"));
assert!(running_branch.contains("create tab with default profile"));
}
/// Terminal `activate` creates a default empty window on cold start; `launch` does not.
#[cfg(target_os = "macos")]
#[test]
fn terminal_applescript_cold_start_uses_launch_before_do_script() {
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
assert!(
script.contains(r#"set was_running to application "Terminal" is running"#),
"missing was_running detection:\n{script}"
);
// Cold launches avoid `activate` until after `do script`, so no default empty window is created first.
assert!(
script.contains(
"else\n launch\n do script launcher_script\n activate"
),
"cold start should launch before activating:\n{script}"
);
// Already-running launches should create a fresh session.
assert!(
script.contains(
"if was_running then\n activate\n do script launcher_script\n"
),
"already-running branch should use bare do script:\n{script}"
);
assert!(
script.contains(r#"set launcher_script to "exec sh '/tmp/cc_switch_launcher.sh'""#),
"Terminal should replace the auto-created shell:\n{script}"
);
}
/// Restored windows should not receive the launcher command.
#[cfg(target_os = "macos")]
#[test]
fn terminal_applescript_does_not_hijack_restored_windows() {
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
assert!(
!script.contains(" in window 1"),
"should not inject into an existing/restored Terminal window:\n{script}"
);
assert!(
!script.contains("count of windows"),
"should not infer restored-window safety from window count:\n{script}"
);
}
/// Ghostty cold starts use `initial-command`; warm starts use the scripting dictionary.
#[cfg(target_os = "macos")]
#[test]
fn ghostty_applescript_cold_start_uses_initial_command() {
let script = build_macos_ghostty_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
// Warm launches execute through the AppleScript command property, not `open -na ... -e`.
assert!(
script.contains(r#"set launcher_command to "sh '/tmp/cc_switch_launcher.sh'""#),
"missing launcher_command:\n{script}"
);
assert!(script.contains("if was_running then"));
assert!(script.contains("new window with configuration {command:launcher_command}"));
assert!(
!script.contains(" --args -e"),
"should not execute through open -na -e:\n{script}"
);
// Cold launches make Ghostty's first default surface execute the launcher.
assert!(script.contains(r#"set was_running to application "Ghostty" is running"#));
assert!(
script.contains(
r#"do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)"#
),
"cold start should use initial-command:\n{script}"
);
assert!(
!script.contains("--initial-window=false"),
"should not rely on initial-window=false:\n{script}"
);
assert!(
!script.contains("delay 0.5"),
"should not rely on a fixed delay:\n{script}"
);
assert!(
!script.contains("old_ids"),
"should not track default windows for closing:\n{script}"
);
assert!(
!script.contains("close window"),
"should not close a default window:\n{script}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn dash_c_command_wraps_script_path_inside_quoted_arg() {
// The script path must stay inside the `-c` string, not as a bare argv.
let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh"));
assert_eq!(s, "exec sh '/tmp/cc_switch_launcher_1.sh'");
// Spaces and single quotes must stay shell-safe too.
let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh"));
assert_eq!(s2, r#"exec sh '/Users/me/it'"'"'s dir/x.sh'"#);
}
/// AppleScript launchers need both shell-path quoting and AppleScript string quoting.
#[cfg(target_os = "macos")]
#[test]
fn applescript_builders_safely_quote_special_paths() {
// First shell-quote the path, then wrap the whole command as an AppleScript string.
let expected = r#""sh '/Users/me/it'\"'\"'s dir/x.sh'""#;
let p = Path::new("/Users/me/it's dir/x.sh");
assert_eq!(applescript_launcher_command(p), expected);
assert_eq!(
applescript_exec_launcher_command(p),
r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#
);
assert!(
build_macos_terminal_applescript(p)
.contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#),
"Terminal did not quote safely"
);
assert!(
build_macos_iterm2_applescript(p)
.contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#),
"iTerm2 did not quote safely"
);
assert!(
build_macos_ghostty_applescript(p).contains(expected),
"Ghostty did not keep the non-exec launcher"
);
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
}
#[test]
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
assert_eq!(
command,
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
);
}
#[test]
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
assert_eq!(
command,
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
);
}
}