diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-25 20:50:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-25 20:50:13 +0300 |
| commit | 12cce7deeb2d27f7b11daece75881fcfd88ea607 (patch) | |
| tree | 843769b293a2e3cd9ad8c752637307741f6a702e /src/integration/targets.rs | |
| parent | 3c7327c267a352023797c8a13fd0b7f4a7a40591 (diff) | |
refactor: split integration module
Diffstat (limited to 'src/integration/targets.rs')
| -rw-r--r-- | src/integration/targets.rs | 1108 |
1 files changed, 1108 insertions, 0 deletions
diff --git a/src/integration/targets.rs b/src/integration/targets.rs new file mode 100644 index 00000000..a47f0dae --- /dev/null +++ b/src/integration/targets.rs @@ -0,0 +1,1108 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use super::command::{hook_command, shell_single_quote}; +use super::config_edit::{ + build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook, + ensure_direct_command_hook, ensure_hermes_plugin_enabled, ensure_hooks_object, + ensure_simple_command_hook, hooks_object_if_present, remove_direct_hook_commands, + remove_hermes_plugin_enabled, remove_hook_commands, remove_kimi_config_block, + remove_simple_command_hook, +}; +use super::env::{ + claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, hermes_dir, + hermes_plugin_dir, kilo_dir, kimi_dir, omp_extension_dir, opencode_dir, pi_extension_dir, + qodercli_dir, +}; +use super::file_ops::{ + make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, +}; +use super::types::{ + ClaudeInstallPaths, ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, + CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, + DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, + HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, KiloUninstallResult, + KimiInstallPaths, KimiUninstallResult, OmpInstallPaths, OmpUninstallResult, + OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, + QodercliUninstallResult, +}; +use super::{ + CLAUDE_HOOK_ASSET, CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, + COPILOT_HOOK_ASSET, COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME, + COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME, + DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, DEVIN_HOOK_INSTALL_NAME, + DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, DROID_HOOK_EVENTS, + DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, HERMES_PLUGIN_INIT_ASSET, + HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, + HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, + KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, + OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, + PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, + QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; + +pub(crate) fn install_pi() -> io::Result<PathBuf> { + let dir = pi_extension_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "pi extension directory not found at {}. install pi and create the extensions directory first", + dir.display() + ))); + } + + let path = dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&path, PI_EXTENSION_ASSET)?; + Ok(path) +} + +pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> { + let dir = omp_extension_dir()?; + if !dir.is_dir() { + if dir.parent().is_some_and(|parent| parent.is_dir()) { + fs::create_dir_all(&dir)?; + } else { + return Err(io::Error::other(format!( + "omp extension directory not found at {}. install omp and create the extensions directory first", + dir.display() + ))); + } + } + + if !dir.is_dir() { + return Err(io::Error::other(format!( + "omp extension directory not found at {}. install omp and create the extensions directory first", + dir.display() + ))); + } + + let removed_legacy_pi_extension = remove_legacy_pi_extension_from_omp_dir(&dir)?; + let extension_path = dir.join(OMP_EXTENSION_INSTALL_NAME); + fs::write(&extension_path, OMP_EXTENSION_ASSET)?; + Ok(OmpInstallPaths { + extension_path, + removed_legacy_pi_extension, + }) +} + +pub(crate) fn remove_legacy_pi_extension_from_omp_dir(dir: &Path) -> io::Result<bool> { + let legacy_path = dir.join(PI_EXTENSION_INSTALL_NAME); + if !legacy_path.is_file() { + return Ok(false); + } + + let content = fs::read_to_string(&legacy_path)?; + if content.contains("HERDR_INTEGRATION_ID=pi") { + fs::remove_file(legacy_path)?; + return Ok(true); + } + + Ok(false) +} + +pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> { + let dir = claude_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "claude directory not found at {}. install claude code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME); + fs::write(&hook_path, CLAUDE_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "claude settings", + "claude settings hooks", + )?; + remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + ensure_command_hook( + hooks, + "SessionStart", + hook_command(&hook_path, Some("session")), + 10, + Some("*"), + )?; + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(ClaudeInstallPaths { + hook_path, + settings_path, + }) +} + +pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> { + let dir = codex_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "codex config directory not found at {}. install codex first", + dir.display() + ))); + } + + let hook_path = dir.join(CODEX_HOOK_INSTALL_NAME); + fs::write(&hook_path, CODEX_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let hooks_path = dir.join("hooks.json"); + let mut hooks_file = if hooks_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut hooks_file, + &hooks_path, + "codex hooks file", + "codex hooks file hooks", + )?; + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + ensure_command_hook( + hooks, + "SessionStart", + hook_command(&hook_path, Some("session")), + 10, + None, + )?; + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + let config_path = dir.join("config.toml"); + let existing_config = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + String::new() + }; + let new_config = build_codex_config_with_hooks(&existing_config); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + } + + Ok(CodexInstallPaths { + hook_path, + hooks_path, + config_path, + }) +} + +pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> { + let dir = kimi_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "kimi code config directory not found at {}. install kimi code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(KIMI_HOOK_INSTALL_NAME); + fs::write(&hook_path, KIMI_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let config_path = dir.join("config.toml"); + let existing_config = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + String::new() + }; + let new_config = build_kimi_config_with_hooks(&existing_config, &hook_path); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + } + remove_legacy_bash_hook_file(&hook_path)?; + + Ok(KimiInstallPaths { + hook_path, + config_path, + }) +} + +pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { + let dir = copilot_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "copilot config directory not found at {}. install github copilot cli first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(COPILOT_HOOK_INSTALL_NAME); + fs::write(&hook_path, COPILOT_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "copilot settings", + "copilot settings hooks", + )?; + let command = hook_command(&hook_path, None); + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_HOOK_EVENTS { + remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_HOOK_EVENTS { + ensure_direct_command_hook(hooks, event, command.clone(), 10, None)?; + } + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(CopilotInstallPaths { + hook_path, + settings_path, + }) +} + +pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> { + let dir = devin_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "devin config directory not found at {}. install devin cli first", + dir.display() + ))); + } + + let hook_path = dir.join(DEVIN_HOOK_INSTALL_NAME); + fs::write(&hook_path, DEVIN_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let settings_path = dir.join("config.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "devin settings", + "devin settings hooks", + )?; + for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DEVIN_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DEVIN_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + None, + )?; + } + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(DevinInstallPaths { + hook_path, + settings_path, + }) +} + +pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { + let dir = droid_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "droid config directory not found at {}. install droid first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME); + fs::write(&hook_path, DROID_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "droid settings", + "droid settings hooks", + )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + None, + )?; + } + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + let hooks_path = dir.join("hooks.json"); + let mut updated_legacy_hooks = false; + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "droid hooks file", + "droid hooks file hooks", + )? { + updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_legacy_hooks |= + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_legacy_hooks |= + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + } + if updated_legacy_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + Ok(DroidInstallPaths { + hook_path, + hooks_path, + settings_path, + updated_legacy_hooks, + }) +} + +pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> { + let dir = opencode_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "opencode config directory not found at {}. install opencode first", + dir.display() + ))); + } + + let plugins_dir = dir.join("plugins"); + fs::create_dir_all(&plugins_dir)?; + + let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME); + fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?; + + Ok(OpenCodeInstallPaths { plugin_path }) +} + +pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> { + let dir = kilo_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "kilo config directory not found at {}. install kilo first", + dir.display() + ))); + } + + let plugins_dir = dir.join("plugin"); + fs::create_dir_all(&plugins_dir)?; + + let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME); + fs::write(&plugin_path, KILO_PLUGIN_ASSET)?; + + Ok(KiloInstallPaths { plugin_path }) +} + +pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> { + let dir = hermes_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "hermes config directory not found at {}. install hermes agent first", + dir.display() + ))); + } + + let plugin_dir = hermes_plugin_dir()?; + fs::create_dir_all(&plugin_dir)?; + fs::write( + plugin_dir.join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME), + HERMES_PLUGIN_MANIFEST_ASSET, + )?; + fs::write( + plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME), + HERMES_PLUGIN_INIT_ASSET, + )?; + + let config_path = dir.join("config.yaml"); + let existing_config = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + String::new() + }; + let new_config = ensure_hermes_plugin_enabled(&existing_config); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + } + + Ok(HermesInstallPaths { + plugin_dir, + config_path, + }) +} + +pub(crate) fn uninstall_pi() -> io::Result<PiUninstallResult> { + let extension_path = pi_extension_dir()?.join(PI_EXTENSION_INSTALL_NAME); + let removed_extension = remove_file_if_exists(&extension_path)?; + + Ok(PiUninstallResult { + extension_path, + removed_extension, + }) +} + +pub(crate) fn uninstall_omp() -> io::Result<OmpUninstallResult> { + let extension_path = omp_extension_dir()?.join(OMP_EXTENSION_INSTALL_NAME); + let removed_extension = remove_file_if_exists(&extension_path)?; + + Ok(OmpUninstallResult { + extension_path, + removed_extension, + }) +} + +pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> { + let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME); + let settings_path = claude_dir()?.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "claude settings", + "claude settings hooks", + )? { + updated_settings |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + updated_settings |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + updated_settings |= + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + updated_settings |= + remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?; + updated_settings |= + remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?; + updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + updated_settings |= + remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(ClaudeUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + +pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> { + let codex_dir = codex_dir()?; + let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME); + let hooks_path = codex_dir.join("hooks.json"); + let config_path = codex_dir.join("config.toml"); + let mut updated_hooks = false; + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "codex hooks file", + "codex hooks file hooks", + )? { + updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; + updated_hooks |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + updated_hooks |= + remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; + updated_hooks |= + remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; + updated_hooks |= + remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; + updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + } + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(CodexUninstallResult { + hook_path, + hooks_path, + config_path, + removed_hook_file, + updated_hooks, + }) +} + +pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> { + let kimi_dir = kimi_dir()?; + let hook_path = kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME); + let config_path = kimi_dir.join("config.toml"); + let mut updated_config = false; + + if config_path.is_file() { + let existing_config = fs::read_to_string(&config_path)?; + let new_config = remove_kimi_config_block(&existing_config); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + updated_config = true; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(KimiUninstallResult { + hook_path, + config_path, + removed_hook_file, + updated_config, + }) +} + +pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { + let copilot_dir = copilot_dir()?; + let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME); + let settings_path = copilot_dir.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "copilot settings", + "copilot settings hooks", + )? { + for event in COPILOT_HOOK_EVENTS { + updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(CopilotUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + +pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> { + let devin_dir = devin_dir()?; + let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME); + let settings_path = devin_dir.join("config.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "devin settings", + "devin settings hooks", + )? { + for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DEVIN_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(DevinUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + +pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { + let droid_dir = droid_dir()?; + let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME); + let hooks_path = droid_dir.join("hooks.json"); + let settings_path = droid_dir.join("settings.json"); + let mut updated_hooks = false; + let mut updated_settings = false; + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "droid hooks file", + "droid hooks file hooks", + )? { + updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + } + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "droid settings", + "droid settings hooks", + )? { + updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(DroidUninstallResult { + hook_path, + hooks_path, + settings_path, + removed_hook_file, + updated_hooks, + updated_settings, + }) +} + +pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { + let plugin_path = opencode_dir()? + .join("plugins") + .join(OPENCODE_PLUGIN_INSTALL_NAME); + let removed_plugin = remove_file_if_exists(&plugin_path)?; + + Ok(OpenCodeUninstallResult { + plugin_path, + removed_plugin, + }) +} + +pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> { + let plugin_path = kilo_dir()?.join("plugin").join(KILO_PLUGIN_INSTALL_NAME); + let removed_plugin = remove_file_if_exists(&plugin_path)?; + + Ok(KiloUninstallResult { + plugin_path, + removed_plugin, + }) +} + +pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> { + let dir = hermes_dir()?; + let plugin_dir = hermes_plugin_dir()?; + let config_path = dir.join("config.yaml"); + + let removed_plugin_dir = remove_dir_all_if_exists(&plugin_dir)?; + let mut updated_config = false; + if config_path.is_file() { + let existing_config = fs::read_to_string(&config_path)?; + let new_config = remove_hermes_plugin_enabled(&existing_config); + if new_config != existing_config { + fs::write(&config_path, new_config)?; + updated_config = true; + } + } + + Ok(HermesUninstallResult { + plugin_dir, + config_path, + removed_plugin_dir, + updated_config, + }) +} + +pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { + let dir = qodercli_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "qodercli config directory not found at {}. install qodercli first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(QODERCLI_HOOK_INSTALL_NAME); + fs::write(&hook_path, QODERCLI_HOOK_ASSET)?; + make_executable(&hook_path)?; + + // Register the hook in ~/.qoder/settings.json. The schema mirrors claude + // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level + // `hooks` object keyed by event name, each entry holding a matcher + a + // list of `{type: "command", command, timeout?}` invocations. The hook + // script reads the event payload from stdin via `hook_event_name`. + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "qodercli settings", + "qodercli settings hooks", + )?; + for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + Some("*"), + )?; + } + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(QodercliInstallPaths { + hook_path, + settings_path, + }) +} + +pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> { + let dir = cursor_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "cursor config directory not found at {}. install cursor agent cli first", + dir.display() + ))); + } + + let hook_path = dir.join(CURSOR_HOOK_INSTALL_NAME); + fs::write(&hook_path, CURSOR_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let hooks_path = dir.join("hooks.json"); + let mut hooks_file = if hooks_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({ "version": 1 }) + }; + + if hooks_file.get("version").is_none() { + hooks_file + .as_object_mut() + .ok_or_else(|| { + io::Error::other(format!( + "cursor hooks file at {} must be a JSON object", + hooks_path.display() + )) + })? + .insert("version".to_string(), json!(1)); + } + + let hooks = ensure_hooks_object( + &mut hooks_file, + &hooks_path, + "cursor hooks file", + "cursor hooks file hooks", + )?; + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + let session_command = format!("bash {quoted_hook_path} session"); + remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?; + remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?; + remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?; + remove_simple_command_hook(hooks, "stop", &session_command)?; + remove_simple_command_hook(hooks, "sessionEnd", &session_command)?; + ensure_simple_command_hook(hooks, "sessionStart", session_command)?; + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + Ok(CursorInstallPaths { + hook_path, + hooks_path, + }) +} + +pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { + let hook_path = qodercli_dir()? + .join("hooks") + .join(QODERCLI_HOOK_INSTALL_NAME); + let settings_path = qodercli_dir()?.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "qodercli settings", + "qodercli settings hooks", + )? { + for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(QodercliUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + +pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> { + let cursor_home = cursor_dir()?; + let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME); + let hooks_path = cursor_home.join("hooks.json"); + let mut updated_hooks = false; + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "cursor hooks file", + "cursor hooks file hooks", + )? { + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + let session_command = format!("bash {quoted_hook_path} session"); + updated_hooks |= remove_simple_command_hook(hooks, "sessionStart", &session_command)?; + updated_hooks |= + remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?; + updated_hooks |= + remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?; + updated_hooks |= + remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?; + updated_hooks |= remove_simple_command_hook(hooks, "stop", &session_command)?; + updated_hooks |= remove_simple_command_hook(hooks, "sessionEnd", &session_command)?; + } + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(CursorUninstallResult { + hook_path, + hooks_path, + removed_hook_file, + updated_hooks, + }) +} |
