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
+8 -8
View File
@@ -2,7 +2,7 @@
# CC Switch
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -195,13 +195,13 @@ TeamoRouter also offers enterprise features including centralized billing, team
## Why CC Switch?
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
**CC Switch** gives you a single desktop app to manage all supported AI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.
- **One App, Seven Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes from a single interface
- **One App, Eight Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes from a single interface
- **No More Manual Editing** — 50+ provider presets including AWS Bedrock, NVIDIA NIM, and community relays; just pick and switch
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync
- **System Tray Quick Switch** — Switch providers instantly from the tray menu, no need to open the full app
- **Cloud Sync** — Sync provider data across devices via Dropbox, OneDrive, iCloud, or WebDAV servers
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux, built with Tauri 2
@@ -219,18 +219,18 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
### Provider Management
- **7 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; copy your key and import with one click
- **8 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; copy your key and import with one click
- **Universal providers** — One config syncs to Claude Code, Codex, and Gemini CLI
- One-click switching, system tray quick access, drag-and-drop sorting, import/export
### Proxy & Failover
- **Local proxy with hot-switching** — Format conversion, auto-failover, circuit breaker, provider health monitoring, and request rectifier
- **App-level takeover** — Independently proxy Claude, Codex, or Gemini, down to individual providers
- **App-level takeover** — Independently proxy Claude, Codex, Gemini, or Grok Build, down to individual providers
### MCP, Prompts & Skills
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync and Deep Link import
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync and Deep Link import
- **Prompts** — Markdown editor with cross-app sync (CLAUDE.md / AGENTS.md / GEMINI.md) and backfill protection
- **Skills** — One-click install from GitHub repos or ZIP files, custom repository management, with symlink and file copy support
@@ -254,7 +254,7 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
<details>
<summary><strong>Which AI tools does CC Switch support?</strong></summary>
CC Switch supports seven tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
CC Switch supports eight tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
</details>
+8 -8
View File
@@ -2,7 +2,7 @@
# CC Switch
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -195,13 +195,13 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
## Warum CC Switch?
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
**CC Switch** gibt Ihnen eine einzige Desktop-App, um alle unterstützten KI-Werkzeuge zu verwalten. Statt Konfigurationsdateien von Hand zu bearbeiten, erhalten Sie eine visuelle Oberfläche, um Anbieter mit einem Klick zu importieren und sofort zwischen ihnen zu wechseln — mit 50+ integrierten Anbieter-Presets, einheitlicher MCP- und Skills-Verwaltung und schnellem Umschalten über das System-Tray. Das Ganze gestützt auf eine zuverlässige SQLite-Datenbank mit atomaren Schreibvorgängen, die Ihre Konfigurationen vor Beschädigung schützen.
- **Eine App, sieben Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
- **Eine App, acht Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
- **Kein manuelles Bearbeiten mehr** — 50+ Anbieter-Presets einschließlich AWS Bedrock, NVIDIA NIM und Community-Relays; einfach auswählen und umschalten
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung
- **Schnellumschaltung über System-Tray** — Wechseln Sie Anbieter sofort über das Tray-Menü, ohne die vollständige App öffnen zu müssen
- **Cloud-Synchronisierung** — Synchronisieren Sie Anbieterdaten geräteübergreifend über Dropbox, OneDrive, iCloud oder WebDAV-Server
- **Plattformübergreifend** — Native Desktop-App für Windows, macOS und Linux, gebaut mit Tauri 2
@@ -219,18 +219,18 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
### Anbieterverwaltung
- **7 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
- **8 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
- **Universelle Anbieter** — Eine Konfiguration synchronisiert sich mit Claude Code, Codex und Gemini CLI
- Umschaltung mit einem Klick, Schnellzugriff über System-Tray, Sortierung per Drag-and-drop, Import/Export
### Proxy & Failover
- **Lokaler Proxy mit Hot-Switching** — Formatkonvertierung, automatisches Failover, Circuit Breaker, Anbieter-Health-Monitoring und Request-Rectifier
- **Übernahme auf App-Ebene** — Claude, Codex oder Gemini unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
- **Übernahme auf App-Ebene** — Claude, Codex, Gemini oder Grok Build unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
### MCP, Prompts & Skills
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
- **Prompts** — Markdown-Editor mit App-übergreifender Synchronisierung (CLAUDE.md / AGENTS.md / GEMINI.md) und Backfill-Schutz
- **Skills** — Installation mit einem Klick aus GitHub-Repositorys oder ZIP-Dateien, Verwaltung eigener Repositorys, mit Unterstützung für Symlinks und Dateikopien
@@ -254,7 +254,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
<details>
<summary><strong>Welche KI-Werkzeuge unterstützt CC Switch?</strong></summary>
CC Switch unterstützt sieben Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
CC Switch unterstützt acht Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
</details>
+8 -8
View File
@@ -2,7 +2,7 @@
# CC Switch
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -195,13 +195,13 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
## CC Switch を選ぶ理由
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
- **1 つのアプリで 8 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし
- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期
- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ
@@ -219,18 +219,18 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
### プロバイダ管理
- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
- **8 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期
- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート
### プロキシ & フェイルオーバー
- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini、Grok Build を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
### MCP、Prompts & Skills
- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
- **統一 MCP パネル** -- Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護
- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応
@@ -254,7 +254,7 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
<details>
<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw**、**Hermes** の 8 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
</details>
+8 -8
View File
@@ -2,7 +2,7 @@
# CC Switch
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -196,13 +196,13 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
## 为什么选择 CC Switch
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
**CC Switch** 为你提供一个桌面应用来管理所有支持的 AI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
- **一个应用,个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes
- **一个应用,个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes
- **告别手动编辑** — 50+ 供应商预设,包括 AWS Bedrock、NVIDIA NIM 和社区中转服务;一键即可切换
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
- **系统托盘快速切换** — 从托盘菜单即时切换供应商,无需打开完整应用
- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据
- **跨平台** — 基于 Tauri 2 构建的原生桌面应用,支持 Windows、macOS 和 Linux
@@ -220,18 +220,18 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
### 供应商管理
- **7 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
- **8 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
- **通用供应商** — 一份配置同步到 Claude Code、Codex 和 Gemini CLI
- 一键切换、系统托盘快速访问、拖拽排序、导入导出
### 代理与故障转移
- **本地代理热切换** — 格式转换、自动故障转移、熔断器、供应商健康监控和整流器
- **应用级代理接管** — 独立为 Claude、CodexGemini 配置代理,具体到单个供应商
- **应用级代理接管** — 独立为 Claude、CodexGemini 或 Grok Build 配置代理,具体到单个供应商
### MCP、Prompts 与 Skills
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
- **Prompts** — Markdown 编辑器,跨应用同步(CLAUDE.md / AGENTS.md / GEMINI.md),回填保护
- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装,自定义仓库管理,支持软连接和文件复制
@@ -255,7 +255,7 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
<details>
<summary><strong>CC Switch 支持哪些 AI 工具?</strong></summary>
CC Switch 支持个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
CC Switch 支持个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
</details>
+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",
+8 -1
View File
@@ -127,6 +127,7 @@ const VALID_APPS: AppId[] = [
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
@@ -194,6 +195,7 @@ function App() {
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
@@ -204,6 +206,7 @@ function App() {
if (visibleApps["claude-desktop"]) return "claude-desktop";
if (visibleApps.codex) return "codex";
if (visibleApps.gemini) return "gemini";
if (visibleApps.grokbuild) return "grokbuild";
if (visibleApps.opencode) return "opencode";
if (visibleApps.openclaw) return "openclaw";
if (visibleApps.hermes) return "hermes";
@@ -222,6 +225,7 @@ function App() {
currentView === "sessions" &&
sharedFeatureApp !== "claude" &&
sharedFeatureApp !== "codex" &&
sharedFeatureApp !== "grokbuild" &&
sharedFeatureApp !== "opencode" &&
sharedFeatureApp !== "openclaw" &&
sharedFeatureApp !== "gemini" &&
@@ -291,6 +295,7 @@ function App() {
const hasSessionSupport =
sharedFeatureApp === "claude" ||
sharedFeatureApp === "codex" ||
sharedFeatureApp === "grokbuild" ||
sharedFeatureApp === "opencode" ||
sharedFeatureApp === "openclaw" ||
sharedFeatureApp === "gemini" ||
@@ -1405,7 +1410,9 @@ function App() {
? "openclaw"
: activeApp === "hermes"
? "hermes"
: "default"
: activeApp === "grokbuild"
? "grokbuild"
: "default"
}
className="flex items-center gap-1"
initial={{ opacity: 0 }}
+3
View File
@@ -23,6 +23,7 @@ const ALL_APPS: AppId[] = [
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
@@ -46,6 +47,7 @@ export function AppSwitcher({
"claude-desktop": "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
@@ -55,6 +57,7 @@ export function AppSwitcher({
"claude-desktop": "Claude Desktop",
codex: "Codex",
gemini: "Gemini",
grokbuild: "Grok Build",
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
+10
View File
@@ -14,6 +14,7 @@ import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import { parseGrokBuildConfig } from "@/utils/grokBuildConfig";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -262,6 +263,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
} else if (appId === "grokbuild") {
const grokConfig = parseGrokBuildConfig(
(config as any).config,
provider.name,
);
return {
apiKey: grokConfig.apiKey,
baseUrl: grokConfig.baseUrl,
};
} else if (appId === "hermes") {
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml
return {
+23 -2
View File
@@ -42,7 +42,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onClose,
existingIds = [],
defaultFormat = "json",
defaultEnabledApps = ["claude", "codex", "gemini"],
defaultEnabledApps = ["claude", "codex", "gemini", "grokbuild"],
}) => {
const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
@@ -65,17 +65,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: boolean;
codex: boolean;
gemini: boolean;
grokbuild: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
return {
...initialData.apps,
grokbuild: initialData.apps.grokbuild ?? false,
};
}
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
grokbuild: defaultEnabledApps.includes("grokbuild"),
opencode: defaultEnabledApps.includes("opencode"),
openclaw: defaultEnabledApps.includes("openclaw"),
hermes: defaultEnabledApps.includes("hermes"),
@@ -566,6 +571,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-grokbuild"
checked={enabledApps.grokbuild}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, grokbuild: checked })
}
/>
<label
htmlFor="enable-grokbuild"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.grokbuild")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-opencode"
+1
View File
@@ -61,6 +61,7 @@ const UnifiedMcpPanel = React.forwardRef<
"claude-desktop": 0,
codex: 0,
gemini: 0,
grokbuild: 0,
opencode: 0,
openclaw: 0,
hermes: 0,
@@ -35,6 +35,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
hermes: "AGENTS.md",
};
@@ -29,6 +29,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
hermes: "AGENTS.md",
@@ -19,6 +19,7 @@ import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
@@ -48,6 +49,7 @@ export function AddProviderDialog({
appId !== "opencode" &&
appId !== "openclaw" &&
appId !== "hermes" &&
appId !== "grokbuild" &&
appId !== "claude-desktop";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
@@ -255,6 +257,11 @@ export function AddProviderDialog({
if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "grokbuild") {
const config = parsedConfig.config as string | undefined;
if (config) {
addUrl(extractGrokBuildBaseUrl(config));
}
} else if (appId === "opencode") {
const options = parsedConfig.options as
| Record<string, any>
+6 -1
View File
@@ -28,6 +28,7 @@ import {
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
import { resolveProviderIcon } from "@/utils/providerIcon";
interface DragHandleProps {
attributes: DraggableAttributes;
@@ -350,7 +351,11 @@ export function ProviderCard({
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
icon={resolveProviderIcon(
appId,
provider.icon,
provider.iconColor,
)}
name={provider.name}
color={provider.iconColor}
size={20}
@@ -43,12 +43,14 @@ import type {
PromptCacheRoutingMode,
ProviderCategory,
} from "@/types";
import type { AppId } from "@/lib/api";
interface EndpointCandidate {
url: string;
}
interface CodexFormFieldsProps {
appId?: AppId;
providerId?: string;
// API Key
codexApiKey: string;
@@ -155,6 +157,7 @@ function catalogRowsMatchModels(
}
export function CodexFormFields({
appId = "codex",
providerId,
codexApiKey,
onApiKeyChange,
@@ -1007,7 +1010,7 @@ export function CodexFormFields({
{/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appId="codex"
appId={appId}
providerId={providerId}
value={codexBaseUrl}
onChange={onBaseUrlChange}
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
claude: 8,
"claude-desktop": 8,
gemini: 8,
grokbuild: 12,
opencode: 8,
openclaw: 8,
hermes: 8,
@@ -0,0 +1,576 @@
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import JsonEditor from "@/components/JsonEditor";
import { useDarkMode } from "@/hooks/useDarkMode";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import {
buildLocalProxyRequestOverrides,
formatRequestOverrideObject,
} from "@/lib/requestOverrides";
import type {
ClaudeApiKeyField,
CodexApiFormat,
CodexChatReasoning,
PromptCacheRoutingMode,
ProviderCategory,
ProviderMeta,
} from "@/types";
import type { ProviderFormProps, ProviderFormValues } from "./ProviderForm";
import { BasicFormFields } from "./BasicFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import {
codexProviderPresets,
type CodexProviderPreset,
} from "@/config/codexProviderPresets";
import {
codexApiFormatFromWireApi,
extractCodexBaseUrl,
extractCodexModelName,
extractCodexWireApi,
} from "@/utils/providerConfigUtils";
import {
buildGrokBuildConfig,
parseGrokBuildConfig,
updateGrokBuildConfig,
validateGrokBuildConfig,
} from "@/utils/grokBuildConfig";
import { resolveProviderIcon } from "@/utils/providerIcon";
type GrokBuildProviderFormProps = Omit<ProviderFormProps, "appId">;
const grokPresetEntries: Array<{
id: string;
preset: CodexProviderPreset;
}> = codexProviderPresets
.map((preset, index) => ({ id: `grokbuild-${index}`, preset }))
.filter(({ preset }) => preset.category !== "official" && !preset.isOfficial);
export const grokApiBackendFromApiFormat = (format: CodexApiFormat): string => {
if (format === "openai_chat") return "chat_completions";
if (format === "anthropic") return "messages";
return "responses";
};
export function GrokBuildProviderForm({
providerId,
submitLabel,
onSubmit,
onCancel,
onSubmittingChange,
initialData,
showButtons = true,
}: GrokBuildProviderFormProps) {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const initialConfigText =
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: undefined;
const initialConfig = useMemo(
() => parseGrokBuildConfig(initialConfigText, initialData?.name),
[initialConfigText, initialData?.name],
);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
initialData ? null : "custom",
);
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category ?? "custom",
);
const [isPartner, setIsPartner] = useState(
initialData?.meta?.isPartner ?? false,
);
const [partnerPromotionKey, setPartnerPromotionKey] = useState<string>();
const [profile, setProfile] = useState(initialConfig.model);
const [upstreamModel, setUpstreamModel] = useState(
initialConfig.upstreamModel ?? initialConfig.model,
);
const [baseUrl, setBaseUrl] = useState(initialConfig.baseUrl);
const [apiKey, setApiKey] = useState(initialConfig.apiKey);
const [apiBackend, setApiBackend] = useState(initialConfig.apiBackend);
const [contextWindow, setContextWindow] = useState(
String(initialConfig.contextWindow),
);
const [rawConfig, setRawConfig] = useState(
initialConfigText ?? buildGrokBuildConfig(initialConfig),
);
const [apiFormat, setApiFormat] = useState<CodexApiFormat>(
(initialData?.meta?.apiFormat as CodexApiFormat | undefined) ??
"openai_responses",
);
const [anthropicAuthField, setAnthropicAuthField] =
useState<ClaudeApiKeyField>(
initialData?.meta?.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN",
);
const [impersonateClaudeCode, setImpersonateClaudeCode] = useState(
initialData?.meta?.impersonateClaudeCode === true,
);
const [maxOutputTokens, setMaxOutputTokens] = useState(
initialData?.meta?.maxOutputTokens
? String(initialData.meta.maxOutputTokens)
: "",
);
const [codexChatReasoning, setCodexChatReasoning] =
useState<CodexChatReasoning>(initialData?.meta?.codexChatReasoning ?? {});
const [promptCacheRouting, setPromptCacheRouting] =
useState<PromptCacheRoutingMode>(
initialData?.meta?.promptCacheRouting ?? "auto",
);
const [isFullUrl, setIsFullUrl] = useState(
initialData?.meta?.isFullUrl ?? false,
);
const [customUserAgent, setCustomUserAgent] = useState(
initialData?.meta?.customUserAgent ?? "",
);
const [headersOverride, setHeadersOverride] = useState(
formatRequestOverrideObject(
initialData?.meta?.localProxyRequestOverrides?.headers,
),
);
const [bodyOverride, setBodyOverride] = useState(
formatRequestOverrideObject(
initialData?.meta?.localProxyRequestOverrides?.body,
),
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState(
initialData?.meta?.endpointAutoSelect ?? true,
);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [presetEndpoints, setPresetEndpoints] = useState<string[]>([]);
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
[],
);
const form = useForm<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues: {
name: initialData?.name ?? initialConfig.name,
websiteUrl: initialData?.websiteUrl ?? "",
notes: initialData?.notes ?? "",
settingsConfig: JSON.stringify({ config: rawConfig }),
icon:
resolveProviderIcon(
"grokbuild",
initialData?.icon,
initialData?.iconColor,
) ?? "",
iconColor: initialData?.iconColor ?? "",
},
mode: "onSubmit",
});
const { isSubmitting } = form.formState;
const websiteUrl = form.watch("websiteUrl") ?? "";
useEffect(() => {
onSubmittingChange?.(isSubmitting);
}, [isSubmitting, onSubmittingChange]);
const presetCategoryLabels = useMemo(
() => ({
official: t("providerForm.categoryOfficial", { defaultValue: "官方" }),
cn_official: t("providerForm.categoryCnOfficial", {
defaultValue: "国内官方",
}),
aggregator: t("providerForm.categoryAggregation", {
defaultValue: "聚合服务",
}),
third_party: t("providerForm.categoryThirdParty", {
defaultValue: "第三方",
}),
}),
[t],
);
const speedTestEndpoints = useMemo(() => {
const urls = new Set<string>();
const add = (url?: string) => {
const normalized = url?.trim().replace(/\/+$/, "");
if (normalized) urls.add(normalized);
};
add(baseUrl);
presetEndpoints.forEach(add);
draftCustomEndpoints.forEach(add);
return Array.from(urls).map((url) => ({ url }));
}, [baseUrl, draftCustomEndpoints, presetEndpoints]);
const syncStructuredConfig = (
overrides: Partial<ReturnType<typeof parseGrokBuildConfig>>,
) => {
const next = {
model: profile,
upstreamModel,
baseUrl,
name: form.getValues("name") || initialConfig.name,
apiKey,
apiBackend,
contextWindow: Number.parseInt(contextWindow, 10),
...overrides,
};
setRawConfig((current) => updateGrokBuildConfig(current, next));
};
const handlePresetChange = (presetId: string) => {
setSelectedPresetId(presetId);
if (presetId === "custom") {
setCategory("custom");
setIsPartner(false);
setPartnerPromotionKey(undefined);
setPresetEndpoints([]);
return;
}
const entry = grokPresetEntries.find(
(candidate) => candidate.id === presetId,
);
if (!entry) return;
const preset = entry.preset;
const presetName = preset.nameKey ? String(t(preset.nameKey)) : preset.name;
const presetBaseUrl = extractCodexBaseUrl(preset.config) ?? "";
const presetModel = extractCodexModelName(preset.config) ?? profile;
const presetApiFormat =
preset.apiFormat ??
codexApiFormatFromWireApi(extractCodexWireApi(preset.config)) ??
"openai_responses";
const presetApiKey =
"auth" in preset && typeof preset.auth?.OPENAI_API_KEY === "string"
? preset.auth.OPENAI_API_KEY
: "";
const presetApiBackend = grokApiBackendFromApiFormat(presetApiFormat);
form.setValue("name", presetName);
form.setValue("websiteUrl", preset.websiteUrl ?? "");
form.setValue("icon", preset.icon ?? "");
form.setValue("iconColor", preset.iconColor ?? "");
setCategory(preset.category ?? "custom");
setIsPartner(preset.isPartner ?? false);
setPartnerPromotionKey(preset.partnerPromotionKey);
setBaseUrl(presetBaseUrl);
setApiKey(presetApiKey);
setUpstreamModel(presetModel);
setApiFormat(presetApiFormat);
setApiBackend(presetApiBackend);
setPresetEndpoints(preset.endpointCandidates ?? []);
setRawConfig(
buildGrokBuildConfig({
model: profile,
upstreamModel: presetModel,
baseUrl: presetBaseUrl,
name: presetName,
apiKey: presetApiKey,
apiBackend: presetApiBackend,
contextWindow: Number.parseInt(contextWindow, 10),
}),
);
};
const handleRawConfigChange = (value: string) => {
setRawConfig(value);
if (validateGrokBuildConfig(value)) return;
const parsed = parseGrokBuildConfig(value, form.getValues("name"));
setProfile(parsed.model);
setUpstreamModel(parsed.upstreamModel ?? parsed.model);
setBaseUrl(parsed.baseUrl);
setApiKey(parsed.apiKey);
setApiBackend(parsed.apiBackend);
setContextWindow(String(parsed.contextWindow));
if (parsed.name) form.setValue("name", parsed.name);
};
const handleSubmit = async (values: ProviderFormData) => {
const name = values.name.trim();
const parsedContextWindow = Number.parseInt(contextWindow, 10);
const envKey = parseGrokBuildConfig(rawConfig).envKey?.trim();
if (
!name ||
!baseUrl.trim() ||
(!apiKey.trim() && !envKey) ||
!profile.trim()
) {
toast.error(
t("providerForm.requiredFields", {
defaultValue: "请填写供应商名称、API 地址、API Key 和模型",
}),
);
return;
}
if (!Number.isInteger(parsedContextWindow) || parsedContextWindow <= 0) {
toast.error(
t("grokBuild.contextWindowInvalid", {
defaultValue: "上下文窗口必须是正整数",
}),
);
return;
}
const finalConfig = updateGrokBuildConfig(rawConfig, {
model: profile,
upstreamModel,
baseUrl,
name,
apiKey,
apiBackend,
contextWindow: parsedContextWindow,
});
const configError = validateGrokBuildConfig(finalConfig);
if (configError) {
toast.error(
t("grokBuild.invalidToml", {
error: configError,
defaultValue: `config.toml 格式错误: ${configError}`,
}),
);
return;
}
const requestOverrides = buildLocalProxyRequestOverrides(
headersOverride,
bodyOverride,
);
if (requestOverrides.error) {
toast.error(requestOverrides.error);
return;
}
const customEndpoints = Object.fromEntries(
draftCustomEndpoints.map((url) => [
url,
{ url, addedAt: Date.now(), lastUsed: undefined },
]),
);
const parsedMaxOutputTokens = Number.parseInt(maxOutputTokens, 10);
const initialMeta = { ...(initialData?.meta ?? {}) };
delete initialMeta.custom_endpoints;
const meta: ProviderMeta = {
...initialMeta,
apiFormat,
apiKeyField: anthropicAuthField,
isFullUrl,
endpointAutoSelect,
isPartner,
partnerPromotionKey,
impersonateClaudeCode,
promptCacheRouting,
codexChatReasoning,
customUserAgent: customUserAgent.trim() || undefined,
localProxyRequestOverrides: requestOverrides.overrides,
maxOutputTokens:
Number.isInteger(parsedMaxOutputTokens) && parsedMaxOutputTokens > 0
? parsedMaxOutputTokens
: undefined,
};
if (!providerId && Object.keys(customEndpoints).length > 0) {
meta.custom_endpoints = customEndpoints;
}
const payload: ProviderFormValues = {
...values,
name,
websiteUrl: values.websiteUrl?.trim() ?? "",
notes: values.notes?.trim() ?? "",
settingsConfig: JSON.stringify({ config: finalConfig }),
presetId: selectedPresetId ?? undefined,
presetCategory: category ?? "custom",
isPartner,
meta,
};
await onSubmit(payload);
};
const rawConfigError = validateGrokBuildConfig(rawConfig);
return (
<Form {...form}>
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
{!initialData && (
<ProviderPresetSelector
selectedPresetId={selectedPresetId}
presetEntries={grokPresetEntries}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
category={category}
/>
)}
<BasicFormFields form={form} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormItem>
<FormLabel htmlFor="grokbuild-profile">
{t("grokBuild.profile", { defaultValue: "客户端模型档位" })}
</FormLabel>
<Input
id="grokbuild-profile"
value={profile}
onChange={(event) => {
const value = event.target.value;
setProfile(value);
syncStructuredConfig({ model: value });
}}
placeholder="grok-4.5"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-api-backend">
{t("grokBuild.apiBackend", { defaultValue: "API Backend" })}
</FormLabel>
<Input
id="grokbuild-api-backend"
value={apiBackend}
onChange={(event) => {
const value = event.target.value;
setApiBackend(value);
syncStructuredConfig({ apiBackend: value });
}}
placeholder="responses"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-context-window">
{t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })}
</FormLabel>
<Input
id="grokbuild-context-window"
type="number"
min={1}
step={1}
value={contextWindow}
onChange={(event) => {
const value = event.target.value;
setContextWindow(value);
syncStructuredConfig({
contextWindow: Number.parseInt(value, 10),
});
}}
/>
</FormItem>
</div>
<CodexFormFields
appId="grokbuild"
providerId={providerId}
codexApiKey={apiKey}
onApiKeyChange={(value) => {
setApiKey(value);
syncStructuredConfig({ apiKey: value });
}}
category={category}
shouldShowApiKeyLink={Boolean(websiteUrl)}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
shouldShowSpeedTest
codexBaseUrl={baseUrl}
onBaseUrlChange={(value) => {
setBaseUrl(value);
syncStructuredConfig({ baseUrl: value });
}}
isFullUrl={isFullUrl}
onFullUrlChange={setIsFullUrl}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
codexModel={upstreamModel}
onModelChange={(value) => {
setUpstreamModel(value);
syncStructuredConfig({ upstreamModel: value });
}}
apiFormat={apiFormat}
onApiFormatChange={(value) => {
const backend = grokApiBackendFromApiFormat(value);
setApiFormat(value);
setApiBackend(backend);
syncStructuredConfig({ apiBackend: backend });
}}
anthropicAuthField={anthropicAuthField}
onAnthropicAuthFieldChange={setAnthropicAuthField}
impersonateClaudeCode={impersonateClaudeCode}
onImpersonateClaudeCodeChange={setImpersonateClaudeCode}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={setMaxOutputTokens}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
promptCacheRouting={promptCacheRouting}
onPromptCacheRoutingChange={setPromptCacheRouting}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={headersOverride}
onLocalProxyHeadersOverrideChange={setHeadersOverride}
localProxyBodyOverride={bodyOverride}
onLocalProxyBodyOverrideChange={setBodyOverride}
/>
<div className="space-y-2">
<FormLabel htmlFor="grokbuild-config-toml">
{t("grokBuild.rawConfig", { defaultValue: "config.toml" })}
</FormLabel>
<JsonEditor
value={rawConfig}
onChange={handleRawConfigChange}
placeholder=""
darkMode={isDarkMode}
rows={12}
showValidation={false}
language="javascript"
/>
{rawConfigError && (
<p className="text-xs text-destructive">
{t("grokBuild.invalidToml", {
error: rawConfigError,
defaultValue: `Invalid config.toml: ${rawConfigError}`,
})}
</p>
)}
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="hidden">
<FormControl>
<Input type="hidden" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
</Form>
);
}
@@ -77,6 +77,7 @@ import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
import { GrokBuildProviderForm } from "./GrokBuildProviderForm";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
@@ -243,6 +244,9 @@ export function ProviderForm(props: ProviderFormProps) {
if (props.appId === "claude-desktop") {
return <ClaudeDesktopProviderForm {...props} />;
}
if (props.appId === "grokbuild") {
return <GrokBuildProviderForm {...props} />;
}
return <ProviderFormFull {...props} />;
}
@@ -25,7 +25,10 @@ export function ClaudeDesktopRouteToggle({
const isBusy = isStarting || isStoppingServer;
const otherTakeoverActive = Boolean(
takeoverStatus?.claude || takeoverStatus?.codex || takeoverStatus?.gemini,
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
takeoverStatus?.grokbuild,
);
const routeAddress = status?.address ?? "127.0.0.1";
const routePort = status?.port ?? 15721;
+3 -1
View File
@@ -38,7 +38,9 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
? "Claude"
: activeApp === "codex"
? "Codex"
: "Gemini";
: activeApp === "grokbuild"
? "Grok Build"
: "Gemini";
const tooltipText = !takeoverEnabled
? t("failover.tooltip.takeoverRequired", {
+41 -25
View File
@@ -72,6 +72,7 @@ export function ProxyPanel({
const { data: claudeQueue = [] } = useFailoverQueue("claude");
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const { data: grokQueue = [] } = useFailoverQueue("grokbuild");
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
@@ -272,30 +273,32 @@ export function ProxyPanel({
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{(["claude", "codex", "gemini", "grokbuild"] as const).map(
(appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType === "grokbuild" ? "Grok Build" : appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
},
)}
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.takeover.hint", {
@@ -415,7 +418,8 @@ export function ProxyPanel({
{/* [6] Provider queues */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
geminiQueue.length > 0) && (
geminiQueue.length > 0 ||
grokQueue.length > 0) && (
<div className="pt-3 border-t border-border space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
@@ -459,6 +463,18 @@ export function ProxyPanel({
status={status}
/>
)}
{grokQueue.length > 0 && (
<ProviderQueueGroup
appType="grokbuild"
appLabel="Grok Build"
targets={grokQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
</div>
)}
</div>
+3 -1
View File
@@ -39,7 +39,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
? "Codex"
: activeApp === "gemini"
? "Gemini"
: "OpenCode";
: activeApp === "grokbuild"
? "Grok Build"
: "OpenCode";
const tooltipText = takeoverEnabled
? isRunning
@@ -83,6 +83,7 @@ const SESSION_GROUP_EXPANSION_STORAGE_KEY =
type ProviderFilter =
| "all"
| "codex"
| "grokbuild"
| "claude"
| "opencode"
| "openclaw"
@@ -1077,6 +1078,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<span>Codex</span>
</div>
</SelectItem>
<SelectItem value="grokbuild">
<div className="flex items-center gap-2">
<ProviderIcon
icon="grok"
name="grokbuild"
size={14}
/>
<span>Grok Build</span>
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<ProviderIcon
+1
View File
@@ -121,6 +121,7 @@ export const getProviderLabel = (
// 根据 providerId 获取对应的图标名称
export const getProviderIconName = (providerId: string) => {
if (providerId === "codex") return "openai";
if (providerId === "grokbuild") return "grok";
if (providerId === "claude") return "claude";
if (providerId === "opencode") return "opencode";
if (providerId === "openclaw") return "openclaw";
+7
View File
@@ -63,6 +63,7 @@ const TOOL_NAMES = [
"claude",
"codex",
"gemini",
"grok",
"opencode",
"openclaw",
"hermes",
@@ -130,6 +131,8 @@ ${posixScriptInstallCommand("https://claude.ai/install.sh")} || npm i -g @anthro
npm i -g @openai/codex@latest
# Gemini CLI
npm i -g @google/gemini-cli@latest
# Grok Build
npm i -g @xai-official/grok@latest
# OpenCode
${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode-ai@latest
# OpenClaw
@@ -143,6 +146,8 @@ npm i -g @anthropic-ai/claude-code@latest
npm i -g @openai/codex@latest
# Gemini CLI
npm i -g @google/gemini-cli@latest
# Grok Build
npm i -g @xai-official/grok@latest
# OpenCode
npm i -g opencode-ai@latest
# OpenClaw
@@ -158,6 +163,7 @@ const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
claude: "Claude Code",
codex: "Codex",
gemini: "Gemini CLI",
grok: "Grok Build",
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
@@ -173,6 +179,7 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
claude: "claude",
codex: "codex",
gemini: "gemini",
grok: "grokbuild",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
@@ -26,6 +26,7 @@ const APP_CONFIG: Array<{
},
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
{ id: "grokbuild", icon: "grok", nameKey: "apps.grokbuild" },
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
{ id: "hermes", icon: "hermes", nameKey: "apps.hermes" },
@@ -42,6 +43,7 @@ export function AppVisibilitySettings({
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
@@ -73,7 +75,7 @@ export function AppVisibilitySettings({
{t("settings.appVisibility.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<div className="flex flex-wrap gap-1 rounded-md border border-border-default bg-background p-1">
{APP_CONFIG.map((app) => {
const isVisible = visibleApps[app.id];
// Disable button if this is the last visible app
@@ -17,6 +17,7 @@ interface DirectorySettingsProps {
claudeDir?: string;
codexDir?: string;
geminiDir?: string;
grokDir?: string;
opencodeDir?: string;
openclawDir?: string;
hermesDir?: string;
@@ -34,6 +35,7 @@ export function DirectorySettings({
claudeDir,
codexDir,
geminiDir,
grokDir,
opencodeDir,
openclawDir,
hermesDir,
@@ -126,6 +128,17 @@ export function DirectorySettings({
onReset={() => onResetDirectory("gemini")}
/>
<DirectoryInput
label={t("settings.grokConfigDir")}
description={undefined}
value={grokDir}
resolvedValue={resolvedDirs.grokbuild}
placeholder={t("settings.browsePlaceholderGrok")}
onChange={(val) => onDirectoryChange("grokbuild", val)}
onBrowse={() => onBrowseDirectory("grokbuild")}
onReset={() => onResetDirectory("grokbuild")}
/>
<DirectoryInput
label={t("settings.opencodeConfigDir")}
description={undefined}
+1
View File
@@ -352,6 +352,7 @@ export function SettingsPage({
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
grokDir={settings.grokConfigDir}
opencodeDir={settings.opencodeConfigDir}
openclawDir={settings.openclawConfigDir}
hermesDir={settings.hermesConfigDir}
@@ -119,6 +119,7 @@ const UnifiedSkillsPanel = React.forwardRef<
"claude-desktop": 0,
codex: 0,
gemini: 0,
grokbuild: 0,
opencode: 0,
openclaw: 0,
hermes: 0,
@@ -746,6 +747,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: skill.foundIn.includes("claude"),
codex: skill.foundIn.includes("codex"),
gemini: skill.foundIn.includes("gemini"),
grokbuild: skill.foundIn.includes("grokbuild"),
opencode: skill.foundIn.includes("opencode"),
openclaw: false,
hermes: skill.foundIn.includes("hermes"),
@@ -772,6 +774,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
@@ -815,6 +818,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
@@ -828,6 +832,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
+3 -1
View File
@@ -33,7 +33,7 @@ import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
type PricingModelSource = "request" | "response";
@@ -57,6 +57,7 @@ export function PricingConfigPanel() {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
grokbuild: { multiplier: "1", source: "response" },
});
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
null,
@@ -102,6 +103,7 @@ export function PricingConfigPanel() {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
grokbuild: { multiplier: "1", source: "response" },
};
for (const result of results) {
newState[result.app] = {
+1
View File
@@ -63,6 +63,7 @@ const APP_FILTER_ICON: Record<AppType, string> = {
claude: "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
};
+4
View File
@@ -61,6 +61,10 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
accent: "text-sky-600 dark:text-sky-400",
iconBg: "bg-sky-500/10",
},
grokbuild: {
accent: "text-rose-600 dark:text-rose-400",
iconBg: "bg-rose-500/10",
},
opencode: {
accent: "text-purple-600 dark:text-purple-400",
iconBg: "bg-purple-500/10",
+17
View File
@@ -20,6 +20,7 @@ export const APP_IDS: AppId[] = [
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
@@ -30,6 +31,7 @@ export const SKILLS_APP_IDS: AppId[] = [
"claude",
"codex",
"gemini",
"grokbuild",
"opencode",
"hermes",
];
@@ -70,6 +72,21 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
badgeClass:
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
},
grokbuild: {
label: "Grok Build",
icon: (
<ProviderIcon
icon="grok"
name="Grok Build"
size={14}
showFallback={false}
/>
),
activeClass:
"bg-cyan-500/10 ring-1 ring-cyan-500/20 hover:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300",
badgeClass:
"bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 hover:bg-cyan-500/20 border-0 gap-1.5",
},
opencode: {
label: "OpenCode",
icon: (
+13
View File
@@ -10,6 +10,7 @@ type AppDirectoryKey =
| "claude"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
@@ -20,6 +21,7 @@ export interface ResolvedDirectories {
claude: string;
codex: string;
gemini: string;
grokbuild: string;
opencode: string;
openclaw: string;
hermes: string;
@@ -33,6 +35,7 @@ const APP_DIRECTORY_META: Record<
claude: { key: "claude", defaultFolder: ".claude" },
codex: { key: "codex", defaultFolder: ".codex" },
gemini: { key: "gemini", defaultFolder: ".gemini" },
grokbuild: { key: "grokbuild", defaultFolder: ".grok" },
opencode: { key: "opencode", defaultFolder: ".config/opencode" },
openclaw: { key: "openclaw", defaultFolder: ".openclaw" },
hermes: { key: "hermes", defaultFolder: ".hermes" },
@@ -45,6 +48,7 @@ const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
claude: "claudeConfigDir",
codex: "codexConfigDir",
gemini: "geminiConfigDir",
grokbuild: "grokConfigDir",
opencode: "opencodeConfigDir",
openclaw: "openclawConfigDir",
hermes: "hermesConfigDir",
@@ -130,6 +134,7 @@ export function useDirectorySettings({
claude: "",
codex: "",
gemini: "",
grokbuild: "",
opencode: "",
openclaw: "",
hermes: "",
@@ -141,6 +146,7 @@ export function useDirectorySettings({
claude: "",
codex: "",
gemini: "",
grokbuild: "",
opencode: "",
openclaw: "",
hermes: "",
@@ -159,6 +165,7 @@ export function useDirectorySettings({
claudeDir,
codexDir,
geminiDir,
grokDir,
opencodeDir,
openclawDir,
hermesDir,
@@ -166,6 +173,7 @@ export function useDirectorySettings({
defaultClaudeDir,
defaultCodexDir,
defaultGeminiDir,
defaultGrokDir,
defaultOpencodeDir,
defaultOpenclawDir,
defaultHermesDir,
@@ -174,6 +182,7 @@ export function useDirectorySettings({
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.getConfigDir("gemini"),
settingsApi.getConfigDir("grokbuild"),
settingsApi.getConfigDir("opencode"),
settingsApi.getConfigDir("openclaw"),
settingsApi.getConfigDir("hermes"),
@@ -181,6 +190,7 @@ export function useDirectorySettings({
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"),
computeDefaultConfigDir("grokbuild"),
computeDefaultConfigDir("opencode"),
computeDefaultConfigDir("openclaw"),
computeDefaultConfigDir("hermes"),
@@ -195,6 +205,7 @@ export function useDirectorySettings({
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
gemini: defaultGeminiDir ?? "",
grokbuild: defaultGrokDir ?? "",
opencode: defaultOpencodeDir ?? "",
openclaw: defaultOpenclawDir ?? "",
hermes: defaultHermesDir ?? "",
@@ -208,6 +219,7 @@ export function useDirectorySettings({
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
gemini: geminiDir || defaultsRef.current.gemini,
grokbuild: grokDir || defaultsRef.current.grokbuild,
opencode: opencodeDir || defaultsRef.current.opencode,
openclaw: openclawDir || defaultsRef.current.openclaw,
hermes: hermesDir || defaultsRef.current.hermes,
@@ -349,6 +361,7 @@ export function useDirectorySettings({
claude: overrides?.claude ?? defaultsRef.current.claude,
codex: overrides?.codex ?? defaultsRef.current.codex,
gemini: overrides?.gemini ?? defaultsRef.current.gemini,
grokbuild: overrides?.grokbuild ?? defaultsRef.current.grokbuild,
opencode: overrides?.opencode ?? defaultsRef.current.opencode,
openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw,
hermes: overrides?.hermes ?? defaultsRef.current.hermes,
+8 -3
View File
@@ -159,7 +159,7 @@ export function useProviderActions(
activeApp === "claude" &&
provider.meta?.providerType === "github_copilot";
const isCodexChatFormat =
activeApp === "codex" &&
(activeApp === "codex" || activeApp === "grokbuild") &&
(provider.meta?.apiFormat === "openai_chat" ||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
"string" &&
@@ -169,7 +169,7 @@ export function useProviderActions(
),
)));
const isCodexAnthropicFormat =
activeApp === "codex" &&
(activeApp === "codex" || activeApp === "grokbuild") &&
(provider.meta?.apiFormat === "anthropic" ||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
"string" &&
@@ -220,7 +220,9 @@ export function useProviderActions(
});
} else if (
provider.meta?.isFullUrl &&
(activeApp === "claude" || activeApp === "codex")
(activeApp === "claude" ||
activeApp === "codex" ||
activeApp === "grokbuild")
) {
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
defaultValue: "开启了完整 URL 连接模式",
@@ -281,6 +283,9 @@ export function useProviderActions(
if (activeApp === "codex") {
messageKey = "notifications.codexRestartRequired";
defaultMessage = "切换成功,请重启客户端以生效";
} else if (activeApp === "grokbuild") {
messageKey = "notifications.grokBuildRestartRequired";
defaultMessage = "切换成功,请重启 Grok Build 以生效";
} else if (activeApp === "claude-desktop") {
if (provider.meta?.claudeDesktopMode === "proxy") {
messageKey = "notifications.claudeDesktopProxyRestartRequired";
+4 -1
View File
@@ -132,7 +132,9 @@ export function useProxyStatus() {
? "Codex"
: variables.appType === "gemini"
? "Gemini"
: "OpenCode";
: variables.appType === "grokbuild"
? "Grok Build"
: "OpenCode";
toast.success(
variables.enabled
@@ -215,6 +217,7 @@ export function useProxyStatus() {
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
takeoverStatus?.grokbuild ||
false,
// 启动/停止(总开关)
+8
View File
@@ -110,6 +110,7 @@ export function useSettings(): UseSettingsResult {
claude: sanitizeDir(data?.claudeConfigDir),
codex: sanitizeDir(data?.codexConfigDir),
gemini: sanitizeDir(data?.geminiConfigDir),
grokbuild: sanitizeDir(data?.grokConfigDir),
opencode: sanitizeDir(data?.opencodeConfigDir),
openclaw: sanitizeDir(data?.openclawConfigDir),
hermes: sanitizeDir(data?.hermesConfigDir),
@@ -187,6 +188,7 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedGrokDir = sanitizeDir(mergedSettings.grokConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
@@ -204,6 +206,7 @@ export function useSettings(): UseSettingsResult {
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
grokConfigDir: sanitizedGrokDir,
opencodeConfigDir: sanitizedOpencodeDir,
openclawConfigDir: sanitizedOpenclawDir,
language: mergedSettings.language,
@@ -318,6 +321,7 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedGrokDir = sanitizeDir(mergedSettings.grokConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
@@ -328,6 +332,7 @@ export function useSettings(): UseSettingsResult {
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const previousGrokDir = sanitizeDir(data?.grokConfigDir);
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir);
const {
@@ -341,6 +346,7 @@ export function useSettings(): UseSettingsResult {
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
grokConfigDir: sanitizedGrokDir,
opencodeConfigDir: sanitizedOpencodeDir,
openclawConfigDir: sanitizedOpenclawDir,
language: mergedSettings.language,
@@ -427,6 +433,7 @@ export function useSettings(): UseSettingsResult {
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
const grokDirChanged = sanitizedGrokDir !== previousGrokDir;
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir;
if (
@@ -434,6 +441,7 @@ export function useSettings(): UseSettingsResult {
(claudeDirChanged ||
codexDirChanged ||
geminiDirChanged ||
grokDirChanged ||
opencodeDirChanged ||
openclawDirChanged)
) {
+2
View File
@@ -122,6 +122,7 @@ export function useSettingsForm(): UseSettingsFormResult {
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
grokConfigDir: sanitizeDir(data.grokConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
openclawConfigDir: sanitizeDir(data.openclawConfigDir),
language: normalizedLanguage,
@@ -188,6 +189,7 @@ export function useSettingsForm(): UseSettingsFormResult {
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
grokConfigDir: sanitizeDir(serverData.grokConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
openclawConfigDir: sanitizeDir(serverData.openclawConfigDir),
language: normalizedLanguage,
+15 -1
View File
@@ -231,6 +231,7 @@
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful!",
"codexRestartRequired": "Switched successfully. Restart the client to apply changes.",
"grokBuildRestartRequired": "Switched successfully. Restart Grok Build to apply changes.",
"claudeDesktopRestartRequired": "Switched successfully. Restart Claude Desktop to apply changes.",
"claudeDesktopProxyRestartRequired": "Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.",
"addToConfigSuccess": "Added to config",
@@ -756,6 +757,8 @@
"codexConfigDirDescription": "Override Codex configuration directory.",
"geminiConfigDir": "Gemini Configuration Directory",
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
"grokConfigDir": "Grok Build Configuration Directory",
"grokConfigDirDescription": "Override Grok Build configuration directory (config.toml).",
"opencodeConfigDir": "OpenCode Configuration Directory",
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
"openclawConfigDir": "OpenClaw Configuration Directory",
@@ -765,6 +768,7 @@
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
"browsePlaceholderGrok": "e.g., /home/<your-username>/.grok",
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
"browsePlaceholderOpenclaw": "e.g., /home/<your-username>/.openclaw",
"browsePlaceholderHermes": "e.g., /home/<your-username>/.hermes",
@@ -875,13 +879,22 @@
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
},
"grokBuild": {
"apiBackend": "API backend",
"contextWindow": "Context window",
"contextWindowInvalid": "Context window must be a positive integer",
"profile": "Client model profile",
"rawConfig": "config.toml",
"invalidToml": "Invalid config.toml: {{error}}"
},
"sessionManager": {
"title": "Session Manager",
"subtitle": "Manage Claude Code, Codex, OpenCode, OpenClaw, Hermes and Gemini CLI sessions",
"subtitle": "Manage Claude Code, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw and Hermes sessions",
"searchPlaceholder": "Search by content, directory, or ID",
"searchSessions": "Search sessions",
"providerFilterAll": "All",
@@ -1719,6 +1732,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
+15 -1
View File
@@ -231,6 +231,7 @@
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功!",
"codexRestartRequired": "切り替えました。反映するにはクライアントを再起動してください",
"grokBuildRestartRequired": "切り替えました。反映するには Grok Build を再起動してください",
"claudeDesktopRestartRequired": "切り替えました。反映するには Claude Desktop を再起動してください",
"claudeDesktopProxyRestartRequired": "切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください",
"addToConfigSuccess": "設定に追加しました",
@@ -756,6 +757,8 @@
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"grokConfigDir": "Grok Build 設定ディレクトリ",
"grokConfigDirDescription": "Grok Build の設定ディレクトリ(config.toml)を上書きします。",
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
"openclawConfigDir": "OpenClaw 設定ディレクトリ",
@@ -765,6 +768,7 @@
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browsePlaceholderGrok": "例: /home/<your-username>/.grok",
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
"browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw",
"browsePlaceholderHermes": "例: /home/<your-username>/.hermes",
@@ -875,13 +879,22 @@
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
},
"grokBuild": {
"apiBackend": "API Backend",
"contextWindow": "コンテキストウィンドウ",
"contextWindowInvalid": "コンテキストウィンドウは正の整数で指定してください",
"profile": "クライアントモデルプロファイル",
"rawConfig": "config.toml",
"invalidToml": "config.toml の形式が不正です: {{error}}"
},
"sessionManager": {
"title": "セッション管理",
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw / Hermes / Gemini CLI のセッションを管理",
"subtitle": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes のセッションを管理",
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
"searchSessions": "セッションを検索",
"providerFilterAll": "すべて",
@@ -1719,6 +1732,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
+15 -1
View File
@@ -231,6 +231,7 @@
"providerDeleted": "供應商刪除成功",
"switchSuccess": "切換成功!",
"codexRestartRequired": "切換成功,請重新啟動客戶端以套用",
"grokBuildRestartRequired": "切換成功,請重新啟動 Grok Build 以套用",
"claudeDesktopRestartRequired": "切換成功,重新啟動 Claude Desktop 後生效",
"claudeDesktopProxyRestartRequired": "切換成功,請保持 CC Switch 執行,並重新啟動 Claude Desktop 後生效",
"addToConfigSuccess": "已新增至設定",
@@ -756,6 +757,8 @@
"codexConfigDirDescription": "覆寫 Codex 設定目錄。",
"geminiConfigDir": "Gemini 設定目錄",
"geminiConfigDirDescription": "覆寫 Gemini 設定目錄 (.env)。",
"grokConfigDir": "Grok Build 設定目錄",
"grokConfigDirDescription": "覆寫 Grok Build 設定目錄 (config.toml)。",
"opencodeConfigDir": "OpenCode 設定目錄",
"opencodeConfigDirDescription": "覆寫 OpenCode 設定目錄 (opencode.json)。",
"openclawConfigDir": "OpenClaw 設定目錄",
@@ -765,6 +768,7 @@
"browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude",
"browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex",
"browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini",
"browsePlaceholderGrok": "例如:/home/<您的帳號>/.grok",
"browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode",
"browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw",
"browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes",
@@ -846,13 +850,22 @@
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
},
"grokBuild": {
"apiBackend": "API Backend",
"contextWindow": "上下文視窗",
"contextWindowInvalid": "上下文視窗必須是正整數",
"profile": "用戶端模型檔位",
"rawConfig": "config.toml",
"invalidToml": "config.toml 格式錯誤:{{error}}"
},
"sessionManager": {
"title": "工作階段管理",
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClawHermes 與 Gemini CLI 工作階段紀錄",
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClawHermes 工作階段紀錄",
"searchPlaceholder": "搜尋對話內容、目錄或 ID",
"searchSessions": "搜尋工作階段",
"providerFilterAll": "全部",
@@ -1691,6 +1704,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
+15 -1
View File
@@ -231,6 +231,7 @@
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!",
"codexRestartRequired": "切换成功,请重启客户端以生效",
"grokBuildRestartRequired": "切换成功,请重启 Grok Build 以生效",
"claudeDesktopRestartRequired": "切换成功,重启 Claude Desktop 后生效",
"claudeDesktopProxyRestartRequired": "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效",
"addToConfigSuccess": "已添加到配置",
@@ -756,6 +757,8 @@
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
"geminiConfigDir": "Gemini 配置目录",
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
"grokConfigDir": "Grok Build 配置目录",
"grokConfigDirDescription": "覆盖 Grok Build 配置目录 (config.toml)。",
"opencodeConfigDir": "OpenCode 配置目录",
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
"openclawConfigDir": "OpenClaw 配置目录",
@@ -765,6 +768,7 @@
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
"browsePlaceholderGrok": "例如:/home/<你的用户名>/.grok",
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
"browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw",
"browsePlaceholderHermes": "例如:/home/<你的用户名>/.hermes",
@@ -875,13 +879,22 @@
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
},
"grokBuild": {
"apiBackend": "API Backend",
"contextWindow": "上下文窗口",
"contextWindowInvalid": "上下文窗口必须是正整数",
"profile": "客户端模型档位",
"rawConfig": "config.toml",
"invalidToml": "config.toml 格式错误:{{error}}"
},
"sessionManager": {
"title": "会话管理",
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClawHermes 与 Gemini CLI 会话记录",
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClawHermes 会话记录",
"searchPlaceholder": "搜索会话内容、目录或 ID",
"searchSessions": "搜索会话",
"providerFilterAll": "全部",
@@ -1719,6 +1732,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
+2
View File
@@ -7,6 +7,7 @@ export type AppType =
| "claude-desktop"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
@@ -17,6 +18,7 @@ export interface SkillApps {
"claude-desktop"?: boolean;
codex: boolean;
gemini: boolean;
grokbuild?: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
+1
View File
@@ -4,6 +4,7 @@ export type AppId =
| "claude-desktop"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
+3 -1
View File
@@ -227,7 +227,9 @@ export function useSetAutoFailoverEnabled() {
? "Claude"
: variables.appType === "codex"
? "Codex"
: "Gemini";
: variables.appType === "grokbuild"
? "Grok Build"
: "Gemini";
toast.success(
variables.enabled
+1
View File
@@ -24,6 +24,7 @@ export const settingsSchema = z.object({
claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(),
geminiConfigDir: directorySchema.nullable().optional(),
grokConfigDir: directorySchema.nullable().optional(),
opencodeConfigDir: directorySchema.nullable().optional(),
openclawConfigDir: directorySchema.nullable().optional(),
+4
View File
@@ -280,6 +280,7 @@ export interface VisibleApps {
"claude-desktop": boolean;
codex: boolean;
gemini: boolean;
grokbuild: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
@@ -394,6 +395,8 @@ export interface Settings {
codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string;
// 覆盖 Grok Build 配置目录(可选)
grokConfigDir?: string;
// 覆盖 OpenCode 配置目录(可选)
opencodeConfigDir?: string;
// 覆盖 OpenClaw 配置目录(可选)
@@ -488,6 +491,7 @@ export interface McpApps {
"claude-desktop"?: boolean;
codex: boolean;
gemini: boolean;
grokbuild?: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
+1
View File
@@ -46,6 +46,7 @@ export interface ProxyTakeoverStatus {
"claude-desktop"?: boolean;
codex: boolean;
gemini: boolean;
grokbuild: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
+3 -1
View File
@@ -172,7 +172,7 @@ export interface UsageRangeSelection {
* `opencode` / `openclaw` / `hermes` have no proxy handler at all they
* appear only as managed apps elsewhere.
*/
export type AppType = "claude" | "codex" | "gemini" | "opencode";
export type AppType = "claude" | "codex" | "gemini" | "grokbuild" | "opencode";
export type AppTypeFilter = "all" | AppType;
@@ -180,6 +180,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
"claude",
"codex",
"gemini",
"grokbuild",
"opencode",
];
@@ -198,6 +199,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
"codex",
"gemini",
"grokbuild",
]);
/** Subset of request-log fields needed to derive cache-normalized input. */
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { parse as parseToml } from "smol-toml";
import {
buildGrokBuildConfig,
extractGrokBuildBaseUrl,
parseGrokBuildConfig,
updateGrokBuildConfig,
validateGrokBuildConfig,
} from "./grokBuildConfig";
describe("Grok Build config", () => {
it("builds the expected provider TOML", () => {
const config = buildGrokBuildConfig({
model: "grok-4.5",
baseUrl: "https://relay.example.com/v1",
name: 'Relay "A"',
apiKey: "secret",
apiBackend: "responses",
contextWindow: 500000,
});
const parsed = parseToml(config) as any;
expect(parsed.models.default).toBe("grok-4.5");
expect(parsed.model["grok-4.5"]).toEqual({
model: "grok-4.5",
base_url: "https://relay.example.com/v1",
name: 'Relay "A"',
api_key: "secret",
api_backend: "responses",
context_window: 500000,
});
expect(config).toContain('[model."grok-4.5"]');
});
it("reads values back from a generated config", () => {
const config = buildGrokBuildConfig({
model: "custom-model",
upstreamModel: "upstream-model",
baseUrl: "https://api.example.com",
name: "Custom",
apiKey: "key",
envKey: "",
apiBackend: "responses",
contextWindow: 320000,
});
expect(parseGrokBuildConfig(config)).toEqual({
model: "custom-model",
upstreamModel: "upstream-model",
baseUrl: "https://api.example.com",
name: "Custom",
apiKey: "key",
envKey: "",
apiBackend: "responses",
contextWindow: 320000,
});
expect(extractGrokBuildBaseUrl(config)).toBe("https://api.example.com");
});
it("accepts env_key credentials without adding an empty api_key", () => {
const config = `[models]
default = "env-profile"
[model."env-profile"]
model = "grok-4.5"
base_url = "https://api.example.com/v1"
name = "Env Relay"
env_key = "XAI_API_KEY"
api_backend = "responses"
context_window = 500000
`;
expect(validateGrokBuildConfig(config)).toBeNull();
expect(parseGrokBuildConfig(config).envKey).toBe("XAI_API_KEY");
const updated = updateGrokBuildConfig(config, {
...parseGrokBuildConfig(config),
baseUrl: "https://updated.example.com/v1",
});
const parsed = parseToml(updated) as any;
expect(parsed.model["env-profile"].env_key).toBe("XAI_API_KEY");
expect(parsed.model["env-profile"]).not.toHaveProperty("api_key");
});
it("reports malformed, incomplete, and invalid-window configs", () => {
expect(validateGrokBuildConfig("")).toBe("config.toml must not be empty");
expect(validateGrokBuildConfig("[models")).not.toBeNull();
expect(validateGrokBuildConfig('[models]\ndefault = "missing"\n')).toBe(
"Missing [models] default model table",
);
const missingCredentials = buildGrokBuildConfig({
model: "grok-4.5",
baseUrl: "https://api.example.com/v1",
name: "Relay",
apiKey: "",
apiBackend: "responses",
contextWindow: 500000,
});
expect(validateGrokBuildConfig(missingCredentials)).toBe(
"Missing api_key or env_key",
);
const invalidWindow = missingCredentials.replace(
"context_window = 500000",
"context_window = 0",
);
expect(validateGrokBuildConfig(invalidWindow)).toBe(
"Missing api_key or env_key",
);
expect(
validateGrokBuildConfig(
invalidWindow.replace(
'name = "Relay"',
'name = "Relay"\napi_key = "secret"',
),
),
).toBe("context_window must be a positive integer");
});
it("renames the selected profile without leaving the old table behind", () => {
const original = buildGrokBuildConfig({
model: "old-profile",
upstreamModel: "grok-upstream",
baseUrl: "https://api.example.com/v1",
name: "Relay",
apiKey: "secret",
apiBackend: "responses",
contextWindow: 500000,
});
const renamed = updateGrokBuildConfig(original, {
...parseGrokBuildConfig(original),
model: "new-profile",
});
const parsed = parseToml(renamed) as any;
expect(parsed.models.default).toBe("new-profile");
expect(parsed.model["new-profile"].model).toBe("grok-upstream");
expect(parsed.model).not.toHaveProperty("old-profile");
});
});

Some files were not shown because too many files have changed in this diff Show More