diff options
| author | Lane <150401101+LaneBirmingham@users.noreply.github.com> | 2026-06-02 08:32:05 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-02 01:32:05 +0300 |
| commit | f7d315d494b6a3d583574012526d15c1ddc82bfe (patch) | |
| tree | 327585c6cb27d478bbb1e4b0da782d9bbfd00901 /src | |
| parent | 99e097c7d3cf76ae2d53bac5e7e2b3fd88b83591 (diff) | |
feat: add github copilot cli integration (#386)
* feat: add github copilot cli integration
refs #232
* docs: restore 0.6.5 changelog and document copilot hook flow
* test: cover copilot camelcase hook payloads
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 52 | ||||
| -rw-r--r-- | src/api/schema.rs | 1 | ||||
| -rw-r--r-- | src/cli/integration.rs | 11 | ||||
| -rw-r--r-- | src/integration/assets/copilot/herdr-agent-state.sh | 183 | ||||
| -rw-r--r-- | src/integration/mod.rs | 449 |
5 files changed, 690 insertions, 6 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 8f450088..269b74de 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -106,6 +106,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:codex", "codex", AgentSessionRefKind::Id) => { vec!["codex".into(), "resume".into(), session_ref.value.clone()] } + ("herdr:copilot", "copilot", AgentSessionRefKind::Id) => { + vec!["copilot".into(), format!("--resume={}", session_ref.value)] + } ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { vec!["pi".into(), "--session".into(), session_ref.value.clone()] } @@ -145,6 +148,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool { (source, agent), ("herdr:claude", "claude") | ("herdr:codex", "codex") + | ("herdr:copilot", "copilot") | ("herdr:pi", "pi") | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") @@ -190,6 +194,16 @@ mod tests { ); assert_eq!( plan( + "herdr:copilot", + "copilot", + &AgentSessionRef::id("copilot-session").unwrap() + ) + .unwrap() + .argv, + vec!["copilot", "--resume=copilot-session"] + ); + assert_eq!( + plan( "herdr:pi", "pi", &AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap() @@ -261,13 +275,34 @@ mod tests { Some("/tmp/claude-session".into()) ) .is_none()); + + let session_ref = + session_ref_from_report("herdr:copilot", "copilot", Some("copilot-id".into()), None) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "copilot-id"); + assert!(session_ref_from_report( + "herdr:copilot", + "copilot", + None, + Some("/tmp/copilot-session".into()) + ) + .is_none()); } #[test] fn ids_are_data_not_shell_text() { let id = "abc; rm -rf /"; - let plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap(); - assert_eq!(plan.argv, vec!["codex", "resume", id]); + let codex_plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap(); + assert_eq!(codex_plan.argv, vec!["codex", "resume", id]); + + let copilot_plan = plan( + "herdr:copilot", + "copilot", + &AgentSessionRef::id(id).unwrap(), + ) + .unwrap(); + assert_eq!(copilot_plan.argv, vec!["copilot", "--resume=abc; rm -rf /"]); } #[test] @@ -284,6 +319,12 @@ mod tests { &AgentSessionRef::path("/tmp/opencode-session").unwrap() ) .is_none()); + assert!(plan( + "herdr:copilot", + "copilot", + &AgentSessionRef::path("/tmp/copilot-session").unwrap() + ) + .is_none()); assert!(session_ref_from_snapshot( "herdr:hermes", "hermes", @@ -298,5 +339,12 @@ mod tests { "opencode-session" ) .is_some()); + assert!(session_ref_from_snapshot( + "herdr:copilot", + "copilot", + AgentSessionRefKind::Id, + "copilot-session" + ) + .is_some()); } } diff --git a/src/api/schema.rs b/src/api/schema.rs index 5d933af9..794f6db4 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -508,6 +508,7 @@ pub enum IntegrationTarget { Omp, Claude, Codex, + Copilot, Opencode, Hermes, Qodercli, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index d347628c..d8eb027c 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result<Option<IntegrationTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes|qodercli>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|opencode|hermes|qodercli>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|opencode|hermes|qodercli>" ); return Ok(None); } @@ -119,12 +119,15 @@ fn parse_integration_target( "omp" => IntegrationTarget::Omp, "claude" => IntegrationTarget::Claude, "codex" => IntegrationTarget::Codex, + "copilot" => IntegrationTarget::Copilot, "opencode" => IntegrationTarget::Opencode, "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, _ => { eprintln!("unknown integration target: {target}"); - eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes, qodercli"); + eprintln!( + "currently supported: pi, omp, claude, codex, copilot, opencode, hermes, qodercli" + ); return Ok(None); } }; @@ -138,6 +141,7 @@ fn print_integration_help() { eprintln!(" herdr integration install omp"); eprintln!(" herdr integration install claude"); eprintln!(" herdr integration install codex"); + eprintln!(" herdr integration install copilot"); eprintln!(" herdr integration install opencode"); eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); @@ -145,6 +149,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall omp"); eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); + eprintln!(" herdr integration uninstall copilot"); eprintln!(" herdr integration uninstall opencode"); eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh new file mode 100644 index 00000000..766b562a --- /dev/null +++ b/src/integration/assets/copilot/herdr-agent-state.sh @@ -0,0 +1,183 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# High-level state machine: +# - SessionStart reports working when Copilot includes an initial prompt, else idle. +# - UserPromptSubmit reports working for the active turn. +# - PreToolUse reports blocked for ask_user and exit_plan_mode, else working. +# - notification permission_prompt / elicitation_dialog reports blocked. +# - PostToolUse / PostToolUseFailure clears handled blockers back to working. +# - agentStop / Stop with end_turn reports idle. +# - SessionEnd only releases ownership for real exits such as user_exit / abort. +# Official Copilot hooks reference: +# https://docs.github.com/en/copilot/reference/hooks-reference +# HERDR_INTEGRATION_ID=copilot +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-copilot-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +import json +import os +import random +import socket +import time + +source = "herdr:copilot" +agent = "copilot" +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") + +if not pane_id or not socket_path: + raise SystemExit(0) + +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + parsed = json.loads(content) + if isinstance(parsed, dict): + hook_input = parsed + except Exception: + hook_input = {} + +def first_text(*keys): + for key in keys: + value = hook_input.get(key) + if isinstance(value, str) and value: + return value + return None + +def normalize_event(event): + return event.replace("_", "").replace("-", "").lower() + +def infer_event(): + explicit = first_text("hook_event_name", "hookEventName") + if explicit: + return explicit + if first_text("notification_type", "notificationType"): + return "notification" + if "toolResult" in hook_input or "tool_result" in hook_input: + return "postToolUse" + if "error" in hook_input 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 "prompt" in hook_input: + return "userPromptSubmitted" + if ( + "initial_prompt" in hook_input + or "initialPrompt" in hook_input + or "source" in hook_input + or first_text("session_id", "sessionId") + ): + return "sessionStart" + return "" + +def has_initial_prompt(): + value = hook_input.get("initial_prompt", hook_input.get("initialPrompt")) + return isinstance(value, str) and bool(value.strip()) + +def is_user_blocking_tool(name): + return name in ("ask_user", "exit_plan_mode") + +def is_ignored_post_tool(name): + # report_intent can arrive after ask_user has already blocked the turn but + # before the user answers. Suppress it so it cannot clear that blocker. + return name in ("report_intent",) + +def send(method, params): + request = { + "id": f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}", + "method": method, + "params": { + "pane_id": pane_id, + "source": source, + "agent": agent, + "seq": time.time_ns(), + **params, + }, + } + 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() + except Exception: + pass + +def report(state, session_id): + params = {"state": state} + if session_id: + params["agent_session_id"] = session_id + send("pane.report_agent", params) + +def release(): + send("pane.release_agent", {}) + +try: + event = infer_event() + event_key = normalize_event(event) + session_id = first_text("session_id", "sessionId") + tool_name = first_text("tool_name", "toolName") + notification_type = first_text("notification_type", "notificationType") + stop_reason = first_text("stop_reason", "stopReason") + reason = first_text("reason") + + if event_key == "sessionstart": + report("working" if has_initial_prompt() else "idle", session_id) + elif event_key in ("userpromptsubmit", "userpromptsubmitted"): + report("working", session_id) + elif event_key == "pretooluse": + report("blocked" if is_user_blocking_tool(tool_name) else "working", session_id) + elif event_key in ("posttooluse", "posttoolusefailure"): + if is_ignored_post_tool(tool_name): + pass + else: + report("working", session_id) + elif event_key == "notification": + if notification_type in ("permission_prompt", "elicitation_dialog"): + report("blocked", session_id) + elif notification_type == "agent_idle": + report("idle", session_id) + else: + pass + elif event_key in ("stop", "agentstop", "sessionstop"): + if not stop_reason or stop_reason == "end_turn": + report("idle", session_id) + else: + pass + elif event_key == "sessionend": + # Copilot can emit reason=complete at normal turn boundaries. Keep + # ownership until a real session exit so Herdr can still track later turns. + if reason in ("user_exit", "abort"): + release() + else: + pass + else: + pass +except Exception: + pass +PY diff --git a/src/integration/mod.rs b/src/integration/mod.rs index ccd3e045..82afb195 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -25,6 +25,10 @@ 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_INTEGRATION_VERSION: u32 = 4; const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; +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_INTEGRATION_VERSION: u32 = 1; +const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); const OPENCODE_INTEGRATION_VERSION: u32 = 3; @@ -54,6 +58,12 @@ pub(crate) struct CodexInstallPaths { } #[derive(Debug)] +pub(crate) struct CopilotInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, } @@ -156,6 +166,14 @@ pub(crate) struct CodexUninstallResult { } #[derive(Debug)] +pub(crate) struct CopilotUninstallResult { + pub hook_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_settings: bool, +} + +#[derive(Debug)] pub(crate) struct OpenCodeUninstallResult { pub plugin_path: PathBuf, pub removed_plugin: bool, @@ -227,6 +245,19 @@ pub(crate) fn install_target( ), ] } + crate::api::schema::IntegrationTarget::Copilot => { + let installed = install_copilot()?; + vec![ + format!( + "installed copilot integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured copilot settings at {}", + installed.settings_path.display() + ), + ] + } crate::api::schema::IntegrationTarget::Opencode => { let installed = install_opencode()?; vec![format!( @@ -356,6 +387,33 @@ pub(crate) fn uninstall_target( )); messages } + crate::api::schema::IntegrationTarget::Copilot => { + let result = uninstall_copilot()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed copilot hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no copilot hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr copilot hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr copilot hook entries found in {}", + result.settings_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Opencode => { let result = uninstall_opencode()?; if result.removed_plugin { @@ -438,6 +496,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Omp => "omp", crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", + crate::api::schema::IntegrationTarget::Copilot => "copilot", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", @@ -450,6 +509,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> crate::api::schema::IntegrationTarget::Omp => "omp", crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", + crate::api::schema::IntegrationTarget::Copilot => "copilot", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", @@ -526,7 +586,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 7] { +); 8] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -549,6 +609,11 @@ fn integration_specs() -> [( CODEX_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Copilot, + copilot_dir().map(|dir| dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME)), + COPILOT_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Opencode, opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)), OPENCODE_INTEGRATION_VERSION, @@ -884,6 +949,68 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> { }) } +pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { + let dir = copilot_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "copilot config directory not found at {}. install github copilot cli first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(COPILOT_HOOK_INSTALL_NAME); + fs::write(&hook_path, COPILOT_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "copilot settings", + "copilot settings hooks", + )?; + let command = format!( + "bash {}", + shell_single_quote(&hook_path.display().to_string()) + ); + 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)?; + ensure_direct_command_hook(hooks, "PostToolUse", command.clone(), 10, None)?; + ensure_direct_command_hook(hooks, "PostToolUseFailure", command.clone(), 10, None)?; + ensure_direct_command_hook(hooks, "Stop", command.clone(), 10, None)?; + ensure_direct_command_hook(hooks, "agentStop", command.clone(), 10, None)?; + ensure_direct_command_hook(hooks, "SessionEnd", command.clone(), 10, None)?; + ensure_direct_command_hook( + hooks, + "notification", + command, + 10, + Some("permission_prompt|elicitation_dialog|agent_idle"), + )?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(CopilotInstallPaths { + hook_path, + settings_path, + }) +} + pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> { let dir = opencode_dir()?; if !dir.is_dir() { @@ -1099,6 +1226,57 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> { }) } +pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { + let copilot_dir = copilot_dir()?; + let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME); + let settings_path = copilot_dir.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "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)?; + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(CopilotUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { let plugin_path = opencode_dir()? .join("plugins") @@ -1393,6 +1571,55 @@ fn ensure_command_hook( Ok(()) } +// Claude and Codex use nested hook groups: +// { "matcher": "...", "hooks": [{ "type": "command", ... }] } +// Copilot and Qoder CLI use the flatter settings shape: +// { "type": "command", "matcher": "...", "command": "...", ... } +// Keep the helpers separate so install/uninstall preserves unrelated hooks in +// each agent's native format instead of normalizing user configuration. +fn ensure_direct_command_hook( + hooks: &mut Map<String, Value>, + event: &str, + command: String, + timeout_sec: u64, + matcher: Option<&str>, +) -> io::Result<()> { + let entries = hooks + .entry(event.to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + + if let Some(entry) = entries.iter_mut().find(|entry| { + entry.get("type").and_then(Value::as_str) == Some("command") + && entry.get("command").and_then(Value::as_str) == Some(command.as_str()) + }) { + let Some(entry_object) = entry.as_object_mut() else { + return Ok(()); + }; + entry_object.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into())); + match matcher { + Some(matcher) => { + entry_object.insert("matcher".to_string(), Value::String(matcher.to_string())); + } + None => { + entry_object.remove("matcher"); + } + } + return Ok(()); + } + + let mut entry = Map::new(); + entry.insert("type".to_string(), Value::String("command".to_string())); + if let Some(matcher) = matcher { + entry.insert("matcher".to_string(), Value::String(matcher.to_string())); + } + entry.insert("command".to_string(), Value::String(command)); + entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into())); + entries.push(Value::Object(entry)); + Ok(()) +} + fn remove_command_hook( hooks: &mut Map<String, Value>, event: &str, @@ -1435,6 +1662,31 @@ fn remove_command_hook( Ok(removed) } +fn remove_direct_command_hook( + hooks: &mut Map<String, Value>, + event: &str, + command: &str, +) -> io::Result<bool> { + let Some(entries_value) = hooks.get_mut(event) else { + return Ok(false); + }; + + let entries = entries_value + .as_array_mut() + .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + + let before = entries.len(); + entries.retain(|entry| { + !(entry.get("type").and_then(Value::as_str) == Some("command") + && entry.get("command").and_then(Value::as_str) == Some(command)) + }); + let removed = entries.len() != before; + if entries.is_empty() { + hooks.remove(event); + } + Ok(removed) +} + 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) @@ -1708,6 +1960,10 @@ fn codex_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"]) } +fn copilot_dir() -> io::Result<PathBuf> { + config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"]) +} + fn config_dir_from_env_or_home( env_var: &str, home_relative_segments: &[&str], @@ -1781,6 +2037,7 @@ mod tests { std::env::remove_var(PI_CODING_AGENT_DIR_ENV_VAR); std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR); std::env::remove_var(CODEX_HOME_ENV_VAR); + std::env::remove_var(COPILOT_HOME_ENV_VAR); std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); } @@ -2680,6 +2937,192 @@ mod tests { } #[test] + fn install_copilot_writes_hook_and_updates_settings() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let copilot_dir = home.join(".copilot"); + fs::create_dir_all(&copilot_dir).unwrap(); + fs::write( + copilot_dir.join("settings.json"), + r#"{"theme":"dark","hooks":{"PreToolUse":[{"type":"command","command":"echo keep","timeoutSec":10}]}}"#, + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_copilot().unwrap(); + let hook_content = fs::read_to_string(&installed.hook_path).unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap(); + + assert_eq!( + installed.hook_path, + copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.settings_path, copilot_dir.join("settings.json")); + assert_eq!(hook_content, COPILOT_HOOK_ASSET); + assert_eq!(settings["theme"], "dark"); + assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 2); + assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep"); + assert!(settings["hooks"]["PreToolUse"][1]["command"] + .as_str() + .unwrap() + .contains(COPILOT_HOOK_INSTALL_NAME)); + assert!(settings["hooks"]["PostToolUse"][0].get("matcher").is_none()); + assert!(settings["hooks"]["PostToolUseFailure"][0] + .get("matcher") + .is_none()); + assert_eq!( + settings["hooks"]["notification"][0]["matcher"], + "permission_prompt|elicitation_dialog|agent_idle" + ); + assert!(settings["hooks"]["Stop"][0]["command"] + .as_str() + .unwrap() + .contains(COPILOT_HOOK_INSTALL_NAME)); + assert!(settings["hooks"]["agentStop"][0]["command"] + .as_str() + .unwrap() + .contains(COPILOT_HOOK_INSTALL_NAME)); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn copilot_v1_integration_status_is_current() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let copilot_hooks_dir = home.join(".copilot").join("hooks"); + fs::create_dir_all(&copilot_hooks_dir).unwrap(); + let hook_path = copilot_hooks_dir.join(COPILOT_HOOK_INSTALL_NAME); + fs::write( + &hook_path, + "#!/bin/sh\n# HERDR_INTEGRATION_ID=copilot\n# HERDR_INTEGRATION_VERSION=1\n", + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let statuses = installed_integration_statuses(); + let copilot = statuses + .iter() + .find(|status| status.target == crate::api::schema::IntegrationTarget::Copilot) + .unwrap(); + + assert_eq!(copilot.path, hook_path); + assert_eq!(copilot.installed_version, Some(1)); + assert_eq!(copilot.expected_version, 1); + assert_eq!(copilot.state, IntegrationStatusKind::Current); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_copilot_uses_copilot_home_env_and_is_idempotent() { + let _lock = integration_env_lock(); + let base = unique_base(); + let copilot_dir = base.join("custom-copilot"); + fs::create_dir_all(&copilot_dir).unwrap(); + std::env::set_var(COPILOT_HOME_ENV_VAR, &copilot_dir); + + let installed = install_copilot().unwrap(); + install_copilot().unwrap(); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(copilot_dir.join("settings.json")).unwrap()) + .unwrap(); + + assert_eq!( + installed.hook_path, + copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME) + ); + assert_eq!( + settings["hooks"]["SessionStart"].as_array().unwrap().len(), + 1 + ); + assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + assert_eq!( + settings["hooks"]["PostToolUse"].as_array().unwrap().len(), + 1 + ); + assert_eq!( + settings["hooks"]["PostToolUseFailure"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + settings["hooks"]["notification"].as_array().unwrap().len(), + 1 + ); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_copilot_removes_herdr_hooks_and_preserves_others() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let copilot_dir = home.join(".copilot"); + let hooks_dir = copilot_dir.join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join(COPILOT_HOOK_INSTALL_NAME); + fs::write(&hook_path, COPILOT_HOOK_ASSET).unwrap(); + let command = format!( + "bash {}", + shell_single_quote(&hook_path.display().to_string()) + ); + fs::write( + copilot_dir.join("settings.json"), + format!( + r#"{{"hooks":{{"PreToolUse":[{{"type":"command","command":"{}","timeoutSec":10}},{{"type":"command","command":"echo keep","timeoutSec":10}}],"PostToolUse":[{{"type":"command","command":"{}","timeoutSec":10}}],"notification":[{{"type":"command","matcher":"permission_prompt|elicitation_dialog|agent_idle","command":"{}","timeoutSec":10}}]}}}}"#, + command, + command, + command, + ), + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let result = uninstall_copilot().unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(copilot_dir.join("settings.json")).unwrap()) + .unwrap(); + + assert!(result.removed_hook_file); + assert!(result.updated_settings); + assert!(!result.hook_path.exists()); + assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep"); + assert!(settings["hooks"].get("PostToolUse").is_none()); + assert!(settings["hooks"].get("notification").is_none()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_copilot_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_copilot().unwrap_err().to_string(); + + assert!(err.contains("copilot config directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_opencode_writes_plugin_to_plugins_dir() { let _lock = integration_env_lock(); let base = unique_base(); @@ -2856,6 +3299,10 @@ mod tests { assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id")); assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")); assert!(CODEX_HOOK_ASSET.contains("agent_session_id")); + assert!(COPILOT_HOOK_ASSET.contains("agent_session_id")); + assert!(COPILOT_HOOK_ASSET.contains("notification_type")); + assert!(COPILOT_HOOK_ASSET.contains("ask_user")); + assert!(COPILOT_HOOK_ASSET.contains("exit_plan_mode")); assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID")); assert!(OPENCODE_PLUGIN_ASSET.contains("dispose: async")); assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); |
