aboutsummaryrefslogtreecommitdiff
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
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>
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/api/herdr-api.schema.json6
-rw-r--r--docs/next/website/src/content/docs/agents.mdx4
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx24
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx1
-rw-r--r--src/agent_resume.rs15
-rw-r--r--src/api/schema/integrations.rs4
-rw-r--r--src/cli/integration.rs9
-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
17 files changed, 623 insertions, 25 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 4a462aef..ee05a843 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -125,6 +125,7 @@
- Added `herdr terminal session control` for bridge processes that need live ANSI frames plus input, resize, scroll, release, and takeover authority.
- Added `ui.hide_tab_bar_when_single_tab` to hide the tab row when a workspace has one tab. (#448)
- Added Japanese and Simplified Chinese website docs.
+- Added `herdr integration install grok` for Grok CLI (Grok Build) hooks that report session ids through Herdr's socket API. Grok state stays screen-detected. When native agent session restore is enabled, Herdr can resume Grok panes with `grok --resume <id>`.
### Changed
- The mobile switcher now starts from an agents-first summary and renders worktrees as a tree, making narrow terminals easier to scan.
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
index 93cf653a..dc6dce83 100644
--- a/docs/next/api/herdr-api.schema.json
+++ b/docs/next/api/herdr-api.schema.json
@@ -2085,7 +2085,8 @@
"hermes",
"qodercli",
"cursor",
- "mastracode"
+ "mastracode",
+ "grok"
],
"type": "string"
},
@@ -6997,7 +6998,8 @@
"hermes",
"qodercli",
"cursor",
- "mastracode"
+ "mastracode",
+ "grok"
],
"type": "string"
},
diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index ee275d19..f5a4ac48 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -28,7 +28,7 @@ Automatic detection works out of the box for common coding agents. The important
| Codex | screen manifest | session |
| Cursor Agent CLI | screen manifest | session |
| Amp | screen manifest | none |
-| Grok CLI | screen manifest | none |
+| Grok CLI | screen manifest | session |
| Antigravity CLI | screen manifest | none |
| Kiro CLI | screen manifest | none |
| Maki | screen manifest | none |
@@ -45,7 +45,7 @@ For agents without complete lifecycle hooks, Herdr identifies the foreground pro
The screen snapshot comes from the recent bottom of the pane buffer, not the scrolled viewport. If you scroll back in Herdr, detection still follows the live agent UI at the bottom.
-Claude Code, Codex, GitHub Copilot CLI, Droid, Qoder CLI, and Cursor Agent CLI integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses screen manifest detection.
+Claude Code, Codex, GitHub Copilot CLI, Droid, Qoder CLI, Cursor Agent CLI, and Grok CLI integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses screen manifest detection.
## VMs and sandbox wrappers
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index b9f2dc2c..19a7ec23 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -367,6 +367,7 @@ herdr integration install hermes
herdr integration install qodercli
herdr integration install cursor
herdr integration install mastracode
+herdr integration install grok
herdr integration uninstall pi
herdr integration uninstall omp
herdr integration uninstall claude
@@ -381,6 +382,7 @@ herdr integration uninstall hermes
herdr integration uninstall qodercli
herdr integration uninstall cursor
herdr integration uninstall mastracode
+herdr integration uninstall grok
herdr integration status [--outdated-only]
```
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 940d1deb..fcc908a7 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, and MastraCode.
+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.
---
Herdr detects supported agents automatically. Official integrations can add native session identity for restore, lifecycle state reports, or both.
@@ -26,6 +26,7 @@ herdr integration install hermes
herdr integration install qodercli
herdr integration install cursor
herdr integration install mastracode
+herdr integration install grok
```
## Uninstall integrations
@@ -45,6 +46,7 @@ herdr integration uninstall hermes
herdr integration uninstall qodercli
herdr integration uninstall cursor
herdr integration uninstall mastracode
+herdr integration uninstall grok
```
## How Herdr uses integrations
@@ -54,13 +56,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 | 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 | 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, 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, and MastraCode 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`, 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`, or MastraCode version `1`. Check installed versions with `herdr integration status`.
## Pi
@@ -256,6 +258,20 @@ Herdr uses `~/.mastracode`. Install writes `hooks/herdr-agent-state.sh` and adds
Herdr resumes stored MastraCode threads with `mastracode --thread <id>`.
+## Grok CLI
+
+Install the Grok CLI hook:
+
+```bash
+herdr integration install grok
+```
+
+The hook reports session identity through Grok's `SessionStart` hook while Grok CLI runs inside a Herdr pane. Grok state comes from Herdr's screen manifest detection.
+
+Herdr uses `~/.grok` by default, or `GROK_HOME` when set. The Grok config directory must already exist. Grok merges every `hooks/*.json` file in that directory, so install writes a self-contained `hooks/herdr.json` with the Herdr `SessionStart` entry next to the `hooks/herdr-agent-state.sh` script, and never edits other hook files. Uninstall removes exactly those two Herdr-owned files.
+
+After Grok emits a session start event, Herdr can use the reported session id to resume the pane with `grok --resume <id>`.
+
## Custom status labels
Integrations report lifecycle state as semantic state only. For example, report an agent as `working` without adding display fields to the lifecycle report.
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index 811a346d..87987b2d 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -71,6 +71,7 @@ Native session restore requires these Herdr integration versions or newer:
| Claude Code | `6` | `claude --resume <id>` |
| Codex | `5` | `codex resume <id>` |
| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` |
+| Grok CLI | `1` | `grok --resume <id>` |
| GitHub Copilot CLI | `2` | `copilot --resume=<id>` |
| Devin CLI | `2` | `devin --resume <id>` |
| Droid | `2` | `droid --resume <id>` |
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 1cb0f9dd..f1389b54 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -88,6 +88,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool {
| ("herdr:droid", "droid")
| ("herdr:qodercli", "qodercli")
| ("herdr:cursor", "cursor")
+ | ("herdr:grok", "grok")
)
}
@@ -186,6 +187,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<
session_ref.value.clone(),
]
}
+ ("herdr:grok", "grok", AgentSessionRefKind::Id) => {
+ vec!["grok".into(), "--resume".into(), session_ref.value.clone()]
+ }
_ => return None,
};
@@ -220,6 +224,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool {
| ("herdr:qodercli", "qodercli")
| ("herdr:kilo", "kilo")
| ("herdr:cursor", "cursor")
+ | ("herdr:grok", "grok")
)
}
@@ -402,6 +407,16 @@ mod tests {
.argv,
vec!["cursor-agent", "--resume", "cursor-session"]
);
+ assert_eq!(
+ plan(
+ "herdr:grok",
+ "grok",
+ &AgentSessionRef::id("grok-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["grok", "--resume", "grok-session"]
+ );
}
#[test]
diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs
index 5a267ff1..8681de5d 100644
--- a/src/api/schema/integrations.rs
+++ b/src/api/schema/integrations.rs
@@ -27,10 +27,11 @@ pub enum IntegrationTarget {
Qodercli,
Cursor,
Mastracode,
+ Grok,
}
impl IntegrationTarget {
- pub(crate) const ALL: [Self; 14] = [
+ pub(crate) const ALL: [Self; 15] = [
Self::Pi,
Self::Omp,
Self::Claude,
@@ -45,6 +46,7 @@ impl IntegrationTarget {
Self::Qodercli,
Self::Cursor,
Self::Mastracode,
+ Self::Grok,
];
}
diff --git a/src/cli/integration.rs b/src/cli/integration.rs
index c407278e..265bbf95 100644
--- a/src/cli/integration.rs
+++ b/src/cli/integration.rs
@@ -103,13 +103,13 @@ fn parse_integration_target(
) -> std::io::Result<Option<IntegrationTarget>> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode|grok>"
);
return Ok(None);
};
if args.len() != 1 {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode|grok>"
);
return Ok(None);
}
@@ -129,10 +129,11 @@ fn parse_integration_target(
"qodercli" => IntegrationTarget::Qodercli,
"cursor" => IntegrationTarget::Cursor,
"mastracode" => IntegrationTarget::Mastracode,
+ "grok" => IntegrationTarget::Grok,
_ => {
eprintln!("unknown integration target: {target}");
eprintln!(
- "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode"
+ "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, grok"
);
return Ok(None);
}
@@ -157,6 +158,7 @@ fn print_integration_help() {
eprintln!(" herdr integration install qodercli");
eprintln!(" herdr integration install cursor");
eprintln!(" herdr integration install mastracode");
+ eprintln!(" herdr integration install grok");
eprintln!(" herdr integration uninstall pi");
eprintln!(" herdr integration uninstall omp");
eprintln!(" herdr integration uninstall claude");
@@ -171,5 +173,6 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall qodercli");
eprintln!(" herdr integration uninstall cursor");
eprintln!(" herdr integration uninstall mastracode");
+ eprintln!(" herdr integration uninstall grok");
eprintln!(" herdr integration status [--outdated-only]");
}
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,