diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli/pane.rs | 164 | ||||
| -rw-r--r-- | src/client/input.rs | 27 | ||||
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.ps1 | 29 | ||||
| -rw-r--r-- | src/integration/assets/codex/herdr-agent-state.ps1 | 27 | ||||
| -rw-r--r-- | src/integration/assets/copilot/herdr-agent-state.ps1 | 104 | ||||
| -rw-r--r-- | src/integration/assets/droid/herdr-agent-state.ps1 | 26 | ||||
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.ps1 | 22 | ||||
| -rw-r--r-- | src/integration/assets/qodercli/herdr-agent-state.ps1 | 35 | ||||
| -rw-r--r-- | src/integration/mod.rs | 526 | ||||
| -rw-r--r-- | src/worktree.rs | 23 |
10 files changed, 748 insertions, 235 deletions
diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 3fb83248..08d84cf9 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,9 +1,9 @@ use crate::api::schema::{ Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams, - PaneListParams, PaneNeighborParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, - PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, - PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, - ReadFormat, ReadSource, Request, + PaneListParams, PaneNeighborParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams, + PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, + PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -29,6 +29,8 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), "report-agent" => pane_report_agent(&args[1..]), + "report-agent-session" => pane_report_agent_session(&args[1..]), + "release-agent" => pane_release_agent(&args[1..]), "report-metadata" => pane_report_metadata(&args[1..]), "run" => pane_run(&args[1..]), "help" | "--help" | "-h" => { @@ -831,6 +833,158 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { })) } +fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + return Ok(2); + }; + + let pane_id = super::normalize_pane_id(raw_pane_id); + let mut source = None; + let mut agent = None; + let mut seq = None; + let mut agent_session_id = None; + let mut agent_session_path = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = Some(value.clone()); + index += 2; + } + "--agent" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent"); + return Ok(2); + }; + agent = Some(value.clone()); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(super::parse_u64_flag("--seq", value)?); + index += 2; + } + "--agent-session-id" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent-session-id"); + return Ok(2); + }; + agent_session_id = Some(value.clone()); + index += 2; + } + "--agent-session-path" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent-session-path"); + return Ok(2); + }; + agent_session_path = Some(value.clone()); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(source) = source.and_then(|source| { + let source = source.trim().to_string(); + (!source.is_empty()).then_some(source) + }) else { + eprintln!("missing required --source"); + return Ok(2); + }; + let Some(agent) = agent else { + eprintln!("missing required --agent"); + return Ok(2); + }; + + super::send_ok_request(Method::PaneReportAgentSession( + PaneReportAgentSessionParams { + pane_id, + source, + agent, + seq, + agent_session_id, + agent_session_path, + }, + )) +} + +fn pane_release_agent(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); + return Ok(2); + }; + + let pane_id = super::normalize_pane_id(raw_pane_id); + let mut source = None; + let mut agent = None; + let mut seq = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = Some(value.clone()); + index += 2; + } + "--agent" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent"); + return Ok(2); + }; + agent = Some(value.clone()); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(super::parse_u64_flag("--seq", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(source) = source.and_then(|source| { + let source = source.trim().to_string(); + (!source.is_empty()).then_some(source) + }) else { + eprintln!("missing required --source"); + return Ok(2); + }; + let Some(agent) = agent else { + eprintln!("missing required --agent"); + return Ok(2); + }; + + super::send_ok_request(Method::PaneReleaseAgent(PaneReleaseAgentParams { + pane_id, + source, + agent, + seq, + })) +} + fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); @@ -1038,6 +1192,8 @@ fn print_pane_help() { eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); eprintln!(" herdr pane run <pane_id> <command>"); } diff --git a/src/client/input.rs b/src/client/input.rs index bbc37a58..b89a57b1 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -180,6 +180,15 @@ fn windows_key_raw_bytes( match key.code { KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]), + KeyCode::Char(ch) + if !raw_sequence_pending + && matches!(ch, 'i' | 'I') + && key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + let mut buf = [0; 4]; + Some(ch.encode_utf8(&mut buf).as_bytes().to_vec()) + } KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => { let mut bytes = Vec::new(); if key.modifiers.contains(KeyModifiers::ALT) { @@ -357,6 +366,24 @@ mod windows_tests { } #[test] + fn windows_pasted_printable_ctrl_i_routes_as_literal_i() { + let event = Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL)); + assert_eq!( + windows_key_raw_bytes(&event, false).as_deref(), + Some(b"i".as_slice()) + ); + + let event = Event::Key(KeyEvent::new( + KeyCode::Char('I'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + assert_eq!( + windows_key_raw_bytes(&event, false).as_deref(), + Some(b"I".as_slice()) + ); + } + + #[test] fn windows_eot_control_char_normalizes_to_ctrl_d() { let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty())); let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer"); diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1 new file mode 100644 index 00000000..71417e9c --- /dev/null +++ b/src/integration/assets/claude/herdr-agent-state.ps1 @@ -0,0 +1,29 @@ +# 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=claude +# HERDR_INTEGRATION_VERSION=5 + +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 { + exit 0 +} + +if ($payload.hook_event_name -eq "SubagentStop") { exit 0 } + +$sessionId = $payload.session_id +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:claude --agent claude --seq $seq --agent-session-id $sessionId 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1 new file mode 100644 index 00000000..e56948f5 --- /dev/null +++ b/src/integration/assets/codex/herdr-agent-state.ps1 @@ -0,0 +1,27 @@ +# 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=codex +# HERDR_INTEGRATION_VERSION=5 + +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 { + exit 0 +} + +$sessionId = $payload.session_id +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:codex --agent codex --seq $seq --agent-session-id $sessionId 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/src/integration/assets/copilot/herdr-agent-state.ps1 new file mode 100644 index 00000000..61cec0c9 --- /dev/null +++ b/src/integration/assets/copilot/herdr-agent-state.ps1 @@ -0,0 +1,104 @@ +# 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=copilot +# HERDR_INTEGRATION_VERSION=1 + +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)) { @{} } else { $inputText | ConvertFrom-Json } +} catch { + $payload = @{} +} + +function First-Text { + param([object[]]$Names) + foreach ($name in $Names) { + $value = $payload.$name + if ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + return $null +} + +function Normalize-Event { + param([string]$Event) + if ([string]::IsNullOrWhiteSpace($Event)) { return "" } + return $Event.Replace("_", "").Replace("-", "").ToLowerInvariant() +} + +function Infer-Event { + $explicit = First-Text @("hook_event_name", "hookEventName") + if ($explicit) { return $explicit } + if (First-Text @("notification_type", "notificationType")) { return "notification" } + if ($payload.PSObject.Properties.Name -contains "toolResult" -or $payload.PSObject.Properties.Name -contains "tool_result") { return "postToolUse" } + if (($payload.PSObject.Properties.Name -contains "error") -and (First-Text @("tool_name", "toolName"))) { return "postToolUseFailure" } + if (First-Text @("tool_name", "toolName")) { return "preToolUse" } + if (First-Text @("stop_reason", "stopReason")) { return "agentStop" } + if (First-Text @("reason")) { return "sessionEnd" } + if ($payload.PSObject.Properties.Name -contains "prompt") { return "userPromptSubmitted" } + if ( + $payload.PSObject.Properties.Name -contains "initial_prompt" -or + $payload.PSObject.Properties.Name -contains "initialPrompt" -or + $payload.PSObject.Properties.Name -contains "source" -or + (First-Text @("session_id", "sessionId")) + ) { return "sessionStart" } + return "" +} + +function Has-Initial-Prompt { + $value = $payload.initial_prompt + if ($null -eq $value) { $value = $payload.initialPrompt } + return ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) +} + +function Report-State { + param([string]$State, [string]$SessionId) + $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:copilot", "--agent", "copilot", "--state", $State, "--seq", "$seq") + if (-not [string]::IsNullOrWhiteSpace($SessionId)) { + $args += @("--agent-session-id", $SessionId) + } + & herdr @args 2>$null | Out-Null +} + +function Release-Agent { + $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:copilot --agent copilot --seq $seq 2>$null | Out-Null +} + +try { + $eventKey = Normalize-Event (Infer-Event) + $sessionId = First-Text @("session_id", "sessionId") + $toolName = First-Text @("tool_name", "toolName") + $notificationType = First-Text @("notification_type", "notificationType") + $stopReason = First-Text @("stop_reason", "stopReason") + $reason = First-Text @("reason") + + if ($eventKey -eq "sessionstart") { + Report-State ($(if (Has-Initial-Prompt) { "working" } else { "idle" })) $sessionId + } elseif ($eventKey -in @("userpromptsubmit", "userpromptsubmitted")) { + Report-State "working" $sessionId + } elseif ($eventKey -eq "pretooluse") { + Report-State ($(if ($toolName -in @("ask_user", "exit_plan_mode")) { "blocked" } else { "working" })) $sessionId + } elseif ($eventKey -in @("posttooluse", "posttoolusefailure")) { + if ($toolName -ne "report_intent") { Report-State "working" $sessionId } + } elseif ($eventKey -eq "notification") { + if ($notificationType -in @("permission_prompt", "elicitation_dialog")) { + Report-State "blocked" $sessionId + } elseif ($notificationType -eq "agent_idle") { + Report-State "idle" $sessionId + } + } elseif ($eventKey -in @("stop", "agentstop", "sessionstop")) { + if ([string]::IsNullOrWhiteSpace($stopReason) -or $stopReason -eq "end_turn") { + Report-State "idle" $sessionId + } + } elseif ($eventKey -eq "sessionend") { + if ($reason -in @("user_exit", "abort")) { Release-Agent } + } +} catch { +} diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/src/integration/assets/droid/herdr-agent-state.ps1 new file mode 100644 index 00000000..ad035d72 --- /dev/null +++ b/src/integration/assets/droid/herdr-agent-state.ps1 @@ -0,0 +1,26 @@ +# 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=droid +# HERDR_INTEGRATION_VERSION=1 + +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 { + exit 0 +} + +if ($payload.hook_event_name -ne "SessionStart") { exit 0 } + +$sessionId = $payload.session_id +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:droid --agent droid --seq $seq --agent-session-id $sessionId 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 new file mode 100644 index 00000000..a230a627 --- /dev/null +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -0,0 +1,22 @@ +# 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=kimi +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -notin @("working", "idle", "blocked", "release")) { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } + +[Console]::In.ReadToEnd() | Out-Null +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + if ($Action -eq "release") { + & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --seq $seq 2>$null | Out-Null + } else { + & herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null + } +} catch { +} diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/src/integration/assets/qodercli/herdr-agent-state.ps1 new file mode 100644 index 00000000..f7102817 --- /dev/null +++ b/src/integration/assets/qodercli/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=qodercli +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -notin @("working", "idle", "blocked", "release")) { 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 +} + +if ($payload.hook_event_name -eq "SubagentStop") { exit 0 } +if ($payload.agent_id -and $Action -in @("idle", "release")) { exit 0 } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + if ($Action -eq "release") { + & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:qodercli --agent qodercli --seq $seq 2>$null | Out-Null + } else { + $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:qodercli", "--agent", "qodercli", "--state", $Action, "--seq", "$seq") + if (-not [string]::IsNullOrWhiteSpace($payload.session_id)) { + $args += @("--agent-session-id", $payload.session_id) + } + & herdr @args 2>$null | Out-Null + } +} catch { +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 2989ba90..abeb51e7 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -17,16 +17,40 @@ const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts"); const OMP_INTEGRATION_VERSION: u32 = 2; const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR"; -const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); +const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/claude/herdr-agent-state.ps1") +} else { + include_str!("assets/claude/herdr-agent-state.sh") +}; const CLAUDE_INTEGRATION_VERSION: u32 = 5; const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR"; -const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); +const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const CODEX_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/codex/herdr-agent-state.ps1") +} else { + include_str!("assets/codex/herdr-agent-state.sh") +}; const CODEX_INTEGRATION_VERSION: u32 = 5; const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; -const KIMI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const KIMI_HOOK_ASSET: &str = include_str!("assets/kimi/herdr-agent-state.sh"); +const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const KIMI_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/kimi/herdr-agent-state.ps1") +} else { + include_str!("assets/kimi/herdr-agent-state.sh") +}; const KIMI_INTEGRATION_VERSION: u32 = 1; const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; @@ -44,12 +68,28 @@ const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [ ("StopFailure", "idle"), ("SessionEnd", "release"), ]; -const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh"); +const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const COPILOT_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/copilot/herdr-agent-state.ps1") +} else { + include_str!("assets/copilot/herdr-agent-state.sh") +}; const COPILOT_INTEGRATION_VERSION: u32 = 1; const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; -const DROID_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const DROID_HOOK_ASSET: &str = include_str!("assets/droid/herdr-agent-state.sh"); +const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const DROID_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/droid/herdr-agent-state.ps1") +} else { + include_str!("assets/droid/herdr-agent-state.sh") +}; const DROID_INTEGRATION_VERSION: u32 = 1; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); @@ -60,8 +100,16 @@ 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 = 2; -const QODERCLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const QODERCLI_HOOK_ASSET: &str = include_str!("assets/qodercli/herdr-agent-state.sh"); +const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/qodercli/herdr-agent-state.ps1") +} else { + include_str!("assets/qodercli/herdr-agent-state.sh") +}; const QODERCLI_INTEGRATION_VERSION: u32 = 1; const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; @@ -1074,55 +1122,24 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> { "claude settings", "claude settings hooks", )?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - remove_command_hook( - hooks, - "PostToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook( - hooks, - "PostToolUseFailure", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook( - hooks, - "SubagentStop", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook( - hooks, - "PermissionRequest", - &format!("bash {quoted_hook_path} blocked"), - )?; - remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} idle"), - )?; - remove_command_hook( - hooks, - "UserPromptSubmit", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook( - hooks, - "PreToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; - remove_command_hook( - hooks, - "SessionEnd", - &format!("bash {quoted_hook_path} release"), - )?; + remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; ensure_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} session"), + hook_command(&hook_path, Some("session")), 10, Some("*"), )?; + remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1160,35 +1177,20 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> { "codex hooks file", "codex hooks file hooks", )?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - remove_command_hook( - hooks, - "PermissionRequest", - &format!("bash {quoted_hook_path} blocked"), - )?; - remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} idle"), - )?; - remove_command_hook( - hooks, - "UserPromptSubmit", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook( - hooks, - "PreToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; ensure_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} session"), + hook_command(&hook_path, Some("session")), 10, None, )?; + remove_legacy_bash_hook_file(&hook_path)?; fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; @@ -1236,6 +1238,7 @@ pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> { if new_config != existing_config { fs::write(&config_path, new_config)?; } + remove_legacy_bash_hook_file(&hook_path)?; Ok(KimiInstallPaths { hook_path, @@ -1277,10 +1280,20 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { "copilot settings", "copilot settings hooks", )?; - let command = format!( - "bash {}", - shell_single_quote(&hook_path.display().to_string()) - ); + let command = hook_command(&hook_path, None); + for event in [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "agentStop", + "SessionEnd", + "notification", + ] { + remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } ensure_direct_command_hook(hooks, "SessionStart", command.clone(), 10, None)?; ensure_direct_command_hook(hooks, "UserPromptSubmit", command.clone(), 10, None)?; ensure_direct_command_hook(hooks, "PreToolUse", command.clone(), 10, None)?; @@ -1296,6 +1309,7 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { 10, Some("permission_prompt|elicitation_dialog|agent_idle"), )?; + remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1339,15 +1353,15 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { "droid settings", "droid settings hooks", )?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; ensure_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path}"), + hook_command(&hook_path, None), 10, None, )?; + remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1364,8 +1378,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { "droid hooks file", "droid hooks file hooks", )? { - updated_legacy_hooks = - remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; } if updated_legacy_hooks { fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; @@ -1475,54 +1488,25 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> { "claude settings", "claude settings hooks", )? { - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - updated_settings |= remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} idle"), - )?; - updated_settings |= remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} session"), - )?; - updated_settings |= remove_command_hook( - hooks, - "UserPromptSubmit", - &format!("bash {quoted_hook_path} working"), - )?; - updated_settings |= remove_command_hook( - hooks, - "PreToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - updated_settings |= remove_command_hook( - hooks, - "PermissionRequest", - &format!("bash {quoted_hook_path} blocked"), - )?; - updated_settings |= remove_command_hook( - hooks, - "PostToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - updated_settings |= remove_command_hook( - hooks, - "PostToolUseFailure", - &format!("bash {quoted_hook_path} working"), - )?; - updated_settings |= remove_command_hook( - hooks, - "SubagentStop", - &format!("bash {quoted_hook_path} working"), - )?; updated_settings |= - remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; - updated_settings |= remove_command_hook( - hooks, - "SessionEnd", - &format!("bash {quoted_hook_path} release"), - )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + updated_settings |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + updated_settings |= + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + updated_settings |= + remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?; + updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + updated_settings |= + remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; } if updated_settings { @@ -1530,7 +1514,8 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> { } } - let removed_hook_file = remove_file_if_exists(&hook_path)?; + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; Ok(ClaudeUninstallResult { hook_path, @@ -1559,34 +1544,16 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> { "codex hooks file", "codex hooks file hooks", )? { - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - updated_hooks |= remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} idle"), - )?; - updated_hooks |= remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} session"), - )?; - updated_hooks |= remove_command_hook( - hooks, - "UserPromptSubmit", - &format!("bash {quoted_hook_path} working"), - )?; - updated_hooks |= remove_command_hook( - hooks, - "PreToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - updated_hooks |= remove_command_hook( - hooks, - "PermissionRequest", - &format!("bash {quoted_hook_path} blocked"), - )?; + updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + updated_hooks |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + updated_hooks |= + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + updated_hooks |= + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; updated_hooks |= - remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; } if updated_hooks { @@ -1594,7 +1561,8 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> { } } - let removed_hook_file = remove_file_if_exists(&hook_path)?; + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; Ok(CodexUninstallResult { hook_path, @@ -1620,7 +1588,8 @@ pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> { } } - let removed_hook_file = remove_file_if_exists(&hook_path)?; + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; Ok(KimiUninstallResult { hook_path, @@ -1651,19 +1620,20 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { "copilot settings", "copilot settings hooks", )? { - let command = format!( - "bash {}", - shell_single_quote(&hook_path.display().to_string()) - ); - updated_settings |= remove_direct_command_hook(hooks, "SessionStart", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "UserPromptSubmit", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "PreToolUse", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "PostToolUse", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "PostToolUseFailure", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "Stop", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "agentStop", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "SessionEnd", &command)?; - updated_settings |= remove_direct_command_hook(hooks, "notification", &command)?; + updated_settings |= + remove_direct_hook_commands(hooks, "SessionStart", &hook_path, None)?; + updated_settings |= + remove_direct_hook_commands(hooks, "UserPromptSubmit", &hook_path, None)?; + updated_settings |= remove_direct_hook_commands(hooks, "PreToolUse", &hook_path, None)?; + updated_settings |= + remove_direct_hook_commands(hooks, "PostToolUse", &hook_path, None)?; + updated_settings |= + remove_direct_hook_commands(hooks, "PostToolUseFailure", &hook_path, None)?; + updated_settings |= remove_direct_hook_commands(hooks, "Stop", &hook_path, None)?; + updated_settings |= remove_direct_hook_commands(hooks, "agentStop", &hook_path, None)?; + updated_settings |= remove_direct_hook_commands(hooks, "SessionEnd", &hook_path, None)?; + updated_settings |= + remove_direct_hook_commands(hooks, "notification", &hook_path, None)?; } if updated_settings { @@ -1671,7 +1641,8 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { } } - let removed_hook_file = remove_file_if_exists(&hook_path)?; + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; Ok(CopilotUninstallResult { hook_path, @@ -1688,8 +1659,6 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { let settings_path = droid_dir.join("settings.json"); let mut updated_hooks = false; let mut updated_settings = false; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - if hooks_path.is_file() { let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) .map_err(|err| { @@ -1702,8 +1671,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { "droid hooks file", "droid hooks file hooks", )? { - updated_hooks |= - remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; } if updated_hooks { @@ -1725,8 +1693,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { "droid settings", "droid settings hooks", )? { - updated_settings = - remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; } if updated_settings { @@ -1734,7 +1701,8 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { } } - let removed_hook_file = remove_file_if_exists(&hook_path)?; + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; Ok(DroidUninstallResult { hook_path, @@ -1823,53 +1791,58 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { "qodercli settings", "qodercli settings hooks", )?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - // SubagentStop is intentionally *not* mapped to working: the hook script // returns early on it (mirroring assets/claude/herdr-agent-state.sh) so // that recap/away-summary frames cannot revive an idle pane. + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; ensure_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} idle"), + hook_command(&hook_path, Some("idle")), 10, Some("*"), )?; ensure_command_hook( hooks, "UserPromptSubmit", - format!("bash {quoted_hook_path} working"), + hook_command(&hook_path, Some("working")), 10, Some("*"), )?; ensure_command_hook( hooks, "PreToolUse", - format!("bash {quoted_hook_path} working"), + hook_command(&hook_path, Some("working")), 10, Some("*"), )?; ensure_command_hook( hooks, "PermissionRequest", - format!("bash {quoted_hook_path} blocked"), + hook_command(&hook_path, Some("blocked")), 10, Some("*"), )?; ensure_command_hook( hooks, "Stop", - format!("bash {quoted_hook_path} idle"), + hook_command(&hook_path, Some("idle")), 10, Some("*"), )?; ensure_command_hook( hooks, "SessionEnd", - format!("bash {quoted_hook_path} release"), + hook_command(&hook_path, Some("release")), 10, Some("*"), )?; + remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1901,34 +1874,17 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { "qodercli settings", "qodercli settings hooks", )? { - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - updated_settings |= remove_command_hook( - hooks, - "SessionStart", - &format!("bash {quoted_hook_path} idle"), - )?; - updated_settings |= remove_command_hook( - hooks, - "UserPromptSubmit", - &format!("bash {quoted_hook_path} working"), - )?; - updated_settings |= remove_command_hook( - hooks, - "PreToolUse", - &format!("bash {quoted_hook_path} working"), - )?; - updated_settings |= remove_command_hook( - hooks, - "PermissionRequest", - &format!("bash {quoted_hook_path} blocked"), - )?; updated_settings |= - remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; - updated_settings |= remove_command_hook( - hooks, - "SessionEnd", - &format!("bash {quoted_hook_path} release"), - )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + updated_settings |= + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + updated_settings |= + remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; } if updated_settings { @@ -1936,7 +1892,8 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { } } - let removed_hook_file = remove_file_if_exists(&hook_path)?; + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; Ok(QodercliUninstallResult { hook_path, @@ -2156,6 +2113,53 @@ fn remove_direct_command_hook( Ok(removed) } +fn remove_hook_commands( + hooks: &mut Map<String, Value>, + event: &str, + hook_path: &Path, + action: Option<&str>, +) -> io::Result<bool> { + let mut removed = false; + for command in hook_command_variants(hook_path, action) { + removed |= remove_command_hook(hooks, event, &command)?; + } + Ok(removed) +} + +fn remove_direct_hook_commands( + hooks: &mut Map<String, Value>, + event: &str, + hook_path: &Path, + action: Option<&str>, +) -> io::Result<bool> { + let mut removed = false; + for command in hook_command_variants(hook_path, action) { + removed |= remove_direct_command_hook(hooks, event, &command)?; + } + Ok(removed) +} + +fn hook_command_variants(hook_path: &Path, action: Option<&str>) -> Vec<String> { + let mut commands = vec![hook_command(hook_path, action)]; + push_unique_command(&mut commands, legacy_bash_hook_command(hook_path, action)); + + #[cfg(windows)] + { + push_unique_command( + &mut commands, + legacy_bash_hook_command(&legacy_bash_hook_path(hook_path), action), + ); + } + + commands +} + +fn push_unique_command(commands: &mut Vec<String>, command: String) { + if !commands.iter().any(|existing| existing == &command) { + commands.push(command); + } +} + fn is_matching_command_hook(hook: &Value, command: &str) -> bool { hook.get("type").and_then(Value::as_str) == Some("command") && hook.get("command").and_then(Value::as_str) == Some(command) @@ -2169,6 +2173,33 @@ fn remove_file_if_exists(path: &Path) -> io::Result<bool> { } } +#[cfg(windows)] +fn legacy_bash_hook_path(hook_path: &Path) -> PathBuf { + hook_path.with_file_name("herdr-agent-state.sh") +} + +#[cfg(windows)] +fn remove_legacy_bash_hook_file(hook_path: &Path) -> io::Result<bool> { + let legacy_path = legacy_bash_hook_path(hook_path); + let content = match fs::read_to_string(&legacy_path) { + Ok(content) => content, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + + if content.contains("HERDR_INTEGRATION_ID=") { + fs::remove_file(legacy_path)?; + return Ok(true); + } + + Ok(false) +} + +#[cfg(not(windows))] +fn remove_legacy_bash_hook_file(_hook_path: &Path) -> io::Result<bool> { + Ok(false) +} + fn remove_dir_all_if_exists(path: &Path) -> io::Result<bool> { match fs::remove_dir_all(path) { Ok(()) => Ok(true), @@ -2540,10 +2571,7 @@ fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String { } fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String { - let command = format!( - "bash {} {action}", - shell_single_quote(&hook_path.display().to_string()) - ); + let command = hook_command(hook_path, Some(action)); format!( "[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n", toml_basic_string(event), @@ -2650,6 +2678,49 @@ fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +fn hook_command(hook_path: &Path, action: Option<&str>) -> String { + let path = hook_path.display().to_string(); + #[cfg(windows)] + { + let mut command = format!( + "powershell -NoProfile -ExecutionPolicy Bypass -File {}", + windows_command_quote(&path) + ); + if let Some(action) = action { + command.push(' '); + command.push_str(action); + } + command + } + + #[cfg(not(windows))] + { + let mut command = format!("bash {}", shell_single_quote(&path)); + if let Some(action) = action { + command.push(' '); + command.push_str(action); + } + command + } +} + +fn legacy_bash_hook_command(hook_path: &Path, action: Option<&str>) -> String { + let mut command = format!( + "bash {}", + shell_single_quote(&hook_path.display().to_string()) + ); + if let Some(action) = action { + command.push(' '); + command.push_str(action); + } + command +} + +#[cfg(windows)] +fn windows_command_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\\\"")) +} + fn make_executable(_path: &Path) -> io::Result<()> { #[cfg(unix)] { @@ -2795,10 +2866,7 @@ mod tests { } fn kimi_hook_command(hook_path: &Path, action: &str) -> String { - format!( - "bash {} {action}", - shell_single_quote(&hook_path.display().to_string()) - ) + hook_command(hook_path, Some(action)) } fn kimi_config_hooks(config: &str) -> Vec<toml::Value> { diff --git a/src/worktree.rs b/src/worktree.rs index 29eb7647..3f8a017f 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -68,13 +68,23 @@ pub(crate) fn expand_tilde_path(path: &str) -> PathBuf { }); if let Some(rest) = tilde_rest { return home_dir_from_env(cfg!(windows), |key| std::env::var_os(key)) - .map(|home| home.join(rest)) + .map(|home| join_tilde_rest(home, rest)) .unwrap_or_else(|_| PathBuf::from(path)); } PathBuf::from(path) } +fn join_tilde_rest(home: PathBuf, rest: &str) -> PathBuf { + if cfg!(windows) { + rest.split(['/', '\\']) + .filter(|component| !component.is_empty()) + .fold(home, |path, component| path.join(component)) + } else { + home.join(rest) + } +} + fn home_dir_from_env( is_windows: bool, env: impl Fn(&str) -> Option<OsString>, @@ -519,11 +529,20 @@ prunable stale #[cfg(windows)] #[test] - fn windows_tilde_expansion_accepts_windows_separator() { + fn windows_tilde_expansion_normalizes_separators() { let old_home = std::env::var_os("HOME"); let old_userprofile = std::env::var_os("USERPROFILE"); std::env::set_var("HOME", "~"); std::env::set_var("USERPROFILE", r"C:\Users\herdr"); + let default_path = expand_tilde_path("~/.herdr/worktrees"); + assert_eq!( + default_path, + PathBuf::from(r"C:\Users\herdr\.herdr\worktrees") + ); + assert_eq!( + default_path.display().to_string(), + r"C:\Users\herdr\.herdr\worktrees" + ); assert_eq!( expand_tilde_path(r"~\.herdr\worktrees"), PathBuf::from(r"C:\Users\herdr\.herdr\worktrees") |
