feat(tools): prefer xAI native Grok installer with npm fallback

Treat Grok like Claude/OpenCode: install via the official xAI installer
(POSIX shell / Windows PowerShell), keep npm as fallback, discover
~/.grok/bin, and anchor updates to `grok update` only for native installs.
This commit is contained in:
Jason
2026-07-20 21:05:35 +08:00
parent eccb296afe
commit 8dcedbc062
+192 -28
View File
@@ -441,6 +441,8 @@ 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'"; "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 = 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'"; "bash -c 'tmp=$(mktemp) && curl -fsSL https://opencode.ai/install -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
const GROK_INSTALL_UNIX: &str =
"bash -c 'tmp=$(mktemp) && curl -fsSL https://x.ai/cli/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
/// Hermes 官方安装器会自带/选择合适的 Python 运行时。不要再用 /// Hermes 官方安装器会自带/选择合适的 Python 运行时。不要再用
/// `python3 -m pip ... || python -m pip ...`:Hermes PyPI 包要求 Python >=3.11, /// `python3 -m pip ... || python -m pip ...`:Hermes PyPI 包要求 Python >=3.11,
@@ -454,6 +456,8 @@ const HERMES_UPDATE_UNIX: &str =
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
const HERMES_INSTALL_WINDOWS_SCRIPT: &str = const HERMES_INSTALL_WINDOWS_SCRIPT: &str =
"irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex"; "irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex";
#[cfg(target_os = "windows")]
const GROK_INSTALL_WINDOWS_SCRIPT: &str = "irm https://x.ai/cli/install.ps1 | iex";
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn powershell_encoded_command(script: &str) -> String { fn powershell_encoded_command(script: &str) -> String {
@@ -474,6 +478,14 @@ fn hermes_install_windows_command() -> String {
) )
} }
#[cfg(target_os = "windows")]
fn grok_install_windows_command() -> String {
format!(
"powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {}",
powershell_encoded_command(GROK_INSTALL_WINDOWS_SCRIPT)
)
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn hermes_update_windows_command() -> String { fn hermes_update_windows_command() -> String {
// fallback 是 powershell.exe,不是 .cmd/.bat;这里不需要 `call`。PowerShell 的 // fallback 是 powershell.exe,不是 .cmd/.bat;这里不需要 `call`。PowerShell 的
@@ -502,7 +514,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
fn official_update_args(tool: &str) -> Option<&'static str> { fn official_update_args(tool: &str) -> Option<&'static str> {
match tool { match tool {
"claude" | "codex" | "hermes" => Some("update"), "claude" | "codex" | "grok" | "hermes" => Some("update"),
"openclaw" => Some("update --yes"), "openclaw" => Some("update --yes"),
"opencode" => Some("upgrade"), "opencode" => Some("upgrade"),
_ => None, _ => None,
@@ -534,6 +546,20 @@ fn tool_action_shell_command_for_shell(
action: ToolLifecycleAction, action: ToolLifecycleAction,
shell: LifecycleCommandShell, shell: LifecycleCommandShell,
) -> Option<String> { ) -> Option<String> {
// xAI's primary Windows distribution is the native PowerShell installer.
// Keep npm as the network/policy fallback, matching the POSIX installer chain.
#[cfg(target_os = "windows")]
if tool == "grok"
&& matches!(action, ToolLifecycleAction::Install)
&& matches!(shell, LifecycleCommandShell::WindowsBatch)
{
return Some(chain_update_commands(
grok_install_windows_command(),
npm_install_command_for(tool)?.to_string(),
shell,
));
}
if tool == "hermes" { if tool == "hermes" {
return Some( return Some(
match (action, shell) { match (action, shell) {
@@ -1454,6 +1480,22 @@ fn opencode_extra_search_paths(
paths paths
} }
/// Grok's official installer writes the launcher to `$GROK_BIN_DIR` or, by
/// default, `~/.grok/bin`. Keep these ahead of generic npm/Node locations so
/// version probing and anchored updates can see the native distribution even
/// when the GUI process inherited a stale PATH.
fn grok_extra_search_paths(
home: &Path,
grok_bin_dir: Option<std::ffi::OsString>,
) -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
push_env_single_dir(&mut paths, grok_bin_dir);
if !home.as_os_str().is_empty() {
push_unique_path(&mut paths, home.join(".grok").join("bin"));
}
paths
}
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> { fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -1503,6 +1545,12 @@ fn build_tool_search_paths(tool: &str) -> Vec<std::path::PathBuf> {
// 常见的安装路径(原生安装优先) // 常见的安装路径(原生安装优先)
let mut search_paths: Vec<std::path::PathBuf> = Vec::new(); let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
if tool == "grok" {
let extra_paths = grok_extra_search_paths(&home, std::env::var_os("GROK_BIN_DIR"));
for path in extra_paths {
push_unique_path(&mut search_paths, path);
}
}
if !home.as_os_str().is_empty() { 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(".local/bin"));
push_unique_path(&mut search_paths, home.join(".npm-global/bin")); push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
@@ -1987,6 +2035,19 @@ fn brew_formula_from_path(real: &str) -> Option<String> {
None None
} }
/// xAI's native installer uses `~/.grok/bin` for its launchers and
/// `~/.grok/downloads/grok-<platform>` for the downloaded binary. The launcher
/// directory also covers the default Windows layout, where the executable is
/// copied instead of symlinked. Checking the real target additionally supports
/// a custom `$GROK_BIN_DIR` on POSIX, whose launcher still points into the
/// standard downloads directory.
fn is_grok_native_install(bin_path: &str, real_target: &str) -> bool {
[bin_path, real_target].iter().any(|path| {
let normalized = path.replace('\\', "/").to_ascii_lowercase();
normalized.contains("/.grok/bin/") || normalized.contains("/.grok/downloads/grok-")
})
}
/// 含空格才用 POSIX 单引号包一层,否则保持裸路径——命令展示更干净。 /// 含空格才用 POSIX 单引号包一层,否则保持裸路径——命令展示更干净。
/// claude / brew / volta / bun / npm 五个锚定分支共用,避免"含空格"判定漂移。 /// claude / brew / volta / bun / npm 五个锚定分支共用,避免"含空格"判定漂移。
/// ///
@@ -2275,14 +2336,14 @@ fn package_manager_anchored_command_from_paths(
/// 判定顺序(命中即返回): /// 判定顺序(命中即返回):
/// ① Hermes → `<bin_path 绝对> update`;Hermes CLI 自己知道安装环境,避免 cc-switch /// ① Hermes → `<bin_path 绝对> update`;Hermes CLI 自己知道安装环境,避免 cc-switch
/// 猜系统 `python3`/`python` 时撞上 Python 版本或 pyenv shim 问题。 /// 猜系统 `python3`/`python` 时撞上 Python 版本或 pyenv shim 问题。
/// ② Claude 原生安装器(`~/.local/share/claude/versions/`→ `<bin_path 绝对> update` /// ② Claude / Grok 原生安装器 → `<bin_path 绝对> update`
/// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管, /// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管,
/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。 /// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
/// ③ Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`; /// ③ Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`;
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。 /// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>` /// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。 /// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到 /// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI,以及非 native 的 Grok Build) → 锚定到
/// "那处 bin 目录的 npm"。 /// "那处 bin 目录的 npm"。
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> { fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
@@ -2297,6 +2358,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
{ {
return anchored_official_update_command(tool, bin_path); return anchored_official_update_command(tool, bin_path);
} }
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target); let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
if brew_formula_from_path(real_target).is_some() { if brew_formula_from_path(real_target).is_some() {
return package_command; return package_command;
@@ -2344,7 +2408,7 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
/// Windows 版锚定命令生成。对平台确认可静默运行的工具优先使用官方 CLI 自升级; /// Windows 版锚定命令生成。对平台确认可静默运行的工具优先使用官方 CLI 自升级;
/// 对 npm/Volta/pnpm 这类可确认写回位置的安装,再接一个包管理器 fallback。不存在 brew/bun/claude-native /// 对 npm/Volta/pnpm 这类可确认写回位置的安装,再接一个包管理器 fallback。不存在 brew/bun/claude-native
/// (Windows 没 Homebrew、Bun for Windows 仍 preview、claude.ai/install.sh 是 bash 脚本)。 /// (Windows 没 Homebrew、Bun for Windows 仍 previewGrok native 使用 PowerShell installer)。
/// Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类——它们都只是"如何装 /// Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类——它们都只是"如何装
/// node"的不同入口,全局包真正的 idiom 仍是 sibling `npm.cmd`。 /// node"的不同入口,全局包真正的 idiom 仍是 sibling `npm.cmd`。
/// ///
@@ -2353,16 +2417,15 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
/// 装 `.cmd`、Volta 装 `.exe`,纯字符串拼接无法消歧。这一平台差异**被刻意保留**: /// 装 `.cmd`、Volta 装 `.exe`,纯字符串拼接无法消歧。这一平台差异**被刻意保留**:
/// 测试用 tempdir 隔离 fs,生产侧 TOCTOU 是 by design(见 `sibling_bin_with_ext` doc)。 /// 测试用 tempdir 隔离 fs,生产侧 TOCTOU 是 by design(见 `sibling_bin_with_ext` doc)。
/// ///
/// `_real_target` 占位维持与 POSIX 版的签名对称——Windows 上未观测到需要真身路径 /// `real_target` 维持与 POSIX 版的签名对称,并辅助识别 Grok native。若未来加 Scoop
/// 区分的等价类(无 Cellar、无 claude-native installer)。若未来加 Scoop persist 锚定 /// persist 锚定(scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),也从这里取真身。
/// (scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),从这里启用 `_real_target`。
/// ///
/// **关键不变量同 POSIX 版:返回的命令必须用绝对路径,不依赖 PATH**。Windows GUI /// **关键不变量同 POSIX 版:返回的命令必须用绝对路径,不依赖 PATH**。Windows GUI
/// 进程 PATH 由 Service Control Manager / explorer.exe 给,通常不含用户 `%LOCALAPPDATA%` /// 进程 PATH 由 Service Control Manager / explorer.exe 给,通常不含用户 `%LOCALAPPDATA%`
/// 下的 Volta/pnpm 路径;`$SHELL -lic` 的探测时 PATH 与执行时 PATH 不对称。 /// 下的 Volta/pnpm 路径;`$SHELL -lic` 的探测时 PATH 与执行时 PATH 不对称。
/// ///
/// 判定顺序(命中即返回): /// 判定顺序(命中即返回):
/// ① hermes → `<bin_path> update`;Hermes CLI 自己处理安装环境。 /// ① hermes / Grok native → `<bin_path> update`;CLI 自己处理安装环境。
/// ② 支持官方自升级且 Windows 可安全静默执行的工具 → `<bin_path> update/upgrade || call <包管理器 fallback>`。 /// ② 支持官方自升级且 Windows 可安全静默执行的工具 → `<bin_path> update/upgrade || call <包管理器 fallback>`。
/// ③ 其余 npm 工具 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest。 /// ③ 其余 npm 工具 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest。
/// ///
@@ -2370,10 +2433,13 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
/// 扩展名存在时,支持官方自升级的工具仍返回 `<bin_path> update/upgrade`,其余工具 /// 扩展名存在时,支持官方自升级的工具仍返回 `<bin_path> update/upgrade`,其余工具
/// 才返 None 让上游兜回静态命令、`anchored=false`。 /// 才返 None 让上游兜回静态命令、`anchored=false`。
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn anchored_command_from_paths(tool: &str, bin_path: &str, _real_target: &str) -> Option<String> { fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
if tool == "hermes" { if tool == "hermes" {
return anchored_official_update_command(tool, bin_path); return anchored_official_update_command(tool, bin_path);
} }
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path); let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) { if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
let update = anchored_official_update_command(tool, bin_path)?; let update = anchored_official_update_command(tool, bin_path)?;
@@ -2444,17 +2510,17 @@ fn static_fallback_command(tool: &str) -> String {
/// ///
/// 设计理由: /// 设计理由:
/// - install 没有锚点可言(从无到有),但**有"上游推荐方式"这一事实** —— /// - install 没有锚点可言(从无到有),但**有"上游推荐方式"这一事实** ——
/// Anthropic 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为传统方式。 /// Anthropic、xAI 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为替代方式。
/// 把这层认知补进来,让 install 表与 update 端的锚定决策树共用同一份"上游事实"。 /// 把这层认知补进来,让 install 表与 update 端的锚定决策树共用同一份"上游事实"。
/// - Hermes 使用官方 installer,避免用系统 Python/pip 安装时踩 Python >=3.11 与 pyenv /// - Hermes 使用官方 installer,避免用系统 Python/pip 安装时踩 Python >=3.11 与 pyenv
/// `python` shim 问题;更新路径若能锚定已安装 CLI,则走 `<hermes> update`。 /// `python` shim 问题;更新路径若能锚定已安装 CLI,则走 `<hermes> update`。
/// **Hermes 没有 npm 包,install 端不享受 `||` 降级**——上游 installer 不可达就只能等。 /// **Hermes 没有 npm 包,install 端不享受 `||` 降级**——上游 installer 不可达就只能等。
/// - 对**有 npm 包**的工具(claude/opencode),短路链(POSIX `||`)保证官方脚本不可达/ /// - 对**有 npm 包**的工具(claude/grok/opencode),短路链(POSIX `||`)保证官方脚本不可达/
/// 防火墙拦截时仍能装上,降级到裸 `npm i -g`。官方脚本本身不用 pipe, /// 防火墙拦截时仍能装上,降级到裸 `npm i -g`。官方脚本本身不用 pipe,
/// 所以这条路径在 WSL 的 `sh -c` 子 shell 中也不依赖外层 `pipefail`。 /// 所以这条路径在 WSL 的 `sh -c` 子 shell 中也不依赖外层 `pipefail`。
/// - Windows 原生不启用:claude.ai/install.sh、opencode.ai/install 都是 bash 脚本, /// - Windows 上 Claude/OpenCode 原生不启用(对应 installer 都是 bash 脚本);Grok
/// Windows 原生继续走 `tool_action_shell_command` 的 npm/PowerShell 命令;WSL 作为 /// 使用官方 PowerShell installer,并同样保留 npm fallback。WSL 作为 Linux 环境
/// Linux 环境复用这套 POSIX 安装优先级。 /// 复用这套 POSIX 安装优先级。
fn installer_with_npm_fallback(installer: &str, tool: &str) -> String { fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
match npm_install_command_for(tool) { match npm_install_command_for(tool) {
Some(npm) => chain_update_commands( Some(npm) => chain_update_commands(
@@ -2469,6 +2535,7 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
fn posix_install_command_for(tool: &str) -> String { fn posix_install_command_for(tool: &str) -> String {
match tool { match tool {
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool), "claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
"grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool),
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool), "opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
"hermes" => HERMES_INSTALL_UNIX.to_string(), "hermes" => HERMES_INSTALL_UNIX.to_string(),
_ => static_fallback_command_for(tool, ToolLifecycleAction::Install), _ => static_fallback_command_for(tool, ToolLifecycleAction::Install),
@@ -3697,23 +3764,27 @@ mod tests {
npm_install_command_for("grok"), npm_install_command_for("grok"),
Some("npm i -g @xai-official/grok@latest") Some("npm i -g @xai-official/grok@latest")
); );
assert_eq!(official_update_args("grok"), None); assert_eq!(official_update_args("grok"), Some("update"));
for shell in [ for action in [ToolLifecycleAction::Install, ToolLifecycleAction::Update] {
LifecycleCommandShell::Posix,
LifecycleCommandShell::WindowsBatch,
] {
assert_eq!( assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,) tool_action_shell_command_for_shell("grok", action, LifecycleCommandShell::Posix)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,)
.as_deref(), .as_deref(),
Some("npm i -g @xai-official/grok@latest") Some("npm i -g @xai-official/grok@latest")
); );
} }
// Static update remains package-manager based. Only a path positively
// identified as xAI's native install may run `grok update`.
assert_eq!(
tool_action_shell_command_for_shell(
"grok",
ToolLifecycleAction::Update,
LifecycleCommandShell::WindowsBatch,
)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
} }
#[test] #[test]
@@ -3971,6 +4042,35 @@ mod tests {
assert_eq!(cmd.as_deref(), Some(expected.as_str())); assert_eq!(cmd.as_deref(), Some(expected.as_str()));
} }
#[test]
fn grok_native_windows_uses_self_update() {
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn grok_windows_install_prefers_powershell_with_npm_fallback() {
let install = static_fallback_command_for("grok", ToolLifecycleAction::Install);
let native = grok_install_windows_command();
assert!(
install.starts_with(&native),
"native installer first: {install}"
);
assert!(
install.ends_with("|| call npm i -g @xai-official/grok@latest"),
"npm fallback should remain available: {install}"
);
let expected_encoded = powershell_encoded_command(GROK_INSTALL_WINDOWS_SCRIPT);
assert_eq!(
native
.split_once("-EncodedCommand ")
.map(|(_, encoded)| encoded),
Some(expected_encoded.as_str())
);
}
#[test] #[test]
fn windows_no_sibling_uses_cli_update_without_package_fallback() { fn windows_no_sibling_uses_cli_update_without_package_fallback() {
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。 // sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
@@ -4293,7 +4393,13 @@ mod tests {
assert_eq!(codex, "npm i -g @openai/codex@latest"); assert_eq!(codex, "npm i -g @openai/codex@latest");
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap(); let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
assert_eq!(grok, "npm i -g @xai-official/grok@latest"); assert!(
grok.starts_with(
"bash -c 'tmp=$(mktemp) && curl -fsSL https://x.ai/cli/install.sh "
) && grok.contains(" || npm i -g @xai-official/grok@latest"),
"WSL grok install should prefer native POSIX installer with npm fallback: {grok}"
);
assert!(!grok.contains("| bash"));
} }
#[test] #[test]
@@ -4410,6 +4516,28 @@ mod tests {
assert_eq!(cmd.as_deref(), Some("/Users/me/.local/bin/claude update")); assert_eq!(cmd.as_deref(), Some("/Users/me/.local/bin/claude update"));
} }
#[test]
fn grok_native_installer_uses_self_update() {
// ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads.
// Updating it through npm would create or mutate a different install.
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/.grok/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
}
#[test]
fn grok_custom_bin_dir_is_native_when_target_is_official_download() {
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
}
#[test] #[test]
fn gemini_homebrew_formula_uses_brew_upgrade() { fn gemini_homebrew_formula_uses_brew_upgrade() {
// /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包, // /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包,
@@ -4975,9 +5103,24 @@ mod tests {
} }
#[test] #[test]
fn grok_install_keeps_static_npm() { fn grok_install_prefers_native_with_npm_fallback() {
let cmd = install_command_for("grok"); let cmd = install_command_for("grok");
assert_eq!(cmd, "npm i -g @xai-official/grok@latest"); assert!(
cmd.contains("https://x.ai/cli/install.sh"),
"should include official installer URL: {cmd}"
);
assert!(
cmd.contains("@xai-official/grok@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] #[test]
@@ -5149,6 +5292,27 @@ mod tests {
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[test]
fn grok_extra_search_paths_prefers_override_then_default_native_dir() {
let home = PathBuf::from("/home/tester");
let paths =
grok_extra_search_paths(&home, Some(std::ffi::OsString::from("/custom/grok/bin")));
assert_eq!(paths[0], PathBuf::from("/custom/grok/bin"));
assert_eq!(paths[1], PathBuf::from("/home/tester/.grok/bin"));
}
#[test]
fn grok_extra_search_paths_deduplicates_default_override() {
let home = PathBuf::from("/home/tester");
let paths = grok_extra_search_paths(
&home,
Some(std::ffi::OsString::from("/home/tester/.grok/bin")),
);
assert_eq!(paths, vec![PathBuf::from("/home/tester/.grok/bin")]);
}
#[test] #[test]
fn cli_path_env_search_paths_include_path_entries_and_dedupe() { fn cli_path_env_search_paths_include_path_entries_and_dedupe() {
let temp = tempfile::tempdir().expect("temp dir should be created"); let temp = tempfile::tempdir().expect("temp dir should be created");