agent-harness 0.1.0

Use existing agent CLIs (Claude Code, Codex, bob) programmatically from Rust — or add your own — behind one Harness trait + a normalized run-event stream. Imported as `harness`.
Documentation
//! Claude Code (`claude`) as a [`Harness`].
//!
//! Same process-spawn shape as the bob adapter — a different binary,
//! flags, and stdout parser. We invoke `claude -p` in headless
//! streaming mode and parse its NDJSON into the shared normalized
//! [`crate::RunEvent`] stream, so the front-end treats Claude exactly
//! like any other harness.
//!
//! Auth: Claude Code manages its own credentials (its OAuth login or
//! its own `ANTHROPIC_API_KEY` in the environment), so Compose does
//! not store or inject a key — `credential().required` is `false`.
//!
//! The stdout wire format and its decode into [`crate::RunEvent`]s live in
//! [`parser`] ([`parse_claude_line`]).

use std::path::PathBuf;
use std::process::Command;

use serde_json::Value;

use crate::{
    normalize_process_event, spawn_streaming, CredentialSpec, Harness, HarnessCapabilities,
    HarnessError, HarnessInfo, HarnessModel, HarnessReadiness, InstallCallback, InstallEvent,
    RunCallback, RunHandle, RunMode, RunRequest, RunTuning,
};

mod parser;
pub use parser::parse_claude_line;

/// Registry id for the Claude Code harness.
pub const CLAUDE_HARNESS_ID: &str = "claude";

/// Claude Code CLI as a [`Harness`].
#[derive(Debug, Default, Clone)]
pub struct ClaudeHarness;

impl ClaudeHarness {
    pub fn new() -> Self {
        Self
    }
}

impl Harness for ClaudeHarness {
    fn info(&self) -> HarnessInfo {
        HarnessInfo {
            id: CLAUDE_HARNESS_ID.to_owned(),
            display_name: "Claude Code".to_owned(),
            description: "Anthropic's Claude Code agent CLI. Uses your existing Claude Code login."
                .to_owned(),
            requires_install: true,
            capabilities: HarnessCapabilities {
                // Claude Code owns its own login; it edits files
                // directly (no previews). Curated model aliases (no
                // free-text) + a turn cap; no reasoning-effort flag.
                credential_required: false,
                previews_edits: false,
                models: vec![
                    HarnessModel { value: "sonnet".to_owned(), label: "Sonnet (latest)".to_owned() },
                    HarnessModel { value: "opus".to_owned(), label: "Opus (latest)".to_owned() },
                    HarnessModel { value: "haiku".to_owned(), label: "Haiku (latest)".to_owned() },
                ],
                allows_custom_model: false,
                supports_effort: false,
                supports_max_turns: true,
                supports_login: true,
            },
        }
    }

    fn readiness(&self) -> HarnessReadiness {
        let Some(version) = probe_version("claude") else {
            return HarnessReadiness {
                harness_id: CLAUDE_HARNESS_ID.to_owned(),
                ready: false,
                installed: false,
                version: None,
                auth_configured: false,
                error: Some("Claude Code (`claude`) is not installed or not on PATH.".to_owned()),
                details: Value::Null,
            };
        };
        // Installed — now distinguish signed-in from not, so the picker
        // can offer "Sign in" instead of failing the first run. Either the
        // CLI's own OAuth login OR an `ANTHROPIC_API_KEY` in the environment
        // counts: the env key is how you run headless (a container / CI),
        // where `claude auth login` can't open a browser. `claude auth status`
        // only sees the OAuth state, so we OR in the env key ourselves.
        let signed_in = probe_claude_signed_in()
            || crate::harness::api_key_value_usable(std::env::var("ANTHROPIC_API_KEY").ok());
        HarnessReadiness {
            harness_id: CLAUDE_HARNESS_ID.to_owned(),
            ready: signed_in,
            installed: true,
            version: Some(version),
            auth_configured: signed_in,
            error: if signed_in {
                None
            } else {
                Some(
                    "Claude Code is installed but not signed in. Click Sign in to connect your Anthropic account, or set ANTHROPIC_API_KEY."
                        .to_owned(),
                )
            },
            details: Value::Null,
        }
    }

    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
        // npm global install. Blocking (matches the `install`
        // contract); we capture output and forward it as install
        // events. Streaming live progress is a future refinement.
        (*on_event)(InstallEvent::Step {
            text: "Installing Claude Code via npm…".to_owned(),
        });
        let output = Command::new("npm")
            .args(["install", "-g", "@anthropic-ai/claude-code"])
            .env("PATH", crate::augmented_node_path())
            .output()
            .map_err(|e| HarnessError::install(format!("failed to run npm: {e}")))?;
        for line in String::from_utf8_lossy(&output.stdout).lines() {
            (*on_event)(InstallEvent::Stdout {
                text: line.to_owned(),
            });
        }
        for line in String::from_utf8_lossy(&output.stderr).lines() {
            (*on_event)(InstallEvent::Stderr {
                text: line.to_owned(),
            });
        }
        (*on_event)(InstallEvent::Done {
            exit_code: output.status.code(),
            ok: output.status.success(),
        });
        Ok(())
    }

    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
        let RunRequest { run_id, prompt, cwd, mode, tuning } = request;
        let args = build_claude_args(prompt, mode, &tuning);
        let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());

        // No env injected — Claude Code uses its own auth. PATH
        // augmentation inside `spawn_streaming` ensures `node` is
        // found for a Finder-launched .app.
        let handle = spawn_streaming(
            PathBuf::from("claude"),
            args,
            Vec::new(),
            cwd,
            run_id,
            move |event| {
                for normalized in normalize_process_event(event, parse_claude_line) {
                    (*on_event)(normalized);
                }
            },
        )
        .map_err(HarnessError::spawn)?;
        Ok(Box::new(handle))
    }

    fn credential(&self) -> CredentialSpec {
        CredentialSpec {
            label: "Claude Code login (managed by the claude CLI)".to_owned(),
            keychain_service: "anthropic".to_owned(),
            keychain_account: "ANTHROPIC_API_KEY".to_owned(),
            // Claude Code authenticates itself; Compose need not store
            // a key for it.
            required: false,
        }
    }

    fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
        // `claude auth login` runs the CLI's OAuth flow (opens the
        // browser); streamed + blocked-until-exit by the shared helper.
        crate::run_login_command("claude", &["auth", "login"], on_event)
    }
}

