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