aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorLane <150401101+LaneBirmingham@users.noreply.github.com>2026-06-02 08:32:05 +1000
committerGitHub <noreply@github.com>2026-06-02 01:32:05 +0300
commitf7d315d494b6a3d583574012526d15c1ddc82bfe (patch)
tree327585c6cb27d478bbb1e4b0da782d9bbfd00901 /src
parent99e097c7d3cf76ae2d53bac5e7e2b3fd88b83591 (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.rs52
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli/integration.rs11
-rw-r--r--src/integration/assets/copilot/herdr-agent-state.sh183
-rw-r--r--src/integration/mod.rs449
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"));