diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs
index 0b5163d94..e13ac22cb 100644
--- a/src-tauri/src/commands/misc.rs
+++ b/src-tauri/src/commands/misc.rs
@@ -1268,33 +1268,6 @@ pub struct ToolInstallation {
source: String,
/// 是否为 PATH 解析到的那处(= 命令行默认,也是升级会作用的目标)。
is_path_default: bool,
- /// 卸载该处的命令(仅展示给用户复制,UI 不代执行)。源感知 + 真身感知:
- /// brew formula → `brew uninstall `、volta → `volta uninstall`、bun →
- /// `bun rm -g`、pnpm → `pnpm rm -g`、其余 npm 全局 → 锚定那处同目录 npm 的
- /// `npm rm -g`(附物理删除兜底)。复用 `brew_formula_from_path`、`infer_install_source`、
- /// `sibling_bin`,与升级路径(`anchored_command_from_paths`)共享真身判定——避免
- /// 前端按 `source` 字符串拼命令时把"homebrew formula"和"homebrew node 的 npm 全局包"
- /// 混为一谈。
- ///
- /// **eager 计算(与升级命令按需算的不对称是 by design)**:升级命令在
- /// `installs_anchored_command` 里只为 default install 算一次;卸载命令则**每处
- /// install 都预计算**进字段,因为冲突诊断 UI 需要为每处展示对应的卸载 hint——
- /// 延后到二次 IPC 会增加"诊断 → 显示命令"的端到端延迟。计算成本是纯字符串
- /// 扫描(评估:8 install × ~2μs ≈ 16μs,可忽略),不影响 probe 1-3s 主时延。
- uninstall_command: String,
- /// `uninstall_command` 是否是 Windows cmd 兼容形式(含 quoted 路径,PowerShell
- /// 用户需 `&` call operator 前缀)。前端据此**条件渲染**一行 PowerShell 限制提示。
- ///
- /// **必须由后端提供结构化信号、前端不能 string match**:POSIX 的
- /// `shell_single_quote` 转义形式是 `'"'"'`(关单引号 + 双引号包字面单引号 +
- /// 重开)——含双引号!所以 macOS 用户名带 `'`(如 `o'leary`)时,POSIX 卸载命令
- /// **巧合地也含 `"`**,前端 `cmd.includes('"')` 会**误判为 Windows cmd 形式**,
- /// 给 POSIX 用户错误显示 PowerShell 提示。string-based 平台推断在这里 false-positive。
- ///
- /// 后端用 `cfg!(target_os = "windows") && uninstall_command.contains('"')` 算:
- /// `cfg!` 是**编译时 ground truth**(不是字符串猜测),POSIX 编译时整体短路为
- /// false,与 POSIX 单引号 escape 含 `"` 的形态完全无关。
- uninstall_command_needs_cmd_hint: bool,
/// canonicalize 解析后的真身路径(brew 形如 `Cellar//...`、claude 原生形如
/// `~/.local/share/claude/versions/...`),用于 `anchored_command_from_paths` 的真身
/// 判定。`enumerate_tool_installations` 已经为去重算过一次,这里复用避免上游
@@ -1465,22 +1438,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec {
let is_path_default = path_default.as_ref() == Some(&real);
let path_str = tool_path.display().to_string();
- // **source 在这里算一次,同时供 ToolInstallation.source 字段和
- // uninstall_command_from_paths 的 match 用**(review N1):函数签名上
- // source 作为参数传入,避免函数内部再 `infer_install_source(Path::new(bin_path))`
- // 二次扫描 + 重复 lower-case 分配。同时把"路径分类"的契约外化——上下游
- // 都以同一标签作判定,不会因函数内外重算意外漂移。
let source = infer_install_source(&tool_path);
- // 卸载提示与升级锚定共用同一份 (bin_path, real) —— 见
- // `uninstall_command_from_paths` 的 doc:**前端按 source 字符串拼无法
- // 区分 brew formula vs homebrew npm 全局包**,只能在后端拿真身判定。
- let uninstall_command =
- uninstall_command_from_paths(tool, &path_str, &real.to_string_lossy(), source);
- // PowerShell hint 触发位:Windows + 命令含 quoted 路径。`cfg!` 是编译时
- // 常量,POSIX 编译时整体短路为 false——这就把 POSIX 单引号 escape `'"'"'`
- // 也含 `"` 的字面巧合从触发域里排除掉(见字段 doc)。
- let uninstall_command_needs_cmd_hint =
- cfg!(target_os = "windows") && uninstall_command.contains('"');
installs.push(ToolInstallation {
path: path_str,
@@ -1489,8 +1447,6 @@ fn enumerate_tool_installations(tool: &str) -> Vec {
error,
source: source.to_string(),
is_path_default,
- uninstall_command,
- uninstall_command_needs_cmd_hint,
// 复用上面 line ~1357 已 canonicalize 的真身,避免下游
// installs_anchored_command 再 canonicalize 一遍同一文件。
real: real.clone(),
@@ -1566,43 +1522,6 @@ fn quote_path_if_spaced(p: &str) -> String {
}
}
-/// **卸载 hint 专用 POSIX quoting**——白名单判定:全字符都在
-/// `[A-Za-z0-9._/+=:@-]` 安全集合内 → 保持裸路径(常规 nvm/brew/volta/bun 路径
-/// 以及 npm scope 包名 `@scope/pkg` 都命中,命令展示干净);任何不在集合的字符
-/// (包括空格、`;`、`$`、`` ` ``、`'`、`"`、`&`、`|`、`<`、`>`、`(`、`)`、`*`、
-/// `?`、`[`、`]`、`{`、`}`、`!`、`#`、换行、tab 等)→ 走 `shell_single_quote`
-/// 无条件强 quote。
-///
-/// **白名单含 `@`** :npm scope 包名形如 `@anthropic-ai/claude-code` /
-/// `@google/gemini-cli`,pkg_dir 必然含 `@`;且 `@` 在 bash/zsh/dash/sh 都无特殊
-/// 含义(zsh 的 `${var@P}` 是 parameter expansion 的修饰符,需要前缀 `$`,单独 `@`
-/// 字符在路径里无害),纳入白名单不引入风险。
-///
-/// **为什么不复用 `quote_path_if_spaced`**(P2 修复):后者只查空格,但卸载命令
-/// 是 destructive(`rm -rf `)——含 `;` / `` ` `` / `$(...)` 的路径会让
-/// shell 把卸载链拆成多条命令、把 rm 落到错的路径。升级命令"出错"是 inconvenient,
-/// 但卸载侧的 quoting 漏洞是 destructive risk,**安全门槛必须更高**。
-///
-/// **为什么用白名单而非黑名单**:shell 元字符列表长且 shell-specific(bash vs zsh
-/// vs dash 略有差异),黑名单容易漏。白名单只允许"绝对安全字符",其他一律 quote
-/// ——default-quoting 比 default-bare 更适合 destructive 命令。常规 POSIX 安装
-/// 路径 + npm 包名只用安全字符,白名单不影响展示美观。
-///
-/// 路径含安全集合外字符(如 home dir 名带 `;` `'` `$`)时,`shell_single_quote`
-/// 把单引号 escape 为 `'"'"'`(关引号 + 双引号包单引号 + 重开引号),跨
-/// bash/zsh/dash/sh 全部 POSIX 兼容。
-#[cfg(not(target_os = "windows"))]
-fn posix_quote_for_user_shell(p: &str) -> String {
- let is_safe = |c: char| {
- c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '+' | '=' | ':' | '-' | '@')
- };
- if p.chars().all(is_safe) {
- p.to_string()
- } else {
- shell_single_quote(p)
- }
-}
-
/// 锚定路径走 `.bat` 文件且**被 `call` 调用**,需要为 batch 特殊字符做两层防御:
///
/// **(1) `%` 经历两轮 percent expansion → 用 4 个 `%` 转义**。.bat 中字面 `%` 的
@@ -1647,51 +1566,6 @@ fn win_quote_path_for_batch(p: &str) -> String {
}
}
-/// **复制给用户的命令**专用 Windows quoting,**目标语法为 cmd**——与
-/// `win_quote_path_for_batch` 的关键差异是**不做 `%` 转义**(本 helper 服务的
-/// 命令不进 .bat + call,只一轮 expansion 不需要 4 倍)。
-///
-/// **PowerShell 用户的已知限制**(P3, 2026-05-24):本 helper 生成的 quoted 路径
-/// (含空格时 `"C:\Program Files\nodejs\npm.cmd"`)在 PowerShell 下**当字符串字面
-/// 值,不会执行**——PowerShell 对含引号的可执行路径需要 call operator `&` 前缀:
-///
-/// ```text
-/// cmd: "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← OK
-/// PowerShell: "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← 当字符串字面
-/// PowerShell: & "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← OK
-/// cmd: & "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← `&` 是命令分隔符,报错
-/// ```
-///
-/// 两个 shell 在引号路径上语法完全不兼容,无法用一份 quoting 同时满足。本 helper
-/// 选 cmd 兼容形式(Windows 历史默认 shell;路径不含空格时 PowerShell 也能直接跑,
-/// 因为无引号路径在 PowerShell 下走 unquoted invocation)。**含空格路径的 PowerShell
-/// 用户需手动在命令前加 `&`** ——前端 UI 文案应明示此限制(否则用户照 hint 复制
-/// 粘到 PowerShell 会看到"字符串回显但什么都没执行"的迷惑现象)。
-///
-/// **`%` 字符的语义对比**:cmd 一轮展开会把 `%foo%` 当变量;PowerShell / bash on
-/// Windows / WSL 都无 % 展开(字面识别)。三套语义无法共存,**保持原始 `%`**
-/// 是最诚实的兜底——cmd 用户在含 `%` 路径下仍会被变量展开困住(cmd 固有限制,
-/// 双引号内 `%` 仍展开,任何 quoting 救不了);若用 batch quoter 的 4 倍 `%`,
-/// 则三个 shell 全部得到错路径(cmd 一轮变 `%%`、PowerShell/bash 字面 `%%%%`)。
-///
-/// **本 helper 与 `win_quote_path_for_batch` 必须保持镜像的部分**:token 边界字符
-/// (空格 & ( ) ^ ; < > | ,)的双引号包裹规则一致——这些字符在三个 shell 下都
-/// 用引号包裹保持字面,跨 shell 一致。
-///
-/// 使用场景仅限"卸载提示之类只展示不代执行的命令";.bat + call 链路坚决用
-/// `win_quote_path_for_batch`,否则两轮 expansion 下 % 字面会丢失。
-#[cfg(target_os = "windows")]
-fn win_quote_path_for_user_shell(p: &str) -> String {
- let needs_quote = p
- .chars()
- .any(|c| matches!(c, ' ' | '&' | '(' | ')' | '^' | ';' | '<' | '>' | '|' | ','));
- if needs_quote {
- win_double_quote(p)
- } else {
- p.to_string()
- }
-}
-
/// Windows 版 sibling 推导:在 `` 下按 `ext_candidates` 顺序找
/// 第一个存在的 `.` 文件,返回该绝对路径。
///
@@ -1755,42 +1629,6 @@ fn sibling_bin(bin_path: &str, exe: &str) -> Option {
}
}
-/// 卸载 hint 专用:取 sibling exe 绝对路径并 quote;推不出 sibling 时退化为**裸命令名**
-/// (让用户终端 PATH 默认那个工具处理)。
-///
-/// **与 anchored upgrade 不返 None 的差异**:`anchored_command_from_paths` 的 POSIX 版
-/// 用 `?` 短路返 `None`,让上游退化到静态命令并标 `anchored=false`;卸载 hint 是直接
-/// 给用户复制粘贴的——返 None 等于"不展示命令",诚实但 UX 差。退化为裸命令(无路径
-/// 前缀)反而是"明确意图 + 让用户终端自决"的合理兜底,与 `static_fallback_command` 同款。
-///
-/// **quoting 用 `posix_quote_for_user_shell` 而非 `quote_path_if_spaced`**(P2 修复):
-/// 卸载命令是 destructive(部分分支带 `rm -rf` 兜底),含 `;` / `` ` `` / `$` 的路径
-/// 必须强 quote 防止 shell 拆词出错——detail 见 `posix_quote_for_user_shell` doc。
-///
-/// 仅在 POSIX uninstall 的 brew/volta/bun/pnpm/npm 五条分支共用,消除"sibling_bin →
-/// map → unwrap_or_else 裸字面" 三行 boilerplate 的重复出现。
-#[cfg(not(target_os = "windows"))]
-fn quoted_sibling_or_bare(bin_path: &str, exe: &str) -> String {
- sibling_bin(bin_path, exe)
- .map(|b| posix_quote_for_user_shell(&b))
- .unwrap_or_else(|| exe.to_string())
-}
-
-/// Windows 版镜像 `quoted_sibling_or_bare`,quoter 用 `win_quote_path_for_user_shell`
-/// (不做 4× % 转义,目标是复制到用户 shell 而非 .bat + call)。fallback 是 exe_basename
-/// 裸名(`"npm"`/`"pnpm"`/`"volta"`),与原 `format!("npm rm -g {pkg}")` 兜底一致——
-/// 不返回带扩展名的 `npm.cmd`,因为用户 shell 上 `npm` 已被 PATH 解析时自动加扩展。
-#[cfg(target_os = "windows")]
-fn quoted_sibling_or_bare_with_ext(
- bin_path: &str,
- exe_basename: &str,
- ext_candidates: &[&str],
-) -> String {
- sibling_bin_with_ext(bin_path, exe_basename, ext_candidates)
- .map(|p| win_quote_path_for_user_shell(&p))
- .unwrap_or_else(|| exe_basename.to_string())
-}
-
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
/// 推断"写回同一处"的锚定升级命令。**POSIX 版是纯函数(不碰 FS)**——真实 canonicalize
/// 由调用方做(`installs_anchored_command` 复用 enumerate 时算出的 `inst.real`),
@@ -1798,10 +1636,6 @@ fn quoted_sibling_or_bare_with_ext(
/// 是刻意保留的平台差异(详见 Windows 版本 doc)。hermes(pip)不在此处:
/// `npm_package_for` 返回 None → None。
///
-/// **对偶函数**:`uninstall_command_from_paths` 是本函数在卸载侧的镜像,共享 brew
-/// formula 真身判定 + sibling 锚定 + source 等价类。任一边新增/移除等价类都得
-/// 同步另一边——卸载侧 doc 对此点有更详细的不变量说明。
-///
/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。
/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行——
/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含**
@@ -1860,200 +1694,6 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm)))
}
-/// 给定一处具体安装的 bin 路径、真身路径与 source 标签,推断**卸载该处**的命令
-/// (纯函数、不碰 FS)。`source` 由调用方传入(`enumerate_tool_installations` 复用
-/// 已为 `ToolInstallation.source` 字段算过的那次 `infer_install_source` 结果,本函数
-/// 不再二次调用,见 review N1)。与 `anchored_command_from_paths` 共享真身判定:
-/// formula 走 `brew uninstall`、volta/bun/pnpm 走各自 uninstaller、其余 npm 全局走
-/// sibling npm 的 `rm -g`。
-///
-/// **为什么不让前端拼**:前端只拿到 `source` 字符串,但 `infer_install_source` 把
-/// `/Cellar/` 与 `/opt/homebrew/lib/node_modules/` 都归为 `"homebrew"`,**有损分类**——
-/// 前端区分不出 formula 和 npm 全局包,只能统一拼成 `npm rm -g`,对 brew formula
-/// 会 `no such package` 失败(实测案例:`/opt/homebrew/bin/gemini` → `Cellar/gemini-cli/`,
-/// 前端给的 `npm rm -g @google/gemini-cli` 跑出来报"not installed")。把卸载命令
-/// 搬到后端、复用 `brew_formula_from_path(real)` 即可正确区分。
-///
-/// **不变量**:与升级路径一致,尽量绝对路径锚定(避免命令拷贝到非登录 shell 里
-/// PATH 缺 `/opt/homebrew/bin` 等用户级目录而 `command not found`)。同目录推不出
-/// brew/volta/bun/pnpm/npm 时退化到裸命令(`brew uninstall ` 等),与
-/// `static_fallback_command` 同款"诚实兜底"语义。`quoted_sibling_or_bare` 封装此模式。
-///
-/// 判定顺序(命中即返回):
-/// ① hermes → pip(包名固定,不参与锚定)
-/// ② brew formula(真身 `Cellar//`)→ sibling `brew uninstall `;
-/// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli),用 `npm_package_for(pkg)`
-/// 会卸错对象。
-/// ③ volta → sibling `volta uninstall `;bun → sibling `bun rm -g `。
-/// ④ pnpm → sibling `pnpm rm -g `。**pnpm 必须独占分支**,不能落到 ⑤ npm 兜底:
-/// pnpm 全局包**不在 npm prefix 下**,`npm rm -g` 会报"not installed"必失败。
-/// POSIX 上 `~/.local/share/pnpm/` 是常见形态(review M1 实际命中)。
-/// ⑤ nvm/fnm/mise/homebrew(npm 全局)→ sibling `npm rm -g ` + 物理删除兜底;
-/// "homebrew npm 全局"在 ② 之后落到这里——它是 `/opt/homebrew/lib/node_modules/`
-/// 下的包(真身**不含 Cellar**),正确卸载就是 `npm rm`,与 nvm 共享同一分支。
-/// ⑥ 其余(system / unknown / scoop / pip 等无可靠锚定的来源)→ 无前缀 `npm rm -g`,
-/// 让用户终端 PATH 默认那个 npm 处理。
-#[cfg(not(target_os = "windows"))]
-fn uninstall_command_from_paths(
- tool: &str,
- bin_path: &str,
- real_target: &str,
- source: &str,
-) -> String {
- if tool == "hermes" {
- return "python3 -m pip uninstall hermes-agent".to_string();
- }
- let Some(pkg) = npm_package_for(tool) else {
- return String::new();
- };
- if let Some(formula) = brew_formula_from_path(real_target) {
- let brew = quoted_sibling_or_bare(bin_path, "brew");
- return format!("{brew} uninstall {formula}");
- }
- match source {
- "volta" => {
- let volta = quoted_sibling_or_bare(bin_path, "volta");
- format!("{volta} uninstall {pkg}")
- }
- "bun" => {
- let bun = quoted_sibling_or_bare(bin_path, "bun");
- format!("{bun} rm -g {pkg}")
- }
- "pnpm" => {
- // 与 ⑤ 分支故意不共享 npm 路径:pnpm 全局 prefix 与 npm 不同
- // (`~/.local/share/pnpm/global` vs `/lib/node_modules`),
- // 用 `npm rm` 卸 pnpm 包会"package not installed"。Windows 版 anchored
- // upgrade 已有 pnpm 分支,POSIX 卸载端补齐对偶。
- let pnpm = quoted_sibling_or_bare(bin_path, "pnpm");
- format!("{pnpm} rm -g {pkg}")
- }
- // 自带同级 npm 的 node 管理器:锚定到那处的 npm,保证卸载作用于命令行
- // 实际命中的那个 node。等价类与 anchored_command_from_paths 的 POSIX 版
- // 一一对应,任何一边新增/移除等价类都要同步另一边。
- //
- // **附加物理删除兜底(Jason 2026-05-23 实测 bug 修复)**:nvm node v18.20.8
- // 上 `npm rm -g ` 静默 no-op——返回 `up to date in 91ms`、exit 0、
- // 文案像 `npm install`、bin 软链与 `lib/node_modules/` 都没动,用户
- // 照 hint 复制后"以为成功了"但版本没变。可能根因:老 node 的 npm 全局
- // 元数据/package-lock 损坏。
- //
- // 兜底形态:` rm -g ; [ -e ] && rm -f && rm -rf `
- // - npm rm 真生效 → bin 已不存在 → `[ -e bin ]` 失败 → 短路,物理删除不触发(幂等)
- // - npm rm 静默 no-op → bin 仍在 → 物理删除接管,与"卸载意图"对齐
- // - 命令变长 2-3× 是可接受成本,换用户复制粘贴一次到位
- //
- // **rm -rf 红线**:目标是 `/lib/node_modules/` 这一具体
- // 包目录,**不是** `/lib/node_modules` 整个 node_modules。``
- // 是 `@anthropic-ai/claude-code`/`opencode-ai` 等 npm 包全名(含 scope),
- // npm 包名规则严格无注入面;但 pkg_dir 算不出(parent_dir 返空)时**不加
- // 兜底**,保留原 `npm rm -g`,避免拼出 `/lib/node_modules/` 这种
- // 误指根目录的命令。
- //
- // **本分支专用 `/{bin,lib}` 标准 layout 假设**:nvm/fnm/mise/
- // homebrew(npm 全局)四者都把 node 装到 `/{bin,lib,...}` 标准
- // 布局下,所以 `` 的祖父就是 node 安装根。volta/bun/scoop 的
- // 包目录布局完全不同(volta `~/.volta/tools/image/packages/`、bun
- // `~/.bun/install/global/node_modules/`),它们走各自分支不进这里。未来
- // 扩广本分支到新来源时,务必先校验 `/../../lib/node_modules/`
- // 假设是否成立。
- "nvm" | "fnm" | "mise" | "homebrew" => {
- let npm = quoted_sibling_or_bare(bin_path, "npm");
- // = parent(bin_path), = parent(bin_dir)
- // 即 nvm:`~/.nvm/versions/node/v18/bin/gemini` → bin_dir `~/.nvm/.../v18/bin`
- // → node_root `~/.nvm/.../v18` → pkg_dir `~/.nvm/.../v18/lib/node_modules/@google/gemini-cli`
- let bin_dir = parent_dir(bin_path);
- let node_root = parent_dir(&bin_dir);
- if node_root.is_empty() {
- // parent_dir 推不出 node_root(理论不会发生:bin_path 是绝对路径) →
- // 不加兜底,与原行为一致,避免拼错 pkg_dir。
- format!("{npm} rm -g {pkg}")
- } else {
- let pkg_dir = format!("{node_root}/lib/node_modules/{pkg}");
- // **强 quote 而非 quote_path_if_spaced**(P2 修复):兜底链含 `;` 和
- // `&&` 多语句分隔,bin/pkg_dir 路径若含 `;` / `` ` `` / `$` 等元字符,
- // 弱 quoting 会让 shell 拆出额外命令——destructive `rm -rf` 落到错的
- // 路径。`posix_quote_for_user_shell` 白名单判定:常规路径不加引号
- // (展示干净),特殊字符触发 `shell_single_quote` 无条件强 quote。
- let bin_q = posix_quote_for_user_shell(bin_path);
- let pkg_dir_q = posix_quote_for_user_shell(&pkg_dir);
- format!("{npm} rm -g {pkg}; [ -e {bin_q} ] && rm -f {bin_q} && rm -rf {pkg_dir_q}")
- }
- }
- // system / unknown / scoop / pip 等不期望锚定 npm 的来源:
- // **不要拼 `/npm`** (该处通常无 sibling npm,会拼出必失败的命令——
- // 与 anchored_command_from_paths 在同类来源返回 None 的语义对偶)。
- // 退化到无前缀的 `npm rm -g `,让用户终端 PATH 默认的 npm 处理;
- // 卸载语义下这比"返回 None 让 UI 不显示命令"更友好,因为这条 hint 本来
- // 就是给用户复制粘贴的——明确意图 + 用户自决比沉默更好。
- _ => format!("npm rm -g {pkg}"),
- }
-}
-
-/// Windows 版卸载命令生成。等价类与 anchored upgrade 一致(volta/pnpm/npm 三分支),
-/// 无 brew/bun/claude-native(平台不存在)、无 formula 判定路径——`real_target` 占位
-/// 保持与 POSIX 版签名对称;`source` 由调用方传入(见 POSIX 版 doc)。
-/// `quoted_sibling_or_bare_with_ext` 探不到时退化到 exe_basename 裸名。
-///
-/// **quoting 用 `win_quote_path_for_user_shell` 而非 `win_quote_path_for_batch`**:
-/// 卸载命令在前端只展示给用户复制到自己的 shell,不经过 .bat + call 的两轮 %
-/// expansion——后者把 `%` 转 `%%%%` 是为了让 call 看到字面 `%`,但用户的 cmd /
-/// PowerShell / bash 都只一轮甚至零轮展开,直接复制 `%%%%` 会得到错的路径。
-/// 与升级路径(`anchored_command_from_paths` Windows 版用 `win_quote_path_for_batch`,
-/// 因为它落 .bat + call)形成"目标终端决定 quoter"的对照,doc 在 helper 一侧
-/// 已经写明,这里不重复。
-///
-/// **目标语法是 cmd / PowerShell 限制**(P3, 2026-05-24):本函数生成的命令是
-/// cmd 兼容形式——含空格路径用双引号包裹(`"C:\Program Files\..."`),PowerShell
-/// 对这种引号路径**当字符串字面值不执行**,需要 call operator `&` 前缀。两个
-/// shell 在引号路径上语法不兼容,无法用一份命令同时满足;详见
-/// `win_quote_path_for_user_shell` doc 的 PowerShell 限制段。
-///
-/// **前端 UI 通过 `ToolInstallation.uninstall_command_needs_cmd_hint` 结构化字段
-/// 条件渲染提示**(AboutSection.tsx 卸载行下方, i18n key `toolUninstallPwshHint`)。
-/// 该 bool 由 enumerate 用 `cfg!(target_os = "windows") && contains('"')` 算出,
-/// `cfg!` 是编译时 ground truth——POSIX 编译时整体短路为 false,与 POSIX
-/// `shell_single_quote` 的 `'"'"'` 转义形态含 `"` 的字面巧合**完全无关**。
-/// **前端不能 string match `uninstall_command`**:POSIX 路径带 `'`(如
-/// macOS `o'leary`)的命令也含 `"`,前端 includes('"') 会误判为 Windows cmd 形式。
-///
-/// **POSIX 兜底物理删除链在 Windows 版**有意不镜像**:实测未观察到 nvm-windows /
-/// Scoop 上 `npm.cmd rm -g` 静默 no-op(POSIX 的 npm rm 静默是 nvm node v18.20.8
-/// 老 node 的特定 bug),而 Windows 的物理删除链需要 `del` / `rmdir /S /Q` + `if
-/// exist` 三段语义,跨 cmd/PowerShell shell 不一致(`del` 在 PowerShell 是 Remove-
-/// Item 别名、参数不同),收益不抵复杂度。若未来在 Windows 上观察到同款 bug,可
-/// 在本函数加 cmd-only 兜底链。
-///
-/// hermes 走 `py -m pip uninstall`,与 `tool_action_shell_command` 的 Windows 分支
-/// 用同一 py launcher 入口。
-#[cfg(target_os = "windows")]
-fn uninstall_command_from_paths(
- tool: &str,
- bin_path: &str,
- _real_target: &str,
- source: &str,
-) -> String {
- if tool == "hermes" {
- return "py -m pip uninstall hermes-agent".to_string();
- }
- let Some(pkg) = npm_package_for(tool) else {
- return String::new();
- };
- match source {
- "volta" => {
- let volta = quoted_sibling_or_bare_with_ext(bin_path, "volta", &["exe", "cmd"]);
- format!("{volta} uninstall {pkg}")
- }
- "pnpm" => {
- let pnpm = quoted_sibling_or_bare_with_ext(bin_path, "pnpm", &["cmd", "exe"]);
- format!("{pnpm} rm -g {pkg}")
- }
- _ => {
- let npm = quoted_sibling_or_bare_with_ext(bin_path, "npm", &["cmd", "exe"]);
- format!("{npm} rm -g {pkg}")
- }
- }
-}
-
/// Windows 版锚定命令生成。等价类压缩到 3 种(volta/pnpm/npm),不存在 brew/bun/
/// claude-native(Windows 没 Homebrew、Bun for Windows 仍 preview、claude.ai/install.sh
/// 是 bash 脚本)。Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类——
@@ -3303,27 +2943,6 @@ mod tests {
}
}
- /// `win_quote_path_for_user_shell` 的镜像——卸载命令是给用户复制到自己 shell
- /// 跑的,**不做** `%` → `%%%%` 转义(那是 .bat + call 两轮 expansion 的需要,
- /// 用户的 cmd 一轮、PowerShell/bash 零轮展开,4 倍转义会让三个 shell 都拿不到
- /// 原路径)。其他 token 边界字符的引号包裹规则与 `expect_quoted_path` 一致,
- /// 因为引号在三个 shell 下都保持字面。
- ///
- /// 与 `expect_quoted_path` 物理相邻并排放,提醒读者两个 helper **同步**反映
- /// 生产端 `win_quote_path_for_batch` / `win_quote_path_for_user_shell` 那对函数
- /// 的"目标终端决定 quoter"边界——任一边改了,另一边也得跟。
- fn expect_user_shell_quoted_path(p: &str) -> String {
- // 不替换 `%`——这正是与 expect_quoted_path 的唯一差异。
- let needs_quote = p
- .chars()
- .any(|c| matches!(c, ' ' | '&' | '(' | ')' | '^' | ';' | '<' | '>' | '|' | ','));
- if needs_quote {
- format!("\"{p}\"")
- } else {
- p.to_string()
- }
- }
-
#[test]
fn volta_windows_uses_volta_install() {
// tempdir 路径里不含 "volta" 子串,所以在 tempdir 下手建一个 `Volta` 子目录
@@ -3430,40 +3049,6 @@ mod tests {
"batch 行不应含未转义的字面 `%foo%`(会被 call 二次解析展开): {batch_line}"
);
}
-
- #[test]
- fn windows_uninstall_for_percent_path_keeps_literal_percent() {
- // **P3 回归用例**(2026-05-23):卸载命令是给用户复制到自己 shell 跑的,
- // **不能复用 anchored upgrade 的 `win_quote_path_for_batch`**——后者把
- // `%` 转 `%%%%` 是因为命令落 .bat + call(两轮 expansion 后还原 `%foo%`),
- // 而卸载命令直接渲染给用户复制,cmd 只一轮、PowerShell/bash 零轮展开,
- // `%%%%foo%%%%` 在三个 shell 下都拿不到原路径。
- //
- // 这条 integration 测试是上面那条 windows_full_batch_line_for_percent_path...
- // 的精确对照:同样 `path%foo%` 子目录,升级路径生成 `%%%%foo%%%%`、
- // 卸载路径应保持字面 `%foo%`。两条用例并排锁住"目标终端决定 quoter"边界。
- let (_dir, sub, bin_path) = setup_sibling("path%foo%", "codex.cmd", &["npm.cmd"]);
- // tempdir 路径不命中 nvm/homebrew/volta/... 任何前缀 → source = "system",
- // 落到 Windows 版 uninstall 的 `_ => npm` 兜底分支(这正是本用例要验证的:
- // npm.cmd 同目录被 sibling 探到、anchored 到那处)。
- let source = infer_install_source(std::path::Path::new(&bin_path));
- let uninstall = uninstall_command_from_paths("codex", &bin_path, &bin_path, source);
- // 含字面 `%foo%`,**不含** `%%%%`(那是 batch quoter 的痕迹)。
- assert!(
- uninstall.contains("path%foo%"),
- "卸载命令应保持字面 `path%foo%`(cmd 用户字面识别;PowerShell/bash 也字面但 PowerShell 还需 `&` 前缀执行含引号命令——见 win_quote_path_for_user_shell doc): {uninstall}"
- );
- assert!(
- !uninstall.contains("%%%%"),
- "卸载命令不应含 4 倍 `%` 转义(那是 .bat+call 链路的 quoting): {uninstall}"
- );
- // 端到端形态:` rm -g @openai/codex`,npm 路径在 sub 下。
- // sub 可能含空格(取决于 temp 根目录),用 user-shell quoter 镜像算 expected——
- // 与 expect_quoted_path 对偶,锁住"卸载侧不做 % 转义、其他字符引号规则同款"。
- let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
- let expected_npm = expect_user_shell_quoted_path(&npm_full);
- assert_eq!(uninstall, format!("{expected_npm} rm -g @openai/codex"));
- }
}
/// Windows-only helpers 单测——在 macOS/Linux 上整块通过 cfg 排除,不参与 `cargo test`。
@@ -3550,57 +3135,6 @@ mod tests {
assert!(!out.starts_with('"'), "纯 `%` 路径不应加外层引号: {out}");
}
- // ─── win_quote_path_for_user_shell:对照 win_quote_path_for_batch ───
- // 关键差异:**不做 `%` 转义**(目标是用户复制到 cmd/PowerShell/bash,
- // 不进 .bat + call 的两轮 expansion)。token 边界字符的引号包裹规则
- // 与 batch quoter 一致(三个 shell 下引号都保持字面)。
-
- #[test]
- fn user_shell_quote_clean_path_stays_bare() {
- // 与 batch quoter 同样的"无特殊字符不加引号"语义。
- assert_eq!(
- win_quote_path_for_user_shell("C:\\Users\\me\\npm.cmd"),
- "C:\\Users\\me\\npm.cmd"
- );
- }
-
- #[test]
- fn user_shell_quote_spaced_path_gets_quoted() {
- // 空格触发外层引号(防止 shell 拆 token),与 batch quoter 同款。
- assert_eq!(
- win_quote_path_for_user_shell("C:\\Program Files\\nodejs\\npm.cmd"),
- "\"C:\\Program Files\\nodejs\\npm.cmd\""
- );
- }
-
- #[test]
- fn user_shell_quote_percent_stays_literal_unlike_batch() {
- // **回归用例**(P3 反馈):卸载命令是用户复制到自己 shell 跑的,
- // **不能用 batch 的 4 倍 % 转义**。对比:
- // - win_quote_path_for_batch("C:\\path%foo%\\npm.cmd")
- // → "C:\\path%%%%foo%%%%\\npm.cmd" (供 .bat+call 两轮展开后还原 `%foo%`)
- // - win_quote_path_for_user_shell("C:\\path%foo%\\npm.cmd")
- // → "C:\\path%foo%\\npm.cmd" (保持原 `%`,让用户自决)
- // 若用 batch 那个 quoter,粘到 cmd 一轮展开变 `%%`、粘到 PowerShell/bash
- // 直接字面 `%%%%`,三个 shell 都拿不到原路径。
- assert_eq!(
- win_quote_path_for_user_shell("C:\\path%foo%\\npm.cmd"),
- "C:\\path%foo%\\npm.cmd"
- );
- }
-
- #[test]
- fn user_shell_quote_percent_with_space_keeps_percent_literal() {
- // 与 batch quoter 的 win_quote_percent_with_space_gets_both 形成精确对照:
- // - batch: "C:\\my %dir%\\npm.cmd" → "\"C:\\my %%%%dir%%%%\\npm.cmd\""
- // - user_shell: same input → "\"C:\\my %dir%\\npm.cmd\""
- // 空格仍触发引号(token 边界、跨 shell 一致),`%` 保持字面(目标 shell 不确定)。
- assert_eq!(
- win_quote_path_for_user_shell("C:\\my %dir%\\npm.cmd"),
- "\"C:\\my %dir%\\npm.cmd\""
- );
- }
-
#[test]
fn sibling_bin_picks_first_existing_extension() {
// 同目录同时存在 `npm.cmd` 和 `npm.exe` 时,候选顺序 `[cmd, exe]` 应取 .cmd——
@@ -3741,13 +3275,6 @@ mod tests {
use super::super::*;
use std::path::Path;
- /// 测试 helper:跟生产代码 `enumerate_tool_installations` 同款契约——从 bin 路径
- /// 推 source 标签,再传给 `uninstall_command_from_paths`。封一层让 uninstall_*
- /// 用例的 4 参数调用形态简洁。
- fn src(bin: &str) -> &'static str {
- infer_install_source(Path::new(bin))
- }
-
fn inst(path: &str, is_default: bool) -> ToolInstallation {
ToolInstallation {
path: path.to_string(),
@@ -3756,10 +3283,6 @@ mod tests {
error: None,
source: infer_install_source(Path::new(path)).to_string(),
is_path_default: is_default,
- // 升级锚定测试不关心 uninstall_command(那条由 uninstall_* 用例覆盖);
- // 填空串避免与本组用例的"升级命令应该是什么"互相干扰。
- uninstall_command: String::new(),
- uninstall_command_needs_cmd_hint: false,
// 测试场景下不需要走 fs canonicalize——POSIX 锚定测试关心的是
// path/real 都被传给 anchored_command_from_paths 的纯字符串判定,
// 已有用例(brew_formula_extraction / claude_native_*)是直接
@@ -3997,307 +3520,6 @@ mod tests {
);
}
- // ─── uninstall command (bug fix: 卸载命令的 source-aware 锚定) ───
- // 这组测试与上面 anchored upgrade 是对偶关系——同一组真身判定要在卸载侧
- // 也产出正确命令。先前卸载命令在前端拼,前端只拿到 `source` 字符串,无法
- // 区分 brew formula vs homebrew npm 全局包,把所有 `/opt/homebrew/bin/*`
- // 默认拼成 `npm rm -g`,对 brew formula 必然 `no such package` 失败。
- // 修复后卸载在后端拼,复用 brew_formula_from_path + sibling_bin。
-
- #[test]
- fn brew_formula_uninstall_uses_brew_uninstall() {
- // 关键回归用例:Jason 2026-05-23 实测的 bug 现场——
- // /opt/homebrew/bin/gemini 真身在 /Cellar/gemini-cli/,前端给出
- // `npm rm -g @google/gemini-cli` 会失败。修复后应该是 brew uninstall。
- // formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli),
- // 用 npm 包名做参数会卸错对象。
- let bin = "/opt/homebrew/bin/gemini";
- let cmd = uninstall_command_from_paths(
- "gemini",
- bin,
- "/opt/homebrew/Cellar/gemini-cli/0.43.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
- src(bin),
- );
- assert_eq!(cmd, "/opt/homebrew/bin/brew uninstall gemini-cli");
- }
-
- #[test]
- fn homebrew_npm_global_uninstall_uses_npm_rm() {
- // openclaw 装在 Homebrew node 的全局目录(lib/node_modules,**非 Cellar**)
- // 是 npm 全局包,应该走 npm rm -g 而不是 brew uninstall。
- // 这条 case 锁住"homebrew formula vs homebrew npm 全局"的边界——
- // 两者前端 source 都是 "homebrew",必须靠后端真身判定区分。
- //
- // 命令尾巴附加物理删除兜底:`npm rm -g` 在某些环境(老 node 损坏 npm
- // 元数据)静默 no-op,`[ -e bin ] && rm -f bin && rm -rf pkg_dir` 兜底
- // 让真正不生效时仍能清理。详见 uninstall_command_from_paths doc。
- let bin = "/opt/homebrew/bin/openclaw";
- let cmd = uninstall_command_from_paths(
- "openclaw",
- bin,
- "/opt/homebrew/lib/node_modules/openclaw/dist/cli.js",
- src(bin),
- );
- assert_eq!(
- cmd,
- concat!(
- "/opt/homebrew/bin/npm rm -g openclaw",
- "; [ -e /opt/homebrew/bin/openclaw ]",
- " && rm -f /opt/homebrew/bin/openclaw",
- " && rm -rf /opt/homebrew/lib/node_modules/openclaw",
- )
- );
- }
-
- #[test]
- fn nvm_uninstall_anchors_to_that_npm() {
- // nvm node18 v18.20.8 装的 @google/gemini-cli 0.1.7——Jason 实测的
- // 第二处安装。卸载必须锚定到 v18.20.8 下的 npm,避免误删 v22.14.0 的。
- //
- // **这条用例是 npm rm 静默 no-op bug 的关键回归现场**:Jason 实测
- // 此机型上 `npm rm -g @google/gemini-cli` 返回 `up to date in 91ms`
- // exit 0 不动包目录。命令尾巴的物理删除兜底专门防御此 case。
- let bin = "/Users/me/.nvm/versions/node/v18.20.8/bin/gemini";
- let cmd = uninstall_command_from_paths(
- "gemini",
- bin,
- "/Users/me/.nvm/versions/node/v18.20.8/lib/node_modules/@google/gemini-cli/dist/index.js",
- src(bin),
- );
- assert_eq!(
- cmd,
- concat!(
- "/Users/me/.nvm/versions/node/v18.20.8/bin/npm rm -g @google/gemini-cli",
- "; [ -e /Users/me/.nvm/versions/node/v18.20.8/bin/gemini ]",
- " && rm -f /Users/me/.nvm/versions/node/v18.20.8/bin/gemini",
- " && rm -rf /Users/me/.nvm/versions/node/v18.20.8/lib/node_modules/@google/gemini-cli",
- )
- );
- }
-
- #[test]
- fn npm_uninstall_fallback_quotes_spaced_paths() {
- // 兜底链里有 3 处路径(npm / bin / pkg_dir),含空格时 3 处都要单引号包裹,
- // 否则 `[ -e /Users/my name/.../bin/gemini ]` 会被 shell 拆成多 token。
- // 锁住 quote_path_if_spaced 应用在兜底所有路径上,而非只 npm 头部。
- let bin = "/Users/my name/.nvm/versions/node/v22/bin/gemini";
- let cmd = uninstall_command_from_paths(
- "gemini",
- bin,
- "/Users/my name/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli/dist/index.js",
- src(bin),
- );
- assert_eq!(
- cmd,
- concat!(
- "'/Users/my name/.nvm/versions/node/v22/bin/npm' rm -g @google/gemini-cli",
- "; [ -e '/Users/my name/.nvm/versions/node/v22/bin/gemini' ]",
- " && rm -f '/Users/my name/.nvm/versions/node/v22/bin/gemini'",
- " && rm -rf '/Users/my name/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli'",
- )
- );
- }
-
- #[test]
- fn npm_uninstall_fallback_strong_quotes_shell_metachars() {
- // **P2 回归用例**:卸载命令是 destructive(`rm -rf `),路径含
- // `;` / `'` / `$` / `` ` `` 等 shell 元字符时必须强 quote——否则
- // `[ -e /Users/foo;bad/.../gemini ]` 会被 shell 拆成 `[ -e /Users/foo]`
- // + `bad/.../gemini ] && rm -rf ...`,destructive 命令落到错的路径。
- // `quote_path_if_spaced` 只查空格,本用例确保升级到
- // `posix_quote_for_user_shell`(白名单)后,危险字符触发 `shell_single_quote`。
- //
- // 用 `;` 作为代表性元字符——所有非 `[A-Za-z0-9._/+=:-]` 字符都走同一
- // shell_single_quote 路径,锁一个就够覆盖白名单切换语义。
- let bin = "/Users/foo;bar/.nvm/versions/node/v22/bin/gemini";
- let cmd = uninstall_command_from_paths(
- "gemini",
- bin,
- "/Users/foo;bar/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli/dist/index.js",
- src(bin),
- );
- // npm/bin/pkg_dir 三处都被单引号包裹(shell_single_quote 的形态)。
- // 这条 assert_eq 锁住"含 `;` 路径**所有**位置都强 quote",任一漏掉
- // 都会让 shell 拆词、可能 destructive。
- assert_eq!(
- cmd,
- concat!(
- "'/Users/foo;bar/.nvm/versions/node/v22/bin/npm' rm -g @google/gemini-cli",
- "; [ -e '/Users/foo;bar/.nvm/versions/node/v22/bin/gemini' ]",
- " && rm -f '/Users/foo;bar/.nvm/versions/node/v22/bin/gemini'",
- " && rm -rf '/Users/foo;bar/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli'",
- )
- );
- }
-
- #[test]
- fn npm_uninstall_fallback_quotes_single_quote_in_path() {
- // 路径含字面 `'`——`shell_single_quote` 的 `'"'"'` 转义形式必须能 round-trip。
- // 形态:`o'leary` → `'o'"'"'leary'`(关单引号 + 双引号包字面单引号 + 重开
- // 单引号),跨 bash/zsh/dash/sh 全部 POSIX 兼容。本用例在卸载兜底链上下文
- // 验证 npm/bin/pkg_dir 三处都正确 round-trip,防止未来"换 quoter"重构时
- // 漏掉单引号场景(单引号是最棘手的 shell quoting 边界)。
- //
- // **关键 ToolInstallation.uninstall_command_needs_cmd_hint 边界**(reviewer
- // 2026-05-24 抓的 false-positive):此命令含 `"`(escape 的一部分),但
- // 它是 **POSIX shell_single_quote 形态**,**不是** Windows cmd 引号路径。
- // 前端 `cmd.includes('"')` 在此场景会**误判**为 Windows cmd 形式,
- // 给 macOS 用户名带 `'`(如 `o'leary`)的用户错误显示 PowerShell 提示。
- // 这就是为什么 `uninstall_command_needs_cmd_hint` 必须由后端用
- // `cfg!(target_os = "windows") && contains('"')` 算——`cfg!` 是编译时
- // ground truth, POSIX 编译时整体短路 false, 与含 `"` 的字面巧合无关。
- // 本测试的存在 = 字面 contains('"') 永远 true, cfg 短路是唯一防线。
- let bin = "/Users/o'leary/.nvm/versions/node/v22/bin/gemini";
- let cmd = uninstall_command_from_paths(
- "gemini",
- bin,
- "/Users/o'leary/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli/dist/index.js",
- src(bin),
- );
- // 验证三处都得到同款 `'"'"'` 转义形态。
- assert!(
- cmd.contains("'/Users/o'\"'\"'leary/.nvm/versions/node/v22/bin/npm'"),
- "npm 路径单引号转义不对: {cmd}"
- );
- assert!(
- cmd.contains("'/Users/o'\"'\"'leary/.nvm/versions/node/v22/bin/gemini'"),
- "bin 路径单引号转义不对: {cmd}"
- );
- assert!(
- cmd.contains("'/Users/o'\"'\"'leary/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli'"),
- "pkg_dir 路径单引号转义不对: {cmd}"
- );
- // 显式断言:命令字面**确实**含 `"`——这是上面 doc 关键边界的存在证明。
- // 此 assert 让"前端 string match 是 false-positive"的论据有了不可绕过的
- // 测试支撑(未来若 shell_single_quote 改用别的 escape 形式,本断言会失败、
- // 触发 reviewer 评估是否还需要 cfg 防御层)。
- assert!(
- cmd.contains('"'),
- "POSIX shell_single_quote 转义形态必须含 `\"`(本用例的存在前提): {cmd}"
- );
- }
-
- #[test]
- fn npm_uninstall_fallback_only_for_anchored_branches() {
- // 兜底链**只在 nvm/fnm/mise/homebrew npm 全局四个分支出现**(那里 bin 与
- // pkg_dir 都能可靠推导)。其他分支不应该带兜底:
- // - brew formula: brew uninstall 没观察到 no-op
- // - volta: volta uninstall 没观察到 no-op
- // - bun: bun rm 没观察到 no-op
- // - system/unknown: 路径不可靠,**不要拼出可能误指根目录的 rm -rf**
- // 这条用例反向锁住——任意分支若误带兜底,会被这里 catch。
- //
- // **不包含 hermes** :hermes 在函数开头就 early return pip uninstall,根本
- // 不进 source match 分支,断言对 pip uninstall 是 vacuously true。hermes
- // 的"不进 npm 分支"已由 hermes_uninstall_uses_pip 用例独立锁住,这里再放
- // 一行只会误导读者(让人以为本用例在验证"hermes 走 npm 分支也不带兜底")。
- for (tool, bin, real) in [
- ("gemini", "/opt/homebrew/bin/gemini",
- "/opt/homebrew/Cellar/gemini-cli/0.43.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js"),
- ("codex", "/Users/me/.volta/bin/codex",
- "/Users/me/.volta/tools/image/packages/@openai/codex/lib/cli.js"),
- ("opencode", "/Users/me/.bun/bin/opencode",
- "/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode"),
- ("opencode", "/Users/me/.opencode/bin/opencode", "/Users/me/.opencode/bin/opencode"),
- ] {
- let cmd = uninstall_command_from_paths(tool, bin, real, src(bin));
- assert!(
- !cmd.contains("rm -rf"),
- "tool={tool} bin={bin}: 该分支不应含 rm -rf 兜底,实际生成: {cmd}"
- );
- assert!(
- !cmd.contains("[ -e "),
- "tool={tool} bin={bin}: 该分支不应含 `[ -e ]` 测试,实际生成: {cmd}"
- );
- }
- }
-
- #[test]
- fn volta_uninstall_uses_volta_uninstall() {
- let bin = "/Users/me/.volta/bin/codex";
- let cmd = uninstall_command_from_paths(
- "codex",
- bin,
- "/Users/me/.volta/tools/image/packages/@openai/codex/lib/cli.js",
- src(bin),
- );
- assert_eq!(cmd, "/Users/me/.volta/bin/volta uninstall @openai/codex");
- }
-
- #[test]
- fn bun_uninstall_uses_bun_rm() {
- let bin = "/Users/me/.bun/bin/opencode";
- let cmd = uninstall_command_from_paths(
- "opencode",
- bin,
- "/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode",
- src(bin),
- );
- assert_eq!(cmd, "/Users/me/.bun/bin/bun rm -g opencode-ai");
- }
-
- #[test]
- fn pnpm_uninstall_uses_pnpm_rm() {
- // **review M1 回归用例**:pnpm 全局包不在 npm prefix 下,落到 npm rm -g
- // 会"not installed"必失败。Windows anchored upgrade 早已有 pnpm 分支,
- // POSIX 卸载补齐对偶——`~/.local/share/pnpm/` 是 pnpm 在 macOS/Linux
- // 上的标准全局 bin 形态,被 infer_install_source 归为 "pnpm" 类。
- let bin = "/Users/me/.local/share/pnpm/codex";
- let cmd = uninstall_command_from_paths(
- "codex",
- bin,
- "/Users/me/.local/share/pnpm/global/5/node_modules/@openai/codex/dist/cli.js",
- src(bin),
- );
- assert_eq!(cmd, "/Users/me/.local/share/pnpm/pnpm rm -g @openai/codex");
- }
-
- #[test]
- fn hermes_uninstall_uses_pip() {
- // hermes 走 pip,与锚定无关——包名固定 hermes-agent。
- let bin = "/Users/me/.local/bin/hermes";
- let cmd = uninstall_command_from_paths(
- "hermes",
- bin,
- "/Users/me/.local/bin/hermes",
- src(bin),
- );
- assert_eq!(cmd, "python3 -m pip uninstall hermes-agent");
- }
-
- #[test]
- fn brew_uninstall_path_with_space_is_quoted() {
- // brew 分支用 `/brew`,目录含空格时同样要引号包裹,
- // 否则 `/opt/my brew/bin/brew uninstall gemini-cli` 会被 shell 拆词
- // 当成两个命令。镜像 anchored upgrade 的 brew_path_with_space_is_quoted。
- let bin = "/opt/my brew/bin/gemini";
- let cmd = uninstall_command_from_paths(
- "gemini",
- bin,
- "/opt/my brew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
- src(bin),
- );
- assert_eq!(cmd, "'/opt/my brew/bin/brew' uninstall gemini-cli");
- }
-
- #[test]
- fn system_install_uninstall_falls_back_to_bare_npm_rm() {
- // ~/.opencode/bin / ~/go/bin / ~/.local/bin 等独立安装无 sibling npm
- // (与 anchored upgrade 在同类来源返回 None 的语义对偶)。
- // **关键**:不要拼 `/npm rm -g `——该处无 npm 文件,
- // 拼出来必失败。退化到**无前缀** `npm rm -g `,让用户终端 PATH
- // 默认的 npm 处理;卸载语义下"不锚定但明确意图"比"返回 None 让 UI
- // 不显示命令"更友好(这条 hint 本来就是给用户复制粘贴的)。
- let bin = "/Users/me/.opencode/bin/opencode";
- let cmd = uninstall_command_from_paths(
- "opencode",
- bin,
- "/Users/me/.opencode/bin/opencode",
- src(bin),
- );
- assert_eq!(cmd, "npm rm -g opencode-ai");
- }
-
#[test]
fn sibling_bin_returns_none_when_bin_path_has_no_directory() {
// bin_path 不含 `/` → parent_dir 返回空 → sibling_bin 不能拼出绝对路径
@@ -4370,8 +3592,6 @@ mod tests {
error: None,
source: "nvm".to_string(),
is_path_default: false,
- uninstall_command: String::new(),
- uninstall_command_needs_cmd_hint: false,
real: std::path::PathBuf::from("/x"),
};
// 单处 → 不冲突。
diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx
index d66b8400e..a472a8c86 100644
--- a/src/components/settings/AboutSection.tsx
+++ b/src/components/settings/AboutSection.tsx
@@ -14,7 +14,6 @@ import {
ArrowUpCircle,
ChevronDown,
Stethoscope,
- Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -142,11 +141,6 @@ const TOOL_APP_IDS: Record = {
hermes: "hermes",
};
-// 卸载命令历史上在前端按 inst.source 拼,但 `source` 把 brew formula 和 homebrew npm
-// 全局包都归为 "homebrew"——前端无法区分两者(formula 真身在 /Cellar/、npm 全局在
-// /opt/homebrew/lib/node_modules/),会给 brew formula 错拼 `npm rm -g` 必失败。
-// 现已迁回后端 uninstall_command_from_paths,复用 brew_formula_from_path 真身判定;
-// 这里只读 inst.uninstall_command,不再前端拼。
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation();
@@ -407,20 +401,6 @@ 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);
@@ -1034,41 +1014,11 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{t("settings.toolConflictHint")}
- {conflicts.map((inst) => {
- const uninstallCmd = inst.uninstall_command;
- return (
- -
-
- {/* 卸载建议命令:仅供复制,绝不代执行。前置红色垃圾桶图标
- 明示这是卸载(破坏性)命令,避免误以为是普通信息。 */}
-
-
- {uninstallCmd}
-
-
-
-
- {/* PowerShell 限制提示:用后端结构化字段而非前端 string match。
- **不能用 uninstallCmd.includes('"')**:POSIX 的
- shell_single_quote 转义是 `'"'"'` 也含 `"`,含 `'` 的 POSIX
- 路径会被误判为 Windows cmd 形式。后端用
- `cfg!(target_os = "windows") && contains('"')` 算这个 bit,
- POSIX 编译时短路 false,前端只读 bool。 */}
- {inst.uninstall_command_needs_cmd_hint && (
-
- {t("settings.toolUninstallPwshHint")}
-
- )}
-
- );
- })}
+ {conflicts.map((inst) => (
+ -
+
+
+ ))}
)}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 2fb85c294..a68a0acad 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -684,9 +684,6 @@
"toolConflictNotRunnable": "can't run",
"toolDiagnoseNoConflict": "No conflicting installations found",
"toolDiagnoseFailed": "Diagnosis failed",
- "toolUninstallCopyHint": "Copy uninstall command (won't run it)",
- "toolUninstallCopied": "Uninstall command copied",
- "toolUninstallPwshHint": "Built for Command Prompt (cmd). PowerShell users: prefix the command with `&` (call operator), otherwise the quoted path is treated as a string literal.",
"toolUpgradeConfirmTitle": "Confirm upgrade target",
"toolUpgradeConfirmHint": "Multiple installations detected. This upgrade won't update all of them; refer to each tool below for what it actually targets.",
"toolUpgradeWillRun": "Will run:",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 07ba69e89..29d50b650 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -684,9 +684,6 @@
"toolConflictNotRunnable": "実行不可",
"toolDiagnoseNoConflict": "競合するインストールは見つかりませんでした",
"toolDiagnoseFailed": "診断に失敗しました",
- "toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)",
- "toolUninstallCopied": "アンインストールコマンドをコピーしました",
- "toolUninstallPwshHint": "コマンドプロンプト (cmd) 用です。PowerShell ではコマンド先頭に `&`(呼び出し演算子)を付ける必要があります。付けないと引用符付きパスが文字列リテラルとして扱われます。",
"toolUpgradeConfirmTitle": "アップグレード先の確認",
"toolUpgradeConfirmHint": "複数のインストールを検出しました。今回のアップグレードはすべてを更新するわけではありません。実際に作用する箇所は下記の各項目をご確認ください。",
"toolUpgradeWillRun": "実行内容:",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index ee54054da..1deaf227b 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -684,9 +684,6 @@
"toolConflictNotRunnable": "无法运行",
"toolDiagnoseNoConflict": "未发现安装冲突",
"toolDiagnoseFailed": "诊断失败",
- "toolUninstallCopyHint": "复制卸载命令(不会自动执行)",
- "toolUninstallCopied": "卸载命令已复制",
- "toolUninstallPwshHint": "适用于命令提示符 (cmd)。PowerShell 用户需在命令前加 `&`(call 运算符),否则带引号的路径会被当作字符串字面值。",
"toolUpgradeConfirmTitle": "确认升级位置",
"toolUpgradeConfirmHint": "检测到多处安装。本次升级不会全部更新,具体作用的位置以下方各项为准。",
"toolUpgradeWillRun": "将执行:",
diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts
index fa6fd01e0..45e77806a 100644
--- a/src/lib/api/settings.ts
+++ b/src/lib/api/settings.ts
@@ -249,20 +249,6 @@ export interface ToolInstallation {
error: string | null;
source: string;
is_path_default: boolean;
- /** 后端拼好的卸载命令(仅展示给用户复制,UI 不代执行)。source-aware:
- * brew formula → brew uninstall、volta → volta uninstall、bun → bun rm -g、
- * 其余 npm 全局 → 锚定该处同目录 npm 的 npm rm -g。
- * 此前前端按 source 字符串自己拼,无法区分 brew formula vs homebrew npm 全局包,
- * 会给 brew formula 错拼 `npm rm -g`。现统一在后端用 brew_formula_from_path 的
- * 真身判定生成,与升级路径共享判定逻辑。 */
- uninstall_command: string;
- /** 该卸载命令是否是 Windows cmd 兼容形式(含 quoted 路径,PowerShell 用户需 `&`
- * call operator 前缀)。前端据此条件渲染一行 PowerShell 限制提示。
- * **必须由后端给出**:POSIX `shell_single_quote` 转义形式 `'"'"'` 含双引号,
- * 前端 `cmd.includes('"')` 会把 POSIX 路径带 `'` 的用户(macOS `o'leary`)
- * 误判为 Windows cmd 形式。后端用 `cfg!(target_os = "windows") && contains('"')`
- * 算,POSIX 编译时整体短路为 false,精确锁定触发域。 */
- uninstall_command_needs_cmd_hint: boolean;
}
/** 一次"探测工具安装分布"的结果。字段对应后端 ToolInstallationReport。 */