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
+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";