diff options
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/actions.rs | 53 | ||||
| -rw-r--r-- | src/integration/assets/antigravity_cli/herdr-agent-state.ps1 | 57 | ||||
| -rw-r--r-- | src/integration/assets/antigravity_cli/herdr-agent-state.sh | 77 | ||||
| -rw-r--r-- | src/integration/env.rs | 8 | ||||
| -rw-r--r-- | src/integration/mod.rs | 27 | ||||
| -rw-r--r-- | src/integration/registry.rs | 13 | ||||
| -rw-r--r-- | src/integration/targets.rs | 141 | ||||
| -rw-r--r-- | src/integration/tests.rs | 184 | ||||
| -rw-r--r-- | src/integration/types.rs | 14 |
9 files changed, 546 insertions, 28 deletions
diff --git a/src/integration/actions.rs b/src/integration/actions.rs index c00e10b7..3cb73774 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -2,12 +2,13 @@ use std::io; use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ - install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid, - install_grok, install_hermes, install_kilo, install_kimi, install_mastracode, install_omp, - install_opencode, install_pi, install_qodercli, uninstall_claude, uninstall_codex, - uninstall_copilot, uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, - uninstall_hermes, uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, - uninstall_opencode, uninstall_pi, uninstall_qodercli, + install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, + install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, + install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, + uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, + uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, + uninstall_pi, uninstall_qodercli, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -204,6 +205,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::AntigravityCli => { + let installed = install_antigravity_cli()?; + vec![ + format!( + "installed antigravity-cli integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured antigravity-cli hooks at {}", + installed.hooks_path.display() + ), + ] + } crate::api::schema::IntegrationTarget::Grok => { let installed = install_grok()?; vec![ @@ -571,6 +585,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::AntigravityCli => { + let result = uninstall_antigravity_cli()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed antigravity-cli hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no antigravity-cli hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr antigravity-cli hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr antigravity-cli hook entries found in {}", + result.hooks_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Grok => { let result = uninstall_grok()?; let mut messages = Vec::new(); diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 new file mode 100644 index 00000000..aecb42a9 --- /dev/null +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 @@ -0,0 +1,57 @@ +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=antigravity_cli +# HERDR_INTEGRATION_VERSION=1 + +# Session-only: this hook reports the Antigravity conversation so Herdr can +# resume the pane. Lifecycle state comes from Herdr's screen detection. + +param([string]$Action = "") + +# Antigravity CLI expects a JSON object on stdout and this hook never injects +# anything, so every exit path emits an empty object. +function Exit-Hook { + Write-Output "{}" + exit 0 +} + +if ($Action -ne "session") { Exit-Hook } +if ($env:HERDR_ENV -ne "1") { Exit-Hook } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + Exit-Hook +} + +if ($null -eq $payload) { Exit-Hook } + +$conversationId = if ($payload.conversationId -is [string]) { $payload.conversationId } else { $null } +if ([string]::IsNullOrWhiteSpace($conversationId)) { Exit-Hook } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + $sessionArgs = @( + "pane", + "report-agent-session", + $env:HERDR_PANE_ID, + "--source", + "herdr:antigravity_cli", + "--agent", + "agy", + "--seq", + "$seq", + "--agent-session-id", + "$conversationId" + ) + if ($payload.transcriptPath -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.transcriptPath)) { + $sessionArgs += @("--agent-session-path", "$($payload.transcriptPath)") + } + & herdr @sessionArgs 2>$null | Out-Null +} catch { +} + +Exit-Hook diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.sh b/src/integration/assets/antigravity_cli/herdr-agent-state.sh new file mode 100644 index 00000000..dd3db028 --- /dev/null +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=antigravity_cli +# HERDR_INTEGRATION_VERSION=1 + +# Session-only: this hook reports the Antigravity conversation so Herdr can +# resume the pane. Lifecycle state comes from Herdr's screen detection. + +set -eu + +# Antigravity CLI expects a JSON object on stdout and this hook never injects +# anything, so every exit path emits an empty object. +emit_and_exit() { + printf '{}\n' + exit 0 +} + +[ "${1:-}" = "session" ] || emit_and_exit +[ "${HERDR_ENV:-}" = "1" ] || emit_and_exit +[ -n "${HERDR_SOCKET_PATH:-}" ] || emit_and_exit +[ -n "${HERDR_PANE_ID:-}" ] || emit_and_exit +command -v python3 >/dev/null 2>&1 || emit_and_exit + +python3 -c ' +import json +import os +import socket +import sys +import time + +try: + payload = json.load(sys.stdin) +except Exception: + raise SystemExit(0) + +if not isinstance(payload, dict): + raise SystemExit(0) + +def text(name): + value = payload.get(name) + return value if isinstance(value, str) and value else None + +session_id = text("conversationId") +if session_id is None: + raise SystemExit(0) + +seq = time.time_ns() +params = { + "pane_id": os.environ["HERDR_PANE_ID"], + "source": "herdr:antigravity_cli", + "agent": "agy", + "seq": seq, + "agent_session_id": session_id, +} + +transcript_path = text("transcriptPath") +if transcript_path is not None: + params["agent_session_path"] = transcript_path + +request = json.dumps({ + "id": f"herdr:antigravity_cli:{seq}", + "method": "pane.report_agent_session", + "params": params, +}) +try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(0.5) + client.connect(os.environ["HERDR_SOCKET_PATH"]) + client.sendall((request + "\n").encode()) + client.recv(4096) +except Exception: + pass +' 2>/dev/null || true + +emit_and_exit diff --git a/src/integration/env.rs b/src/integration/env.rs index 2d0feeb0..d76ce66c 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -17,6 +17,7 @@ pub(crate) const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; pub(crate) const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; pub(crate) const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; pub(crate) const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR"; +pub(crate) const ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR: &str = "ANTIGRAVITY_CLI_CONFIG_DIR"; pub(crate) const GROK_CONFIG_DIR_ENV_VAR: &str = "GROK_CONFIG_DIR"; /// The grok CLI's own config-home override (documented alongside /// `$GROK_HOME/config.toml` and `$GROK_HOME/auth.json`). @@ -142,6 +143,13 @@ pub(crate) fn mastracode_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".mastracode")) } +pub(crate) fn antigravity_cli_dir() -> io::Result<PathBuf> { + // Antigravity CLI discovers global customizations (hooks.json included) + // from ~/.gemini/config; ~/.gemini/antigravity-cli holds runtime data and + // is never read for hooks. + config_dir_from_env_or_home(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &[".gemini", "config"]) +} + pub(crate) fn grok_dir() -> io::Result<PathBuf> { // GROK_CONFIG_DIR is a herdr-level override only (primarily a test // seam); the grok CLI does not honor it, so it stays first and explicit. diff --git a/src/integration/mod.rs b/src/integration/mod.rs index b691dce9..045d1bce 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -196,6 +196,32 @@ const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh"); const CURSOR_INTEGRATION_VERSION: u32 = 1; +#[cfg(windows)] +const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1"; +#[cfg(not(windows))] +const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +#[cfg(windows)] +const ANTIGRAVITY_CLI_HOOK_ASSET: &str = + include_str!("assets/antigravity_cli/herdr-agent-state.ps1"); +#[cfg(not(windows))] +const ANTIGRAVITY_CLI_HOOK_ASSET: &str = + include_str!("assets/antigravity_cli/herdr-agent-state.sh"); +const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 1; +/// Antigravity CLI keys `hooks.json` by hook name, so every Herdr entry lives +/// under one Herdr-owned block that install rewrites and uninstall removes. +const ANTIGRAVITY_CLI_HOOK_BLOCK_NAME: &str = "herdr"; +const ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC: u64 = 10; +/// `(event, reported action)`. Session-only: `PreInvocation` is the only event +/// we need because it carries `conversationId`. The others cannot express +/// lifecycle safely — Antigravity CLI has no blocked event, `PostInvocation` is +/// skipped on interruption, and `Stop` is end-of-turn rather than process exit. +/// Screen detection owns agent state instead. +/// +/// `PreInvocation` takes a flat handler list; only the `PreToolUse`/`PostToolUse` +/// events accept a `matcher`/`hooks` wrapper, and sending one here would +/// invalidate the whole file. +const ANTIGRAVITY_CLI_HOOK_EVENTS: [(&str, &str); 1] = [("PreInvocation", "session")]; +const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh"); const MASTRACODE_INTEGRATION_VERSION: u32 = 2; @@ -219,7 +245,6 @@ const GROK_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const GROK_HOOK_CONFIG_INSTALL_NAME: &str = "herdr.json"; const GROK_HOOK_ASSET: &str = include_str!("assets/grok/herdr-agent-state.sh"); const GROK_INTEGRATION_VERSION: u32 = 1; -const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 10500df5..7041eb1b 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -22,6 +22,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Cursor => "cursor", crate::api::schema::IntegrationTarget::Mastracode => "mastracode", + crate::api::schema::IntegrationTarget::AntigravityCli => "antigravity-cli", crate::api::schema::IntegrationTarget::Grok => "grok", } } @@ -50,6 +51,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], + crate::api::schema::IntegrationTarget::AntigravityCli => &["agy"], crate::api::schema::IntegrationTarget::Grok => &["grok"], } } @@ -73,6 +75,7 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati | crate::api::schema::IntegrationTarget::Droid | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli + | crate::api::schema::IntegrationTarget::AntigravityCli ) } @@ -259,7 +262,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 15] { +); 16] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -335,6 +338,14 @@ fn integration_specs() -> [( super::MASTRACODE_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::AntigravityCli, + antigravity_cli_dir().map(|dir| { + dir.join("hooks") + .join(super::ANTIGRAVITY_CLI_HOOK_INSTALL_NAME) + }), + super::ANTIGRAVITY_CLI_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Grok, grok_dir().map(|dir| dir.join("hooks").join(super::GROK_HOOK_INSTALL_NAME)), super::GROK_INTEGRATION_VERSION, diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 87000eae..575c7e46 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -2,7 +2,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use super::command::{hook_command, shell_single_quote}; use super::config_edit::{ @@ -13,32 +13,33 @@ use super::config_edit::{ remove_hook_commands, remove_kimi_config_block, remove_simple_command_hook, }; use super::env::{ - claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, grok_dir, hermes_dir, - hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, opencode_dir, - pi_extension_dir, qodercli_dir, + antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, + grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, + opencode_dir, pi_extension_dir, qodercli_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, }; use super::types::{ - ClaudeInstallPaths, ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, - CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, - DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, - GrokInstallPaths, GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, - KiloInstallPaths, KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, - MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, - OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, - QodercliUninstallResult, + AntigravityCliInstallPaths, AntigravityCliUninstallResult, ClaudeInstallPaths, + ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths, + CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, + DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths, + GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, + KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, + MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, + OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, }; use super::{ - CLAUDE_HOOK_ASSET, CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, - COPILOT_HOOK_ASSET, COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME, - COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME, - DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, DEVIN_HOOK_INSTALL_NAME, - DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, DROID_HOOK_EVENTS, - DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, GROK_HOOK_ASSET, - GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, HERMES_PLUGIN_INIT_ASSET, - HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, + ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, + ANTIGRAVITY_CLI_HOOK_INSTALL_NAME, ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, CLAUDE_HOOK_ASSET, + CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, COPILOT_HOOK_ASSET, + COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME, COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, + CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME, DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, + DEVIN_HOOK_INSTALL_NAME, DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, + DROID_HOOK_EVENTS, DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, + GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, + HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, @@ -1203,6 +1204,106 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> { }) } +pub(crate) fn install_antigravity_cli() -> io::Result<AntigravityCliInstallPaths> { + let dir = antigravity_cli_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "antigravity cli config directory not found at {}. install antigravity cli first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME); + fs::write(&hook_path, ANTIGRAVITY_CLI_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let hooks_path = dir.join("hooks.json"); + let mut hooks_file = if hooks_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({}) + }; + + let hooks = hooks_file.as_object_mut().ok_or_else(|| { + io::Error::other(format!( + "antigravity cli hooks file at {} must be a JSON object", + hooks_path.display() + )) + })?; + + // The Herdr block is Herdr-owned, so rewrite it wholesale and leave every + // other named hook untouched. + hooks.insert( + ANTIGRAVITY_CLI_HOOK_BLOCK_NAME.to_string(), + antigravity_cli_hook_block(&hook_path), + ); + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + Ok(AntigravityCliInstallPaths { + hook_path, + hooks_path, + }) +} + +/// Builds the Herdr-owned `hooks.json` block for Antigravity CLI. +/// +/// Every event Herdr registers takes a flat handler list; the `matcher`/`hooks` +/// group is only valid for the tool events, which Herdr does not use. +fn antigravity_cli_hook_block(hook_path: &Path) -> Value { + let mut block = Map::new(); + for (event, action) in ANTIGRAVITY_CLI_HOOK_EVENTS { + let handler = json!({ + "type": "command", + "command": hook_command(hook_path, Some(action)), + "timeout": ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, + }); + block.insert(event.to_string(), json!([handler])); + } + Value::Object(block) +} + +pub(crate) fn uninstall_antigravity_cli() -> io::Result<AntigravityCliUninstallResult> { + let dir = antigravity_cli_dir()?; + let hook_path = dir.join("hooks").join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME); + let hooks_path = dir.join("hooks.json"); + let mut updated_hooks = false; + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + let hooks = hooks_file.as_object_mut().ok_or_else(|| { + io::Error::other(format!( + "antigravity cli hooks file at {} must be a JSON object", + hooks_path.display() + )) + })?; + + updated_hooks = hooks.remove(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME).is_some(); + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(AntigravityCliUninstallResult { + hook_path, + hooks_path, + removed_hook_file, + updated_hooks, + }) +} + /// The complete Herdr-owned Grok hook config. Installation and status share /// this value so any config drift is reported as outdated. pub(crate) fn grok_hook_config(hook_path: &Path) -> Value { diff --git a/src/integration/tests.rs b/src/integration/tests.rs index ff616264..7abf7be2 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -102,6 +102,7 @@ fn clear_integration_path_env() { std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_HOME_ENV_VAR); } @@ -3645,6 +3646,154 @@ fn uninstall_mastracode_errors_when_event_value_not_array() { } #[test] +fn install_antigravity_cli_writes_hook_and_updates_hooks_json() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + fs::create_dir_all(&agy_dir).unwrap(); + fs::write( + agy_dir.join("hooks.json"), + r#"{"lint-checker":{"PreInvocation":[{"type":"command","command":"echo keep-me"}]}}"#, + ) + .unwrap(); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + let installed = install_antigravity_cli().unwrap(); + + assert_eq!( + installed.hook_path, + agy_dir + .join("hooks") + .join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, agy_dir.join("hooks.json")); + assert_eq!( + fs::read_to_string(&installed.hook_path).unwrap(), + ANTIGRAVITY_CLI_HOOK_ASSET + ); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(agy_dir.join("hooks.json")).unwrap()).unwrap(); + let hooks = hooks_file.as_object().unwrap(); + + // Herdr entries live under a named hook block; Antigravity CLI rejects a + // file whose top level maps event names straight to arrays. + let block = hooks + .get(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME) + .and_then(Value::as_object) + .unwrap(); + + for (event, action) in ANTIGRAVITY_CLI_HOOK_EVENTS { + let entries = block.get(event).and_then(Value::as_array).unwrap(); + assert_eq!(entries.len(), 1, "{event} should hold one Herdr entry"); + let handler = &entries[0]; + + // Handlers must be a flat list; the matcher/hooks wrapper is only + // valid for tool events and invalidates the whole file here. + assert!( + handler.get("matcher").is_none() && handler.get("hooks").is_none(), + "{event} must be a flat handler, got {handler}" + ); + + assert_eq!(handler.get("type").and_then(Value::as_str), Some("command")); + assert_eq!( + handler.get("timeout").and_then(Value::as_u64), + Some(ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC) + ); + let command = handler.get("command").and_then(Value::as_str).unwrap(); + assert!(command.contains("herdr-agent-state")); + assert!(command.ends_with(action)); + } + + // The integration is session-only. Antigravity CLI cannot express blocked + // state, skips PostInvocation on interruption, and fires Stop at end of + // turn rather than process exit, so Herdr never claims lifecycle authority + // here and screen detection owns agent state. + for event in ["PreToolUse", "PostToolUse", "PostInvocation", "Stop"] { + assert!( + block.get(event).is_none(), + "{event} must not be registered; lifecycle stays with screen detection" + ); + } + + // Other named hooks are left untouched. + assert_eq!( + hooks + .get("lint-checker") + .and_then(|block| block.get("PreInvocation")) + .and_then(Value::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("command")) + .and_then(Value::as_str), + Some("echo keep-me") + ); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_antigravity_cli_rewrites_stale_herdr_block() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + fs::create_dir_all(&agy_dir).unwrap(); + // An older Herdr install claimed lifecycle authority, wrapped events in + // matcher/hooks, and left entries Antigravity CLI now rejects. + fs::write( + agy_dir.join("hooks.json"), + r#"{"herdr":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"stale"}]}],"PostInvocation":[{"type":"command","command":"stale idle"}],"Legacy":[]}}"#, + ) + .unwrap(); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + install_antigravity_cli().unwrap(); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(agy_dir.join("hooks.json")).unwrap()).unwrap(); + let block = hooks_file + .get(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME) + .and_then(Value::as_object) + .unwrap(); + + // The block is Herdr-owned and rewritten wholesale, so a stale lifecycle + // install is migrated to session-only rather than merged with. + assert_eq!( + block.keys().map(String::as_str).collect::<Vec<_>>(), + vec!["PreInvocation"], + "stale lifecycle events should be gone" + ); + let entries = block + .get("PreInvocation") + .and_then(Value::as_array) + .unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].get("hooks").is_none()); + assert!(entries[0] + .get("command") + .and_then(Value::as_str) + .is_some_and(|command| command.contains("herdr-agent-state"))); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_antigravity_cli_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + let err = install_antigravity_cli().unwrap_err(); + assert!(err.to_string().contains("install antigravity cli first")); + assert!(!agy_dir.exists(), "install must not create the config dir"); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] fn grok_v1_integration_status_is_current() { let _lock = integration_env_lock(); let base = unique_base(); @@ -3756,6 +3905,41 @@ fn grok_status_reports_outdated_when_hook_config_missing_or_broken() { } #[test] +fn uninstall_antigravity_cli_removes_hooks_json_entries_and_hook_file() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + fs::create_dir_all(&agy_dir).unwrap(); + fs::write( + agy_dir.join("hooks.json"), + r#"{"lint-checker":{"PreInvocation":[{"type":"command","command":"echo keep-me"}]}}"#, + ) + .unwrap(); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + // Install first + let installed = install_antigravity_cli().unwrap(); + assert!(installed.hook_path.is_file()); + + // Uninstall + let result = uninstall_antigravity_cli().unwrap(); + assert!(result.removed_hook_file); + assert!(!installed.hook_path.is_file()); + assert!(result.updated_hooks); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(agy_dir.join("hooks.json")).unwrap()).unwrap(); + let hooks = hooks_file.as_object().unwrap(); + + // The Herdr block is gone and unrelated named hooks survive. + assert!(hooks.get(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME).is_none()); + assert!(hooks.contains_key("lint-checker")); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] fn grok_dir_honors_grok_home_after_config_dir_seam() { let _lock = integration_env_lock(); let base = unique_base(); diff --git a/src/integration/types.rs b/src/integration/types.rs index 93138a3d..09a235b0 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -241,3 +241,17 @@ pub(crate) struct HermesUninstallResult { pub removed_plugin_dir: bool, pub updated_config: bool, } + +#[derive(Debug)] +pub(crate) struct AntigravityCliInstallPaths { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct AntigravityCliUninstallResult { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub removed_hook_file: bool, + pub updated_hooks: bool, +} |
