summaryrefslogtreecommitdiff
path: root/src/integration/targets.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-25 20:50:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-25 20:50:13 +0300
commit12cce7deeb2d27f7b11daece75881fcfd88ea607 (patch)
tree843769b293a2e3cd9ad8c752637307741f6a702e /src/integration/targets.rs
parent3c7327c267a352023797c8a13fd0b7f4a7a40591 (diff)
refactor: split integration module
Diffstat (limited to 'src/integration/targets.rs')
-rw-r--r--src/integration/targets.rs1108
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,
+ })
+}