aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/cursor/herdr-agent-state.ps148
-rw-r--r--src/integration/assets/devin/herdr-agent-state.ps151
-rw-r--r--src/integration/assets/grok/herdr-agent-state.ps141
-rw-r--r--src/integration/assets/hermes/__init__.py70
-rw-r--r--src/integration/assets/mastracode/herdr-agent-state.ps135
-rw-r--r--src/integration/env.rs22
-rw-r--r--src/integration/mod.rs50
-rw-r--r--src/integration/registry.rs10
-rw-r--r--src/integration/targets.rs59
-rw-r--r--src/integration/tests.rs116
10 files changed, 369 insertions, 133 deletions
diff --git a/src/integration/assets/cursor/herdr-agent-state.ps1 b/src/integration/assets/cursor/herdr-agent-state.ps1
new file mode 100644
index 00000000..ebfdb393
--- /dev/null
+++ b/src/integration/assets/cursor/herdr-agent-state.ps1
@@ -0,0 +1,48 @@
+# managed by herdr; reinstalling the integration replaces this file.
+# HERDR_INTEGRATION_ID=cursor
+# HERDR_INTEGRATION_VERSION=1
+
+param([string]$Action = "")
+
+function Exit-Hook {
+ Write-Output "{}"
+ exit 0
+}
+
+if ($Action -ne "session") { Exit-Hook }
+if ($env:HERDR_ENV -ne "1") { Exit-Hook }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook }
+
+$inputText = [Console]::In.ReadToEnd()
+$jsonStart = $inputText.IndexOf("{")
+if ($jsonStart -gt 0) {
+ $inputText = $inputText.Substring($jsonStart)
+}
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ Exit-Hook
+}
+
+if ($null -eq $payload) { Exit-Hook }
+$event = if ($payload.hook_event_name -is [string]) { $payload.hook_event_name } else { $payload.hookEventName }
+if (-not [string]::IsNullOrWhiteSpace($event) -and $event -ne "sessionStart") { Exit-Hook }
+
+$sessionId = $null
+foreach ($name in @("session_id", "sessionId", "conversation_id", "conversationId")) {
+ $value = $payload.$name
+ if ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) {
+ $sessionId = $value
+ break
+ }
+}
+if ([string]::IsNullOrWhiteSpace($sessionId)) { Exit-Hook }
+
+$seq = [DateTime]::UtcNow.Ticks
+$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH }
+try {
+ & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:cursor --agent cursor --seq $seq --agent-session-id $sessionId 2>$null | Out-Null
+} catch {
+}
+
+Exit-Hook
diff --git a/src/integration/assets/devin/herdr-agent-state.ps1 b/src/integration/assets/devin/herdr-agent-state.ps1
new file mode 100644
index 00000000..7d14ad75
--- /dev/null
+++ b/src/integration/assets/devin/herdr-agent-state.ps1
@@ -0,0 +1,51 @@
+# 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=devin
+# HERDR_INTEGRATION_VERSION=2
+
+param([string]$Action = "")
+
+if ($Action -ne "session") { exit 0 }
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ $payload = $null
+}
+
+$sessionId = $null
+if ($null -ne $payload) {
+ if ($payload.session_id -is [string]) { $sessionId = $payload.session_id }
+ elseif ($payload.sessionId -is [string]) { $sessionId = $payload.sessionId }
+}
+
+$event = if ($null -ne $payload -and $payload.hook_event_name -is [string]) { $payload.hook_event_name } else { "" }
+$allowFallback = $event -ne "UserPromptSubmit" -and -not ($event -eq "SessionStart" -and $payload.source -eq "startup")
+if ([string]::IsNullOrWhiteSpace($sessionId) -and $allowFallback) {
+ $projectDir = if ([string]::IsNullOrWhiteSpace($env:DEVIN_PROJECT_DIR)) { (Get-Location).Path } else { $env:DEVIN_PROJECT_DIR }
+ try {
+ $sessions = & devin list --format json 2>$null | ConvertFrom-Json
+ $normalizedProject = [IO.Path]::GetFullPath($projectDir).TrimEnd('\')
+ foreach ($session in @($sessions)) {
+ if ($session.id -isnot [string] -or $session.working_directory -isnot [string]) { continue }
+ $workingDirectory = [IO.Path]::GetFullPath($session.working_directory).TrimEnd('\')
+ if ($workingDirectory -ieq $normalizedProject) {
+ $sessionId = $session.id
+ break
+ }
+ }
+ } catch {
+ }
+}
+if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
+
+$seq = [DateTime]::UtcNow.Ticks
+$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH }
+try {
+ & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:devin --agent devin --seq $seq --agent-session-id $sessionId 2>$null | Out-Null
+} catch {
+}
diff --git a/src/integration/assets/grok/herdr-agent-state.ps1 b/src/integration/assets/grok/herdr-agent-state.ps1
new file mode 100644
index 00000000..69c4f515
--- /dev/null
+++ b/src/integration/assets/grok/herdr-agent-state.ps1
@@ -0,0 +1,41 @@
+# 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
+
+param([string]$Action = "")
+
+if ($Action -ne "session") { exit 0 }
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ $payload = $null
+}
+
+$event = if ($null -ne $payload -and $payload.hook_event_name -is [string]) {
+ $payload.hook_event_name
+} elseif ($null -ne $payload -and $payload.hookEventName -is [string]) {
+ $payload.hookEventName
+} else {
+ $null
+}
+if ($null -ne $event -and $event -notin @("session_start", "SessionStart", "sessionStart")) { exit 0 }
+
+$sessionId = $env:GROK_SESSION_ID
+if ([string]::IsNullOrWhiteSpace($sessionId) -and $null -ne $payload) {
+ if ($payload.session_id -is [string]) { $sessionId = $payload.session_id }
+ elseif ($payload.sessionId -is [string]) { $sessionId = $payload.sessionId }
+}
+if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
+
+$seq = [DateTime]::UtcNow.Ticks
+$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH }
+try {
+ & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:grok --agent grok --seq $seq --agent-session-id $sessionId 2>$null | Out-Null
+} catch {
+}
diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py
index 9b1d51e2..e99fc29e 100644
--- a/src/integration/assets/hermes/__init__.py
+++ b/src/integration/assets/hermes/__init__.py
@@ -1,14 +1,12 @@
"""Hermes plugin installed by Herdr to report resumable session identity."""
# HERDR_INTEGRATION_ID=hermes
-# HERDR_INTEGRATION_VERSION=4
+# HERDR_INTEGRATION_VERSION=5
from __future__ import annotations
-import json
import os
-import random
-import socket
+import subprocess
import time
_SOURCE = "herdr:hermes"
@@ -16,43 +14,37 @@ _AGENT = "hermes"
_INTERACTIVE_PLATFORMS = {"cli", "tui", "desktop", "acp"}
-def _base_params() -> tuple[str, str] | None:
+def _pane_id() -> str | None:
if os.environ.get("HERDR_ENV") != "1":
return None
- pane_id = os.environ.get("HERDR_PANE_ID", "").strip()
- socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip()
- if not pane_id or not socket_path:
- return None
- return pane_id, socket_path
+ return os.environ.get("HERDR_PANE_ID", "").strip() or None
-def _send(method: str, params: dict) -> None:
- base = _base_params()
- if base is None:
+def _send_session(session_id: str, start_source: str) -> None:
+ pane_id = _pane_id()
+ if pane_id is None:
return
- pane_id, socket_path = base
- params = {
- "pane_id": pane_id,
- "source": _SOURCE,
- "agent": _AGENT,
- "seq": time.time_ns(),
- **params,
- }
- request = {
- "id": f"{_SOURCE}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}",
- "method": method,
- "params": params,
- }
+ command = [
+ os.environ.get("HERDR_BIN_PATH") or "herdr",
+ "pane",
+ "report-agent-session",
+ pane_id,
+ "--source",
+ _SOURCE,
+ "--agent",
+ _AGENT,
+ "--seq",
+ str(time.time_ns()),
+ "--agent-session-id",
+ session_id,
+ "--session-start-source",
+ start_source,
+ ]
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("utf-8"))
- try:
- client.recv(4096)
- except Exception:
- pass
- client.close()
+ kwargs = {"timeout": 1, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
+ if os.name == "nt":
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
+ subprocess.run(command, check=False, **kwargs)
except Exception:
pass
@@ -63,13 +55,7 @@ def _report_session(start_source: str, **kwargs) -> None:
session_id = kwargs.get("session_id")
if not isinstance(session_id, str) or not session_id:
return
- _send(
- "pane.report_agent_session",
- {
- "agent_session_id": session_id,
- "session_start_source": start_source,
- },
- )
+ _send_session(session_id, start_source)
def _session_started(**kwargs) -> None:
diff --git a/src/integration/assets/mastracode/herdr-agent-state.ps1 b/src/integration/assets/mastracode/herdr-agent-state.ps1
new file mode 100644
index 00000000..b0cca059
--- /dev/null
+++ b/src/integration/assets/mastracode/herdr-agent-state.ps1
@@ -0,0 +1,35 @@
+# 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=mastracode
+# HERDR_INTEGRATION_VERSION=2
+
+param([string]$Action = "")
+
+if ($Action -notin @("session", "working", "idle", "blocked")) { exit 0 }
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ $payload = $null
+}
+
+$sessionId = if ($null -ne $payload -and $payload.session_id -is [string]) { $payload.session_id } else { $null }
+$seq = [DateTime]::UtcNow.Ticks
+$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH }
+try {
+ if ($Action -eq "session") {
+ if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
+ & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:mastracode --agent mastracode --seq $seq --session-start-source startup --agent-session-id $sessionId 2>$null | Out-Null
+ } else {
+ $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:mastracode", "--agent", "mastracode", "--state", $Action, "--seq", "$seq")
+ if (-not [string]::IsNullOrWhiteSpace($sessionId)) {
+ $args += @("--agent-session-id", $sessionId)
+ }
+ & $herdr @args 2>$null | Out-Null
+ }
+} catch {
+}
diff --git a/src/integration/env.rs b/src/integration/env.rs
index d76ce66c..e86f6ee8 100644
--- a/src/integration/env.rs
+++ b/src/integration/env.rs
@@ -22,9 +22,13 @@ pub(crate) const GROK_CONFIG_DIR_ENV_VAR: &str = "GROK_CONFIG_DIR";
/// The grok CLI's own config-home override (documented alongside
/// `$GROK_HOME/config.toml` and `$GROK_HOME/auth.json`).
pub(crate) const GROK_HOME_ENV_VAR: &str = "GROK_HOME";
+pub(crate) const HERMES_HOME_ENV_VAR: &str = "HERMES_HOME";
pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) {
cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
+ if let Ok(executable) = std::env::current_exe() {
+ cmd.env("HERDR_BIN_PATH", executable);
+ }
}
pub(crate) fn pi_extension_dir() -> io::Result<PathBuf> {
@@ -122,6 +126,24 @@ pub(crate) fn kilo_dir() -> io::Result<PathBuf> {
}
pub(crate) fn hermes_dir() -> io::Result<PathBuf> {
+ if let Some(value) = std::env::var_os(HERMES_HOME_ENV_VAR).filter(|value| !value.is_empty()) {
+ return expand_tilde_path(PathBuf::from(value));
+ }
+
+ #[cfg(windows)]
+ {
+ let explicit_home = std::env::var_os("HOME").filter(|value| !value.is_empty());
+ let profile = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty());
+ if let Some(home) = explicit_home.filter(|home| profile.as_ref() != Some(home)) {
+ return Ok(PathBuf::from(home).join(".hermes"));
+ }
+ if let Some(local_app_data) =
+ std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
+ {
+ return Ok(PathBuf::from(local_app_data).join("hermes"));
+ }
+ }
+
Ok(home_dir()?.join(".hermes"))
}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 8be54c84..89d4d34d 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -114,8 +114,16 @@ const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [
"notification",
"sessionStart",
];
-const DEVIN_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const DEVIN_HOOK_ASSET: &str = include_str!("assets/devin/herdr-agent-state.sh");
+const DEVIN_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const DEVIN_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/devin/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/devin/herdr-agent-state.sh")
+};
const DEVIN_INTEGRATION_VERSION: u32 = 2;
const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [
("SessionStart", "session"),
@@ -167,7 +175,7 @@ const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml");
const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py");
-const HERMES_INTEGRATION_VERSION: u32 = 4;
+const HERMES_INTEGRATION_VERSION: u32 = 5;
const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -194,8 +202,16 @@ const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [
("Stop", "idle"),
("SessionEnd", "release"),
];
-const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh");
+const CURSOR_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const CURSOR_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/cursor/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/cursor/herdr-agent-state.sh")
+};
const CURSOR_INTEGRATION_VERSION: u32 = 1;
#[cfg(windows)]
const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1";
@@ -223,8 +239,16 @@ const ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC: u64 = 10;
/// invalidate the whole file.
const ANTIGRAVITY_CLI_HOOK_EVENTS: [(&str, &str); 1] = [("PreInvocation", "session")];
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
-const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
-const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh");
+const MASTRACODE_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "herdr-agent-state.sh"
+};
+const MASTRACODE_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/mastracode/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/mastracode/herdr-agent-state.sh")
+};
const MASTRACODE_INTEGRATION_VERSION: u32 = 2;
const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000;
const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] =
@@ -242,9 +266,17 @@ const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [
("AgentEnd", "idle"),
("Stop", "idle"),
];
-const GROK_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
+const GROK_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-state.ps1"
+} else {
+ "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_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/grok/herdr-agent-state.ps1")
+} else {
+ include_str!("assets/grok/herdr-agent-state.sh")
+};
const GROK_INTEGRATION_VERSION: u32 = 1;
pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:";
diff --git a/src/integration/registry.rs b/src/integration/registry.rs
index 7041eb1b..569494a2 100644
--- a/src/integration/registry.rs
+++ b/src/integration/registry.rs
@@ -76,6 +76,11 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati
| crate::api::schema::IntegrationTarget::Kimi
| crate::api::schema::IntegrationTarget::Qodercli
| crate::api::schema::IntegrationTarget::AntigravityCli
+ | crate::api::schema::IntegrationTarget::Devin
+ | crate::api::schema::IntegrationTarget::Hermes
+ | crate::api::schema::IntegrationTarget::Cursor
+ | crate::api::schema::IntegrationTarget::Mastracode
+ | crate::api::schema::IntegrationTarget::Grok
)
}
@@ -196,12 +201,9 @@ pub(crate) fn codex_executable_name() -> &'static str {
pub(crate) fn hermes_install_layout_available() -> bool {
#[cfg(windows)]
{
- let Some(local_app_data) =
- std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
- else {
+ let Ok(dir) = hermes_dir() else {
return false;
};
- let dir = PathBuf::from(local_app_data).join("hermes");
[
dir.join("hermes.exe"),
dir.join("bin").join("hermes.exe"),
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index b387b960..0cf2f933 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -7,7 +7,9 @@ use serde_json::{json, Map, Value};
use super::claude_settings::{
install as install_claude_settings, uninstall as uninstall_claude_settings,
};
-use super::command::{hook_command, shell_single_quote};
+use super::command::hook_command;
+#[cfg(not(windows))]
+use super::command::shell_single_quote;
use super::config_edit::{
build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook,
ensure_direct_command_hook, ensure_flat_command_hook, ensure_hermes_plugin_enabled,
@@ -948,8 +950,7 @@ pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
"cursor hooks file",
"cursor hooks file hooks",
)?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- let session_command = format!("bash {quoted_hook_path} session");
+ let session_command = hook_command(&hook_path, Some("session"));
remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?;
remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?;
remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?;
@@ -1029,8 +1030,7 @@ pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
"cursor hooks file",
"cursor hooks file hooks",
)? {
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- let session_command = format!("bash {quoted_hook_path} session");
+ let session_command = hook_command(&hook_path, Some("session"));
updated_hooks |= remove_simple_command_hook(hooks, "sessionStart", &session_command)?;
updated_hooks |=
remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?;
@@ -1057,6 +1057,26 @@ pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
})
}
+pub(crate) fn mastracode_hook_command(hook_path: &Path, action: &str) -> String {
+ #[cfg(windows)]
+ {
+ use base64::Engine;
+
+ let path = hook_path.display().to_string().replace('\'', "''");
+ let script = format!("& '{path}' {action}");
+ let encoded_script = script
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::<Vec<_>>();
+ let encoded = base64::engine::general_purpose::STANDARD.encode(encoded_script);
+ format!("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {encoded}")
+ }
+ #[cfg(not(windows))]
+ {
+ hook_command(hook_path, Some(action))
+ }
+}
+
pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
let mastracode_home = mastracode_dir()?;
let hook_dir = mastracode_home.join("hooks");
@@ -1082,15 +1102,16 @@ pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
))
})?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
for (event, action) in MASTRACODE_REMOVED_HOOK_EVENTS {
- remove_flat_command_hook(hooks, event, &format!("bash {quoted_hook_path} {action}"))?;
+ remove_flat_command_hook(hooks, event, &hook_command(&hook_path, Some(action)))?;
+ remove_flat_command_hook(hooks, event, &mastracode_hook_command(&hook_path, action))?;
}
for (event, action) in MASTRACODE_HOOK_EVENTS {
+ remove_flat_command_hook(hooks, event, &hook_command(&hook_path, Some(action)))?;
ensure_flat_command_hook(
hooks,
event,
- format!("bash {quoted_hook_path} {action}"),
+ mastracode_hook_command(&hook_path, action),
MASTRACODE_HOOK_TIMEOUT_MS,
)?;
}
@@ -1123,15 +1144,16 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> {
))
})?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
for (event, action) in MASTRACODE_HOOK_EVENTS
.into_iter()
.chain(MASTRACODE_REMOVED_HOOK_EVENTS)
{
+ updated_hooks |=
+ remove_flat_command_hook(hooks, event, &hook_command(&hook_path, Some(action)))?;
updated_hooks |= remove_flat_command_hook(
hooks,
event,
- &format!("bash {quoted_hook_path} {action}"),
+ &mastracode_hook_command(&hook_path, action),
)?;
}
@@ -1252,9 +1274,22 @@ pub(crate) fn uninstall_antigravity_cli() -> io::Result<AntigravityCliUninstallR
/// The complete Herdr-owned Grok hook config. Installation and status share
/// this value so any config drift is reported as outdated.
+fn grok_hook_command(hook_path: &Path) -> String {
+ #[cfg(windows)]
+ {
+ hook_command(hook_path, Some("session"))
+ }
+ #[cfg(not(windows))]
+ {
+ format!(
+ "sh {} session",
+ shell_single_quote(&hook_path.display().to_string())
+ )
+ }
+}
+
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");
+ let session_command = grok_hook_command(hook_path);
json!({
"hooks": {
"SessionStart": [
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 6827dae7..0081fcdb 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -179,10 +179,11 @@ fn home_dir_uses_userprofile_when_home_is_missing() {
fn windows_supports_portable_integrations() {
use crate::api::schema::IntegrationTarget;
- assert!(!integration_target_supported(IntegrationTarget::Hermes));
- assert!(!integration_target_supported(IntegrationTarget::Cursor));
- assert!(!integration_target_supported(IntegrationTarget::Devin));
- assert!(!integration_target_supported(IntegrationTarget::Mastracode));
+ assert!(integration_target_supported(IntegrationTarget::Hermes));
+ assert!(integration_target_supported(IntegrationTarget::Cursor));
+ assert!(integration_target_supported(IntegrationTarget::Devin));
+ assert!(integration_target_supported(IntegrationTarget::Mastracode));
+ assert!(integration_target_supported(IntegrationTarget::Grok));
assert!(integration_target_supported(IntegrationTarget::Pi));
assert!(integration_target_supported(IntegrationTarget::Omp));
@@ -198,7 +199,7 @@ fn windows_supports_portable_integrations() {
#[cfg(windows)]
#[test]
-fn windows_availability_excludes_unsupported_integrations() {
+fn windows_availability_includes_native_integrations() {
use crate::api::schema::IntegrationTarget;
let _lock = integration_env_lock();
@@ -216,15 +217,17 @@ fn windows_availability_excludes_unsupported_integrations() {
fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap();
fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap();
fs::write(bin.join("mastracode.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("grok.cmd"), "@echo off\r\n").unwrap();
assert!(integration_target_available(IntegrationTarget::Pi));
assert!(integration_target_available(IntegrationTarget::Omp));
assert!(integration_target_available(IntegrationTarget::Opencode));
assert!(integration_target_available(IntegrationTarget::Kilo));
- assert!(!integration_target_available(IntegrationTarget::Hermes));
- assert!(!integration_target_available(IntegrationTarget::Cursor));
- assert!(!integration_target_available(IntegrationTarget::Devin));
- assert!(!integration_target_available(IntegrationTarget::Mastracode));
+ assert!(integration_target_available(IntegrationTarget::Hermes));
+ assert!(integration_target_available(IntegrationTarget::Cursor));
+ assert!(integration_target_available(IntegrationTarget::Devin));
+ assert!(integration_target_available(IntegrationTarget::Mastracode));
+ assert!(integration_target_available(IntegrationTarget::Grok));
if let Some(path) = original_path {
std::env::set_var("PATH", path);
@@ -234,41 +237,6 @@ fn windows_availability_excludes_unsupported_integrations() {
let _ = fs::remove_dir_all(base);
}
-#[cfg(windows)]
-#[test]
-fn windows_install_rejects_unsupported_integration_before_config_lookup() {
- use crate::api::schema::IntegrationTarget;
-
- let _lock = integration_env_lock();
- let original_home = std::env::var_os("HOME");
- let original_userprofile = std::env::var_os("USERPROFILE");
- let original_homedrive = std::env::var_os("HOMEDRIVE");
- let original_homepath = std::env::var_os("HOMEPATH");
- std::env::remove_var("HOME");
- std::env::remove_var("USERPROFILE");
- std::env::remove_var("HOMEDRIVE");
- std::env::remove_var("HOMEPATH");
-
- let err = install_target(IntegrationTarget::Hermes).unwrap_err();
- assert_eq!(
- err.to_string(),
- "hermes integration is not supported on Windows"
- );
-
- if let Some(home) = original_home {
- std::env::set_var("HOME", home);
- }
- if let Some(userprofile) = original_userprofile {
- std::env::set_var("USERPROFILE", userprofile);
- }
- if let Some(homedrive) = original_homedrive {
- std::env::set_var("HOMEDRIVE", homedrive);
- }
- if let Some(homepath) = original_homepath {
- std::env::set_var("HOMEPATH", homepath);
- }
-}
-
#[test]
#[cfg(unix)]
fn command_available_requires_executable_file_on_path() {
@@ -349,23 +317,37 @@ fn qodercli_availability_checks_windows_aliases() {
#[test]
#[cfg(windows)]
-fn hermes_layout_can_exist_without_making_unsupported_target_available() {
+fn hermes_layout_makes_target_available() {
let _lock = integration_env_lock();
let base = unique_base();
let local_app_data = base.join("local-app-data");
let hermes_bin = local_app_data.join("hermes").join("bin");
fs::create_dir_all(&hermes_bin).unwrap();
fs::write(hermes_bin.join("hermes.exe"), "").unwrap();
+ let original_hermes_home = std::env::var_os(HERMES_HOME_ENV_VAR);
+ let original_home = std::env::var_os("HOME");
let original_local_app_data = std::env::var_os("LOCALAPPDATA");
let original_path = std::env::var_os("PATH");
+ std::env::remove_var(HERMES_HOME_ENV_VAR);
+ std::env::remove_var("HOME");
std::env::set_var("LOCALAPPDATA", &local_app_data);
std::env::set_var("PATH", "");
assert!(hermes_install_layout_available());
- assert!(!integration_target_available(
+ assert!(integration_target_available(
crate::api::schema::IntegrationTarget::Hermes
));
+ if let Some(hermes_home) = original_hermes_home {
+ std::env::set_var(HERMES_HOME_ENV_VAR, hermes_home);
+ } else {
+ std::env::remove_var(HERMES_HOME_ENV_VAR);
+ }
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
if let Some(local_app_data) = original_local_app_data {
std::env::set_var("LOCALAPPDATA", local_app_data);
} else {
@@ -3134,14 +3116,10 @@ fn install_cursor_writes_hook_and_updates_hooks_json() {
let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap();
assert_eq!(session_start.len(), 1);
- assert!(session_start[0]
- .get("command")
- .and_then(Value::as_str)
- .is_some_and(|command| {
- command.starts_with("bash ")
- && command.contains("herdr-agent-state.sh")
- && command.ends_with(" session")
- }));
+ assert_eq!(
+ session_start[0].get("command").and_then(Value::as_str),
+ Some(hook_command(&installed.hook_path, Some("session")).as_str())
+ );
assert!(hooks.get("beforeSubmitPrompt").is_none());
assert!(hooks.get("beforeShellExecution").is_none());
let stop = hooks.get("stop").and_then(Value::as_array).unwrap();
@@ -3308,9 +3286,10 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() {
let entries = hooks.get(event).and_then(Value::as_array).unwrap();
assert_eq!(entries.len(), 1, "{event} should have one Herdr hook");
let command = entries[0].get("command").and_then(Value::as_str).unwrap();
- assert!(command.starts_with("bash "));
- assert!(command.contains(MASTRACODE_HOOK_INSTALL_NAME));
- assert!(command.ends_with(action));
+ assert_eq!(
+ command,
+ mastracode_hook_command(&installed.hook_path, action)
+ );
assert_eq!(
entries[0].get("type").and_then(Value::as_str),
Some("command")
@@ -3369,9 +3348,14 @@ fn install_grok_writes_hook_and_config() {
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"));
+ #[cfg(windows)]
+ assert_eq!(command, hook_command(&installed.hook_path, Some("session")));
+ #[cfg(not(windows))]
+ {
+ 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);
@@ -3392,12 +3376,12 @@ fn install_mastracode_removes_v1_lifecycle_hooks() {
serde_json::to_string(&json!({
"SessionStart": [{
"type": "command",
- "command": format!("bash '{}' idle", hook_path.display()),
+ "command": hook_command(&hook_path, Some("idle")),
"timeout": MASTRACODE_HOOK_TIMEOUT_MS
}],
"SessionEnd": [{
"type": "command",
- "command": format!("bash '{}' release", hook_path.display()),
+ "command": hook_command(&hook_path, Some("release")),
"timeout": MASTRACODE_HOOK_TIMEOUT_MS
}]
}))
@@ -3415,10 +3399,10 @@ fn install_mastracode_removes_v1_lifecycle_hooks() {
assert!(!hooks.contains_key("SessionEnd"));
let session_start = hooks["SessionStart"].as_array().unwrap();
assert_eq!(session_start.len(), 1);
- assert!(session_start[0]["command"]
- .as_str()
- .unwrap()
- .ends_with("session"));
+ assert_eq!(
+ session_start[0]["command"].as_str().unwrap(),
+ mastracode_hook_command(&hook_path, "session")
+ );
if let Some(home) = original_home {
std::env::set_var("HOME", home);