diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-07 23:10:28 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-07 23:10:28 +0300 |
| commit | 50ddc06f00ec69ede35fd5488a260dec2b74e98a (patch) | |
| tree | 2a01f30600ccfea600040476703cf7de50087156 /src/integration | |
| parent | 7d77e927eba265ea864485bf99411abcf62d977d (diff) | |
feat: support all agent integrations on windows (#2496)
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/cursor/herdr-agent-state.ps1 | 48 | ||||
| -rw-r--r-- | src/integration/assets/devin/herdr-agent-state.ps1 | 51 | ||||
| -rw-r--r-- | src/integration/assets/grok/herdr-agent-state.ps1 | 41 | ||||
| -rw-r--r-- | src/integration/assets/hermes/__init__.py | 70 | ||||
| -rw-r--r-- | src/integration/assets/mastracode/herdr-agent-state.ps1 | 35 | ||||
| -rw-r--r-- | src/integration/env.rs | 22 | ||||
| -rw-r--r-- | src/integration/mod.rs | 50 | ||||
| -rw-r--r-- | src/integration/registry.rs | 10 | ||||
| -rw-r--r-- | src/integration/targets.rs | 59 | ||||
| -rw-r--r-- | src/integration/tests.rs | 116 |
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); |
