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> { + 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> { + 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> { + 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> { + 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>> { + 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, + 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, + 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, + event: &str, + command: &str, +) -> io::Result { + 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, + event: &str, + command: &str, +) -> io::Result { + 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, + 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, + event: &str, + command: &str, +) -> io::Result { + 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, + event: &str, + hook_path: &Path, + action: Option<&str>, +) -> io::Result { + 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, + event: &str, + hook_path: &Path, + action: Option<&str>, +) -> io::Result { + 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 { + 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, 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 = 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 { + 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 { + 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 { + 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 { + 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> { + 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(¤t)); + current.clear(); + } + _ => current.push(ch), + } + } + + if quote.is_some() { + return None; + } + + items.push(yaml_scalar_value(¤t)); + 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, 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 = 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, 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 { + 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 { + Ok( + config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])? + .join("extensions"), + ) +} + +pub(crate) fn claude_dir() -> io::Result { + config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"]) +} + +pub(crate) fn codex_dir() -> io::Result { + config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"]) +} + +pub(crate) fn kimi_dir() -> io::Result { + config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"]) +} + +pub(crate) fn copilot_dir() -> io::Result { + config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"]) +} + +pub(crate) fn devin_dir() -> io::Result { + 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 { + Ok(home_dir()?.join(".factory")) +} + +pub(crate) fn config_dir_from_env_or_home( + env_var: &str, + home_relative_segments: &[&str], +) -> io::Result { + 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 { + 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 { + Ok(home_dir()?.join(".config/opencode")) +} + +pub(crate) fn kilo_dir() -> io::Result { + Ok(home_dir()?.join(".config/kilo")) +} + +pub(crate) fn hermes_dir() -> io::Result { + Ok(home_dir()?.join(".hermes")) +} + +pub(crate) fn hermes_plugin_dir() -> io::Result { + Ok(hermes_dir()? + .join("plugins") + .join(super::HERMES_PLUGIN_INSTALL_NAME)) +} + +pub(crate) fn qodercli_dir() -> io::Result { + config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"]) +} + +pub(crate) fn cursor_dir() -> io::Result { + config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) +} + +pub(crate) fn home_dir() -> io::Result { + 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> = 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 { + 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 { + 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 { + Ok(false) +} + +pub(crate) fn remove_dir_all_if_exists(path: &Path) -> io::Result { + 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}; +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}; -use portable_pty::CommandBuilder; -use serde_json::{json, Map, Value}; - -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, - 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 { - 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::() - }) - .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> { - 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> { - 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> { - 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> { - 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 { - 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 { - 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 { - 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 { - installed_integration_statuses() - .into_iter() - .filter(|status| status.state == IntegrationStatusKind::Outdated) - .collect() -} - -fn integration_specs() -> [( - crate::api::schema::IntegrationTarget, - io::Result, - 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 = 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::>(); - 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 { - 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 { - 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 { - 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 { - 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 { - 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::(&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 { - 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::(&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 { - 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 { - 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::(&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 { - 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::(&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 { - 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::(&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::(&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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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::(&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 { - 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::(&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 { - 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 { - 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::(&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 { - 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::(&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 { - 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::(&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::(&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 { - 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 { - 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 { - 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 { - 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::(&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 { - 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::(&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 { - 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::(&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 { - 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::(&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> { - 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>> { - 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, - 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, - 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, - event: &str, - command: &str, -) -> io::Result { - 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, - event: &str, - command: &str, -) -> io::Result { - 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, - 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, - event: &str, - command: &str, -) -> io::Result { - 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, - event: &str, - hook_path: &Path, - action: Option<&str>, -) -> io::Result { - 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, - event: &str, - hook_path: &Path, - action: Option<&str>, -) -> io::Result { - 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 { - 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, 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 { - 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 { - 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 { - Ok(false) -} - -fn remove_dir_all_if_exists(path: &Path) -> io::Result { - 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 = 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 { - 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 { - lines - .iter() - .position(|line| yaml_key_at_indent(line, 0) == Some(key)) -} - -fn next_top_level_yaml_key_index(lines: &[String], start: usize) -> Option { - 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 { - 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> { - 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(¤t)); - current.clear(); - } - _ => current.push(ch), - } - } - - if quote.is_some() { - return None; - } - - items.push(yaml_scalar_value(¤t)); - 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, 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 = 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, 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 { - Ok( - config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".pi", "agent"])? - .join("extensions"), - ) -} - -fn omp_extension_dir() -> io::Result { - Ok( - config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])? - .join("extensions"), - ) -} - -fn claude_dir() -> io::Result { - config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"]) -} - -fn codex_dir() -> io::Result { - config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"]) -} - -fn kimi_dir() -> io::Result { - config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"]) -} - -fn copilot_dir() -> io::Result { - config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"]) -} - -fn devin_dir() -> io::Result { - 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 { - Ok(home_dir()?.join(".factory")) -} - -fn config_dir_from_env_or_home( - env_var: &str, - home_relative_segments: &[&str], -) -> io::Result { - 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 { - 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 { - Ok(home_dir()?.join(".config/opencode")) -} - -fn kilo_dir() -> io::Result { - Ok(home_dir()?.join(".config/kilo")) -} - -fn hermes_dir() -> io::Result { - Ok(home_dir()?.join(".hermes")) -} - -fn hermes_plugin_dir() -> io::Result { - Ok(hermes_dir()? - .join("plugins") - .join(HERMES_PLUGIN_INSTALL_NAME)) -} - -fn qodercli_dir() -> io::Result { - config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"]) -} - -fn cursor_dir() -> io::Result { - config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) -} - -fn home_dir() -> io::Result { - 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> = 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 { - 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 { + 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 { + 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 { + 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 { + installed_integration_statuses() + .into_iter() + .filter(|status| status.state == super::IntegrationStatusKind::Outdated) + .collect() +} + +fn integration_specs() -> [( + crate::api::schema::IntegrationTarget, + io::Result, + 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 = 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::>(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(&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 { + 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::(&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 { + 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 { + 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::(&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 { + 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::(&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 { + 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::(&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::(&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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(&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 { + 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::(&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 { + 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 { + 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::(&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 { + 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::(&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 { + 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::(&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::(&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 { + 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 { + 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 { + 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 { + 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::(&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 { + 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::(&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 { + 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::(&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 { + 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::(&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 { + 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, + 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 { + 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::() + }) + .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> { + 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) +}