diff options
| author | Enrico Carlesso <enricocarlesso@gmail.com> | 2026-07-24 12:05:20 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-24 22:05:20 +0300 |
| commit | e9b22084e03880f43d1f1daaf5d9f45c717b77c4 (patch) | |
| tree | 343c3099fcb0b935f857b7eb00df1e4d9c83d609 /src/integration | |
| parent | b56baee9d60a002b5d8c0fe74d5032afad27ef30 (diff) | |
feat: add grok cli integration for native session restore (#1807)
* feat: add grok cli integration for native session restore
tl;dr: adds `herdr integration install grok` (session-identity tier: a
SessionStart hook reports the grok session id for native restore; state
stays screen-detected via the bundled manifest).
Grok merges every ~/.grok/hooks/*.json, so install writes a
self-contained hooks/herdr.json registering a SessionStart hook next to
hooks/herdr-agent-state.sh, and never touches other hook files. The hook
prefers $GROK_SESSION_ID (injected into every grok hook process) and
reports through the socket API; herdr:grok is a reserved-native-state
source so screen detection stays authoritative. Restore uses
`grok --resume <id>`. Additive JSON API change; no protocol bump; the
schema artifact is regenerated.
refs #1800
* fix: honor GROK_HOME and validate grok hook config in status
Review follow-ups: grok_dir now resolves GROK_CONFIG_DIR (herdr test
seam) before GROK_HOME (the grok CLI's own config-home override) before
~/.grok, so hook installs land where grok actually looks. Integration
status now also validates the herdr-owned hooks/herdr.json next to the
hook script — missing, corrupt, or no-longer-referencing configs report
the install as outdated instead of masking a hook grok never invokes.
refs #1800
* fix: validate the exact grok session hook command in status
The config validator matched on a filename substring, so a SessionStart
command like `echo herdr-agent-state.sh`, or one invoking the script
without the `session` action, still reported the integration as current
while grok never delivered a session id. Status now compares against the
exact command the installer writes, via a shared builder, and the
degradation tests cover both nonfunctional shapes.
refs #1800
* fix: require a command-type grok session hook in status validation
A config entry carrying the exact generated command under a non-command
hook type (e.g. "http") is never executed by grok, so it must not report
the integration as current. The validator now also requires
type = "command", with a degradation test for the mismatched-type shape.
refs #1800
* fix: validate the complete grok hook config
refs #1800
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/actions.rs | 50 | ||||
| -rw-r--r-- | src/integration/assets/grok/herdr-agent-state.sh | 99 | ||||
| -rw-r--r-- | src/integration/env.rs | 16 | ||||
| -rw-r--r-- | src/integration/mod.rs | 4 | ||||
| -rw-r--r-- | src/integration/registry.rs | 35 | ||||
| -rw-r--r-- | src/integration/targets.rs | 84 | ||||
| -rw-r--r-- | src/integration/tests.rs | 280 | ||||
| -rw-r--r-- | src/integration/types.rs | 14 |
8 files changed, 569 insertions, 13 deletions
diff --git a/src/integration/actions.rs b/src/integration/actions.rs index 807ceeb6..c00e10b7 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -3,11 +3,11 @@ 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_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_hermes, uninstall_kilo, - uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, uninstall_pi, - uninstall_qodercli, + 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, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -204,6 +204,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::Grok => { + let installed = install_grok()?; + vec![ + format!( + "installed grok integration hook to {}", + installed.hook_path.display() + ), + format!( + "registered grok hook config at {}", + installed.config_path.display() + ), + ] + } }; if let Some(warning) = version_warning { @@ -558,6 +571,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Grok => { + let result = uninstall_grok()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed grok hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no grok hook found at {}", + result.hook_path.display() + )); + } + if result.removed_config_file { + messages.push(format!( + "removed grok hook config at {}", + result.config_path.display() + )); + } else { + messages.push(format!( + "no grok hook config found at {}", + result.config_path.display() + )); + } + messages + } }; crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); diff --git a/src/integration/assets/grok/herdr-agent-state.sh b/src/integration/assets/grok/herdr-agent-state.sh new file mode 100644 index 00000000..bdf0f671 --- /dev/null +++ b/src/integration/assets/grok/herdr-agent-state.sh @@ -0,0 +1,99 @@ +#!/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=grok +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +action="${1:-}" +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-grok-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:grok" +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 + + +# Backstop: only report on session-start payloads. Grok emits +# "session_start" and accepts the Claude/Cursor spellings, so tolerate all +# three; a missing field is allowed for forward compatibility. +hook_event_name = first_text("hook_event_name", "hookEventName") +if hook_event_name not in (None, "session_start", "SessionStart", "sessionStart"): + raise SystemExit(0) + +# Grok injects GROK_SESSION_ID into every hook process; prefer it and fall +# back to the event payload's session id fields. +session_id = os.environ.get("GROK_SESSION_ID") or first_text("session_id", "sessionId") +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": "grok", + "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 3cd1b604..2d0feeb0 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -17,6 +17,10 @@ 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 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) fn apply_pane_base_env(cmd: &mut CommandBuilder) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); @@ -138,6 +142,18 @@ pub(crate) fn mastracode_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".mastracode")) } +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. + if let Some(value) = std::env::var_os(GROK_CONFIG_DIR_ENV_VAR).filter(|value| !value.is_empty()) + { + return expand_tilde_path(PathBuf::from(value)); + } + // The grok CLI honors GROK_HOME as its config home (config.toml, + // auth.json, hooks/); mirror it so hook installs land where grok looks. + config_dir_from_env_or_home(GROK_HOME_ENV_VAR, &[".grok"]) +} + pub(crate) fn home_dir() -> io::Result<PathBuf> { 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 d7bd8b3f..40189c64 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -215,6 +215,10 @@ const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [ ("AgentEnd", "idle"), ("Stop", "idle"), ]; +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 bcd17220..10500df5 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::Grok => "grok", } } @@ -49,6 +50,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::Grok => &["grok"], } } @@ -257,7 +259,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 14] { +); 15] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -332,6 +334,11 @@ fn integration_specs() -> [( mastracode_dir().map(|dir| dir.join("hooks").join(super::MASTRACODE_HOOK_INSTALL_NAME)), super::MASTRACODE_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Grok, + grok_dir().map(|dir| dir.join("hooks").join(super::GROK_HOOK_INSTALL_NAME)), + super::GROK_INTEGRATION_VERSION, + ), ] } @@ -372,6 +379,19 @@ pub(crate) fn print_outdated_update_notice() -> bool { true } +/// Whether the Herdr-owned Grok hook config exactly matches the installed +/// integration. JSON formatting and object key order do not affect validity. +fn grok_hook_config_is_valid(hook_path: &Path) -> bool { + let Some(hooks_dir) = hook_path.parent() else { + return false; + }; + let config_path = hooks_dir.join(super::GROK_HOOK_CONFIG_INSTALL_NAME); + fs::read_to_string(config_path) + .ok() + .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok()) + .is_some_and(|config| config == super::targets::grok_hook_config(hook_path)) +} + pub(crate) fn integration_status_at( target: crate::api::schema::IntegrationTarget, path: PathBuf, @@ -390,12 +410,23 @@ pub(crate) fn integration_status_at( let installed_version = fs::read_to_string(&path) .ok() .and_then(|content| parse_integration_version(&content)); - let state = if installed_version.is_some_and(|version| version >= expected_version) { + let mut state = if installed_version.is_some_and(|version| version >= expected_version) { super::IntegrationStatusKind::Current } else { super::IntegrationStatusKind::Outdated }; + // Grok only invokes the hook when the herdr-owned `hooks/herdr.json` + // registers it, so a current hook script with a missing or broken config + // is a nonfunctional install: report it as outdated so `herdr integration + // status` flags it and a reinstall rewrites both files. + if target == crate::api::schema::IntegrationTarget::Grok + && state == super::IntegrationStatusKind::Current + && !grok_hook_config_is_valid(&path) + { + state = super::IntegrationStatusKind::Outdated; + } + super::IntegrationStatus { target, path, diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 488b7032..87000eae 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -13,7 +13,7 @@ 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, hermes_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, }; @@ -24,10 +24,11 @@ use super::types::{ ClaudeInstallPaths, ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, - HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, KiloUninstallResult, - KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, - OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, - PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, + 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, @@ -35,7 +36,8 @@ use super::{ 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, HERMES_PLUGIN_INIT_ASSET, + 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, @@ -1200,3 +1202,73 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> { 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 { + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + let session_command = format!("sh {quoted_hook_path} session"); + json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": session_command, + "timeout": 10, + } + ] + } + ] + } + }) +} + +pub(crate) fn install_grok() -> io::Result<GrokInstallPaths> { + let dir = grok_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "grok config directory not found at {}. install grok cli first", + dir.display() + ))); + } + + // Grok merges every `~/.grok/hooks/*.json`, so herdr owns a dedicated + // config file and never edits the user's other hooks. The hook script and + // its config live side by side under `hooks/`. + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(GROK_HOOK_INSTALL_NAME); + fs::write(&hook_path, GROK_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let config_path = hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME); + fs::write( + &config_path, + serde_json::to_string_pretty(&grok_hook_config(&hook_path))?, + )?; + + Ok(GrokInstallPaths { + hook_path, + config_path, + }) +} + +pub(crate) fn uninstall_grok() -> io::Result<GrokUninstallResult> { + let hooks_dir = grok_dir()?.join("hooks"); + let hook_path = hooks_dir.join(GROK_HOOK_INSTALL_NAME); + let config_path = hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME); + + // herdr owns both files outright, so removal is a straight delete. + let removed_config_file = remove_file_if_exists(&config_path)?; + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(GrokUninstallResult { + hook_path, + config_path, + removed_hook_file, + removed_config_file, + }) +} diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 076a22b6..f695bf52 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -102,6 +102,8 @@ 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(GROK_CONFIG_DIR_ENV_VAR); + std::env::remove_var(GROK_HOME_ENV_VAR); } fn kimi_hook_command(hook_path: &Path, action: &str) -> String { @@ -2777,6 +2779,14 @@ fn bundled_integration_assets_report_session_refs() { assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent_session")); assert!(MASTRACODE_HOOK_ASSET.contains("session_start_source")); assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent")); + assert!(GROK_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=grok")); + assert!(GROK_HOOK_ASSET.contains("GROK_SESSION_ID")); + assert!(GROK_HOOK_ASSET.contains("sessionId")); + assert!(GROK_HOOK_ASSET.contains("agent_session_id")); + assert!(GROK_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(GROK_HOOK_ASSET.contains("herdr:grok")); + assert!(!GROK_HOOK_ASSET.contains("\"state\":")); + assert!(!GROK_HOOK_ASSET.contains("pane.release_agent")); } #[test] @@ -3331,6 +3341,48 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() { let _ = fs::remove_dir_all(base); } +fn grok_session_command(config: &Value) -> String { + config["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .expect("grok SessionStart command") + .to_string() +} + +#[test] +fn install_grok_writes_hook_and_config() { + let _lock = integration_env_lock(); + let base = unique_base(); + let grok_dir = base.join(".grok"); + fs::create_dir_all(&grok_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir); + + let installed = install_grok().unwrap(); + + let hooks_dir = grok_dir.join("hooks"); + assert_eq!(installed.hook_path, hooks_dir.join(GROK_HOOK_INSTALL_NAME)); + assert_eq!( + installed.config_path, + hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME) + ); + assert_eq!( + fs::read_to_string(&installed.hook_path).unwrap(), + GROK_HOOK_ASSET + ); + + let config: Value = + serde_json::from_str(&fs::read_to_string(&installed.config_path).unwrap()).unwrap(); + assert_eq!(config, grok_hook_config(&installed.hook_path)); + let session_start = config["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(session_start.len(), 1); + let command = grok_session_command(&config); + assert!(command.starts_with("sh ")); + assert!(command.contains("herdr-agent-state.sh")); + assert!(command.ends_with(" session")); + + std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + #[test] fn install_mastracode_removes_v1_lifecycle_hooks() { let _lock = integration_env_lock(); @@ -3410,6 +3462,26 @@ fn install_mastracode_is_idempotent_for_hook_entries() { } #[test] +fn install_grok_is_idempotent() { + let _lock = integration_env_lock(); + let base = unique_base(); + let grok_dir = base.join(".grok"); + fs::create_dir_all(&grok_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir); + + install_grok().unwrap(); + let first = + fs::read_to_string(grok_dir.join("hooks").join(GROK_HOOK_CONFIG_INSTALL_NAME)).unwrap(); + install_grok().unwrap(); + let second = + fs::read_to_string(grok_dir.join("hooks").join(GROK_HOOK_CONFIG_INSTALL_NAME)).unwrap(); + assert_eq!(first, second); + + std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] fn uninstall_mastracode_removes_herdr_hooks_and_preserves_others() { let _lock = integration_env_lock(); let base = unique_base(); @@ -3467,6 +3539,71 @@ fn uninstall_mastracode_removes_herdr_hooks_and_preserves_others() { } #[test] +fn install_grok_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + // Deliberately do not create the ~/.grok directory ahead of time: the + // installer must refuse instead of conjuring a config dir for an agent + // that is not installed. + let missing = base.join(".grok"); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &missing); + + let err = install_grok().unwrap_err().to_string(); + assert!( + err.contains("grok config directory not found"), + "unexpected error: {err}" + ); + + std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_grok_removes_files() { + let _lock = integration_env_lock(); + let base = unique_base(); + let grok_dir = base.join(".grok"); + fs::create_dir_all(&grok_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir); + + install_grok().unwrap(); + let result = uninstall_grok().unwrap(); + assert!(result.removed_hook_file); + assert!(result.removed_config_file); + assert!(!result.hook_path.is_file()); + assert!(!result.config_path.is_file()); + + // Uninstalling again is a no-op. + let again = uninstall_grok().unwrap(); + assert!(!again.removed_hook_file); + assert!(!again.removed_config_file); + + std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_grok_uses_grok_config_dir_env() { + let _lock = integration_env_lock(); + let base = unique_base(); + let grok_dir = base.join("custom-grok"); + fs::create_dir_all(&grok_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir); + + let installed = install_grok().unwrap(); + + let hooks_dir = grok_dir.join("hooks"); + assert_eq!(installed.hook_path, hooks_dir.join(GROK_HOOK_INSTALL_NAME)); + assert_eq!( + installed.config_path, + hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME) + ); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} + +#[test] fn install_mastracode_errors_when_event_value_not_array() { let _lock = integration_env_lock(); let base = unique_base(); @@ -3513,3 +3650,146 @@ fn uninstall_mastracode_errors_when_event_value_not_array() { } let _ = fs::remove_dir_all(base); } + +#[test] +fn grok_v1_integration_status_is_current() { + let _lock = integration_env_lock(); + let base = unique_base(); + let grok_dir = base.join(".grok"); + fs::create_dir_all(&grok_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir); + // A real install writes both the hook script and hooks/herdr.json. + install_grok().unwrap(); + + let statuses = installed_integration_statuses(); + let grok = statuses + .iter() + .find(|status| status.target == crate::api::schema::IntegrationTarget::Grok) + .expect("grok integration status"); + assert_eq!(grok.state, IntegrationStatusKind::Current); + assert_eq!(grok.installed_version, Some(GROK_INTEGRATION_VERSION)); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn grok_status_reports_outdated_when_hook_config_missing_or_broken() { + let _lock = integration_env_lock(); + let base = unique_base(); + let grok_dir = base.join(".grok"); + fs::create_dir_all(&grok_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir); + install_grok().unwrap(); + let config_path = grok_dir.join("hooks").join(GROK_HOOK_CONFIG_INSTALL_NAME); + + let grok_state = || { + installed_integration_statuses() + .into_iter() + .find(|status| status.target == crate::api::schema::IntegrationTarget::Grok) + .expect("grok integration status") + .state + }; + + // Missing config: grok never runs the hook, so the install is not current. + fs::remove_file(&config_path).unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // Corrupt config. + fs::write(&config_path, "{not json").unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // Config that no longer references the hook script. + fs::write( + &config_path, + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo other"}]}]}}"#, + ) + .unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // Config that mentions the script name without invoking it, and one that + // invokes it without the required `session` action: both are + // nonfunctional, so neither may report current. + fs::write( + &config_path, + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo herdr-agent-state.sh"}]}]}}"#, + ) + .unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + let hook_path = grok_dir.join("hooks").join(GROK_HOOK_INSTALL_NAME); + fs::write( + &config_path, + format!( + r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"sh '{}'"}}]}}]}}}}"#, + hook_path.display() + ), + ) + .unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // Correct command but not a command-type hook: grok will not execute it. + let session_command = grok_session_command(&grok_hook_config(&hook_path)); + fs::write( + &config_path, + format!( + r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"http","command":{}}}]}}]}}}}"#, + serde_json::to_string(&session_command).unwrap() + ), + ) + .unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // A matcher can prevent the expected hook from running. + let mut config = grok_hook_config(&hook_path); + config["hooks"]["SessionStart"][0]["matcher"] = json!("("); + fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // A malformed sibling group makes grok reject the event's hook groups. + let mut config = grok_hook_config(&hook_path); + config["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(json!({})); + fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Outdated); + + // Reinstall repairs both files. + install_grok().unwrap(); + assert_eq!(grok_state(), IntegrationStatusKind::Current); + + clear_integration_path_env(); + 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(); + let home_dir = base.join("grok-home"); + fs::create_dir_all(&home_dir).unwrap(); + std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); + std::env::set_var(GROK_HOME_ENV_VAR, &home_dir); + + // The grok CLI reads its config (and hooks/) from $GROK_HOME, so the + // integration must install there too. + let installed = install_grok().unwrap(); + assert_eq!( + installed.hook_path, + home_dir.join("hooks").join(GROK_HOOK_INSTALL_NAME) + ); + + // The herdr-level test seam still wins over GROK_HOME when set. + let seam_dir = base.join("seam"); + fs::create_dir_all(&seam_dir).unwrap(); + std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &seam_dir); + let installed = install_grok().unwrap(); + assert_eq!( + installed.hook_path, + seam_dir.join("hooks").join(GROK_HOOK_INSTALL_NAME) + ); + + std::env::remove_var(GROK_HOME_ENV_VAR); + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); +} diff --git a/src/integration/types.rs b/src/integration/types.rs index 44ec76f1..93138a3d 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -96,6 +96,20 @@ pub(crate) struct MastracodeUninstallResult { } #[derive(Debug)] +pub(crate) struct GrokInstallPaths { + pub hook_path: PathBuf, + pub config_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct GrokUninstallResult { + pub hook_path: PathBuf, + pub config_path: PathBuf, + pub removed_hook_file: bool, + pub removed_config_file: bool, +} + +#[derive(Debug)] pub(crate) struct QodercliUninstallResult { pub hook_path: PathBuf, pub settings_path: PathBuf, |
