car-external-agents 0.26.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
//! Per-tool detection adapters.
//!
//! Each adapter is a small static descriptor the detection module
//! drives — binary name, version-probe arg shape, credential-file
//! candidates, and capability set. No per-tool dynamic dispatch is
//! needed in Phase 1; invocation logic lands in Phase 2.

use crate::health::ExternalAgentHealth;
use crate::types::{AdapterId, AuthKind, Capabilities};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;

mod claude_code;
mod codex;
mod gemini;

/// Boxed future returned by `health_check`. Function pointers can't
/// directly hold `async fn` so each adapter wraps its async probe
/// behind a `Pin<Box<dyn Future>>`.
pub type HealthFuture = Pin<Box<dyn Future<Output = ExternalAgentHealth> + Send>>;

/// Static metadata + probe routines for one external agent.
pub struct Adapter {
    pub id: AdapterId,
    /// Binary name to look up in `$PATH`.
    pub bin_name: &'static str,
    /// Capability set advertised by this adapter.
    pub capabilities: Capabilities,
    /// Resolve `<binary_path> --version` style output to a version
    /// string. Returns `None` when the output doesn't match the
    /// expected shape — never panics.
    pub parse_version: fn(&str) -> Option<String>,
    /// Heuristic auth probe. Inspects credential file *shape* via
    /// `home_dir`-rooted paths; never reads content beyond what's
    /// needed to differentiate Subscription vs ApiKey. Phase 1 MVP —
    /// scheduled for replacement by `health_check` in Phase 2.
    pub probe_auth: fn(home: &Path) -> AuthKind,
    /// Ground-truth health check. Delegates to the tool's own
    /// auth-status command (`claude auth status`,
    /// `codex login status`, etc.). Replaces `probe_auth` as the
    /// load-bearing signal for "is this tool ready to invoke."
    pub health_check: fn(binary_path: &Path) -> HealthFuture,
}

/// All adapters the runtime knows about. Order is stable for
/// deterministic UI listing — alphabetical by `id.as_str()`.
pub fn all() -> &'static [Adapter] {
    &ADAPTERS
}

const ADAPTERS: [Adapter; 3] = [
    Adapter {
        id: AdapterId::ClaudeCode,
        bin_name: "claude",
        capabilities: claude_code::CAPABILITIES,
        parse_version: claude_code::parse_version,
        probe_auth: claude_code::probe_auth,
        health_check: claude_code::health_check,
    },
    Adapter {
        id: AdapterId::Codex,
        bin_name: "codex",
        capabilities: codex::CAPABILITIES,
        parse_version: codex::parse_version,
        probe_auth: codex::probe_auth,
        health_check: codex::health_check,
    },
    Adapter {
        id: AdapterId::Gemini,
        bin_name: "gemini",
        capabilities: gemini::CAPABILITIES,
        parse_version: gemini::parse_version,
        probe_auth: gemini::probe_auth,
        health_check: gemini::health_check,
    },
];

/// Common helper: read a JSON file's top-level keys without parsing
/// values. Used by every adapter's auth probe to differentiate
/// Subscription / ApiKey via key presence alone — credential values
/// are never read.
pub(crate) fn json_top_level_keys(path: &PathBuf) -> Option<Vec<String>> {
    let bytes = std::fs::read(path).ok()?;
    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    let map = value.as_object()?;
    Some(map.keys().cloned().collect())
}

/// Scan `stdout` for the first whitespace-delimited token that starts
/// with an ASCII digit. Handles the three observed `--version` shapes
/// across adapters: leading version (`1.0.51 (Claude Code)`),
/// name-then-version (`codex-cli 0.128.0`), and bare semver (`0.1.4`).
/// Returns `None` for empty / non-versionish output rather than
/// fabricating a value.
pub(crate) fn first_versionish_token(stdout: &str) -> Option<String> {
    for token in stdout.split_whitespace() {
        if token
            .chars()
            .next()
            .map(|c| c.is_ascii_digit())
            .unwrap_or(false)
        {
            return Some(
                token
                    .trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-')
                    .to_string(),
            );
        }
    }
    None
}