feat(grokbuild): add first-class Grok Build support (#5453)

* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
This commit is contained in:
Thefool
2026-07-17 15:50:50 +08:00
committed by GitHub
parent f6e37ed994
commit 1c0ee0c58a
112 changed files with 4530 additions and 246 deletions
+71 -4
View File
@@ -14,6 +14,8 @@ pub struct McpApps {
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub grokbuild: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
@@ -26,6 +28,7 @@ impl McpApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
@@ -39,6 +42,7 @@ impl McpApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
@@ -58,6 +62,9 @@ impl McpApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.grokbuild {
apps.push(AppType::GrokBuild);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
@@ -69,7 +76,12 @@ impl McpApps {
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude
&& !self.codex
&& !self.gemini
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
}
}
@@ -83,6 +95,8 @@ pub struct SkillApps {
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub grokbuild: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
@@ -95,6 +109,7 @@ impl SkillApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
@@ -108,6 +123,7 @@ impl SkillApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
@@ -127,6 +143,9 @@ impl SkillApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.grokbuild {
apps.push(AppType::GrokBuild);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
@@ -138,7 +157,12 @@ impl SkillApps {
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude
&& !self.codex
&& !self.gemini
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -271,6 +295,8 @@ pub struct McpRoot {
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub grokbuild: McpConfig,
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
@@ -292,6 +318,7 @@ impl Default for McpRoot {
claude_desktop: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
grokbuild: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
hermes: McpConfig::default(),
@@ -323,6 +350,8 @@ pub struct PromptRoot {
#[serde(default)]
pub gemini: PromptConfig,
#[serde(default)]
pub grokbuild: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
@@ -348,6 +377,7 @@ pub enum AppType {
ClaudeDesktop,
Codex,
Gemini,
GrokBuild,
OpenCode,
OpenClaw,
Hermes,
@@ -360,6 +390,7 @@ impl AppType {
AppType::ClaudeDesktop => "claude-desktop",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::GrokBuild => "grokbuild",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
@@ -384,6 +415,7 @@ impl AppType {
AppType::ClaudeDesktop,
AppType::Codex,
AppType::Gemini,
AppType::GrokBuild,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
@@ -402,13 +434,14 @@ impl FromStr for AppType {
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"grokbuild" | "grok-build" | "grok_build" | "grok" => Ok(AppType::GrokBuild),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
)),
}
}
@@ -444,6 +477,7 @@ impl CommonConfigSnippets {
AppType::ClaudeDesktop => None,
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::GrokBuild => None,
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
@@ -457,6 +491,7 @@ impl CommonConfigSnippets {
AppType::ClaudeDesktop => {}
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::GrokBuild => {}
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
@@ -500,6 +535,7 @@ impl Default for MultiAppConfig {
apps.insert("claude-desktop".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("grokbuild".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
@@ -662,6 +698,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::GrokBuild => &self.mcp.grokbuild,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
AppType::Hermes => &self.mcp.hermes,
@@ -675,6 +712,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::GrokBuild => &mut self.mcp.grokbuild,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
AppType::Hermes => &mut self.mcp.hermes,
@@ -691,6 +729,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::GrokBuild)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
@@ -714,6 +753,7 @@ impl MultiAppConfig {
|| !self.prompts.claude_desktop.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.grokbuild.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
@@ -728,6 +768,7 @@ impl MultiAppConfig {
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::GrokBuild,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
@@ -801,6 +842,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::GrokBuild => &mut config.prompts.grokbuild.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
@@ -843,6 +885,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::GrokBuild => continue,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
@@ -1133,6 +1176,30 @@ mod tests {
);
}
#[test]
#[serial]
fn auto_imports_grokbuild_prompt_on_first_launch() {
let _home = TempHome::new();
write_prompt_file(AppType::GrokBuild, "# Grok Build Prompt\n\nTest content");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.grokbuild.prompts.len(), 1);
let prompt = config
.prompts
.grokbuild
.prompts
.values()
.next()
.expect("grokbuild prompt exists");
assert!(prompt.enabled, "grokbuild prompt should be enabled");
assert_eq!(prompt.content, "# Grok Build Prompt\n\nTest content");
assert_eq!(
prompt.description,
Some("Automatically imported on first launch".to_string())
);
}
#[test]
#[serial]
fn auto_imports_all_three_apps_prompts() {
+11
View File
@@ -99,6 +99,15 @@ pub async fn get_config_status(
Ok(ConfigStatus { exists, path })
}
AppType::GrokBuild => {
let config_path = crate::grok_config::get_grok_config_path();
let exists = config_path.exists();
let path = crate::grok_config::get_grok_config_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
AppType::OpenCode => {
let config_path = crate::opencode_config::get_opencode_config_path();
let exists = config_path.exists();
@@ -143,6 +152,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
@@ -160,6 +170,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
+79 -3
View File
@@ -111,8 +111,8 @@ pub struct ToolVersion {
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 6] = [
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
const VALID_TOOLS: [&str; 7] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
];
#[derive(Debug, Clone, serde::Deserialize)]
@@ -424,6 +424,7 @@ fn tool_display_name(tool: &str) -> &'static str {
"claude" => "Claude Code",
"codex" => "Codex",
"gemini" => "Gemini CLI",
"grok" => "Grok Build",
"opencode" => "OpenCode",
"openclaw" => "OpenClaw",
"hermes" => "Hermes",
@@ -492,6 +493,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
"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"),
"grok" => Some("npm i -g @xai-official/grok@latest"),
"opencode" => Some("npm i -g opencode-ai@latest"),
"openclaw" => Some("npm i -g openclaw@latest"),
_ => None,
@@ -762,6 +764,7 @@ async fn get_single_tool_version_impl(
}
"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,
"grok" => fetch_npm_latest_for_tool(&client, "@xai-official/grok", tool, local).await,
"opencode" => {
if let Some(version) =
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
@@ -1945,6 +1948,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
"claude" => Some("@anthropic-ai/claude-code"),
"codex" => Some("@openai/codex"),
"gemini" => Some("@google/gemini-cli"),
"grok" => Some("@xai-official/grok"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
_ => None,
@@ -2257,7 +2261,8 @@ fn package_manager_anchored_command_from_paths(
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到
/// "那处 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();
@@ -2553,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"grok" => crate::settings::get_grok_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
"openclaw" => crate::settings::get_openclaw_override_dir(),
"hermes" => crate::settings::get_hermes_override_dir(),
@@ -3660,6 +3666,35 @@ mod tests {
assert_eq!(extract_version("no version here"), "no version here");
}
#[test]
fn grok_lifecycle_metadata_is_consistent() {
let requested = vec!["unsupported".to_string(), "grok".to_string()];
assert_eq!(normalize_requested_tools(&requested), vec!["grok"]);
assert_eq!(tool_display_name("grok"), "Grok Build");
assert_eq!(npm_package_for("grok"), Some("@xai-official/grok"));
assert_eq!(
npm_install_command_for("grok"),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(official_update_args("grok"), None);
for shell in [
LifecycleCommandShell::Posix,
LifecycleCommandShell::WindowsBatch,
] {
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
}
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
@@ -3903,6 +3938,18 @@ mod tests {
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn grok_windows_anchors_to_sibling_npm() {
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "grok.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
let expected = format!(
"{} i -g @xai-official/grok@latest",
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 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
@@ -4223,6 +4270,9 @@ mod tests {
let codex =
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
assert_eq!(codex, "npm i -g @openai/codex@latest");
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
assert_eq!(grok, "npm i -g @xai-official/grok@latest");
}
#[test]
@@ -4383,6 +4433,21 @@ mod tests {
);
}
#[test]
fn grok_nvm_anchors_to_npm_without_cli_update() {
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/.nvm/versions/node/v22.14.0/bin/grok",
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@xai-official/grok/bin/grok",
);
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest"
)
);
}
#[test]
fn codex_nvm_anchors_to_that_npm() {
// Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
@@ -4833,6 +4898,12 @@ mod tests {
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
}
#[test]
fn grok_install_keeps_static_npm() {
let cmd = install_command_for("grok");
assert_eq!(cmd, "npm i -g @xai-official/grok@latest");
}
#[test]
fn openclaw_install_keeps_static_npm() {
let cmd = install_command_for("openclaw");
@@ -4855,6 +4926,11 @@ mod tests {
"npm i -g @google/gemini-cli@latest"
);
assert!(!static_fallback_command("gemini").contains("gemini update"));
assert_eq!(
static_fallback_command("grok"),
"npm i -g @xai-official/grok@latest"
);
assert!(!static_fallback_command("grok").contains("grok update"));
assert_eq!(
static_fallback_command("opencode"),
"opencode upgrade || npm i -g opencode-ai@latest"
+1
View File
@@ -23,6 +23,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
if takeover.claude
|| takeover.codex
|| takeover.gemini
|| takeover.grokbuild
|| takeover.opencode
|| takeover.openclaw
{
+8 -5
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,8 +30,9 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let enabled_hermes: bool = row.get(11)?;
let enabled_grokbuild: bool = row.get(10)?;
let enabled_opencode: bool = row.get(11)?;
let enabled_hermes: bool = row.get(12)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -46,6 +47,7 @@ impl Database {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
grokbuild: enabled_grokbuild,
opencode: enabled_opencode,
hermes: enabled_hermes,
},
@@ -72,8 +74,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
server.id,
server.name,
@@ -88,6 +90,7 @@ impl Database {
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.grokbuild,
server.apps.opencode,
server.apps.hermes,
],
+13
View File
@@ -328,6 +328,7 @@ impl Database {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
@@ -398,6 +399,18 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// grokbuild: Responses protocol, same timeout defaults as Codex.
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+21 -18
View File
@@ -22,8 +22,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -43,12 +43,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -67,8 +68,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -87,12 +88,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
});
@@ -109,9 +111,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
skill.id,
skill.name,
@@ -124,6 +126,7 @@ impl Database {
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
skill.installed_at,
@@ -157,8 +160,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
+1 -1
View File
@@ -52,7 +52,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 13;
pub(crate) const SCHEMA_VERSION: i32 = 15;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+238 -4
View File
@@ -65,7 +65,8 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
)",
[],
@@ -93,6 +94,7 @@ impl Database {
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
@@ -122,7 +124,7 @@ impl Database {
// 8. Proxy Config 表(三行结构,app_type 主键)
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -169,6 +171,15 @@ impl Database {
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// 9. Provider Health 表
@@ -485,6 +496,16 @@ impl Database {
Self::migrate_v12_to_v13(conn)?;
Self::set_user_version(conn, 13)?;
}
13 => {
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
Self::migrate_v13_to_v14(conn)?;
Self::set_user_version(conn, 14)?;
}
14 => {
log::info!("迁移数据库从 v14 到 v15Skills/MCP 添加 Grok Build 支持)");
Self::migrate_v14_to_v15(conn)?;
Self::set_user_version(conn, 15)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -798,12 +819,25 @@ impl Database {
old_cb.3,
old_cb.4,
),
(
"grokbuild",
false,
false,
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -814,6 +848,7 @@ impl Database {
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -1354,6 +1389,127 @@ impl Database {
Ok(())
}
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
if !Self::table_exists(conn, "proxy_config")? {
return Ok(());
}
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE TABLE proxy_config_v14 (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721,
enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0,
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let copied_columns = [
("app_type", "'claude'"),
("proxy_enabled", "0"),
("listen_address", "'127.0.0.1'"),
("listen_port", "15721"),
("enable_logging", "1"),
("enabled", "0"),
("auto_failover_enabled", "0"),
("max_retries", "3"),
("streaming_first_byte_timeout", "60"),
("streaming_idle_timeout", "120"),
("non_streaming_timeout", "600"),
("circuit_failure_threshold", "4"),
("circuit_success_threshold", "2"),
("circuit_timeout_seconds", "60"),
("circuit_error_rate_threshold", "0.6"),
("circuit_min_requests", "10"),
("default_cost_multiplier", "'1'"),
("pricing_model_source", "'response'"),
("live_takeover_active", "0"),
("created_at", "datetime('now')"),
("updated_at", "datetime('now')"),
]
.into_iter()
.map(|(column, fallback)| {
Self::has_column(conn, "proxy_config", column).map(|exists| {
if exists {
format!("\"{column}\"")
} else {
fallback.into()
}
})
})
.collect::<Result<Vec<_>, AppError>>()?
.join(", ");
let copy_sql = format!(
"INSERT INTO proxy_config_v14 (
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests,
default_cost_multiplier, pricing_model_source, live_takeover_active,
created_at, updated_at
)
SELECT {copied_columns} FROM proxy_config"
);
conn.execute(&copy_sql, [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("DROP TABLE proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "mcp_servers")? {
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -2766,7 +2922,7 @@ mod tests {
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, 13);
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"proxy_request_logs",
@@ -2787,4 +2943,82 @@ mod tests {
Ok(())
}
#[test]
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
Database::create_tables_on_conn(&conn)?;
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
conn.execute(
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
[],
)?;
Database::set_user_version(&conn, 13)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
let grok_rows: i64 = conn.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
[],
|row| row.get(0),
)?;
assert_eq!(grok_rows, 1);
let codex_values: (i64, i64) = conn.query_row(
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(codex_values, (1, 9));
Ok(())
}
#[test]
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE skills (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);",
)?;
conn.execute(
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
[],
)?;
conn.execute(
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
[],
)?;
Database::set_user_version(&conn, 14)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"mcp_servers",
"enabled_grokbuild"
)?);
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
let mcp_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let skill_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(mcp_values, (1, 0));
assert_eq!(skill_values, (1, 0));
Ok(())
}
}
+2 -2
View File
@@ -517,7 +517,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
@@ -657,7 +657,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
assert_eq!(proxy_rows, 4);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn
+40 -11
View File
@@ -101,17 +101,7 @@ pub fn import_mcp_from_deeplink(
// Server exists - merge apps only, keep other fields unchanged
log::info!("MCP server '{id}' already exists, merging apps only");
let mut merged_apps = existing.apps.clone();
// Merge new apps into existing apps
if target_apps.claude {
merged_apps.claude = true;
}
if target_apps.codex {
merged_apps.codex = true;
}
if target_apps.gemini {
merged_apps.gemini = true;
}
let merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
McpServer {
id: existing.id.clone(),
@@ -166,6 +156,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
};
@@ -175,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"grokbuild" | "grok" => apps.grokbuild = true,
"opencode" => apps.opencode = true,
"openclaw" => {
// OpenClaw doesn't support MCP, ignore silently
@@ -197,3 +189,40 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
Ok(apps)
}
fn merge_mcp_apps(existing: &McpApps, target: &McpApps) -> McpApps {
let mut merged = existing.clone();
for app in target.enabled_apps() {
merged.set_enabled_for(&app, true);
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_apps_merge_covers_every_supported_mcp_client() {
let existing = McpApps {
claude: true,
..McpApps::default()
};
let target = McpApps {
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
hermes: true,
..McpApps::default()
};
let merged = merge_mcp_apps(&existing, &target);
assert!(merged.claude);
assert!(merged.codex);
assert!(merged.gemini);
assert!(merged.grokbuild);
assert!(merged.opencode);
assert!(merged.hermes);
}
}
+13 -6
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -262,10 +262,17 @@ fn parse_mcp_deeplink(
let trimmed = app.trim();
if !matches!(
trimmed,
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude"
| "codex"
| "gemini"
| "grokbuild"
| "grok"
| "opencode"
| "openclaw"
| "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
)));
}
}
+82
View File
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::GrokBuild => build_grokbuild_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
@@ -444,6 +445,36 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env })
}
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let model = request
.model
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
.trim();
let name = request
.name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("custom")
.trim();
let endpoint = get_primary_endpoint(request).trim().to_string();
let api_key = request.api_key.as_deref().unwrap_or("").trim();
let model_value = toml_edit::Value::from(model).to_string();
let name_value = toml_edit::Value::from(name).to_string();
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
let api_key_value = toml_edit::Value::from(api_key).to_string();
json!({
"config": format!(
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
crate::grok_config::DEFAULT_API_BACKEND,
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
)
})
}
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
@@ -600,6 +631,7 @@ pub fn parse_and_merge_config(
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" | "hermes" => {
merge_additive_config(&mut merged, &config_value)?;
@@ -774,6 +806,56 @@ fn merge_gemini_config(
Ok(())
}
fn merge_grokbuild_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
config_toml.to_string()
} else {
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?;
toml::to_string(&toml_value).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?
};
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
})?;
if request
.api_key
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.api_key = model.api_key.or_else(|| {
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
});
}
if request
.endpoint
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.endpoint = Some(model.base_url);
}
if request.model.is_none() {
request.model = Some(model.model);
}
if request
.homepage
.as_ref()
.is_none_or(|value| value.is_empty())
{
if let Some(endpoint) = request.endpoint.as_deref() {
request.homepage = infer_homepage_from_endpoint(endpoint);
}
}
Ok(())
}
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
///
/// These apps use JSON config directly, so we only extract common fields
+95
View File
@@ -87,6 +87,39 @@ fn test_parse_deeplink_with_notes() {
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn test_parse_grokbuild_provider() {
use super::provider::build_provider_from_request;
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.app.as_deref(), Some("grokbuild"));
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
assert_eq!(
request.endpoint.as_deref(),
Some("https://api.example.com/v1")
);
assert_eq!(request.api_key.as_deref(), Some("secret"));
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
let config = provider.settings_config["config"].as_str().unwrap();
let document = config.parse::<toml::Value>().unwrap();
let model = &document["model"]["grok-4.5"];
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
assert_eq!(
model["base_url"].as_str(),
Some("https://api.example.com/v1")
);
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
assert_eq!(model["api_key"].as_str(), Some("secret"));
assert_eq!(model["api_backend"].as_str(), Some("responses"));
assert_eq!(model["context_window"].as_integer(), Some(500_000));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
@@ -500,6 +533,38 @@ experimental_bearer_token = "sk-rightcode"
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
}
#[test]
fn test_parse_and_merge_config_grokbuild() {
let config_toml = r#"[models]
default = "grok-profile"
[model."grok-profile"]
model = "grok-upstream"
base_url = "https://grok.example/v1"
name = "Grok Relay"
api_key = "sk-grok"
api_backend = "responses"
context_window = 500000
"#;
let config_json = serde_json::json!({ "config": config_toml }).to_string();
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Grok Relay".to_string()),
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
}
#[test]
fn test_parse_and_merge_config_url_override() {
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
@@ -709,6 +774,11 @@ fn test_parse_mcp_apps() {
assert!(!apps.codex);
assert!(apps.gemini);
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
assert!(apps.grokbuild);
assert!(apps.opencode);
assert!(apps.hermes);
let err = parse_mcp_apps("invalid").unwrap_err();
assert!(err.to_string().contains("Invalid app"));
}
@@ -731,6 +801,18 @@ fn test_parse_prompt_deeplink() {
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_prompt_deeplink() {
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
let url = format!(
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
assert_eq!(request.app.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
@@ -747,6 +829,19 @@ fn test_parse_mcp_deeplink() {
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
let url = format!(
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
+516
View File
@@ -0,0 +1,516 @@
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use crate::config::{get_home_dir, write_text_file};
use crate::error::AppError;
use crate::provider::Provider;
pub const DEFAULT_MODEL: &str = "grok-4.5";
pub const DEFAULT_API_BACKEND: &str = "responses";
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrokModelConfig {
pub profile: String,
pub model: String,
pub base_url: String,
pub name: String,
pub api_key: Option<String>,
pub env_key: Option<String>,
pub api_backend: String,
pub context_window: i64,
}
/// Grok Build configuration directory (`~/.grok`).
pub fn get_grok_config_dir() -> PathBuf {
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
}
/// Grok Build live configuration path (`~/.grok/config.toml`).
pub fn get_grok_config_path() -> PathBuf {
get_grok_config_dir().join("config.toml")
}
fn required_non_empty_string<'a>(
table: &'a toml::value::Table,
key: &str,
) -> Result<&'a str, AppError> {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.field.missing",
format!("Grok Build 配置缺少有效的 {key} 字段"),
format!("Grok Build configuration is missing a valid {key} field"),
)
})
}
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
/// Validate the provider-owned Grok Build TOML document.
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
let document = config_toml.parse::<toml::Value>().map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})?;
let root = document.as_table().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.not_table",
"Grok Build 配置必须是 TOML 表结构",
"Grok Build configuration must be a TOML table",
)
})?;
let models = root
.get("models")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.models.missing",
"Grok Build 配置缺少 [models]",
"Grok Build configuration is missing [models]",
)
})?;
let default_model = required_non_empty_string(models, "default")?;
let model_entries = root
.get("model")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.model.missing",
"Grok Build 配置缺少 [model.<name>]",
"Grok Build configuration is missing [model.<name>]",
)
})?;
let selected_model = model_entries
.get(default_model)
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
)
})?;
required_non_empty_string(selected_model, "model")?;
required_non_empty_string(selected_model, "base_url")?;
required_non_empty_string(selected_model, "name")?;
if optional_non_empty_string(selected_model, "api_key").is_none()
&& optional_non_empty_string(selected_model, "env_key").is_none()
{
return Err(AppError::localized(
"provider.grokbuild.credentials.missing",
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
"Grok Build configuration is missing a valid api_key or env_key field",
));
}
required_non_empty_string(selected_model, "api_backend")?;
selected_model
.get("context_window")
.and_then(toml::Value::as_integer)
.filter(|value| *value > 0)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.context_window.invalid",
"Grok Build context_window 必须是正整数",
"Grok Build context_window must be a positive integer",
)
})?;
Ok(())
}
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
let document = config_toml.parse::<toml::Value>().ok()?;
let root = document.as_table()?;
let default_model = root
.get("models")?
.as_table()?
.get("default")?
.as_str()?
.trim();
let selected_model = root
.get("model")?
.as_table()?
.get(default_model)?
.as_table()?;
Some(GrokModelConfig {
profile: default_model.to_string(),
model: selected_model.get("model")?.as_str()?.trim().to_string(),
base_url: selected_model
.get("base_url")?
.as_str()?
.trim_end_matches('/')
.to_string(),
name: selected_model.get("name")?.as_str()?.trim().to_string(),
api_key: optional_non_empty_string(selected_model, "api_key"),
env_key: optional_non_empty_string(selected_model, "env_key"),
api_backend: selected_model
.get("api_backend")?
.as_str()?
.trim()
.to_string(),
context_window: selected_model.get("context_window")?.as_integer()?,
})
}
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
let config = extract_model_config(config_toml)?;
let api_key = config
.api_key
.or_else(|| {
config
.env_key
.as_deref()
.and_then(|key| std::env::var(key).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.or_else(|| {
std::env::var("XAI_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})?;
Some((config.base_url, api_key))
}
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
extract_model_config(config_toml)?.api_key
}
pub fn extract_base_url(config_toml: &str) -> Option<String> {
Some(extract_model_config(config_toml)?.base_url)
}
fn update_selected_model_string(
config_toml: &str,
field: &str,
value: &str,
) -> Result<String, AppError> {
let mut document = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})?;
let default_model = document
.get("models")
.and_then(|item| item.get("default"))
.and_then(toml_edit::Item::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
"Grok Build 配置缺少 models.default",
"Grok Build configuration is missing models.default",
)
})?
.to_string();
let selected_model = document
.get_mut("model")
.and_then(|item| item.get_mut(&default_model))
.and_then(toml_edit::Item::as_table_like_mut)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
)
})?;
selected_model.insert(field, toml_edit::value(value));
Ok(document.to_string())
}
pub fn apply_proxy_takeover(
config_toml: &str,
proxy_base_url: &str,
token_placeholder: &str,
) -> Result<String, AppError> {
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
update_selected_model_string(&updated, "api_key", token_placeholder)
}
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
update_selected_model_string(config_toml, "api_key", api_key)
}
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
extract_model_config(config_toml)
.and_then(|config| config.api_key)
.is_some_and(|api_key| api_key == token_placeholder)
}
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
}
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
/// MCP servers are owned by the database and projected into live config.toml.
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(Value::as_str)
.map(str::to_string)
else {
return Ok(());
};
if !config_text.contains("mcp") {
return Ok(());
}
let mut document = config_text
.parse::<toml_edit::DocumentMut>()
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
if let Some(mcp_table) = document
.get_mut("mcp")
.and_then(toml_edit::Item::as_table_like_mut)
{
if mcp_table.remove("servers").is_some() {
changed = true;
}
if mcp_table.is_empty() {
document.as_table_mut().remove("mcp");
}
}
if changed {
if let Some(object) = settings.as_object_mut() {
object.insert("config".to_string(), Value::String(document.to_string()));
}
}
Ok(())
}
pub fn read_grok_live_settings() -> Result<Value, AppError> {
let path = get_grok_config_path();
if !path.exists() {
return Err(AppError::localized(
"grokbuild.config.missing",
"Grok Build 配置文件不存在",
"Grok Build configuration file not found",
));
}
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
validate_config_toml(&config)?;
Ok(json!({ "config": config }))
}
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.settings.not_object",
"Grok Build 配置必须是 JSON 对象",
"Grok Build configuration must be a JSON object",
)
})?;
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
write_grok_live_settings(&json!({ "config": config }))
}
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
validate_config_toml(config)?;
write_text_file(&get_grok_config_path(), config)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
fn valid_config() -> &'static str {
r#"[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example"
api_key = "secret"
api_backend = "responses"
context_window = 500000
"#
}
fn valid_env_key_config() -> &'static str {
r#"[models]
default = "grok-env"
[model."grok-env"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example Env"
env_key = "GROK_TEST_API_KEY"
api_backend = "responses"
context_window = 500000
"#
}
#[test]
fn validates_expected_config_shape() {
validate_config_toml(valid_config()).expect("valid Grok Build config");
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
}
#[test]
fn rejects_missing_selected_model_table() {
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
.expect_err("missing model table should fail");
assert!(error.to_string().contains("model"));
}
#[test]
fn rejects_config_without_api_key_or_env_key() {
let config = valid_config().replace("api_key = \"secret\"\n", "");
let error = validate_config_toml(&config).expect_err("credentials should be required");
assert!(error.to_string().contains("api_key"));
assert!(error.to_string().contains("env_key"));
}
#[test]
fn extracts_selected_model_and_updates_takeover_fields() {
let selected = extract_model_config(valid_config()).expect("selected model");
assert_eq!(selected.profile, "grok-4.5");
assert_eq!(selected.model, "grok-4.5");
assert_eq!(selected.base_url, "https://example.com/v1");
let updated = apply_proxy_takeover(
valid_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
.expect("takeover config");
let selected = extract_model_config(&updated).expect("updated selected model");
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
}
#[test]
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
let updated = apply_proxy_takeover(
valid_env_key_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
.expect("takeover config");
let selected = extract_model_config(&updated).expect("updated selected model");
assert_eq!(selected.profile, "grok-env");
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
}
#[test]
#[serial]
fn resolves_api_key_from_configured_environment_variable() {
let original = std::env::var_os("GROK_TEST_API_KEY");
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
assert_eq!(credentials.0, "https://example.com/v1");
assert_eq!(credentials.1, "env-secret");
match original {
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
None => std::env::remove_var("GROK_TEST_API_KEY"),
}
}
#[test]
fn strips_projected_mcp_servers_without_touching_model_config() {
let mut settings = json!({
"config": format!(
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
valid_config()
)
});
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
let config = settings.get("config").and_then(Value::as_str).unwrap();
assert!(!config.contains("mcp_servers"));
assert!(config.contains("model = \"grok-4.5\""));
validate_config_toml(config).expect("stripped config remains valid");
}
#[test]
#[serial]
fn writes_and_reads_live_config() {
let temp = TempDir::new().expect("temp dir");
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let provider = Provider::with_id(
"grok".to_string(),
"Example".to_string(),
json!({ "config": valid_config() }),
None,
);
write_grok_provider_live(&provider).expect("write live config");
let path = get_grok_config_path();
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
assert_eq!(
fs::read_to_string(path).expect("read config"),
valid_config()
);
assert_eq!(
read_grok_live_settings()
.expect("read live settings")
.get("config")
.and_then(Value::as_str),
Some(valid_config())
);
match original_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
+51 -13
View File
@@ -14,6 +14,7 @@ mod deeplink;
mod error;
mod gemini_config;
mod gemini_mcp;
mod grok_config;
pub mod hermes_config;
mod init_status;
mod lightweight;
@@ -47,10 +48,11 @@ pub use database::{Database, Profile};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
remove_server_from_grokbuild, sync_enabled_to_claude, sync_enabled_to_codex,
sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex,
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
};
pub use prompt::Prompt;
pub use provider::{Provider, ProviderMeta};
@@ -783,6 +785,14 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_grokbuild(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Grok Build");
}
Ok(_) => log::debug!("○ No Grok Build MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Grok Build MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
@@ -808,6 +818,7 @@ pub fn run() {
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
crate::app_config::AppType::GrokBuild,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
@@ -1740,16 +1751,25 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
let mut apps = Vec::new();
for app_type in PROXY_STARTUP_APP_TYPES {
if db
.get_proxy_config_for_app(app_type)
.await
.is_ok_and(|config| config.enabled)
{
apps.push(app_type);
}
}
apps
}
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
@@ -2067,7 +2087,8 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{classify_exit_request, ExitRequestAction};
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
use crate::database::Database;
#[test]
fn no_code_keeps_app_alive_in_tray() {
@@ -2093,4 +2114,21 @@ mod tests {
ExitRequestAction::CleanupAndExit
);
}
#[tokio::test]
async fn startup_restore_includes_enabled_grokbuild_route() {
let db = Database::memory().expect("initialize database");
let mut config = db
.get_proxy_config_for_app("grokbuild")
.await
.expect("read Grok Build proxy config");
config.enabled = true;
db.update_proxy_config_for_app(config)
.await
.expect("enable Grok Build proxy config");
let apps = enabled_proxy_apps_on_startup(&db).await;
assert_eq!(apps, vec!["grokbuild"]);
}
}
+1
View File
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+2 -1
View File
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -556,7 +557,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
/// 3. 其他未知字段使用通用转换器尝试转换
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
use toml_edit::{Array, Item, Table};
let mut t = Table::new();
+1
View File
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: true,
grokbuild: false,
opencode: false,
hermes: false,
},
+221
View File
@@ -0,0 +1,221 @@
//! Grok Build MCP synchronization and import.
//!
//! Grok Build uses the same top-level `[mcp_servers]` TOML layout as Codex,
//! stored alongside its model configuration in `~/.grok/config.toml`.
use serde_json::{json, Value};
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::codex::json_server_to_toml_table;
use super::validation::validate_server_spec;
fn should_sync_grokbuild_mcp() -> bool {
crate::grok_config::get_grok_config_dir().exists()
}
fn read_config_text() -> Result<String, AppError> {
let path = crate::grok_config::get_grok_config_path();
if !path.exists() {
return Ok(String::new());
}
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
}
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
let mut table = json_server_to_toml_table(server_spec)?;
// Grok infers transport from `command` or `url` and uses `headers`, while
// Codex writes an explicit `type` plus `http_headers`.
table.remove("type");
if let Some(headers) = table.remove("http_headers") {
table.insert("headers", headers);
}
Ok(table)
}
fn toml_server_to_json(entry: &toml::value::Table) -> Value {
fn convert(value: &toml::Value) -> Option<Value> {
match value {
toml::Value::String(value) => Some(json!(value)),
toml::Value::Integer(value) => Some(json!(value)),
toml::Value::Float(value) => Some(json!(value)),
toml::Value::Boolean(value) => Some(json!(value)),
toml::Value::Datetime(value) => Some(json!(value.to_string())),
toml::Value::Array(values) => Some(Value::Array(
values.iter().filter_map(convert).collect::<Vec<_>>(),
)),
toml::Value::Table(values) => Some(Value::Object(
values
.iter()
.filter_map(|(key, value)| convert(value).map(|value| (key.clone(), value)))
.collect(),
)),
}
}
let mut spec = serde_json::Map::new();
for (key, value) in entry {
let output_key = if key == "http_headers" {
"headers"
} else {
key
};
if let Some(value) = convert(value) {
spec.insert(output_key.to_string(), value);
}
}
let default_type = if spec.contains_key("url") {
"http"
} else {
"stdio"
};
spec.entry("type".to_string())
.or_insert_with(|| json!(default_type));
Value::Object(spec)
}
pub fn import_from_grokbuild(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let text = read_config_text()?;
if text.trim().is_empty() {
return Ok(0);
}
let root: toml::Table = toml::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.grok/config.toml 失败: {e}")))?;
let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else {
return Ok(0);
};
let servers = config
.mcp
.servers
.get_or_insert_with(std::collections::HashMap::new);
let mut changed = 0;
for (id, entry) in entries {
let Some(entry) = entry.as_table() else {
continue;
};
let spec = toml_server_to_json(entry);
if let Err(error) = validate_server_spec(&spec) {
log::warn!("跳过无效 Grok Build MCP 项 '{id}': {error}");
continue;
}
if let Some(existing) = servers.get_mut(id) {
if !existing.apps.grokbuild {
existing.apps.grokbuild = true;
changed += 1;
}
} else {
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec,
apps: McpApps {
grokbuild: true,
..Default::default()
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
}
}
Ok(changed)
}
pub fn sync_single_server_to_grokbuild(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_grokbuild_mcp() {
return Ok(());
}
use toml_edit::Item;
let path = crate::grok_config::get_grok_config_path();
let text = read_config_text()?;
let mut doc = if text.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
text.parse::<toml_edit::DocumentMut>().map_err(|e| {
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
})?
};
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
crate::config::write_text_file(&path, &doc.to_string())
}
pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
if !should_sync_grokbuild_mcp() {
return Ok(());
}
let path = crate::grok_config::get_grok_config_path();
if !path.exists() {
return Ok(());
}
let text = read_config_text()?;
let mut doc = match text.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(error) => {
log::warn!("解析 Grok Build config.toml 失败: {error},跳过删除操作");
return Ok(());
}
};
if let Some(servers) = doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_mut)
{
servers.remove(id);
}
crate::config::write_text_file(&path, &doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_http_headers_to_unified_headers() {
let entry: toml::value::Table = toml::from_str(
r#"type = "http"
url = "https://example.com/mcp"
http_headers = { Authorization = "Bearer token" }
"#,
)
.expect("parse table");
let spec = toml_server_to_json(&entry);
assert_eq!(spec["type"], "http");
assert_eq!(spec["headers"]["Authorization"], "Bearer token");
}
#[test]
fn writes_grokbuild_remote_server_without_codex_only_fields() {
let table = json_server_to_grokbuild_toml_table(&json!({
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer token" }
}))
.expect("convert server");
assert!(!table.contains_key("type"));
assert!(!table.contains_key("http_headers"));
assert_eq!(
table
.get("headers")
.and_then(toml_edit::Item::as_table)
.and_then(|headers| headers.get("Authorization"))
.and_then(toml_edit::Item::as_str),
Some("Bearer token")
);
}
}
+1
View File
@@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: true,
},
+4
View File
@@ -14,6 +14,7 @@
mod claude;
mod codex;
mod gemini;
mod grokbuild;
mod hermes;
mod opencode;
mod validation;
@@ -30,6 +31,9 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use grokbuild::{
import_from_grokbuild, remove_server_from_grokbuild, sync_single_server_to_grokbuild,
};
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
+1
View File
@@ -258,6 +258,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: true,
hermes: false,
},
+5 -4
View File
@@ -12,9 +12,9 @@ use crate::opencode_config::get_opencode_dir;
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
if matches!(app, AppType::ClaudeDesktop) {
return Err(AppError::localized(
"claude_desktop.prompts_unsupported",
"Claude Desktop 暂不支持 Prompts",
"Claude Desktop does not support Prompts",
"app.prompts_unsupported",
"当前应用暂不支持 Prompts",
"This app does not support Prompts",
));
}
@@ -22,6 +22,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
@@ -32,7 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
+5
View File
@@ -164,6 +164,11 @@ impl Provider {
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
(base_url, api_key)
}
AppType::GrokBuild => settings
.get("config")
.and_then(Value::as_str)
.and_then(crate::grok_config::extract_credentials)
.unwrap_or_default(),
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
AppType::Hermes => (
str_at(settings.get("base_url")),
+10 -3
View File
@@ -1142,9 +1142,9 @@ impl RequestForwarder {
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
// below must be skipped on the Anthropic path (the marker has to survive to
// catalog matching and to the transform's own strip+beta detection).
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
let codex_responses_to_chat = matches!(app_type, AppType::Codex | AppType::GrokBuild)
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
&& super::providers::is_codex_official_provider(provider);
@@ -1168,6 +1168,13 @@ impl RequestForwarder {
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mut mapped_body = normalize_thinking_type(mapped_body);
// Grok Build exposes a stable client-side model profile in config.toml.
// Route requests to the provider's real upstream model before applying
// the optional Responses -> Chat/Anthropic bridge.
if matches!(app_type, AppType::GrokBuild) {
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
}
if is_copilot {
mapped_body =
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
@@ -1511,7 +1518,7 @@ impl RequestForwarder {
mapped_body
};
if matches!(app_type, AppType::Codex) {
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
self.apply_media_prevention(&mut request_body, provider);
}
+52 -4
View File
@@ -758,6 +758,30 @@ pub async fn handle_chat_completions(
pub async fn handle_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -774,7 +798,7 @@ pub async fn handle_responses(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body
@@ -786,7 +810,7 @@ pub async fn handle_responses(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
@@ -849,6 +873,30 @@ pub async fn handle_responses(
pub async fn handle_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_compact_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -865,7 +913,7 @@ pub async fn handle_responses_compact(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body
@@ -877,7 +925,7 @@ pub async fn handle_responses_compact(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
+42 -1
View File
@@ -250,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_model_from_toml)
.and_then(|config| {
crate::grok_config::extract_model_config(config)
.map(|model| model.model)
.or_else(|| extract_codex_model_from_toml(config))
})
})
}
@@ -621,6 +625,9 @@ impl CodexAdapter {
}
if let Some(config_str) = config.as_str() {
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
return Some(key);
}
if let Some(key) =
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
{
@@ -675,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
@@ -796,6 +806,37 @@ mod tests {
}
}
#[test]
fn grok_build_toml_exposes_upstream_credentials_and_model() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"config": r#"
[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "upstream-grok-model"
base_url = "https://relay.example.com/v1/"
name = "Example Relay"
api_key = "grok-secret"
api_backend = "responses"
context_window = 500000
"#
}));
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
"https://relay.example.com/v1"
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "grok-secret");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
assert_eq!(
codex_provider_upstream_model(&provider).as_deref(),
Some("upstream-grok-model")
);
}
#[test]
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
+4 -8
View File
@@ -192,10 +192,8 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
AppType::GrokBuild => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
}
}
@@ -246,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::GrokBuild => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
}
}
+10
View File
@@ -327,6 +327,12 @@ impl ProxyServer {
.route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses))
.route("/codex/v1/responses", post(handlers::handle_responses))
// Grok Build uses the Responses protocol but has an independent
// provider namespace and failover queue.
.route(
"/grokbuild/v1/responses",
post(handlers::handle_grokbuild_responses),
)
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
.route(
"/responses/compact",
@@ -344,6 +350,10 @@ impl ProxyServer {
"/codex/v1/responses/compact",
post(handlers::handle_responses_compact),
)
.route(
"/grokbuild/v1/responses/compact",
post(handlers::handle_grokbuild_responses_compact),
)
// Gemini API (支持带前缀和不带前缀)
//
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
+1
View File
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub grokbuild: bool,
pub opencode: bool,
pub openclaw: bool,
}
+20 -1
View File
@@ -59,7 +59,7 @@ impl CostCalculator {
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
Self::calculate_with_cache_semantics(
usage,
pricing,
@@ -211,6 +211,25 @@ mod tests {
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
fn grokbuild_does_not_double_bill_cached_input() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 0,
cache_read_tokens: 600,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
}
#[test]
fn test_cost_multiplier() {
let usage = TokenUsage {
+41 -5
View File
@@ -71,11 +71,12 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
let input_token_semantics =
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
@@ -459,4 +460,39 @@ mod tests {
assert_eq!(error, Some("Internal Server Error".to_string()));
Ok(())
}
#[test]
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
let logger = UsageLogger::new(&db);
let log = RequestLog {
request_id: "grok-semantics".to_string(),
provider_id: "grok-provider".to_string(),
app_type: "grokbuild".to_string(),
model: "grok-4.5".to_string(),
request_model: "grok-4.5".to_string(),
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms: 1,
first_token_ms: None,
status_code: 200,
error_message: None,
session_id: None,
provider_type: Some("grokbuild".to_string()),
is_streaming: false,
cost_multiplier: "1".to_string(),
};
logger.log_request(&log)?;
let conn = crate::database::lock_conn!(db.conn);
let semantics: i64 = conn.query_row(
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
[],
|row| row.get(0),
)?;
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
Ok(())
}
}
+2
View File
@@ -88,6 +88,7 @@ impl ConfigService {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Self::sync_current_provider_for_app(config, &AppType::GrokBuild)?;
Ok(())
}
@@ -125,6 +126,7 @@ impl ConfigService {
// Claude Desktop 3P profiles are managed by claude_desktop_config.
}
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::GrokBuild => crate::grok_config::write_grok_provider_live(&provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
+39 -1
View File
@@ -37,6 +37,9 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
if prev_apps.grokbuild && !server.apps.grokbuild {
Self::remove_server_from_app(state, &server.id, &AppType::GrokBuild)?;
}
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
@@ -122,6 +125,13 @@ impl McpService {
AppType::Gemini => {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
AppType::GrokBuild => {
mcp::sync_single_server_to_grokbuild(
&Default::default(),
&server.id,
&server.server,
)?;
}
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(
&Default::default(),
@@ -162,6 +172,7 @@ impl McpService {
}
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::GrokBuild => mcp::remove_server_from_grokbuild(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
@@ -393,6 +404,32 @@ impl McpService {
Ok(new_count)
}
/// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。
pub fn import_from_grokbuild(state: &AppState) -> Result<usize, AppError> {
let mut temp_config = crate::app_config::MultiAppConfig::default();
let count = crate::mcp::import_from_grokbuild(&mut temp_config)?;
let mut new_count = 0;
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.grokbuild = true;
merged
} else {
new_count += 1;
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save);
}
}
}
Ok(new_count)
}
/// 从 OpenCode 导入 MCPv3.9.2+ 新增)
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
@@ -479,10 +516,11 @@ impl McpService {
let mut total = 0;
let mut failures: Vec<String> = Vec::new();
let results: [(&str, Result<usize, AppError>); 5] = [
let results: [(&str, Result<usize, AppError>); 6] = [
("claude", Self::import_from_claude(state)),
("codex", Self::import_from_codex(state)),
("gemini", Self::import_from_gemini(state)),
("grokbuild", Self::import_from_grokbuild(state)),
("opencode", Self::import_from_opencode(state)),
("hermes", Self::import_from_hermes(state)),
];
+62 -7
View File
@@ -523,7 +523,11 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
}
_ => false,
},
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::ClaudeDesktop => false,
}
}
@@ -593,9 +597,11 @@ pub(crate) fn remove_common_config_from_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -650,9 +656,11 @@ fn apply_common_config_to_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -825,6 +833,16 @@ fn restore_live_settings_for_provider_backfill(
strip_injected_kimi_for_coding_context_defaults(&mut settings, provider);
return settings;
}
if matches!(app_type, AppType::GrokBuild) {
let mut settings = live_settings;
if let Err(err) = crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings) {
log::warn!(
"Failed to strip Grok Build mcp_servers while backfilling '{}': {err}",
provider.id
);
}
return settings;
}
if !matches!(app_type, AppType::Codex) {
return live_settings;
}
@@ -1037,6 +1055,9 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
}
AppType::GrokBuild => {
crate::grok_config::write_grok_provider_live(provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
@@ -1367,6 +1388,7 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = read_opencode_config()?;
Ok(config)
}
AppType::GrokBuild => crate::grok_config::read_grok_live_settings(),
AppType::OpenClaw => {
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
@@ -1435,6 +1457,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
let settings_config = match app_type {
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::GrokBuild => {
let mut settings = crate::grok_config::read_grok_live_settings()?;
crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?;
settings
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
@@ -2536,4 +2563,32 @@ base_url = "https://a.example/v1"
"non-MCP content must survive the strip"
);
}
#[test]
fn grok_switch_backfill_strips_synced_mcp_servers() {
let provider = Provider::with_id(
"grok".to_string(),
"Grok".to_string(),
json!({
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n"
}),
None,
);
let live_settings = json!({
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n\n[mcp_servers.echo]\ncommand = \"echo\"\n"
});
let result = restore_live_settings_for_provider_backfill(
&AppType::GrokBuild,
&provider,
live_settings,
);
let config_text = result
.get("config")
.and_then(Value::as_str)
.expect("config text");
assert!(!config_text.contains("mcp_servers"));
assert!(config_text.contains("model = \"grok-4.5\""));
}
}
+54 -8
View File
@@ -2527,14 +2527,16 @@ impl ProviderService {
// restore backup. Serialize them per app, then decide from the locked
// current state so a just-started takeover cannot be overwritten by a
// normal live write.
let _switch_guard =
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
Some(futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
))
} else {
None
};
let _switch_guard = if matches!(
app_type,
AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild
) {
Some(futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
))
} else {
None
};
// Backup or live placeholders mean the live file is owned by proxy
// takeover, even if the proxy server is temporarily stopped or is in the
@@ -2993,6 +2995,7 @@ impl ProviderService {
AppType::ClaudeDesktop => Ok(String::new()),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::GrokBuild => Ok(String::new()),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
@@ -3009,6 +3012,7 @@ impl ProviderService {
AppType::ClaudeDesktop => Ok(String::new()),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::GrokBuild => Ok(String::new()),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
@@ -3479,6 +3483,26 @@ impl ProviderService {
use crate::gemini_config::validate_gemini_settings;
validate_gemini_settings(&provider.settings_config)?
}
AppType::GrokBuild => {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.settings.not_object",
"Grok Build 配置必须是 JSON 对象",
"Grok Build configuration must be a JSON object",
)
})?;
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
crate::grok_config::validate_config_toml(config)?;
}
AppType::OpenCode => {
// OpenCode uses a different config structure: { npm, options, models }
// Basic validation - must be an object
@@ -3575,6 +3599,28 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::GrokBuild => {
let config_toml = provider
.settings_config
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.credentials.missing",
"Grok Build 配置缺少 Base URL 或 API Key",
"Grok Build configuration is missing the base URL or API key",
)
})?;
Ok((api_key, base_url))
}
AppType::ClaudeDesktop => {
let credentials =
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -511,6 +511,11 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::GrokBuild => {
if let Some(custom) = crate::settings::get_grok_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
@@ -538,6 +543,7 @@ impl SkillService {
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::GrokBuild => home.join(".grok").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
+11 -3
View File
@@ -16,7 +16,7 @@
/// style provider not added here) shows up loudly as a too-low cache hit
/// rate, which is easier to catch than the silent over-deduction that
/// would happen with the opposite default.
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
@@ -93,6 +93,7 @@ mod tests {
assert!(!sql.contains("."));
assert!(sql.contains("'codex'"));
assert!(sql.contains("'gemini'"));
assert!(sql.contains("'grokbuild'"));
}
#[test]
@@ -112,6 +113,13 @@ mod tests {
[],
)
.unwrap();
// Grok Build uses OpenAI Responses semantics too.
conn.execute(
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
VALUES ('grok-1', 'grokbuild', 700, 250)",
[],
)
.unwrap();
// Claude row: Anthropic semantics — input_tokens already excludes cache.
conn.execute(
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
@@ -123,8 +131,8 @@ mod tests {
let expr = fresh_input_sql("l");
let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l");
let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
assert_eq!(total, 400 + 500 + 200);
// Codex: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged.
assert_eq!(total, 400 + 500 + 450 + 200);
}
#[test]
+10 -2
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -56,13 +56,14 @@ pub struct DeleteSessionOutcome {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| {
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions);
let h2 = s.spawn(claude::scan_sessions);
let h3 = s.spawn(opencode::scan_sessions);
let h4 = s.spawn(openclaw::scan_sessions);
let h5 = s.spawn(gemini::scan_sessions);
let h6 = s.spawn(hermes::scan_sessions);
let h7 = s.spawn(grokbuild::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
@@ -70,6 +71,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
h4.join().unwrap_or_default(),
h5.join().unwrap_or_default(),
h6.join().unwrap_or_default(),
h7.join().unwrap_or_default(),
)
});
@@ -80,6 +82,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions.extend(r4);
sessions.extend(r5);
sessions.extend(r6);
sessions.extend(r7);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -106,6 +109,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
"opencode" => opencode::load_messages(path),
"openclaw" => openclaw::load_messages(path),
"gemini" => gemini::load_messages(path),
"grokbuild" => grokbuild::load_messages(path),
"hermes" => hermes::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
@@ -165,6 +169,9 @@ fn delete_session_with_roots(
openclaw::delete_session(&validated_root, &validated_source, session_id)
}
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
"grokbuild" => {
grokbuild::delete_session(&validated_root, &validated_source, session_id)
}
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
_ => Err(format!("Unsupported provider: {provider_id}")),
};
@@ -194,6 +201,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
"opencode" => vec![opencode::get_opencode_data_dir()],
"openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")],
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
"grokbuild" => grokbuild::session_roots(),
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
@@ -0,0 +1,296 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS};
#[derive(Debug, Deserialize)]
struct GrokSessionInfo {
id: String,
#[serde(default)]
cwd: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GrokSessionSummary {
info: GrokSessionInfo,
#[serde(default)]
session_summary: Option<String>,
#[serde(default)]
generated_title: Option<String>,
#[serde(default)]
created_at: Option<Value>,
#[serde(default)]
updated_at: Option<Value>,
#[serde(default)]
last_active_at: Option<Value>,
}
pub fn session_roots() -> Vec<PathBuf> {
let config_dir = crate::grok_config::get_grok_config_dir();
vec![
config_dir.join("sessions"),
config_dir.join("archived_sessions"),
]
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let mut summaries = Vec::new();
for root in session_roots() {
collect_summary_files(&root, &mut summaries);
}
summaries
.into_iter()
.filter_map(|path| parse_summary(&path))
.collect()
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let session_dir = path
.parent()
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
let chat_path = session_dir.join("chat_history.jsonl");
let file = File::open(&chat_path)
.map_err(|e| format!("Failed to open Grok Build chat history: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
let role = match kind {
"system" | "user" | "assistant" | "tool" => kind,
// Reasoning records can contain encrypted/internal state and are not
// conversation messages shown by Grok's own history view.
_ => continue,
};
let content = value.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value
.get("timestamp")
.or_else(|| value.get("ts"))
.and_then(parse_timestamp_to_ms);
messages.push(SessionMessage {
role: role.to_string(),
content,
ts,
});
}
Ok(messages)
}
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if !path.starts_with(root) {
return Err(format!(
"Grok Build session source is outside the session root: {}",
path.display()
));
}
if path.file_name().and_then(|name| name.to_str()) != Some("summary.json") {
return Err(format!(
"Unexpected Grok Build session source: {}",
path.display()
));
}
let summary = read_summary(path)?;
if summary.info.id != session_id {
return Err(format!(
"Grok Build session ID mismatch: expected {session_id}, found {}",
summary.info.id
));
}
let session_dir = path
.parent()
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
if session_dir == root || !session_dir.starts_with(root) {
return Err(format!(
"Refusing to delete Grok Build session directory outside its root: {}",
session_dir.display()
));
}
if session_dir.file_name().and_then(|name| name.to_str()) != Some(session_id) {
return Err(format!(
"Grok Build session directory does not match session ID: {}",
session_dir.display()
));
}
std::fs::remove_dir_all(session_dir).map_err(|e| {
format!(
"Failed to delete Grok Build session directory {}: {e}",
session_dir.display()
)
})?;
Ok(true)
}
fn collect_summary_files(root: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_summary_files(&path, files);
} else if path.file_name().and_then(|name| name.to_str()) == Some("summary.json") {
files.push(path);
}
}
}
fn read_summary(path: &Path) -> Result<GrokSessionSummary, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read Grok Build session summary: {e}"))?;
serde_json::from_str(&text)
.map_err(|e| format!("Failed to parse Grok Build session summary: {e}"))
}
fn parse_summary(path: &Path) -> Option<SessionMeta> {
let summary = read_summary(path).ok()?;
let session_id = summary.info.id;
let title = summary
.generated_title
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
summary
.session_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
})
.map(|value| truncate_summary(value, TITLE_MAX_CHARS));
let session_summary = summary
.session_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| truncate_summary(value, 160));
let created_at = summary.created_at.as_ref().and_then(parse_timestamp_to_ms);
let last_active_at = summary
.last_active_at
.as_ref()
.or(summary.updated_at.as_ref())
.and_then(parse_timestamp_to_ms);
Some(SessionMeta {
provider_id: "grokbuild".to_string(),
session_id: session_id.clone(),
title,
summary: session_summary,
project_dir: summary.info.cwd,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: Some(format!("grok --resume {session_id}")),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scans_native_grokbuild_session_layout() {
let temp = tempdir().expect("tempdir");
let sessions_dir = temp.path().join("sessions");
let session_id = "019f6af2-18b0-7673-958e-d25be650e172";
let session_dir = sessions_dir.join("encoded-project").join(session_id);
std::fs::create_dir_all(&session_dir).expect("create session dir");
std::fs::write(
session_dir.join("summary.json"),
format!(
r#"{{"info":{{"id":"{session_id}","cwd":"C:/work"}},"session_summary":"hello grok","generated_title":"Grok session","created_at":"2026-07-16T12:00:00Z","last_active_at":"2026-07-16T12:00:01Z"}}"#
),
)
.expect("write summary");
let mut files = Vec::new();
collect_summary_files(&sessions_dir, &mut files);
let sessions = files
.iter()
.filter_map(|path| parse_summary(path))
.collect::<Vec<_>>();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].provider_id, "grokbuild");
assert_eq!(sessions[0].session_id, session_id);
assert_eq!(sessions[0].title.as_deref(), Some("Grok session"));
let expected_resume = format!("grok --resume {session_id}");
assert_eq!(
sessions[0].resume_command.as_deref(),
Some(expected_resume.as_str())
);
}
#[test]
fn loads_native_grokbuild_chat_history() {
let temp = tempdir().expect("tempdir");
let summary_path = temp.path().join("summary.json");
std::fs::write(&summary_path, "{}").expect("write summary placeholder");
std::fs::write(
temp.path().join("chat_history.jsonl"),
concat!(
"{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}\n",
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"private\"}]}\n",
"{\"type\":\"assistant\",\"content\":\"Hi there\"}\n"
),
)
.expect("write chat history");
let messages = load_messages(&summary_path).expect("load messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].content, "Hi there");
}
#[test]
fn delete_session_removes_only_the_matching_session_directory() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("sessions");
let session_id = "session-to-delete";
let session_dir = root.join("project").join(session_id);
let sibling_dir = root.join("project").join("session-to-keep");
std::fs::create_dir_all(&session_dir).expect("create session directory");
std::fs::create_dir_all(&sibling_dir).expect("create sibling directory");
let summary_path = session_dir.join("summary.json");
std::fs::write(
&summary_path,
format!(r#"{{"info":{{"id":"{session_id}"}}}}"#),
)
.expect("write summary");
std::fs::write(sibling_dir.join("keep.txt"), "keep").expect("write sibling file");
let deleted = delete_session(&root, &summary_path, session_id).expect("delete session");
assert!(deleted);
assert!(!session_dir.exists());
assert!(sibling_dir.exists());
}
#[test]
fn delete_session_rejects_remove_dir_all_target_outside_root() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("sessions");
let outside_dir = temp.path().join("outside").join("session-outside");
std::fs::create_dir_all(&root).expect("create root");
std::fs::create_dir_all(&outside_dir).expect("create outside directory");
let summary_path = outside_dir.join("summary.json");
std::fs::write(&summary_path, r#"{"info":{"id":"session-outside"}}"#)
.expect("write summary");
let error = delete_session(&root, &summary_path, "session-outside")
.expect_err("outside path must be rejected");
assert!(error.contains("outside the session root"));
assert!(outside_dir.exists());
}
}
@@ -1,6 +1,7 @@
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod grokbuild;
pub mod hermes;
pub mod openclaw;
pub mod opencode;
+28
View File
@@ -39,6 +39,8 @@ pub struct VisibleApps {
#[serde(default = "default_true")]
pub gemini: bool,
#[serde(default = "default_true")]
pub grokbuild: bool,
#[serde(default = "default_true")]
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
@@ -53,6 +55,7 @@ impl Default for VisibleApps {
claude_desktop: true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: false, // 默认不显示,需用户手动启用
@@ -68,6 +71,7 @@ impl VisibleApps {
AppType::ClaudeDesktop => self.claude_desktop,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
AppType::Hermes => self.hermes,
@@ -411,6 +415,8 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grok_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw_config_dir: Option<String>,
@@ -430,6 +436,9 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
/// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_grokbuild: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
@@ -520,6 +529,7 @@ impl Default for AppSettings {
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
grok_config_dir: None,
opencode_config_dir: None,
openclaw_config_dir: None,
hermes_config_dir: None,
@@ -527,6 +537,7 @@ impl Default for AppSettings {
current_provider_claude_desktop: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_grokbuild: None,
current_provider_opencode: None,
current_provider_openclaw: None,
current_provider_hermes: None,
@@ -575,6 +586,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.grok_config_dir = self
.grok_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.opencode_config_dir = self
.opencode_config_dir
.as_ref()
@@ -883,6 +901,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_grok_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.grok_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_opencode_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -940,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::GrokBuild => settings.current_provider_grokbuild.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
AppType::Hermes => settings.current_provider_hermes.clone(),
@@ -957,6 +984,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(),
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
AppType::GrokBuild => settings.current_provider_grokbuild = id_owned.clone(),
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
+22 -2
View File
@@ -118,7 +118,7 @@ pub struct TrayAppSection {
pub const AUTO_SUFFIX: &str = "auto";
pub const TRAY_ID: &str = "cc-switch";
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
@@ -140,6 +140,13 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
header_label: "Gemini",
log_name: "Gemini",
},
TrayAppSection {
app_type: AppType::GrokBuild,
prefix: "grokbuild_",
empty_id: "grokbuild_empty",
header_label: "Grok Build",
log_name: "Grok Build",
},
];
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
@@ -1072,7 +1079,8 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
#[cfg(test)]
mod tests {
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
use super::{format_script_summary, format_subscription_summary, TRAY_ID, TRAY_SECTIONS};
use crate::app_config::AppType;
use crate::provider::{UsageData, UsageResult};
use crate::services::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
@@ -1086,6 +1094,18 @@ mod tests {
assert_ne!(TRAY_ID, "main");
}
#[test]
fn tray_sections_include_grokbuild_provider_switching() {
let section = TRAY_SECTIONS
.iter()
.find(|section| section.app_type == AppType::GrokBuild)
.expect("Grok Build tray section should exist");
assert_eq!(section.prefix, "grokbuild_");
assert_eq!(section.empty_id, "grokbuild_empty");
assert_eq!(section.header_label, "Grok Build");
}
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
SubscriptionQuota {
tool: tool.to_string(),
+8
View File
@@ -6,6 +6,14 @@ use cc_switch_lib::AppType;
fn parse_known_apps_case_insensitive_and_trim() {
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
assert!(matches!(
AppType::from_str("grokbuild"),
Ok(AppType::GrokBuild)
));
assert!(matches!(
AppType::from_str("Grok-Build"),
Ok(AppType::GrokBuild)
));
assert!(matches!(
AppType::from_str(" ClAuDe \n"),
Ok(AppType::Claude)
+2
View File
@@ -748,6 +748,7 @@ command = "echo"
claude: false,
codex: false, // 初始未启用
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -877,6 +878,7 @@ fn import_from_claude_merges_into_config() {
claude: false, // 初始未启用
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+12
View File
@@ -227,6 +227,7 @@ command = "echo"
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -371,6 +372,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
claude: false,
codex: false, // 初始未启用
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -436,6 +438,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -481,6 +484,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -515,6 +519,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -648,6 +653,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -703,6 +709,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -758,6 +765,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -814,6 +822,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -893,6 +902,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() {
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1032,6 +1042,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1054,6 +1065,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+76
View File
@@ -19,6 +19,81 @@ fn settings_path(home: &Path) -> PathBuf {
home.join(".cc-switch").join("settings.json")
}
fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String {
format!(
r#"[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "{endpoint}"
name = "{name}"
api_key = "{api_key}"
api_backend = "responses"
context_window = 500000
"#
)
}
#[test]
fn grokbuild_import_and_switch_write_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let live_path = home.join(".grok").join("config.toml");
std::fs::create_dir_all(live_path.parent().expect("grok config dir"))
.expect("create grok config dir");
let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key");
std::fs::write(&live_path, &imported_config).expect("seed Grok Build config");
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::GrokBuild)
.expect("import Grok Build default provider");
let imported = state
.db
.get_provider_by_id("default", AppType::GrokBuild.as_str())
.expect("query imported provider")
.expect("imported provider exists");
assert_eq!(
imported
.settings_config
.get("config")
.and_then(|value| value.as_str()),
Some(imported_config.as_str())
);
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
state
.db
.save_provider(
AppType::GrokBuild.as_str(),
&Provider::with_id(
"relay".to_string(),
"Relay".to_string(),
json!({ "config": next_config }),
None,
),
)
.expect("save second Grok Build provider");
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
.expect("switch Grok Build provider");
assert_eq!(
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
next_config
);
assert_eq!(
state
.db
.get_current_provider(AppType::GrokBuild.as_str())
.expect("read Grok Build current provider")
.as_deref(),
Some("relay")
);
}
#[test]
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -300,6 +375,7 @@ command = "say"
claude: false,
codex: true, // 启用 Codex
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+5
View File
@@ -164,6 +164,7 @@ command = "say"
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -934,6 +935,7 @@ fn reapply_codex_official_live_resyncs_mcp_servers() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1032,6 +1034,7 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1124,6 +1127,7 @@ fn switch_codex_projects_mcp_despite_broken_claude_json() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -1187,6 +1191,7 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+1
View File
@@ -31,6 +31,7 @@ pub fn reset_test_fs() {
".codex",
".cc-switch",
".gemini",
".grok",
".config",
".openclaw",
"profiles",