diff --git a/src/commands/install_skills.rs b/src/commands/install_skills.rs index 33a0e947..04f210dc 100644 --- a/src/commands/install_skills.rs +++ b/src/commands/install_skills.rs @@ -72,7 +72,7 @@ pub async fn run(args: crate::InstallSkillsArgs) -> Result<()> { .collect(); if selected.is_empty() { return Err(anyhow!( - "unknown agent '{name}' (expected: claude, codex, opencode, cursor, or all)" + "unknown agent '{name}' (expected: claude, codex, opencode, cursor, antigravity, or all)" )); } selected @@ -176,6 +176,7 @@ async fn write_skill_set( fn matches_agent(harness: &dyn Harness, name: &str) -> bool { match harness.id() { "claude-code" => name == "claude" || name == "claude-code", + "antigravity" => name == "antigravity" || name == "agy", id => id == name, } } diff --git a/src/local/harness/antigravity.rs b/src/local/harness/antigravity.rs new file mode 100644 index 00000000..c5cb3321 --- /dev/null +++ b/src/local/harness/antigravity.rs @@ -0,0 +1,887 @@ +//! Google Antigravity harness. +//! +//! Chat: one `agy --output-format stream-json` child per turn. Multi-turn continues +//! via `--conversation ` from the init/result `conversation_id`. Isolated +//! ORX worktrees are the child's current working directory and added workspace (`--add-dir`). +//! +//! The playbook is pointed at on the first turn (the file is already in the +//! worktree via [`ensure_playbook`]); session skills land in `.agents/skills`. +//! +//! Options: Ask prompts before changes, Auto allows changes unless denied, and +//! Bypass passes `--dangerously-skip-permissions`. +//! +//! Detection: `agy` on PATH or in `~/.local/bin` / `~/.gemini/antigravity-cli/bin`; +//! `agy models` for catalog and authentication verification. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +use super::detect::{probe_bin, resolve_symlinks, HarnessAuthState, HarnessInfo, ModelInfo}; +use super::options::{ + HarnessOptions, OptionChoice, PermissionMode, PlanActivation, REASONING_DEFAULT_ID, +}; +use super::{ + Harness, OneShot, OneShotQuality, ResumeAction, TurnFailure, TurnOutcome, TurnResult, + TURN_WATCHDOG, +}; +use crate::error::{anyhow, Result}; +use crate::local::chat::{ + find_part_mut, harness_log, prepare_env, set_chat_session_env, DeliveryState, PromptAnswer, + ResumeCtx, TurnCtx, WirePart, WirePrompt, WireToolState, +}; +use crate::local::native_store::{self, NativeStore}; +use crate::local::opencode::{ensure_playbook, PLAYBOOK_REL}; +use crate::local::shell_env::{find_in_dir, find_on_path}; + +const AGY_REINSTALL: &str = + "Reinstall Antigravity CLI via curl -sSf https://antigravity.google/install | sh"; +const MODELS_TIMEOUT: Duration = Duration::from_secs(15); + +pub struct Antigravity; + +#[async_trait] +impl Harness for Antigravity { + fn id(&self) -> &'static str { + "antigravity" + } + + fn name(&self) -> &'static str { + "Google Antigravity" + } + + fn supports_chat(&self) -> bool { + true + } + + async fn detect(&self) -> Option { + let mut info = HarnessInfo::new(self.id(), self.name()); + if let Some(bin) = find_agy() { + info.record_bin(&bin, probe_bin(&bin).await); + } + if info.installed && !info.install_broken { + let authed = match info.bin_path.as_deref().map(Path::new) { + Some(bin) => check_auth_ready(bin).await, + None => false, + }; + if authed { + info.authenticated = true; + info.auth_state = HarnessAuthState::Ready; + info.auth_method = Some("oauth"); + } else { + info.auth_state = HarnessAuthState::NeedsLogin; + } + } + + info.agent_ready = info.ready(); + if info.agent_ready { + let models = match info.bin_path.as_deref().map(Path::new) { + Some(bin) => agy_model_list(bin).await, + None => None, + }; + info = info.with_models(models.unwrap_or_else(fallback_models)); + } else if info.install_broken { + info.agent_note = Some(info.broken_note(AGY_REINSTALL)); + } else if info.installed { + info.agent_note = Some( + "Sign in by running `agy` in your terminal, then re-check this harness." + .to_string(), + ); + } else { + info.agent_note = Some( + "Install Antigravity CLI with `curl -sSf https://antigravity.google/install | sh`, then sign in with `agy`." + .to_string(), + ); + } + Some(info) + } + + async fn run_turn(&self, ctx: &mut TurnCtx) -> TurnResult { + run_turn(ctx) + .await + .map(|()| TurnOutcome::Completed) + .map_err(|error| TurnFailure::adapter(error, ctx.delivery_state())) + } + + fn options(&self) -> HarnessOptions { + HarnessOptions::none() + .with_permission_choices( + vec![ + OptionChoice::described( + "ask", + "Ask", + "Prompt before running commands or modifying files", + ), + OptionChoice::described( + "auto", + "Auto", + "Allow actions unless explicitly denied", + ), + OptionChoice::described( + "bypass", + "Bypass", + "Allow commands and skip tool confirmation prompts", + ), + ], + "auto", + PlanActivation::Command, + ) + .with_reasoning_levels(&["low", "medium", "high"]) + } + + async fn resume_from_prompt( + &self, + _ctx: &ResumeCtx, + prompt: &WirePrompt, + answer: &PromptAnswer, + ) -> Result { + if prompt.kind != "plan" { + return Ok(ResumeAction::Nothing); + } + if !answer.approve && answer.note.as_deref().is_none_or(|s| s.trim().is_empty()) { + return Ok(ResumeAction::Nothing); + } + let note = answer.note.as_deref().filter(|s| !s.trim().is_empty()); + let (text, plan_mode) = if answer.approve { + let mut text = "Implement the plan.".to_string(); + if let Some(note) = note { + text.push_str(&format!("\n\nAdditional guidance: {note}")); + } + (text, false) + } else { + (super::synthesize_resume("plan", answer).0, true) + }; + Ok(ResumeAction::SendMessage { + text, + mode: None, + plan_mode: Some(plan_mode), + }) + } + + async fn one_shot(&self, request: OneShot<'_>) -> Option { + agy_one_shot(&find_agy()?, request).await + } + + fn config_home(&self) -> Option { + Some(native_store::antigravity_home(NativeStore::Legacy)) + } + + fn skill_target(&self) -> Option { + Some( + self.config_home()? + .join("skills") + .join("orx") + .join("SKILL.md"), + ) + } + + fn extra_skill_targets(&self) -> Vec<(PathBuf, &'static str)> { + dirs::home_dir() + .map(|h| { + vec![( + h.join(".agents") + .join("skills") + .join("orx") + .join("SKILL.md"), + super::CLAUDE_SKILL, + )] + }) + .unwrap_or_default() + } + + fn skill_shim(&self) -> Option<&'static str> { + Some(super::CLAUDE_SKILL) + } + + fn session_skills_dir(&self) -> Option<&'static str> { + Some(".agents/skills") + } +} + +/// `agy` on PATH, else search common install locations under `~/.local/bin` +/// or `~/.gemini/antigravity-cli/bin`. +pub(crate) fn find_agy() -> Option { + find_on_path("agy") + .or_else(|| { + let home = dirs::home_dir()?; + let local = home.join(".local").join("bin"); + find_in_dir(&local, "agy").or_else(|| { + let agy_bin = home.join(".gemini").join("antigravity-cli").join("bin"); + find_in_dir(&agy_bin, "agy") + }) + }) + .map(resolve_symlinks) +} + +async fn check_auth_ready(bin: &Path) -> bool { + let mut cmd = Command::new(bin); + cmd.args(["models"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + prepare_env(&mut cmd); + cmd.env("NO_COLOR", "1"); + + if let Ok(Ok(out)) = tokio::time::timeout(MODELS_TIMEOUT, cmd.output()).await { + return out.status.success(); + } + false +} + +async fn agy_model_list(bin: &Path) -> Option> { + let mut cmd = Command::new(bin); + cmd.args(["models"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + prepare_env(&mut cmd); + cmd.env("NO_COLOR", "1"); + + let out = tokio::time::timeout(MODELS_TIMEOUT, cmd.output()) + .await + .ok()? + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let parsed = parse_agy_model_list(&text); + (!parsed.is_empty()).then_some(parsed) +} + +fn fallback_models() -> Vec { + vec![ + ModelInfo::new("gemini-3.8-flash-high").with_label(Some("Gemini 3.8 Flash (High)"), None), + ModelInfo::new("gemini-3.1-pro-high").with_label(Some("Gemini 3.1 Pro (High)"), None), + ModelInfo::new("claude-sonnet-4-6").with_label(Some("Claude Sonnet 4.6 (Thinking)"), None), + ] +} + +/// Parse `agy models` output. Lines are tab-separated `\t