roboticus-cli 0.11.3

CLI commands and migration engine for the Roboticus agent runtime
Documentation
use super::*;

pub(crate) fn is_connection_error(msg: &str) -> bool {
    msg.contains("Connection refused")
        || msg.contains("ConnectionRefused")
        || msg.contains("ConnectError")
        || msg.contains("connect error")
        || msg.contains("kind: Decode")
        || msg.contains("hyper::Error")
}

pub async fn cmd_status(url: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
    let c = RoboticusClient::new(url)?;
    let health = match c.get("/api/health").await {
        Ok(h) => h,
        Err(e) => {
            let msg = format!("{:?}", e);
            if is_connection_error(&msg) {
                if json {
                    println!("{}", serde_json::json!({"status": "offline", "url": url}));
                } else {
                    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
                    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
                    eprintln!();
                    eprintln!("  {WARN} Server is not running at {BOLD}{url}{RESET}");
                    eprintln!("  {DIM}Start with: {BOLD}roboticus serve{RESET}");
                    eprintln!();
                }
                return Ok(());
            }
            eprintln!();
            eprintln!("  Could not connect to agent at {url}: {e}");
            eprintln!();
            RoboticusClient::check_connectivity_hint(&*e);
            return Err(e);
        }
    };
    let agent = c.get("/api/agent/status").await?;
    let config = c.get("/api/config").await?;
    let sessions = c.get("/api/sessions").await?;
    let skills = c.get("/api/skills").await?;
    let jobs = c.get("/api/cron/jobs").await?;
    let cache = c.get("/api/stats/cache").await?;
    let wallet = c.get("/api/wallet/balance").await?;

    if json {
        let out = serde_json::json!({
            "status": "online",
            "version": health["version"],
            "agent": {
                "name": config["agent"]["name"],
                "id": config["agent"]["id"],
                "state": agent["state"],
            },
            "sessions": sessions["sessions"].as_array().map(|a| a.len()).unwrap_or(0),
            "skills": skills["skills"].as_array().map(|a| a.len()).unwrap_or(0),
            "cron_jobs": jobs["jobs"].as_array().map(|a| a.len()).unwrap_or(0),
            "cache": cache,
            "wallet": wallet,
            "primary_model": config["models"]["primary"],
        });
        println!("{}", serde_json::to_string_pretty(&out)?);
        return Ok(());
    }

    heading("Agent Status");
    let agent_name = config["agent"]["name"].as_str().unwrap_or("unknown");
    let agent_id = config["agent"]["id"].as_str().unwrap_or("unknown");
    let agent_state = agent["state"].as_str().unwrap_or("unknown");
    let version = health["version"].as_str().unwrap_or("?");
    let session_count = sessions["sessions"]
        .as_array()
        .map(|a| a.len())
        .unwrap_or(0);
    let skill_count = skills["skills"].as_array().map(|a| a.len()).unwrap_or(0);
    let job_count = jobs["jobs"].as_array().map(|a| a.len()).unwrap_or(0);
    let hits = cache["hits"].as_u64().unwrap_or(0);
    let misses = cache["misses"].as_u64().unwrap_or(0);
    let hit_rate = if hits + misses > 0 {
        format!("{:.1}%", hits as f64 / (hits + misses) as f64 * 100.0)
    } else {
        "0%".into()
    };
    let balance = wallet["balance"].as_str().unwrap_or("0.00");
    let currency = wallet["currency"].as_str().unwrap_or("USDC");
    kv_accent("Agent", &format!("{agent_name} ({agent_id})"));
    kv("State", &status_badge(agent_state).to_string());
    kv_accent("Version", version);
    kv("Sessions", &session_count.to_string());
    kv("Skills", &skill_count.to_string());
    kv("Cron Jobs", &job_count.to_string());
    kv("Cache Hit Rate", &hit_rate);
    kv_accent("Balance", &format!("{balance} {currency}"));
    let primary = config["models"]["primary"].as_str().unwrap_or("unknown");
    kv("Primary Model", primary);
    eprintln!();
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{Json, Router, routing::get};
    use std::net::SocketAddr;
    use tokio::net::TcpListener;

    #[test]
    fn detects_connection_refused() {
        assert!(is_connection_error("Connection refused (os error 61)"));
    }

    #[test]
    fn detects_connect_error_variant() {
        assert!(is_connection_error("hyper::Error(Connect, ConnectError)"));
    }

    #[test]
    fn detects_decode_error() {
        assert!(is_connection_error("kind: Decode"));
    }

    #[test]
    fn ignores_unrelated_errors() {
        assert!(!is_connection_error("404 Not Found"));
        assert!(!is_connection_error("timeout after 30s"));
    }

    #[test]
    fn detects_connect_error_lowercase() {
        assert!(is_connection_error("connect error: tcp handshake failed"));
    }

    #[test]
    fn detects_hyper_error() {
        assert!(is_connection_error("hyper::Error somewhere in the chain"));
    }

    #[test]
    fn empty_string_is_not_connection_error() {
        assert!(!is_connection_error(""));
    }

    #[test]
    fn detects_connection_refused_variant() {
        assert!(is_connection_error("ConnectionRefused: host unreachable"));
    }

    #[tokio::test]
    async fn cmd_status_succeeds_against_local_mock_server() {
        async fn health() -> Json<serde_json::Value> {
            Json(serde_json::json!({"version":"0.8.0"}))
        }
        async fn agent_status() -> Json<serde_json::Value> {
            Json(serde_json::json!({"state":"running"}))
        }
        async fn config() -> Json<serde_json::Value> {
            Json(serde_json::json!({
                "agent": {"name":"TestBot","id":"test-bot"},
                "models": {"primary":"ollama/qwen3:8b"}
            }))
        }
        async fn sessions() -> Json<serde_json::Value> {
            Json(serde_json::json!({"sessions":[{"id":"s1"}]}))
        }
        async fn skills() -> Json<serde_json::Value> {
            Json(serde_json::json!({"skills":[{"id":"k1"},{"id":"k2"}]}))
        }
        async fn cron_jobs() -> Json<serde_json::Value> {
            Json(serde_json::json!({"jobs":[{"id":"j1"}]}))
        }
        async fn cache() -> Json<serde_json::Value> {
            Json(serde_json::json!({"hits":3,"misses":1}))
        }
        async fn wallet() -> Json<serde_json::Value> {
            Json(serde_json::json!({"balance":"12.34","currency":"USDC"}))
        }

        let app = Router::new()
            .route("/api/health", get(health))
            .route("/api/agent/status", get(agent_status))
            .route("/api/config", get(config))
            .route("/api/sessions", get(sessions))
            .route("/api/skills", get(skills))
            .route("/api/cron/jobs", get(cron_jobs))
            .route("/api/stats/cache", get(cache))
            .route("/api/wallet/balance", get(wallet));

        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr: SocketAddr = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            axum::serve(listener, app).await.unwrap();
        });

        let url = format!("http://{}:{}", addr.ip(), addr.port());
        let result = cmd_status(&url, false).await;
        server.abort();
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn cmd_status_returns_ok_for_unreachable_server() {
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);
        let url = format!("http://127.0.0.1:{port}");
        let result = cmd_status(&url, false).await;
        assert!(result.is_ok());
    }
}