diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index fcc908a7b2..676d017502 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,6 +1,6 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Cursor Agent CLI, MastraCode, and Grok CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Cursor Agent CLI, MastraCode, Grok CLI, and Antigravity CLI (agy). --- Herdr detects supported agents automatically. Official integrations can add native session identity for restore, lifecycle state reports, or both. @@ -27,6 +27,7 @@ herdr integration install qodercli herdr integration install cursor herdr integration install mastracode herdr integration install grok +herdr integration install agy ``` ## Uninstall integrations @@ -47,6 +48,7 @@ herdr integration uninstall qodercli herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall grok +herdr integration uninstall agy ``` ## How Herdr uses integrations @@ -56,13 +58,13 @@ Herdr uses integrations in two different ways: | Integration type | Agents | Effect | | --- | --- | --- | | Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Cursor Agent CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Cursor Agent CLI, Grok CLI, Antigravity CLI (agy) | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | Custom socket integrations can also report state when they define state that is not visible in the native terminal UI. -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, and MastraCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI (`agy`) panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, or MastraCode version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi diff --git a/src/agent_resume.rs b/src/agent_resume.rs index f1389b546b..f2da2ec3f2 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -89,6 +89,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { | ("herdr:qodercli", "qodercli") | ("herdr:cursor", "cursor") | ("herdr:grok", "grok") + | ("herdr:agy", "agy") ) } @@ -190,6 +191,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:grok", "grok", AgentSessionRefKind::Id) => { vec!["grok".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:agy", "agy", AgentSessionRefKind::Id) => { + vec!["agy".into(), "--resume".into(), session_ref.value.clone()] + } _ => return None, }; @@ -225,6 +229,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:kilo", "kilo") | ("herdr:cursor", "cursor") | ("herdr:grok", "grok") + | ("herdr:agy", "agy") ) } diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index 8681de5d04..5a82dc78c0 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -28,10 +28,11 @@ pub enum IntegrationTarget { Cursor, Mastracode, Grok, + Agy, } impl IntegrationTarget { - pub(crate) const ALL: [Self; 15] = [ + pub(crate) const ALL: [Self; 16] = [ Self::Pi, Self::Omp, Self::Claude, @@ -47,6 +48,7 @@ impl IntegrationTarget { Self::Cursor, Self::Mastracode, Self::Grok, + Self::Agy, ]; } diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 265bbf9549..716441ddf2 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); } @@ -130,10 +130,11 @@ fn parse_integration_target( "cursor" => IntegrationTarget::Cursor, "mastracode" => IntegrationTarget::Mastracode, "grok" => IntegrationTarget::Grok, + "agy" | "antigravity" => IntegrationTarget::Agy, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, grok, agy" ); return Ok(None); } @@ -159,6 +160,7 @@ fn print_integration_help() { eprintln!(" herdr integration install cursor"); eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration install grok"); + eprintln!(" herdr integration install agy"); eprintln!(" herdr integration uninstall pi"); eprintln!(" herdr integration uninstall omp"); eprintln!(" herdr integration uninstall claude"); @@ -174,5 +176,6 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall cursor"); eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration uninstall grok"); + eprintln!(" herdr integration uninstall agy"); eprintln!(" herdr integration status [--outdated-only]"); } diff --git a/src/integration/actions.rs b/src/integration/actions.rs index c00e10b779..d80ea81906 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -2,12 +2,12 @@ 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_agy, 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_agy, 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}; @@ -217,6 +217,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::Agy => { + let installed = install_agy()?; + vec![ + format!( + "installed antigravity integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured antigravity hooks at {}", + installed.hooks_path.display() + ), + ] + } }; if let Some(warning) = version_warning { @@ -598,6 +611,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Agy => { + let result = uninstall_agy()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed antigravity hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no antigravity hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr antigravity hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr antigravity hook entries found in {}", + result.hooks_path.display() + )); + } + messages + } }; crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); diff --git a/src/integration/assets/agy/herdr-agent-state.ps1 b/src/integration/assets/agy/herdr-agent-state.ps1 new file mode 100644 index 0000000000..7a1dd5caf5 --- /dev/null +++ b/src/integration/assets/agy/herdr-agent-state.ps1 @@ -0,0 +1,26 @@ +# 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=agy +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} + +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:agy --agent agy --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/agy/herdr-agent-state.sh b/src/integration/assets/agy/herdr-agent-state.sh new file mode 100644 index 0000000000..3ae652b42a --- /dev/null +++ b/src/integration/assets/agy/herdr-agent-state.sh @@ -0,0 +1,94 @@ +#!/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=agy +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +action="${1:-}" +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-agy-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true + +case "$action" in + session) ;; + *) exit 0 ;; +esac + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +import json +import os +import random +import socket +import time + +source = "herdr:agy" +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") + +if not pane_id or not socket_path: + raise SystemExit(0) + +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + + +def first_text(*keys): + for key in keys: + value = hook_input.get(key) + if isinstance(value, str) and value: + return value + return None + + +hook_event_name = first_text("hook_event_name", "hookEventName") +if hook_event_name not in (None, "session_start", "SessionStart", "sessionStart"): + raise SystemExit(0) + +session_id = os.environ.get("AGY_SESSION_ID") or os.environ.get("ANTIGRAVITY_SESSION_ID") or first_text("session_id", "sessionId", "conversation_id", "conversationId") +agent_session_id = session_id if isinstance(session_id, str) and session_id else None +if not agent_session_id: + raise SystemExit(0) + +request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "agy", + "seq": report_seq, + "agent_session_id": agent_session_id, + }, +} + +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode()) + try: + client.recv(4096) + except Exception: + pass + client.close() +except Exception: + pass +PY diff --git a/src/integration/env.rs b/src/integration/env.rs index 2d0feeb01b..dafea88fdb 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -21,6 +21,8 @@ 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`). pub(crate) const GROK_HOME_ENV_VAR: &str = "GROK_HOME"; +pub(crate) const AGY_CONFIG_DIR_ENV_VAR: &str = "AGY_CONFIG_DIR"; +pub(crate) const ANTIGRAVITY_CONFIG_DIR_ENV_VAR: &str = "ANTIGRAVITY_CONFIG_DIR"; pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); @@ -154,6 +156,16 @@ pub(crate) fn grok_dir() -> io::Result { config_dir_from_env_or_home(GROK_HOME_ENV_VAR, &[".grok"]) } +pub(crate) fn agy_dir() -> io::Result { + if let Some(value) = std::env::var_os(AGY_CONFIG_DIR_ENV_VAR) + .or_else(|| std::env::var_os(ANTIGRAVITY_CONFIG_DIR_ENV_VAR)) + .filter(|value| !value.is_empty()) + { + return expand_tilde_path(PathBuf::from(value)); + } + config_dir_from_env_or_home(AGY_CONFIG_DIR_ENV_VAR, &[".gemini", "antigravity-cli"]) +} + pub(crate) fn home_dir() -> io::Result { if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) { return Ok(PathBuf::from(home)); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index b691dce95c..373dac2fa3 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -219,6 +219,18 @@ 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 AGY_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const AGY_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/agy/herdr-agent-state.ps1") +} else { + include_str!("assets/agy/herdr-agent-state.sh") +}; +const AGY_INTEGRATION_VERSION: u32 = 1; +const AGY_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; 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 10500df536..ab593f06d0 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -23,6 +23,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Cursor => "cursor", crate::api::schema::IntegrationTarget::Mastracode => "mastracode", crate::api::schema::IntegrationTarget::Grok => "grok", + crate::api::schema::IntegrationTarget::Agy => "agy", } } @@ -51,6 +52,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], crate::api::schema::IntegrationTarget::Grok => &["grok"], + crate::api::schema::IntegrationTarget::Agy => &["agy", "antigravity"], } } @@ -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::Agy ) } @@ -259,7 +262,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, -); 15] { +); 16] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -339,6 +342,11 @@ fn integration_specs() -> [( grok_dir().map(|dir| dir.join("hooks").join(super::GROK_HOOK_INSTALL_NAME)), super::GROK_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Agy, + agy_dir().map(|dir| dir.join("hooks").join(super::AGY_HOOK_INSTALL_NAME)), + super::AGY_INTEGRATION_VERSION, + ), ] } diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 87000eae1d..59cfb1d625 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -13,32 +13,32 @@ 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, + agy_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, + AgyInstallPaths, AgyUninstallResult, 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, + AGY_HOOK_ASSET, AGY_HOOK_EVENTS, AGY_HOOK_INSTALL_NAME, 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, @@ -1272,3 +1272,85 @@ pub(crate) fn uninstall_grok() -> io::Result { removed_config_file, }) } + +pub(crate) fn install_agy() -> io::Result { + let dir = agy_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "antigravity config directory not found at {}. install antigravity (agy) first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(AGY_HOOK_INSTALL_NAME); + fs::write(&hook_path, AGY_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::(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut hooks_file, + &hooks_path, + "antigravity hooks file", + "antigravity hooks file hooks", + )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + ensure_command_hook( + hooks, + "SessionStart", + hook_command(&hook_path, Some("session")), + 10, + None, + )?; + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + Ok(AgyInstallPaths { + hook_path, + hooks_path, + }) +} + +pub(crate) fn uninstall_agy() -> io::Result { + let dir = agy_dir()?; + let hook_path = dir.join("hooks").join(AGY_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::(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + if let Some(hooks) = + hooks_object_if_present(&mut hooks_file, &hooks_path, "agy hooks", "agy hooks")? + { + for (event, action) in AGY_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + 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(AgyUninstallResult { + hook_path, + hooks_path, + removed_hook_file, + updated_hooks, + }) +} diff --git a/src/integration/tests.rs b/src/integration/tests.rs index ff6162647f..0246b02d40 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -104,6 +104,8 @@ fn clear_integration_path_env() { std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_HOME_ENV_VAR); + std::env::remove_var(AGY_CONFIG_DIR_ENV_VAR); + std::env::remove_var(ANTIGRAVITY_CONFIG_DIR_ENV_VAR); } fn kimi_hook_command(hook_path: &Path, action: &str) -> String { @@ -3786,3 +3788,98 @@ fn grok_dir_honors_grok_home_after_config_dir_seam() { clear_integration_path_env(); let _ = fs::remove_dir_all(base); } + +#[test] +fn agy_hook_asset_headers_are_valid() { + assert!(AGY_HOOK_ASSET.contains("installed by herdr")); + assert!(AGY_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=agy")); + assert!(AGY_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=1")); + assert!(AGY_HOOK_ASSET.contains("herdr:agy")); +} + +#[test] +fn install_agy_writes_hook_and_hooks_json() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("antigravity-cli"); + fs::create_dir_all(&agy_dir).unwrap(); + std::env::set_var(AGY_CONFIG_DIR_ENV_VAR, &agy_dir); + + let installed = install_agy().unwrap(); + assert_eq!( + installed.hook_path, + agy_dir.join("hooks").join(AGY_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, agy_dir.join("hooks.json")); + assert!(installed.hook_path.is_file()); + assert!(installed.hooks_path.is_file()); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap(); + assert!(hooks_file.get("hooks").is_some()); + + let status = installed_integration_statuses() + .into_iter() + .find(|status| status.target == crate::api::schema::IntegrationTarget::Agy) + .expect("agy integration status"); + assert_eq!(status.state, IntegrationStatusKind::Current); + assert_eq!(status.installed_version, Some(AGY_INTEGRATION_VERSION)); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_agy_is_idempotent() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("antigravity-cli"); + fs::create_dir_all(&agy_dir).unwrap(); + std::env::set_var(AGY_CONFIG_DIR_ENV_VAR, &agy_dir); + + install_agy().unwrap(); + let first = fs::read_to_string(agy_dir.join("hooks.json")).unwrap(); + install_agy().unwrap(); + let second = fs::read_to_string(agy_dir.join("hooks.json")).unwrap(); + assert_eq!(first, second); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_agy_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let missing = base.join(".gemini").join("antigravity-cli"); + std::env::set_var(AGY_CONFIG_DIR_ENV_VAR, &missing); + + let err = install_agy().unwrap_err().to_string(); + assert!( + err.contains("antigravity config directory not found"), + "expected missing config dir error, got: {err}" + ); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_agy_removes_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("antigravity-cli"); + fs::create_dir_all(&agy_dir).unwrap(); + std::env::set_var(AGY_CONFIG_DIR_ENV_VAR, &agy_dir); + + install_agy().unwrap(); + let result = uninstall_agy().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_hooks); + + let again = uninstall_agy().unwrap(); + assert!(!again.removed_hook_file); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} diff --git a/src/integration/types.rs b/src/integration/types.rs index 93138a3db5..298e3f6da9 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 AgyInstallPaths { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct AgyUninstallResult { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub removed_hook_file: bool, + pub updated_hooks: bool, +}