aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorEnrico Carlesso <enricocarlesso@gmail.com>2026-07-24 12:05:20 -0700
committerGitHub <noreply@github.com>2026-07-24 22:05:20 +0300
commite9b22084e03880f43d1f1daaf5d9f45c717b77c4 (patch)
tree343c3099fcb0b935f857b7eb00df1e4d9c83d609 /src/integration
parentb56baee9d60a002b5d8c0fe74d5032afad27ef30 (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.rs50
-rw-r--r--src/integration/assets/grok/herdr-agent-state.sh99
-rw-r--r--src/integration/env.rs16
-rw-r--r--src/integration/mod.rs4
-rw-r--r--src/integration/registry.rs35
-rw-r--r--src/integration/targets.rs84
-rw-r--r--src/integration/tests.rs280
-rw-r--r--src/integration/types.rs14
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,