car-external-agents 0.26.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
//! Google Gemini CLI adapter.

use crate::adapters::{json_top_level_keys, HealthFuture};
use crate::health::{base_health, HealthStatus};
use crate::types::{AdapterId, AuthKind, Capabilities};
use std::path::Path;

pub(super) const CAPABILITIES: Capabilities = Capabilities {
    tool_use: true,
    mcp: false,
    hooks: false,
    sessions: true,
    streaming: true,
    images: true,
};

pub(super) fn parse_version(stdout: &str) -> Option<String> {
    crate::adapters::first_versionish_token(stdout)
}

/// Gemini CLI stores Google OAuth credentials under
/// `~/.gemini/oauth_creds.json`; an alternate `GEMINI_API_KEY` flow
/// surfaces through `~/.gemini/settings.json`. Probe both, prefer
/// OAuth shape.
pub(super) fn probe_auth(home: &Path) -> AuthKind {
    let dir = home.join(".gemini");
    let oauth_path = dir.join("oauth_creds.json");
    if oauth_path.exists() {
        let Some(keys) = json_top_level_keys(&oauth_path) else {
            return AuthKind::Unknown;
        };
        let lower: Vec<String> = keys.iter().map(|k| k.to_ascii_lowercase()).collect();
        if lower
            .iter()
            .any(|k| k.contains("refresh_token") || k.contains("access_token"))
        {
            return AuthKind::Subscription;
        }
        return AuthKind::Unknown;
    }
    let settings_path = dir.join("settings.json");
    if settings_path.exists() {
        let Some(keys) = json_top_level_keys(&settings_path) else {
            return AuthKind::Unknown;
        };
        let lower: Vec<String> = keys.iter().map(|k| k.to_ascii_lowercase()).collect();
        if lower.iter().any(|k| k.contains("api_key") || k == "apikey") {
            return AuthKind::ApiKey;
        }
    }
    if dir.is_dir() {
        AuthKind::Unknown
    } else {
        AuthKind::Unauthenticated
    }
}

/// Gemini CLI does not expose a safe headless auth-status command —
/// running the binary without args triggers a browser OAuth flow,
/// which would be a UX disaster as a health probe. Fall back to
/// credential-file shape inspection (the same logic `probe_auth`
/// uses) and report `Ready` only when an OAuth refresh token is
/// present on disk. Document the gap in the reason field so callers
/// understand this is the weakest health signal of the three
/// adapters.
pub(super) fn health_check(binary_path: &Path) -> HealthFuture {
    // Resolve $HOME inside the future (it's `Send`able).
    let _ = binary_path; // We don't spawn the binary — file-only probe.
    Box::pin(async move {
        let mut h = base_health(AdapterId::Gemini);
        let home = match std::env::var_os("HOME").map(std::path::PathBuf::from) {
            Some(p) => p,
            None => {
                h.status = HealthStatus::Unknown;
                h.reason = Some("HOME unset; cannot inspect ~/.gemini".to_string());
                return h;
            }
        };
        let kind = probe_auth(&home);
        h.status = match kind {
            AuthKind::Subscription | AuthKind::ApiKey => HealthStatus::Ready,
            AuthKind::Unauthenticated => HealthStatus::NotConfigured,
            AuthKind::Unknown => HealthStatus::Unknown,
        };
        h.reason = Some(
            "gemini CLI has no headless auth-status command (running it without args \
             triggers a browser OAuth flow); falling back to credential-file shape \
             inspection. Ground truth requires an actual invocation."
                .to_string(),
        );
        h
    })
}