zagens-cli 0.8.3

Zagens headless CLI + HTTP/SSE runtime sidecar (`zagens`, `zagens-runtime` binaries)
Documentation
use std::path::Path;

use anyhow::Result;
use colored::Colorize;
use serde_json::json;

use crate::cli::args::DoctorArgs;
use crate::cli::context::{CliContext, default_config_path, display_path};
use crate::cli::doctor::{
    McpServerDoctorStatus, doctor_api_target, doctor_check_mcp_server,
    doctor_timeout_recovery_lines,
};
use crate::cli::mcp_config::load_mcp_config;
use crate::cli::setup::{
    ApiKeySource, count_dir_entries, default_plugins_dir, default_tools_dir,
    resolve_api_key_source, skills_count_for,
};
use crate::config::{Config, provider_capability};

pub async fn run(ctx: &CliContext, cli_config: Option<&Path>, args: DoctorArgs) -> Result<()> {
    if args.json {
        return run_json(&ctx.config, &ctx.workspace, cli_config);
    }
    run_human(&ctx.config, &ctx.workspace, cli_config).await;
    Ok(())
}

async fn run_human(config: &Config, workspace: &Path, config_path_override: Option<&Path>) {
    println!("{}", "Zagens Doctor".bold());
    println!();
    println!("Version: zagens {}", env!("CARGO_PKG_VERSION"));
    println!("Rust: {}", rustc_version());
    println!();

    let config_path = config_path_override
        .map(Path::to_path_buf)
        .unwrap_or_else(default_config_path);
    if config_path.exists() {
        println!("✓ config: {}", display_path(&config_path));
    } else {
        println!(
            "! config missing at {} (using defaults/env)",
            display_path(&config_path)
        );
    }
    println!("  workspace: {}", display_path(workspace));

    println!();
    println!("{}", "API".bold());
    let target = doctor_api_target(config);
    println!("  provider: {}", target.provider);
    println!("  base_url: {}", target.base_url);
    println!("  model: {}", target.model);
    match resolve_api_key_source(config) {
        ApiKeySource::Missing => println!("  api_key: {}", "missing".red()),
        ApiKeySource::Env => println!("  api_key: set (env)"),
        ApiKeySource::Config => println!("  api_key: set (config)"),
        ApiKeySource::Keyring => println!("  api_key: set (keyring)"),
    }

    println!();
    println!("{}", "MCP".bold());
    let mcp_path = config.mcp_config_path();
    match load_mcp_config(&mcp_path) {
        Ok(cfg) if cfg.servers.is_empty() => {
            println!("  No servers in {}", mcp_path.display());
        }
        Ok(cfg) => {
            for (name, server) in cfg.servers {
                let status = doctor_check_mcp_server(&server);
                let label = match status {
                    McpServerDoctorStatus::Ok(d) => format!("ok — {d}"),
                    McpServerDoctorStatus::Warning(d) => format!("warn — {d}"),
                    McpServerDoctorStatus::Error(d) => format!("error — {d}"),
                };
                println!("  - {name}: {label}");
            }
        }
        Err(err) => println!("  Failed to read MCP config: {err}"),
    }

    println!();
    println!("{}", "Connectivity".bold());
    match test_api_connectivity(config).await {
        Ok(model) => println!("  ✓ API reachable (model probe: {model})"),
        Err(err) => {
            println!("  ✗ API check failed: {err}");
            for line in doctor_timeout_recovery_lines(config) {
                println!("    {line}");
            }
        }
    }
}

