diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 4c8cfa570..4a455fc42 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -974,10 +974,10 @@ fn extend_mise_node_search_paths(paths: &mut Vec, home: &Pat } } -/// 扫描常见路径查找 CLI -fn scan_cli_version(tool: &str) -> ShellProbe { - use std::process::Command; - +/// 构建某工具的候选搜索目录(原生安装优先,PATH 兜底)。 +/// 单探兜底 (`scan_cli_version`) 与全量枚举 (`enumerate_tool_installations`) 共用, +/// 确保两条路径看到的是同一组安装位置。 +fn build_tool_search_paths(tool: &str) -> Vec { let home = dirs::home_dir().unwrap_or_default(); // 常见的安装路径(原生安装优先) @@ -1102,9 +1102,16 @@ fn scan_cli_version(tool: &str) -> ShellProbe { } let path_env = std::env::var_os("PATH"); - extend_from_cli_path_env(&mut search_paths, path_env.clone()); - let current_path = path_env - .as_ref() + extend_from_cli_path_env(&mut search_paths, path_env); + search_paths +} + +/// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。 +fn scan_cli_version(tool: &str) -> ShellProbe { + 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(); @@ -1168,6 +1175,220 @@ fn scan_cli_version(tool: &str) -> ShellProbe { } } +/// 单个工具在系统中的一处安装,用于"多处安装互相打架"的冲突诊断。 +/// 字段保持 snake_case(与 `ToolVersion` 一致),前端按同名字段读取。 +#[derive(Debug, serde::Serialize)] +pub struct ToolInstallation { + /// 候选入口路径(用户实际在 PATH 里看到/输入的那个,未解析软链)。 + path: String, + /// `--version` 成功时解析出的版本号。 + version: Option, + /// `--version` 是否 exit 0(装了且能在当前环境跑起来)。 + runnable: bool, + /// 跑不起来时的诊断信息末尾若干行。 + error: Option, + /// 由路径前缀推断的安装来源(nvm/homebrew/...),驱动 UI 徽章与卸载建议。 + source: String, + /// 是否为 PATH 解析到的那处(= 命令行默认,也是升级会作用的目标)。 + is_path_default: bool, +} + +/// 由可执行文件路径前缀推断安装来源。纯字符串匹配、无副作用。 +/// 顺序敏感: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" + } else if s.contains("/.volta/") { + "volta" + } else if s.contains("fnm_multishells") { + "fnm" + } else if s.contains("/mise/") { + "mise" + } else if s.contains("/.bun/") { + "bun" + } else if s.contains("/scoop/") { + "scoop" + } else if s.contains("/library/python") + || s.contains("/scripts/") + || s.contains("/site-packages/") + { + "pip" + } else { + "system" + } +} + +/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径, +/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。 +#[cfg(not(target_os = "windows"))] +fn resolve_path_default(tool: &str) -> Option { + 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 = String::from_utf8_lossy(&out.stdout); + let first = raw.lines().next()?.trim(); + if first.is_empty() { + return None; + } + std::fs::canonicalize(first).ok() +} + +#[cfg(target_os = "windows")] +fn resolve_path_default(tool: &str) -> Option { + 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 = String::from_utf8_lossy(&out.stdout); + let first = raw.lines().next()?.trim(); + if first.is_empty() { + return None; + } + std::fs::canonicalize(first).ok() +} + +/// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用 +/// `build_tool_search_paths`,但不在首个命中处停止——而是对每个去重后的真实 +/// 可执行文件都跑一次 `--version`,从而能发现"升级写入 A 处、PATH 实际用 B 处"。 +fn enumerate_tool_installations(tool: &str) -> Vec { + 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::collections::HashSet::new(); + let mut installs: Vec = 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 = { + use std::os::windows::process::CommandExt; + Command::new("cmd") + .args(["/C", &format!("\"{}\" --version", tool_path.display())]) + .env("PATH", &new_path) + .creation_flags(CREATE_NO_WINDOW) + .output() + }; + #[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 = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let raw = if stdout.is_empty() { stderr } else { stdout }; + (Some(extract_version(&raw)), true, None) + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&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); + + installs.push(ToolInstallation { + path: tool_path.display().to_string(), + version, + runnable, + error, + source: infer_install_source(&tool_path).to_string(), + is_path_default, + }); + } + } + + // PATH 默认那处排最前,UI 一眼看到"命令行默认用的是哪处"。 + installs.sort_by_key(|i| std::cmp::Reverse(i.is_path_default)); + installs +} + +/// 诊断某工具是否存在多处互相打架的安装。懒触发:前端仅在卡片 broken 或 +/// "升级后版本未变"时调用,正常版本刷新不受影响。 +/// +/// 无冲突时返回空 `Vec`:仅一处安装,或多处但版本与可运行状态完全一致 +/// (例如同一版本经多个包管理器装了两遍但都能跑)——这类不打扰用户。 +#[tauri::command] +pub async fn diagnose_tool_installations(tool: String) -> Result, String> { + if !VALID_TOOLS.contains(&tool.as_str()) { + return Err(format!("Unsupported tool: {tool}")); + } + + let installs = tokio::task::spawn_blocking(move || enumerate_tool_installations(&tool)) + .await + .map_err(|e| format!("diagnose task join error: {e}"))?; + + if installs.len() < 2 { + return Ok(Vec::new()); + } + + // 仅当存在分歧才算冲突:版本不唯一,或"有能跑的也有跑不起来的"。 + let distinct_versions: std::collections::HashSet<&Option> = + installs.iter().map(|i| &i.version).collect(); + let runnable_mixed = + installs.iter().any(|i| i.runnable) && installs.iter().any(|i| !i.runnable); + + if distinct_versions.len() > 1 || runnable_mixed { + Ok(installs) + } else { + Ok(Vec::new()) + } +} + #[cfg(target_os = "windows")] fn wsl_distro_for_tool(tool: &str) -> Option { let override_dir = match tool { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f36961ba7..2f6c1d5a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1283,6 +1283,7 @@ pub fn run() { commands::launch_session_terminal, commands::get_tool_versions, commands::run_tool_lifecycle_action, + commands::diagnose_tool_installations, // Provider terminal commands::open_provider_terminal, // Universal Provider management diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index b7d946710..e28f0d026 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -13,6 +13,8 @@ import { AlertCircle, ArrowUpCircle, ChevronDown, + Stethoscope, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +28,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getVersion } from "@tauri-apps/api/app"; import { settingsApi } from "@/lib/api"; +import type { ToolInstallation } from "@/lib/api/settings"; import { useUpdate } from "@/contexts/UpdateContext"; import { relaunchApp } from "@/lib/updater"; import { Badge } from "@/components/ui/badge"; @@ -128,6 +131,57 @@ const TOOL_APP_IDS: Record = { hermes: "hermes", }; +// 各工具的全局包名:npm 系用 npm 包名,hermes 例外(pip 包 hermes-agent)。 +const TOOL_NPM_PACKAGES: Record, string> = { + claude: "@anthropic-ai/claude-code", + codex: "@openai/codex", + gemini: "@google/gemini-cli", + opencode: "opencode-ai", + openclaw: "openclaw", +}; + +// 取路径的目录部分(兼容 / 与 \ 分隔符);无分隔符返回空串。 +function dirOfPath(p: string): string { + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return i > 0 ? p.slice(0, i) : ""; +} + +// 路径是否为 Windows 风格(含反斜杠或盘符前缀)。 +function isWindowsPath(p: string): boolean { + return p.includes("\\") || /^[a-zA-Z]:/.test(p); +} + +// 含空格时加引号,避免命令被 shell 拆断。 +function shellQuote(s: string): string { + return s.includes(" ") ? `"${s}"` : s; +} + +// 为「某一处具体安装」生成卸载建议命令(只读、供复制,绝不代执行)。 +// 关键:用该处同目录的 npm 精确作用于这一处的 node 安装,避免裸 `npm rm -g` +// 误删了当前激活(可能是想保留的默认)那处。volta/bun/pip 走各自的卸载器。 +function buildUninstallCommand( + toolName: ToolName, + inst: ToolInstallation, +): string { + if (toolName === "hermes") { + return "python3 -m pip uninstall hermes-agent"; + } + const pkg = TOOL_NPM_PACKAGES[toolName]; + if (inst.source === "volta") { + return `volta uninstall ${pkg}`; + } + if (inst.source === "bun") { + return `bun rm -g ${pkg}`; + } + // nvm / fnm / mise / homebrew / system / scoop 上的 node 全局包:统一 npm rm -g, + // 但锚定到该处同目录的 npm 以删对版本。 + const dir = dirOfPath(inst.path); + const win = isWindowsPath(inst.path); + const npmBin = win ? "npm.cmd" : "npm"; + const npm = dir ? shellQuote(`${dir}${win ? "\\" : "/"}${npmBin}`) : "npm"; + return `${npm} rm -g ${pkg}`; +} + export function AboutSection({ isPortable }: AboutSectionProps) { // ... (use hooks as before) ... const { t } = useTranslation(); @@ -157,6 +211,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) { Record >({}); const [loadingTools, setLoadingTools] = useState>({}); + // 多处安装冲突诊断结果:按工具存储,有冲突的工具会在其卡片下方展示。 + // 来源两路:顶部「诊断安装冲突」按钮一次性扫全部,或升级后版本未变时自动补诊。 + const [toolDiagnostics, setToolDiagnostics] = useState< + Partial> + >({}); + const [isDiagnosingAll, setIsDiagnosingAll] = useState(false); const toolVersionByName = useMemo(() => { return new Map(toolVersions.map((tool) => [tool.name, tool])); @@ -366,6 +426,20 @@ export function AboutSection({ isPortable }: AboutSectionProps) { } }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]); + // 复制单条卸载命令到剪贴板(只复制,不执行)。 + const handleCopyCommand = useCallback( + async (command: string) => { + try { + await navigator.clipboard.writeText(command); + toast.success(t("settings.toolUninstallCopied"), { closeButton: true }); + } catch (error) { + console.error("[AboutSection] Failed to copy command", error); + toast.error(t("settings.installCommandsCopyFailed")); + } + }, + [t], + ); + const handleCopyInstallCommands = useCallback(async () => { try { await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS); @@ -376,6 +450,54 @@ export function AboutSection({ isPortable }: AboutSectionProps) { } }, [t]); + // 升级后自动补诊单个工具:静默后台执行,仅在确有冲突时写入结果, + // 无冲突不弹 toast、不报错打扰——与用户主动点的全量诊断区别对待。 + const diagnoseToolSilently = useCallback(async (toolName: ToolName) => { + try { + const installs = await settingsApi.diagnoseToolInstallations(toolName); + if (installs.length > 0) { + setToolDiagnostics((prev) => ({ ...prev, [toolName]: installs })); + } + } catch (error) { + console.error( + `[AboutSection] Auto-diagnose failed for ${toolName}`, + error, + ); + } + }, []); + + // 顶部按钮:一次性诊断全部 6 个工具,有冲突的写入各自卡片, + // 全部无冲突时给一条 info toast。后端逐工具枚举所有安装并判定分歧。 + const handleDiagnoseAll = useCallback(async () => { + setIsDiagnosingAll(true); + try { + const entries = await Promise.all( + TOOL_NAMES.map( + async (name) => + [name, await settingsApi.diagnoseToolInstallations(name)] as const, + ), + ); + const next: Partial> = {}; + let conflicts = 0; + for (const [name, installs] of entries) { + next[name] = installs; + if (installs.length > 0) conflicts += 1; + } + setToolDiagnostics(next); + if (conflicts === 0) { + toast.info(t("settings.toolDiagnoseNoConflict"), { closeButton: true }); + } + } catch (error) { + console.error("[AboutSection] Diagnose all failed", error); + toast.error(t("settings.toolDiagnoseFailed"), { + description: extractErrorMessage(error) || undefined, + closeButton: true, + }); + } finally { + setIsDiagnosingAll(false); + } + }, [t]); + const handleRunToolAction = useCallback( async (toolNames: ToolName[], action: ToolLifecycleAction) => { if (toolNames.length === 0) return; @@ -395,6 +517,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) { for (const toolName of toolNames) { setToolActions((prev) => ({ ...prev, [toolName]: action })); + // 记录执行前的版本:用于识别"升级跑完但版本没变"——这正是多处安装互相 + // 打架的头号症状(升级写入 A 处、PATH 实际仍跑 B 处),需自动触发诊断。 + const beforeVersion = toolVersionByName.get(toolName)?.version ?? null; try { await settingsApi.runToolLifecycleAction( [toolName], @@ -409,12 +534,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) { const tool = refreshed.find((t) => t.name === toolName); if (tool?.version) { succeeded += 1; + // 升级命令成功、版本号却没动:大概率被另一处安装遮蔽,自动诊断。 + if ( + action === "update" && + beforeVersion && + tool.version === beforeVersion + ) { + void diagnoseToolSilently(toolName); + } } else { // 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来" // (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把 // version 置空并写入后端 error,这里只需归类为软失败并展示原因。 const detail = tool?.error?.trim() || t("settings.toolNotRunnable"); failures.push({ toolName, detail, soft: true }); + // 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。 + void diagnoseToolSilently(toolName); } } catch (error) { console.error( @@ -490,7 +625,13 @@ export function AboutSection({ isPortable }: AboutSectionProps) { ); } }, - [t, wslShellByTool, refreshToolVersions], + [ + t, + wslShellByTool, + refreshToolVersions, + toolVersionByName, + diagnoseToolSilently, + ], ); const displayVersion = version ?? t("common.unknown"); @@ -640,6 +781,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {

