aboutsummaryrefslogtreecommitdiff
path: root/src/integration/registry.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/registry.rs
parent3c7327c267a352023797c8a13fd0b7f4a7a40591 (diff)
refactor: split integration module
Diffstat (limited to 'src/integration/registry.rs')
-rw-r--r--src/integration/registry.rs410
1 files changed, 410 insertions, 0 deletions
diff --git a/src/integration/registry.rs b/src/integration/registry.rs
new file mode 100644
index 00000000..e68dce0c
--- /dev/null
+++ b/src/integration/registry.rs
@@ -0,0 +1,410 @@
+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<PathBuf> {
+ 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<super::IntegrationStatus> {
+ 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<super::IntegrationRecommendation> {
+ 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<super::IntegrationStatus> {
+ installed_integration_statuses()
+ .into_iter()
+ .filter(|status| status.state == super::IntegrationStatusKind::Outdated)
+ .collect()
+}
+
+fn integration_specs() -> [(
+ crate::api::schema::IntegrationTarget,
+ io::Result<PathBuf>,
+ 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<String> = 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::<Vec<_>>();
+ 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<u32> {
+ 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()
+ })
+}