use std::fs; use std::io; use std::path::{Path, PathBuf}; use super::env::*; pub(crate) fn integration_target_label( target: crate::api::schema::IntegrationTarget, ) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", crate::api::schema::IntegrationTarget::Omp => "omp", crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Copilot => "copilot", crate::api::schema::IntegrationTarget::Devin => "devin", crate::api::schema::IntegrationTarget::Droid => "droid", crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Kilo => "kilo", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Cursor => "cursor", } } pub(crate) fn integration_target_command( target: crate::api::schema::IntegrationTarget, ) -> &'static str { integration_target_command_names(target)[0] } pub(crate) fn integration_target_command_names( target: crate::api::schema::IntegrationTarget, ) -> &'static [&'static str] { match target { crate::api::schema::IntegrationTarget::Pi => &["pi"], crate::api::schema::IntegrationTarget::Omp => &["omp"], crate::api::schema::IntegrationTarget::Claude => &["claude"], crate::api::schema::IntegrationTarget::Codex => &["codex"], crate::api::schema::IntegrationTarget::Copilot => &["copilot"], crate::api::schema::IntegrationTarget::Devin => &["devin"], crate::api::schema::IntegrationTarget::Droid => &["droid"], crate::api::schema::IntegrationTarget::Kimi => &["kimi"], crate::api::schema::IntegrationTarget::Opencode => &["opencode"], crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"], crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), } } pub(crate) fn cursor_command_names() -> &'static [&'static str] { &["cursor-agent"] } pub(crate) fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool { #[cfg(windows)] { matches!( target, crate::api::schema::IntegrationTarget::Claude | crate::api::schema::IntegrationTarget::Codex | crate::api::schema::IntegrationTarget::Copilot | crate::api::schema::IntegrationTarget::Droid | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli ) } #[cfg(not(windows))] { let _ = target; true } } pub(crate) fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool { if !integration_target_supported(target) { return false; } integration_target_command_names(target) .iter() .any(|command| command_available(command)) || integration_target_install_layout_available(target) } #[cfg(windows)] pub(crate) fn qodercli_command_names() -> &'static [&'static str] { &["qodercli", "qoder", "qoderclicn", "qodercn"] } #[cfg(not(windows))] pub(crate) fn qodercli_command_names() -> &'static [&'static str] { &["qodercli"] } pub(crate) fn integration_target_install_layout_available( target: crate::api::schema::IntegrationTarget, ) -> bool { match target { crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(), crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(), _ => false, } } pub(crate) fn command_available(command: &str) -> bool { let Some(paths) = std::env::var_os("PATH") else { return false; }; std::env::split_paths(&paths).any(|dir| { command_path_candidates(&dir, command) .into_iter() .any(|path| executable_file_exists(&path)) }) } pub(crate) fn command_path_candidates(dir: &Path, command: &str) -> Vec { let base = dir.join(command); #[cfg(not(windows))] { vec![base] } #[cfg(windows)] { if Path::new(command).extension().is_some() { return vec![base]; } let mut candidates = vec![base]; for extension in [".exe", ".cmd", ".bat", ".ps1"] { candidates.push(dir.join(format!("{command}{extension}"))); } candidates } } pub(crate) fn executable_file_exists(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; if !metadata.is_file() { return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] { true } } pub(crate) fn codex_standalone_binary_available() -> bool { let Ok(releases_dir) = codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases")) else { return false; }; let Ok(entries) = fs::read_dir(releases_dir) else { return false; }; entries.filter_map(Result::ok).any(|entry| { executable_file_exists(&entry.path().join("bin").join(codex_executable_name())) }) } pub(crate) fn codex_executable_name() -> &'static str { if cfg!(windows) { "codex.exe" } else { "codex" } } pub(crate) fn hermes_install_layout_available() -> bool { #[cfg(windows)] { let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty()) else { return false; }; let dir = PathBuf::from(local_app_data).join("hermes"); [ dir.join("hermes.exe"), dir.join("bin").join("hermes.exe"), dir.join("Scripts").join("hermes.exe"), ] .into_iter() .any(|path| executable_file_exists(&path)) } #[cfg(not(windows))] { false } } pub(crate) fn installed_integration_statuses() -> Vec { integration_specs() .into_iter() .filter_map(|(target, path, expected_version)| { if !integration_target_supported(target) { return None; } Some(integration_status_at(target, path.ok()?, expected_version)) }) .collect() } pub(crate) fn integration_recommendations() -> Vec { integration_specs() .into_iter() .filter_map(|(target, path, expected_version)| { if !integration_target_supported(target) { return None; } let path = path.ok()?; let status = integration_status_at(target, path.clone(), expected_version); Some(super::IntegrationRecommendation { target, label: integration_target_label(target), command: integration_target_command(target), available: integration_target_available(target) || status.state != super::IntegrationStatusKind::NotInstalled, path, state: status.state, }) }) .collect() } pub(crate) fn outdated_installed_integrations() -> Vec { installed_integration_statuses() .into_iter() .filter(|status| status.state == super::IntegrationStatusKind::Outdated) .collect() } fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, ); 13] { [ ( crate::api::schema::IntegrationTarget::Pi, pi_extension_dir().map(|dir| dir.join(super::PI_EXTENSION_INSTALL_NAME)), super::PI_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Omp, omp_extension_dir().map(|dir| dir.join(super::OMP_EXTENSION_INSTALL_NAME)), super::OMP_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Claude, claude_dir().map(|dir| dir.join("hooks").join(super::CLAUDE_HOOK_INSTALL_NAME)), super::CLAUDE_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Codex, codex_dir().map(|dir| dir.join(super::CODEX_HOOK_INSTALL_NAME)), super::CODEX_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Copilot, copilot_dir().map(|dir| dir.join("hooks").join(super::COPILOT_HOOK_INSTALL_NAME)), super::COPILOT_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Devin, devin_dir().map(|dir| dir.join(super::DEVIN_HOOK_INSTALL_NAME)), super::DEVIN_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Droid, droid_dir().map(|dir| dir.join("hooks").join(super::DROID_HOOK_INSTALL_NAME)), super::DROID_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Kimi, kimi_dir().map(|dir| dir.join("hooks").join(super::KIMI_HOOK_INSTALL_NAME)), super::KIMI_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Opencode, opencode_dir().map(|dir| { dir.join("plugins") .join(super::OPENCODE_PLUGIN_INSTALL_NAME) }), super::OPENCODE_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Kilo, kilo_dir().map(|dir| dir.join("plugin").join(super::KILO_PLUGIN_INSTALL_NAME)), super::KILO_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Hermes, hermes_plugin_dir().map(|dir| dir.join(super::HERMES_PLUGIN_INIT_INSTALL_NAME)), super::HERMES_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Qodercli, qodercli_dir().map(|dir| dir.join("hooks").join(super::QODERCLI_HOOK_INSTALL_NAME)), super::QODERCLI_INTEGRATION_VERSION, ), ( crate::api::schema::IntegrationTarget::Cursor, cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)), super::CURSOR_INTEGRATION_VERSION, ), ] } pub(crate) fn integration_update_instructions( targets: &[crate::api::schema::IntegrationTarget], ) -> String { let commands: Vec = targets .iter() .map(|target| { format!( "`herdr integration install {}`", integration_target_label(*target) ) }) .collect(); match commands.as_slice() { [] => String::new(), [command] => format!("run {command}"), [rest @ .., last] => format!("run {} and {last}", rest.join(", ")), } } pub(crate) fn print_outdated_update_notice() -> bool { let outdated = outdated_installed_integrations(); if outdated.is_empty() { return false; } let targets = outdated .iter() .map(|integration| integration.target) .collect::>(); eprintln!( "installed herdr integrations need updating; {}.", integration_update_instructions(&targets).replace('`', "") ); true } pub(crate) fn integration_status_at( target: crate::api::schema::IntegrationTarget, path: PathBuf, expected_version: u32, ) -> super::IntegrationStatus { if !path.is_file() { return super::IntegrationStatus { target, path, state: super::IntegrationStatusKind::NotInstalled, installed_version: None, expected_version, }; } let installed_version = fs::read_to_string(&path) .ok() .and_then(|content| parse_integration_version(&content)); let state = if installed_version.is_some_and(|version| version >= expected_version) { super::IntegrationStatusKind::Current } else { super::IntegrationStatusKind::Outdated }; super::IntegrationStatus { target, path, state, installed_version, expected_version, } } pub(crate) fn parse_integration_version(content: &str) -> Option { content.lines().find_map(|line| { let marker_line = line .trim() .trim_start_matches('/') .trim_start_matches('#') .trim(); marker_line .strip_prefix(super::INTEGRATION_VERSION_MARKER)? .trim() .parse() .ok() }) }