diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-19 23:58:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 00:00:16 +0300 |
| commit | fb93e3747544afc3288d7b5e831d5ca3dd5c3768 (patch) | |
| tree | 95ed3208395ebe68dc49fbd7d8edb7e16597ba49 /src/integration | |
| parent | 2bebac461b1d5d6a1ec42d70c2338cf27eee8c56 (diff) | |
feat: surface agent integrations in onboarding
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/mod.rs | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/src/integration/mod.rs b/src/integration/mod.rs index c484a8f1..ae3756b0 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -73,6 +73,32 @@ pub(crate) enum IntegrationStatusKind { Outdated, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IntegrationRecommendation { + pub target: crate::api::schema::IntegrationTarget, + pub label: &'static str, + pub command: &'static str, + pub available: bool, + pub path: PathBuf, + pub state: IntegrationStatusKind, +} + +impl IntegrationRecommendation { + pub fn needs_install(&self) -> bool { + self.state == IntegrationStatusKind::Outdated + || (self.available && self.state == IntegrationStatusKind::NotInstalled) + } + + pub fn status_label(&self) -> &'static str { + match (self.available, self.state) { + (_, IntegrationStatusKind::Current) => "installed", + (_, IntegrationStatusKind::Outdated) => "update available", + (true, IntegrationStatusKind::NotInstalled) => "available", + (false, IntegrationStatusKind::NotInstalled) => "not found", + } + } +} + #[derive(Debug)] pub(crate) struct PiUninstallResult { pub extension_path: PathBuf, @@ -311,6 +337,47 @@ pub(crate) fn integration_target_label( } } +fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str { + match target { + crate::api::schema::IntegrationTarget::Pi => "pi", + crate::api::schema::IntegrationTarget::Claude => "claude", + crate::api::schema::IntegrationTarget::Codex => "codex", + crate::api::schema::IntegrationTarget::Opencode => "opencode", + crate::api::schema::IntegrationTarget::Hermes => "hermes", + } +} + +fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool { + command_available(integration_target_command(target)) +} + +fn command_available(command: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| executable_file_exists(&dir.join(command))) +} + +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 installed_integration_statuses() -> Vec<IntegrationStatus> { integration_specs() .into_iter() @@ -320,6 +387,25 @@ pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> { .collect() } +pub(crate) fn integration_recommendations() -> Vec<IntegrationRecommendation> { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + let path = path.ok()?; + let status = integration_status_at(target, path.clone(), expected_version); + Some(IntegrationRecommendation { + target, + label: integration_target_label(target), + command: integration_target_command(target), + available: integration_target_available(target) + || status.state != IntegrationStatusKind::NotInstalled, + path, + state: status.state, + }) + }) + .collect() +} + fn outdated_installed_integrations() -> Vec<IntegrationStatus> { installed_integration_statuses() .into_iter() @@ -1363,6 +1449,58 @@ mod tests { } #[test] + #[cfg(unix)] + fn command_available_requires_executable_file_on_path() { + use std::os::unix::fs::PermissionsExt; + + let _lock = integration_env_lock(); + let base = unique_base(); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let original_path = std::env::var_os("PATH"); + std::env::set_var("PATH", &bin); + + let command = bin.join("claude"); + fs::write(&command, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&command, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(!command_available("claude")); + + fs::set_permissions(&command, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(command_available("claude")); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + let _ = fs::remove_dir_all(base); + } + + #[test] + fn integration_recommendation_installs_available_or_outdated_targets() { + let mut recommendation = IntegrationRecommendation { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude", + command: "claude", + available: false, + path: PathBuf::from("/tmp/herdr-agent-state.sh"), + state: IntegrationStatusKind::NotInstalled, + }; + assert!(!recommendation.needs_install()); + + recommendation.available = true; + assert!(recommendation.needs_install()); + + recommendation.available = false; + recommendation.state = IntegrationStatusKind::Outdated; + assert!(recommendation.needs_install()); + + recommendation.available = true; + recommendation.state = IntegrationStatusKind::Current; + assert!(!recommendation.needs_install()); + } + + #[test] fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { let _lock = integration_env_lock(); let base = unique_base(); |
