car-external-agents 0.25.0

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

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

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

/// `claude --version` emits e.g. `1.0.51 (Claude Code)` or, on newer
/// builds, `2.1.138 (Claude Code)`. We pick the first token that
/// starts with an ASCII digit so the same parser works whether the
/// upstream tool prefixes the version with a name (`claude-cli x.y.z`)
/// or leads with the version itself.
pub(super) fn parse_version(stdout: &str) -> Option<String> {
    crate::adapters::first_versionish_token(stdout)
}

/// Claude Code stores credentials under `~/.claude/`. Two known
/// file shapes: `.credentials.json` (older OAuth flow) and `auth.json`.
/// Modern macOS builds use the system Keychain — no file to inspect —
/// so a config dir without either file falls through to `Unknown`
/// rather than `Unauthenticated`. Subscription vs ApiKey is
/// differentiated by top-level key presence; credential values are
/// never read.
///
/// Heuristic and best-effort. The upstream tool reshuffles credential
/// layouts without notice; `Unknown` is the safe default when shape
/// doesn't match either pattern.
pub(super) fn probe_auth(home: &Path) -> AuthKind {
    let dir = home.join(".claude");
    let candidates = [".credentials.json", "auth.json"];
    for candidate in candidates {
        let path = dir.join(candidate);
        if !path.exists() {
            continue;
        }
        let Some(keys) = json_top_level_keys(&path) else {
            return AuthKind::Unknown;
        };
        let lower: Vec<String> = keys.iter().map(|k| k.to_ascii_lowercase()).collect();
        let has_oauth = lower
            .iter()
            .any(|k| k.contains("oauth") || k.contains("account"));
        let has_api_key = lower.iter().any(|k| k.contains("apikey") || k == "api_key");
        return match (has_oauth, has_api_key) {
            (true, _) => AuthKind::Subscription,
            (false, true) => AuthKind::ApiKey,
            (false, false) => AuthKind::Unknown,
        };
    }
    // Config dir exists but no readable cred file — most likely the
    // user is on a macOS build that stores credentials in Keychain,
    // or a Linux build that uses the secret service. Either way the
    // binary is installed and config has been written, so the tool
    // has been used; report Unknown rather than Unauthenticated to
    // avoid false-negatives on subscription users.
    if dir.is_dir() {
        AuthKind::Unknown
    } else {
        AuthKind::Unauthenticated
    }
}

/// `claude auth status` returns structured JSON on stdout:
///
/// ```json
/// {
///   "loggedIn": true,
///   "authMethod": "claude.ai",
///   "apiProvider": "firstParty",
///   "email": "...",
///   "orgId": "...",
///   "subscriptionType": "max"
/// }
/// ```
///
/// Exit 0 + `loggedIn: true` → Ready. Exit 0 + `loggedIn: false` →
/// NotConfigured. Non-zero exit or unparseable JSON → Unknown with
/// the captured output as the reason.
pub(super) fn health_check(binary_path: &Path) -> HealthFuture {
    let bin = binary_path.to_path_buf();
    Box::pin(async move {
        let mut h = base_health(AdapterId::ClaudeCode);
        match run_status_probe(&bin, &["auth", "status"]).await {
            Ok((stdout, stderr, code)) if code == 0 => match serde_json::from_str::<Value>(&stdout)
            {
                Ok(parsed) => {
                    let logged_in = parsed
                        .get("loggedIn")
                        .and_then(Value::as_bool)
                        .unwrap_or(false);
                    h.status = if logged_in {
                        HealthStatus::Ready
                    } else {
                        HealthStatus::NotConfigured
                    };
                    h.details = parsed;
                    if !logged_in {
                        h.reason = Some("Run `claude auth login` to authenticate".to_string());
                    }
                }
                Err(e) => {
                    h.status = HealthStatus::Unknown;
                    h.reason = Some(format!(
                        "could not parse `claude auth status` output ({e}); stderr: {stderr}",
                    ));
                }
            },
            Ok((_stdout, stderr, code)) => {
                h.status = HealthStatus::NotConfigured;
                h.reason = Some(format!(
                    "`claude auth status` exited {code}; stderr: {}",
                    stderr.trim()
                ));
            }
            Err(e) => {
                h.status = HealthStatus::NetworkError;
                h.reason = Some(format!("status probe failed: {e}"));
            }
        }
        h
    })
}