/// Probe Claude Code's auth: `claude auth status` prints JSON with a
/// `loggedIn` boolean (exit 0 when signed in). Returns true only when
/// signed in; defensively falls back to the exit code if the JSON is
/// unexpected. Lets [`ClaudeHarness::readiness`] distinguish installed
/// from signed-in.
fn probe_claude_signed_in() -> bool {
    let Ok(output) = Command::new("claude")
        .args(["auth", "status"])
        .env("PATH", crate::augmented_node_path())
        .output()
    else {
        return false;
    };
    let stdout = String::from_utf8_lossy(&output.stdout);
    if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(stdout.trim()) {
        if let Some(logged_in) = map.get("loggedIn").and_then(Value::as_bool) {
            return logged_in;
        }
    }
    // Fallback: exit 0 with non-empty output ≈ signed in.
    output.status.success() && !stdout.trim().is_empty()
}

/// Build the argv for a `claude -p` headless run. Kept pure (no
/// spawn) so the flag mapping is unit-tested. `tuning.model` →
/// `--model`, `tuning.max_turns` → `--max-turns`; Claude Code has no
/// reasoning-effort `-p` flag, so `tuning.effort` is intentionally
/// ignored here.
fn build_claude_args(prompt: String, mode: RunMode, tuning: &RunTuning) -> Vec<String> {
    let mut args = vec![
        "-p".to_owned(),
        prompt,
        "--output-format".to_owned(),
        "stream-json".to_owned(),
        "--verbose".to_owned(),
        "--include-partial-messages".to_owned(),
    ];
    if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
        args.push("--model".to_owned());
        args.push(model.to_owned());
    }
    if let Some(max_turns) = tuning.max_turns {
        args.push("--max-turns".to_owned());
        args.push(max_turns.to_string());
    }
    if matches!(mode, RunMode::Edit) {
        // Let Claude write files without an interactive prompt in
        // Edit mode; in Ask mode it stays read-only by default.
        args.push("--permission-mode".to_owned());
        args.push("acceptEdits".to_owned());
    }
    args
}

/// Run `<program> --version`, returning the trimmed stdout on
/// success. Used by readiness to detect the CLI on PATH.
fn probe_version(program: &str) -> Option<String> {
    // Augment PATH so a packaged `.app` (minimal launchd PATH) can find a
    // CLI installed via nvm / Homebrew / official installer — otherwise an
    // installed CLI is mis-reported as "not installed".
    let output = Command::new(program)
        .arg("--version")
        .env("PATH", crate::augmented_node_path())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    if text.is_empty() {
        None
    } else {
        Some(text)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ReasoningEffort;

    #[test]
    fn claude_info_and_credential() {
        let h = ClaudeHarness::new();
        assert_eq!(h.info().id, CLAUDE_HARNESS_ID);
        assert!(h.info().requires_install);
        // Claude manages its own auth — Compose doesn't require a key.
        assert!(!h.credential().required);
    }

    /// Value of the arg immediately following `flag`, if present.
    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
        args.iter()
            .position(|a| a == flag)
            .and_then(|i| args.get(i + 1))
            .map(String::as_str)
    }

    #[test]
    fn claude_args_default_omit_model_and_turn_cap() {
        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default());
        // Prompt is the positional right after `-p`.
        assert_eq!(args[0], "-p");
        assert_eq!(args[1], "hi");
        assert!(!args.iter().any(|a| a == "--model"));
        assert!(!args.iter().any(|a| a == "--max-turns"));
        assert!(!args.iter().any(|a| a == "--permission-mode"));
    }

    #[test]
    fn claude_args_carry_model_and_max_turns_and_ignore_effort() {
        let tuning = RunTuning {
            model: Some("opus".to_owned()),
            effort: Some(ReasoningEffort::High),
            max_turns: Some(5),
        };
        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning);
        assert_eq!(flag_value(&args, "--model"), Some("opus"));
        assert_eq!(flag_value(&args, "--max-turns"), Some("5"));
        // Claude Code has no reasoning-effort `-p` flag — it must not leak.
        assert!(!args.iter().any(|a| a.contains("reasoning_effort")));
    }

    #[test]
    fn claude_blank_model_is_treated_as_unset() {
        let tuning = RunTuning { model: Some("   ".to_owned()), ..RunTuning::default() };
        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning);
        assert!(!args.iter().any(|a| a == "--model"));
    }

    #[test]
    fn claude_edit_mode_accepts_edits() {
        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &RunTuning::default());
        assert_eq!(flag_value(&args, "--permission-mode"), Some("acceptEdits"));
    }
}