aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-25 20:50:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-25 20:50:13 +0300
commit12cce7deeb2d27f7b11daece75881fcfd88ea607 (patch)
tree843769b293a2e3cd9ad8c752637307741f6a702e
parent3c7327c267a352023797c8a13fd0b7f4a7a40591 (diff)
refactor: split integration module
-rw-r--r--src/integration/actions.rs524
-rw-r--r--src/integration/command.rs48
-rw-r--r--src/integration/config_edit.rs809
-rw-r--r--src/integration/env.rs156
-rw-r--r--src/integration/file_ops.rs59
-rw-r--r--src/integration/mod.rs6334
-rw-r--r--src/integration/registry.rs410
-rw-r--r--src/integration/targets.rs1108
-rw-r--r--src/integration/tests.rs2990
-rw-r--r--src/integration/types.rs215
-rw-r--r--src/integration/version.rs89
11 files changed, 6429 insertions, 6313 deletions
diff --git a/src/integration/actions.rs b/src/integration/actions.rs
new file mode 100644
index 00000000..ea732891
--- /dev/null
+++ b/src/integration/actions.rs
@@ -0,0 +1,524 @@
+use std::io;
+
+use super::registry::{integration_target_label, integration_target_supported};
+use super::targets::{
+ install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid,
+ install_hermes, install_kilo, install_kimi, install_omp, install_opencode, install_pi,
+ install_qodercli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor,
+ uninstall_devin, uninstall_droid, uninstall_hermes, uninstall_kilo, uninstall_kimi,
+ uninstall_omp, uninstall_opencode, uninstall_pi, uninstall_qodercli,
+};
+use super::version::{agent_version_requirement, enforce_agent_version};
+use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME};
+
+pub(crate) fn install_target(
+ target: crate::api::schema::IntegrationTarget,
+) -> io::Result<Vec<String>> {
+ let result = install_target_inner(target);
+ let outcome = if result.is_ok() { "ok" } else { "error" };
+ crate::logging::integration_action("install", integration_target_label(target), outcome);
+ result
+}
+
+fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result<Vec<String>> {
+ if !integration_target_supported(target) {
+ return Err(io::Error::other(format!(
+ "{} integration is not supported on Windows",
+ integration_target_label(target)
+ )));
+ }
+
+ let version_warning = match agent_version_requirement(target) {
+ Some(requirement) => enforce_agent_version(&requirement)?,
+ None => None,
+ };
+
+ let mut messages = match target {
+ crate::api::schema::IntegrationTarget::Pi => {
+ let path = install_pi()?;
+ vec![format!("installed pi integration to {}", path.display())]
+ }
+ crate::api::schema::IntegrationTarget::Omp => {
+ let installed = install_omp()?;
+ let mut messages = Vec::new();
+ if installed.removed_legacy_pi_extension {
+ messages.push(format!(
+ "removed legacy pi integration from omp extension directory at {}",
+ installed
+ .extension_path
+ .with_file_name(PI_EXTENSION_INSTALL_NAME)
+ .display()
+ ));
+ }
+ messages.push(format!(
+ "installed omp integration to {}",
+ installed.extension_path.display()
+ ));
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Claude => {
+ let installed = install_claude()?;
+ vec![
+ format!(
+ "installed claude integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured claude settings at {}",
+ installed.settings_path.display()
+ ),
+ ]
+ }
+ crate::api::schema::IntegrationTarget::Codex => {
+ let installed = install_codex()?;
+ vec![
+ format!(
+ "installed codex integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!("ensured codex hooks at {}", installed.hooks_path.display()),
+ format!(
+ "ensured codex config at {}",
+ installed.config_path.display()
+ ),
+ ]
+ }
+ 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::Devin => {
+ let installed = install_devin()?;
+ vec![
+ format!(
+ "installed devin integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured devin settings at {}",
+ installed.settings_path.display()
+ ),
+ ]
+ }
+ crate::api::schema::IntegrationTarget::Kimi => {
+ let installed = install_kimi()?;
+ vec![
+ format!(
+ "installed kimi integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!("ensured kimi config at {}", installed.config_path.display()),
+ format!("requires kimi code {KIMI_MIN_VERSION} or newer"),
+ ]
+ }
+ crate::api::schema::IntegrationTarget::Droid => {
+ let installed = install_droid()?;
+ let mut messages = vec![
+ format!(
+ "installed droid integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured droid hooks at {}",
+ installed.settings_path.display()
+ ),
+ ];
+ if installed.updated_legacy_hooks {
+ messages.push(format!(
+ "removed legacy herdr droid hook entries from {}",
+ installed.hooks_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Opencode => {
+ let installed = install_opencode()?;
+ vec![format!(
+ "installed opencode integration plugin to {}",
+ installed.plugin_path.display()
+ )]
+ }
+ crate::api::schema::IntegrationTarget::Kilo => {
+ let installed = install_kilo()?;
+ vec![format!(
+ "installed kilo integration plugin to {}",
+ installed.plugin_path.display()
+ )]
+ }
+ crate::api::schema::IntegrationTarget::Hermes => {
+ let installed = install_hermes()?;
+ vec![
+ format!(
+ "installed hermes integration plugin to {}",
+ installed.plugin_dir.display()
+ ),
+ format!(
+ "enabled hermes plugin in {}",
+ installed.config_path.display()
+ ),
+ ]
+ }
+ crate::api::schema::IntegrationTarget::Qodercli => {
+ let installed = install_qodercli()?;
+ vec![
+ format!(
+ "installed qodercli integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured qodercli settings at {}",
+ installed.settings_path.display()
+ ),
+ ]
+ }
+ crate::api::schema::IntegrationTarget::Cursor => {
+ let installed = install_cursor()?;
+ vec![
+ format!(
+ "installed cursor integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!("updated cursor hooks at {}", installed.hooks_path.display()),
+ ]
+ }
+ };
+
+ if let Some(warning) = version_warning {
+ messages.push(warning);
+ }
+
+ Ok(messages)
+}
+
+pub(crate) fn uninstall_target(
+ target: crate::api::schema::IntegrationTarget,
+) -> io::Result<Vec<String>> {
+ let messages = match target {
+ crate::api::schema::IntegrationTarget::Pi => {
+ let result = uninstall_pi()?;
+ if result.removed_extension {
+ vec![format!(
+ "removed pi integration extension at {}",
+ result.extension_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no pi integration extension found at {}",
+ result.extension_path.display()
+ )]
+ }
+ }
+ crate::api::schema::IntegrationTarget::Omp => {
+ let result = uninstall_omp()?;
+ if result.removed_extension {
+ vec![format!(
+ "removed omp integration extension at {}",
+ result.extension_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no omp integration extension found at {}",
+ result.extension_path.display()
+ )]
+ }
+ }
+ crate::api::schema::IntegrationTarget::Claude => {
+ let result = uninstall_claude()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed claude hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no claude hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr claude hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr claude hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Codex => {
+ let result = uninstall_codex()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed codex hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no codex hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_hooks {
+ messages.push(format!(
+ "removed herdr codex hook entries from {}",
+ result.hooks_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr codex hook entries found in {}",
+ result.hooks_path.display()
+ ));
+ }
+ messages.push(format!(
+ "left codex config unchanged at {}",
+ result.config_path.display()
+ ));
+ 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::Devin => {
+ let result = uninstall_devin()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed devin hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no devin hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr devin hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr devin hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Kimi => {
+ let result = uninstall_kimi()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed kimi hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no kimi hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_config {
+ messages.push(format!(
+ "removed herdr kimi hook entries from {}",
+ result.config_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr kimi hook entries found in {}",
+ result.config_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Droid => {
+ let result = uninstall_droid()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed droid hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no droid hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_hooks {
+ messages.push(format!(
+ "removed legacy herdr droid hook entries from {}",
+ result.hooks_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no legacy herdr droid hook entries found in {}",
+ result.hooks_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr droid hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr droid hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Opencode => {
+ let result = uninstall_opencode()?;
+ if result.removed_plugin {
+ vec![format!(
+ "removed opencode integration plugin at {}",
+ result.plugin_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no opencode integration plugin found at {}",
+ result.plugin_path.display()
+ )]
+ }
+ }
+ crate::api::schema::IntegrationTarget::Kilo => {
+ let result = uninstall_kilo()?;
+ if result.removed_plugin {
+ vec![format!(
+ "removed kilo integration plugin at {}",
+ result.plugin_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no kilo integration plugin found at {}",
+ result.plugin_path.display()
+ )]
+ }
+ }
+ crate::api::schema::IntegrationTarget::Hermes => {
+ let result = uninstall_hermes()?;
+ let mut messages = Vec::new();
+ if result.removed_plugin_dir {
+ messages.push(format!(
+ "removed hermes integration plugin at {}",
+ result.plugin_dir.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no hermes integration plugin found at {}",
+ result.plugin_dir.display()
+ ));
+ }
+ if result.updated_config {
+ messages.push(format!(
+ "disabled hermes plugin in {}",
+ result.config_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no hermes plugin entry found in {}",
+ result.config_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Qodercli => {
+ let result = uninstall_qodercli()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed qodercli hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no qodercli hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr qodercli hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr qodercli hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
+ crate::api::schema::IntegrationTarget::Cursor => {
+ let result = uninstall_cursor()?;
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed cursor hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no cursor hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_hooks {
+ messages.push(format!(
+ "removed herdr cursor hook entries from {}",
+ result.hooks_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr cursor hook entries found in {}",
+ result.hooks_path.display()
+ ));
+ }
+ messages
+ }
+ };
+
+ crate::logging::integration_action("uninstall", integration_target_label(target), "ok");
+ Ok(messages)
+}
diff --git a/src/integration/command.rs b/src/integration/command.rs
new file mode 100644
index 00000000..a5d58f0c
--- /dev/null
+++ b/src/integration/command.rs
@@ -0,0 +1,48 @@
+use std::path::Path;
+
+pub(crate) fn shell_single_quote(value: &str) -> String {
+ format!("'{}'", value.replace('\'', "'\"'\"'"))
+}
+
+pub(crate) 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
+ }
+}
+
+pub(crate) 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('"', "\\\""))
+}
diff --git a/src/integration/config_edit.rs b/src/integration/config_edit.rs
new file mode 100644
index 00000000..a1653395
--- /dev/null
+++ b/src/integration/config_edit.rs
@@ -0,0 +1,809 @@
+use std::io;
+use std::path::Path;
+
+use serde_json::{json, Map, Value};
+
+use super::command::{hook_command, legacy_bash_hook_command};
+#[cfg(windows)]
+use super::file_ops::legacy_bash_hook_path;
+use super::{
+ HERMES_PLUGIN_INSTALL_NAME, KIMI_CONFIG_BLOCK_BEGIN, KIMI_CONFIG_BLOCK_END, KIMI_HOOK_EVENTS,
+};
+
+pub(crate) fn ensure_hooks_object<'a>(
+ settings: &'a mut Value,
+ settings_path: &Path,
+ root_description: &str,
+ hooks_description: &str,
+) -> io::Result<&'a mut Map<String, Value>> {
+ let root = settings.as_object_mut().ok_or_else(|| {
+ io::Error::other(format!(
+ "{root_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })?;
+
+ let hooks = root.entry("hooks").or_insert_with(|| json!({}));
+ hooks.as_object_mut().ok_or_else(|| {
+ io::Error::other(format!(
+ "{hooks_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })
+}
+
+pub(crate) fn hooks_object_if_present<'a>(
+ settings: &'a mut Value,
+ settings_path: &Path,
+ root_description: &str,
+ hooks_description: &str,
+) -> io::Result<Option<&'a mut Map<String, Value>>> {
+ let root = settings.as_object_mut().ok_or_else(|| {
+ io::Error::other(format!(
+ "{root_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })?;
+
+ let Some(hooks) = root.get_mut("hooks") else {
+ return Ok(None);
+ };
+
+ hooks.as_object_mut().map(Some).ok_or_else(|| {
+ io::Error::other(format!(
+ "{hooks_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })
+}
+
+pub(crate) fn ensure_command_hook(
+ hooks: &mut Map<String, Value>,
+ event: &str,
+ command: String,
+ timeout: 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")))?;
+
+ let already_installed = entries.iter().any(|entry| {
+ entry
+ .get("hooks")
+ .and_then(Value::as_array)
+ .is_some_and(|hook_entries| {
+ hook_entries.iter().any(|hook| {
+ hook.get("type").and_then(Value::as_str) == Some("command")
+ && hook.get("command").and_then(Value::as_str) == Some(command.as_str())
+ })
+ })
+ });
+ if already_installed {
+ return Ok(());
+ }
+
+ let mut entry = Map::new();
+ if let Some(matcher) = matcher {
+ entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
+ }
+ entry.insert(
+ "hooks".to_string(),
+ json!([
+ {
+ "type": "command",
+ "command": command,
+ "timeout": timeout,
+ }
+ ]),
+ );
+
+ entries.push(Value::Object(entry));
+ Ok(())
+}
+
+// Claude and Codex use nested hook groups:
+// { "matcher": "...", "hooks": [{ "type": "command", ... }] }
+// Copilot uses the flatter settings shape:
+// { "type": "command", "matcher": "...", "bash": "...", ... }
+// Keep the helpers separate so install/uninstall preserves unrelated hooks in
+// each agent's native format instead of normalizing user configuration.
+pub(crate) 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")))?;
+
+ let command_field = direct_command_field();
+ if let Some(entry) = entries.iter_mut().find(|entry| {
+ entry.get("type").and_then(Value::as_str) == Some("command")
+ && is_matching_direct_command_entry(entry, command.as_str())
+ }) {
+ let Some(entry_object) = entry.as_object_mut() else {
+ return Ok(());
+ };
+ entry_object.remove("command");
+ entry_object.remove("bash");
+ entry_object.remove("powershell");
+ entry_object.insert(command_field.to_string(), Value::String(command.clone()));
+ 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_field.to_string(), Value::String(command));
+ entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
+ entries.push(Value::Object(entry));
+ Ok(())
+}
+
+pub(crate) fn direct_command_field() -> &'static str {
+ if cfg!(windows) {
+ "powershell"
+ } else {
+ "bash"
+ }
+}
+
+pub(crate) fn is_matching_direct_command_entry(entry: &Value, command: &str) -> bool {
+ entry.get("command").and_then(Value::as_str) == Some(command)
+ || entry.get("bash").and_then(Value::as_str) == Some(command)
+ || entry.get("powershell").and_then(Value::as_str) == Some(command)
+}
+
+pub(crate) fn remove_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 mut removed = false;
+ entries.retain_mut(|entry| {
+ let Some(entry_object) = entry.as_object_mut() else {
+ return true;
+ };
+ let Some(hook_entries) = entry_object.get_mut("hooks") else {
+ return true;
+ };
+ let Some(hook_entries) = hook_entries.as_array_mut() else {
+ return true;
+ };
+
+ let before = hook_entries.len();
+ hook_entries.retain(|hook| !is_matching_command_hook(hook, command));
+ if hook_entries.len() != before {
+ removed = true;
+ }
+
+ !hook_entries.is_empty()
+ });
+
+ let remove_event = entries.is_empty();
+ if remove_event {
+ hooks.remove(event);
+ }
+
+ Ok(removed)
+}
+
+pub(crate) 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")
+ && is_matching_direct_command_entry(entry, command))
+ });
+ let removed = entries.len() != before;
+ if entries.is_empty() {
+ hooks.remove(event);
+ }
+ Ok(removed)
+}
+
+// Cursor hooks.json uses the minimal shape `{ "command": "..." }` documented at
+// https://cursor.com/docs/hooks. Keep this separate from the nested codex and
+// flat copilot helpers so install/uninstall does not rewrite unrelated hooks.
+pub(crate) fn ensure_simple_command_hook(
+ hooks: &mut Map<String, Value>,
+ event: &str,
+ command: String,
+) -> 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 entries
+ .iter()
+ .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command.as_str()))
+ {
+ return Ok(());
+ }
+
+ entries.push(json!({ "command": command }));
+ Ok(())
+}
+
+pub(crate) fn remove_simple_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("command").and_then(Value::as_str) != Some(command));
+ let removed = entries.len() != before;
+ if entries.is_empty() {
+ hooks.remove(event);
+ }
+ Ok(removed)
+}
+
+pub(crate) 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)
+}
+
+pub(crate) 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)
+}
+
+pub(crate) 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
+}
+
+pub(crate) fn push_unique_command(commands: &mut Vec<String>, command: String) {
+ if !commands.iter().any(|existing| existing == &command) {
+ commands.push(command);
+ }
+}
+
+pub(crate) 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)
+}
+
+pub(crate) fn ensure_hermes_plugin_enabled(content: &str) -> String {
+ update_hermes_enabled_plugin(content, true)
+}
+
+pub(crate) fn remove_hermes_plugin_enabled(content: &str) -> String {
+ update_hermes_enabled_plugin(content, false)
+}
+
+pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> String {
+ let trailing_newline = content.ends_with('\n');
+ let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
+ let Some(plugins_index) = top_level_yaml_key_index(&lines, "plugins") else {
+ if !enabled {
+ return content.to_string();
+ }
+ let mut result = content.trim_end_matches('\n').to_string();
+ if !result.is_empty() {
+ result.push('\n');
+ }
+ result.push_str("plugins:\n enabled:\n - herdr-agent-state\n");
+ return result;
+ };
+
+ let plugins_end =
+ next_top_level_yaml_key_index(&lines, plugins_index + 1).unwrap_or(lines.len());
+ let plugins_inline_items = yaml_key_value_at_indent(&lines[plugins_index], 0, "plugins")
+ .and_then(yaml_flow_sequence_items);
+ let enabled_index = lines[plugins_index + 1..plugins_end]
+ .iter()
+ .position(|line| yaml_key_at_indent(line, 2) == Some("enabled"))
+ .map(|offset| plugins_index + 1 + offset);
+ let flat_list_start = lines[plugins_index + 1..plugins_end]
+ .iter()
+ .position(|line| yaml_list_item_value_at_indent(line, 2).is_some())
+ .map(|offset| plugins_index + 1 + offset);
+
+ if let Some(enabled_index) = enabled_index {
+ let line = lines[enabled_index].trim();
+ if line == "enabled: []" || line == "enabled: [] # herdr" {
+ if enabled {
+ lines[enabled_index] = " enabled:".to_string();
+ lines.insert(enabled_index + 1, " - herdr-agent-state".to_string());
+ }
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ let list_start = enabled_index + 1;
+ let list_end = lines[list_start..plugins_end]
+ .iter()
+ .position(|line| {
+ yaml_indent(line).is_some_and(|indent| indent <= 2) && yaml_key_name(line).is_some()
+ })
+ .map(|offset| list_start + offset)
+ .unwrap_or(plugins_end);
+ let existing_item_index = lines[list_start..list_end]
+ .iter()
+ .position(|line| yaml_list_item_matches(line, HERMES_PLUGIN_INSTALL_NAME))
+ .map(|offset| list_start + offset);
+
+ match (enabled, existing_item_index) {
+ (true, Some(_)) | (false, None) => return content.to_string(),
+ (true, None) => lines.insert(list_start, " - herdr-agent-state".to_string()),
+ (false, Some(index)) => {
+ lines.remove(index);
+ }
+ }
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ if let Some(mut items) = plugins_inline_items {
+ let existing_item_index = items
+ .iter()
+ .position(|item| item == HERMES_PLUGIN_INSTALL_NAME);
+
+ match (enabled, existing_item_index) {
+ (true, Some(_)) | (false, None) => return content.to_string(),
+ (true, None) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()),
+ (false, Some(index)) => {
+ items.remove(index);
+ }
+ }
+
+ let replacement = hermes_flat_plugin_lines(&items);
+ lines.splice(plugins_index..plugins_end, replacement);
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ if let Some(flat_list_start) = flat_list_start {
+ let existing_item_index = lines[plugins_index + 1..plugins_end]
+ .iter()
+ .position(|line| yaml_list_item_matches_at_indent(line, 2, HERMES_PLUGIN_INSTALL_NAME))
+ .map(|offset| plugins_index + 1 + offset);
+
+ match (enabled, existing_item_index) {
+ (true, Some(_)) | (false, None) => return content.to_string(),
+ (true, None) => lines.insert(flat_list_start, " - herdr-agent-state".to_string()),
+ (false, Some(index)) => {
+ lines.remove(index);
+ }
+ }
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ if enabled {
+ lines.insert(plugins_index + 1, " enabled:".to_string());
+ lines.insert(plugins_index + 2, " - herdr-agent-state".to_string());
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ content.to_string()
+}
+
+pub(crate) fn hermes_flat_plugin_lines(items: &[String]) -> Vec<String> {
+ if items.is_empty() {
+ return vec!["plugins: []".to_string()];
+ }
+
+ let mut lines = vec!["plugins:".to_string()];
+ lines.extend(items.iter().map(|item| format!(" - {item}")));
+ lines
+}
+
+pub(crate) fn top_level_yaml_key_index(lines: &[String], key: &str) -> Option<usize> {
+ lines
+ .iter()
+ .position(|line| yaml_key_at_indent(line, 0) == Some(key))
+}
+
+pub(crate) fn next_top_level_yaml_key_index(lines: &[String], start: usize) -> Option<usize> {
+ lines[start..]
+ .iter()
+ .position(|line| yaml_indent(line) == Some(0) && yaml_key_name(line).is_some())
+ .map(|offset| start + offset)
+}
+
+pub(crate) fn yaml_key_at_indent(line: &str, indent: usize) -> Option<&str> {
+ if yaml_indent(line)? != indent {
+ return None;
+ }
+ yaml_key_name(line)
+}
+
+pub(crate) fn yaml_key_value_at_indent<'a>(
+ line: &'a str,
+ indent: usize,
+ key: &str,
+) -> Option<&'a str> {
+ if yaml_indent(line)? != indent {
+ return None;
+ }
+ let trimmed = line.trim_start();
+ if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
+ return None;
+ }
+ let (line_key, value) = trimmed.split_once(':')?;
+ (line_key.trim() == key).then_some(value.trim())
+}
+
+pub(crate) fn yaml_key_name(line: &str) -> Option<&str> {
+ let trimmed = line.trim_start();
+ if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
+ return None;
+ }
+ let (key, _) = trimmed.split_once(':')?;
+ let key = key.trim();
+ (!key.is_empty()).then_some(key)
+}
+
+pub(crate) fn yaml_indent(line: &str) -> Option<usize> {
+ let trimmed = line.trim_start();
+ if trimmed.is_empty() || trimmed.starts_with('#') {
+ return None;
+ }
+ Some(line.len() - trimmed.len())
+}
+
+pub(crate) fn yaml_list_item_value(line: &str) -> Option<&str> {
+ line.trim().strip_prefix("- ").map(str::trim)
+}
+
+pub(crate) fn yaml_list_item_matches(line: &str, value: &str) -> bool {
+ yaml_list_item_value(line).is_some_and(|item| yaml_scalar_value(item) == value)
+}
+
+pub(crate) fn yaml_list_item_value_at_indent(line: &str, indent: usize) -> Option<&str> {
+ if yaml_indent(line)? != indent {
+ return None;
+ }
+ yaml_list_item_value(line)
+}
+
+pub(crate) fn yaml_list_item_matches_at_indent(line: &str, indent: usize, value: &str) -> bool {
+ yaml_list_item_value_at_indent(line, indent)
+ .is_some_and(|item| yaml_scalar_value(item) == value)
+}
+
+pub(crate) fn yaml_flow_sequence_items(value: &str) -> Option<Vec<String>> {
+ let value = strip_yaml_inline_comment(value).trim();
+ let inner = value.strip_prefix('[')?.strip_suffix(']')?.trim();
+ if inner.is_empty() {
+ return Some(Vec::new());
+ }
+
+ let mut items = Vec::new();
+ let mut current = String::new();
+ let mut quote = None;
+ let mut escaped = false;
+
+ for ch in inner.chars() {
+ if let Some(quote_char) = quote {
+ current.push(ch);
+ if quote_char == '"' && ch == '\\' && !escaped {
+ escaped = true;
+ continue;
+ }
+ if ch == quote_char && !escaped {
+ quote = None;
+ }
+ escaped = false;
+ continue;
+ }
+
+ match ch {
+ '"' | '\'' => {
+ quote = Some(ch);
+ current.push(ch);
+ }
+ ',' => {
+ items.push(yaml_scalar_value(&current));
+ current.clear();
+ }
+ _ => current.push(ch),
+ }
+ }
+
+ if quote.is_some() {
+ return None;
+ }
+
+ items.push(yaml_scalar_value(&current));
+ Some(items)
+}
+
+pub(crate) fn yaml_scalar_value(value: &str) -> String {
+ let value = strip_yaml_inline_comment(value).trim();
+ if value.len() >= 2 {
+ let bytes = value.as_bytes();
+ let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
+ || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'');
+ if quoted {
+ return value[1..value.len() - 1].to_string();
+ }
+ }
+ value.to_string()
+}
+
+pub(crate) fn strip_yaml_inline_comment(value: &str) -> &str {
+ let mut quote = None;
+ let mut escaped = false;
+
+ for (index, ch) in value.char_indices() {
+ if let Some(quote_char) = quote {
+ if quote_char == '"' && ch == '\\' && !escaped {
+ escaped = true;
+ continue;
+ }
+ if ch == quote_char && !escaped {
+ quote = None;
+ }
+ escaped = false;
+ continue;
+ }
+
+ match ch {
+ '"' | '\'' => quote = Some(ch),
+ '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
+ return value[..index].trim_end();
+ }
+ _ => {}
+ }
+ }
+
+ value
+}
+
+pub(crate) fn join_yaml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
+ let mut result = lines.join("\n");
+ if trailing_newline || result.is_empty() {
+ result.push('\n');
+ }
+ result
+}
+
+pub(crate) fn build_codex_config_with_hooks(content: &str) -> String {
+ let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
+ let trailing_newline = content.ends_with('\n');
+ let mut in_top_level_features = false;
+ let mut features_header_index = None;
+ let mut hooks_index = None;
+ let mut deprecated_hooks_indexes = Vec::new();
+
+ for (index, line) in lines.iter().enumerate() {
+ if let Some(header) = toml_table_header(line) {
+ in_top_level_features = header == "[features]";
+ if in_top_level_features && features_header_index.is_none() {
+ features_header_index = Some(index);
+ }
+ continue;
+ }
+
+ if !in_top_level_features {
+ continue;
+ }
+
+ if is_toml_key(line, "codex_hooks") {
+ deprecated_hooks_indexes.push(index);
+ } else if is_toml_key(line, "hooks") {
+ hooks_index = Some(index);
+ }
+ }
+
+ if let Some(index) = hooks_index {
+ lines[index] = "hooks = true".to_string();
+ }
+
+ for index in deprecated_hooks_indexes.into_iter().rev() {
+ lines.remove(index);
+ }
+
+ if hooks_index.is_none() {
+ if let Some(index) = features_header_index {
+ lines.insert(index + 1, "hooks = true".to_string());
+ return join_toml_lines(lines, trailing_newline);
+ }
+
+ let mut result = content.trim_end_matches('\n').to_string();
+ if !result.is_empty() {
+ result.push('\n');
+ result.push('\n');
+ }
+ result.push_str("[features]\nhooks = true\n");
+ return result;
+ }
+
+ join_toml_lines(lines, trailing_newline)
+}
+
+pub(crate) fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String {
+ let mut result = remove_kimi_config_block(content)
+ .trim_end_matches('\n')
+ .to_string();
+ if !result.is_empty() {
+ result.push('\n');
+ result.push('\n');
+ }
+
+ result.push_str(KIMI_CONFIG_BLOCK_BEGIN);
+ result.push('\n');
+ for (event, action) in KIMI_HOOK_EVENTS {
+ result.push_str(&kimi_hook_table(event, hook_path, action));
+ }
+ result.push_str(KIMI_CONFIG_BLOCK_END);
+ result.push('\n');
+ result
+}
+
+pub(crate) fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String {
+ let command = hook_command(hook_path, Some(action));
+ format!(
+ "[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n",
+ toml_basic_string(event),
+ toml_basic_string(&command)
+ )
+}
+
+pub(crate) fn remove_kimi_config_block(content: &str) -> String {
+ let trailing_newline = content.ends_with('\n');
+ let mut lines = Vec::new();
+ let mut in_block = false;
+ let mut removed_block = false;
+
+ for line in content.lines() {
+ if line.trim() == KIMI_CONFIG_BLOCK_BEGIN {
+ in_block = true;
+ removed_block = true;
+ continue;
+ }
+ if in_block {
+ if line.trim() == KIMI_CONFIG_BLOCK_END {
+ in_block = false;
+ }
+ continue;
+ }
+ lines.push(line.to_string());
+ }
+
+ if !removed_block {
+ return content.to_string();
+ }
+
+ let mut result = join_toml_lines(lines, trailing_newline);
+ while result.ends_with("\n\n") {
+ result.pop();
+ }
+ if result == "\n" {
+ String::new()
+ } else {
+ result
+ }
+}
+
+pub(crate) fn toml_basic_string(value: &str) -> String {
+ let mut result = String::with_capacity(value.len() + 2);
+ result.push('"');
+ for ch in value.chars() {
+ match ch {
+ '"' => result.push_str("\\\""),
+ '\\' => result.push_str("\\\\"),
+ '\u{08}' => result.push_str("\\b"),
+ '\t' => result.push_str("\\t"),
+ '\n' => result.push_str("\\n"),
+ '\u{0c}' => result.push_str("\\f"),
+ '\r' => result.push_str("\\r"),
+ ch if ch <= '\u{1f}' || ch == '\u{7f}' => {
+ result.push_str(&format!("\\u{:04X}", ch as u32));
+ }
+ ch => result.push(ch),
+ }
+ }
+ result.push('"');
+ result
+}
+
+pub(crate) fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
+ let mut result = lines.join("\n");
+ if trailing_newline || result.is_empty() {
+ result.push('\n');
+ }
+ result
+}
+
+pub(crate) fn toml_table_header(line: &str) -> Option<&str> {
+ let trimmed = line.trim_start();
+ if trimmed.starts_with('#') || !trimmed.starts_with('[') {
+ return None;
+ }
+
+ let header_end = if trimmed.starts_with("[[") {
+ trimmed.find("]]").map(|index| index + 2)?
+ } else {
+ trimmed.find(']').map(|index| index + 1)?
+ };
+ let header = &trimmed[..header_end];
+ let rest = trimmed[header_end..].trim_start();
+ if !rest.is_empty() && !rest.starts_with('#') {
+ return None;
+ }
+
+ Some(header)
+}
+
+pub(crate) fn is_toml_key(line: &str, key: &str) -> bool {
+ let trimmed = line.trim();
+ if trimmed.starts_with('#') || !trimmed.starts_with(key) {
+ return false;
+ }
+
+ trimmed[key.len()..].trim_start().starts_with('=')
+}
diff --git a/src/integration/env.rs b/src/integration/env.rs
new file mode 100644
index 00000000..575c4c9a
--- /dev/null
+++ b/src/integration/env.rs
@@ -0,0 +1,156 @@
+use std::io;
+use std::path::PathBuf;
+#[cfg(test)]
+use std::sync::{Mutex, MutexGuard, OnceLock};
+
+use portable_pty::CommandBuilder;
+
+pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
+pub(crate) const HERDR_TAB_ID_ENV_VAR: &str = "HERDR_TAB_ID";
+pub(crate) const HERDR_WORKSPACE_ID_ENV_VAR: &str = "HERDR_WORKSPACE_ID";
+
+pub(crate) const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
+pub(crate) const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
+pub(crate) const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
+pub(crate) const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME";
+pub(crate) const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
+pub(crate) const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR";
+pub(crate) const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR";
+
+pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) {
+ cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
+}
+
+pub(crate) fn pi_extension_dir() -> io::Result<PathBuf> {
+ Ok(
+ config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".pi", "agent"])?
+ .join("extensions"),
+ )
+}
+
+pub(crate) fn omp_extension_dir() -> io::Result<PathBuf> {
+ Ok(
+ config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])?
+ .join("extensions"),
+ )
+}
+
+pub(crate) fn claude_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"])
+}
+
+pub(crate) fn codex_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"])
+}
+
+pub(crate) fn kimi_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"])
+}
+
+pub(crate) fn copilot_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"])
+}
+
+pub(crate) fn devin_dir() -> io::Result<PathBuf> {
+ if let Some(value) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
+ return expand_tilde_path(PathBuf::from(value)).map(|path| path.join("devin"));
+ }
+
+ Ok(home_dir()?.join(".config").join("devin"))
+}
+
+pub(crate) fn droid_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".factory"))
+}
+
+pub(crate) fn config_dir_from_env_or_home(
+ env_var: &str,
+ home_relative_segments: &[&str],
+) -> io::Result<PathBuf> {
+ if let Some(value) = std::env::var_os(env_var).filter(|value| !value.is_empty()) {
+ return expand_tilde_path(PathBuf::from(value));
+ }
+
+ let mut path = home_dir()?;
+ for segment in home_relative_segments {
+ path.push(segment);
+ }
+ Ok(path)
+}
+
+pub(crate) fn expand_tilde_path(path: PathBuf) -> io::Result<PathBuf> {
+ let Some(raw) = path.to_str() else {
+ return Ok(path);
+ };
+
+ if raw == "~" {
+ return home_dir();
+ }
+
+ if let Some(rest) = raw
+ .strip_prefix("~/")
+ .or_else(|| raw.strip_prefix("~\\"))
+ .or_else(|| raw.strip_prefix('~'))
+ {
+ return Ok(home_dir()?.join(rest));
+ }
+
+ Ok(path)
+}
+
+pub(crate) fn opencode_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".config/opencode"))
+}
+
+pub(crate) fn kilo_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".config/kilo"))
+}
+
+pub(crate) fn hermes_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".hermes"))
+}
+
+pub(crate) fn hermes_plugin_dir() -> io::Result<PathBuf> {
+ Ok(hermes_dir()?
+ .join("plugins")
+ .join(super::HERMES_PLUGIN_INSTALL_NAME))
+}
+
+pub(crate) fn qodercli_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"])
+}
+
+pub(crate) fn cursor_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"])
+}
+
+pub(crate) fn home_dir() -> io::Result<PathBuf> {
+ if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
+ return Ok(PathBuf::from(home));
+ }
+
+ #[cfg(windows)]
+ {
+ if let Some(profile) = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()) {
+ return Ok(PathBuf::from(profile));
+ }
+ if let (Some(drive), Some(path)) = (
+ std::env::var_os("HOMEDRIVE").filter(|value| !value.is_empty()),
+ std::env::var_os("HOMEPATH").filter(|value| !value.is_empty()),
+ ) {
+ let mut home = PathBuf::from(drive);
+ home.push(path);
+ return Ok(home);
+ }
+ }
+
+ Err(io::Error::other(
+ "home directory is not set; cannot locate home directory",
+ ))
+}
+
+#[cfg(test)]
+pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
+}
diff --git a/src/integration/file_ops.rs b/src/integration/file_ops.rs
new file mode 100644
index 00000000..2e345759
--- /dev/null
+++ b/src/integration/file_ops.rs
@@ -0,0 +1,59 @@
+use std::fs;
+use std::io;
+use std::path::Path;
+
+pub(crate) fn remove_file_if_exists(path: &Path) -> io::Result<bool> {
+ match fs::remove_file(path) {
+ Ok(()) => Ok(true),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err),
+ }
+}
+
+#[cfg(windows)]
+pub(crate) fn legacy_bash_hook_path(hook_path: &Path) -> std::path::PathBuf {
+ hook_path.with_file_name("herdr-agent-state.sh")
+}
+
+#[cfg(windows)]
+pub(crate) 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))]
+pub(crate) fn remove_legacy_bash_hook_file(_hook_path: &Path) -> io::Result<bool> {
+ Ok(false)
+}
+
+pub(crate) fn remove_dir_all_if_exists(path: &Path) -> io::Result<bool> {
+ match fs::remove_dir_all(path) {
+ Ok(()) => Ok(true),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err),
+ }
+}
+
+pub(crate) fn make_executable(_path: &Path) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ let mut perms = fs::metadata(_path)?.permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(_path, perms)?;
+ }
+
+ Ok(())
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 287ab89b..a0e4b52e 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -1,22 +1,31 @@
-use std::fs;
-use std::io;
-use std::path::{Path, PathBuf};
+mod actions;
+mod command;
+mod config_edit;
+mod env;
+mod file_ops;
+mod registry;
+mod targets;
+mod types;
+mod version;
+
+pub(crate) use actions::{install_target, uninstall_target};
#[cfg(test)]
-use std::sync::{Mutex, MutexGuard, OnceLock};
-
-use portable_pty::CommandBuilder;
-use serde_json::{json, Map, Value};
+pub(crate) use env::integration_env_lock;
+pub(crate) use env::{
+ apply_pane_base_env, HERDR_PANE_ID_ENV_VAR, HERDR_TAB_ID_ENV_VAR, HERDR_WORKSPACE_ID_ENV_VAR,
+};
+pub(crate) use registry::{
+ installed_integration_statuses, integration_recommendations, integration_target_label,
+ print_outdated_update_notice,
+};
+pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, IntegrationStatusKind};
-pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
-pub(crate) const HERDR_TAB_ID_ENV_VAR: &str = "HERDR_TAB_ID";
-pub(crate) const HERDR_WORKSPACE_ID_ENV_VAR: &str = "HERDR_WORKSPACE_ID";
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
const PI_INTEGRATION_VERSION: u32 = 4;
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 = 4;
-const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -28,7 +37,6 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) {
include_str!("assets/claude/herdr-agent-state.sh")
};
const CLAUDE_INTEGRATION_VERSION: u32 = 7;
-const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -40,7 +48,6 @@ const CODEX_HOOK_ASSET: &str = if cfg!(windows) {
include_str!("assets/codex/herdr-agent-state.sh")
};
const CODEX_INTEGRATION_VERSION: u32 = 6;
-const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
@@ -52,7 +59,6 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) {
include_str!("assets/kimi/herdr-agent-state.sh")
};
const KIMI_INTEGRATION_VERSION: u32 = 4;
-const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME";
const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration";
const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration";
const KIMI_MIN_VERSION: &str = "0.14.0";
@@ -78,7 +84,6 @@ const COPILOT_HOOK_ASSET: &str = if cfg!(windows) {
include_str!("assets/copilot/herdr-agent-state.sh")
};
const COPILOT_INTEGRATION_VERSION: u32 = 2;
-const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"];
const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [
"UserPromptSubmit",
@@ -156,7 +161,6 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) {
include_str!("assets/qodercli/herdr-agent-state.sh")
};
const QODERCLI_INTEGRATION_VERSION: u32 = 2;
-const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR";
const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")];
const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [
("SessionStart", "idle"),
@@ -175,6305 +179,9 @@ const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [
const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh");
const CURSOR_INTEGRATION_VERSION: u32 = 1;
-const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR";
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
-#[derive(Debug)]
-pub(crate) struct ClaudeInstallPaths {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct CodexInstallPaths {
- pub hook_path: PathBuf,
- pub hooks_path: PathBuf,
- pub config_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct KimiInstallPaths {
- pub hook_path: PathBuf,
- pub config_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct CopilotInstallPaths {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct DevinInstallPaths {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct DroidInstallPaths {
- pub hook_path: PathBuf,
- pub hooks_path: PathBuf,
- pub settings_path: PathBuf,
- pub updated_legacy_hooks: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct OpenCodeInstallPaths {
- pub plugin_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct KiloInstallPaths {
- pub plugin_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct OmpInstallPaths {
- pub extension_path: PathBuf,
- pub removed_legacy_pi_extension: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct HermesInstallPaths {
- pub plugin_dir: PathBuf,
- pub config_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct QodercliInstallPaths {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct CursorInstallPaths {
- pub hook_path: PathBuf,
- pub hooks_path: PathBuf,
-}
-
-#[derive(Debug)]
-pub(crate) struct CursorUninstallResult {
- pub hook_path: PathBuf,
- pub hooks_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_hooks: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct QodercliUninstallResult {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_settings: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct IntegrationStatus {
- pub target: crate::api::schema::IntegrationTarget,
- pub path: PathBuf,
- pub state: IntegrationStatusKind,
- pub installed_version: Option<u32>,
- pub expected_version: u32,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum IntegrationStatusKind {
- NotInstalled,
- Current,
- Outdated,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct IntegrationRecommendation {
- pub target: crate::api::schema::IntegrationTarget,
- pub label: &'static str,
- pub command: &'static str,
- pub available: bool,
- pub path: PathBuf,
- pub state: IntegrationStatusKind,
-}
-
-impl IntegrationRecommendation {
- pub fn needs_install(&self) -> bool {
- self.state == IntegrationStatusKind::Outdated
- || (self.available && self.state == IntegrationStatusKind::NotInstalled)
- }
-
- pub fn status_label(&self) -> &'static str {
- match (self.available, self.state) {
- (_, IntegrationStatusKind::Current) => "installed",
- (_, IntegrationStatusKind::Outdated) => "update available",
- (true, IntegrationStatusKind::NotInstalled) => "available",
- (false, IntegrationStatusKind::NotInstalled) => "not found",
- }
- }
-}
-
-#[derive(Debug)]
-pub(crate) struct PiUninstallResult {
- pub extension_path: PathBuf,
- pub removed_extension: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct OmpUninstallResult {
- pub extension_path: PathBuf,
- pub removed_extension: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct ClaudeUninstallResult {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_settings: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct CodexUninstallResult {
- pub hook_path: PathBuf,
- pub hooks_path: PathBuf,
- pub config_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_hooks: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct KimiUninstallResult {
- pub hook_path: PathBuf,
- pub config_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_config: bool,
-}
-
-#[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 DevinUninstallResult {
- pub hook_path: PathBuf,
- pub settings_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_settings: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct DroidUninstallResult {
- pub hook_path: PathBuf,
- pub hooks_path: PathBuf,
- pub settings_path: PathBuf,
- pub removed_hook_file: bool,
- pub updated_hooks: bool,
- pub updated_settings: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct OpenCodeUninstallResult {
- pub plugin_path: PathBuf,
- pub removed_plugin: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct KiloUninstallResult {
- pub plugin_path: PathBuf,
- pub removed_plugin: bool,
-}
-
-#[derive(Debug)]
-pub(crate) struct HermesUninstallResult {
- pub plugin_dir: PathBuf,
- pub config_path: PathBuf,
- pub removed_plugin_dir: bool,
- pub updated_config: bool,
-}
-
-pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) {
- cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
-}
-
pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:";
-struct AgentVersionRequirement {
- label: &'static str,
- binary: &'static str,
- args: &'static [&'static str],
- min_version: &'static str,
-}
-
-fn agent_version_requirement(
- target: crate::api::schema::IntegrationTarget,
-) -> Option<AgentVersionRequirement> {
- match target {
- crate::api::schema::IntegrationTarget::Kimi => Some(AgentVersionRequirement {
- label: "kimi code",
- binary: "kimi",
- args: &["--version"],
- min_version: KIMI_MIN_VERSION,
- }),
- _ => None,
- }
-}
-
-fn extract_version_triple(text: &str) -> Option<(u64, u64, u64)> {
- text.split_whitespace().find_map(|token| {
- let token = token.trim_start_matches('v');
- let mut parts = token.splitn(3, '.');
- let major: u64 = parts.next()?.parse().ok()?;
- let minor: u64 = parts.next()?.parse().ok()?;
- let patch: u64 = parts
- .next()
- .map(|rest| {
- rest.chars()
- .take_while(|c| c.is_ascii_digit())
- .collect::<String>()
- })
- .and_then(|digits| digits.parse().ok())
- .unwrap_or(0);
- Some((major, minor, patch))
- })
-}
-
-/// Returns `Ok(None)` when the installed agent satisfies the requirement,
-/// `Ok(Some(warning))` when the version cannot be determined (install
-/// proceeds), and `Err` when the installed agent is too old.
-fn enforce_agent_version(requirement: &AgentVersionRequirement) -> io::Result<Option<String>> {
- let probe = format!("{} {}", requirement.binary, requirement.args.join(" "));
- let output = match std::process::Command::new(requirement.binary)
- .args(requirement.args)
- .output()
- {
- Ok(output) if output.status.success() => output,
- _ => {
- return Ok(Some(format!(
- "{INSTALL_WARNING_PREFIX} could not run `{probe}` to verify the installed version; hooks require {} {} or newer",
- requirement.label, requirement.min_version
- )));
- }
- };
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- let Some(found) = extract_version_triple(&stdout) else {
- return Ok(Some(format!(
- "{INSTALL_WARNING_PREFIX} could not parse the {} version from `{probe}` output; hooks require {} {} or newer",
- requirement.label, requirement.label, requirement.min_version
- )));
- };
- let required = extract_version_triple(requirement.min_version)
- .expect("static min version must be a valid version triple");
-
- if found < required {
- return Err(io::Error::other(format!(
- "{label} {}.{}.{} is too old: herdr hooks require {label} {min} or newer. upgrade {label}, then re-run install",
- found.0,
- found.1,
- found.2,
- label = requirement.label,
- min = requirement.min_version
- )));
- }
- Ok(None)
-}
-
-pub(crate) fn install_target(
- target: crate::api::schema::IntegrationTarget,
-) -> io::Result<Vec<String>> {
- let result = install_target_inner(target);
- let outcome = if result.is_ok() { "ok" } else { "error" };
- crate::logging::integration_action("install", integration_target_label(target), outcome);
- result
-}
-
-fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result<Vec<String>> {
- if !integration_target_supported(target) {
- return Err(io::Error::other(format!(
- "{} integration is not supported on Windows",
- integration_target_label(target)
- )));
- }
-
- let version_warning = match agent_version_requirement(target) {
- Some(requirement) => enforce_agent_version(&requirement)?,
- None => None,
- };
-
- let mut messages = match target {
- crate::api::schema::IntegrationTarget::Pi => {
- let path = install_pi()?;
- vec![format!("installed pi integration to {}", path.display())]
- }
- crate::api::schema::IntegrationTarget::Omp => {
- let installed = install_omp()?;
- let mut messages = Vec::new();
- if installed.removed_legacy_pi_extension {
- messages.push(format!(
- "removed legacy pi integration from omp extension directory at {}",
- installed
- .extension_path
- .with_file_name(PI_EXTENSION_INSTALL_NAME)
- .display()
- ));
- }
- messages.push(format!(
- "installed omp integration to {}",
- installed.extension_path.display()
- ));
- messages
- }
- crate::api::schema::IntegrationTarget::Claude => {
- let installed = install_claude()?;
- vec![
- format!(
- "installed claude integration hook to {}",
- installed.hook_path.display()
- ),
- format!(
- "ensured claude settings at {}",
- installed.settings_path.display()
- ),
- ]
- }
- crate::api::schema::IntegrationTarget::Codex => {
- let installed = install_codex()?;
- vec![
- format!(
- "installed codex integration hook to {}",
- installed.hook_path.display()
- ),
- format!("ensured codex hooks at {}", installed.hooks_path.display()),
- format!(
- "ensured codex config at {}",
- installed.config_path.display()
- ),
- ]
- }
- 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::Devin => {
- let installed = install_devin()?;
- vec![
- format!(
- "installed devin integration hook to {}",
- installed.hook_path.display()
- ),
- format!(
- "ensured devin settings at {}",
- installed.settings_path.display()
- ),
- ]
- }
- crate::api::schema::IntegrationTarget::Kimi => {
- let installed = install_kimi()?;
- vec![
- format!(
- "installed kimi integration hook to {}",
- installed.hook_path.display()
- ),
- format!("ensured kimi config at {}", installed.config_path.display()),
- format!("requires kimi code {KIMI_MIN_VERSION} or newer"),
- ]
- }
- crate::api::schema::IntegrationTarget::Droid => {
- let installed = install_droid()?;
- let mut messages = vec![
- format!(
- "installed droid integration hook to {}",
- installed.hook_path.display()
- ),
- format!(
- "ensured droid hooks at {}",
- installed.settings_path.display()
- ),
- ];
- if installed.updated_legacy_hooks {
- messages.push(format!(
- "removed legacy herdr droid hook entries from {}",
- installed.hooks_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Opencode => {
- let installed = install_opencode()?;
- vec![format!(
- "installed opencode integration plugin to {}",
- installed.plugin_path.display()
- )]
- }
- crate::api::schema::IntegrationTarget::Kilo => {
- let installed = install_kilo()?;
- vec![format!(
- "installed kilo integration plugin to {}",
- installed.plugin_path.display()
- )]
- }
- crate::api::schema::IntegrationTarget::Hermes => {
- let installed = install_hermes()?;
- vec![
- format!(
- "installed hermes integration plugin to {}",
- installed.plugin_dir.display()
- ),
- format!(
- "enabled hermes plugin in {}",
- installed.config_path.display()
- ),
- ]
- }
- crate::api::schema::IntegrationTarget::Qodercli => {
- let installed = install_qodercli()?;
- vec![
- format!(
- "installed qodercli integration hook to {}",
- installed.hook_path.display()
- ),
- format!(
- "ensured qodercli settings at {}",
- installed.settings_path.display()
- ),
- ]
- }
- crate::api::schema::IntegrationTarget::Cursor => {
- let installed = install_cursor()?;
- vec![
- format!(
- "installed cursor integration hook to {}",
- installed.hook_path.display()
- ),
- format!("updated cursor hooks at {}", installed.hooks_path.display()),
- ]
- }
- };
-
- if let Some(warning) = version_warning {
- messages.push(warning);
- }
-
- Ok(messages)
-}
-
-pub(crate) fn uninstall_target(
- target: crate::api::schema::IntegrationTarget,
-) -> io::Result<Vec<String>> {
- let messages = match target {
- crate::api::schema::IntegrationTarget::Pi => {
- let result = uninstall_pi()?;
- if result.removed_extension {
- vec![format!(
- "removed pi integration extension at {}",
- result.extension_path.display()
- )]
- } else {
- vec![format!(
- "no pi integration extension found at {}",
- result.extension_path.display()
- )]
- }
- }
- crate::api::schema::IntegrationTarget::Omp => {
- let result = uninstall_omp()?;
- if result.removed_extension {
- vec![format!(
- "removed omp integration extension at {}",
- result.extension_path.display()
- )]
- } else {
- vec![format!(
- "no omp integration extension found at {}",
- result.extension_path.display()
- )]
- }
- }
- crate::api::schema::IntegrationTarget::Claude => {
- let result = uninstall_claude()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed claude hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no claude hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_settings {
- messages.push(format!(
- "removed herdr claude hook entries from {}",
- result.settings_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr claude hook entries found in {}",
- result.settings_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Codex => {
- let result = uninstall_codex()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed codex hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no codex hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_hooks {
- messages.push(format!(
- "removed herdr codex hook entries from {}",
- result.hooks_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr codex hook entries found in {}",
- result.hooks_path.display()
- ));
- }
- messages.push(format!(
- "left codex config unchanged at {}",
- result.config_path.display()
- ));
- 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::Devin => {
- let result = uninstall_devin()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed devin hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no devin hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_settings {
- messages.push(format!(
- "removed herdr devin hook entries from {}",
- result.settings_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr devin hook entries found in {}",
- result.settings_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Kimi => {
- let result = uninstall_kimi()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed kimi hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no kimi hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_config {
- messages.push(format!(
- "removed herdr kimi hook entries from {}",
- result.config_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr kimi hook entries found in {}",
- result.config_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Droid => {
- let result = uninstall_droid()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed droid hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no droid hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_hooks {
- messages.push(format!(
- "removed legacy herdr droid hook entries from {}",
- result.hooks_path.display()
- ));
- } else {
- messages.push(format!(
- "no legacy herdr droid hook entries found in {}",
- result.hooks_path.display()
- ));
- }
- if result.updated_settings {
- messages.push(format!(
- "removed herdr droid hook entries from {}",
- result.settings_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr droid hook entries found in {}",
- result.settings_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Opencode => {
- let result = uninstall_opencode()?;
- if result.removed_plugin {
- vec![format!(
- "removed opencode integration plugin at {}",
- result.plugin_path.display()
- )]
- } else {
- vec![format!(
- "no opencode integration plugin found at {}",
- result.plugin_path.display()
- )]
- }
- }
- crate::api::schema::IntegrationTarget::Kilo => {
- let result = uninstall_kilo()?;
- if result.removed_plugin {
- vec![format!(
- "removed kilo integration plugin at {}",
- result.plugin_path.display()
- )]
- } else {
- vec![format!(
- "no kilo integration plugin found at {}",
- result.plugin_path.display()
- )]
- }
- }
- crate::api::schema::IntegrationTarget::Hermes => {
- let result = uninstall_hermes()?;
- let mut messages = Vec::new();
- if result.removed_plugin_dir {
- messages.push(format!(
- "removed hermes integration plugin at {}",
- result.plugin_dir.display()
- ));
- } else {
- messages.push(format!(
- "no hermes integration plugin found at {}",
- result.plugin_dir.display()
- ));
- }
- if result.updated_config {
- messages.push(format!(
- "disabled hermes plugin in {}",
- result.config_path.display()
- ));
- } else {
- messages.push(format!(
- "no hermes plugin entry found in {}",
- result.config_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Qodercli => {
- let result = uninstall_qodercli()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed qodercli hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no qodercli hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_settings {
- messages.push(format!(
- "removed herdr qodercli hook entries from {}",
- result.settings_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr qodercli hook entries found in {}",
- result.settings_path.display()
- ));
- }
- messages
- }
- crate::api::schema::IntegrationTarget::Cursor => {
- let result = uninstall_cursor()?;
- let mut messages = Vec::new();
- if result.removed_hook_file {
- messages.push(format!(
- "removed cursor hook at {}",
- result.hook_path.display()
- ));
- } else {
- messages.push(format!(
- "no cursor hook found at {}",
- result.hook_path.display()
- ));
- }
- if result.updated_hooks {
- messages.push(format!(
- "removed herdr cursor hook entries from {}",
- result.hooks_path.display()
- ));
- } else {
- messages.push(format!(
- "no herdr cursor hook entries found in {}",
- result.hooks_path.display()
- ));
- }
- messages
- }
- };
-
- crate::logging::integration_action("uninstall", integration_target_label(target), "ok");
- Ok(messages)
-}
-
-pub(crate) fn integration_target_label(
- target: crate::api::schema::IntegrationTarget,
-) -> &'static str {
- match target {
- crate::api::schema::IntegrationTarget::Pi => "pi",
- 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::Devin => "devin",
- crate::api::schema::IntegrationTarget::Droid => "droid",
- crate::api::schema::IntegrationTarget::Kimi => "kimi",
- crate::api::schema::IntegrationTarget::Opencode => "opencode",
- crate::api::schema::IntegrationTarget::Kilo => "kilo",
- crate::api::schema::IntegrationTarget::Hermes => "hermes",
- crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
- crate::api::schema::IntegrationTarget::Cursor => "cursor",
- }
-}
-
-fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str {
- integration_target_command_names(target)[0]
-}
-
-fn integration_target_command_names(
- target: crate::api::schema::IntegrationTarget,
-) -> &'static [&'static str] {
- match target {
- crate::api::schema::IntegrationTarget::Pi => &["pi"],
- 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::Devin => &["devin"],
- crate::api::schema::IntegrationTarget::Droid => &["droid"],
- crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
- crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
- crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"],
- crate::api::schema::IntegrationTarget::Hermes => &["hermes"],
- crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(),
- crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(),
- }
-}
-
-fn cursor_command_names() -> &'static [&'static str] {
- &["cursor-agent"]
-}
-
-fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool {
- #[cfg(windows)]
- {
- matches!(
- target,
- crate::api::schema::IntegrationTarget::Claude
- | crate::api::schema::IntegrationTarget::Codex
- | crate::api::schema::IntegrationTarget::Copilot
- | crate::api::schema::IntegrationTarget::Droid
- | crate::api::schema::IntegrationTarget::Kimi
- | crate::api::schema::IntegrationTarget::Qodercli
- )
- }
-
- #[cfg(not(windows))]
- {
- let _ = target;
- true
- }
-}
-
-fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool {
- if !integration_target_supported(target) {
- return false;
- }
-
- integration_target_command_names(target)
- .iter()
- .any(|command| command_available(command))
- || integration_target_install_layout_available(target)
-}
-
-#[cfg(windows)]
-fn qodercli_command_names() -> &'static [&'static str] {
- &["qodercli", "qoder", "qoderclicn", "qodercn"]
-}
-
-#[cfg(not(windows))]
-fn qodercli_command_names() -> &'static [&'static str] {
- &["qodercli"]
-}
-
-fn integration_target_install_layout_available(
- target: crate::api::schema::IntegrationTarget,
-) -> bool {
- match target {
- crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(),
- crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(),
- _ => false,
- }
-}
-
-fn command_available(command: &str) -> bool {
- let Some(paths) = std::env::var_os("PATH") else {
- return false;
- };
- std::env::split_paths(&paths).any(|dir| {
- command_path_candidates(&dir, command)
- .into_iter()
- .any(|path| executable_file_exists(&path))
- })
-}
-
-fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
- let base = dir.join(command);
-
- #[cfg(not(windows))]
- {
- vec![base]
- }
-
- #[cfg(windows)]
- {
- if Path::new(command).extension().is_some() {
- return vec![base];
- }
-
- let mut candidates = vec![base];
- for extension in [".exe", ".cmd", ".bat", ".ps1"] {
- candidates.push(dir.join(format!("{command}{extension}")));
- }
- candidates
- }
-}
-
-fn executable_file_exists(path: &Path) -> bool {
- let Ok(metadata) = path.metadata() else {
- return false;
- };
- if !metadata.is_file() {
- return false;
- }
-
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- metadata.permissions().mode() & 0o111 != 0
- }
-
- #[cfg(not(unix))]
- {
- true
- }
-}
-
-fn codex_standalone_binary_available() -> bool {
- let Ok(releases_dir) =
- codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases"))
- else {
- return false;
- };
- let Ok(entries) = fs::read_dir(releases_dir) else {
- return false;
- };
-
- entries.filter_map(Result::ok).any(|entry| {
- executable_file_exists(&entry.path().join("bin").join(codex_executable_name()))
- })
-}
-
-fn codex_executable_name() -> &'static str {
- if cfg!(windows) {
- "codex.exe"
- } else {
- "codex"
- }
-}
-
-fn hermes_install_layout_available() -> bool {
- #[cfg(windows)]
- {
- let Some(local_app_data) =
- std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
- else {
- return false;
- };
- let dir = PathBuf::from(local_app_data).join("hermes");
- [
- dir.join("hermes.exe"),
- dir.join("bin").join("hermes.exe"),
- dir.join("Scripts").join("hermes.exe"),
- ]
- .into_iter()
- .any(|path| executable_file_exists(&path))
- }
-
- #[cfg(not(windows))]
- {
- false
- }
-}
-
-pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> {
- integration_specs()
- .into_iter()
- .filter_map(|(target, path, expected_version)| {
- if !integration_target_supported(target) {
- return None;
- }
- Some(integration_status_at(target, path.ok()?, expected_version))
- })
- .collect()
-}
-
-pub(crate) fn integration_recommendations() -> Vec<IntegrationRecommendation> {
- integration_specs()
- .into_iter()
- .filter_map(|(target, path, expected_version)| {
- if !integration_target_supported(target) {
- return None;
- }
- let path = path.ok()?;
- let status = integration_status_at(target, path.clone(), expected_version);
- Some(IntegrationRecommendation {
- target,
- label: integration_target_label(target),
- command: integration_target_command(target),
- available: integration_target_available(target)
- || status.state != IntegrationStatusKind::NotInstalled,
- path,
- state: status.state,
- })
- })
- .collect()
-}
-
-fn outdated_installed_integrations() -> Vec<IntegrationStatus> {
- installed_integration_statuses()
- .into_iter()
- .filter(|status| status.state == IntegrationStatusKind::Outdated)
- .collect()
-}
-
-fn integration_specs() -> [(
- crate::api::schema::IntegrationTarget,
- io::Result<PathBuf>,
- u32,
-); 13] {
- [
- (
- crate::api::schema::IntegrationTarget::Pi,
- pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)),
- PI_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Omp,
- omp_extension_dir().map(|dir| dir.join(OMP_EXTENSION_INSTALL_NAME)),
- OMP_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Claude,
- claude_dir().map(|dir| dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)),
- CLAUDE_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Codex,
- codex_dir().map(|dir| dir.join(CODEX_HOOK_INSTALL_NAME)),
- 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::Devin,
- devin_dir().map(|dir| dir.join(DEVIN_HOOK_INSTALL_NAME)),
- DEVIN_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Droid,
- droid_dir().map(|dir| dir.join("hooks").join(DROID_HOOK_INSTALL_NAME)),
- DROID_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Kimi,
- kimi_dir().map(|dir| dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)),
- KIMI_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Opencode,
- opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)),
- OPENCODE_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Kilo,
- kilo_dir().map(|dir| dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)),
- KILO_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Hermes,
- hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)),
- HERMES_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Qodercli,
- qodercli_dir().map(|dir| dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)),
- QODERCLI_INTEGRATION_VERSION,
- ),
- (
- crate::api::schema::IntegrationTarget::Cursor,
- cursor_dir().map(|dir| dir.join(CURSOR_HOOK_INSTALL_NAME)),
- CURSOR_INTEGRATION_VERSION,
- ),
- ]
-}
-
-pub(crate) fn integration_update_instructions(
- targets: &[crate::api::schema::IntegrationTarget],
-) -> String {
- let commands: Vec<String> = targets
- .iter()
- .map(|target| {
- format!(
- "`herdr integration install {}`",
- integration_target_label(*target)
- )
- })
- .collect();
-
- match commands.as_slice() {
- [] => String::new(),
- [command] => format!("run {command}"),
- [rest @ .., last] => format!("run {} and {last}", rest.join(", ")),
- }
-}
-
-pub(crate) fn print_outdated_update_notice() -> bool {
- let outdated = outdated_installed_integrations();
- if outdated.is_empty() {
- return false;
- }
-
- let targets = outdated
- .iter()
- .map(|integration| integration.target)
- .collect::<Vec<_>>();
- eprintln!(
- "installed herdr integrations need updating; {}.",
- integration_update_instructions(&targets).replace('`', "")
- );
- true
-}
-
-fn integration_status_at(
- target: crate::api::schema::IntegrationTarget,
- path: PathBuf,
- expected_version: u32,
-) -> IntegrationStatus {
- if !path.is_file() {
- return IntegrationStatus {
- target,
- path,
- state: IntegrationStatusKind::NotInstalled,
- installed_version: None,
- expected_version,
- };
- }
-
- let installed_version = fs::read_to_string(&path)
- .ok()
- .and_then(|content| parse_integration_version(&content));
- let state = if installed_version.is_some_and(|version| version >= expected_version) {
- IntegrationStatusKind::Current
- } else {
- IntegrationStatusKind::Outdated
- };
-
- IntegrationStatus {
- target,
- path,
- state,
- installed_version,
- expected_version,
- }
-}
-
-fn parse_integration_version(content: &str) -> Option<u32> {
- content.lines().find_map(|line| {
- let marker_line = line
- .trim()
- .trim_start_matches('/')
- .trim_start_matches('#')
- .trim();
- marker_line
- .strip_prefix(INTEGRATION_VERSION_MARKER)?
- .trim()
- .parse()
- .ok()
- })
-}
-
-pub(crate) fn install_pi() -> io::Result<PathBuf> {
- let dir = pi_extension_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "pi extension directory not found at {}. install pi and create the extensions directory first",
- dir.display()
- )));
- }
-
- let path = dir.join(PI_EXTENSION_INSTALL_NAME);
- fs::write(&path, PI_EXTENSION_ASSET)?;
- Ok(path)
-}
-
-pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> {
- let dir = omp_extension_dir()?;
- if !dir.is_dir() {
- if dir.parent().is_some_and(|parent| parent.is_dir()) {
- fs::create_dir_all(&dir)?;
- } else {
- return Err(io::Error::other(format!(
- "omp extension directory not found at {}. install omp and create the extensions directory first",
- dir.display()
- )));
- }
- }
-
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "omp extension directory not found at {}. install omp and create the extensions directory first",
- dir.display()
- )));
- }
-
- let removed_legacy_pi_extension = remove_legacy_pi_extension_from_omp_dir(&dir)?;
- let extension_path = dir.join(OMP_EXTENSION_INSTALL_NAME);
- fs::write(&extension_path, OMP_EXTENSION_ASSET)?;
- Ok(OmpInstallPaths {
- extension_path,
- removed_legacy_pi_extension,
- })
-}
-
-fn remove_legacy_pi_extension_from_omp_dir(dir: &Path) -> io::Result<bool> {
- let legacy_path = dir.join(PI_EXTENSION_INSTALL_NAME);
- if !legacy_path.is_file() {
- return Ok(false);
- }
-
- let content = fs::read_to_string(&legacy_path)?;
- if content.contains("HERDR_INTEGRATION_ID=pi") {
- fs::remove_file(legacy_path)?;
- return Ok(true);
- }
-
- Ok(false)
-}
-
-pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
- let dir = claude_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "claude directory not found at {}. install claude code first",
- dir.display()
- )));
- }
-
- let hooks_dir = dir.join("hooks");
- fs::create_dir_all(&hooks_dir)?;
-
- let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
- fs::write(&hook_path, CLAUDE_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,
- "claude settings",
- "claude settings hooks",
- )?;
- 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",
- 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)?)?;
-
- Ok(ClaudeInstallPaths {
- hook_path,
- settings_path,
- })
-}
-
-pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
- let dir = codex_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "codex config directory not found at {}. install codex first",
- dir.display()
- )));
- }
-
- let hook_path = dir.join(CODEX_HOOK_INSTALL_NAME);
- fs::write(&hook_path, CODEX_HOOK_ASSET)?;
- make_executable(&hook_path)?;
-
- let hooks_path = dir.join("hooks.json");
- let mut hooks_file = if hooks_path.is_file() {
- serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| {
- io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
- })?
- } else {
- json!({})
- };
-
- let hooks = ensure_hooks_object(
- &mut hooks_file,
- &hooks_path,
- "codex hooks file",
- "codex hooks file hooks",
- )?;
- 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",
- 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)?)?;
-
- let config_path = dir.join("config.toml");
- let existing_config = if config_path.is_file() {
- fs::read_to_string(&config_path)?
- } else {
- String::new()
- };
- let new_config = build_codex_config_with_hooks(&existing_config);
- if new_config != existing_config {
- fs::write(&config_path, new_config)?;
- }
-
- Ok(CodexInstallPaths {
- hook_path,
- hooks_path,
- config_path,
- })
-}
-
-pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
- let dir = kimi_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "kimi code config directory not found at {}. install kimi code first",
- dir.display()
- )));
- }
-
- let hooks_dir = dir.join("hooks");
- fs::create_dir_all(&hooks_dir)?;
-
- let hook_path = hooks_dir.join(KIMI_HOOK_INSTALL_NAME);
- fs::write(&hook_path, KIMI_HOOK_ASSET)?;
- make_executable(&hook_path)?;
-
- let config_path = dir.join("config.toml");
- let existing_config = if config_path.is_file() {
- fs::read_to_string(&config_path)?
- } else {
- String::new()
- };
- let new_config = build_kimi_config_with_hooks(&existing_config, &hook_path);
- if new_config != existing_config {
- fs::write(&config_path, new_config)?;
- }
- remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(KimiInstallPaths {
- hook_path,
- config_path,
- })
-}
-
-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 = hook_command(&hook_path, None);
- for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
- remove_direct_hook_commands(hooks, event, &hook_path, None)?;
- }
- for event in COPILOT_HOOK_EVENTS {
- remove_direct_hook_commands(hooks, event, &hook_path, None)?;
- }
- for event in COPILOT_HOOK_EVENTS {
- ensure_direct_command_hook(hooks, event, command.clone(), 10, None)?;
- }
- remove_legacy_bash_hook_file(&hook_path)?;
-
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
-
- Ok(CopilotInstallPaths {
- hook_path,
- settings_path,
- })
-}
-
-pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> {
- let dir = devin_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "devin config directory not found at {}. install devin cli first",
- dir.display()
- )));
- }
-
- let hook_path = dir.join(DEVIN_HOOK_INSTALL_NAME);
- fs::write(&hook_path, DEVIN_HOOK_ASSET)?;
- make_executable(&hook_path)?;
-
- let settings_path = dir.join("config.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,
- "devin settings",
- "devin settings hooks",
- )?;
- for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DEVIN_HOOK_EVENTS {
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DEVIN_HOOK_EVENTS {
- ensure_command_hook(
- hooks,
- event,
- hook_command(&hook_path, Some(action)),
- 10,
- None,
- )?;
- }
- remove_legacy_bash_hook_file(&hook_path)?;
-
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
-
- Ok(DevinInstallPaths {
- hook_path,
- settings_path,
- })
-}
-
-pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
- let dir = droid_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "droid config directory not found at {}. install droid first",
- dir.display()
- )));
- }
-
- let hooks_dir = dir.join("hooks");
- fs::create_dir_all(&hooks_dir)?;
-
- let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME);
- fs::write(&hook_path, DROID_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,
- "droid settings",
- "droid settings hooks",
- )?;
- remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
- for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DROID_HOOK_EVENTS {
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DROID_HOOK_EVENTS {
- ensure_command_hook(
- hooks,
- event,
- hook_command(&hook_path, Some(action)),
- 10,
- None,
- )?;
- }
- remove_legacy_bash_hook_file(&hook_path)?;
-
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
-
- let hooks_path = dir.join("hooks.json");
- let mut updated_legacy_hooks = false;
- if hooks_path.is_file() {
- let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
- .map_err(|err| {
- io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
- })?;
- if let Some(hooks) = hooks_object_if_present(
- &mut hooks_file,
- &hooks_path,
- "droid hooks file",
- "droid hooks file hooks",
- )? {
- updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
- for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
- updated_legacy_hooks |=
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DROID_HOOK_EVENTS {
- updated_legacy_hooks |=
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- }
- if updated_legacy_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
- }
- }
-
- Ok(DroidInstallPaths {
- hook_path,
- hooks_path,
- settings_path,
- updated_legacy_hooks,
- })
-}
-
-pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
- let dir = opencode_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "opencode config directory not found at {}. install opencode first",
- dir.display()
- )));
- }
-
- let plugins_dir = dir.join("plugins");
- fs::create_dir_all(&plugins_dir)?;
-
- let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
- fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?;
-
- Ok(OpenCodeInstallPaths { plugin_path })
-}
-
-pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> {
- let dir = kilo_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "kilo config directory not found at {}. install kilo first",
- dir.display()
- )));
- }
-
- let plugins_dir = dir.join("plugin");
- fs::create_dir_all(&plugins_dir)?;
-
- let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME);
- fs::write(&plugin_path, KILO_PLUGIN_ASSET)?;
-
- Ok(KiloInstallPaths { plugin_path })
-}
-
-pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> {
- let dir = hermes_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "hermes config directory not found at {}. install hermes agent first",
- dir.display()
- )));
- }
-
- let plugin_dir = hermes_plugin_dir()?;
- fs::create_dir_all(&plugin_dir)?;
- fs::write(
- plugin_dir.join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME),
- HERMES_PLUGIN_MANIFEST_ASSET,
- )?;
- fs::write(
- plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
- HERMES_PLUGIN_INIT_ASSET,
- )?;
-
- let config_path = dir.join("config.yaml");
- let existing_config = if config_path.is_file() {
- fs::read_to_string(&config_path)?
- } else {
- String::new()
- };
- let new_config = ensure_hermes_plugin_enabled(&existing_config);
- if new_config != existing_config {
- fs::write(&config_path, new_config)?;
- }
-
- Ok(HermesInstallPaths {
- plugin_dir,
- config_path,
- })
-}
-
-pub(crate) fn uninstall_pi() -> io::Result<PiUninstallResult> {
- let extension_path = pi_extension_dir()?.join(PI_EXTENSION_INSTALL_NAME);
- let removed_extension = remove_file_if_exists(&extension_path)?;
-
- Ok(PiUninstallResult {
- extension_path,
- removed_extension,
- })
-}
-
-pub(crate) fn uninstall_omp() -> io::Result<OmpUninstallResult> {
- let extension_path = omp_extension_dir()?.join(OMP_EXTENSION_INSTALL_NAME);
- let removed_extension = remove_file_if_exists(&extension_path)?;
-
- Ok(OmpUninstallResult {
- extension_path,
- removed_extension,
- })
-}
-
-pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
- let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME);
- let settings_path = claude_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,
- "claude settings",
- "claude settings hooks",
- )? {
- updated_settings |=
- 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 {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(ClaudeUninstallResult {
- hook_path,
- settings_path,
- removed_hook_file,
- updated_settings,
- })
-}
-
-pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
- let codex_dir = codex_dir()?;
- let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
- let hooks_path = codex_dir.join("hooks.json");
- let config_path = codex_dir.join("config.toml");
- let mut updated_hooks = false;
-
- if hooks_path.is_file() {
- let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
- .map_err(|err| {
- io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
- })?;
-
- if let Some(hooks) = hooks_object_if_present(
- &mut hooks_file,
- &hooks_path,
- "codex hooks file",
- "codex hooks file hooks",
- )? {
- 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_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
- updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
- }
-
- if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(CodexUninstallResult {
- hook_path,
- hooks_path,
- config_path,
- removed_hook_file,
- updated_hooks,
- })
-}
-
-pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
- let kimi_dir = kimi_dir()?;
- let hook_path = kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME);
- let config_path = kimi_dir.join("config.toml");
- let mut updated_config = false;
-
- if config_path.is_file() {
- let existing_config = fs::read_to_string(&config_path)?;
- let new_config = remove_kimi_config_block(&existing_config);
- if new_config != existing_config {
- fs::write(&config_path, new_config)?;
- updated_config = true;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(KimiUninstallResult {
- hook_path,
- config_path,
- removed_hook_file,
- updated_config,
- })
-}
-
-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",
- )? {
- for event in COPILOT_HOOK_EVENTS {
- updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?;
- }
- for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
- updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?;
- }
- }
-
- if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(CopilotUninstallResult {
- hook_path,
- settings_path,
- removed_hook_file,
- updated_settings,
- })
-}
-
-pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> {
- let devin_dir = devin_dir()?;
- let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
- let settings_path = devin_dir.join("config.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,
- "devin settings",
- "devin settings hooks",
- )? {
- for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
- updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DEVIN_HOOK_EVENTS {
- updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- }
-
- if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(DevinUninstallResult {
- hook_path,
- settings_path,
- removed_hook_file,
- updated_settings,
- })
-}
-
-pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
- let droid_dir = droid_dir()?;
- let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME);
- let hooks_path = droid_dir.join("hooks.json");
- let settings_path = droid_dir.join("settings.json");
- let mut updated_hooks = false;
- let mut updated_settings = false;
- if hooks_path.is_file() {
- let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
- .map_err(|err| {
- io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
- })?;
-
- if let Some(hooks) = hooks_object_if_present(
- &mut hooks_file,
- &hooks_path,
- "droid hooks file",
- "droid hooks file hooks",
- )? {
- updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
- for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
- updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DROID_HOOK_EVENTS {
- updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- }
-
- if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
- }
- }
-
- 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,
- "droid settings",
- "droid settings hooks",
- )? {
- updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
- for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
- updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in DROID_HOOK_EVENTS {
- updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- }
-
- if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(DroidUninstallResult {
- hook_path,
- hooks_path,
- settings_path,
- removed_hook_file,
- updated_hooks,
- updated_settings,
- })
-}
-
-pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
- let plugin_path = opencode_dir()?
- .join("plugins")
- .join(OPENCODE_PLUGIN_INSTALL_NAME);
- let removed_plugin = remove_file_if_exists(&plugin_path)?;
-
- Ok(OpenCodeUninstallResult {
- plugin_path,
- removed_plugin,
- })
-}
-
-pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> {
- let plugin_path = kilo_dir()?.join("plugin").join(KILO_PLUGIN_INSTALL_NAME);
- let removed_plugin = remove_file_if_exists(&plugin_path)?;
-
- Ok(KiloUninstallResult {
- plugin_path,
- removed_plugin,
- })
-}
-
-pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
- let dir = hermes_dir()?;
- let plugin_dir = hermes_plugin_dir()?;
- let config_path = dir.join("config.yaml");
-
- let removed_plugin_dir = remove_dir_all_if_exists(&plugin_dir)?;
- let mut updated_config = false;
- if config_path.is_file() {
- let existing_config = fs::read_to_string(&config_path)?;
- let new_config = remove_hermes_plugin_enabled(&existing_config);
- if new_config != existing_config {
- fs::write(&config_path, new_config)?;
- updated_config = true;
- }
- }
-
- Ok(HermesUninstallResult {
- plugin_dir,
- config_path,
- removed_plugin_dir,
- updated_config,
- })
-}
-
-pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
- let dir = qodercli_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "qodercli config directory not found at {}. install qodercli first",
- dir.display()
- )));
- }
-
- let hooks_dir = dir.join("hooks");
- fs::create_dir_all(&hooks_dir)?;
-
- let hook_path = hooks_dir.join(QODERCLI_HOOK_INSTALL_NAME);
- fs::write(&hook_path, QODERCLI_HOOK_ASSET)?;
- make_executable(&hook_path)?;
-
- // Register the hook in ~/.qoder/settings.json. The schema mirrors claude
- // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level
- // `hooks` object keyed by event name, each entry holding a matcher + a
- // list of `{type: "command", command, timeout?}` invocations. The hook
- // script reads the event payload from stdin via `hook_event_name`.
- 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,
- "qodercli settings",
- "qodercli settings hooks",
- )?;
- for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS {
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in QODERCLI_HOOK_EVENTS {
- remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in QODERCLI_HOOK_EVENTS {
- ensure_command_hook(
- hooks,
- event,
- hook_command(&hook_path, Some(action)),
- 10,
- Some("*"),
- )?;
- }
- remove_legacy_bash_hook_file(&hook_path)?;
-
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
-
- Ok(QodercliInstallPaths {
- hook_path,
- settings_path,
- })
-}
-
-pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
- let dir = cursor_dir()?;
- if !dir.is_dir() {
- return Err(io::Error::other(format!(
- "cursor config directory not found at {}. install cursor agent cli first",
- dir.display()
- )));
- }
-
- let hook_path = dir.join(CURSOR_HOOK_INSTALL_NAME);
- fs::write(&hook_path, CURSOR_HOOK_ASSET)?;
- make_executable(&hook_path)?;
-
- let hooks_path = dir.join("hooks.json");
- let mut hooks_file = if hooks_path.is_file() {
- serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| {
- io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
- })?
- } else {
- json!({ "version": 1 })
- };
-
- if hooks_file.get("version").is_none() {
- hooks_file
- .as_object_mut()
- .ok_or_else(|| {
- io::Error::other(format!(
- "cursor hooks file at {} must be a JSON object",
- hooks_path.display()
- ))
- })?
- .insert("version".to_string(), json!(1));
- }
-
- let hooks = ensure_hooks_object(
- &mut hooks_file,
- &hooks_path,
- "cursor hooks file",
- "cursor hooks file hooks",
- )?;
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- let session_command = format!("bash {quoted_hook_path} session");
- remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?;
- remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?;
- remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?;
- remove_simple_command_hook(hooks, "stop", &session_command)?;
- remove_simple_command_hook(hooks, "sessionEnd", &session_command)?;
- ensure_simple_command_hook(hooks, "sessionStart", session_command)?;
-
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
-
- Ok(CursorInstallPaths {
- hook_path,
- hooks_path,
- })
-}
-
-pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
- let hook_path = qodercli_dir()?
- .join("hooks")
- .join(QODERCLI_HOOK_INSTALL_NAME);
- let settings_path = qodercli_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,
- "qodercli settings",
- "qodercli settings hooks",
- )? {
- for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS {
- updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- for (event, action) in QODERCLI_HOOK_EVENTS {
- updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
- }
- }
-
- if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
- }
- }
-
- let removed_hook_file =
- remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
-
- Ok(QodercliUninstallResult {
- hook_path,
- settings_path,
- removed_hook_file,
- updated_settings,
- })
-}
-
-pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
- let cursor_home = cursor_dir()?;
- let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME);
- let hooks_path = cursor_home.join("hooks.json");
- let mut updated_hooks = false;
-
- if hooks_path.is_file() {
- let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
- .map_err(|err| {
- io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
- })?;
-
- if let Some(hooks) = hooks_object_if_present(
- &mut hooks_file,
- &hooks_path,
- "cursor hooks file",
- "cursor hooks file hooks",
- )? {
- let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- let session_command = format!("bash {quoted_hook_path} session");
- updated_hooks |= remove_simple_command_hook(hooks, "sessionStart", &session_command)?;
- updated_hooks |=
- remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?;
- updated_hooks |=
- remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?;
- updated_hooks |=
- remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?;
- updated_hooks |= remove_simple_command_hook(hooks, "stop", &session_command)?;
- updated_hooks |= remove_simple_command_hook(hooks, "sessionEnd", &session_command)?;
- }
-
- if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
- }
- }
-
- let removed_hook_file = remove_file_if_exists(&hook_path)?;
-
- Ok(CursorUninstallResult {
- hook_path,
- hooks_path,
- removed_hook_file,
- updated_hooks,
- })
-}
-
-fn ensure_hooks_object<'a>(
- settings: &'a mut Value,
- settings_path: &Path,
- root_description: &str,
- hooks_description: &str,
-) -> io::Result<&'a mut Map<String, Value>> {
- let root = settings.as_object_mut().ok_or_else(|| {
- io::Error::other(format!(
- "{root_description} at {} must be a JSON object",
- settings_path.display()
- ))
- })?;
-
- let hooks = root.entry("hooks").or_insert_with(|| json!({}));
- hooks.as_object_mut().ok_or_else(|| {
- io::Error::other(format!(
- "{hooks_description} at {} must be a JSON object",
- settings_path.display()
- ))
- })
-}
-
-fn hooks_object_if_present<'a>(
- settings: &'a mut Value,
- settings_path: &Path,
- root_description: &str,
- hooks_description: &str,
-) -> io::Result<Option<&'a mut Map<String, Value>>> {
- let root = settings.as_object_mut().ok_or_else(|| {
- io::Error::other(format!(
- "{root_description} at {} must be a JSON object",
- settings_path.display()
- ))
- })?;
-
- let Some(hooks) = root.get_mut("hooks") else {
- return Ok(None);
- };
-
- hooks.as_object_mut().map(Some).ok_or_else(|| {
- io::Error::other(format!(
- "{hooks_description} at {} must be a JSON object",
- settings_path.display()
- ))
- })
-}
-
-fn ensure_command_hook(
- hooks: &mut Map<String, Value>,
- event: &str,
- command: String,
- timeout: 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")))?;
-
- let already_installed = entries.iter().any(|entry| {
- entry
- .get("hooks")
- .and_then(Value::as_array)
- .is_some_and(|hook_entries| {
- hook_entries.iter().any(|hook| {
- hook.get("type").and_then(Value::as_str) == Some("command")
- && hook.get("command").and_then(Value::as_str) == Some(command.as_str())
- })
- })
- });
- if already_installed {
- return Ok(());
- }
-
- let mut entry = Map::new();
- if let Some(matcher) = matcher {
- entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
- }
- entry.insert(
- "hooks".to_string(),
- json!([
- {
- "type": "command",
- "command": command,
- "timeout": timeout,
- }
- ]),
- );
-
- entries.push(Value::Object(entry));
- Ok(())
-}
-
-// Claude and Codex use nested hook groups:
-// { "matcher": "...", "hooks": [{ "type": "command", ... }] }
-// Copilot uses the flatter settings shape:
-// { "type": "command", "matcher": "...", "bash": "...", ... }
-// 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")))?;
-
- let command_field = direct_command_field();
- if let Some(entry) = entries.iter_mut().find(|entry| {
- entry.get("type").and_then(Value::as_str) == Some("command")
- && is_matching_direct_command_entry(entry, command.as_str())
- }) {
- let Some(entry_object) = entry.as_object_mut() else {
- return Ok(());
- };
- entry_object.remove("command");
- entry_object.remove("bash");
- entry_object.remove("powershell");
- entry_object.insert(command_field.to_string(), Value::String(command.clone()));
- 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_field.to_string(), Value::String(command));
- entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
- entries.push(Value::Object(entry));
- Ok(())
-}
-
-fn direct_command_field() -> &'static str {
- if cfg!(windows) {
- "powershell"
- } else {
- "bash"
- }
-}
-
-fn is_matching_direct_command_entry(entry: &Value, command: &str) -> bool {
- entry.get("command").and_then(Value::as_str) == Some(command)
- || entry.get("bash").and_then(Value::as_str) == Some(command)
- || entry.get("powershell").and_then(Value::as_str) == Some(command)
-}
-
-fn remove_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 mut removed = false;
- entries.retain_mut(|entry| {
- let Some(entry_object) = entry.as_object_mut() else {
- return true;
- };
- let Some(hook_entries) = entry_object.get_mut("hooks") else {
- return true;
- };
- let Some(hook_entries) = hook_entries.as_array_mut() else {
- return true;
- };
-
- let before = hook_entries.len();
- hook_entries.retain(|hook| !is_matching_command_hook(hook, command));
- if hook_entries.len() != before {
- removed = true;
- }
-
- !hook_entries.is_empty()
- });
-
- let remove_event = entries.is_empty();
- if remove_event {
- hooks.remove(event);
- }
-
- 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")
- && is_matching_direct_command_entry(entry, command))
- });
- let removed = entries.len() != before;
- if entries.is_empty() {
- hooks.remove(event);
- }
- Ok(removed)
-}
-
-// Cursor hooks.json uses the minimal shape `{ "command": "..." }` documented at
-// https://cursor.com/docs/hooks. Keep this separate from the nested codex and
-// flat copilot helpers so install/uninstall does not rewrite unrelated hooks.
-fn ensure_simple_command_hook(
- hooks: &mut Map<String, Value>,
- event: &str,
- command: String,
-) -> 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 entries
- .iter()
- .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command.as_str()))
- {
- return Ok(());
- }
-
- entries.push(json!({ "command": command }));
- Ok(())
-}
-
-fn remove_simple_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("command").and_then(Value::as_str) != Some(command));
- let removed = entries.len() != before;
- if entries.is_empty() {
- hooks.remove(event);
- }
- 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)
-}
-
-fn remove_file_if_exists(path: &Path) -> io::Result<bool> {
- match fs::remove_file(path) {
- Ok(()) => Ok(true),
- Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
- Err(err) => Err(err),
- }
-}
-
-#[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),
- Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
- Err(err) => Err(err),
- }
-}
-
-fn ensure_hermes_plugin_enabled(content: &str) -> String {
- update_hermes_enabled_plugin(content, true)
-}
-
-fn remove_hermes_plugin_enabled(content: &str) -> String {
- update_hermes_enabled_plugin(content, false)
-}
-
-fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> String {
- let trailing_newline = content.ends_with('\n');
- let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
- let Some(plugins_index) = top_level_yaml_key_index(&lines, "plugins") else {
- if !enabled {
- return content.to_string();
- }
- let mut result = content.trim_end_matches('\n').to_string();
- if !result.is_empty() {
- result.push('\n');
- }
- result.push_str("plugins:\n enabled:\n - herdr-agent-state\n");
- return result;
- };
-
- let plugins_end =
- next_top_level_yaml_key_index(&lines, plugins_index + 1).unwrap_or(lines.len());
- let plugins_inline_items = yaml_key_value_at_indent(&lines[plugins_index], 0, "plugins")
- .and_then(yaml_flow_sequence_items);
- let enabled_index = lines[plugins_index + 1..plugins_end]
- .iter()
- .position(|line| yaml_key_at_indent(line, 2) == Some("enabled"))
- .map(|offset| plugins_index + 1 + offset);
- let flat_list_start = lines[plugins_index + 1..plugins_end]
- .iter()
- .position(|line| yaml_list_item_value_at_indent(line, 2).is_some())
- .map(|offset| plugins_index + 1 + offset);
-
- if let Some(enabled_index) = enabled_index {
- let line = lines[enabled_index].trim();
- if line == "enabled: []" || line == "enabled: [] # herdr" {
- if enabled {
- lines[enabled_index] = " enabled:".to_string();
- lines.insert(enabled_index + 1, " - herdr-agent-state".to_string());
- }
- return join_yaml_lines(lines, trailing_newline);
- }
-
- let list_start = enabled_index + 1;
- let list_end = lines[list_start..plugins_end]
- .iter()
- .position(|line| {
- yaml_indent(line).is_some_and(|indent| indent <= 2) && yaml_key_name(line).is_some()
- })
- .map(|offset| list_start + offset)
- .unwrap_or(plugins_end);
- let existing_item_index = lines[list_start..list_end]
- .iter()
- .position(|line| yaml_list_item_matches(line, HERMES_PLUGIN_INSTALL_NAME))
- .map(|offset| list_start + offset);
-
- match (enabled, existing_item_index) {
- (true, Some(_)) | (false, None) => return content.to_string(),
- (true, None) => lines.insert(list_start, " - herdr-agent-state".to_string()),
- (false, Some(index)) => {
- lines.remove(index);
- }
- }
- return join_yaml_lines(lines, trailing_newline);
- }
-
- if let Some(mut items) = plugins_inline_items {
- let existing_item_index = items
- .iter()
- .position(|item| item == HERMES_PLUGIN_INSTALL_NAME);
-
- match (enabled, existing_item_index) {
- (true, Some(_)) | (false, None) => return content.to_string(),
- (true, None) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()),
- (false, Some(index)) => {
- items.remove(index);
- }
- }
-
- let replacement = hermes_flat_plugin_lines(&items);
- lines.splice(plugins_index..plugins_end, replacement);
- return join_yaml_lines(lines, trailing_newline);
- }
-
- if let Some(flat_list_start) = flat_list_start {
- let existing_item_index = lines[plugins_index + 1..plugins_end]
- .iter()
- .position(|line| yaml_list_item_matches_at_indent(line, 2, HERMES_PLUGIN_INSTALL_NAME))
- .map(|offset| plugins_index + 1 + offset);
-
- match (enabled, existing_item_index) {
- (true, Some(_)) | (false, None) => return content.to_string(),
- (true, None) => lines.insert(flat_list_start, " - herdr-agent-state".to_string()),
- (false, Some(index)) => {
- lines.remove(index);
- }
- }
- return join_yaml_lines(lines, trailing_newline);
- }
-
- if enabled {
- lines.insert(plugins_index + 1, " enabled:".to_string());
- lines.insert(plugins_index + 2, " - herdr-agent-state".to_string());
- return join_yaml_lines(lines, trailing_newline);
- }
-
- content.to_string()
-}
-
-fn hermes_flat_plugin_lines(items: &[String]) -> Vec<String> {
- if items.is_empty() {
- return vec!["plugins: []".to_string()];
- }
-
- let mut lines = vec!["plugins:".to_string()];
- lines.extend(items.iter().map(|item| format!(" - {item}")));
- lines
-}
-
-fn top_level_yaml_key_index(lines: &[String], key: &str) -> Option<usize> {
- lines
- .iter()
- .position(|line| yaml_key_at_indent(line, 0) == Some(key))
-}
-
-fn next_top_level_yaml_key_index(lines: &[String], start: usize) -> Option<usize> {
- lines[start..]
- .iter()
- .position(|line| yaml_indent(line) == Some(0) && yaml_key_name(line).is_some())
- .map(|offset| start + offset)
-}
-
-fn yaml_key_at_indent(line: &str, indent: usize) -> Option<&str> {
- if yaml_indent(line)? != indent {
- return None;
- }
- yaml_key_name(line)
-}
-
-fn yaml_key_value_at_indent<'a>(line: &'a str, indent: usize, key: &str) -> Option<&'a str> {
- if yaml_indent(line)? != indent {
- return None;
- }
- let trimmed = line.trim_start();
- if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
- return None;
- }
- let (line_key, value) = trimmed.split_once(':')?;
- (line_key.trim() == key).then_some(value.trim())
-}
-
-fn yaml_key_name(line: &str) -> Option<&str> {
- let trimmed = line.trim_start();
- if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
- return None;
- }
- let (key, _) = trimmed.split_once(':')?;
- let key = key.trim();
- (!key.is_empty()).then_some(key)
-}
-
-fn yaml_indent(line: &str) -> Option<usize> {
- let trimmed = line.trim_start();
- if trimmed.is_empty() || trimmed.starts_with('#') {
- return None;
- }
- Some(line.len() - trimmed.len())
-}
-
-fn yaml_list_item_value(line: &str) -> Option<&str> {
- line.trim().strip_prefix("- ").map(str::trim)
-}
-
-fn yaml_list_item_matches(line: &str, value: &str) -> bool {
- yaml_list_item_value(line).is_some_and(|item| yaml_scalar_value(item) == value)
-}
-
-fn yaml_list_item_value_at_indent(line: &str, indent: usize) -> Option<&str> {
- if yaml_indent(line)? != indent {
- return None;
- }
- yaml_list_item_value(line)
-}
-
-fn yaml_list_item_matches_at_indent(line: &str, indent: usize, value: &str) -> bool {
- yaml_list_item_value_at_indent(line, indent)
- .is_some_and(|item| yaml_scalar_value(item) == value)
-}
-
-fn yaml_flow_sequence_items(value: &str) -> Option<Vec<String>> {
- let value = strip_yaml_inline_comment(value).trim();
- let inner = value.strip_prefix('[')?.strip_suffix(']')?.trim();
- if inner.is_empty() {
- return Some(Vec::new());
- }
-
- let mut items = Vec::new();
- let mut current = String::new();
- let mut quote = None;
- let mut escaped = false;
-
- for ch in inner.chars() {
- if let Some(quote_char) = quote {
- current.push(ch);
- if quote_char == '"' && ch == '\\' && !escaped {
- escaped = true;
- continue;
- }
- if ch == quote_char && !escaped {
- quote = None;
- }
- escaped = false;
- continue;
- }
-
- match ch {
- '"' | '\'' => {
- quote = Some(ch);
- current.push(ch);
- }
- ',' => {
- items.push(yaml_scalar_value(&current));
- current.clear();
- }
- _ => current.push(ch),
- }
- }
-
- if quote.is_some() {
- return None;
- }
-
- items.push(yaml_scalar_value(&current));
- Some(items)
-}
-
-fn yaml_scalar_value(value: &str) -> String {
- let value = strip_yaml_inline_comment(value).trim();
- if value.len() >= 2 {
- let bytes = value.as_bytes();
- let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
- || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'');
- if quoted {
- return value[1..value.len() - 1].to_string();
- }
- }
- value.to_string()
-}
-
-fn strip_yaml_inline_comment(value: &str) -> &str {
- let mut quote = None;
- let mut escaped = false;
-
- for (index, ch) in value.char_indices() {
- if let Some(quote_char) = quote {
- if quote_char == '"' && ch == '\\' && !escaped {
- escaped = true;
- continue;
- }
- if ch == quote_char && !escaped {
- quote = None;
- }
- escaped = false;
- continue;
- }
-
- match ch {
- '"' | '\'' => quote = Some(ch),
- '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
- return value[..index].trim_end();
- }
- _ => {}
- }
- }
-
- value
-}
-
-fn join_yaml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
- let mut result = lines.join("\n");
- if trailing_newline || result.is_empty() {
- result.push('\n');
- }
- result
-}
-
-fn build_codex_config_with_hooks(content: &str) -> String {
- let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
- let trailing_newline = content.ends_with('\n');
- let mut in_top_level_features = false;
- let mut features_header_index = None;
- let mut hooks_index = None;
- let mut deprecated_hooks_indexes = Vec::new();
-
- for (index, line) in lines.iter().enumerate() {
- if let Some(header) = toml_table_header(line) {
- in_top_level_features = header == "[features]";
- if in_top_level_features && features_header_index.is_none() {
- features_header_index = Some(index);
- }
- continue;
- }
-
- if !in_top_level_features {
- continue;
- }
-
- if is_toml_key(line, "codex_hooks") {
- deprecated_hooks_indexes.push(index);
- } else if is_toml_key(line, "hooks") {
- hooks_index = Some(index);
- }
- }
-
- if let Some(index) = hooks_index {
- lines[index] = "hooks = true".to_string();
- }
-
- for index in deprecated_hooks_indexes.into_iter().rev() {
- lines.remove(index);
- }
-
- if hooks_index.is_none() {
- if let Some(index) = features_header_index {
- lines.insert(index + 1, "hooks = true".to_string());
- return join_toml_lines(lines, trailing_newline);
- }
-
- let mut result = content.trim_end_matches('\n').to_string();
- if !result.is_empty() {
- result.push('\n');
- result.push('\n');
- }
- result.push_str("[features]\nhooks = true\n");
- return result;
- }
-
- join_toml_lines(lines, trailing_newline)
-}
-
-fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String {
- let mut result = remove_kimi_config_block(content)
- .trim_end_matches('\n')
- .to_string();
- if !result.is_empty() {
- result.push('\n');
- result.push('\n');
- }
-
- result.push_str(KIMI_CONFIG_BLOCK_BEGIN);
- result.push('\n');
- for (event, action) in KIMI_HOOK_EVENTS {
- result.push_str(&kimi_hook_table(event, hook_path, action));
- }
- result.push_str(KIMI_CONFIG_BLOCK_END);
- result.push('\n');
- result
-}
-
-fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String {
- let command = hook_command(hook_path, Some(action));
- format!(
- "[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n",
- toml_basic_string(event),
- toml_basic_string(&command)
- )
-}
-
-fn remove_kimi_config_block(content: &str) -> String {
- let trailing_newline = content.ends_with('\n');
- let mut lines = Vec::new();
- let mut in_block = false;
- let mut removed_block = false;
-
- for line in content.lines() {
- if line.trim() == KIMI_CONFIG_BLOCK_BEGIN {
- in_block = true;
- removed_block = true;
- continue;
- }
- if in_block {
- if line.trim() == KIMI_CONFIG_BLOCK_END {
- in_block = false;
- }
- continue;
- }
- lines.push(line.to_string());
- }
-
- if !removed_block {
- return content.to_string();
- }
-
- let mut result = join_toml_lines(lines, trailing_newline);
- while result.ends_with("\n\n") {
- result.pop();
- }
- if result == "\n" {
- String::new()
- } else {
- result
- }
-}
-
-fn toml_basic_string(value: &str) -> String {
- let mut result = String::with_capacity(value.len() + 2);
- result.push('"');
- for ch in value.chars() {
- match ch {
- '"' => result.push_str("\\\""),
- '\\' => result.push_str("\\\\"),
- '\u{08}' => result.push_str("\\b"),
- '\t' => result.push_str("\\t"),
- '\n' => result.push_str("\\n"),
- '\u{0c}' => result.push_str("\\f"),
- '\r' => result.push_str("\\r"),
- ch if ch <= '\u{1f}' || ch == '\u{7f}' => {
- result.push_str(&format!("\\u{:04X}", ch as u32));
- }
- ch => result.push(ch),
- }
- }
- result.push('"');
- result
-}
-
-fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
- let mut result = lines.join("\n");
- if trailing_newline || result.is_empty() {
- result.push('\n');
- }
- result
-}
-
-fn toml_table_header(line: &str) -> Option<&str> {
- let trimmed = line.trim_start();
- if trimmed.starts_with('#') || !trimmed.starts_with('[') {
- return None;
- }
-
- let header_end = if trimmed.starts_with("[[") {
- trimmed.find("]]").map(|index| index + 2)?
- } else {
- trimmed.find(']').map(|index| index + 1)?
- };
- let header = &trimmed[..header_end];
- let rest = trimmed[header_end..].trim_start();
- if !rest.is_empty() && !rest.starts_with('#') {
- return None;
- }
-
- Some(header)
-}
-
-fn is_toml_key(line: &str, key: &str) -> bool {
- let trimmed = line.trim();
- if trimmed.starts_with('#') || !trimmed.starts_with(key) {
- return false;
- }
-
- trimmed[key.len()..].trim_start().starts_with('=')
-}
-
-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)]
- {
- use std::os::unix::fs::PermissionsExt;
-
- let mut perms = fs::metadata(_path)?.permissions();
- perms.set_mode(0o755);
- fs::set_permissions(_path, perms)?;
- }
-
- Ok(())
-}
-
-fn pi_extension_dir() -> io::Result<PathBuf> {
- Ok(
- config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".pi", "agent"])?
- .join("extensions"),
- )
-}
-
-fn omp_extension_dir() -> io::Result<PathBuf> {
- Ok(
- config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])?
- .join("extensions"),
- )
-}
-
-fn claude_dir() -> io::Result<PathBuf> {
- config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"])
-}
-
-fn codex_dir() -> io::Result<PathBuf> {
- config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"])
-}
-
-fn kimi_dir() -> io::Result<PathBuf> {
- config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"])
-}
-
-fn copilot_dir() -> io::Result<PathBuf> {
- config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"])
-}
-
-fn devin_dir() -> io::Result<PathBuf> {
- if let Some(value) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
- return expand_tilde_path(PathBuf::from(value)).map(|path| path.join("devin"));
- }
-
- Ok(home_dir()?.join(".config").join("devin"))
-}
-
-fn droid_dir() -> io::Result<PathBuf> {
- Ok(home_dir()?.join(".factory"))
-}
-
-fn config_dir_from_env_or_home(
- env_var: &str,
- home_relative_segments: &[&str],
-) -> io::Result<PathBuf> {
- if let Some(value) = std::env::var_os(env_var).filter(|value| !value.is_empty()) {
- return expand_tilde_path(PathBuf::from(value));
- }
-
- let mut path = home_dir()?;
- for segment in home_relative_segments {
- path.push(segment);
- }
- Ok(path)
-}
-
-fn expand_tilde_path(path: PathBuf) -> io::Result<PathBuf> {
- let Some(raw) = path.to_str() else {
- return Ok(path);
- };
-
- if raw == "~" {
- return home_dir();
- }
-
- if let Some(rest) = raw
- .strip_prefix("~/")
- .or_else(|| raw.strip_prefix("~\\"))
- .or_else(|| raw.strip_prefix('~'))
- {
- return Ok(home_dir()?.join(rest));
- }
-
- Ok(path)
-}
-
-fn opencode_dir() -> io::Result<PathBuf> {
- Ok(home_dir()?.join(".config/opencode"))
-}
-
-fn kilo_dir() -> io::Result<PathBuf> {
- Ok(home_dir()?.join(".config/kilo"))
-}
-
-fn hermes_dir() -> io::Result<PathBuf> {
- Ok(home_dir()?.join(".hermes"))
-}
-
-fn hermes_plugin_dir() -> io::Result<PathBuf> {
- Ok(hermes_dir()?
- .join("plugins")
- .join(HERMES_PLUGIN_INSTALL_NAME))
-}
-
-fn qodercli_dir() -> io::Result<PathBuf> {
- config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"])
-}
-
-fn cursor_dir() -> io::Result<PathBuf> {
- config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"])
-}
-
-fn home_dir() -> io::Result<PathBuf> {
- if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
- return Ok(PathBuf::from(home));
- }
-
- #[cfg(windows)]
- {
- if let Some(profile) = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()) {
- return Ok(PathBuf::from(profile));
- }
- if let (Some(drive), Some(path)) = (
- std::env::var_os("HOMEDRIVE").filter(|value| !value.is_empty()),
- std::env::var_os("HOMEPATH").filter(|value| !value.is_empty()),
- ) {
- let mut home = PathBuf::from(drive);
- home.push(path);
- return Ok(home);
- }
- }
-
- Err(io::Error::other(
- "home directory is not set; cannot locate home directory",
- ))
-}
-
-#[cfg(test)]
-pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> {
- static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
- LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
-}
-
#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn extract_version_triple_parses_common_outputs() {
- assert_eq!(extract_version_triple("0.14.0"), Some((0, 14, 0)));
- assert_eq!(extract_version_triple("v1.2.3"), Some((1, 2, 3)));
- assert_eq!(
- extract_version_triple("kimi-code 0.14.0 (linux/x64)"),
- Some((0, 14, 0))
- );
- assert_eq!(extract_version_triple("0.14"), Some((0, 14, 0)));
- assert_eq!(extract_version_triple("0.14.1-beta.2"), Some((0, 14, 1)));
- assert_eq!(extract_version_triple("no version here"), None);
- assert_eq!(extract_version_triple(""), None);
- }
-
- #[test]
- fn extract_version_triple_orders_versions() {
- let old = extract_version_triple("0.12.1").unwrap();
- let min = extract_version_triple(KIMI_MIN_VERSION).unwrap();
- let new = extract_version_triple("0.15.0").unwrap();
- assert!(old < min);
- assert!(min <= min);
- assert!(min < new);
- }
-
- #[test]
- fn agent_version_requirement_only_set_for_kimi() {
- let requirement = agent_version_requirement(crate::api::schema::IntegrationTarget::Kimi)
- .expect("kimi must have a version requirement");
- assert_eq!(requirement.binary, "kimi");
- assert_eq!(requirement.min_version, KIMI_MIN_VERSION);
- assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Claude).is_none());
- assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Codex).is_none());
- }
-
- #[test]
- fn enforce_agent_version_warns_when_binary_missing() {
- let requirement = AgentVersionRequirement {
- label: "kimi code",
- binary: "herdr-test-binary-that-does-not-exist",
- args: &["--version"],
- min_version: "0.14.0",
- };
- let warning = enforce_agent_version(&requirement)
- .expect("missing binary must not fail the install")
- .expect("missing binary must produce a warning");
- assert!(warning.contains("could not run"));
- assert!(warning.contains("0.14.0"));
- }
-
- #[cfg(unix)]
- #[test]
- fn enforce_agent_version_rejects_old_version() {
- let requirement = AgentVersionRequirement {
- label: "kimi code",
- binary: "echo",
- args: &["0.12.1"],
- min_version: "0.14.0",
- };
- let err =
- enforce_agent_version(&requirement).expect_err("old version must fail the install");
- let message = err.to_string();
- assert!(message.contains("0.12.1"));
- assert!(message.contains("0.14.0"));
- assert!(message.contains("upgrade"));
- }
-
- #[cfg(unix)]
- #[test]
- fn enforce_agent_version_accepts_current_version() {
- let requirement = AgentVersionRequirement {
- label: "kimi code",
- binary: "echo",
- args: &["0.14.0"],
- min_version: "0.14.0",
- };
- let result = enforce_agent_version(&requirement)
- .expect("matching version must not fail the install");
- assert!(result.is_none(), "matching version must not warn");
- }
-
- fn clear_integration_path_env() {
- 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(KIMI_CODE_HOME_ENV_VAR);
- std::env::remove_var("XDG_CONFIG_HOME");
- std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
- std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
- }
-
- fn kimi_hook_command(hook_path: &Path, action: &str) -> String {
- hook_command(hook_path, Some(action))
- }
-
- fn kimi_config_hooks(config: &str) -> Vec<toml::Value> {
- let parsed: toml::Value = toml::from_str(config).unwrap();
- parsed
- .get("hooks")
- .and_then(toml::Value::as_array)
- .cloned()
- .unwrap_or_default()
- }
-
- fn assert_kimi_hook(config: &str, hook_path: &Path, event: &str, action: &str) {
- let command = kimi_hook_command(hook_path, action);
- let hooks = kimi_config_hooks(config);
- assert!(
- hooks.iter().any(|hook| {
- hook.get("event").and_then(toml::Value::as_str) == Some(event)
- && hook.get("command").and_then(toml::Value::as_str) == Some(command.as_str())
- && hook.get("timeout").and_then(toml::Value::as_integer) == Some(10)
- }),
- "missing kimi hook for {event} -> {action}"
- );
- }
-
- fn unique_base() -> PathBuf {
- clear_integration_path_env();
- std::env::temp_dir().join(format!(
- "herdr-integration-install-test-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- ))
- }
-
- #[cfg(windows)]
- #[test]
- fn home_dir_uses_userprofile_when_home_is_missing() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let previous_home = std::env::var_os("HOME");
- let previous_userprofile = std::env::var_os("USERPROFILE");
- std::env::remove_var("HOME");
- std::env::set_var("USERPROFILE", &base);
-
- assert_eq!(home_dir().unwrap(), base);
-
- if let Some(home) = previous_home {
- std::env::set_var("HOME", home);
- }
- if let Some(userprofile) = previous_userprofile {
- std::env::set_var("USERPROFILE", userprofile);
- } else {
- std::env::remove_var("USERPROFILE");
- }
- }
-
- #[cfg(windows)]
- #[test]
- fn windows_supports_only_cli_hook_integrations() {
- use crate::api::schema::IntegrationTarget;
-
- assert!(!integration_target_supported(IntegrationTarget::Pi));
- assert!(!integration_target_supported(IntegrationTarget::Omp));
- assert!(!integration_target_supported(IntegrationTarget::Opencode));
- assert!(!integration_target_supported(IntegrationTarget::Kilo));
- assert!(!integration_target_supported(IntegrationTarget::Hermes));
- assert!(!integration_target_supported(IntegrationTarget::Cursor));
- assert!(!integration_target_supported(IntegrationTarget::Devin));
-
- assert!(integration_target_supported(IntegrationTarget::Claude));
- assert!(integration_target_supported(IntegrationTarget::Codex));
- assert!(integration_target_supported(IntegrationTarget::Copilot));
- assert!(integration_target_supported(IntegrationTarget::Droid));
- assert!(integration_target_supported(IntegrationTarget::Kimi));
- assert!(integration_target_supported(IntegrationTarget::Qodercli));
- }
-
- #[cfg(windows)]
- #[test]
- fn windows_does_not_offer_unsupported_integrations_even_when_commands_exist() {
- use crate::api::schema::IntegrationTarget;
-
- let _lock = integration_env_lock();
- let base = unique_base();
- let bin = base.join("bin");
- fs::create_dir_all(&bin).unwrap();
- let original_path = std::env::var_os("PATH");
- std::env::set_var("PATH", &bin);
-
- fs::write(bin.join("pi.cmd"), "@echo off\r\n").unwrap();
- fs::write(bin.join("omp.cmd"), "@echo off\r\n").unwrap();
- fs::write(bin.join("opencode.cmd"), "@echo off\r\n").unwrap();
- fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap();
- fs::write(bin.join("hermes.exe"), "").unwrap();
- fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap();
- fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap();
-
- assert!(!integration_target_available(IntegrationTarget::Pi));
- assert!(!integration_target_available(IntegrationTarget::Omp));
- assert!(!integration_target_available(IntegrationTarget::Opencode));
- assert!(!integration_target_available(IntegrationTarget::Kilo));
- assert!(!integration_target_available(IntegrationTarget::Hermes));
- assert!(!integration_target_available(IntegrationTarget::Cursor));
- assert!(!integration_target_available(IntegrationTarget::Devin));
-
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[cfg(windows)]
- #[test]
- fn windows_install_rejects_unsupported_integration_before_config_lookup() {
- use crate::api::schema::IntegrationTarget;
-
- let _lock = integration_env_lock();
- let original_home = std::env::var_os("HOME");
- let original_userprofile = std::env::var_os("USERPROFILE");
- let original_homedrive = std::env::var_os("HOMEDRIVE");
- let original_homepath = std::env::var_os("HOMEPATH");
- std::env::remove_var("HOME");
- std::env::remove_var("USERPROFILE");
- std::env::remove_var("HOMEDRIVE");
- std::env::remove_var("HOMEPATH");
-
- let err = install_target(IntegrationTarget::Pi).unwrap_err();
- assert_eq!(
- err.to_string(),
- "pi integration is not supported on Windows"
- );
-
- if let Some(home) = original_home {
- std::env::set_var("HOME", home);
- }
- if let Some(userprofile) = original_userprofile {
- std::env::set_var("USERPROFILE", userprofile);
- }
- if let Some(homedrive) = original_homedrive {
- std::env::set_var("HOMEDRIVE", homedrive);
- }
- if let Some(homepath) = original_homepath {
- std::env::set_var("HOMEPATH", homepath);
- }
- }
-
- #[test]
- #[cfg(unix)]
- fn command_available_requires_executable_file_on_path() {
- use std::os::unix::fs::PermissionsExt;
-
- let _lock = integration_env_lock();
- let base = unique_base();
- let bin = base.join("bin");
- fs::create_dir_all(&bin).unwrap();
- let original_path = std::env::var_os("PATH");
- std::env::set_var("PATH", &bin);
-
- let command = bin.join("claude");
- fs::write(&command, "#!/bin/sh\n").unwrap();
- fs::set_permissions(&command, fs::Permissions::from_mode(0o644)).unwrap();
- assert!(!command_available("claude"));
-
- fs::set_permissions(&command, fs::Permissions::from_mode(0o755)).unwrap();
- assert!(command_available("claude"));
-
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- #[cfg(windows)]
- fn command_available_finds_windows_command_shims_on_path() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let bin = base.join("bin");
- fs::create_dir_all(&bin).unwrap();
- let original_path = std::env::var_os("PATH");
- std::env::set_var("PATH", &bin);
-
- fs::write(bin.join("claude.cmd"), "@echo off\r\n").unwrap();
- assert!(command_available("claude"));
-
- fs::write(bin.join("codex.exe"), "").unwrap();
- assert!(command_available("codex"));
-
- assert!(!command_available("missing-agent"));
-
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- #[cfg(windows)]
- fn qodercli_availability_checks_windows_aliases() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let bin = base.join("bin");
- fs::create_dir_all(&bin).unwrap();
- let original_path = std::env::var_os("PATH");
- std::env::set_var("PATH", &bin);
-
- fs::write(bin.join("qoder.cmd"), "@echo off\r\n").unwrap();
-
- assert!(integration_target_available(
- crate::api::schema::IntegrationTarget::Qodercli
- ));
-
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- #[cfg(windows)]
- fn hermes_layout_can_exist_without_making_unsupported_target_available() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let local_app_data = base.join("local-app-data");
- let hermes_bin = local_app_data.join("hermes").join("bin");
- fs::create_dir_all(&hermes_bin).unwrap();
- fs::write(hermes_bin.join("hermes.exe"), "").unwrap();
- let original_local_app_data = std::env::var_os("LOCALAPPDATA");
- let original_path = std::env::var_os("PATH");
- std::env::set_var("LOCALAPPDATA", &local_app_data);
- std::env::set_var("PATH", "");
-
- assert!(hermes_install_layout_available());
- assert!(!integration_target_available(
- crate::api::schema::IntegrationTarget::Hermes
- ));
-
- if let Some(local_app_data) = original_local_app_data {
- std::env::set_var("LOCALAPPDATA", local_app_data);
- } else {
- std::env::remove_var("LOCALAPPDATA");
- }
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn codex_availability_finds_standalone_binary_under_codex_home() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let bin = home
- .join(".codex/packages/standalone/releases/0.137.0-test")
- .join("bin");
- fs::create_dir_all(&bin).unwrap();
- let binary = bin.join(codex_executable_name());
- fs::write(&binary, "").unwrap();
- make_executable(&binary).unwrap();
- let original_home = std::env::var_os("HOME");
- let original_path = std::env::var_os("PATH");
- std::env::set_var("HOME", &home);
- std::env::set_var("PATH", "");
-
- assert!(integration_target_available(
- crate::api::schema::IntegrationTarget::Codex
- ));
-
- if let Some(home) = original_home {
- std::env::set_var("HOME", home);
- } else {
- std::env::remove_var("HOME");
- }
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn integration_recommendations_mark_standalone_codex_available() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let bin = home
- .join(".codex/packages/standalone/releases/0.137.0-test")
- .join("bin");
- fs::create_dir_all(&bin).unwrap();
- let binary = bin.join(codex_executable_name());
- fs::write(&binary, "").unwrap();
- make_executable(&binary).unwrap();
- let original_home = std::env::var_os("HOME");
- let original_path = std::env::var_os("PATH");
- std::env::set_var("HOME", &home);
- std::env::set_var("PATH", "");
-
- let codex = integration_recommendations()
- .into_iter()
- .find(|recommendation| {
- recommendation.target == crate::api::schema::IntegrationTarget::Codex
- })
- .expect("codex recommendation should be present");
-
- assert!(codex.available);
- assert_eq!(codex.state, IntegrationStatusKind::NotInstalled);
- assert!(codex.needs_install());
-
- if let Some(home) = original_home {
- std::env::set_var("HOME", home);
- } else {
- std::env::remove_var("HOME");
- }
- if let Some(path) = original_path {
- std::env::set_var("PATH", path);
- } else {
- std::env::remove_var("PATH");
- }
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn integration_recommendation_installs_available_or_outdated_targets() {
- let mut recommendation = IntegrationRecommendation {
- target: crate::api::schema::IntegrationTarget::Claude,
- label: "claude",
- command: "claude",
- available: false,
- path: PathBuf::from("/tmp/herdr-agent-state.sh"),
- state: IntegrationStatusKind::NotInstalled,
- };
- assert!(!recommendation.needs_install());
-
- recommendation.available = true;
- assert!(recommendation.needs_install());
-
- recommendation.available = false;
- recommendation.state = IntegrationStatusKind::Outdated;
- assert!(recommendation.needs_install());
-
- recommendation.available = true;
- recommendation.state = IntegrationStatusKind::Current;
- assert!(!recommendation.needs_install());
- }
-
- #[test]
- fn install_pi_writes_embedded_asset_to_pi_extensions_dir() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".pi/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- let path = install_pi().unwrap();
- let content = fs::read_to_string(&path).unwrap();
-
- assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
- assert_eq!(content, PI_EXTENSION_ASSET);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_pi_uses_pi_coding_agent_dir_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let agent_dir = base.join("custom-pi-agent");
- let ext_dir = agent_dir.join("extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, &agent_dir);
-
- let path = install_pi().unwrap();
-
- assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_pi_expands_tilde_in_pi_coding_agent_dir_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join("custom-pi-agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- std::env::set_var("HOME", &home);
- std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, "~/custom-pi-agent");
-
- let path = install_pi().unwrap();
-
- assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
-
- std::env::remove_var("HOME");
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_omp_writes_embedded_asset_to_omp_extensions_dir() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".omp/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_omp().unwrap();
- let content = fs::read_to_string(&installed.extension_path).unwrap();
-
- assert_eq!(
- installed.extension_path,
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
- );
- assert!(!installed.removed_legacy_pi_extension);
- assert_eq!(content, OMP_EXTENSION_ASSET);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_omp_removes_legacy_pi_integration_from_omp_extensions_dir() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".omp/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- let legacy_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
- fs::write(&legacy_path, PI_EXTENSION_ASSET).unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_omp().unwrap();
-
- assert_eq!(
- installed.extension_path,
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
- );
- assert!(installed.removed_legacy_pi_extension);
- assert!(!legacy_path.exists());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_omp_preserves_non_herdr_file_with_pi_install_name() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".omp/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- let user_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
- fs::write(&user_path, "// user extension\n").unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_omp().unwrap();
-
- assert_eq!(
- installed.extension_path,
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
- );
- assert!(!installed.removed_legacy_pi_extension);
- assert_eq!(
- fs::read_to_string(user_path).unwrap(),
- "// user extension\n"
- );
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_omp_uses_pi_coding_agent_dir_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let agent_dir = base.join("custom-omp-agent");
- let ext_dir = agent_dir.join("extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, &agent_dir);
-
- let installed = install_omp().unwrap();
-
- assert_eq!(
- installed.extension_path,
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
- );
- assert!(!installed.removed_legacy_pi_extension);
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_omp_creates_extensions_dir_when_agent_dir_exists() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let agent_dir = home.join(".omp/agent");
- let ext_dir = agent_dir.join("extensions");
- fs::create_dir_all(&agent_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_omp().unwrap();
-
- assert_eq!(
- installed.extension_path,
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
- );
- assert!(ext_dir.is_dir());
- assert!(!installed.removed_legacy_pi_extension);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_omp_removes_embedded_extension_when_present() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".omp/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- fs::write(
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME),
- OMP_EXTENSION_ASSET,
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_omp().unwrap();
-
- assert_eq!(
- result.extension_path,
- ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
- );
- assert!(result.removed_extension);
- assert!(!result.extension_path.exists());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_omp_errors_when_extension_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_omp().unwrap_err().to_string();
-
- assert!(err.contains("omp extension directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_pi_removes_embedded_extension_when_present() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".pi/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_pi().unwrap();
-
- assert_eq!(
- result.extension_path,
- ext_dir.join(PI_EXTENSION_INSTALL_NAME)
- );
- assert!(result.removed_extension);
- assert!(!result.extension_path.exists());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn outdated_integrations_treat_missing_version_marker_as_legacy() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".pi/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
- fs::write(&extension_path, "// installed by herdr\n").unwrap();
- std::env::set_var("HOME", &home);
-
- let outdated = outdated_installed_integrations();
-
- assert_eq!(outdated.len(), 1);
- assert_eq!(
- outdated[0].target,
- crate::api::schema::IntegrationTarget::Pi
- );
- assert_eq!(outdated[0].path, extension_path);
- assert_eq!(outdated[0].installed_version, None);
- assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn outdated_integrations_accept_current_version_marker() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let ext_dir = home.join(".pi/agent/extensions");
- fs::create_dir_all(&ext_dir).unwrap();
- fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
- std::env::set_var("HOME", &home);
-
- assert!(outdated_installed_integrations().is_empty());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_pi_errors_when_extension_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_pi().unwrap_err().to_string();
-
- assert!(err.contains("pi extension directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_claude_writes_hook_and_updates_settings() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let claude_dir = home.join(".claude");
- fs::create_dir_all(&claude_dir).unwrap();
- fs::write(
- claude_dir.join("settings.json"),
- r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_claude().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,
- claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
- );
- assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
- assert!(settings["permissions"]["allow"].is_array());
- assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*");
- assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" session"));
- assert!(settings["hooks"].get("UserPromptSubmit").is_none());
- assert!(settings["hooks"].get("PreToolUse").is_none());
- assert!(settings["hooks"].get("PermissionRequest").is_none());
- assert!(settings["hooks"].get("PostToolUse").is_none());
- assert!(settings["hooks"].get("PostToolUseFailure").is_none());
- assert!(settings["hooks"].get("SubagentStop").is_none());
- assert!(settings["hooks"].get("Stop").is_none());
- assert!(settings["hooks"].get("SessionEnd").is_none());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_claude_uses_claude_config_dir_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let claude_dir = base.join("custom-claude");
- fs::create_dir_all(&claude_dir).unwrap();
- std::env::set_var(CLAUDE_CONFIG_DIR_ENV_VAR, &claude_dir);
-
- let installed = install_claude().unwrap();
-
- assert_eq!(installed.settings_path, claude_dir.join("settings.json"));
- assert_eq!(
- installed.hook_path,
- claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
- );
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_claude_is_idempotent_for_hook_entries() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let claude_dir = home.join(".claude");
- fs::create_dir_all(&claude_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- install_claude().unwrap();
- install_claude().unwrap();
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
- .unwrap();
- assert_eq!(
- settings["hooks"]["SessionStart"].as_array().unwrap().len(),
- 1
- );
- assert!(settings["hooks"].get("UserPromptSubmit").is_none());
- assert!(settings["hooks"].get("PreToolUse").is_none());
- assert!(settings["hooks"].get("PermissionRequest").is_none());
- assert!(settings["hooks"].get("PostToolUse").is_none());
- assert!(settings["hooks"].get("PostToolUseFailure").is_none());
- assert!(settings["hooks"].get("SubagentStop").is_none());
- assert!(settings["hooks"].get("Stop").is_none());
- assert!(settings["hooks"].get("SessionEnd").is_none());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_claude_removes_deprecated_completion_hooks_and_preserves_user_hooks() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let claude_dir = home.join(".claude");
- let hooks_dir = claude_dir.join("hooks");
- fs::create_dir_all(&hooks_dir).unwrap();
- let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
- let settings = serde_json::json!({
- "hooks": {
- "PostToolUse": [{
- "matcher": "*",
- "hooks": [
- {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
- {"type": "command", "command": "echo keep-post", "timeout": 10}
- ]
- }],
- "PostToolUseFailure": [{
- "matcher": "*",
- "hooks": [
- {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
- {"type": "command", "command": "echo keep-failure", "timeout": 10}
- ]
- }],
- "SubagentStop": [{
- "matcher": "*",
- "hooks": [
- {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
- {"type": "command", "command": "echo keep-subagent", "timeout": 10}
- ]
- }],
- "SessionEnd": [{
- "matcher": "*",
- "hooks": [
- {"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10},
- {"type": "command", "command": "echo keep-session-end", "timeout": 10}
- ]
- }]
- }
- });
- fs::write(
- claude_dir.join("settings.json"),
- serde_json::to_string(&settings).unwrap(),
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_claude().unwrap();
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
- .unwrap();
- assert_eq!(
- settings["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
- "echo keep-post"
- );
- assert_eq!(
- settings["hooks"]["PostToolUseFailure"][0]["hooks"][0]["command"],
- "echo keep-failure"
- );
- assert_eq!(
- settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"],
- "echo keep-subagent"
- );
- assert_eq!(
- settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"],
- "echo keep-session-end"
- );
- assert!(settings["hooks"].get("UserPromptSubmit").is_none());
- assert!(settings["hooks"].get("PreToolUse").is_none());
- assert!(settings["hooks"].get("Stop").is_none());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn claude_v1_integration_status_is_outdated() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let claude_hooks_dir = home.join(".claude").join("hooks");
- fs::create_dir_all(&claude_hooks_dir).unwrap();
- let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
- fs::write(
- &hook_path,
- "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=1\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let statuses = installed_integration_statuses();
- let claude = statuses
- .iter()
- .find(|status| status.target == crate::api::schema::IntegrationTarget::Claude)
- .unwrap();
-
- assert_eq!(claude.path, hook_path);
- assert_eq!(claude.installed_version, Some(1));
- assert_eq!(claude.expected_version, 7);
- assert_eq!(claude.state, IntegrationStatusKind::Outdated);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn claude_v2_integration_status_is_outdated() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let claude_hooks_dir = home.join(".claude").join("hooks");
- fs::create_dir_all(&claude_hooks_dir).unwrap();
- let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
- fs::write(
- &hook_path,
- "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=2\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let statuses = installed_integration_statuses();
- let claude = statuses
- .iter()
- .find(|status| status.target == crate::api::schema::IntegrationTarget::Claude)
- .unwrap();
-
- assert_eq!(claude.path, hook_path);
- assert_eq!(claude.installed_version, Some(2));
- assert_eq!(claude.expected_version, 7);
- assert_eq!(claude.state, IntegrationStatusKind::Outdated);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_claude_removes_herdr_hooks_and_preserves_others() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let claude_dir = home.join(".claude");
- let hooks_dir = claude_dir.join("hooks");
- fs::create_dir_all(&hooks_dir).unwrap();
- let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
- fs::write(&hook_path, CLAUDE_HOOK_ASSET).unwrap();
- let settings = serde_json::json!({
- "hooks": {
- "SessionStart": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
- }],
- "UserPromptSubmit": [{
- "matcher": "*",
- "hooks": [
- {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
- {"type": "command", "command": "echo keep", "timeout": 10}
- ]
- }],
- "PermissionRequest": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]
- }],
- "PostToolUse": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
- }],
- "PostToolUseFailure": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
- }],
- "SubagentStop": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
- }],
- "Stop": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
- }],
- "SessionEnd": [{
- "matcher": "*",
- "hooks": [{"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10}]
- }]
- }
- });
- fs::write(
- claude_dir.join("settings.json"),
- serde_json::to_string(&settings).unwrap(),
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_claude().unwrap();
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
- .unwrap();
-
- assert!(result.removed_hook_file);
- assert!(result.updated_settings);
- assert!(!result.hook_path.exists());
- assert_eq!(
- settings["hooks"]["UserPromptSubmit"][0]["hooks"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
- settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
- "echo keep"
- );
- assert!(settings["hooks"].get("PermissionRequest").is_none());
- assert!(settings["hooks"].get("SessionStart").is_none());
- assert!(settings["hooks"].get("PostToolUse").is_none());
- assert!(settings["hooks"].get("PostToolUseFailure").is_none());
- assert!(settings["hooks"].get("SubagentStop").is_none());
- assert!(settings["hooks"].get("Stop").is_none());
- assert!(settings["hooks"].get("SessionEnd").is_none());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_claude_errors_when_claude_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_claude().unwrap_err().to_string();
-
- assert!(err.contains("claude directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn codex_v2_integration_status_is_outdated() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let codex_dir = home.join(".codex");
- fs::create_dir_all(&codex_dir).unwrap();
- let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
- fs::write(
- &hook_path,
- "#!/bin/sh\n# HERDR_INTEGRATION_ID=codex\n# HERDR_INTEGRATION_VERSION=2\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let statuses = installed_integration_statuses();
- let codex = statuses
- .iter()
- .find(|status| status.target == crate::api::schema::IntegrationTarget::Codex)
- .unwrap();
-
- assert_eq!(codex.path, hook_path);
- assert_eq!(codex.installed_version, Some(2));
- assert_eq!(codex.expected_version, 6);
- assert_eq!(codex.state, IntegrationStatusKind::Outdated);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_codex_writes_hook_and_updates_hooks_and_config() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let codex_dir = home.join(".codex");
- fs::create_dir_all(&codex_dir).unwrap();
- fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.4\"\n").unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_codex().unwrap();
- let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
- let hooks: Value =
- serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap();
- let config = fs::read_to_string(&installed.config_path).unwrap();
-
- assert_eq!(installed.hook_path, codex_dir.join(CODEX_HOOK_INSTALL_NAME));
- assert_eq!(installed.hooks_path, codex_dir.join("hooks.json"));
- assert_eq!(installed.config_path, codex_dir.join("config.toml"));
- assert_eq!(hook_content, CODEX_HOOK_ASSET);
- assert!(hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" session"));
- assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
- assert!(hooks["hooks"].get("PreToolUse").is_none());
- assert!(hooks["hooks"].get("PermissionRequest").is_none());
- assert!(hooks["hooks"].get("Stop").is_none());
- assert!(config.contains("model = \"gpt-5.4\""));
- assert!(config.contains("[features]"));
- assert!(config.contains("hooks = true"));
- assert!(!config.contains("codex_hooks"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_codex_uses_codex_home_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let codex_dir = base.join("custom-codex");
- fs::create_dir_all(&codex_dir).unwrap();
- fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.4\"\n").unwrap();
- std::env::set_var(CODEX_HOME_ENV_VAR, &codex_dir);
-
- let installed = install_codex().unwrap();
-
- assert_eq!(installed.hook_path, codex_dir.join(CODEX_HOOK_INSTALL_NAME));
- assert_eq!(installed.hooks_path, codex_dir.join("hooks.json"));
- assert_eq!(installed.config_path, codex_dir.join("config.toml"));
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let codex_dir = home.join(".codex");
- fs::create_dir_all(&codex_dir).unwrap();
- fs::write(
- codex_dir.join("config.toml"),
- "[features]\ncodex_hooks = false\nother = true\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_codex().unwrap();
- install_codex().unwrap();
-
- let hooks: Value =
- serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
- .unwrap();
- let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
-
- assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
- assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
- assert!(hooks["hooks"].get("PreToolUse").is_none());
- assert!(hooks["hooks"].get("PermissionRequest").is_none());
- assert!(hooks["hooks"].get("Stop").is_none());
- assert_eq!(config.matches("hooks = true").count(), 1);
- assert!(!config.contains("codex_hooks"));
- assert!(config.contains("other = true"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_codex_only_migrates_top_level_feature_flags() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let codex_dir = home.join(".codex");
- fs::create_dir_all(&codex_dir).unwrap();
- fs::write(
- codex_dir.join("config.toml"),
- "profile = \"work\"\n\n[profiles.work.features]\nhooks = false\ncodex_hooks = false\n\n[features]\ncodex_hooks = true\nother = true\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_codex().unwrap();
-
- let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
-
- assert!(config.contains("[profiles.work.features]\nhooks = false\ncodex_hooks = false"));
- assert!(config.contains("[features]\nhooks = true\nother = true"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let codex_dir = home.join(".codex");
- fs::create_dir_all(&codex_dir).unwrap();
- let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
- fs::write(&hook_path, CODEX_HOOK_ASSET).unwrap();
- let hooks = serde_json::json!({
- "hooks": {
- "SessionStart": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}],
- "UserPromptSubmit": [{"hooks": [
- {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
- {"type": "command", "command": "echo keep", "timeout": 10}
- ]}],
- "PreToolUse": [{"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]}],
- "PermissionRequest": [{"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]}],
- "Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}]
- }
- });
- fs::write(
- codex_dir.join("hooks.json"),
- serde_json::to_string(&hooks).unwrap(),
- )
- .unwrap();
- fs::write(
- codex_dir.join("config.toml"),
- "[features]\nhooks = true\nother = true\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_codex().unwrap();
- let hooks: Value =
- serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
- .unwrap();
- let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
-
- assert!(result.removed_hook_file);
- assert!(result.updated_hooks);
- assert!(!result.hook_path.exists());
- assert!(hooks["hooks"].get("SessionStart").is_none());
- assert!(hooks["hooks"].get("PreToolUse").is_none());
- assert!(hooks["hooks"].get("PermissionRequest").is_none());
- assert!(hooks["hooks"].get("Stop").is_none());
- assert_eq!(
- hooks["hooks"]["UserPromptSubmit"][0]["hooks"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
- hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
- "echo keep"
- );
- assert!(config.contains("hooks = true"));
- assert!(config.contains("other = true"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_codex_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_codex().unwrap_err().to_string();
-
- assert!(err.contains("codex config directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_kimi_writes_hook_and_updates_config() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let kimi_dir = home.join(".kimi-code");
- fs::create_dir_all(&kimi_dir).unwrap();
- fs::write(
- kimi_dir.join("config.toml"),
- "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\nmatcher = \"task.completed\"\ncommand = \"echo keep\"\ntimeout = 3\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_kimi().unwrap();
- let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
- let config = fs::read_to_string(&installed.config_path).unwrap();
- let hooks = kimi_config_hooks(&config);
-
- assert_eq!(
- installed.hook_path,
- kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)
- );
- assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
- assert_eq!(hook_content, KIMI_HOOK_ASSET);
- assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len() + 1);
- assert!(config.contains("default_model = \"moonshot\""));
- assert!(config.contains("command = \"echo keep\""));
- assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN));
- assert!(config.contains(KIMI_CONFIG_BLOCK_END));
- for (event, action) in KIMI_HOOK_EVENTS {
- assert_kimi_hook(&config, &installed.hook_path, event, action);
- }
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_kimi_uses_kimi_code_home_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let kimi_dir = base.join("custom-kimi");
- fs::create_dir_all(&kimi_dir).unwrap();
- std::env::set_var(KIMI_CODE_HOME_ENV_VAR, &kimi_dir);
-
- let installed = install_kimi().unwrap();
-
- assert_eq!(
- installed.hook_path,
- kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)
- );
- assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_kimi_is_idempotent_for_config_block() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let kimi_dir = home.join(".kimi-code");
- fs::create_dir_all(&kimi_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- install_kimi().unwrap();
- install_kimi().unwrap();
-
- let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap();
- let hooks = kimi_config_hooks(&config);
-
- assert_eq!(config.matches(KIMI_CONFIG_BLOCK_BEGIN).count(), 1);
- assert_eq!(config.matches(KIMI_CONFIG_BLOCK_END).count(), 1);
- assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_kimi_removes_hook_and_config_block_preserves_other_hooks() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let kimi_dir = home.join(".kimi-code");
- fs::create_dir_all(&kimi_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_kimi().unwrap();
- fs::write(
- &installed.config_path,
- format!(
- "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\ncommand = \"echo keep\"\n\n{}",
- fs::read_to_string(&installed.config_path).unwrap()
- ),
- )
- .unwrap();
-
- let result = uninstall_kimi().unwrap();
- let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap();
- let hooks = kimi_config_hooks(&config);
-
- assert!(result.removed_hook_file);
- assert!(result.updated_config);
- assert!(!result.hook_path.exists());
- assert!(config.contains("default_model = \"moonshot\""));
- assert!(config.contains("command = \"echo keep\""));
- assert!(!config.contains(KIMI_CONFIG_BLOCK_BEGIN));
- assert!(!config.contains(KIMI_CONFIG_BLOCK_END));
- assert_eq!(hooks.len(), 1);
- assert_eq!(
- hooks[0].get("event").and_then(toml::Value::as_str),
- Some("Notification")
- );
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_kimi_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_kimi().unwrap_err().to_string();
-
- assert!(err.contains("kimi code config directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[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();
- let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME);
- let stale_session_start_command = format!(
- "bash {}",
- shell_single_quote(&hook_path.display().to_string())
- );
- fs::write(
- copilot_dir.join("settings.json"),
- format!(
- r#"{{"theme":"dark","hooks":{{"PreToolUse":[{{"type":"command","command":"echo keep","timeoutSec":10}}],"sessionStart":[{{"type":"command","bash":{},"timeoutSec":10}}]}}}}"#,
- serde_json::to_string(&stale_session_start_command).unwrap()
- ),
- )
- .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(), 1);
- assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep");
- assert!(settings["hooks"]["SessionStart"][0][direct_command_field()]
- .as_str()
- .unwrap()
- .contains(COPILOT_HOOK_INSTALL_NAME));
- for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
- if let Some(entries) = settings["hooks"].get(event) {
- assert!(
- !entries.to_string().contains(COPILOT_HOOK_INSTALL_NAME),
- "expected herdr hooks.{event} entries to be removed"
- );
- }
- }
- assert!(settings["hooks"].get("sessionStart").is_none());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn copilot_v1_integration_status_is_outdated() {
- 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, COPILOT_INTEGRATION_VERSION);
- assert_eq!(copilot.state, IntegrationStatusKind::Outdated);
-
- 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
- );
- for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
- assert!(
- settings["hooks"].get(event).is_none(),
- "expected hooks.{event} to be absent"
- );
- }
- assert!(settings["hooks"].get("sessionStart").is_none());
-
- 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())
- );
- let settings = serde_json::json!({
- "hooks": {
- "PreToolUse": [
- {"type": "command", direct_command_field(): command, "timeoutSec": 10},
- {"type": "command", "command": "echo keep", "timeoutSec": 10}
- ],
- "PostToolUse": [{"type": "command", direct_command_field(): command, "timeoutSec": 10}],
- "notification": [{
- "type": "command",
- "matcher": "permission_prompt|elicitation_dialog|agent_idle",
- direct_command_field(): command,
- "timeoutSec": 10
- }]
- }
- });
- fs::write(
- copilot_dir.join("settings.json"),
- serde_json::to_string(&settings).unwrap(),
- )
- .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_devin_writes_hook_and_updates_settings() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let xdg_config = base.join("xdg");
- let devin_dir = xdg_config.join("devin");
- fs::create_dir_all(&devin_dir).unwrap();
- fs::write(
- devin_dir.join("config.json"),
- r#"{"theme_mode":"dark","hooks":{}}"#,
- )
- .unwrap();
- std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
- std::env::set_var("HOME", base.join("home"));
-
- let installed = install_devin().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, devin_dir.join(DEVIN_HOOK_INSTALL_NAME));
- assert_eq!(installed.settings_path, devin_dir.join("config.json"));
- assert_eq!(hook_content, DEVIN_HOOK_ASSET);
- assert_eq!(settings["theme_mode"], "dark");
- for (event, action) in DEVIN_HOOK_EVENTS {
- let command = settings["hooks"][event][0]["hooks"][0]["command"]
- .as_str()
- .unwrap();
- assert!(
- command.contains(DEVIN_HOOK_INSTALL_NAME) && command.ends_with(action),
- "expected devin {event} hook command to end with {action}, got {command}"
- );
- }
-
- clear_integration_path_env();
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_devin_is_idempotent_for_hook_entries() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let xdg_config = base.join("xdg");
- let devin_dir = xdg_config.join("devin");
- fs::create_dir_all(&devin_dir).unwrap();
- std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
- std::env::set_var("HOME", base.join("home"));
-
- install_devin().unwrap();
- install_devin().unwrap();
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
- .unwrap();
- for (event, _) in DEVIN_HOOK_EVENTS {
- assert_eq!(
- settings["hooks"][event].as_array().unwrap().len(),
- 1,
- "expected hooks.{event} to be idempotent"
- );
- }
-
- clear_integration_path_env();
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_devin_removes_legacy_lifecycle_hook_entries() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let xdg_config = base.join("xdg");
- let devin_dir = xdg_config.join("devin");
- fs::create_dir_all(&devin_dir).unwrap();
- std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
- std::env::set_var("HOME", base.join("home"));
-
- let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
- let mut hooks = Map::new();
- for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
- hooks.insert(
- event.to_string(),
- json!([
- {
- "hooks": [{
- "type": "command",
- "command": hook_command(&hook_path, Some(action)),
- "timeout": 10
- }]
- }
- ]),
- );
- }
- fs::write(
- devin_dir.join("config.json"),
- serde_json::to_string_pretty(&json!({ "hooks": hooks })).unwrap(),
- )
- .unwrap();
-
- install_devin().unwrap();
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
- .unwrap();
- for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
- let legacy_command = hook_command(&hook_path, Some(action));
- let entries = settings["hooks"][event].as_array();
- assert!(
- entries.is_none_or(|entries| {
- entries.iter().all(|entry| {
- entry
- .get("hooks")
- .and_then(Value::as_array)
- .is_none_or(|hooks| {
- hooks.iter().all(|hook| {
- hook.get("command").and_then(Value::as_str)
- != Some(legacy_command.as_str())
- })
- })
- })
- }),
- "expected legacy devin {event} -> {action} hook to be removed"
- );
-
- if !DEVIN_HOOK_EVENTS
- .iter()
- .any(|(installed_event, _)| installed_event == &event)
- {
- continue;
- }
-
- let session_command = hook_command(&hook_path, Some("session"));
- let entries = entries.unwrap();
- assert!(
- entries.iter().any(|entry| {
- entry
- .get("hooks")
- .and_then(Value::as_array)
- .is_some_and(|hooks| {
- hooks.iter().any(|hook| {
- hook.get("command").and_then(Value::as_str)
- == Some(session_command.as_str())
- })
- })
- }),
- "expected devin {event} session hook to be installed"
- );
- }
-
- clear_integration_path_env();
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_devin_removes_herdr_hooks_and_preserves_others() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let xdg_config = base.join("xdg");
- let devin_dir = xdg_config.join("devin");
- fs::create_dir_all(&devin_dir).unwrap();
- std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
- std::env::set_var("HOME", base.join("home"));
-
- install_devin().unwrap();
-
- let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
- let mut settings: Value =
- serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
- .unwrap();
- settings["hooks"]["UserPromptSubmit"]
- .as_array_mut()
- .unwrap()
- .push(json!({
- "matcher": "*",
- "hooks": [{
- "type": "command",
- "command": "echo keep",
- "timeout": 10
- }]
- }));
- fs::write(
- devin_dir.join("config.json"),
- serde_json::to_string_pretty(&settings).unwrap(),
- )
- .unwrap();
-
- let result = uninstall_devin().unwrap();
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap())
- .unwrap();
-
- assert!(result.removed_hook_file);
- assert!(result.updated_settings);
- assert!(!hook_path.exists());
- assert_eq!(
- settings["hooks"]["UserPromptSubmit"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
- settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
- "echo keep"
- );
- assert!(settings["hooks"].get("SessionStart").is_none());
- assert!(settings["hooks"].get("PreToolUse").is_none());
- assert!(settings["hooks"].get("PermissionRequest").is_none());
- assert!(settings["hooks"].get("Stop").is_none());
- assert!(settings["hooks"].get("SessionEnd").is_none());
-
- clear_integration_path_env();
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_devin_errors_when_config_dir_missing() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let xdg_config = base.join("xdg");
- fs::create_dir_all(&xdg_config).unwrap();
- std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
- std::env::set_var("HOME", base.join("home"));
-
- let err = install_devin().unwrap_err().to_string();
- assert!(err.contains("devin config directory not found"));
-
- clear_integration_path_env();
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_droid_writes_hook_to_settings_and_cleans_legacy_hooks_json() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let droid_dir = home.join(".factory");
- let legacy_hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME);
- fs::create_dir_all(legacy_hook_path.parent().unwrap()).unwrap();
- fs::create_dir_all(&droid_dir).unwrap();
- let legacy_command = format!(
- "bash {}",
- shell_single_quote(&legacy_hook_path.display().to_string())
- );
- fs::write(
- droid_dir.join("hooks.json"),
- format!(
- r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}}]}}],"PreToolUse":[{{"matcher":"Read","hooks":[{{"type":"command","command":"echo keep","timeout":10}}]}}]}}}}"#,
- legacy_command,
- ),
- )
- .unwrap();
- fs::write(
- droid_dir.join("settings.json"),
- r#"{"theme":"factory-dark"}"#,
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_droid().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();
- let legacy_hooks: Value =
- serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap();
-
- assert_eq!(
- installed.hook_path,
- droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME)
- );
- assert_eq!(installed.hooks_path, droid_dir.join("hooks.json"));
- assert_eq!(installed.settings_path, droid_dir.join("settings.json"));
- assert!(installed.updated_legacy_hooks);
- assert_eq!(hook_content, DROID_HOOK_ASSET);
- assert_eq!(settings["theme"], "factory-dark");
- assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(DROID_HOOK_INSTALL_NAME));
- assert!(settings["hooks"]["SessionStart"][0]
- .get("matcher")
- .is_none());
- for (event, action) in DROID_HOOK_EVENTS {
- let command = settings["hooks"][event][0]["hooks"][0]["command"]
- .as_str()
- .unwrap();
- assert!(
- command.contains(DROID_HOOK_INSTALL_NAME) && command.ends_with(action),
- "expected droid {event} hook command to end with {action}, got {command}"
- );
- }
- assert_eq!(legacy_hooks["hooks"]["PreToolUse"][0]["matcher"], "Read");
- assert!(legacy_hooks["hooks"].get("SessionStart").is_none());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_droid_is_idempotent_for_hook_entries() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let droid_dir = home.join(".factory");
- fs::create_dir_all(&droid_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- install_droid().unwrap();
- install_droid().unwrap();
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap())
- .unwrap();
- for (event, _) in DROID_HOOK_EVENTS {
- assert_eq!(
- settings["hooks"][event].as_array().unwrap().len(),
- 1,
- "expected hooks.{event} to be idempotent"
- );
- }
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn droid_v1_integration_status_is_outdated() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let droid_hooks_dir = home.join(".factory").join("hooks");
- fs::create_dir_all(&droid_hooks_dir).unwrap();
- let hook_path = droid_hooks_dir.join(DROID_HOOK_INSTALL_NAME);
- fs::write(
- &hook_path,
- "#!/bin/sh\n# HERDR_INTEGRATION_ID=droid\n# HERDR_INTEGRATION_VERSION=1\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let statuses = installed_integration_statuses();
- let droid = statuses
- .iter()
- .find(|status| status.target == crate::api::schema::IntegrationTarget::Droid)
- .unwrap();
-
- assert_eq!(droid.path, hook_path);
- assert_eq!(droid.installed_version, Some(1));
- assert_eq!(droid.expected_version, DROID_INTEGRATION_VERSION);
- assert_eq!(droid.state, IntegrationStatusKind::Outdated);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_droid_removes_herdr_hooks_and_preserves_others() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let droid_dir = home.join(".factory");
- let hooks_dir = droid_dir.join("hooks");
- fs::create_dir_all(&hooks_dir).unwrap();
- let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME);
- fs::write(&hook_path, DROID_HOOK_ASSET).unwrap();
- let command = format!(
- "bash {}",
- shell_single_quote(&hook_path.display().to_string())
- );
- fs::write(
- droid_dir.join("hooks.json"),
- format!(
- r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PreToolUse":[{{"matcher":"Read","hooks":[{{"type":"command","command":"echo read","timeout":10}}]}}]}}}}"#,
- command,
- ),
- )
- .unwrap();
- fs::write(
- droid_dir.join("settings.json"),
- format!(
- r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}}]}}],"PostToolUse":[{{"matcher":"Edit","hooks":[{{"type":"command","command":"echo post","timeout":10}}]}}]}}}}"#,
- command,
- ),
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_droid().unwrap();
- let hooks: Value =
- serde_json::from_str(&fs::read_to_string(droid_dir.join("hooks.json")).unwrap())
- .unwrap();
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap())
- .unwrap();
-
- assert!(result.removed_hook_file);
- assert!(result.updated_hooks);
- assert!(result.updated_settings);
- assert!(!result.hook_path.exists());
- assert_eq!(
- hooks["hooks"]["SessionStart"][0]["hooks"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
- hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"],
- "echo keep"
- );
- assert_eq!(hooks["hooks"]["PreToolUse"][0]["matcher"], "Read");
- assert!(settings["hooks"].get("SessionStart").is_none());
- assert_eq!(settings["hooks"]["PostToolUse"][0]["matcher"], "Edit");
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_droid_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_droid().unwrap_err().to_string();
-
- assert!(err.contains("droid 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();
- let home = base.join("home");
- let opencode_dir = home.join(".config/opencode");
- fs::create_dir_all(&opencode_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_opencode().unwrap();
- let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
-
- assert_eq!(
- installed.plugin_path,
- opencode_dir
- .join("plugins")
- .join(OPENCODE_PLUGIN_INSTALL_NAME)
- );
- assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_opencode_removes_plugin_when_present() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let opencode_dir = home.join(".config/opencode/plugins");
- fs::create_dir_all(&opencode_dir).unwrap();
- fs::write(
- opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME),
- OPENCODE_PLUGIN_ASSET,
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_opencode().unwrap();
-
- assert!(result.removed_plugin);
- assert!(!result.plugin_path.exists());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_opencode_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_opencode().unwrap_err().to_string();
-
- assert!(err.contains("opencode config directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_kilo_writes_plugin_to_plugin_dir() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let kilo_dir = home.join(".config/kilo");
- fs::create_dir_all(&kilo_dir).unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_kilo().unwrap();
- let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
-
- assert_eq!(
- installed.plugin_path,
- kilo_dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)
- );
- assert_eq!(plugin_content, KILO_PLUGIN_ASSET);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_kilo_removes_plugin_when_present() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let kilo_plugin_dir = home.join(".config/kilo/plugin");
- fs::create_dir_all(&kilo_plugin_dir).unwrap();
- fs::write(
- kilo_plugin_dir.join(KILO_PLUGIN_INSTALL_NAME),
- KILO_PLUGIN_ASSET,
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_kilo().unwrap();
-
- assert!(result.removed_plugin);
- assert!(!result.plugin_path.exists());
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_kilo_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_kilo().unwrap_err().to_string();
-
- assert!(err.contains("kilo config directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_hermes_writes_plugin_and_enables_it() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- fs::create_dir_all(&hermes_dir).unwrap();
- fs::write(hermes_dir.join("config.yaml"), "model:\n provider: auto\n").unwrap();
- std::env::set_var("HOME", &home);
-
- let installed = install_hermes().unwrap();
- let manifest = fs::read_to_string(
- installed
- .plugin_dir
- .join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME),
- )
- .unwrap();
- let init =
- fs::read_to_string(installed.plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)).unwrap();
- let config = fs::read_to_string(&installed.config_path).unwrap();
-
- assert_eq!(
- installed.plugin_dir,
- hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME)
- );
- assert_eq!(manifest, HERMES_PLUGIN_MANIFEST_ASSET);
- assert_eq!(init, HERMES_PLUGIN_INIT_ASSET);
- assert!(config.contains("plugins:\n enabled:\n - herdr-agent-state"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_hermes_is_idempotent_for_enabled_entry() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- fs::create_dir_all(&hermes_dir).unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins:\n enabled:\n - herdr-agent-state\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_hermes().unwrap();
- install_hermes().unwrap();
-
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
- assert_eq!(config.matches("herdr-agent-state").count(), 1);
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_hermes_preserves_flat_plugin_list() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- fs::create_dir_all(&hermes_dir).unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins:\n - platforms/discord\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_hermes().unwrap();
-
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
- assert_eq!(
- config,
- "plugins:\n - herdr-agent-state\n - platforms/discord\n"
- );
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_hermes_converts_flow_plugin_list_to_block_list() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- fs::create_dir_all(&hermes_dir).unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins: [platforms/discord]\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_hermes().unwrap();
-
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
- assert_eq!(
- config,
- "plugins:\n - herdr-agent-state\n - platforms/discord\n"
- );
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_hermes_is_idempotent_for_quoted_flat_plugin_entry() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- fs::create_dir_all(&hermes_dir).unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins:\n - \"herdr-agent-state\" # installed by herdr\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- install_hermes().unwrap();
-
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
- assert_eq!(
- config,
- "plugins:\n - \"herdr-agent-state\" # installed by herdr\n"
- );
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_hermes_removes_plugin_and_enabled_entry() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
- fs::create_dir_all(&plugin_dir).unwrap();
- fs::write(
- plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
- HERMES_PLUGIN_INIT_ASSET,
- )
- .unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins:\n enabled:\n - other-plugin\n - herdr-agent-state\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_hermes().unwrap();
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
-
- assert!(result.removed_plugin_dir);
- assert!(result.updated_config);
- assert!(!plugin_dir.exists());
- assert!(config.contains(" - other-plugin"));
- assert!(!config.contains("herdr-agent-state"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_hermes_preserves_flat_plugin_list() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
- fs::create_dir_all(&plugin_dir).unwrap();
- fs::write(
- plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
- HERMES_PLUGIN_INIT_ASSET,
- )
- .unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins:\n - other-plugin\n - herdr-agent-state\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_hermes().unwrap();
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
-
- assert!(result.removed_plugin_dir);
- assert!(result.updated_config);
- assert_eq!(config, "plugins:\n - other-plugin\n");
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_hermes_removes_flow_plugin_list_entry() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
- fs::create_dir_all(&plugin_dir).unwrap();
- fs::write(
- plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
- HERMES_PLUGIN_INIT_ASSET,
- )
- .unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins: [other-plugin, herdr-agent-state]\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_hermes().unwrap();
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
-
- assert!(result.removed_plugin_dir);
- assert!(result.updated_config);
- assert_eq!(config, "plugins:\n - other-plugin\n");
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_hermes_removes_commented_flat_plugin_entry() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let home = base.join("home");
- let hermes_dir = home.join(".hermes");
- let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
- fs::create_dir_all(&plugin_dir).unwrap();
- fs::write(
- plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
- HERMES_PLUGIN_INIT_ASSET,
- )
- .unwrap();
- fs::write(
- hermes_dir.join("config.yaml"),
- "plugins:\n - other-plugin\n - herdr-agent-state # installed by herdr\n",
- )
- .unwrap();
- std::env::set_var("HOME", &home);
-
- let result = uninstall_hermes().unwrap();
- let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
-
- assert!(result.removed_plugin_dir);
- assert!(result.updated_config);
- assert_eq!(config, "plugins:\n - other-plugin\n");
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_hermes_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_hermes().unwrap_err().to_string();
-
- assert!(err.contains("hermes config directory not found"));
-
- std::env::remove_var("HOME");
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn bundled_integration_assets_report_session_refs() {
- assert!(PI_EXTENSION_ASSET.contains("agent_session_path"));
- assert!(PI_EXTENSION_ASSET.contains("agent_session_id"));
- assert!(PI_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
- assert!(PI_EXTENSION_ASSET.contains("pane.report_agent_session"));
- assert!(PI_EXTENSION_ASSET.contains("pane.report_agent\""));
- assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
- assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_end\""));
- assert!(PI_EXTENSION_ASSET.contains("pane.release_agent"));
- assert!(PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
- assert!(OMP_EXTENSION_ASSET.contains("agent_session_path"));
- assert!(OMP_EXTENSION_ASSET.contains("agent_session_id"));
- assert!(OMP_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
- assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent_session"));
- assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent\""));
- assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
- assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_end\""));
- assert!(OMP_EXTENSION_ASSET.contains("pane.release_agent"));
- assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
- assert!(
- CLAUDE_HOOK_ASSET.contains("agent_session_id")
- || CLAUDE_HOOK_ASSET.contains("--agent-session-id")
- );
- assert!(
- CLAUDE_HOOK_ASSET.contains("agent_session_path")
- || CLAUDE_HOOK_ASSET.contains("--agent-session-path")
- );
- assert!(CLAUDE_HOOK_ASSET.contains("agent_id"));
- assert!(
- CLAUDE_HOOK_ASSET.contains("session_start_source")
- || CLAUDE_HOOK_ASSET.contains("--session-start-source")
- );
- assert!(
- CLAUDE_HOOK_ASSET.contains("pane.report_agent_session")
- || CLAUDE_HOOK_ASSET.contains("report-agent-session")
- );
- assert!(!CLAUDE_HOOK_ASSET.contains("\"state\": action"));
- assert!(!CLAUDE_HOOK_ASSET.contains("pane.release_agent"));
- assert!(
- CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")
- || CODEX_HOOK_ASSET.contains("In.ReadToEnd")
- );
- assert!(
- CODEX_HOOK_ASSET.contains("agent_session_id")
- || CODEX_HOOK_ASSET.contains("--agent-session-id")
- );
- assert!(
- CODEX_HOOK_ASSET.contains("session_start_source")
- || CODEX_HOOK_ASSET.contains("--session-start-source")
- );
- assert!(
- CODEX_HOOK_ASSET.contains("pane.report_agent_session")
- || CODEX_HOOK_ASSET.contains("report-agent-session")
- );
- assert!(!CODEX_HOOK_ASSET.contains("\"state\": action"));
- assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent"));
- assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\""));
- assert!(KIMI_HOOK_ASSET.contains("agent_session_id"));
- assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session"));
- assert!(KIMI_HOOK_ASSET.contains("\"state\": action"));
- assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent"));
- assert!(COPILOT_HOOK_ASSET.contains("agent_session_id"));
- assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session"));
- assert!(!COPILOT_HOOK_ASSET.contains("\"state\":"));
- assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent"));
- assert!(DEVIN_HOOK_ASSET.contains("HERDR_DEVIN_LIST_JSON"));
- assert!(DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent_session\""));
- assert!(!DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent\""));
- assert!(!DEVIN_HOOK_ASSET.contains("\"state\":"));
- assert!(!DEVIN_HOOK_ASSET.contains("pane.release_agent"));
- assert!(DEVIN_HOOK_ASSET.contains("agent_session_id"));
- assert!(DROID_HOOK_ASSET.contains("agent_session_id"));
- assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session"));
- assert!(!DROID_HOOK_ASSET.contains("\"state\": action"));
- assert!(!DROID_HOOK_ASSET.contains("pane.release_agent"));
- assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
- assert!(OPENCODE_PLUGIN_ASSET.contains("params.agent_session_id = sessionID"));
- assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session"));
- assert!(OPENCODE_PLUGIN_ASSET.contains("reportState"));
- assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
- assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\""));
- assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\""));
- assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session"));
- assert!(KILO_PLUGIN_ASSET.contains("reportState"));
- assert!(!KILO_PLUGIN_ASSET.contains("pane.release_agent"));
- assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
- assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
- assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\","));
- assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end"));
- assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize"));
- assert!(!HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent"));
- assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
- assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id"));
- assert!(QODERCLI_HOOK_ASSET.contains("pane.report_agent_session"));
- assert!(!QODERCLI_HOOK_ASSET.contains("\"state\": action"));
- assert!(!QODERCLI_HOOK_ASSET.contains("pane.release_agent"));
- assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT"));
- assert!(CURSOR_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=cursor"));
- assert!(CURSOR_HOOK_ASSET.contains("conversation_id"));
- assert!(CURSOR_HOOK_ASSET.contains("conversationId"));
- assert!(CURSOR_HOOK_ASSET.contains("sessionId"));
- assert!(CURSOR_HOOK_ASSET.contains("agent_session_id"));
- assert!(CURSOR_HOOK_ASSET.contains("pane.report_agent_session"));
- assert!(CURSOR_HOOK_ASSET.contains("hook_event_name"));
- assert!(CURSOR_HOOK_ASSET.contains("sessionStart"));
- assert!(!CURSOR_HOOK_ASSET.contains("\"state\":"));
- assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent"));
- }
-
- #[test]
- fn pi_extension_releases_only_for_quit_session_shutdown() {
- let release_policy = PI_EXTENSION_ASSET
- .find("function shouldReleaseOnSessionShutdown")
- .expect("pi extension should centralize session shutdown release policy");
- let quit_check = PI_EXTENSION_ASSET
- .find("reason === \"quit\"")
- .expect("pi extension should release only for true quit shutdowns");
- let shutdown_handler = PI_EXTENSION_ASSET
- .find("pi.on(\"session_shutdown\", async (event)")
- .expect("pi extension should inspect the session_shutdown event");
- let guarded_release = PI_EXTENSION_ASSET[shutdown_handler..]
- .find("if (shouldReleaseOnSessionShutdown(event))")
- .expect("pi extension should guard releaseAgent by shutdown reason");
-
- assert!(release_policy < shutdown_handler);
- assert!(release_policy < quit_check);
- assert!(quit_check < shutdown_handler);
- assert!(guarded_release > 0);
- }
-
- #[test]
- fn pi_extension_refreshes_session_ref_before_agent_start_state() {
- let agent_start = PI_EXTENSION_ASSET
- .find("pi.on(\"agent_start\", (_event, ctx)")
- .expect("pi extension should receive agent_start context");
- let handler = &PI_EXTENSION_ASSET[agent_start..];
- let update_session = handler
- .find("updateSessionRef(ctx);")
- .expect("pi extension should refresh the active session on agent_start");
- let report_session = handler
- .find("void reportSession();")
- .expect("pi extension should report the refreshed session before state");
- let publish_state = handler
- .find("publishState();")
- .expect("pi extension should publish working state after refreshing session");
-
- assert!(update_session < report_session);
- assert!(report_session < publish_state);
- }
-
- #[test]
- fn omp_extension_releases_only_for_quit_session_shutdown() {
- let release_policy = OMP_EXTENSION_ASSET
- .find("function shouldReleaseOnSessionShutdown")
- .expect("omp extension should centralize session shutdown release policy");
- let quit_check = OMP_EXTENSION_ASSET
- .find("reason === \"quit\"")
- .expect("omp extension should release only for true quit shutdowns");
- let shutdown_handler = OMP_EXTENSION_ASSET
- .find("pi.on(\"session_shutdown\", async (event)")
- .expect("omp extension should inspect the session_shutdown event");
- let guarded_release = OMP_EXTENSION_ASSET[shutdown_handler..]
- .find("if (shouldReleaseOnSessionShutdown(event))")
- .expect("omp extension should guard releaseAgent by shutdown reason");
-
- assert!(release_policy < shutdown_handler);
- assert!(release_policy < quit_check);
- assert!(quit_check < shutdown_handler);
- assert!(guarded_release > 0);
- }
-
- #[test]
- fn omp_extension_refreshes_session_ref_before_agent_start_state() {
- let agent_start = OMP_EXTENSION_ASSET
- .find("pi.on(\"agent_start\", (_event, ctx)")
- .expect("omp extension should receive agent_start context");
- let handler = &OMP_EXTENSION_ASSET[agent_start..];
- let update_session = handler
- .find("updateSessionRef(ctx);")
- .expect("omp extension should refresh the active session on agent_start");
- let report_session = handler
- .find("void reportSession();")
- .expect("omp extension should report the refreshed session before state");
- let publish_state = handler
- .find("publishState();")
- .expect("omp extension should publish working state after refreshing session");
-
- assert!(update_session < report_session);
- assert!(report_session < publish_state);
- }
-
- #[test]
- fn omp_session_hook_ignores_non_ui_sessions() {
- let session_start_handler = OMP_EXTENSION_ASSET
- .find("pi.on(\"session_start\"")
- .expect("omp extension registers session_start handler");
- let non_ui_guard = OMP_EXTENSION_ASSET
- .find("ctx?.hasUI !== true")
- .expect("omp extension checks UI context");
- let session_report = OMP_EXTENSION_ASSET
- .find("void reportSession()")
- .expect("omp extension reports root session");
-
- assert!(session_start_handler < non_ui_guard);
- assert!(non_ui_guard < session_report);
- }
-
- #[test]
- fn install_qodercli_writes_hook_and_updates_settings() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let qoder_dir = base.join(".qoder");
- fs::create_dir_all(&qoder_dir).unwrap();
- fs::write(
- qoder_dir.join("settings.json"),
- r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
- )
- .unwrap();
- std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
-
- let installed = install_qodercli().unwrap();
-
- assert_eq!(
- installed.hook_path,
- qoder_dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)
- );
- assert_eq!(installed.settings_path, qoder_dir.join("settings.json"));
- assert!(installed.hook_path.is_file());
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap();
- let hooks = settings
- .get("hooks")
- .and_then(Value::as_object)
- .expect("hooks should be present");
- for (event, action) in QODERCLI_HOOK_EVENTS {
- assert!(
- hooks.contains_key(event),
- "expected hooks.{event} to be registered"
- );
- let command = hooks[event][0]["hooks"][0]["command"].as_str().unwrap();
- assert!(
- command.contains(QODERCLI_HOOK_INSTALL_NAME) && command.ends_with(action),
- "expected qodercli {event} hook command to end with {action}, got {command}"
- );
- }
- // Pre-existing settings keys must be preserved.
- assert!(settings.get("permissions").is_some());
-
- std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_qodercli_is_idempotent_for_hook_entries() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let qoder_dir = base.join(".qoder");
- fs::create_dir_all(&qoder_dir).unwrap();
- std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
-
- install_qodercli().unwrap();
- install_qodercli().unwrap();
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
- .unwrap();
- let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
- for (event, _) in QODERCLI_HOOK_EVENTS {
- let entries = hooks.get(event).and_then(Value::as_array).unwrap();
- assert_eq!(
- entries.len(),
- 1,
- "expected hooks.{event} to contain exactly one entry, got {entries:?}"
- );
- }
-
- std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_qodercli_removes_herdr_hooks_and_preserves_others() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let qoder_dir = base.join(".qoder");
- fs::create_dir_all(&qoder_dir).unwrap();
- std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
-
- install_qodercli().unwrap();
- // Inject a foreign hook entry the user might have configured by hand.
- let mut settings: Value =
- serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
- .unwrap();
- settings["hooks"]["SessionStart"]
- .as_array_mut()
- .unwrap()
- .push(json!({
- "matcher": "*",
- "hooks": [{"type": "command", "command": "echo user-defined"}],
- }));
- fs::write(
- qoder_dir.join("settings.json"),
- serde_json::to_string_pretty(&settings).unwrap(),
- )
- .unwrap();
-
- let result = uninstall_qodercli().unwrap();
- assert!(result.removed_hook_file);
- assert!(result.updated_settings);
-
- let settings: Value =
- serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
- .unwrap();
- let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
- let remaining = hooks.get("SessionStart").and_then(Value::as_array).unwrap();
- assert_eq!(remaining.len(), 1);
- let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap();
- assert_eq!(cmd, "echo user-defined");
-
- std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_qodercli_errors_when_config_dir_missing() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let missing = base.join(".qoder");
- std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &missing);
-
- let err = install_qodercli().unwrap_err().to_string();
- assert!(
- err.contains("qodercli config directory not found"),
- "unexpected error: {err}"
- );
-
- std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_cursor_writes_hook_and_updates_hooks_json() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let cursor_dir = base.join(".cursor");
- fs::create_dir_all(&cursor_dir).unwrap();
- fs::write(
- cursor_dir.join("hooks.json"),
- r#"{"version":1,"hooks":{"stop":[{"command":"echo keep-me"}]}}"#,
- )
- .unwrap();
- std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
-
- let installed = install_cursor().unwrap();
-
- assert_eq!(
- installed.hook_path,
- cursor_dir.join(CURSOR_HOOK_INSTALL_NAME)
- );
- assert_eq!(installed.hooks_path, cursor_dir.join("hooks.json"));
- assert_eq!(
- fs::read_to_string(&installed.hook_path).unwrap(),
- CURSOR_HOOK_ASSET
- );
-
- let hooks_file: Value =
- serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap())
- .unwrap();
- let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
- let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap();
- assert_eq!(session_start.len(), 1);
- assert!(session_start[0]
- .get("command")
- .and_then(Value::as_str)
- .is_some_and(|command| {
- command.starts_with("bash ")
- && command.contains("herdr-agent-state.sh")
- && command.ends_with(" session")
- }));
- assert!(hooks.get("beforeSubmitPrompt").is_none());
- assert!(hooks.get("beforeShellExecution").is_none());
- let stop = hooks.get("stop").and_then(Value::as_array).unwrap();
- assert_eq!(stop.len(), 1);
- assert_eq!(
- stop[0].get("command").and_then(Value::as_str),
- Some("echo keep-me")
- );
-
- std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_cursor_is_idempotent_for_hook_entries() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let cursor_dir = base.join(".cursor");
- fs::create_dir_all(&cursor_dir).unwrap();
- std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
-
- install_cursor().unwrap();
- install_cursor().unwrap();
-
- let hooks_file: Value =
- serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap())
- .unwrap();
- let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
- let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap();
- assert_eq!(session_start.len(), 1);
-
- std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn uninstall_cursor_removes_herdr_hooks_and_preserves_others() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let cursor_dir = base.join(".cursor");
- fs::create_dir_all(&cursor_dir).unwrap();
- std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
-
- install_cursor().unwrap();
- let mut hooks_file: Value =
- serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap())
- .unwrap();
- hooks_file["hooks"]["beforeSubmitPrompt"] = json!([{ "command": "echo user-defined" }]);
- fs::write(
- cursor_dir.join("hooks.json"),
- serde_json::to_string_pretty(&hooks_file).unwrap(),
- )
- .unwrap();
-
- let result = uninstall_cursor().unwrap();
- assert!(result.removed_hook_file);
- assert!(result.updated_hooks);
- assert!(!cursor_dir.join(CURSOR_HOOK_INSTALL_NAME).is_file());
-
- let hooks_file: Value =
- serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap())
- .unwrap();
- let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
- assert!(!hooks.contains_key("sessionStart"));
- assert!(hooks.contains_key("beforeSubmitPrompt"));
-
- std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_cursor_uses_cursor_config_dir_env() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let cursor_dir = base.join("custom-cursor");
- fs::create_dir_all(&cursor_dir).unwrap();
- std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
-
- let installed = install_cursor().unwrap();
-
- assert_eq!(
- installed.hook_path,
- cursor_dir.join(CURSOR_HOOK_INSTALL_NAME)
- );
- assert_eq!(installed.hooks_path, cursor_dir.join("hooks.json"));
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn cursor_v1_integration_status_is_current() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let cursor_dir = base.join(".cursor");
- fs::create_dir_all(&cursor_dir).unwrap();
- let hook_path = cursor_dir.join(CURSOR_HOOK_INSTALL_NAME);
- fs::write(
- &hook_path,
- "#!/bin/sh\n# HERDR_INTEGRATION_ID=cursor\n# HERDR_INTEGRATION_VERSION=1\n",
- )
- .unwrap();
- std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
-
- let statuses = installed_integration_statuses();
- let cursor = statuses
- .iter()
- .find(|status| status.target == crate::api::schema::IntegrationTarget::Cursor)
- .expect("cursor integration status");
- assert_eq!(cursor.state, IntegrationStatusKind::Current);
- assert_eq!(cursor.installed_version, Some(CURSOR_INTEGRATION_VERSION));
-
- clear_integration_path_env();
- let _ = fs::remove_dir_all(base);
- }
-
- #[test]
- fn install_cursor_errors_when_config_dir_missing() {
- let _lock = integration_env_lock();
- let base = unique_base();
- let missing = base.join(".cursor");
- std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &missing);
-
- let err = install_cursor().unwrap_err().to_string();
- assert!(
- err.contains("cursor config directory not found"),
- "unexpected error: {err}"
- );
-
- std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
- let _ = fs::remove_dir_all(base);
- }
-}
+mod tests;
diff --git a/src/integration/registry.rs b/src/integration/registry.rs
new file mode 100644
index 00000000..e68dce0c
--- /dev/null
+++ b/src/integration/registry.rs
@@ -0,0 +1,410 @@
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use super::env::*;
+
+pub(crate) fn integration_target_label(
+ target: crate::api::schema::IntegrationTarget,
+) -> &'static str {
+ match target {
+ crate::api::schema::IntegrationTarget::Pi => "pi",
+ 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::Devin => "devin",
+ crate::api::schema::IntegrationTarget::Droid => "droid",
+ crate::api::schema::IntegrationTarget::Kimi => "kimi",
+ crate::api::schema::IntegrationTarget::Opencode => "opencode",
+ crate::api::schema::IntegrationTarget::Kilo => "kilo",
+ crate::api::schema::IntegrationTarget::Hermes => "hermes",
+ crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
+ crate::api::schema::IntegrationTarget::Cursor => "cursor",
+ }
+}
+
+pub(crate) fn integration_target_command(
+ target: crate::api::schema::IntegrationTarget,
+) -> &'static str {
+ integration_target_command_names(target)[0]
+}
+
+pub(crate) fn integration_target_command_names(
+ target: crate::api::schema::IntegrationTarget,
+) -> &'static [&'static str] {
+ match target {
+ crate::api::schema::IntegrationTarget::Pi => &["pi"],
+ 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::Devin => &["devin"],
+ crate::api::schema::IntegrationTarget::Droid => &["droid"],
+ crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
+ crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
+ crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"],
+ crate::api::schema::IntegrationTarget::Hermes => &["hermes"],
+ crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(),
+ crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(),
+ }
+}
+
+pub(crate) fn cursor_command_names() -> &'static [&'static str] {
+ &["cursor-agent"]
+}
+
+pub(crate) fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool {
+ #[cfg(windows)]
+ {
+ matches!(
+ target,
+ crate::api::schema::IntegrationTarget::Claude
+ | crate::api::schema::IntegrationTarget::Codex
+ | crate::api::schema::IntegrationTarget::Copilot
+ | crate::api::schema::IntegrationTarget::Droid
+ | crate::api::schema::IntegrationTarget::Kimi
+ | crate::api::schema::IntegrationTarget::Qodercli
+ )
+ }
+
+ #[cfg(not(windows))]
+ {
+ let _ = target;
+ true
+ }
+}
+
+pub(crate) fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool {
+ if !integration_target_supported(target) {
+ return false;
+ }
+
+ integration_target_command_names(target)
+ .iter()
+ .any(|command| command_available(command))
+ || integration_target_install_layout_available(target)
+}
+
+#[cfg(windows)]
+pub(crate) fn qodercli_command_names() -> &'static [&'static str] {
+ &["qodercli", "qoder", "qoderclicn", "qodercn"]
+}
+
+#[cfg(not(windows))]
+pub(crate) fn qodercli_command_names() -> &'static [&'static str] {
+ &["qodercli"]
+}
+
+pub(crate) fn integration_target_install_layout_available(
+ target: crate::api::schema::IntegrationTarget,
+) -> bool {
+ match target {
+ crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(),
+ crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(),
+ _ => false,
+ }
+}
+
+pub(crate) fn command_available(command: &str) -> bool {
+ let Some(paths) = std::env::var_os("PATH") else {
+ return false;
+ };
+ std::env::split_paths(&paths).any(|dir| {
+ command_path_candidates(&dir, command)
+ .into_iter()
+ .any(|path| executable_file_exists(&path))
+ })
+}
+
+pub(crate) fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
+ let base = dir.join(command);
+
+ #[cfg(not(windows))]
+ {
+ vec![base]
+ }
+
+ #[cfg(windows)]
+ {
+ if Path::new(command).extension().is_some() {
+ return vec![base];
+ }
+
+ let mut candidates = vec![base];
+ for extension in [".exe", ".cmd", ".bat", ".ps1"] {
+ candidates.push(dir.join(format!("{command}{extension}")));
+ }
+ candidates
+ }
+}
+
+pub(crate) fn executable_file_exists(path: &Path) -> bool {
+ let Ok(metadata) = path.metadata() else {
+ return false;
+ };
+ if !metadata.is_file() {
+ return false;
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o111 != 0
+ }
+
+ #[cfg(not(unix))]
+ {
+ true
+ }
+}
+
+pub(crate) fn codex_standalone_binary_available() -> bool {
+ let Ok(releases_dir) =
+ codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases"))
+ else {
+ return false;
+ };
+ let Ok(entries) = fs::read_dir(releases_dir) else {
+ return false;
+ };
+
+ entries.filter_map(Result::ok).any(|entry| {
+ executable_file_exists(&entry.path().join("bin").join(codex_executable_name()))
+ })
+}
+
+pub(crate) fn codex_executable_name() -> &'static str {
+ if cfg!(windows) {
+ "codex.exe"
+ } else {
+ "codex"
+ }
+}
+
+pub(crate) fn hermes_install_layout_available() -> bool {
+ #[cfg(windows)]
+ {
+ let Some(local_app_data) =
+ std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
+ else {
+ return false;
+ };
+ let dir = PathBuf::from(local_app_data).join("hermes");
+ [
+ dir.join("hermes.exe"),
+ dir.join("bin").join("hermes.exe"),
+ dir.join("Scripts").join("hermes.exe"),
+ ]
+ .into_iter()
+ .any(|path| executable_file_exists(&path))
+ }
+
+ #[cfg(not(windows))]
+ {
+ false
+ }
+}
+
+pub(crate) fn installed_integration_statuses() -> Vec<super::IntegrationStatus> {
+ integration_specs()
+ .into_iter()
+ .filter_map(|(target, path, expected_version)| {
+ if !integration_target_supported(target) {
+ return None;
+ }
+ Some(integration_status_at(target, path.ok()?, expected_version))
+ })
+ .collect()
+}
+
+pub(crate) fn integration_recommendations() -> Vec<super::IntegrationRecommendation> {
+ integration_specs()
+ .into_iter()
+ .filter_map(|(target, path, expected_version)| {
+ if !integration_target_supported(target) {
+ return None;
+ }
+ let path = path.ok()?;
+ let status = integration_status_at(target, path.clone(), expected_version);
+ Some(super::IntegrationRecommendation {
+ target,
+ label: integration_target_label(target),
+ command: integration_target_command(target),
+ available: integration_target_available(target)
+ || status.state != super::IntegrationStatusKind::NotInstalled,
+ path,
+ state: status.state,
+ })
+ })
+ .collect()
+}
+
+pub(crate) fn outdated_installed_integrations() -> Vec<super::IntegrationStatus> {
+ installed_integration_statuses()
+ .into_iter()
+ .filter(|status| status.state == super::IntegrationStatusKind::Outdated)
+ .collect()
+}
+
+fn integration_specs() -> [(
+ crate::api::schema::IntegrationTarget,
+ io::Result<PathBuf>,
+ u32,
+); 13] {
+ [
+ (
+ crate::api::schema::IntegrationTarget::Pi,
+ pi_extension_dir().map(|dir| dir.join(super::PI_EXTENSION_INSTALL_NAME)),
+ super::PI_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Omp,
+ omp_extension_dir().map(|dir| dir.join(super::OMP_EXTENSION_INSTALL_NAME)),
+ super::OMP_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Claude,
+ claude_dir().map(|dir| dir.join("hooks").join(super::CLAUDE_HOOK_INSTALL_NAME)),
+ super::CLAUDE_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Codex,
+ codex_dir().map(|dir| dir.join(super::CODEX_HOOK_INSTALL_NAME)),
+ super::CODEX_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Copilot,
+ copilot_dir().map(|dir| dir.join("hooks").join(super::COPILOT_HOOK_INSTALL_NAME)),
+ super::COPILOT_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Devin,
+ devin_dir().map(|dir| dir.join(super::DEVIN_HOOK_INSTALL_NAME)),
+ super::DEVIN_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Droid,
+ droid_dir().map(|dir| dir.join("hooks").join(super::DROID_HOOK_INSTALL_NAME)),
+ super::DROID_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Kimi,
+ kimi_dir().map(|dir| dir.join("hooks").join(super::KIMI_HOOK_INSTALL_NAME)),
+ super::KIMI_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Opencode,
+ opencode_dir().map(|dir| {
+ dir.join("plugins")
+ .join(super::OPENCODE_PLUGIN_INSTALL_NAME)
+ }),
+ super::OPENCODE_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Kilo,
+ kilo_dir().map(|dir| dir.join("plugin").join(super::KILO_PLUGIN_INSTALL_NAME)),
+ super::KILO_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Hermes,
+ hermes_plugin_dir().map(|dir| dir.join(super::HERMES_PLUGIN_INIT_INSTALL_NAME)),
+ super::HERMES_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Qodercli,
+ qodercli_dir().map(|dir| dir.join("hooks").join(super::QODERCLI_HOOK_INSTALL_NAME)),
+ super::QODERCLI_INTEGRATION_VERSION,
+ ),
+ (
+ crate::api::schema::IntegrationTarget::Cursor,
+ cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)),
+ super::CURSOR_INTEGRATION_VERSION,
+ ),
+ ]
+}
+
+pub(crate) fn integration_update_instructions(
+ targets: &[crate::api::schema::IntegrationTarget],
+) -> String {
+ let commands: Vec<String> = targets
+ .iter()
+ .map(|target| {
+ format!(
+ "`herdr integration install {}`",
+ integration_target_label(*target)
+ )
+ })
+ .collect();
+
+ match commands.as_slice() {
+ [] => String::new(),
+ [command] => format!("run {command}"),
+ [rest @ .., last] => format!("run {} and {last}", rest.join(", ")),
+ }
+}
+
+pub(crate) fn print_outdated_update_notice() -> bool {
+ let outdated = outdated_installed_integrations();
+ if outdated.is_empty() {
+ return false;
+ }
+
+ let targets = outdated
+ .iter()
+ .map(|integration| integration.target)
+ .collect::<Vec<_>>();
+ eprintln!(
+ "installed herdr integrations need updating; {}.",
+ integration_update_instructions(&targets).replace('`', "")
+ );
+ true
+}
+
+pub(crate) fn integration_status_at(
+ target: crate::api::schema::IntegrationTarget,
+ path: PathBuf,
+ expected_version: u32,
+) -> super::IntegrationStatus {
+ if !path.is_file() {
+ return super::IntegrationStatus {
+ target,
+ path,
+ state: super::IntegrationStatusKind::NotInstalled,
+ installed_version: None,
+ expected_version,
+ };
+ }
+
+ let installed_version = fs::read_to_string(&path)
+ .ok()
+ .and_then(|content| parse_integration_version(&content));
+ let state = if installed_version.is_some_and(|version| version >= expected_version) {
+ super::IntegrationStatusKind::Current
+ } else {
+ super::IntegrationStatusKind::Outdated
+ };
+
+ super::IntegrationStatus {
+ target,
+ path,
+ state,
+ installed_version,
+ expected_version,
+ }
+}
+
+pub(crate) fn parse_integration_version(content: &str) -> Option<u32> {
+ content.lines().find_map(|line| {
+ let marker_line = line
+ .trim()
+ .trim_start_matches('/')
+ .trim_start_matches('#')
+ .trim();
+ marker_line
+ .strip_prefix(super::INTEGRATION_VERSION_MARKER)?
+ .trim()
+ .parse()
+ .ok()
+ })
+}
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
new file mode 100644
index 00000000..a47f0dae
--- /dev/null
+++ b/src/integration/targets.rs
@@ -0,0 +1,1108 @@
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use serde_json::{json, Value};
+
+use super::command::{hook_command, shell_single_quote};
+use super::config_edit::{
+ build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook,
+ ensure_direct_command_hook, ensure_hermes_plugin_enabled, ensure_hooks_object,
+ ensure_simple_command_hook, hooks_object_if_present, remove_direct_hook_commands,
+ remove_hermes_plugin_enabled, remove_hook_commands, remove_kimi_config_block,
+ remove_simple_command_hook,
+};
+use super::env::{
+ claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, hermes_dir,
+ hermes_plugin_dir, kilo_dir, kimi_dir, omp_extension_dir, opencode_dir, pi_extension_dir,
+ qodercli_dir,
+};
+use super::file_ops::{
+ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
+};
+use super::types::{
+ ClaudeInstallPaths, ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult,
+ CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult,
+ DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult,
+ HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, KiloUninstallResult,
+ KimiInstallPaths, KimiUninstallResult, OmpInstallPaths, OmpUninstallResult,
+ OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths,
+ QodercliUninstallResult,
+};
+use super::{
+ CLAUDE_HOOK_ASSET, CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME,
+ COPILOT_HOOK_ASSET, COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME,
+ COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME,
+ DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, DEVIN_HOOK_INSTALL_NAME,
+ DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, DROID_HOOK_EVENTS,
+ DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, HERMES_PLUGIN_INIT_ASSET,
+ HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET,
+ HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME,
+ KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME,
+ OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET,
+ PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS,
+ QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
+};
+
+pub(crate) fn install_pi() -> io::Result<PathBuf> {
+ let dir = pi_extension_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "pi extension directory not found at {}. install pi and create the extensions directory first",
+ dir.display()
+ )));
+ }
+
+ let path = dir.join(PI_EXTENSION_INSTALL_NAME);
+ fs::write(&path, PI_EXTENSION_ASSET)?;
+ Ok(path)
+}
+
+pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> {
+ let dir = omp_extension_dir()?;
+ if !dir.is_dir() {
+ if dir.parent().is_some_and(|parent| parent.is_dir()) {
+ fs::create_dir_all(&dir)?;
+ } else {
+ return Err(io::Error::other(format!(
+ "omp extension directory not found at {}. install omp and create the extensions directory first",
+ dir.display()
+ )));
+ }
+ }
+
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "omp extension directory not found at {}. install omp and create the extensions directory first",
+ dir.display()
+ )));
+ }
+
+ let removed_legacy_pi_extension = remove_legacy_pi_extension_from_omp_dir(&dir)?;
+ let extension_path = dir.join(OMP_EXTENSION_INSTALL_NAME);
+ fs::write(&extension_path, OMP_EXTENSION_ASSET)?;
+ Ok(OmpInstallPaths {
+ extension_path,
+ removed_legacy_pi_extension,
+ })
+}
+
+pub(crate) fn remove_legacy_pi_extension_from_omp_dir(dir: &Path) -> io::Result<bool> {
+ let legacy_path = dir.join(PI_EXTENSION_INSTALL_NAME);
+ if !legacy_path.is_file() {
+ return Ok(false);
+ }
+
+ let content = fs::read_to_string(&legacy_path)?;
+ if content.contains("HERDR_INTEGRATION_ID=pi") {
+ fs::remove_file(legacy_path)?;
+ return Ok(true);
+ }
+
+ Ok(false)
+}
+
+pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
+ let dir = claude_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "claude directory not found at {}. install claude code first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CLAUDE_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,
+ "claude settings",
+ "claude settings hooks",
+ )?;
+ 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",
+ 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)?)?;
+
+ Ok(ClaudeInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
+pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
+ let dir = codex_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "codex config directory not found at {}. install codex first",
+ dir.display()
+ )));
+ }
+
+ let hook_path = dir.join(CODEX_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CODEX_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let hooks_path = dir.join("hooks.json");
+ let mut hooks_file = if hooks_path.is_file() {
+ serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?
+ } else {
+ json!({})
+ };
+
+ let hooks = ensure_hooks_object(
+ &mut hooks_file,
+ &hooks_path,
+ "codex hooks file",
+ "codex hooks file hooks",
+ )?;
+ 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",
+ 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)?)?;
+
+ let config_path = dir.join("config.toml");
+ let existing_config = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ String::new()
+ };
+ let new_config = build_codex_config_with_hooks(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ }
+
+ Ok(CodexInstallPaths {
+ hook_path,
+ hooks_path,
+ config_path,
+ })
+}
+
+pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
+ let dir = kimi_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "kimi code config directory not found at {}. install kimi code first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(KIMI_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, KIMI_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let config_path = dir.join("config.toml");
+ let existing_config = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ String::new()
+ };
+ let new_config = build_kimi_config_with_hooks(&existing_config, &hook_path);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ }
+ remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(KimiInstallPaths {
+ hook_path,
+ config_path,
+ })
+}
+
+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 = hook_command(&hook_path, None);
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
+ for event in COPILOT_HOOK_EVENTS {
+ remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
+ for event in COPILOT_HOOK_EVENTS {
+ ensure_direct_command_hook(hooks, event, command.clone(), 10, None)?;
+ }
+ remove_legacy_bash_hook_file(&hook_path)?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ Ok(CopilotInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
+pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> {
+ let dir = devin_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "devin config directory not found at {}. install devin cli first",
+ dir.display()
+ )));
+ }
+
+ let hook_path = dir.join(DEVIN_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, DEVIN_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let settings_path = dir.join("config.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,
+ "devin settings",
+ "devin settings hooks",
+ )?;
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ ensure_command_hook(
+ hooks,
+ event,
+ hook_command(&hook_path, Some(action)),
+ 10,
+ None,
+ )?;
+ }
+ remove_legacy_bash_hook_file(&hook_path)?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ Ok(DevinInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
+pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
+ let dir = droid_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "droid config directory not found at {}. install droid first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, DROID_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,
+ "droid settings",
+ "droid settings hooks",
+ )?;
+ remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ ensure_command_hook(
+ hooks,
+ event,
+ hook_command(&hook_path, Some(action)),
+ 10,
+ None,
+ )?;
+ }
+ remove_legacy_bash_hook_file(&hook_path)?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ let hooks_path = dir.join("hooks.json");
+ let mut updated_legacy_hooks = false;
+ if hooks_path.is_file() {
+ let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
+ .map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?;
+ if let Some(hooks) = hooks_object_if_present(
+ &mut hooks_file,
+ &hooks_path,
+ "droid hooks file",
+ "droid hooks file hooks",
+ )? {
+ updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_legacy_hooks |=
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ updated_legacy_hooks |=
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ }
+ if updated_legacy_hooks {
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ }
+ }
+
+ Ok(DroidInstallPaths {
+ hook_path,
+ hooks_path,
+ settings_path,
+ updated_legacy_hooks,
+ })
+}
+
+pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
+ let dir = opencode_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "opencode config directory not found at {}. install opencode first",
+ dir.display()
+ )));
+ }
+
+ let plugins_dir = dir.join("plugins");
+ fs::create_dir_all(&plugins_dir)?;
+
+ let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?;
+
+ Ok(OpenCodeInstallPaths { plugin_path })
+}
+
+pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> {
+ let dir = kilo_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "kilo config directory not found at {}. install kilo first",
+ dir.display()
+ )));
+ }
+
+ let plugins_dir = dir.join("plugin");
+ fs::create_dir_all(&plugins_dir)?;
+
+ let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin_path, KILO_PLUGIN_ASSET)?;
+
+ Ok(KiloInstallPaths { plugin_path })
+}
+
+pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> {
+ let dir = hermes_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "hermes config directory not found at {}. install hermes agent first",
+ dir.display()
+ )));
+ }
+
+ let plugin_dir = hermes_plugin_dir()?;
+ fs::create_dir_all(&plugin_dir)?;
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME),
+ HERMES_PLUGIN_MANIFEST_ASSET,
+ )?;
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )?;
+
+ let config_path = dir.join("config.yaml");
+ let existing_config = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ String::new()
+ };
+ let new_config = ensure_hermes_plugin_enabled(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ }
+
+ Ok(HermesInstallPaths {
+ plugin_dir,
+ config_path,
+ })
+}
+
+pub(crate) fn uninstall_pi() -> io::Result<PiUninstallResult> {
+ let extension_path = pi_extension_dir()?.join(PI_EXTENSION_INSTALL_NAME);
+ let removed_extension = remove_file_if_exists(&extension_path)?;
+
+ Ok(PiUninstallResult {
+ extension_path,
+ removed_extension,
+ })
+}
+
+pub(crate) fn uninstall_omp() -> io::Result<OmpUninstallResult> {
+ let extension_path = omp_extension_dir()?.join(OMP_EXTENSION_INSTALL_NAME);
+ let removed_extension = remove_file_if_exists(&extension_path)?;
+
+ Ok(OmpUninstallResult {
+ extension_path,
+ removed_extension,
+ })
+}
+
+pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
+ let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME);
+ let settings_path = claude_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,
+ "claude settings",
+ "claude settings hooks",
+ )? {
+ updated_settings |=
+ 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 {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(ClaudeUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
+pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
+ let codex_dir = codex_dir()?;
+ let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
+ let hooks_path = codex_dir.join("hooks.json");
+ let config_path = codex_dir.join("config.toml");
+ let mut updated_hooks = false;
+
+ if hooks_path.is_file() {
+ let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
+ .map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut hooks_file,
+ &hooks_path,
+ "codex hooks file",
+ "codex hooks file hooks",
+ )? {
+ 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_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
+ updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
+ }
+
+ if updated_hooks {
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(CodexUninstallResult {
+ hook_path,
+ hooks_path,
+ config_path,
+ removed_hook_file,
+ updated_hooks,
+ })
+}
+
+pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
+ let kimi_dir = kimi_dir()?;
+ let hook_path = kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME);
+ let config_path = kimi_dir.join("config.toml");
+ let mut updated_config = false;
+
+ if config_path.is_file() {
+ let existing_config = fs::read_to_string(&config_path)?;
+ let new_config = remove_kimi_config_block(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ updated_config = true;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(KimiUninstallResult {
+ hook_path,
+ config_path,
+ removed_hook_file,
+ updated_config,
+ })
+}
+
+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",
+ )? {
+ for event in COPILOT_HOOK_EVENTS {
+ updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?;
+ }
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(CopilotUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
+pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> {
+ let devin_dir = devin_dir()?;
+ let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
+ let settings_path = devin_dir.join("config.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,
+ "devin settings",
+ "devin settings hooks",
+ )? {
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(DevinUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
+pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
+ let droid_dir = droid_dir()?;
+ let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME);
+ let hooks_path = droid_dir.join("hooks.json");
+ let settings_path = droid_dir.join("settings.json");
+ let mut updated_hooks = false;
+ let mut updated_settings = false;
+ if hooks_path.is_file() {
+ let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
+ .map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut hooks_file,
+ &hooks_path,
+ "droid hooks file",
+ "droid hooks file hooks",
+ )? {
+ updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ }
+
+ if updated_hooks {
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ }
+ }
+
+ 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,
+ "droid settings",
+ "droid settings hooks",
+ )? {
+ updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?;
+ for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in DROID_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(DroidUninstallResult {
+ hook_path,
+ hooks_path,
+ settings_path,
+ removed_hook_file,
+ updated_hooks,
+ updated_settings,
+ })
+}
+
+pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
+ let plugin_path = opencode_dir()?
+ .join("plugins")
+ .join(OPENCODE_PLUGIN_INSTALL_NAME);
+ let removed_plugin = remove_file_if_exists(&plugin_path)?;
+
+ Ok(OpenCodeUninstallResult {
+ plugin_path,
+ removed_plugin,
+ })
+}
+
+pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> {
+ let plugin_path = kilo_dir()?.join("plugin").join(KILO_PLUGIN_INSTALL_NAME);
+ let removed_plugin = remove_file_if_exists(&plugin_path)?;
+
+ Ok(KiloUninstallResult {
+ plugin_path,
+ removed_plugin,
+ })
+}
+
+pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
+ let dir = hermes_dir()?;
+ let plugin_dir = hermes_plugin_dir()?;
+ let config_path = dir.join("config.yaml");
+
+ let removed_plugin_dir = remove_dir_all_if_exists(&plugin_dir)?;
+ let mut updated_config = false;
+ if config_path.is_file() {
+ let existing_config = fs::read_to_string(&config_path)?;
+ let new_config = remove_hermes_plugin_enabled(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ updated_config = true;
+ }
+ }
+
+ Ok(HermesUninstallResult {
+ plugin_dir,
+ config_path,
+ removed_plugin_dir,
+ updated_config,
+ })
+}
+
+pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
+ let dir = qodercli_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "qodercli config directory not found at {}. install qodercli first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(QODERCLI_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, QODERCLI_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ // Register the hook in ~/.qoder/settings.json. The schema mirrors claude
+ // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level
+ // `hooks` object keyed by event name, each entry holding a matcher + a
+ // list of `{type: "command", command, timeout?}` invocations. The hook
+ // script reads the event payload from stdin via `hook_event_name`.
+ 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,
+ "qodercli settings",
+ "qodercli settings hooks",
+ )?;
+ for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ ensure_command_hook(
+ hooks,
+ event,
+ hook_command(&hook_path, Some(action)),
+ 10,
+ Some("*"),
+ )?;
+ }
+ remove_legacy_bash_hook_file(&hook_path)?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ Ok(QodercliInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
+pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
+ let dir = cursor_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "cursor config directory not found at {}. install cursor agent cli first",
+ dir.display()
+ )));
+ }
+
+ let hook_path = dir.join(CURSOR_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CURSOR_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let hooks_path = dir.join("hooks.json");
+ let mut hooks_file = if hooks_path.is_file() {
+ serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?
+ } else {
+ json!({ "version": 1 })
+ };
+
+ if hooks_file.get("version").is_none() {
+ hooks_file
+ .as_object_mut()
+ .ok_or_else(|| {
+ io::Error::other(format!(
+ "cursor hooks file at {} must be a JSON object",
+ hooks_path.display()
+ ))
+ })?
+ .insert("version".to_string(), json!(1));
+ }
+
+ let hooks = ensure_hooks_object(
+ &mut hooks_file,
+ &hooks_path,
+ "cursor hooks file",
+ "cursor hooks file hooks",
+ )?;
+ let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ let session_command = format!("bash {quoted_hook_path} session");
+ remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?;
+ remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?;
+ remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?;
+ remove_simple_command_hook(hooks, "stop", &session_command)?;
+ remove_simple_command_hook(hooks, "sessionEnd", &session_command)?;
+ ensure_simple_command_hook(hooks, "sessionStart", session_command)?;
+
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+
+ Ok(CursorInstallPaths {
+ hook_path,
+ hooks_path,
+ })
+}
+
+pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
+ let hook_path = qodercli_dir()?
+ .join("hooks")
+ .join(QODERCLI_HOOK_INSTALL_NAME);
+ let settings_path = qodercli_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,
+ "qodercli settings",
+ "qodercli settings hooks",
+ )? {
+ for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?;
+ }
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file =
+ remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?;
+
+ Ok(QodercliUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
+pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
+ let cursor_home = cursor_dir()?;
+ let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME);
+ let hooks_path = cursor_home.join("hooks.json");
+ let mut updated_hooks = false;
+
+ if hooks_path.is_file() {
+ let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
+ .map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut hooks_file,
+ &hooks_path,
+ "cursor hooks file",
+ "cursor hooks file hooks",
+ )? {
+ let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ let session_command = format!("bash {quoted_hook_path} session");
+ updated_hooks |= remove_simple_command_hook(hooks, "sessionStart", &session_command)?;
+ updated_hooks |=
+ remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?;
+ updated_hooks |=
+ remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?;
+ updated_hooks |=
+ remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?;
+ updated_hooks |= remove_simple_command_hook(hooks, "stop", &session_command)?;
+ updated_hooks |= remove_simple_command_hook(hooks, "sessionEnd", &session_command)?;
+ }
+
+ if updated_hooks {
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ }
+ }
+
+ let removed_hook_file = remove_file_if_exists(&hook_path)?;
+
+ Ok(CursorUninstallResult {
+ hook_path,
+ hooks_path,
+ removed_hook_file,
+ updated_hooks,
+ })
+}
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
new file mode 100644
index 00000000..4c40e2af
--- /dev/null
+++ b/src/integration/tests.rs
@@ -0,0 +1,2990 @@
+use super::command::*;
+use super::config_edit::*;
+use super::env::*;
+use super::file_ops::*;
+use super::registry::*;
+use super::targets::*;
+use super::types::*;
+use super::version::*;
+use super::*;
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use serde_json::{json, Map, Value};
+
+#[test]
+fn extract_version_triple_parses_common_outputs() {
+ assert_eq!(extract_version_triple("0.14.0"), Some((0, 14, 0)));
+ assert_eq!(extract_version_triple("v1.2.3"), Some((1, 2, 3)));
+ assert_eq!(
+ extract_version_triple("kimi-code 0.14.0 (linux/x64)"),
+ Some((0, 14, 0))
+ );
+ assert_eq!(extract_version_triple("0.14"), Some((0, 14, 0)));
+ assert_eq!(extract_version_triple("0.14.1-beta.2"), Some((0, 14, 1)));
+ assert_eq!(extract_version_triple("no version here"), None);
+ assert_eq!(extract_version_triple(""), None);
+}
+
+#[test]
+fn extract_version_triple_orders_versions() {
+ let old = extract_version_triple("0.12.1").unwrap();
+ let min = extract_version_triple(KIMI_MIN_VERSION).unwrap();
+ let new = extract_version_triple("0.15.0").unwrap();
+ assert!(old < min);
+ assert!(min <= min);
+ assert!(min < new);
+}
+
+#[test]
+fn agent_version_requirement_only_set_for_kimi() {
+ let requirement = agent_version_requirement(crate::api::schema::IntegrationTarget::Kimi)
+ .expect("kimi must have a version requirement");
+ assert_eq!(requirement.binary, "kimi");
+ assert_eq!(requirement.min_version, KIMI_MIN_VERSION);
+ assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Claude).is_none());
+ assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Codex).is_none());
+}
+
+#[test]
+fn enforce_agent_version_warns_when_binary_missing() {
+ let requirement = AgentVersionRequirement {
+ label: "kimi code",
+ binary: "herdr-test-binary-that-does-not-exist",
+ args: &["--version"],
+ min_version: "0.14.0",
+ };
+ let warning = enforce_agent_version(&requirement)
+ .expect("missing binary must not fail the install")
+ .expect("missing binary must produce a warning");
+ assert!(warning.contains("could not run"));
+ assert!(warning.contains("0.14.0"));
+}
+
+#[cfg(unix)]
+#[test]
+fn enforce_agent_version_rejects_old_version() {
+ let requirement = AgentVersionRequirement {
+ label: "kimi code",
+ binary: "echo",
+ args: &["0.12.1"],
+ min_version: "0.14.0",
+ };
+ let err = enforce_agent_version(&requirement).expect_err("old version must fail the install");
+ let message = err.to_string();
+ assert!(message.contains("0.12.1"));
+ assert!(message.contains("0.14.0"));
+ assert!(message.contains("upgrade"));
+}
+
+#[cfg(unix)]
+#[test]
+fn enforce_agent_version_accepts_current_version() {
+ let requirement = AgentVersionRequirement {
+ label: "kimi code",
+ binary: "echo",
+ args: &["0.14.0"],
+ min_version: "0.14.0",
+ };
+ let result =
+ enforce_agent_version(&requirement).expect("matching version must not fail the install");
+ assert!(result.is_none(), "matching version must not warn");
+}
+
+fn clear_integration_path_env() {
+ 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(KIMI_CODE_HOME_ENV_VAR);
+ std::env::remove_var("XDG_CONFIG_HOME");
+ std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
+ std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
+}
+
+fn kimi_hook_command(hook_path: &Path, action: &str) -> String {
+ hook_command(hook_path, Some(action))
+}
+
+fn kimi_config_hooks(config: &str) -> Vec<toml::Value> {
+ let parsed: toml::Value = toml::from_str(config).unwrap();
+ parsed
+ .get("hooks")
+ .and_then(toml::Value::as_array)
+ .cloned()
+ .unwrap_or_default()
+}
+
+fn assert_kimi_hook(config: &str, hook_path: &Path, event: &str, action: &str) {
+ let command = kimi_hook_command(hook_path, action);
+ let hooks = kimi_config_hooks(config);
+ assert!(
+ hooks.iter().any(|hook| {
+ hook.get("event").and_then(toml::Value::as_str) == Some(event)
+ && hook.get("command").and_then(toml::Value::as_str) == Some(command.as_str())
+ && hook.get("timeout").and_then(toml::Value::as_integer) == Some(10)
+ }),
+ "missing kimi hook for {event} -> {action}"
+ );
+}
+
+fn unique_base() -> PathBuf {
+ clear_integration_path_env();
+ std::env::temp_dir().join(format!(
+ "herdr-integration-install-test-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ))
+}
+
+#[cfg(windows)]
+#[test]
+fn home_dir_uses_userprofile_when_home_is_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let previous_home = std::env::var_os("HOME");
+ let previous_userprofile = std::env::var_os("USERPROFILE");
+ std::env::remove_var("HOME");
+ std::env::set_var("USERPROFILE", &base);
+
+ assert_eq!(home_dir().unwrap(), base);
+
+ if let Some(home) = previous_home {
+ std::env::set_var("HOME", home);
+ }
+ if let Some(userprofile) = previous_userprofile {
+ std::env::set_var("USERPROFILE", userprofile);
+ } else {
+ std::env::remove_var("USERPROFILE");
+ }
+}
+
+#[cfg(windows)]
+#[test]
+fn windows_supports_only_cli_hook_integrations() {
+ use crate::api::schema::IntegrationTarget;
+
+ assert!(!integration_target_supported(IntegrationTarget::Pi));
+ assert!(!integration_target_supported(IntegrationTarget::Omp));
+ assert!(!integration_target_supported(IntegrationTarget::Opencode));
+ assert!(!integration_target_supported(IntegrationTarget::Kilo));
+ assert!(!integration_target_supported(IntegrationTarget::Hermes));
+ assert!(!integration_target_supported(IntegrationTarget::Cursor));
+ assert!(!integration_target_supported(IntegrationTarget::Devin));
+
+ assert!(integration_target_supported(IntegrationTarget::Claude));
+ assert!(integration_target_supported(IntegrationTarget::Codex));
+ assert!(integration_target_supported(IntegrationTarget::Copilot));
+ assert!(integration_target_supported(IntegrationTarget::Droid));
+ assert!(integration_target_supported(IntegrationTarget::Kimi));
+ assert!(integration_target_supported(IntegrationTarget::Qodercli));
+}
+
+#[cfg(windows)]
+#[test]
+fn windows_does_not_offer_unsupported_integrations_even_when_commands_exist() {
+ use crate::api::schema::IntegrationTarget;
+
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ fs::write(bin.join("pi.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("omp.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("opencode.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("hermes.exe"), "").unwrap();
+ fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap();
+ fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap();
+
+ assert!(!integration_target_available(IntegrationTarget::Pi));
+ assert!(!integration_target_available(IntegrationTarget::Omp));
+ assert!(!integration_target_available(IntegrationTarget::Opencode));
+ assert!(!integration_target_available(IntegrationTarget::Kilo));
+ assert!(!integration_target_available(IntegrationTarget::Hermes));
+ assert!(!integration_target_available(IntegrationTarget::Cursor));
+ assert!(!integration_target_available(IntegrationTarget::Devin));
+
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[cfg(windows)]
+#[test]
+fn windows_install_rejects_unsupported_integration_before_config_lookup() {
+ use crate::api::schema::IntegrationTarget;
+
+ let _lock = integration_env_lock();
+ let original_home = std::env::var_os("HOME");
+ let original_userprofile = std::env::var_os("USERPROFILE");
+ let original_homedrive = std::env::var_os("HOMEDRIVE");
+ let original_homepath = std::env::var_os("HOMEPATH");
+ std::env::remove_var("HOME");
+ std::env::remove_var("USERPROFILE");
+ std::env::remove_var("HOMEDRIVE");
+ std::env::remove_var("HOMEPATH");
+
+ let err = install_target(IntegrationTarget::Pi).unwrap_err();
+ assert_eq!(
+ err.to_string(),
+ "pi integration is not supported on Windows"
+ );
+
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ }
+ if let Some(userprofile) = original_userprofile {
+ std::env::set_var("USERPROFILE", userprofile);
+ }
+ if let Some(homedrive) = original_homedrive {
+ std::env::set_var("HOMEDRIVE", homedrive);
+ }
+ if let Some(homepath) = original_homepath {
+ std::env::set_var("HOMEPATH", homepath);
+ }
+}
+
+#[test]
+#[cfg(unix)]
+fn command_available_requires_executable_file_on_path() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ let command = bin.join("claude");
+ fs::write(&command, "#!/bin/sh\n").unwrap();
+ fs::set_permissions(&command, fs::Permissions::from_mode(0o644)).unwrap();
+ assert!(!command_available("claude"));
+
+ fs::set_permissions(&command, fs::Permissions::from_mode(0o755)).unwrap();
+ assert!(command_available("claude"));
+
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+#[cfg(windows)]
+fn command_available_finds_windows_command_shims_on_path() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ fs::write(bin.join("claude.cmd"), "@echo off\r\n").unwrap();
+ assert!(command_available("claude"));
+
+ fs::write(bin.join("codex.exe"), "").unwrap();
+ assert!(command_available("codex"));
+
+ assert!(!command_available("missing-agent"));
+
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+#[cfg(windows)]
+fn qodercli_availability_checks_windows_aliases() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let bin = base.join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("PATH", &bin);
+
+ fs::write(bin.join("qoder.cmd"), "@echo off\r\n").unwrap();
+
+ assert!(integration_target_available(
+ crate::api::schema::IntegrationTarget::Qodercli
+ ));
+
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+#[cfg(windows)]
+fn hermes_layout_can_exist_without_making_unsupported_target_available() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let local_app_data = base.join("local-app-data");
+ let hermes_bin = local_app_data.join("hermes").join("bin");
+ fs::create_dir_all(&hermes_bin).unwrap();
+ fs::write(hermes_bin.join("hermes.exe"), "").unwrap();
+ let original_local_app_data = std::env::var_os("LOCALAPPDATA");
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("LOCALAPPDATA", &local_app_data);
+ std::env::set_var("PATH", "");
+
+ assert!(hermes_install_layout_available());
+ assert!(!integration_target_available(
+ crate::api::schema::IntegrationTarget::Hermes
+ ));
+
+ if let Some(local_app_data) = original_local_app_data {
+ std::env::set_var("LOCALAPPDATA", local_app_data);
+ } else {
+ std::env::remove_var("LOCALAPPDATA");
+ }
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn codex_availability_finds_standalone_binary_under_codex_home() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let bin = home
+ .join(".codex/packages/standalone/releases/0.137.0-test")
+ .join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let binary = bin.join(codex_executable_name());
+ fs::write(&binary, "").unwrap();
+ make_executable(&binary).unwrap();
+ let original_home = std::env::var_os("HOME");
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("HOME", &home);
+ std::env::set_var("PATH", "");
+
+ assert!(integration_target_available(
+ crate::api::schema::IntegrationTarget::Codex
+ ));
+
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn integration_recommendations_mark_standalone_codex_available() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let bin = home
+ .join(".codex/packages/standalone/releases/0.137.0-test")
+ .join("bin");
+ fs::create_dir_all(&bin).unwrap();
+ let binary = bin.join(codex_executable_name());
+ fs::write(&binary, "").unwrap();
+ make_executable(&binary).unwrap();
+ let original_home = std::env::var_os("HOME");
+ let original_path = std::env::var_os("PATH");
+ std::env::set_var("HOME", &home);
+ std::env::set_var("PATH", "");
+
+ let codex = integration_recommendations()
+ .into_iter()
+ .find(|recommendation| {
+ recommendation.target == crate::api::schema::IntegrationTarget::Codex
+ })
+ .expect("codex recommendation should be present");
+
+ assert!(codex.available);
+ assert_eq!(codex.state, IntegrationStatusKind::NotInstalled);
+ assert!(codex.needs_install());
+
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ if let Some(path) = original_path {
+ std::env::set_var("PATH", path);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn integration_recommendation_installs_available_or_outdated_targets() {
+ let mut recommendation = IntegrationRecommendation {
+ target: crate::api::schema::IntegrationTarget::Claude,
+ label: "claude",
+ command: "claude",
+ available: false,
+ path: PathBuf::from("/tmp/herdr-agent-state.sh"),
+ state: IntegrationStatusKind::NotInstalled,
+ };
+ assert!(!recommendation.needs_install());
+
+ recommendation.available = true;
+ assert!(recommendation.needs_install());
+
+ recommendation.available = false;
+ recommendation.state = IntegrationStatusKind::Outdated;
+ assert!(recommendation.needs_install());
+
+ recommendation.available = true;
+ recommendation.state = IntegrationStatusKind::Current;
+ assert!(!recommendation.needs_install());
+}
+
+#[test]
+fn install_pi_writes_embedded_asset_to_pi_extensions_dir() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".pi/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let path = install_pi().unwrap();
+ let content = fs::read_to_string(&path).unwrap();
+
+ assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
+ assert_eq!(content, PI_EXTENSION_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_pi_uses_pi_coding_agent_dir_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let agent_dir = base.join("custom-pi-agent");
+ let ext_dir = agent_dir.join("extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, &agent_dir);
+
+ let path = install_pi().unwrap();
+
+ assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_pi_expands_tilde_in_pi_coding_agent_dir_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join("custom-pi-agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ std::env::set_var("HOME", &home);
+ std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, "~/custom-pi-agent");
+
+ let path = install_pi().unwrap();
+
+ assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
+
+ std::env::remove_var("HOME");
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_omp_writes_embedded_asset_to_omp_extensions_dir() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".omp/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_omp().unwrap();
+ let content = fs::read_to_string(&installed.extension_path).unwrap();
+
+ assert_eq!(
+ installed.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(!installed.removed_legacy_pi_extension);
+ assert_eq!(content, OMP_EXTENSION_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_omp_removes_legacy_pi_integration_from_omp_extensions_dir() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".omp/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ let legacy_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
+ fs::write(&legacy_path, PI_EXTENSION_ASSET).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_omp().unwrap();
+
+ assert_eq!(
+ installed.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(installed.removed_legacy_pi_extension);
+ assert!(!legacy_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_omp_preserves_non_herdr_file_with_pi_install_name() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".omp/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ let user_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
+ fs::write(&user_path, "// user extension\n").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_omp().unwrap();
+
+ assert_eq!(
+ installed.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(!installed.removed_legacy_pi_extension);
+ assert_eq!(
+ fs::read_to_string(user_path).unwrap(),
+ "// user extension\n"
+ );
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_omp_uses_pi_coding_agent_dir_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let agent_dir = base.join("custom-omp-agent");
+ let ext_dir = agent_dir.join("extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, &agent_dir);
+
+ let installed = install_omp().unwrap();
+
+ assert_eq!(
+ installed.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(!installed.removed_legacy_pi_extension);
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_omp_creates_extensions_dir_when_agent_dir_exists() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let agent_dir = home.join(".omp/agent");
+ let ext_dir = agent_dir.join("extensions");
+ fs::create_dir_all(&agent_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_omp().unwrap();
+
+ assert_eq!(
+ installed.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(ext_dir.is_dir());
+ assert!(!installed.removed_legacy_pi_extension);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_omp_removes_embedded_extension_when_present() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".omp/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ fs::write(
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME),
+ OMP_EXTENSION_ASSET,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_omp().unwrap();
+
+ assert_eq!(
+ result.extension_path,
+ ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
+ );
+ assert!(result.removed_extension);
+ assert!(!result.extension_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_omp_errors_when_extension_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_omp().unwrap_err().to_string();
+
+ assert!(err.contains("omp extension directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_pi_removes_embedded_extension_when_present() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".pi/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_pi().unwrap();
+
+ assert_eq!(
+ result.extension_path,
+ ext_dir.join(PI_EXTENSION_INSTALL_NAME)
+ );
+ assert!(result.removed_extension);
+ assert!(!result.extension_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn outdated_integrations_treat_missing_version_marker_as_legacy() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".pi/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
+ fs::write(&extension_path, "// installed by herdr\n").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let outdated = outdated_installed_integrations();
+
+ assert_eq!(outdated.len(), 1);
+ assert_eq!(
+ outdated[0].target,
+ crate::api::schema::IntegrationTarget::Pi
+ );
+ assert_eq!(outdated[0].path, extension_path);
+ assert_eq!(outdated[0].installed_version, None);
+ assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn outdated_integrations_accept_current_version_marker() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".pi/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
+ std::env::set_var("HOME", &home);
+
+ assert!(outdated_installed_integrations().is_empty());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_pi_errors_when_extension_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_pi().unwrap_err().to_string();
+
+ assert!(err.contains("pi extension directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_claude_writes_hook_and_updates_settings() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ fs::create_dir_all(&claude_dir).unwrap();
+ fs::write(
+ claude_dir.join("settings.json"),
+ r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_claude().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,
+ claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
+ assert!(settings["permissions"]["allow"].is_array());
+ assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*");
+ assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" session"));
+ assert!(settings["hooks"].get("UserPromptSubmit").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
+ assert!(settings["hooks"].get("PostToolUse").is_none());
+ assert!(settings["hooks"].get("PostToolUseFailure").is_none());
+ assert!(settings["hooks"].get("SubagentStop").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_claude_uses_claude_config_dir_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let claude_dir = base.join("custom-claude");
+ fs::create_dir_all(&claude_dir).unwrap();
+ std::env::set_var(CLAUDE_CONFIG_DIR_ENV_VAR, &claude_dir);
+
+ let installed = install_claude().unwrap();
+
+ assert_eq!(installed.settings_path, claude_dir.join("settings.json"));
+ assert_eq!(
+ installed.hook_path,
+ claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
+ );
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_claude_is_idempotent_for_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ fs::create_dir_all(&claude_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_claude().unwrap();
+ install_claude().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
+ .unwrap();
+ assert_eq!(
+ settings["hooks"]["SessionStart"].as_array().unwrap().len(),
+ 1
+ );
+ assert!(settings["hooks"].get("UserPromptSubmit").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
+ assert!(settings["hooks"].get("PostToolUse").is_none());
+ assert!(settings["hooks"].get("PostToolUseFailure").is_none());
+ assert!(settings["hooks"].get("SubagentStop").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_claude_removes_deprecated_completion_hooks_and_preserves_user_hooks() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ let hooks_dir = claude_dir.join("hooks");
+ fs::create_dir_all(&hooks_dir).unwrap();
+ let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ let settings = serde_json::json!({
+ "hooks": {
+ "PostToolUse": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-post", "timeout": 10}
+ ]
+ }],
+ "PostToolUseFailure": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-failure", "timeout": 10}
+ ]
+ }],
+ "SubagentStop": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-subagent", "timeout": 10}
+ ]
+ }],
+ "SessionEnd": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep-session-end", "timeout": 10}
+ ]
+ }]
+ }
+ });
+ fs::write(
+ claude_dir.join("settings.json"),
+ serde_json::to_string(&settings).unwrap(),
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_claude().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
+ .unwrap();
+ assert_eq!(
+ settings["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
+ "echo keep-post"
+ );
+ assert_eq!(
+ settings["hooks"]["PostToolUseFailure"][0]["hooks"][0]["command"],
+ "echo keep-failure"
+ );
+ assert_eq!(
+ settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"],
+ "echo keep-subagent"
+ );
+ assert_eq!(
+ settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"],
+ "echo keep-session-end"
+ );
+ assert!(settings["hooks"].get("UserPromptSubmit").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn claude_v1_integration_status_is_outdated() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_hooks_dir = home.join(".claude").join("hooks");
+ fs::create_dir_all(&claude_hooks_dir).unwrap();
+ let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ fs::write(
+ &hook_path,
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=1\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let statuses = installed_integration_statuses();
+ let claude = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Claude)
+ .unwrap();
+
+ assert_eq!(claude.path, hook_path);
+ assert_eq!(claude.installed_version, Some(1));
+ assert_eq!(claude.expected_version, 7);
+ assert_eq!(claude.state, IntegrationStatusKind::Outdated);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn claude_v2_integration_status_is_outdated() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_hooks_dir = home.join(".claude").join("hooks");
+ fs::create_dir_all(&claude_hooks_dir).unwrap();
+ let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ fs::write(
+ &hook_path,
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=2\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let statuses = installed_integration_statuses();
+ let claude = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Claude)
+ .unwrap();
+
+ assert_eq!(claude.path, hook_path);
+ assert_eq!(claude.installed_version, Some(2));
+ assert_eq!(claude.expected_version, 7);
+ assert_eq!(claude.state, IntegrationStatusKind::Outdated);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_claude_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ let hooks_dir = claude_dir.join("hooks");
+ fs::create_dir_all(&hooks_dir).unwrap();
+ let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CLAUDE_HOOK_ASSET).unwrap();
+ let settings = serde_json::json!({
+ "hooks": {
+ "SessionStart": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
+ }],
+ "UserPromptSubmit": [{
+ "matcher": "*",
+ "hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep", "timeout": 10}
+ ]
+ }],
+ "PermissionRequest": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]
+ }],
+ "PostToolUse": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
+ }],
+ "PostToolUseFailure": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
+ }],
+ "SubagentStop": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
+ }],
+ "Stop": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
+ }],
+ "SessionEnd": [{
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10}]
+ }]
+ }
+ });
+ fs::write(
+ claude_dir.join("settings.json"),
+ serde_json::to_string(&settings).unwrap(),
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_claude().unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
+ .unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+ assert!(!result.hook_path.exists());
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
+ assert!(settings["hooks"].get("SessionStart").is_none());
+ assert!(settings["hooks"].get("PostToolUse").is_none());
+ assert!(settings["hooks"].get("PostToolUseFailure").is_none());
+ assert!(settings["hooks"].get("SubagentStop").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_claude_errors_when_claude_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_claude().unwrap_err().to_string();
+
+ assert!(err.contains("claude directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn codex_v2_integration_status_is_outdated() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
+ fs::write(
+ &hook_path,
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=codex\n# HERDR_INTEGRATION_VERSION=2\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let statuses = installed_integration_statuses();
+ let codex = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Codex)
+ .unwrap();
+
+ assert_eq!(codex.path, hook_path);
+ assert_eq!(codex.installed_version, Some(2));
+ assert_eq!(codex.expected_version, 6);
+ assert_eq!(codex.state, IntegrationStatusKind::Outdated);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_codex_writes_hook_and_updates_hooks_and_config() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.4\"\n").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_codex().unwrap();
+ let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap();
+ let config = fs::read_to_string(&installed.config_path).unwrap();
+
+ assert_eq!(installed.hook_path, codex_dir.join(CODEX_HOOK_INSTALL_NAME));
+ assert_eq!(installed.hooks_path, codex_dir.join("hooks.json"));
+ assert_eq!(installed.config_path, codex_dir.join("config.toml"));
+ assert_eq!(hook_content, CODEX_HOOK_ASSET);
+ assert!(hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" session"));
+ assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
+ assert!(hooks["hooks"].get("PreToolUse").is_none());
+ assert!(hooks["hooks"].get("PermissionRequest").is_none());
+ assert!(hooks["hooks"].get("Stop").is_none());
+ assert!(config.contains("model = \"gpt-5.4\""));
+ assert!(config.contains("[features]"));
+ assert!(config.contains("hooks = true"));
+ assert!(!config.contains("codex_hooks"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_codex_uses_codex_home_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let codex_dir = base.join("custom-codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.4\"\n").unwrap();
+ std::env::set_var(CODEX_HOME_ENV_VAR, &codex_dir);
+
+ let installed = install_codex().unwrap();
+
+ assert_eq!(installed.hook_path, codex_dir.join(CODEX_HOOK_INSTALL_NAME));
+ assert_eq!(installed.hooks_path, codex_dir.join("hooks.json"));
+ assert_eq!(installed.config_path, codex_dir.join("config.toml"));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(
+ codex_dir.join("config.toml"),
+ "[features]\ncodex_hooks = false\nother = true\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_codex().unwrap();
+ install_codex().unwrap();
+
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap()).unwrap();
+ let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
+
+ assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
+ assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
+ assert!(hooks["hooks"].get("PreToolUse").is_none());
+ assert!(hooks["hooks"].get("PermissionRequest").is_none());
+ assert!(hooks["hooks"].get("Stop").is_none());
+ assert_eq!(config.matches("hooks = true").count(), 1);
+ assert!(!config.contains("codex_hooks"));
+ assert!(config.contains("other = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_codex_only_migrates_top_level_feature_flags() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(
+ codex_dir.join("config.toml"),
+ "profile = \"work\"\n\n[profiles.work.features]\nhooks = false\ncodex_hooks = false\n\n[features]\ncodex_hooks = true\nother = true\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_codex().unwrap();
+
+ let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
+
+ assert!(config.contains("[profiles.work.features]\nhooks = false\ncodex_hooks = false"));
+ assert!(config.contains("[features]\nhooks = true\nother = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CODEX_HOOK_ASSET).unwrap();
+ let hooks = serde_json::json!({
+ "hooks": {
+ "SessionStart": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}],
+ "UserPromptSubmit": [{"hooks": [
+ {"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
+ {"type": "command", "command": "echo keep", "timeout": 10}
+ ]}],
+ "PreToolUse": [{"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]}],
+ "PermissionRequest": [{"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]}],
+ "Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}]
+ }
+ });
+ fs::write(
+ codex_dir.join("hooks.json"),
+ serde_json::to_string(&hooks).unwrap(),
+ )
+ .unwrap();
+ fs::write(
+ codex_dir.join("config.toml"),
+ "[features]\nhooks = true\nother = true\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_codex().unwrap();
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap()).unwrap();
+ let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_hooks);
+ assert!(!result.hook_path.exists());
+ assert!(hooks["hooks"].get("SessionStart").is_none());
+ assert!(hooks["hooks"].get("PreToolUse").is_none());
+ assert!(hooks["hooks"].get("PermissionRequest").is_none());
+ assert!(hooks["hooks"].get("Stop").is_none());
+ assert_eq!(
+ hooks["hooks"]["UserPromptSubmit"][0]["hooks"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert!(config.contains("hooks = true"));
+ assert!(config.contains("other = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_codex_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_codex().unwrap_err().to_string();
+
+ assert!(err.contains("codex config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_kimi_writes_hook_and_updates_config() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kimi_dir = home.join(".kimi-code");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ fs::write(
+ kimi_dir.join("config.toml"),
+ "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\nmatcher = \"task.completed\"\ncommand = \"echo keep\"\ntimeout = 3\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_kimi().unwrap();
+ let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
+ let config = fs::read_to_string(&installed.config_path).unwrap();
+ let hooks = kimi_config_hooks(&config);
+
+ assert_eq!(
+ installed.hook_path,
+ kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
+ assert_eq!(hook_content, KIMI_HOOK_ASSET);
+ assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len() + 1);
+ assert!(config.contains("default_model = \"moonshot\""));
+ assert!(config.contains("command = \"echo keep\""));
+ assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN));
+ assert!(config.contains(KIMI_CONFIG_BLOCK_END));
+ for (event, action) in KIMI_HOOK_EVENTS {
+ assert_kimi_hook(&config, &installed.hook_path, event, action);
+ }
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_kimi_uses_kimi_code_home_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let kimi_dir = base.join("custom-kimi");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ std::env::set_var(KIMI_CODE_HOME_ENV_VAR, &kimi_dir);
+
+ let installed = install_kimi().unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.config_path, kimi_dir.join("config.toml"));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_kimi_is_idempotent_for_config_block() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kimi_dir = home.join(".kimi-code");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_kimi().unwrap();
+ install_kimi().unwrap();
+
+ let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap();
+ let hooks = kimi_config_hooks(&config);
+
+ assert_eq!(config.matches(KIMI_CONFIG_BLOCK_BEGIN).count(), 1);
+ assert_eq!(config.matches(KIMI_CONFIG_BLOCK_END).count(), 1);
+ assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_kimi_removes_hook_and_config_block_preserves_other_hooks() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kimi_dir = home.join(".kimi-code");
+ fs::create_dir_all(&kimi_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_kimi().unwrap();
+ fs::write(
+ &installed.config_path,
+ format!(
+ "default_model = \"moonshot\"\n\n[[hooks]]\nevent = \"Notification\"\ncommand = \"echo keep\"\n\n{}",
+ fs::read_to_string(&installed.config_path).unwrap()
+ ),
+ )
+ .unwrap();
+
+ let result = uninstall_kimi().unwrap();
+ let config = fs::read_to_string(kimi_dir.join("config.toml")).unwrap();
+ let hooks = kimi_config_hooks(&config);
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_config);
+ assert!(!result.hook_path.exists());
+ assert!(config.contains("default_model = \"moonshot\""));
+ assert!(config.contains("command = \"echo keep\""));
+ assert!(!config.contains(KIMI_CONFIG_BLOCK_BEGIN));
+ assert!(!config.contains(KIMI_CONFIG_BLOCK_END));
+ assert_eq!(hooks.len(), 1);
+ assert_eq!(
+ hooks[0].get("event").and_then(toml::Value::as_str),
+ Some("Notification")
+ );
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_kimi_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_kimi().unwrap_err().to_string();
+
+ assert!(err.contains("kimi code config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[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();
+ let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME);
+ let stale_session_start_command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ fs::write(
+ copilot_dir.join("settings.json"),
+ format!(
+ r#"{{"theme":"dark","hooks":{{"PreToolUse":[{{"type":"command","command":"echo keep","timeoutSec":10}}],"sessionStart":[{{"type":"command","bash":{},"timeoutSec":10}}]}}}}"#,
+ serde_json::to_string(&stale_session_start_command).unwrap()
+ ),
+ )
+ .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(), 1);
+ assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep");
+ assert!(settings["hooks"]["SessionStart"][0][direct_command_field()]
+ .as_str()
+ .unwrap()
+ .contains(COPILOT_HOOK_INSTALL_NAME));
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ if let Some(entries) = settings["hooks"].get(event) {
+ assert!(
+ !entries.to_string().contains(COPILOT_HOOK_INSTALL_NAME),
+ "expected herdr hooks.{event} entries to be removed"
+ );
+ }
+ }
+ assert!(settings["hooks"].get("sessionStart").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn copilot_v1_integration_status_is_outdated() {
+ 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, COPILOT_INTEGRATION_VERSION);
+ assert_eq!(copilot.state, IntegrationStatusKind::Outdated);
+
+ 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
+ );
+ for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ assert!(
+ settings["hooks"].get(event).is_none(),
+ "expected hooks.{event} to be absent"
+ );
+ }
+ assert!(settings["hooks"].get("sessionStart").is_none());
+
+ 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())
+ );
+ let settings = serde_json::json!({
+ "hooks": {
+ "PreToolUse": [
+ {"type": "command", direct_command_field(): command, "timeoutSec": 10},
+ {"type": "command", "command": "echo keep", "timeoutSec": 10}
+ ],
+ "PostToolUse": [{"type": "command", direct_command_field(): command, "timeoutSec": 10}],
+ "notification": [{
+ "type": "command",
+ "matcher": "permission_prompt|elicitation_dialog|agent_idle",
+ direct_command_field(): command,
+ "timeoutSec": 10
+ }]
+ }
+ });
+ fs::write(
+ copilot_dir.join("settings.json"),
+ serde_json::to_string(&settings).unwrap(),
+ )
+ .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_devin_writes_hook_and_updates_settings() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ fs::write(
+ devin_dir.join("config.json"),
+ r#"{"theme_mode":"dark","hooks":{}}"#,
+ )
+ .unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ let installed = install_devin().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, devin_dir.join(DEVIN_HOOK_INSTALL_NAME));
+ assert_eq!(installed.settings_path, devin_dir.join("config.json"));
+ assert_eq!(hook_content, DEVIN_HOOK_ASSET);
+ assert_eq!(settings["theme_mode"], "dark");
+ for (event, action) in DEVIN_HOOK_EVENTS {
+ let command = settings["hooks"][event][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap();
+ assert!(
+ command.contains(DEVIN_HOOK_INSTALL_NAME) && command.ends_with(action),
+ "expected devin {event} hook command to end with {action}, got {command}"
+ );
+ }
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_devin_is_idempotent_for_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ install_devin().unwrap();
+ install_devin().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()).unwrap();
+ for (event, _) in DEVIN_HOOK_EVENTS {
+ assert_eq!(
+ settings["hooks"][event].as_array().unwrap().len(),
+ 1,
+ "expected hooks.{event} to be idempotent"
+ );
+ }
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_devin_removes_legacy_lifecycle_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
+ let mut hooks = Map::new();
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ hooks.insert(
+ event.to_string(),
+ json!([
+ {
+ "hooks": [{
+ "type": "command",
+ "command": hook_command(&hook_path, Some(action)),
+ "timeout": 10
+ }]
+ }
+ ]),
+ );
+ }
+ fs::write(
+ devin_dir.join("config.json"),
+ serde_json::to_string_pretty(&json!({ "hooks": hooks })).unwrap(),
+ )
+ .unwrap();
+
+ install_devin().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()).unwrap();
+ for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS {
+ let legacy_command = hook_command(&hook_path, Some(action));
+ let entries = settings["hooks"][event].as_array();
+ assert!(
+ entries.is_none_or(|entries| {
+ entries.iter().all(|entry| {
+ entry
+ .get("hooks")
+ .and_then(Value::as_array)
+ .is_none_or(|hooks| {
+ hooks.iter().all(|hook| {
+ hook.get("command").and_then(Value::as_str)
+ != Some(legacy_command.as_str())
+ })
+ })
+ })
+ }),
+ "expected legacy devin {event} -> {action} hook to be removed"
+ );
+
+ if !DEVIN_HOOK_EVENTS
+ .iter()
+ .any(|(installed_event, _)| installed_event == &event)
+ {
+ continue;
+ }
+
+ let session_command = hook_command(&hook_path, Some("session"));
+ let entries = entries.unwrap();
+ assert!(
+ entries.iter().any(|entry| {
+ entry
+ .get("hooks")
+ .and_then(Value::as_array)
+ .is_some_and(|hooks| {
+ hooks.iter().any(|hook| {
+ hook.get("command").and_then(Value::as_str)
+ == Some(session_command.as_str())
+ })
+ })
+ }),
+ "expected devin {event} session hook to be installed"
+ );
+ }
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_devin_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ let devin_dir = xdg_config.join("devin");
+ fs::create_dir_all(&devin_dir).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ install_devin().unwrap();
+
+ let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
+ let mut settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()).unwrap();
+ settings["hooks"]["UserPromptSubmit"]
+ .as_array_mut()
+ .unwrap()
+ .push(json!({
+ "matcher": "*",
+ "hooks": [{
+ "type": "command",
+ "command": "echo keep",
+ "timeout": 10
+ }]
+ }));
+ fs::write(
+ devin_dir.join("config.json"),
+ serde_json::to_string_pretty(&settings).unwrap(),
+ )
+ .unwrap();
+
+ let result = uninstall_devin().unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()).unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+ assert!(!hook_path.exists());
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert!(settings["hooks"].get("SessionStart").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_devin_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let xdg_config = base.join("xdg");
+ fs::create_dir_all(&xdg_config).unwrap();
+ std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
+ std::env::set_var("HOME", base.join("home"));
+
+ let err = install_devin().unwrap_err().to_string();
+ assert!(err.contains("devin config directory not found"));
+
+ clear_integration_path_env();
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_droid_writes_hook_to_settings_and_cleans_legacy_hooks_json() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let droid_dir = home.join(".factory");
+ let legacy_hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME);
+ fs::create_dir_all(legacy_hook_path.parent().unwrap()).unwrap();
+ fs::create_dir_all(&droid_dir).unwrap();
+ let legacy_command = format!(
+ "bash {}",
+ shell_single_quote(&legacy_hook_path.display().to_string())
+ );
+ fs::write(
+ droid_dir.join("hooks.json"),
+ format!(
+ r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}}]}}],"PreToolUse":[{{"matcher":"Read","hooks":[{{"type":"command","command":"echo keep","timeout":10}}]}}]}}}}"#,
+ legacy_command,
+ ),
+ )
+ .unwrap();
+ fs::write(
+ droid_dir.join("settings.json"),
+ r#"{"theme":"factory-dark"}"#,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_droid().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();
+ let legacy_hooks: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.hooks_path, droid_dir.join("hooks.json"));
+ assert_eq!(installed.settings_path, droid_dir.join("settings.json"));
+ assert!(installed.updated_legacy_hooks);
+ assert_eq!(hook_content, DROID_HOOK_ASSET);
+ assert_eq!(settings["theme"], "factory-dark");
+ assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(DROID_HOOK_INSTALL_NAME));
+ assert!(settings["hooks"]["SessionStart"][0]
+ .get("matcher")
+ .is_none());
+ for (event, action) in DROID_HOOK_EVENTS {
+ let command = settings["hooks"][event][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap();
+ assert!(
+ command.contains(DROID_HOOK_INSTALL_NAME) && command.ends_with(action),
+ "expected droid {event} hook command to end with {action}, got {command}"
+ );
+ }
+ assert_eq!(legacy_hooks["hooks"]["PreToolUse"][0]["matcher"], "Read");
+ assert!(legacy_hooks["hooks"].get("SessionStart").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_droid_is_idempotent_for_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let droid_dir = home.join(".factory");
+ fs::create_dir_all(&droid_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_droid().unwrap();
+ install_droid().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap())
+ .unwrap();
+ for (event, _) in DROID_HOOK_EVENTS {
+ assert_eq!(
+ settings["hooks"][event].as_array().unwrap().len(),
+ 1,
+ "expected hooks.{event} to be idempotent"
+ );
+ }
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn droid_v1_integration_status_is_outdated() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let droid_hooks_dir = home.join(".factory").join("hooks");
+ fs::create_dir_all(&droid_hooks_dir).unwrap();
+ let hook_path = droid_hooks_dir.join(DROID_HOOK_INSTALL_NAME);
+ fs::write(
+ &hook_path,
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=droid\n# HERDR_INTEGRATION_VERSION=1\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let statuses = installed_integration_statuses();
+ let droid = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Droid)
+ .unwrap();
+
+ assert_eq!(droid.path, hook_path);
+ assert_eq!(droid.installed_version, Some(1));
+ assert_eq!(droid.expected_version, DROID_INTEGRATION_VERSION);
+ assert_eq!(droid.state, IntegrationStatusKind::Outdated);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_droid_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let droid_dir = home.join(".factory");
+ let hooks_dir = droid_dir.join("hooks");
+ fs::create_dir_all(&hooks_dir).unwrap();
+ let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, DROID_HOOK_ASSET).unwrap();
+ let command = format!(
+ "bash {}",
+ shell_single_quote(&hook_path.display().to_string())
+ );
+ fs::write(
+ droid_dir.join("hooks.json"),
+ format!(
+ r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PreToolUse":[{{"matcher":"Read","hooks":[{{"type":"command","command":"echo read","timeout":10}}]}}]}}}}"#,
+ command,
+ ),
+ )
+ .unwrap();
+ fs::write(
+ droid_dir.join("settings.json"),
+ format!(
+ r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}}]}}],"PostToolUse":[{{"matcher":"Edit","hooks":[{{"type":"command","command":"echo post","timeout":10}}]}}]}}}}"#,
+ command,
+ ),
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_droid().unwrap();
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(droid_dir.join("hooks.json")).unwrap()).unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap())
+ .unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_hooks);
+ assert!(result.updated_settings);
+ assert!(!result.hook_path.exists());
+ assert_eq!(
+ hooks["hooks"]["SessionStart"][0]["hooks"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert_eq!(hooks["hooks"]["PreToolUse"][0]["matcher"], "Read");
+ assert!(settings["hooks"].get("SessionStart").is_none());
+ assert_eq!(settings["hooks"]["PostToolUse"][0]["matcher"], "Edit");
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_droid_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_droid().unwrap_err().to_string();
+
+ assert!(err.contains("droid 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();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_opencode().unwrap();
+ let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
+
+ assert_eq!(
+ installed.plugin_path,
+ opencode_dir
+ .join("plugins")
+ .join(OPENCODE_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_opencode_removes_plugin_when_present() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode/plugins");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ fs::write(
+ opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME),
+ OPENCODE_PLUGIN_ASSET,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_opencode().unwrap();
+
+ assert!(result.removed_plugin);
+ assert!(!result.plugin_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_opencode_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_opencode().unwrap_err().to_string();
+
+ assert!(err.contains("opencode config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_kilo_writes_plugin_to_plugin_dir() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kilo_dir = home.join(".config/kilo");
+ fs::create_dir_all(&kilo_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_kilo().unwrap();
+ let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
+
+ assert_eq!(
+ installed.plugin_path,
+ kilo_dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(plugin_content, KILO_PLUGIN_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_kilo_removes_plugin_when_present() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let kilo_plugin_dir = home.join(".config/kilo/plugin");
+ fs::create_dir_all(&kilo_plugin_dir).unwrap();
+ fs::write(
+ kilo_plugin_dir.join(KILO_PLUGIN_INSTALL_NAME),
+ KILO_PLUGIN_ASSET,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_kilo().unwrap();
+
+ assert!(result.removed_plugin);
+ assert!(!result.plugin_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_kilo_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_kilo().unwrap_err().to_string();
+
+ assert!(err.contains("kilo config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_hermes_writes_plugin_and_enables_it() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(hermes_dir.join("config.yaml"), "model:\n provider: auto\n").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_hermes().unwrap();
+ let manifest = fs::read_to_string(
+ installed
+ .plugin_dir
+ .join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME),
+ )
+ .unwrap();
+ let init =
+ fs::read_to_string(installed.plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)).unwrap();
+ let config = fs::read_to_string(&installed.config_path).unwrap();
+
+ assert_eq!(
+ installed.plugin_dir,
+ hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(manifest, HERMES_PLUGIN_MANIFEST_ASSET);
+ assert_eq!(init, HERMES_PLUGIN_INIT_ASSET);
+ assert!(config.contains("plugins:\n enabled:\n - herdr-agent-state"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_hermes_is_idempotent_for_enabled_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n enabled:\n - herdr-agent-state\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_hermes().unwrap();
+ install_hermes().unwrap();
+
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+ assert_eq!(config.matches("herdr-agent-state").count(), 1);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_hermes_preserves_flat_plugin_list() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n - platforms/discord\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_hermes().unwrap();
+
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+ assert_eq!(
+ config,
+ "plugins:\n - herdr-agent-state\n - platforms/discord\n"
+ );
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_hermes_converts_flow_plugin_list_to_block_list() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins: [platforms/discord]\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_hermes().unwrap();
+
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+ assert_eq!(
+ config,
+ "plugins:\n - herdr-agent-state\n - platforms/discord\n"
+ );
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_hermes_is_idempotent_for_quoted_flat_plugin_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n - \"herdr-agent-state\" # installed by herdr\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_hermes().unwrap();
+
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+ assert_eq!(
+ config,
+ "plugins:\n - \"herdr-agent-state\" # installed by herdr\n"
+ );
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_hermes_removes_plugin_and_enabled_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )
+ .unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n enabled:\n - other-plugin\n - herdr-agent-state\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_hermes().unwrap();
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+
+ assert!(result.removed_plugin_dir);
+ assert!(result.updated_config);
+ assert!(!plugin_dir.exists());
+ assert!(config.contains(" - other-plugin"));
+ assert!(!config.contains("herdr-agent-state"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_hermes_preserves_flat_plugin_list() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )
+ .unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n - other-plugin\n - herdr-agent-state\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_hermes().unwrap();
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+
+ assert!(result.removed_plugin_dir);
+ assert!(result.updated_config);
+ assert_eq!(config, "plugins:\n - other-plugin\n");
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_hermes_removes_flow_plugin_list_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )
+ .unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins: [other-plugin, herdr-agent-state]\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_hermes().unwrap();
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+
+ assert!(result.removed_plugin_dir);
+ assert!(result.updated_config);
+ assert_eq!(config, "plugins:\n - other-plugin\n");
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_hermes_removes_commented_flat_plugin_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )
+ .unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n - other-plugin\n - herdr-agent-state # installed by herdr\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_hermes().unwrap();
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+
+ assert!(result.removed_plugin_dir);
+ assert!(result.updated_config);
+ assert_eq!(config, "plugins:\n - other-plugin\n");
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_hermes_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_hermes().unwrap_err().to_string();
+
+ assert!(err.contains("hermes config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn bundled_integration_assets_report_session_refs() {
+ assert!(PI_EXTENSION_ASSET.contains("agent_session_path"));
+ assert!(PI_EXTENSION_ASSET.contains("agent_session_id"));
+ assert!(PI_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
+ assert!(PI_EXTENSION_ASSET.contains("pane.report_agent_session"));
+ assert!(PI_EXTENSION_ASSET.contains("pane.report_agent\""));
+ assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
+ assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_end\""));
+ assert!(PI_EXTENSION_ASSET.contains("pane.release_agent"));
+ assert!(PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
+ assert!(OMP_EXTENSION_ASSET.contains("agent_session_path"));
+ assert!(OMP_EXTENSION_ASSET.contains("agent_session_id"));
+ assert!(OMP_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
+ assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent_session"));
+ assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent\""));
+ assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
+ assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_end\""));
+ assert!(OMP_EXTENSION_ASSET.contains("pane.release_agent"));
+ assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
+ assert!(
+ CLAUDE_HOOK_ASSET.contains("agent_session_id")
+ || CLAUDE_HOOK_ASSET.contains("--agent-session-id")
+ );
+ assert!(
+ CLAUDE_HOOK_ASSET.contains("agent_session_path")
+ || CLAUDE_HOOK_ASSET.contains("--agent-session-path")
+ );
+ assert!(CLAUDE_HOOK_ASSET.contains("agent_id"));
+ assert!(
+ CLAUDE_HOOK_ASSET.contains("session_start_source")
+ || CLAUDE_HOOK_ASSET.contains("--session-start-source")
+ );
+ assert!(
+ CLAUDE_HOOK_ASSET.contains("pane.report_agent_session")
+ || CLAUDE_HOOK_ASSET.contains("report-agent-session")
+ );
+ assert!(!CLAUDE_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!CLAUDE_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(
+ CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")
+ || CODEX_HOOK_ASSET.contains("In.ReadToEnd")
+ );
+ assert!(
+ CODEX_HOOK_ASSET.contains("agent_session_id")
+ || CODEX_HOOK_ASSET.contains("--agent-session-id")
+ );
+ assert!(
+ CODEX_HOOK_ASSET.contains("session_start_source")
+ || CODEX_HOOK_ASSET.contains("--session-start-source")
+ );
+ assert!(
+ CODEX_HOOK_ASSET.contains("pane.report_agent_session")
+ || CODEX_HOOK_ASSET.contains("report-agent-session")
+ );
+ assert!(!CODEX_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\""));
+ assert!(KIMI_HOOK_ASSET.contains("agent_session_id"));
+ assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(KIMI_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(COPILOT_HOOK_ASSET.contains("agent_session_id"));
+ assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!COPILOT_HOOK_ASSET.contains("\"state\":"));
+ assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(DEVIN_HOOK_ASSET.contains("HERDR_DEVIN_LIST_JSON"));
+ assert!(DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent_session\""));
+ assert!(!DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent\""));
+ assert!(!DEVIN_HOOK_ASSET.contains("\"state\":"));
+ assert!(!DEVIN_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(DEVIN_HOOK_ASSET.contains("agent_session_id"));
+ assert!(DROID_HOOK_ASSET.contains("agent_session_id"));
+ assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!DROID_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!DROID_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("params.agent_session_id = sessionID"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session"));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("reportState"));
+ assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
+ assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\""));
+ assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\""));
+ assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session"));
+ assert!(KILO_PLUGIN_ASSET.contains("reportState"));
+ assert!(!KILO_PLUGIN_ASSET.contains("pane.release_agent"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\","));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end"));
+ assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize"));
+ assert!(!HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent"));
+ assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
+ assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id"));
+ assert!(QODERCLI_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!QODERCLI_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!QODERCLI_HOOK_ASSET.contains("pane.release_agent"));
+ assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT"));
+ assert!(CURSOR_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=cursor"));
+ assert!(CURSOR_HOOK_ASSET.contains("conversation_id"));
+ assert!(CURSOR_HOOK_ASSET.contains("conversationId"));
+ assert!(CURSOR_HOOK_ASSET.contains("sessionId"));
+ assert!(CURSOR_HOOK_ASSET.contains("agent_session_id"));
+ assert!(CURSOR_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(CURSOR_HOOK_ASSET.contains("hook_event_name"));
+ assert!(CURSOR_HOOK_ASSET.contains("sessionStart"));
+ assert!(!CURSOR_HOOK_ASSET.contains("\"state\":"));
+ assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent"));
+}
+
+#[test]
+fn pi_extension_releases_only_for_quit_session_shutdown() {
+ let release_policy = PI_EXTENSION_ASSET
+ .find("function shouldReleaseOnSessionShutdown")
+ .expect("pi extension should centralize session shutdown release policy");
+ let quit_check = PI_EXTENSION_ASSET
+ .find("reason === \"quit\"")
+ .expect("pi extension should release only for true quit shutdowns");
+ let shutdown_handler = PI_EXTENSION_ASSET
+ .find("pi.on(\"session_shutdown\", async (event)")
+ .expect("pi extension should inspect the session_shutdown event");
+ let guarded_release = PI_EXTENSION_ASSET[shutdown_handler..]
+ .find("if (shouldReleaseOnSessionShutdown(event))")
+ .expect("pi extension should guard releaseAgent by shutdown reason");
+
+ assert!(release_policy < shutdown_handler);
+ assert!(release_policy < quit_check);
+ assert!(quit_check < shutdown_handler);
+ assert!(guarded_release > 0);
+}
+
+#[test]
+fn pi_extension_refreshes_session_ref_before_agent_start_state() {
+ let agent_start = PI_EXTENSION_ASSET
+ .find("pi.on(\"agent_start\", (_event, ctx)")
+ .expect("pi extension should receive agent_start context");
+ let handler = &PI_EXTENSION_ASSET[agent_start..];
+ let update_session = handler
+ .find("updateSessionRef(ctx);")
+ .expect("pi extension should refresh the active session on agent_start");
+ let report_session = handler
+ .find("void reportSession();")
+ .expect("pi extension should report the refreshed session before state");
+ let publish_state = handler
+ .find("publishState();")
+ .expect("pi extension should publish working state after refreshing session");
+
+ assert!(update_session < report_session);
+ assert!(report_session < publish_state);
+}
+
+#[test]
+fn omp_extension_releases_only_for_quit_session_shutdown() {
+ let release_policy = OMP_EXTENSION_ASSET
+ .find("function shouldReleaseOnSessionShutdown")
+ .expect("omp extension should centralize session shutdown release policy");
+ let quit_check = OMP_EXTENSION_ASSET
+ .find("reason === \"quit\"")
+ .expect("omp extension should release only for true quit shutdowns");
+ let shutdown_handler = OMP_EXTENSION_ASSET
+ .find("pi.on(\"session_shutdown\", async (event)")
+ .expect("omp extension should inspect the session_shutdown event");
+ let guarded_release = OMP_EXTENSION_ASSET[shutdown_handler..]
+ .find("if (shouldReleaseOnSessionShutdown(event))")
+ .expect("omp extension should guard releaseAgent by shutdown reason");
+
+ assert!(release_policy < shutdown_handler);
+ assert!(release_policy < quit_check);
+ assert!(quit_check < shutdown_handler);
+ assert!(guarded_release > 0);
+}
+
+#[test]
+fn omp_extension_refreshes_session_ref_before_agent_start_state() {
+ let agent_start = OMP_EXTENSION_ASSET
+ .find("pi.on(\"agent_start\", (_event, ctx)")
+ .expect("omp extension should receive agent_start context");
+ let handler = &OMP_EXTENSION_ASSET[agent_start..];
+ let update_session = handler
+ .find("updateSessionRef(ctx);")
+ .expect("omp extension should refresh the active session on agent_start");
+ let report_session = handler
+ .find("void reportSession();")
+ .expect("omp extension should report the refreshed session before state");
+ let publish_state = handler
+ .find("publishState();")
+ .expect("omp extension should publish working state after refreshing session");
+
+ assert!(update_session < report_session);
+ assert!(report_session < publish_state);
+}
+
+#[test]
+fn omp_session_hook_ignores_non_ui_sessions() {
+ let session_start_handler = OMP_EXTENSION_ASSET
+ .find("pi.on(\"session_start\"")
+ .expect("omp extension registers session_start handler");
+ let non_ui_guard = OMP_EXTENSION_ASSET
+ .find("ctx?.hasUI !== true")
+ .expect("omp extension checks UI context");
+ let session_report = OMP_EXTENSION_ASSET
+ .find("void reportSession()")
+ .expect("omp extension reports root session");
+
+ assert!(session_start_handler < non_ui_guard);
+ assert!(non_ui_guard < session_report);
+}
+
+#[test]
+fn install_qodercli_writes_hook_and_updates_settings() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let qoder_dir = base.join(".qoder");
+ fs::create_dir_all(&qoder_dir).unwrap();
+ fs::write(
+ qoder_dir.join("settings.json"),
+ r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
+ )
+ .unwrap();
+ std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
+
+ let installed = install_qodercli().unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ qoder_dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.settings_path, qoder_dir.join("settings.json"));
+ assert!(installed.hook_path.is_file());
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap();
+ let hooks = settings
+ .get("hooks")
+ .and_then(Value::as_object)
+ .expect("hooks should be present");
+ for (event, action) in QODERCLI_HOOK_EVENTS {
+ assert!(
+ hooks.contains_key(event),
+ "expected hooks.{event} to be registered"
+ );
+ let command = hooks[event][0]["hooks"][0]["command"].as_str().unwrap();
+ assert!(
+ command.contains(QODERCLI_HOOK_INSTALL_NAME) && command.ends_with(action),
+ "expected qodercli {event} hook command to end with {action}, got {command}"
+ );
+ }
+ // Pre-existing settings keys must be preserved.
+ assert!(settings.get("permissions").is_some());
+
+ std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_qodercli_is_idempotent_for_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let qoder_dir = base.join(".qoder");
+ fs::create_dir_all(&qoder_dir).unwrap();
+ std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
+
+ install_qodercli().unwrap();
+ install_qodercli().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
+ .unwrap();
+ let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
+ for (event, _) in QODERCLI_HOOK_EVENTS {
+ let entries = hooks.get(event).and_then(Value::as_array).unwrap();
+ assert_eq!(
+ entries.len(),
+ 1,
+ "expected hooks.{event} to contain exactly one entry, got {entries:?}"
+ );
+ }
+
+ std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_qodercli_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let qoder_dir = base.join(".qoder");
+ fs::create_dir_all(&qoder_dir).unwrap();
+ std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir);
+
+ install_qodercli().unwrap();
+ // Inject a foreign hook entry the user might have configured by hand.
+ let mut settings: Value =
+ serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
+ .unwrap();
+ settings["hooks"]["SessionStart"]
+ .as_array_mut()
+ .unwrap()
+ .push(json!({
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": "echo user-defined"}],
+ }));
+ fs::write(
+ qoder_dir.join("settings.json"),
+ serde_json::to_string_pretty(&settings).unwrap(),
+ )
+ .unwrap();
+
+ let result = uninstall_qodercli().unwrap();
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap())
+ .unwrap();
+ let hooks = settings.get("hooks").and_then(Value::as_object).unwrap();
+ let remaining = hooks.get("SessionStart").and_then(Value::as_array).unwrap();
+ assert_eq!(remaining.len(), 1);
+ let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap();
+ assert_eq!(cmd, "echo user-defined");
+
+ std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_qodercli_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let missing = base.join(".qoder");
+ std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &missing);
+
+ let err = install_qodercli().unwrap_err().to_string();
+ assert!(
+ err.contains("qodercli config directory not found"),
+ "unexpected error: {err}"
+ );
+
+ std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_cursor_writes_hook_and_updates_hooks_json() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let cursor_dir = base.join(".cursor");
+ fs::create_dir_all(&cursor_dir).unwrap();
+ fs::write(
+ cursor_dir.join("hooks.json"),
+ r#"{"version":1,"hooks":{"stop":[{"command":"echo keep-me"}]}}"#,
+ )
+ .unwrap();
+ std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
+
+ let installed = install_cursor().unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ cursor_dir.join(CURSOR_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.hooks_path, cursor_dir.join("hooks.json"));
+ assert_eq!(
+ fs::read_to_string(&installed.hook_path).unwrap(),
+ CURSOR_HOOK_ASSET
+ );
+
+ let hooks_file: Value =
+ serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()).unwrap();
+ let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
+ let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap();
+ assert_eq!(session_start.len(), 1);
+ assert!(session_start[0]
+ .get("command")
+ .and_then(Value::as_str)
+ .is_some_and(|command| {
+ command.starts_with("bash ")
+ && command.contains("herdr-agent-state.sh")
+ && command.ends_with(" session")
+ }));
+ assert!(hooks.get("beforeSubmitPrompt").is_none());
+ assert!(hooks.get("beforeShellExecution").is_none());
+ let stop = hooks.get("stop").and_then(Value::as_array).unwrap();
+ assert_eq!(stop.len(), 1);
+ assert_eq!(
+ stop[0].get("command").and_then(Value::as_str),
+ Some("echo keep-me")
+ );
+
+ std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_cursor_is_idempotent_for_hook_entries() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let cursor_dir = base.join(".cursor");
+ fs::create_dir_all(&cursor_dir).unwrap();
+ std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
+
+ install_cursor().unwrap();
+ install_cursor().unwrap();
+
+ let hooks_file: Value =
+ serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()).unwrap();
+ let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
+ let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap();
+ assert_eq!(session_start.len(), 1);
+
+ std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_cursor_removes_herdr_hooks_and_preserves_others() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let cursor_dir = base.join(".cursor");
+ fs::create_dir_all(&cursor_dir).unwrap();
+ std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
+
+ install_cursor().unwrap();
+ let mut hooks_file: Value =
+ serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()).unwrap();
+ hooks_file["hooks"]["beforeSubmitPrompt"] = json!([{ "command": "echo user-defined" }]);
+ fs::write(
+ cursor_dir.join("hooks.json"),
+ serde_json::to_string_pretty(&hooks_file).unwrap(),
+ )
+ .unwrap();
+
+ let result = uninstall_cursor().unwrap();
+ assert!(result.removed_hook_file);
+ assert!(result.updated_hooks);
+ assert!(!cursor_dir.join(CURSOR_HOOK_INSTALL_NAME).is_file());
+
+ let hooks_file: Value =
+ serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()).unwrap();
+ let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap();
+ assert!(!hooks.contains_key("sessionStart"));
+ assert!(hooks.contains_key("beforeSubmitPrompt"));
+
+ std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_cursor_uses_cursor_config_dir_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let cursor_dir = base.join("custom-cursor");
+ fs::create_dir_all(&cursor_dir).unwrap();
+ std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
+
+ let installed = install_cursor().unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ cursor_dir.join(CURSOR_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(installed.hooks_path, cursor_dir.join("hooks.json"));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn cursor_v1_integration_status_is_current() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let cursor_dir = base.join(".cursor");
+ fs::create_dir_all(&cursor_dir).unwrap();
+ let hook_path = cursor_dir.join(CURSOR_HOOK_INSTALL_NAME);
+ fs::write(
+ &hook_path,
+ "#!/bin/sh\n# HERDR_INTEGRATION_ID=cursor\n# HERDR_INTEGRATION_VERSION=1\n",
+ )
+ .unwrap();
+ std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir);
+
+ let statuses = installed_integration_statuses();
+ let cursor = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Cursor)
+ .expect("cursor integration status");
+ assert_eq!(cursor.state, IntegrationStatusKind::Current);
+ assert_eq!(cursor.installed_version, Some(CURSOR_INTEGRATION_VERSION));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_cursor_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let missing = base.join(".cursor");
+ std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &missing);
+
+ let err = install_cursor().unwrap_err().to_string();
+ assert!(
+ err.contains("cursor config directory not found"),
+ "unexpected error: {err}"
+ );
+
+ std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
diff --git a/src/integration/types.rs b/src/integration/types.rs
new file mode 100644
index 00000000..c6ab7a0b
--- /dev/null
+++ b/src/integration/types.rs
@@ -0,0 +1,215 @@
+use std::path::PathBuf;
+
+#[derive(Debug)]
+pub(crate) struct ClaudeInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct CodexInstallPaths {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub config_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct KimiInstallPaths {
+ pub hook_path: PathBuf,
+ pub config_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct CopilotInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct DevinInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct DroidInstallPaths {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub updated_legacy_hooks: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct OpenCodeInstallPaths {
+ pub plugin_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct KiloInstallPaths {
+ pub plugin_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct OmpInstallPaths {
+ pub extension_path: PathBuf,
+ pub removed_legacy_pi_extension: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct HermesInstallPaths {
+ pub plugin_dir: PathBuf,
+ pub config_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct QodercliInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct CursorInstallPaths {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct CursorUninstallResult {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_hooks: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct QodercliUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct IntegrationStatus {
+ pub target: crate::api::schema::IntegrationTarget,
+ pub path: PathBuf,
+ pub state: IntegrationStatusKind,
+ pub installed_version: Option<u32>,
+ pub expected_version: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum IntegrationStatusKind {
+ NotInstalled,
+ Current,
+ Outdated,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct IntegrationRecommendation {
+ pub target: crate::api::schema::IntegrationTarget,
+ pub label: &'static str,
+ pub command: &'static str,
+ pub available: bool,
+ pub path: PathBuf,
+ pub state: IntegrationStatusKind,
+}
+
+impl IntegrationRecommendation {
+ pub fn needs_install(&self) -> bool {
+ self.state == IntegrationStatusKind::Outdated
+ || (self.available && self.state == IntegrationStatusKind::NotInstalled)
+ }
+
+ pub fn status_label(&self) -> &'static str {
+ match (self.available, self.state) {
+ (_, IntegrationStatusKind::Current) => "installed",
+ (_, IntegrationStatusKind::Outdated) => "update available",
+ (true, IntegrationStatusKind::NotInstalled) => "available",
+ (false, IntegrationStatusKind::NotInstalled) => "not found",
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct PiUninstallResult {
+ pub extension_path: PathBuf,
+ pub removed_extension: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct OmpUninstallResult {
+ pub extension_path: PathBuf,
+ pub removed_extension: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct ClaudeUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct CodexUninstallResult {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub config_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_hooks: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct KimiUninstallResult {
+ pub hook_path: PathBuf,
+ pub config_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_config: bool,
+}
+
+#[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 DevinUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct DroidUninstallResult {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_hooks: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct OpenCodeUninstallResult {
+ pub plugin_path: PathBuf,
+ pub removed_plugin: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct KiloUninstallResult {
+ pub plugin_path: PathBuf,
+ pub removed_plugin: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct HermesUninstallResult {
+ pub plugin_dir: PathBuf,
+ pub config_path: PathBuf,
+ pub removed_plugin_dir: bool,
+ pub updated_config: bool,
+}
diff --git a/src/integration/version.rs b/src/integration/version.rs
new file mode 100644
index 00000000..c9c2af8d
--- /dev/null
+++ b/src/integration/version.rs
@@ -0,0 +1,89 @@
+use std::io;
+
+pub(crate) struct AgentVersionRequirement {
+ pub label: &'static str,
+ pub binary: &'static str,
+ pub args: &'static [&'static str],
+ pub min_version: &'static str,
+}
+
+pub(crate) fn agent_version_requirement(
+ target: crate::api::schema::IntegrationTarget,
+) -> Option<AgentVersionRequirement> {
+ match target {
+ crate::api::schema::IntegrationTarget::Kimi => Some(AgentVersionRequirement {
+ label: "kimi code",
+ binary: "kimi",
+ args: &["--version"],
+ min_version: super::KIMI_MIN_VERSION,
+ }),
+ _ => None,
+ }
+}
+
+pub(crate) fn extract_version_triple(text: &str) -> Option<(u64, u64, u64)> {
+ text.split_whitespace().find_map(|token| {
+ let token = token.trim_start_matches('v');
+ let mut parts = token.splitn(3, '.');
+ let major: u64 = parts.next()?.parse().ok()?;
+ let minor: u64 = parts.next()?.parse().ok()?;
+ let patch: u64 = parts
+ .next()
+ .map(|rest| {
+ rest.chars()
+ .take_while(|c| c.is_ascii_digit())
+ .collect::<String>()
+ })
+ .and_then(|digits| digits.parse().ok())
+ .unwrap_or(0);
+ Some((major, minor, patch))
+ })
+}
+
+/// Returns `Ok(None)` when the installed agent satisfies the requirement,
+/// `Ok(Some(warning))` when the version cannot be determined (install
+/// proceeds), and `Err` when the installed agent is too old.
+pub(crate) fn enforce_agent_version(
+ requirement: &AgentVersionRequirement,
+) -> io::Result<Option<String>> {
+ let probe = format!("{} {}", requirement.binary, requirement.args.join(" "));
+ let output = match std::process::Command::new(requirement.binary)
+ .args(requirement.args)
+ .output()
+ {
+ Ok(output) if output.status.success() => output,
+ _ => {
+ return Ok(Some(format!(
+ "{} could not run `{probe}` to verify the installed version; hooks require {} {} or newer",
+ super::INSTALL_WARNING_PREFIX,
+ requirement.label,
+ requirement.min_version
+ )));
+ }
+ };
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let Some(found) = extract_version_triple(&stdout) else {
+ return Ok(Some(format!(
+ "{} could not parse the {} version from `{probe}` output; hooks require {} {} or newer",
+ super::INSTALL_WARNING_PREFIX,
+ requirement.label,
+ requirement.label,
+ requirement.min_version
+ )));
+ };
+ let required = extract_version_triple(requirement.min_version)
+ .expect("static min version must be a valid version triple");
+
+ if found < required {
+ return Err(io::Error::other(format!(
+ "{label} {}.{}.{} is too old: herdr hooks require {label} {min} or newer. upgrade {label}, then re-run install",
+ found.0,
+ found.1,
+ found.2,
+ label = requirement.label,
+ min = requirement.min_version
+ )));
+ }
+ Ok(None)
+}