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
This commit is contained in:
lzt404 committed 2026-09-21 21:58:01 +08:00
1 parent 3545941414
commit 615188b390
23 files changed
+1115 -11

No files matched your search

+4
View File
@@ -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、リモート管理用認証情報、ルーティング戦略の管理。
+4
View File
@@ -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.
+4
View File
@@ -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、远程管理凭证和路由策略。
+1
View File
@@ -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",
+2
View File
@@ -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::*;
+539
View File
@@ -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<PathBuf> {
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<PathBuf> {
#[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<PathBuf> {
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<PathBuf> {
#[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<String> {
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<PathBuf> {
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<String, String> {
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<Vec<AgentFileUpdate>, 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<Vec<&'static str>> {
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<Option<String>, 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<bool, String> {
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>), 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<Images, String> {
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, &current, None))
.transpose()?
.flatten();
Ok((path.clone(), value.map(String::into_bytes)))
})
.collect()
}
pub(crate) fn antigravity_cli_helper_arguments(home: &Path) -> Vec<String> {
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<Command, String> {
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<i32, String> {
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}"))
}
}
+338
View File
@@ -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::<Vec<_>>();
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], &current);
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::<Vec<_>>(),
[
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::<std::collections::HashMap<_, _>>();
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());
}
}
}
+2
View File
@@ -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,
+6
View File
@@ -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)?,
};
+9 -3
View File
@@ -48,6 +48,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBu
]
}
AgentClient::WorkBuddy => 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<PathBuf>
}
pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option<PathBuf> {
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);
}
+16 -1
View File
@@ -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);
+4
View File
@@ -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,
+13
View File
@@ -353,6 +353,19 @@ pub(crate) fn find_windows_registered_zcode_executable() -> Option<PathBuf> {
})
}
#[cfg(not(test))]
pub(crate) fn find_windows_registered_antigravity_ide() -> Option<PathBuf> {
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<PathBuf> {
for executable in ["WorkBuddyAI.exe", "WorkBuddy.exe"] {
+2
View File
@@ -75,6 +75,8 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
AgentClient::DeepSeekHarness,
AgentClient::ZCode,
AgentClient::WorkBuddy,
AgentClient::AntigravityIde,
AgentClient::AntigravityCli,
AgentClient::KimiCode,
AgentClient::GrokBuild,
] {
+18
View File
@@ -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::<Vec<_>>();
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" {
+3
View File
@@ -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",
+4
View File
@@ -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 と動的モデルカタログを使用',
+4
View File
@@ -937,6 +937,10 @@ export const en: Record<MessageKey, string> = {
'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',
+4
View File
@@ -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 接口和动态模型目录',
+10 -3
View File
@@ -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
<MessageNotice tone="success" message={!configurationErrorMessage ? configurationNotice || clearNotice : null}
onDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }} />
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy' || selected === 'antigravity-ide'}
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
+2 -2
View File
@@ -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();
+124
View File
@@ -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()
+2 -2
View File
@@ -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<string,any>={};
@@ -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'&&currentOauth,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'&&currentOauth,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('模拟关闭失败,已回滚');