From 615188b3905b52bbe8fddf8d19db0f36b9cbde61 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 21 Sep 2026 15:00:11 +0800 Subject: [PATCH] feat(agents): add Antigravity IDE and CLI support - Add separate configuration entries for IDE and CLI - Integrate Gemini-compatible routing and scoped credentials - Support detection, launch, backup, restore, and IDE restart - Add localized guidance and configuration, UI, and native integration tests --- README.ja.md | 4 + README.md | 4 + README.zh-CN.md | 4 + src-tauri/Cargo.toml | 1 + src-tauri/src/agents.rs | 2 + src-tauri/src/agents/antigravity.rs | 539 ++++++++++++++++++++++ src-tauri/src/agents/antigravity/tests.rs | 338 ++++++++++++++ src-tauri/src/agents/commands.rs | 2 + src-tauri/src/agents/configuration.rs | 6 + src-tauri/src/agents/discovery.rs | 12 +- src-tauri/src/agents/launch.rs | 17 +- src-tauri/src/agents/state.rs | 4 + src-tauri/src/agents/windows_probe.rs | 13 + src-tauri/src/configuration_watcher.rs | 2 + src-tauri/src/main.rs | 18 + src-tauri/src/tests/agent_paths.rs | 3 + src/i18n/ja.ts | 4 + src/i18n/locales/en.ts | 4 + src/i18n/locales/zh-CN.ts | 4 + src/pages/AgentsPage.tsx | 13 +- tests/agent-management-ui.cjs | 4 +- tests/antigravity-native-probe.py | 124 +++++ tests/fixtures/agent-backups.tsx | 4 +- 23 files changed, 1115 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/agents/antigravity.rs create mode 100644 src-tauri/src/agents/antigravity/tests.rs create mode 100644 tests/antigravity-native-probe.py diff --git a/README.ja.md b/README.ja.md index d8ec82f..3ae74e9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -100,12 +100,16 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - Pi(CLIProxyAPI provider 拡張機能) - ZCode - WorkBuddy / WorkBuddy AI +- Antigravity IDE +- Antigravity CLI - Kimi Code - Grok Build 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 +Antigravity IDE と Antigravity CLI は設定ファイルが異なるため、個別のエントリーとして表示されます。どちらも CPA の Gemini 互換 API に接続します。IDE は設定の適用・解除後に再起動してください。IDE 内のモデル名表示は変わりませんが、リクエストには CPA で選択したモデルが使われます。CLI は CPA から起動すると、そのプロセスに接続先と API キーが渡されます。`agy` を直接実行する場合は環境変数を自分で設定してください。 + ## その他の機能 - コア設定、API Key、リモート管理用認証情報、ルーティング戦略の管理。 diff --git a/README.md b/README.md index 841b0e8..03c40af 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ proxy. Supported clients include: - Pi (with the CLIProxyAPI provider extension) - ZCode - WorkBuddy / WorkBuddy AI +- Antigravity IDE +- Antigravity CLI - Kimi Code - Grok Build @@ -125,6 +127,8 @@ For supported clients, the application can synchronize the available model catal default model, back up the original configuration before applying managed settings, and restore the previous configuration. +Antigravity IDE and Antigravity CLI have separate entries because they use different configuration files. Both connect through CPA's Gemini-compatible API. Restart the IDE after applying or disabling settings; its model labels stay unchanged, while requests use the model selected in CPA. Launch the CLI from CPA to supply its endpoint and API key for that process. Running `agy` directly requires setting those environment variables yourself. + ## Additional Capabilities - Manage core settings, API keys, remote management credentials, and routing strategy. diff --git a/README.zh-CN.md b/README.zh-CN.md index c77cd70..a2f53a9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -109,6 +109,8 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - Pi(通过 CLIProxyAPI provider 插件) - ZCode - WorkBuddy / WorkBuddy AI +- Antigravity IDE +- Antigravity CLI - Kimi Code - Grok Build @@ -119,6 +121,8 @@ WorkBuddy 接入沿用模型选择、更新配置、关闭配置修改、手动 支持 WorkBuddy 与 WorkBuddy AI 的 `models.json` 对象及数组格式,并保留已有自定义模型。 应用后需在 WorkBuddy 内选择 `CPA:模型名`;已有会话不会自动切换模型,未显示时可重启 App。 +Antigravity IDE 与 Antigravity CLI 使用不同的配置文件,因此提供独立入口,均通过 CPA 的 Gemini 兼容接口连接。IDE 应用或关闭配置后需要重启;IDE 内的模型标签保持不变,实际请求使用 CPA 中选定的模型。CLI 请从 CPA 启动,以便为该进程注入接口地址和密钥;直接运行 `agy` 时需自行设置环境变量。 + ## 其他功能 - 管理内核配置、API Key、远程管理凭证和路由策略。 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 056928c..fdbcecd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -58,6 +58,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Environment", + "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index d03b8cf..35d95c8 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -11,6 +11,7 @@ mod state; mod templates; mod transactions; mod workbuddy; +mod antigravity; #[cfg(target_os = "windows")] mod windows_probe; pub(crate) use backups::*; @@ -24,5 +25,6 @@ pub(crate) use state::*; pub(crate) use templates::*; pub(crate) use transactions::*; pub(crate) use workbuddy::*; +pub(crate) use antigravity::*; #[cfg(target_os = "windows")] pub(crate) use windows_probe::*; diff --git a/src-tauri/src/agents/antigravity.rs b/src-tauri/src/agents/antigravity.rs new file mode 100644 index 0000000..0fb4262 --- /dev/null +++ b/src-tauri/src/agents/antigravity.rs @@ -0,0 +1,539 @@ +use super::*; +use serde_json::{json, Value}; + +#[cfg(test)] +mod tests; + +pub(crate) const ANTIGRAVITY_CONNECTION_FILE: &str = "cpa-connection.json"; +const CLI_MODEL_LABEL: &str = "CPA (EasyCLIProxyAPI)"; +const IDE_BINARY_KEY: &str = "codeiumDev.languageServerBinaryPath"; +const IDE_ENV_KEY: &str = "codeiumDev.languageServerEnv"; +const IDE_SERVER_ENV: &str = "CPA_ANTIGRAVITY_LANGUAGE_SERVER"; +const IDE_MODEL_ENV: &str = "CPA_ANTIGRAVITY_MODEL"; + +pub(crate) fn antigravity_config_paths(client: AgentClient, home: &Path) -> Vec { + if client == AgentClient::AntigravityCli { + let directory = home.join(".gemini/antigravity-cli"); + return vec![ + directory.join("settings.json"), + directory.join(ANTIGRAVITY_CONNECTION_FILE), + ]; + } + #[cfg(target_os = "windows")] + let directory = + agent_configuration_environment("APPDATA").unwrap_or_else(|| home.join("AppData/Roaming")); + #[cfg(target_os = "macos")] + let directory = home.join("Library/Application Support"); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let directory = agent_configuration_environment("XDG_CONFIG_HOME") + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(".config")); + let current = directory.join("Antigravity IDE/User/settings.json"); + let legacy = directory.join("Antigravity/User/settings.json"); + vec![if !current.exists() && legacy.exists() { + legacy + } else { + current + }] +} + +pub(crate) fn find_antigravity_cli(home: &Path) -> Option { + #[cfg(target_os = "windows")] + let native = agent_configuration_environment("LOCALAPPDATA") + .unwrap_or_else(|| home.join("AppData/Local")) + .join("agy/bin/agy.exe"); + #[cfg(not(target_os = "windows"))] + let native = home.join(".local/bin/agy"); + if native.is_file() { + Some(native) + } else { + find_named_agent_executable(home, &["agy"]) + } +} + +pub(crate) fn find_antigravity_ide(home: &Path) -> Option { + let mut candidates = Vec::new(); + #[cfg(target_os = "windows")] + { + let local = agent_configuration_environment("LOCALAPPDATA") + .unwrap_or_else(|| home.join("AppData/Local")); + let mut roots = vec![local.join("Programs")]; + for name in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(root) = agent_configuration_environment(name) { + roots.push(root); + } + } + for root in roots { + candidates.push(root.join("Antigravity IDE/Antigravity IDE.exe")); + candidates.push(root.join("Antigravity/Antigravity.exe")); + } + } + #[cfg(target_os = "macos")] + for root in [PathBuf::from("/Applications"), home.join("Applications")] { + for name in ["Antigravity IDE", "Antigravity"] { + candidates.push(root.join(format!("{name}.app/Contents/MacOS/Electron"))); + } + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + candidates.extend([ + PathBuf::from("/usr/share/antigravity-ide/antigravity-ide"), + PathBuf::from("/opt/antigravity-ide/antigravity-ide"), + PathBuf::from("/usr/share/antigravity/antigravity"), + home.join(".local/share/antigravity-ide/antigravity-ide"), + ]); + } + let found = candidates.into_iter().find(|p| p.is_file()); + #[cfg(all(target_os = "windows", not(test)))] + let found = found.or_else(find_windows_registered_antigravity_ide); + found +} + +fn antigravity_resources(executable: &Path) -> Option { + #[cfg(target_os = "macos")] + { + Some(executable.parent()?.parent()?.join("Resources/app")) + } + #[cfg(not(target_os = "macos"))] + { + Some(executable.parent()?.join("resources/app")) + } +} + +pub(crate) fn read_antigravity_ide_version(executable: &Path) -> Option { + let value: Value = serde_json::from_str( + &fs::read_to_string(antigravity_resources(executable)?.join("product.json")).ok()?, + ) + .ok()?; + value + .get("ideVersion") + .and_then(Value::as_str) + .map(str::to_string) +} + +fn antigravity_language_server(executable: &Path) -> Option { + let platform = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "arm" + } else { + "x64" + }; + let suffix = if cfg!(target_os = "windows") { + ".exe" + } else { + "" + }; + let server = antigravity_resources(executable)?.join(format!( + "extensions/antigravity/bin/language_server_{platform}_{arch}{suffix}" + )); + server.is_file().then_some(server) +} + +fn object_at<'a>(root: &'a mut Value, keys: &[&str]) -> Result<&'a mut Value, String> { + let mut value = root; + for key in keys { + if !value.is_object() { + return Err("Antigravity 配置字段必须是对象".into()); + } + value = value + .as_object_mut() + .unwrap() + .entry((*key).to_string()) + .or_insert_with(|| json!({})); + } + if !value.is_object() { + return Err("Antigravity 配置字段必须是对象".into()); + } + Ok(value) +} + +pub(crate) fn build_antigravity_config( + client: AgentClient, + path: &Path, + existing: Option<&str>, + origin: &str, + api_key: &str, + model: &str, + server: Option<&Path>, +) -> Result { + let mut root = parse(path, existing)?; + if !root.is_object() { + return Err("Antigravity 配置根节点必须是对象".into()); + } + if client == AgentClient::AntigravityIde { + let server = server.ok_or("未找到 Antigravity IDE 语言服务,请安装 IDE 后重新检测")?; + let helper = env::current_exe().map_err(|_| "无法定位 CPA 启动适配程序")?; + let environment = object_at(&mut root, &[IDE_ENV_KEY])?; + environment[IDE_SERVER_ENV] = json!(server); + environment[IDE_MODEL_ENV] = json!(model); + environment["GEMINI_API_KEY"] = json!(api_key); + environment["GOOGLE_GEMINI_BASE_URL"] = json!(origin); + root[IDE_BINARY_KEY] = json!(helper); + } else if path + .file_name() + .is_some_and(|n| n == ANTIGRAVITY_CONNECTION_FILE) + { + root["provider"] = json!(MANAGED_AGENT_PROVIDER_ID); + root["baseUrl"] = json!(origin); + root["apiKey"] = json!(api_key); + root["model"] = json!(model); + } else { + let entry = object_at( + &mut root, + &["customModelsConfig", "customModels", CLI_MODEL_LABEL], + )?; + entry["modelName"] = json!(model); + root["modelProvider"] = json!("gemini"); + } + render(path, &root) +} + +pub(crate) fn build_antigravity_updates( + client: AgentClient, + home: &Path, + origin: &str, + api_key: &str, + model: &str, + template: bool, +) -> Result, String> { + let server = if client == AgentClient::AntigravityIde { + find_antigravity_ide(home) + .as_deref() + .and_then(antigravity_language_server) + } else { + None + }; + antigravity_config_paths(client, home) + .into_iter() + .map(|path| { + let before = if template { + None + } else { + read_optional_text(&path)? + }; + let after = build_antigravity_config( + client, + &path, + before.as_deref(), + origin, + api_key, + model, + server.as_deref(), + )?; + Ok(AgentFileUpdate { path, after }) + }) + .collect() +} + +fn managed_fields(client: AgentClient, path: &Path) -> Vec> { + if client == AgentClient::AntigravityIde { + vec![ + vec![IDE_BINARY_KEY], + vec![IDE_ENV_KEY, IDE_SERVER_ENV], + vec![IDE_ENV_KEY, IDE_MODEL_ENV], + vec![IDE_ENV_KEY, "GEMINI_API_KEY"], + vec![IDE_ENV_KEY, "GOOGLE_GEMINI_BASE_URL"], + ] + } else if path + .file_name() + .is_some_and(|n| n == ANTIGRAVITY_CONNECTION_FILE) + { + ["provider", "baseUrl", "apiKey", "model"] + .into_iter() + .map(|k| vec![k]) + .collect() + } else { + vec![ + vec!["modelProvider"], + vec![ + "customModelsConfig", + "customModels", + CLI_MODEL_LABEL, + "modelName", + ], + ] + } +} + +fn field<'a>(root: &'a Value, keys: &[&str]) -> Option<&'a Value> { + keys.iter().try_fold(root, |v, k| v.get(*k)) +} + +fn replace_field(root: &mut Value, keys: &[&str], value: Option<&Value>) { + let Some(map) = root.as_object_mut() else { + return; + }; + if keys.len() == 1 { + if let Some(value) = value { + map.insert(keys[0].into(), value.clone()); + } else { + map.remove(keys[0]); + } + } else { + if value.is_some() { + map.entry(keys[0]).or_insert_with(|| json!({})); + } + if let Some(child) = map.get_mut(keys[0]) { + replace_field(child, &keys[1..], value); + if child.as_object().is_some_and(|m| m.is_empty()) { + map.remove(keys[0]); + } + } + } +} + +pub(crate) fn restore_antigravity_config( + client: AgentClient, + path: &Path, + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut root = parse(path, Some(current))?; + let before = parse(path, original)?; + for keys in managed_fields(client, path) { + replace_field(&mut root, &keys, field(&before, &keys)); + } + if original.is_none() && root.as_object().is_some_and(|m| m.is_empty()) { + Ok(None) + } else { + render(path, &root).map(Some) + } +} + +pub(crate) fn antigravity_has_marker( + client: AgentClient, + paths: &[PathBuf], +) -> Result { + let path = if client == AgentClient::AntigravityIde { + &paths[0] + } else { + paths.get(1).ok_or("Antigravity CLI 配置路径缺失")? + }; + let root = parse(path, read_optional_text(path)?.as_deref())?; + Ok(if client == AgentClient::AntigravityIde { + field(&root, &[IDE_ENV_KEY, IDE_SERVER_ENV]).is_some() + } else { + root.get("provider").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) + }) +} + +pub(crate) fn inspect_antigravity_config( + client: AgentClient, + paths: &[PathBuf], + port: u16, + api_key: &str, +) -> Result<(bool, Option), String> { + let settings = parse(&paths[0], read_optional_text(&paths[0])?.as_deref())?; + let origin = managed_core_loopback_origin(port); + if client == AgentClient::AntigravityIde { + let environment = settings.get(IDE_ENV_KEY).unwrap_or(&Value::Null); + let model = environment + .get(IDE_MODEL_ENV) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let helper = env::current_exe().ok(); + let configured = model.is_some() + && settings + .get(IDE_BINARY_KEY) + .and_then(Value::as_str) + .map(Path::new) + == helper.as_deref() + && environment + .get(IDE_SERVER_ENV) + .and_then(Value::as_str) + .is_some_and(|p| Path::new(p).is_file()) + && environment.get("GEMINI_API_KEY").and_then(Value::as_str) == Some(api_key) + && environment + .get("GOOGLE_GEMINI_BASE_URL") + .and_then(Value::as_str) + == Some(&origin); + Ok((configured, model)) + } else { + let path = paths.get(1).ok_or("Antigravity CLI 配置路径缺失")?; + let connection = parse(path, read_optional_text(path)?.as_deref())?; + let model = connection + .get("model") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let configured = model.is_some() + && settings.get("modelProvider").and_then(Value::as_str) == Some("gemini") + && field( + &settings, + &[ + "customModelsConfig", + "customModels", + CLI_MODEL_LABEL, + "modelName", + ], + ) + .and_then(Value::as_str) + == model.as_deref() + && connection.get("provider").and_then(Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + && connection.get("baseUrl").and_then(Value::as_str) == Some(&origin) + && connection.get("apiKey").and_then(Value::as_str) == Some(api_key); + Ok((configured, model)) + } +} + +pub(crate) fn prepare_antigravity_removal( + client: AgentClient, + paths: &[PathBuf], +) -> Result { + if !antigravity_has_marker(client, paths)? { + return Ok(Vec::new()); + } + paths + .iter() + .map(|path| { + let value = read_optional_text(path)? + .map(|current| restore_antigravity_config(client, path, ¤t, None)) + .transpose()? + .flatten(); + Ok((path.clone(), value.map(String::into_bytes))) + }) + .collect() +} + +pub(crate) fn antigravity_cli_helper_arguments(home: &Path) -> Vec { + vec!["--cpa-antigravity-cli".into(), path_to_string(home)] +} + +pub(crate) fn antigravity_helper_requested(args: &[std::ffi::OsString]) -> bool { + args.get(1).is_some_and(|a| a == "--cpa-antigravity-cli") + || (env::var_os(IDE_SERVER_ENV).is_some() + && args.iter().skip(1).any(|a| { + a == "--extension_server_port" + || a.to_string_lossy().starts_with("--extension_server_port=") + })) +} + +fn antigravity_helper_command(args: &[std::ffi::OsString]) -> Result { + let cli = args.get(1).is_some_and(|a| a == "--cpa-antigravity-cli"); + let mut command = if cli { + let home = args + .get(2) + .map(PathBuf::from) + .ok_or("Antigravity CLI 启动参数缺少用户目录")?; + let paths = antigravity_config_paths(AgentClient::AntigravityCli, &home); + let connection = parse(&paths[1], read_optional_text(&paths[1])?.as_deref())?; + if connection.get("provider").and_then(Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID) { + return Err("Antigravity CLI 的 CPA 配置已关闭,请重新应用配置".into()); + } + let key = connection + .get("apiKey") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or("Antigravity CLI 密钥缺失")?; + let url = connection + .get("baseUrl") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or("Antigravity CLI 地址缺失")?; + let model = connection + .get("model") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or("Antigravity CLI 模型缺失")?; + let settings = parse(&paths[0], read_optional_text(&paths[0])?.as_deref())?; + if settings.get("modelProvider").and_then(Value::as_str) != Some("gemini") + || field( + &settings, + &[ + "customModelsConfig", + "customModels", + CLI_MODEL_LABEL, + "modelName", + ], + ) + .and_then(Value::as_str) + != Some(model) + { + return Err("Antigravity CLI 配置已变化,请重新应用配置".into()); + } + let executable = find_antigravity_cli(&home).ok_or("未找到 Antigravity CLI")?; + let mut command = Command::new(executable); + command + .arg(format!("--gemini_dir={}", home.join(".gemini").display())) + .arg(format!("--model={CLI_MODEL_LABEL}")) + .args(args.iter().skip(3)) + .env("GEMINI_API_KEY", key) + .env("GOOGLE_GEMINI_BASE_URL", url); + command + } else { + let executable = env::var_os(IDE_SERVER_ENV) + .map(PathBuf::from) + .ok_or("Antigravity IDE 语言服务路径缺失")?; + if Some(&executable) == env::current_exe().ok().as_ref() { + return Err("Antigravity IDE 语言服务路径不能指向 CPA".into()); + } + let model = env::var(IDE_MODEL_ENV).map_err(|_| "Antigravity IDE 模型缺失")?; + if model.trim().is_empty() { + return Err("Antigravity IDE 模型缺失".into()); + } + let mut command = Command::new(executable); + command + .args(args.iter().skip(1)) + .arg("--model_api_client_type=gemini") + .arg(format!("--override_model_name={model}")); + command + }; + command + .env_remove("GOOGLE_API_KEY") + .env_remove(IDE_SERVER_ENV) + .env_remove(IDE_MODEL_ENV); + Ok(command) +} + +pub(crate) fn run_antigravity_helper(args: &[std::ffi::OsString]) -> Result { + let mut command = antigravity_helper_command(args)?; + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + if args.get(1).is_some_and(|a| a == "--cpa-antigravity-cli") { + unsafe { + windows_sys::Win32::System::Console::AttachConsole(u32::MAX); + } + } else { + command.creation_flags(0x0800_0000); + } + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + return Err(format!("启动 Antigravity 失败: {}", command.exec())); + } + #[cfg(not(unix))] + { + let mut child = command + .spawn() + .map_err(|error| format!("启动 Antigravity 失败: {error}"))?; + #[cfg(target_os = "windows")] + let job = match attach_child_to_windows_job(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + let result = child.wait(); + #[cfg(target_os = "windows")] + close_windows_handle(job); + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + result + .map(|s| s.code().unwrap_or(1)) + .map_err(|error| format!("等待 Antigravity 退出失败: {error}")) + } +} diff --git a/src-tauri/src/agents/antigravity/tests.rs b/src-tauri/src/agents/antigravity/tests.rs new file mode 100644 index 0000000..5f9d9e4 --- /dev/null +++ b/src-tauri/src/agents/antigravity/tests.rs @@ -0,0 +1,338 @@ +use super::*; + +struct Home(PathBuf); +impl Home { + fn new() -> Self { + let path = env::temp_dir().join(format!( + "cpa-antigravity-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn save(&self, path: &Path, value: &Value) { + assert!(path.starts_with(&self.0)); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, serde_json::to_vec(value).unwrap()).unwrap(); + } + fn apply(&self, client: AgentClient, model: &str) { + let paths = config_paths(client.id(), &self.0).unwrap(); + let before = config_images(&paths).unwrap(); + let server = self.0.join("language_server.exe"); + fs::write(&server, b"fixture").unwrap(); + let updates = paths + .iter() + .map(|path| AgentFileUpdate { + path: path.clone(), + after: build_antigravity_config( + client, + path, + read_optional_text(path).unwrap().as_deref(), + "http://127.0.0.1:8317", + "test-key", + model, + Some(&server), + ) + .unwrap(), + }) + .collect::>(); + config_updates( + client.id(), + &self.0, + &before, + &updates, + "update", + Some(model.into()), + None, + ) + .unwrap(); + assert_eq!( + inspect_antigravity_config(client, &paths, 8317, "test-key").unwrap(), + (true, Some(model.into())) + ); + } +} +impl Drop for Home { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn antigravity_clients_have_independent_paths_and_launch_targets() { + let home = Home::new(); + let ide = antigravity_config_paths(AgentClient::AntigravityIde, &home.0); + let cli = antigravity_config_paths(AgentClient::AntigravityCli, &home.0); + assert!(ide + .iter() + .all(|p| !cli.contains(p) && p.starts_with(&home.0))); + assert_eq!(cli[0], home.0.join(".gemini/antigravity-cli/settings.json")); + for (client, target) in [ + (AgentClient::AntigravityIde, "app"), + (AgentClient::AntigravityCli, "cli"), + ] { + let targets = agent_launch_targets(client, Some(Path::new("fixture")), None, false); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, target); + } + assert!(!should_probe_primary_agent_executable_version( + AgentClient::AntigravityIde + )); + assert!(should_probe_primary_agent_executable_version( + AgentClient::AntigravityCli + )); +} + +#[test] +fn antigravity_updates_and_disable_preserve_external_settings() { + for client in [AgentClient::AntigravityIde, AgentClient::AntigravityCli] { + let home = Home::new(); + let paths = config_paths(client.id(), &home.0).unwrap(); + let original = if client == AgentClient::AntigravityIde { + json!({"editor.fontSize": 15, IDE_BINARY_KEY: "original-server", IDE_ENV_KEY: {"KEEP":"original", "GEMINI_API_KEY":"original-key"}}) + } else { + json!({"modelProvider":"original", "colorScheme":"dark", "customModelsConfig":{"customModels":{"User Model":{"modelName":"user"},CLI_MODEL_LABEL:{"modelName":"original", "maxTokens":123}}}}) + }; + home.save(&paths[0], &original); + home.apply(client, "model-one"); + let applied = config_images(&paths).unwrap(); + home.apply(client, "model-one"); + assert_eq!(config_images(&paths).unwrap(), applied); + let mut current = + parse(&paths[0], read_optional_text(&paths[0]).unwrap().as_deref()).unwrap(); + current["user-added"] = json!({"keep":[1,2]}); + home.save(&paths[0], ¤t); + home.apply(client, "model-two"); + assert!( + !inspect_antigravity_config(client, &paths, 8318, "test-key") + .unwrap() + .0 + ); + assert!( + !inspect_antigravity_config(client, &paths, 8317, "other-key") + .unwrap() + .0 + ); + clear_agent_managed_configuration(client, &home.0, 8317).unwrap(); + let restored = parse(&paths[0], read_optional_text(&paths[0]).unwrap().as_deref()).unwrap(); + let mut expected = original; + expected["user-added"] = json!({"keep":[1,2]}); + assert_eq!(restored, expected); + if client == AgentClient::AntigravityCli { + assert!(!paths[1].exists()); + } + assert!(!antigravity_has_marker(client, &paths).unwrap()); + } +} + +#[test] +fn antigravity_manual_backup_restores_only_its_client() { + let home = Home::new(); + for client in [AgentClient::AntigravityIde, AgentClient::AntigravityCli] { + home.apply(client, "model-one"); + } + let ide_paths = config_paths("antigravity-ide", &home.0).unwrap(); + let cli_paths = config_paths("antigravity-cli", &home.0).unwrap(); + let ide = config_images(&ide_paths).unwrap(); + let cli = config_images(&cli_paths).unwrap(); + let backup = create_backup("antigravity-cli", &home.0).unwrap(); + home.apply(AgentClient::AntigravityCli, "model-two"); + test_restore_backup(AgentClient::AntigravityCli, &home.0, &backup.id); + assert_eq!(config_images(&cli_paths).unwrap(), cli); + clear_agent_managed_configuration(AgentClient::AntigravityCli, &home.0, 8317).unwrap(); + assert_eq!(config_images(&ide_paths).unwrap(), ide); +} + +#[test] +fn antigravity_rejects_malformed_owned_containers() { + let path = Path::new("settings.json"); + for (client, input) in [ + ( + AgentClient::AntigravityCli, + json!({"customModelsConfig":[]}), + ), + ( + AgentClient::AntigravityCli, + json!({"customModelsConfig":{"customModels":false}}), + ), + (AgentClient::AntigravityIde, json!({IDE_ENV_KEY:"invalid"})), + ] { + assert!(build_antigravity_config( + client, + path, + Some(&input.to_string()), + "http://127.0.0.1:8317", + "key", + "model", + Some(Path::new("server")) + ) + .is_err()); + } +} + +#[test] +fn antigravity_cli_template_covers_settings_and_credentials() { + let home = Home::new(); + let updates = build_agent_template_updates(AgentDefaultConfiguration { + client: AgentClient::AntigravityCli, + home: &home.0, + port: 8317, + api_key: "key", + model: "model", + models: &[], + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + }) + .unwrap(); + assert_eq!(updates.len(), 2); + let paths = config_paths("antigravity-cli", &home.0).unwrap(); + prepare_config_updates( + "antigravity-cli", + &paths, + &config_images(&paths).unwrap(), + &updates, + true, + ) + .unwrap(); +} + +#[test] +fn antigravity_cli_helper_uses_managed_credentials_and_model() { + let home = Home::new(); + home.apply(AgentClient::AntigravityCli, "model-one"); + let binary = if cfg!(windows) { + home.0.join("AppData/Local/agy/bin/agy.exe") + } else { + home.0.join(".local/bin/agy") + }; + fs::create_dir_all(binary.parent().unwrap()).unwrap(); + fs::write(&binary, []).unwrap(); + let args = [ + "cpa".into(), + "--cpa-antigravity-cli".into(), + home.0.clone().into_os_string(), + "--print=hello".into(), + ]; + let command = antigravity_helper_command(&args).unwrap(); + assert_eq!(Path::new(command.get_program()), binary.as_path()); + assert_eq!( + command.get_args().collect::>(), + [ + std::ffi::OsStr::new(&format!( + "--gemini_dir={}", + home.0.join(".gemini").display() + )), + std::ffi::OsStr::new(&format!("--model={CLI_MODEL_LABEL}")), + std::ffi::OsStr::new("--print=hello"), + ] + ); + let variables = command + .get_envs() + .collect::>(); + assert_eq!( + variables[std::ffi::OsStr::new("GEMINI_API_KEY")], + Some(std::ffi::OsStr::new("test-key")) + ); + assert_eq!( + variables[std::ffi::OsStr::new("GOOGLE_GEMINI_BASE_URL")], + Some(std::ffi::OsStr::new("http://127.0.0.1:8317")) + ); + assert_eq!(variables[std::ffi::OsStr::new("GOOGLE_API_KEY")], None); + let paths = antigravity_config_paths(AgentClient::AntigravityCli, &home.0); + let valid = fs::read_to_string(&paths[1]).unwrap(); + for key in ["apiKey", "baseUrl", "model", "provider"] { + let mut connection: Value = serde_json::from_str(&valid).unwrap(); + connection[key] = json!(""); + home.save(&paths[1], &connection); + assert!(antigravity_helper_command(&args).is_err(), "{key}"); + } + fs::write(&paths[1], valid).unwrap(); + home.save(&paths[0], &json!({"modelProvider":"other"})); + assert!(antigravity_helper_command(&args).is_err()); +} + +#[cfg(windows)] +#[test] +fn antigravity_ide_discovery_and_template_use_native_server() { + let home = Home::new(); + let install = home.0.join("AppData/Local/Programs/Antigravity IDE"); + let executable = install.join("Antigravity IDE.exe"); + let resources = install.join("resources/app"); + let server = resources.join("extensions/antigravity/bin/language_server_windows_x64.exe"); + fs::create_dir_all(server.parent().unwrap()).unwrap(); + fs::write(&server, []).unwrap(); + fs::write(&executable, []).unwrap(); + home.save( + &resources.join("product.json"), + &json!({"ideVersion":"2.5.5"}), + ); + assert_eq!(find_antigravity_ide(&home.0), Some(executable.clone())); + assert_eq!( + read_antigravity_ide_version(&executable).as_deref(), + Some("2.5.5") + ); + assert_eq!( + antigravity_language_server(&executable), + Some(server.clone()) + ); + let updates = build_agent_template_updates(AgentDefaultConfiguration { + client: AgentClient::AntigravityIde, + home: &home.0, + port: 8317, + api_key: "test-key", + model: "model-one", + models: &[], + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + }) + .unwrap(); + assert_eq!(updates.len(), 1); + let value = parse(&updates[0].path, Some(&updates[0].after)).unwrap(); + assert_eq!( + Path::new(value[IDE_ENV_KEY][IDE_SERVER_ENV].as_str().unwrap()), + server.as_path() + ); + assert_eq!(value[IDE_ENV_KEY][IDE_MODEL_ENV], "model-one"); + assert_eq!(value[IDE_BINARY_KEY], json!(env::current_exe().unwrap())); + fs::remove_file(&server).unwrap(); + assert!(build_antigravity_updates( + AgentClient::AntigravityIde, + &home.0, + "http://127.0.0.1:8317", + "key", + "model", + false + ) + .is_err()); +} + +#[test] +fn antigravity_removal_without_history_keeps_unmanaged_settings() { + for client in [AgentClient::AntigravityIde, AgentClient::AntigravityCli] { + let home = Home::new(); + home.apply(client, "model-one"); + let paths = antigravity_config_paths(client, &home.0); + let mut settings = + parse(&paths[0], read_optional_text(&paths[0]).unwrap().as_deref()).unwrap(); + settings["externalSetting"] = json!(42); + home.save(&paths[0], &settings); + let removals = prepare_antigravity_removal(client, &paths).unwrap(); + let restored = parse( + &paths[0], + Some(std::str::from_utf8(removals[0].1.as_ref().unwrap()).unwrap()), + ) + .unwrap(); + assert_eq!(restored, json!({"externalSetting":42})); + if client == AgentClient::AntigravityCli { + assert!(removals[1].1.is_none()); + } + } +} diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 336bb66..1241f76 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -30,6 +30,8 @@ pub(crate) fn inspect_agent_config_statuses( AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness), AgentStatusDetectionTarget::Client(AgentClient::ZCode), AgentStatusDetectionTarget::Client(AgentClient::WorkBuddy), + AgentStatusDetectionTarget::Client(AgentClient::AntigravityIde), + AgentStatusDetectionTarget::Client(AgentClient::AntigravityCli), AgentStatusDetectionTarget::Client(AgentClient::KimiCode), AgentStatusDetectionTarget::Client(AgentClient::GrokBuild), AgentStatusDetectionTarget::PiProvider, diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index fa7d70d..a52db9e 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -187,6 +187,7 @@ pub(crate) fn build_agent_updates_with_oauth( }, ]) } + AgentClient::AntigravityIde | AgentClient::AntigravityCli => build_antigravity_updates(client, home, &root_base, api_key, model, false), AgentClient::WorkBuddy => { let before = read_optional_text(&paths[0])?; Ok(vec![AgentFileUpdate { @@ -550,6 +551,9 @@ pub(crate) fn write_agent_configuration_file( { return write_codex_private_file(path, content); } + if matches!(client, AgentClient::AntigravityIde | AgentClient::AntigravityCli) { + return write_codex_private_file(path, content); + } if client == AgentClient::DeepSeekHarness { let owner_only = path.file_name().and_then(|name| name.to_str()) == Some(DEEPSEEK_HARNESS_CREDENTIALS_FILE); @@ -1629,6 +1633,7 @@ pub(crate) fn prepare_agent_managed_removal( AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths), AgentClient::ZCode => prepare_zcode_managed_removal(paths), AgentClient::WorkBuddy => prepare_workbuddy_managed_removal(paths), + AgentClient::AntigravityIde | AgentClient::AntigravityCli => prepare_antigravity_removal(client, paths), AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths), AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths), } @@ -2667,6 +2672,7 @@ pub(crate) fn build_agent_session_restored_bytes_with_preference( } AgentClient::ZCode => build_restored_zcode_config(current, original)?, AgentClient::WorkBuddy => build_restored_workbuddy_config(current, original)?, + AgentClient::AntigravityIde | AgentClient::AntigravityCli => restore_antigravity_config(client, path, current, original)?, AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?, AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?, }; diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 6ae88a3..fe2b432 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -48,6 +48,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![workbuddy_home(home).join("models.json")], + AgentClient::AntigravityIde | AgentClient::AntigravityCli => antigravity_config_paths(client, home), AgentClient::ZCode => vec![ home.join(".zcode/v2").join(ZCODE_CONFIG_FILE), home.join(".zcode/cli").join(ZCODE_CONFIG_FILE), @@ -982,6 +983,7 @@ pub(crate) fn inspect_agent_config( AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version), AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version), + AgentClient::AntigravityIde => executable.as_deref().and_then(read_antigravity_ide_version), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); @@ -1067,13 +1069,13 @@ pub(crate) fn agent_installation_detected( version.is_some() || (matches!( client, - AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy + AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::AntigravityIde ) && executable_found) || app_installed } pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool { - !matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy) + !matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::AntigravityIde) } pub(crate) fn agent_launch_targets( @@ -1127,7 +1129,7 @@ pub(crate) fn agent_launch_targets( }); } } - AgentClient::ZCode | AgentClient::WorkBuddy => { + AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::AntigravityIde => { if let Some(executable) = executable { targets.push(AgentLaunchTarget { id: "app".to_string(), @@ -1264,6 +1266,7 @@ pub(crate) fn inspect_agent_managed_config( false, )) } + AgentClient::AntigravityIde | AgentClient::AntigravityCli => inspect_antigravity_config(client, paths, port, api_key).map(|(configured, model)| (configured, model, false)), AgentClient::WorkBuddy => inspect_workbuddy_agent_config(&paths[0], port, api_key) .map(|(configured, model)| (configured, model, false)), AgentClient::KimiCode => inspect_kimi_code_agent_config(&paths[0], port, api_key) @@ -1473,6 +1476,7 @@ pub(crate) fn agent_has_managed_marker( } AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), AgentClient::WorkBuddy => workbuddy_has_managed_marker(&paths[0]), + AgentClient::AntigravityIde | AgentClient::AntigravityCli => antigravity_has_marker(client, paths), AgentClient::ZCode => { let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); for path in paths { @@ -2389,6 +2393,8 @@ pub(crate) fn find_windows_codex_app_executable(home: &Path) -> Option } pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option { + if client == AgentClient::AntigravityIde { return find_antigravity_ide(home); } + if client == AgentClient::AntigravityCli { return find_antigravity_cli(home); } if client == AgentClient::ClaudeDesktop { return find_claude_desktop_executable(home); } diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 7e4db89..99623dc 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -181,6 +181,19 @@ pub(crate) fn launch_agent( let requested_target = requested_target.unwrap_or(default_target); match (client, requested_target) { (AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home), + (AgentClient::AntigravityIde, "app") => { + let executable = find_antigravity_ide(&home).ok_or("未找到 Antigravity IDE")?; + launch_desktop_agent(&executable, client.name()) + } + (AgentClient::AntigravityCli, "cli") + if antigravity_has_marker(client, &agent_config_paths(client, &home))? => { + if !status.configured { + return Err("Antigravity CLI 配置与 CPA 不一致,请重新应用配置".into()); + } + let executable = env::current_exe().map_err(|_| "无法定位 CPA 启动适配程序")?; + let directory = resolve_launch_directory(working_directory.as_deref(), &home)?; + launch_cli_agent(&executable, client.name(), &directory, &antigravity_cli_helper_arguments(&home), &[], &terminal) + } (AgentClient::WorkBuddy, "app") => { let executable = find_workbuddy_desktop_executable(&home) .ok_or_else(|| "未找到 WorkBuddy 应用程序".to_string())?; @@ -193,7 +206,7 @@ pub(crate) fn launch_agent( } (AgentClient::Codex, "app") => launch_codex_desktop(&home), (AgentClient::OpenCode, "app") => launch_opencode_desktop(&home), - (AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy, "cli") => { + (AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::AntigravityIde, "cli") => { Err(format!("{} 不支持 CLI 启动方式", client.name())) } (_, "cli") => { @@ -701,6 +714,7 @@ pub(crate) async fn restart_agent_app(app: tauri::AppHandle, client: String) -> | AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy + | AgentClient::AntigravityIde ) { return Err(format!("{} 不支持桌面应用重启", client.name())); } @@ -747,6 +761,7 @@ fn find_desktop_restart_target( AgentClient::WorkBuddy => { find_workbuddy_desktop_executable(home).map(DesktopAppTarget::Application) } + AgentClient::AntigravityIde => find_antigravity_ide(home).map(DesktopAppTarget::Application), AgentClient::ClaudeDesktop => { let executable = find_claude_desktop_executable(home).map(DesktopAppTarget::Application); diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index c58100c..28681af 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -727,6 +727,7 @@ pub(crate) fn fresh_agent_contents_with_oauth( build_deepseek_harness_settings(None, &openai_base, model, models)?, build_deepseek_harness_credentials(None, api_key)?, ]), + AgentClient::AntigravityIde | AgentClient::AntigravityCli => Err("Antigravity 模板需要安装和用户目录信息".into()), AgentClient::WorkBuddy => Ok(vec![build_workbuddy_agent_config( None, &openai_base, api_key, model, models, )?]), @@ -1540,6 +1541,9 @@ pub(crate) fn build_agent_template_updates( claude_code_model_mappings, claude_desktop_model_mappings, } = request; + if matches!(client, AgentClient::AntigravityIde | AgentClient::AntigravityCli) { + return build_antigravity_updates(client, home, &managed_core_loopback_origin(port), api_key, model, true); + } let paths = agent_config_paths(client, home); let contents = fresh_agent_contents_with_oauth( client, diff --git a/src-tauri/src/agents/windows_probe.rs b/src-tauri/src/agents/windows_probe.rs index bfa35c2..758a44e 100644 --- a/src-tauri/src/agents/windows_probe.rs +++ b/src-tauri/src/agents/windows_probe.rs @@ -353,6 +353,19 @@ pub(crate) fn find_windows_registered_zcode_executable() -> Option { }) } +#[cfg(not(test))] +pub(crate) fn find_windows_registered_antigravity_ide() -> Option { + for executable in ["Antigravity IDE.exe", "Antigravity.exe"] { + if let Some(path) = collect_windows_desktop_registrations(executable, |name| { + name == "Antigravity IDE" || name.starts_with("Antigravity IDE (") + || name == "Antigravity" || name.starts_with("Antigravity (") + }).into_iter().find_map(|r| parse_windows_desktop_registration(r.kind, &r.value, executable)) { + return Some(path); + } + } + None +} + #[cfg(not(test))] pub(crate) fn find_windows_registered_workbuddy_executable() -> Option { for executable in ["WorkBuddyAI.exe", "WorkBuddy.exe"] { diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index 6b623c4..0efaa9f 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -75,6 +75,8 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result, S AgentClient::DeepSeekHarness, AgentClient::ZCode, AgentClient::WorkBuddy, + AgentClient::AntigravityIde, + AgentClient::AntigravityCli, AgentClient::KimiCode, AgentClient::GrokBuild, ] { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b692d65..f712094 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1363,6 +1363,8 @@ enum AgentClient { DeepSeekHarness, ZCode, WorkBuddy, + AntigravityIde, + AntigravityCli, KimiCode, GrokBuild, } @@ -1386,6 +1388,8 @@ impl AgentClient { "deepseek-harness" => Ok(Self::DeepSeekHarness), "zcode" => Ok(Self::ZCode), "workbuddy" => Ok(Self::WorkBuddy), + "antigravity-ide" => Ok(Self::AntigravityIde), + "antigravity-cli" => Ok(Self::AntigravityCli), "kimi-code" => Ok(Self::KimiCode), "grok-build" => Ok(Self::GrokBuild), _ => Err(format!("不支持的智能体客户端: {value}")), @@ -1403,6 +1407,8 @@ impl AgentClient { Self::DeepSeekHarness => "deepseek-harness", Self::ZCode => "zcode", Self::WorkBuddy => "workbuddy", + Self::AntigravityIde => "antigravity-ide", + Self::AntigravityCli => "antigravity-cli", Self::KimiCode => "kimi-code", Self::GrokBuild => "grok-build", } @@ -1419,6 +1425,8 @@ impl AgentClient { Self::DeepSeekHarness => "DeepSeek Harness", Self::ZCode => "ZCode", Self::WorkBuddy => "WorkBuddy", + Self::AntigravityIde => "Antigravity IDE", + Self::AntigravityCli => "Antigravity CLI", Self::KimiCode => "Kimi Code", Self::GrokBuild => "Grok Build", } @@ -1444,6 +1452,8 @@ impl AgentClient { Self::DeepSeekHarness => &["dsh"], Self::ZCode => &["zcode"], Self::WorkBuddy => &[], + Self::AntigravityIde => &[], + Self::AntigravityCli => &["agy"], Self::KimiCode => &["kimi"], Self::GrokBuild => &["grok"], } @@ -2341,6 +2351,14 @@ struct GithubAsset { } fn main() { + let helper_args = env::args_os().collect::>(); + if antigravity_helper_requested(&helper_args) { + let code = run_antigravity_helper(&helper_args).unwrap_or_else(|error| { + eprintln!("{error}"); + 1 + }); + std::process::exit(code); + } let mut args = env::args_os(); while let Some(argument) = args.next() { if argument == "--portable-update-helper" { diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index 4fc4fcb..ea58320 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -14,6 +14,8 @@ fn configuration_paths_ignore_inherited_environment() { "deepseek-harness", "zcode", "workbuddy", + "antigravity-ide", + "antigravity-cli", "kimi-code", "grok-build", "pi", @@ -36,6 +38,7 @@ fn configuration_paths_ignore_inherited_environment() { command.env("CPA_PATH_ISOLATION_CHILD", "1"); for variable in [ "LOCALAPPDATA", + "APPDATA", "XDG_CONFIG_HOME", "CODEX_HOME", "HERMES_HOME", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 1469060..cafd4f8 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -937,6 +937,10 @@ export const jaOverrides = { 'agents.description.openclaw': 'CPA モデルプロバイダーと既定モデルを使用', 'agents.description.hermes': 'CPA カスタムプロバイダーを使用', 'agents.description.deepseekHarness': 'Harness のネイティブ DeepSeek 設定と分離された OpenAI 互換ルートで CPA を使用', + 'agents.description.antigravityIde': 'CPA の Gemini 互換 API で Antigravity IDE を設定', + 'agents.description.antigravityCli': 'CPA の Gemini 互換 API で Antigravity CLI を設定', + 'agents.modify.antigravityIdeHint': '設定の適用・解除後は IDE を再起動してください。リクエストにはここで選択した CPA モデルが使用され、IDE 内のモデル表示名は変わりません。初回は IDE へのログインが必要です。', + 'agents.modify.antigravityCliHint': '設定適用後はこのページの起動ボタンを使用してください。CPA が CLI プロセスに接続先とキーを渡します。agy を直接実行する場合は環境変数の設定が必要です。', 'agents.description.workbuddy': 'CPA の OpenAI Chat Completions API で WorkBuddy のカスタムモデルを設定', 'agents.modify.workbuddyHint': '適用後、WorkBuddy で「CPA:モデル名」を選択してください。表示されない場合は App を再起動してください。既存の会話のモデルは自動で切り替わりません。', 'agents.description.zcode': 'CPA の Anthropic Messages API と動的モデルカタログを使用', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 612dc90..399fdb0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -937,6 +937,10 @@ export const en: Record = { 'agents.description.openclaw': 'Uses the CPA model provider and default model', 'agents.description.hermes': 'Uses the CPA custom provider', 'agents.description.deepseekHarness': 'Uses CPA through an isolated, OpenAI-compatible Harness route', + 'agents.description.antigravityIde': 'Configure Antigravity IDE with CPA’s Gemini-compatible API', + 'agents.description.antigravityCli': 'Configure Antigravity CLI with CPA’s Gemini-compatible API', + 'agents.modify.antigravityIdeHint': 'Restart the IDE after applying or disabling configuration. Requests use the CPA model selected here; model labels inside the IDE stay unchanged. Initial IDE sign-in is still required.', + 'agents.modify.antigravityCliHint': 'Use Launch on this page after applying configuration. CPA supplies the endpoint and key to that CLI process. Running agy directly requires setting the environment variables yourself.', 'agents.description.workbuddy': 'Configures a WorkBuddy custom model using the CPA OpenAI Chat Completions API', 'agents.modify.workbuddyHint': 'After applying, select “CPA:model name” in WorkBuddy. Restart the app if it is missing; existing conversations keep their selected model.', 'agents.description.zcode': 'Uses the CPA Anthropic Messages API and dynamic model catalog', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 678ef32..9f3ed41 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -936,6 +936,10 @@ export const zhCN = { 'agents.description.openclaw': '使用 CPA 模型供应商和默认模型', 'agents.description.hermes': '使用 CPA 自定义提供方', 'agents.description.deepseekHarness': '通过通用 OpenAI 兼容路由使用 CPA,并与 Harness 原生 DeepSeek 配置隔离', + 'agents.description.antigravityIde': '使用 CPA 的 Gemini 兼容接口配置 Antigravity IDE', + 'agents.description.antigravityCli': '使用 CPA 的 Gemini 兼容接口配置 Antigravity CLI', + 'agents.modify.antigravityIdeHint': '应用或关闭配置后请重启 IDE。模型请求使用此处选择的 CPA 模型;IDE 内的模型标签保持原样。首次使用仍需完成 IDE 登录。', + 'agents.modify.antigravityCliHint': '应用后请使用此页的启动按钮,CPA 会为本次 CLI 进程注入地址和密钥。直接运行 agy 需要自行设置环境变量。', 'agents.description.workbuddy': '使用 CPA 的 OpenAI Chat Completions 接口配置 WorkBuddy 自定义模型', 'agents.modify.workbuddyHint': '应用后,在 WorkBuddy 的模型选择器中选择「CPA:模型名」。若未显示,请重启 App;已有会话的模型不会自动切换。', 'agents.description.zcode': '使用 CPA 的 Anthropic Messages 接口和动态模型目录', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 6912878..fd96dad 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -37,6 +37,7 @@ import opencodeIcon from '../assets/icons/opencode.svg'; import piIcon from '../assets/icons/pi-logo-on-light.svg'; import zcodeIcon from '../assets/icons/zcode.png'; import workbuddyIcon from '../assets/icons/workbuddy.png'; +import antigravityIcon from '../assets/icons/antigravity.svg'; import { agentModelAlias, filterAgentModels, @@ -81,6 +82,8 @@ type AgentClientId = | 'deepseek-harness' | 'zcode' | 'workbuddy' + | 'antigravity-ide' + | 'antigravity-cli' | 'kimi-code' | 'grok-build' | 'pi'; @@ -230,7 +233,7 @@ type AgentDefinition = { name: string; icon?: string; Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>; - descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; + descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.antigravityIde' | 'agents.description.antigravityCli' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; }; type AgentSubpageId = 'core' | 'management' | 'sessions'; @@ -284,6 +287,8 @@ const agentDefinitions: AgentDefinition[] = [ icon: grokIcon, descriptionKey: 'agents.description.grokBuild', }, + { id: 'antigravity-ide', name: 'Antigravity IDE', icon: antigravityIcon, descriptionKey: 'agents.description.antigravityIde' }, + { id: 'antigravity-cli', name: 'Antigravity CLI', icon: antigravityIcon, descriptionKey: 'agents.description.antigravityCli' }, { id: 'workbuddy', name: 'WorkBuddy', @@ -1218,6 +1223,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const modificationDescription = activeStatus?.modificationState === 'invalid' ? t('agents.modify.invalid') : nativeOauth ? t('agents.nativeOAuth.activeHint') + : selected === 'antigravity-ide' ? t('agents.modify.antigravityIdeHint') + : selected === 'antigravity-cli' ? t('agents.modify.antigravityCliHint') : selected === 'workbuddy' ? t('agents.modify.workbuddyHint') : selected === 'zcode' @@ -1741,7 +1748,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const restartDesktopApp = async () => { - if (!['codex', 'opencode', 'claude-desktop', 'zcode', 'workbuddy'].includes(selected)) return; + if (!['codex', 'opencode', 'claude-desktop', 'zcode', 'workbuddy', 'antigravity-ide'].includes(selected)) return; setBusyAction('restart-app'); setLaunchError(''); try { @@ -2436,7 +2443,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP { setConfigurationNotice(''); setClearNotice(''); }} /> {activeSubpage === 'core' ? void launchAgent(target)} onRestart={() => void restartDesktopApp()} diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs index dceb9ba..28fec6a 100644 --- a/tests/agent-management-ui.cjs +++ b/tests/agent-management-ui.cjs @@ -66,7 +66,7 @@ const path = require('node:path'); assert.equal(await tab('基础配置').getAttribute('aria-selected'), 'true'); assert.equal(await tab('会话管理').count(), 0); - for (const client of ['claude-desktop', 'zcode', 'workbuddy', 'opencode']) { + for (const client of ['claude-desktop', 'zcode', 'workbuddy', 'antigravity-ide', 'opencode']) { await open(mode + 'client=' + client + '&app-only'); assert.ok(await button('重启 App').isEnabled()); await button('重启 App').click(); @@ -80,7 +80,7 @@ const path = require('node:path'); await open(mode + 'client=kimi-code'); assert.equal(await button('重启 App').count(), 0); - for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'kimi-code', 'grok-build']) { + for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'antigravity-ide', 'antigravity-cli', 'kimi-code', 'grok-build']) { await open(mode + 'client=' + client); assert.ok(await button('关闭配置修改').isEnabled()); await tab('配置管理').click(); diff --git a/tests/antigravity-native-probe.py b/tests/antigravity-native-probe.py new file mode 100644 index 0000000..c2c8118 --- /dev/null +++ b/tests/antigravity-native-probe.py @@ -0,0 +1,124 @@ +import argparse +import http.server +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import threading +import time +import urllib.request + + +def main(): + parser = argparse.ArgumentParser(description="Probe CPA launch helpers with installed Antigravity binaries and a local mock API.") + parser.add_argument("--cpa", type=Path, required=True) + parser.add_argument("--cli", type=Path, required=True) + parser.add_argument("--language-server", type=Path, required=True) + args = parser.parse_args() + for path in (args.cpa, args.cli, args.language_server): + if not path.is_file(): + parser.error(f"Binary not found: {path}") + cpa = str(args.cpa.resolve()) + answer = "CPA integration probe OK" + seen = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + seen.append((self.path, self.headers.get("x-goog-api-key"))) + selected = "/models/cpa-test-model:" in self.path + self.send_response(200 if selected else 404) + self.send_header("Content-Type", "text/event-stream" if selected else "application/json") + self.end_headers() + if selected: + body = {"candidates": [{"content": {"role": "model", "parts": [{"text": answer}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 5, "totalTokenCount": 6}} + self.wfile.write(("data: " + json.dumps(body) + "\n\n").encode()) + else: + self.wfile.write(b'{"error":{"code":404,"message":"Model unavailable","status":"NOT_FOUND"}}') + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + with tempfile.TemporaryDirectory(prefix="cpa-antigravity-native-") as directory: + root = Path(directory) + gemini = root / ".gemini" + settings = gemini / "antigravity-cli" + settings.mkdir(parents=True) + (settings / "settings.json").write_text(json.dumps({"modelProvider": "gemini", "enableTelemetry": False, + "customModelsConfig": {"customModels": {"CPA (EasyCLIProxyAPI)": {"modelName": "cpa-test-model"}}}})) + endpoint = f"http://127.0.0.1:{server.server_port}" + (settings / "cpa-connection.json").write_text(json.dumps({"provider": "cpa-gui", "baseUrl": endpoint, + "apiKey": "cpa-probe-key", "model": "cpa-test-model"})) + env = dict(os.environ, HOME=directory, USERPROFILE=directory, APPDATA=str(root / "roaming"), + LOCALAPPDATA=str(root / "local"), XDG_CONFIG_HOME=str(root / "config"), + PATH=str(args.cli.resolve().parent) + os.pathsep + os.environ.get("PATH", ""), + GOOGLE_API_KEY="must-be-removed", GEMINI_API_KEY="must-be-replaced", GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:1") + flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + result = subprocess.run([cpa, "--cpa-antigravity-cli", directory, f"--print=Say exactly {answer}. Do not use tools.", + "--print-timeout=15s"], env=env, cwd=root, capture_output=True, timeout=30, creationflags=flags) + assert result.returncode == 0, result.stderr.decode(errors="replace") + assert answer in result.stdout.decode(errors="replace"), result.stdout + assert any("/models/cpa-test-model:" in path for path, _ in seen), seen + assert all(key == "cpa-probe-key" for _, key in seen), seen + print("PASS CLI: selected model, scoped credentials, response, unavailable auxiliary model") + seen.clear() + + env.update(CPA_ANTIGRAVITY_LANGUAGE_SERVER=str(args.language_server.resolve()), + CPA_ANTIGRAVITY_MODEL="cpa-test-model", GEMINI_API_KEY="cpa-probe-key", GOOGLE_GEMINI_BASE_URL=endpoint) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + def rpc(method, payload): + request = urllib.request.Request(f"http://127.0.0.1:{port}/exa.language_server_pb.LanguageServerService/{method}", + data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", + "X-Codeium-Csrf-Token": "cpa-probe", "Connect-Protocol-Version": "1"}) + with urllib.request.urlopen(request, timeout=15) as response: + return json.load(response) + + with (root / "server.log").open("wb") as log: + proc = subprocess.Popen([cpa, "--extension_server_port", "0", f"--gemini_dir={gemini}", "--headless", + "--disable_telemetry", f"--http_server_port={port}", "--csrf_token=cpa-probe"], env=env, cwd=root, + stdin=subprocess.PIPE, stdout=log, stderr=log, creationflags=flags) + try: + proc.stdin.write(b"\x0a\x09cpa-probe") + proc.stdin.close() + for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except OSError: + assert proc.poll() is None, (root / "server.log").read_text(errors="replace") + time.sleep(0.1) + model = "MODEL_GOOGLE_GEMINI_2_5_PRO" + cascade = rpc("StartCascade", {"workspaceUris": [root.as_uri()], "source": 1, "requestedModel": model})["cascadeId"] + rpc("SendUserCascadeMessage", {"cascadeId": cascade, "items": [{"text": "Say hello. Do not use tools."}], + "blocking": True, "cascadeConfig": {"plannerConfig": {"planModel": model}}}) + steps = rpc("GetCascadeTrajectory", {"cascadeId": cascade})["trajectory"]["steps"] + assert any(step.get("plannerResponse", {}).get("response") == answer for step in steps), steps + assert seen and all("/models/cpa-test-model:" in path and key == "cpa-probe-key" for path, key in seen), seen + finally: + proc.terminate() + proc.wait(timeout=5) + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + time.sleep(0.1) + except OSError: + break + else: + raise AssertionError("Native language server survived CPA helper termination") + print("PASS IDE: stdin forwarding, selected model, scoped credentials, response, child cleanup") + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index b0d4af0..40040d3 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -9,7 +9,7 @@ if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-s localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN'); document.documentElement.dataset.theme = params.get('theme') || 'light'; localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex'); -const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','workbuddy','zcode','kimi-code','grok-build','pi']; +const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','antigravity-ide','antigravity-cli','workbuddy','zcode','kimi-code','grok-build','pi']; let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one'; let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0; let currentOauth=false; const currentMappings:Record={}; @@ -35,7 +35,7 @@ mockIPC(async (cmd,args:any) => { calls.push({cmd,args}); if(cmd==='plugin:event|listen') return 1; if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null; - if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); + if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode','workbuddy','antigravity-ide'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); if(cmd==='close_codex_config_modification') { closeCount++; if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');