{t("settings.localEnvCheck")}

+ +
+ + ); + })} + +
+ )} +
{isToolVersionLoading ? ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index db756fdcf..1629311e4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -676,6 +676,16 @@ "toolActionInstalledNotRunnable": "Installed, but it can't run in the current environment — please check", "installedNotRunnable": "Installed · can't run", "toolCheckEnv": "Check environment", + "toolDiagnose": "Diagnose installs", + "toolDiagnosing": "Diagnosing…", + "toolConflictTitle": "Multiple installations detected", + "toolConflictHint": "The command line uses the one marked Default; an upgrade may have written to another.", + "toolConflictDefault": "Default", + "toolConflictNotRunnable": "can't run", + "toolDiagnoseNoConflict": "No conflicting installations found", + "toolDiagnoseFailed": "Diagnosis failed", + "toolUninstallCopyHint": "Copy uninstall command (won't run it)", + "toolUninstallCopied": "Uninstall command copied", "envBadge": { "wsl": "WSL", "windows": "Win", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c1c8964f5..baf87658c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -676,6 +676,16 @@ "toolActionInstalledNotRunnable": "インストールされましたが、現在の環境では実行できません。確認してください", "installedNotRunnable": "インストール済み・実行不可", "toolCheckEnv": "実行環境を確認", + "toolDiagnose": "インストールを診断", + "toolDiagnosing": "診断中…", + "toolConflictTitle": "複数のインストールを検出", + "toolConflictHint": "コマンドラインは「既定」のものを使用します。更新が別の場所に適用された可能性があります。", + "toolConflictDefault": "既定", + "toolConflictNotRunnable": "実行不可", + "toolDiagnoseNoConflict": "競合するインストールは見つかりませんでした", + "toolDiagnoseFailed": "診断に失敗しました", + "toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)", + "toolUninstallCopied": "アンインストールコマンドをコピーしました", "envBadge": { "wsl": "WSL", "windows": "Win", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6e3dcf892..e5fdcce6d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -676,6 +676,16 @@ "toolActionInstalledNotRunnable": "已安装,但当前环境无法运行,请检查", "installedNotRunnable": "已安装·无法运行", "toolCheckEnv": "请检查运行环境", + "toolDiagnose": "诊断安装冲突", + "toolDiagnosing": "诊断中…", + "toolConflictTitle": "检测到多处安装", + "toolConflictHint": "命令行实际使用标「默认」的那处;升级可能写到了别处。", + "toolConflictDefault": "默认", + "toolConflictNotRunnable": "无法运行", + "toolDiagnoseNoConflict": "未发现安装冲突", + "toolDiagnoseFailed": "诊断失败", + "toolUninstallCopyHint": "复制卸载命令(不会自动执行)", + "toolUninstallCopied": "卸载命令已复制", "envBadge": { "wsl": "WSL", "windows": "Win", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 8cfd49650..de8f4090c 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -208,6 +208,10 @@ export const settingsApi = { }); }, + async diagnoseToolInstallations(tool: string): Promise { + return await invoke("diagnose_tool_installations", { tool }); + }, + async getRectifierConfig(): Promise { return await invoke("get_rectifier_config"); }, @@ -233,6 +237,16 @@ export const settingsApi = { }, }; +/** 单处工具安装的诊断信息(多处安装冲突检测)。字段对应后端 ToolInstallation。 */ +export interface ToolInstallation { + path: string; + version: string | null; + runnable: boolean; + error: string | null; + source: string; + is_path_default: boolean; +} + export interface RectifierConfig { enabled: boolean; requestThinkingSignature: boolean;