feat(settings): prefer official installer for tool install with npm fallback

Bare `npm i -g <pkg>` was the install command for every managed tool, but
Anthropic and SST now both recommend their native installers over npm. The
update path already anchors to native installs (`claude update` and friends),
so install was the last place still forcing npm — even when the user's
intent was clearly to get the upstream-recommended install.

- New `install_command_for(tool)` (non-Windows only): claude and opencode
  now run the upstream installer with a `|| npm i -g ...@latest` POSIX
  short-circuit fallback, so the tool still installs if the URL is
  unreachable.
- codex / gemini / openclaw / hermes pass through to the original static
  command — they have no independent official installer today.
- `build_tool_lifecycle_command` now opens `set -o pipefail` alongside
  `set -e`: without it, `curl ... | bash` where curl fails would have bash
  exit 0 on empty stdin, silently masking the failure and skipping the
  `||` fallback.
- 6 new `install_strategy` unit tests pin the per-tool install command
  shape, mirroring the existing anchored_upgrade regression suite.

Update routing and Windows install behavior are unchanged.
This commit is contained in:
Jason
2026-05-23 18:40:24 +08:00
parent e25682d345
commit 108dda1772
+106 -3
View File
@@ -306,7 +306,13 @@ fn build_tool_lifecycle_command(
let mut lines = Vec::new();
#[cfg(not(target_os = "windows"))]
lines.push("set -e".to_string());
{
// set -e 让任一步失败即中止;set -o pipefail 让管道前段失败也参与判定——
// install 的 `curl ... | bash` 路径下,curl 失败时 bash 收空 stdin 仍 exit 0,
// 没有 pipefail 会"假成功"而绕过 `||` 兜底链,工具其实没装上。
lines.push("set -e".to_string());
lines.push("set -o pipefail".to_string());
}
#[cfg(target_os = "windows")]
lines.push("@echo off".to_string());
@@ -393,14 +399,15 @@ fn build_tool_action_line(
{
let _ = (wsl_shell, wsl_shell_flag);
// update 锚定到命令行实际命中的那处(写回同一个 node / brew / 原生安装器),
// 而非裸 `npm` 落到 PATH 第一个 npminstall 或探不到默认安装则用静态裸命令。
// 而非裸 `npm` 落到 PATH 第一个 npminstall 走「上游推荐 || npm 兜底」短路链
// (有 native installer 的工具如 claude/opencode),其余仍裸 npm/pip。
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(tool),
ToolLifecycleAction::Install => install_command_for(tool),
};
if command.is_empty() {
return Err(format!("Unsupported tool action target: {tool}"));
@@ -1505,6 +1512,27 @@ fn static_fallback_command(tool: &str) -> String {
.unwrap_or_default()
}
/// 新装(install)的命令:对有官方 installer 的工具走「上游推荐 || npm 兜底」短路链,
/// 其余工具透传到 `static_fallback_command`(= 与 update fallback 同一张 npm/pip 表)。
///
/// 设计理由:
/// - install 没有锚点可言(从无到有),但**有"上游推荐方式"这一事实** ——
/// Anthropic 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为传统方式。
/// 把这层认知补进来,让 install 表与 update 端的锚定决策树共用同一份"上游事实"。
/// - 短路链(POSIX `||`)保证 URL 不可达/防火墙拦截时仍能装上,降级到 npm。
/// - 仅非 Windows 启用:claude.ai/install.sh、opencode.ai/install 都是 bash 脚本,
/// Windows(及不带 bash 的环境)继续走原 `tool_action_shell_command` 的 npm 命令。
/// - 配套要求:`build_tool_lifecycle_command` 头部已开 `set -o pipefail`,确保
/// `curl ... | bash` 中 curl 失败能让管道整体非零、`||` 兜底真正触发。
#[cfg(not(target_os = "windows"))]
fn install_command_for(tool: &str) -> String {
match tool {
"claude" => "curl -fsSL https://claude.ai/install.sh | bash || npm i -g @anthropic-ai/claude-code@latest".to_string(),
"opencode" => "curl -fsSL https://opencode.ai/install | bash || npm i -g opencode-ai@latest".to_string(),
_ => static_fallback_command(tool),
}
}
/// 计算某工具的升级命令与"是否需确认"。非 Windows 走锚定;Windows 保持静态命令、
/// 永不弹确认(避免引入 Windows 路径/批处理复杂度,零回归)。
#[cfg(not(target_os = "windows"))]
@@ -2779,6 +2807,81 @@ mod tests {
}
}
/// 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[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}");
}
#[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 hermes_install_keeps_static_pip_chain() {
// hermes 已经有 python3 || python 兜底,install_command_for 透传到 fallback。
let cmd = install_command_for("hermes");
assert!(cmd.contains("hermes-agent[web]"), "pip package: {cmd}");
assert!(
cmd.contains("python3") && cmd.contains("python "),
"should keep python3→python fallback chain: {cmd}"
);
}
}
#[cfg(target_os = "windows")]
mod wsl_helpers {
use super::super::*;