use std::fs; use std::io; use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::{Mutex, MutexGuard, OnceLock}; use portable_pty::CommandBuilder; use serde_json::{json, Map, Value}; use crate::layout::PaneId; pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_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 = 1; const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR"; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); const CLAUDE_INTEGRATION_VERSION: u32 = 2; const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR"; const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); const CODEX_INTEGRATION_VERSION: u32 = 3; const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); const OPENCODE_INTEGRATION_VERSION: u32 = 1; const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml"); const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py"); const HERMES_INTEGRATION_VERSION: u32 = 1; 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 OpenCodeInstallPaths { pub plugin_path: PathBuf, } #[derive(Debug)] pub(crate) struct HermesInstallPaths { pub plugin_dir: PathBuf, pub config_path: PathBuf, } #[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 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 OpenCodeUninstallResult { 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_env(cmd: &mut CommandBuilder, pane_id: PaneId) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); cmd.env(HERDR_PANE_ID_ENV_VAR, format!("p_{}", pane_id.raw())); } pub(crate) fn install_target( target: crate::api::schema::IntegrationTarget, ) -> io::Result> { let messages = match target { crate::api::schema::IntegrationTarget::Pi => { let path = install_pi()?; vec![format!("installed pi integration to {}", path.display())] } 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::Opencode => { let installed = install_opencode()?; vec![format!( "installed opencode 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::logging::integration_action("install", integration_target_label(target), "ok"); 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::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::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::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::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::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", } } fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", } } fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool { command_available(integration_target_command(target)) } fn command_available(command: &str) -> bool { let Some(paths) = std::env::var_os("PATH") else { return false; }; std::env::split_paths(&paths).any(|dir| executable_file_exists(&dir.join(command))) } 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 installed_integration_statuses() -> Vec { integration_specs() .into_iter() .filter_map(|(target, path, expected_version)| { 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)| { 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, ); 5] { [ ( crate::api::schema::IntegrationTarget::Pi, pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)), PI_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::Opencode, opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)), OPENCODE_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Hermes, hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)), HERMES_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_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", )?; let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); remove_command_hook( hooks, "PostToolUse", &format!("bash {quoted_hook_path} working"), )?; remove_command_hook( hooks, "PostToolUseFailure", &format!("bash {quoted_hook_path} working"), )?; ensure_command_hook( hooks, "UserPromptSubmit", format!("bash {quoted_hook_path} working"), 10, Some("*"), )?; ensure_command_hook( hooks, "PreToolUse", format!("bash {quoted_hook_path} working"), 10, Some("*"), )?; ensure_command_hook( hooks, "PermissionRequest", format!("bash {quoted_hook_path} blocked"), 10, Some("*"), )?; ensure_command_hook( hooks, "SubagentStop", format!("bash {quoted_hook_path} working"), 10, Some("*"), )?; ensure_command_hook( hooks, "Stop", format!("bash {quoted_hook_path} idle"), 10, Some("*"), )?; ensure_command_hook( hooks, "SessionEnd", format!("bash {quoted_hook_path} release"), 10, Some("*"), )?; 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", )?; let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); ensure_command_hook( hooks, "SessionStart", format!("bash {quoted_hook_path} idle"), 10, None, )?; ensure_command_hook( hooks, "UserPromptSubmit", format!("bash {quoted_hook_path} working"), 10, None, )?; ensure_command_hook( hooks, "PreToolUse", format!("bash {quoted_hook_path} working"), 10, None, )?; ensure_command_hook( hooks, "PermissionRequest", format!("bash {quoted_hook_path} blocked"), 10, None, )?; ensure_command_hook( hooks, "Stop", format!("bash {quoted_hook_path} idle"), 10, None, )?; 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_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_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_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", )? { let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); updated_settings |= remove_command_hook( hooks, "UserPromptSubmit", &format!("bash {quoted_hook_path} working"), )?; updated_settings |= remove_command_hook( hooks, "PreToolUse", &format!("bash {quoted_hook_path} working"), )?; updated_settings |= remove_command_hook( hooks, "PermissionRequest", &format!("bash {quoted_hook_path} blocked"), )?; updated_settings |= remove_command_hook( hooks, "PostToolUse", &format!("bash {quoted_hook_path} working"), )?; updated_settings |= remove_command_hook( hooks, "PostToolUseFailure", &format!("bash {quoted_hook_path} working"), )?; updated_settings |= remove_command_hook( hooks, "SubagentStop", &format!("bash {quoted_hook_path} working"), )?; updated_settings |= remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; updated_settings |= remove_command_hook( hooks, "SessionEnd", &format!("bash {quoted_hook_path} release"), )?; } if updated_settings { fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; } } let removed_hook_file = remove_file_if_exists(&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", )? { let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); updated_hooks |= remove_command_hook( hooks, "SessionStart", &format!("bash {quoted_hook_path} idle"), )?; updated_hooks |= remove_command_hook( hooks, "UserPromptSubmit", &format!("bash {quoted_hook_path} working"), )?; updated_hooks |= remove_command_hook( hooks, "PreToolUse", &format!("bash {quoted_hook_path} working"), )?; updated_hooks |= remove_command_hook( hooks, "PermissionRequest", &format!("bash {quoted_hook_path} blocked"), )?; updated_hooks |= remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} 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)?; Ok(CodexUninstallResult { hook_path, hooks_path, config_path, removed_hook_file, updated_hooks, }) } 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_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, }) } 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(()) } 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 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), } } 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 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); 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_value(line) == Some(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 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 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_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 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 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 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 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 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 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 home_dir() -> io::Result { std::env::var("HOME") .map(PathBuf::from) .map_err(|_| io::Error::other("HOME 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::*; 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); } 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() )) } #[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] 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 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"]["UserPromptSubmit"][0]["matcher"], "*"); assert!( settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" working") ); assert!(settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" working")); assert!( settings["hooks"]["PermissionRequest"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" blocked") ); assert!(settings["hooks"].get("PostToolUse").is_none()); assert!(settings["hooks"].get("PostToolUseFailure").is_none()); assert!(settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" working")); assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" idle")); assert!(settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" release")); 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"]["UserPromptSubmit"] .as_array() .unwrap() .len(), 1 ); assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); assert_eq!( settings["hooks"]["PermissionRequest"] .as_array() .unwrap() .len(), 1 ); assert!(settings["hooks"].get("PostToolUse").is_none()); assert!(settings["hooks"].get("PostToolUseFailure").is_none()); assert_eq!( settings["hooks"]["SubagentStop"].as_array().unwrap().len(), 1 ); assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1); assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } #[test] fn install_claude_removes_deprecated_post_tool_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); fs::write( claude_dir.join("settings.json"), format!( r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}]}}}}"#, hook_path.display(), hook_path.display(), ), ) .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"]["UserPromptSubmit"] .as_array() .unwrap() .len(), 1 ); assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1); 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, 2); 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(); fs::write( claude_dir.join("settings.json"), format!( r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#, hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), ), ) .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("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, 3); 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(" idle")); assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" working")); assert!(hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" working")); assert!( hooks["hooks"]["PermissionRequest"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" blocked") ); assert!(hooks["hooks"]["Stop"][0]["hooks"][0]["command"] .as_str() .unwrap() .contains(" idle")); 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_eq!( hooks["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1 ); assert_eq!(hooks["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); assert_eq!( hooks["hooks"]["PermissionRequest"] .as_array() .unwrap() .len(), 1 ); assert_eq!(hooks["hooks"]["Stop"].as_array().unwrap().len(), 1); 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(); fs::write( codex_dir.join("hooks.json"), format!( r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PreToolUse":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PermissionRequest":[{{"hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"Stop":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}]}}}}"#, hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), ), ) .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_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_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 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 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); } }