fn run_json(config: &Config, workspace: &Path, config_path_override: Option<&Path>) -> Result<()> {
    let config_path = config_path_override
        .map(Path::to_path_buf)
        .unwrap_or_else(default_config_path);

    let api_key_state = match resolve_api_key_source(config) {
        ApiKeySource::Env => "env",
        ApiKeySource::Config => "config",
        ApiKeySource::Keyring => "keyring",
        ApiKeySource::Missing => "missing",
    };

    let mcp_config_path = config.mcp_config_path();
    let mcp_summary = match load_mcp_config(&mcp_config_path) {
        Ok(cfg) => {
            let servers: Vec<serde_json::Value> = cfg
                .servers
                .iter()
                .map(|(name, server)| {
                    let status = doctor_check_mcp_server(server);
                    let (kind, detail) = match status {
                        McpServerDoctorStatus::Ok(d) => ("ok", d),
                        McpServerDoctorStatus::Warning(d) => ("warning", d),
                        McpServerDoctorStatus::Error(d) => ("error", d),
                    };
                    json!({
                        "name": name,
                        "enabled": server.enabled && !server.disabled,
                        "status": kind,
                        "detail": detail,
                    })
                })
                .collect();
            json!({
                "config_path": mcp_config_path.display().to_string(),
                "present": mcp_config_path.exists(),
                "servers": servers,
            })
        }
        Err(err) => json!({
            "config_path": mcp_config_path.display().to_string(),
            "present": mcp_config_path.exists(),
            "servers": [],
            "error": err.to_string(),
        }),
    };

    let target = doctor_api_target(config);
    let tools_dir = default_tools_dir();
    let plugins_dir = default_plugins_dir();
    let skills_dir = config.skills_dir();

    let report = json!({
        "version": env!("CARGO_PKG_VERSION"),
        "workspace": workspace.display().to_string(),
        "config_path": config_path.display().to_string(),
        "config_present": config_path.exists(),
        "api_key": api_key_state,
        "provider": target.provider,
        "base_url": target.base_url,
        "default_text_model": target.model,
        "mcp": mcp_summary,
        "skills": {
            "path": skills_dir.display().to_string(),
            "count": skills_count_for(&skills_dir),
        },
        "tools": {
            "path": tools_dir.display().to_string(),
            "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
        },
        "plugins": {
            "path": plugins_dir.display().to_string(),
            "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
        },
        "api_connectivity": {
            "checked": false,
            "note": "Skipped in --json mode; run `zagens doctor` for a live check.",
        },
        "capability": provider_capability_report(config),
    });

    println!("{}", serde_json::to_string_pretty(&report)?);
    Ok(())
}

fn provider_capability_report(config: &Config) -> serde_json::Value {
    let provider = config.api_provider();
    let model = config.default_model();
    let cap = provider_capability(provider, &model);
    json!({
        "resolved_provider": provider.as_str(),
        "resolved_model": cap.resolved_model,
        "context_window": cap.context_window,
        "max_output": cap.max_output,
        "thinking_supported": cap.thinking_supported,
        "cache_telemetry_supported": cap.cache_telemetry_supported,
        "request_payload_mode": serde_json::to_value(cap.request_payload_mode).unwrap_or_default(),
    })
}

async fn test_api_connectivity(config: &Config) -> Result<String> {
    use crate::client::DeepSeekClient;
    use crate::models::{ContentBlock, Message, MessageRequest};
    use zagens_core::chat::LlmClient;

    let client = DeepSeekClient::new(config)?;
    let model = client.model().to_string();
    let request = MessageRequest {
        model: model.clone(),
        messages: vec![Message {
            role: "user".to_string(),
            content: vec![ContentBlock::Text {
                text: "hi".to_string(),
                cache_control: None,
            }],
        }],
        max_tokens: 1,
        system: None,
        tools: None,
        tool_choice: None,
        metadata: None,
        thinking: None,
        reasoning_effort: None,
        stream: Some(false),
        temperature: None,
        top_p: None,
    };

    match tokio::time::timeout(
        std::time::Duration::from_secs(15),
        client.create_message(request),
    )
    .await
    {
        Ok(Ok(_)) => Ok(model),
        Ok(Err(e)) => Err(e),
        Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
    }
}

fn rustc_version() -> String {
    std::process::Command::new("rustc")
        .arg("--version")
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
}