roboticus-cli 0.11.3

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

pub async fn cmd_sessions_list(url: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
    let c = RoboticusClient::new(url)?;
    let data = c.get("/api/sessions").await.map_err(|e| {
        RoboticusClient::check_connectivity_hint(&*e);
        e
    })?;
    if json {
        println!("{}", serde_json::to_string_pretty(&data)?);
        return Ok(());
    }
    heading("Sessions");
    let sessions = data["sessions"].as_array();
    match sessions {
        Some(arr) if !arr.is_empty() => {
            let widths = [14, 18, 28, 22];
            table_header(&["ID", "Agent", "Nickname", "Updated"], &widths);
            for s in arr {
                let id = truncate_id(s["id"].as_str().unwrap_or(""), 11);
                let agent = s["agent_id"].as_str().unwrap_or("").to_string();
                let nickname = s["nickname"].as_str().unwrap_or("\u{2014}").to_string();
                let updated = s["updated_at"].as_str().unwrap_or("").to_string();
                table_row(
                    &[
                        format!("{MONO}{id}{RESET}"),
                        agent,
                        nickname,
                        format!("{DIM}{updated}{RESET}"),
                    ],
                    &widths,
                );
            }
            eprintln!();
            eprintln!("    {DIM}{} session(s){RESET}", arr.len());
        }
        _ => empty_state("No sessions found"),
    }
    eprintln!();
    Ok(())
}

pub async fn cmd_session_detail(
    url: &str,
    id: &str,
    json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
    let c = RoboticusClient::new(url)?;
    let session = c.get(&format!("/api/sessions/{id}")).await.map_err(|e| {
        RoboticusClient::check_connectivity_hint(&*e);
        e
    })?;
    let messages = c.get(&format!("/api/sessions/{id}/messages")).await?;
    if json {
        let combined = serde_json::json!({ "session": session, "messages": messages });
        println!("{}", serde_json::to_string_pretty(&combined)?);
        return Ok(());
    }
    let nickname = session["nickname"].as_str().unwrap_or("\u{2014}");
    heading(&format!("Session {}", truncate_id(id, 12)));
    kv_mono("ID", id);
    kv("Nickname", nickname);
    kv("Agent", session["agent_id"].as_str().unwrap_or(""));
    kv("Created", session["created_at"].as_str().unwrap_or(""));
    kv("Updated", session["updated_at"].as_str().unwrap_or(""));
    let msgs = messages["messages"].as_array();
    match msgs {
        Some(arr) if !arr.is_empty() => {
            eprintln!();
            eprintln!("    {BOLD}Messages ({}):{RESET}", arr.len());
            eprintln!("    {DIM}{}{RESET}", "\u{2500}".repeat(56));
            for m in arr {
                let role = m["role"].as_str().unwrap_or("?");
                let content = m["content"].as_str().unwrap_or("");
                let time = m["created_at"].as_str().unwrap_or("");
                let role_color = match role {
                    "user" => CYAN,
                    "assistant" => GREEN,
                    "system" => YELLOW,
                    _ => DIM,
                };
                let short_time = if time.len() > 19 { &time[11..19] } else { time };
                eprintln!(
                    "    {role_color}\u{25b6}{RESET} {role_color}{BOLD}{role}{RESET} {DIM}{short_time}{RESET}"
                );
                for line in content.lines() {
                    eprintln!("      {line}");
                }
                eprintln!();
            }
        }
        _ => {
            eprintln!();
            empty_state("No messages in this session");
        }
    }
    eprintln!();
    Ok(())
}

pub async fn cmd_session_create(
    url: &str,
    agent_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
    let c = RoboticusClient::new(url)?;
    let body = serde_json::json!({ "agent_id": agent_id });
    let result = c.post("/api/sessions", body).await.map_err(|e| {
        RoboticusClient::check_connectivity_hint(&*e);
        e
    })?;
    let session_id = result["session_id"].as_str().unwrap_or("unknown");
    eprintln!();
    eprintln!("  {OK} Session created: {MONO}{session_id}{RESET}");
    eprintln!();
    Ok(())
}

pub async fn cmd_session_export(
    base_url: &str,
    session_id: &str,
    format: &str,
    output: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
    let resp = super::http_client()?
        .get(format!("{base_url}/api/sessions/{session_id}"))
        .send()
        .await?;
    if !resp.status().is_success() {
        eprintln!("  Session not found: {session_id}");
        return Ok(());
    }
    let session: serde_json::Value = resp.json().await?;
    let resp2 = super::http_client()?
        .get(format!("{base_url}/api/sessions/{session_id}/messages"))
        .send()
        .await?;
    let body: serde_json::Value = resp2.json().await.unwrap_or_default();
    let messages: Vec<serde_json::Value> = body
        .get("messages")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let content = match format {
        "json" => {
            let export = serde_json::json!({ "session": session, "messages": messages, "exported_at": chrono::Utc::now().to_rfc3339() });
            serde_json::to_string_pretty(&export)?
        }
        "markdown" => {
            let mut md = String::new();
            md.push_str(&format!("# Session {}\n\n", session_id));
            md.push_str(&format!(
                "**Agent:** {}\n",
                session
                    .get("agent_id")
                    .and_then(|v| v.as_str())
                    .unwrap_or("?")
            ));
            md.push_str(&format!(
                "**Created:** {}\n\n",
                session
                    .get("created_at")
                    .and_then(|v| v.as_str())
                    .unwrap_or("?")
            ));
            md.push_str("---\n\n");
            for msg in &messages {
                let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("?");
                let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("");
                let ts = msg.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
                md.push_str(&format!("### {} *({ts})*\n\n{content}\n\n", role));
            }
            md
        }
        "html" => {
            let mut html = String::new();
            html.push_str("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Roboticus Session Export</title><style>");
            html.push_str("body{font-family:-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;background:#1a1a2e;color:#e0e0e0}");
            html.push_str(
                "h1{color:#8b5cf6}.msg{margin:16px 0;padding:12px 16px;border-radius:8px}",
            );
            html.push_str(".user{background:#2a2a4a;border-left:3px solid #8b5cf6}.assistant{background:#1e3a2e;border-left:3px solid #22c55e}");
            html.push_str(".system{background:#3a2a1e;border-left:3px solid #f59e0b}.role{font-weight:bold;font-size:.85em}.time{font-size:.75em;color:#888}");
            html.push_str("</style></head><body>");
            html.push_str(&format!("<h1>Session {}</h1>", session_id));
            for msg in &messages {
                let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("?");
                let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("");
                let ts = msg.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
                let class = match role {
                    "user" => "user",
                    "assistant" => "assistant",
                    "system" => "system",
                    _ => "msg",
                };
                let escaped = content
                    .replace('&', "&amp;")
                    .replace('<', "&lt;")
                    .replace('>', "&gt;")
                    .replace('\n', "<br>");
                html.push_str(&format!("<div class=\"msg {class}\"><div class=\"role\">{role} <span class=\"time\">{ts}</span></div><div>{escaped}</div></div>"));
            }
            html.push_str("</body></html>");
            html
        }
        _ => {
            eprintln!("  Unknown format: {format}. Use json, html, or markdown.");
            return Ok(());
        }
    };
    match output {
        Some(path) => {
            std::fs::write(path, &content)?;
            eprintln!("  {OK} Exported to {path}");
        }
        None => print!("{content}"),
    }
    Ok(())
}

pub async fn cmd_sessions_backfill_nicknames(url: &str) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
    let c = RoboticusClient::new(url)?;
    let result = c
        .post("/api/sessions/backfill-nicknames", serde_json::json!({}))
        .await
        .map_err(|e| {
            RoboticusClient::check_connectivity_hint(&*e);
            e
        })?;
    let count = result["backfilled"].as_u64().unwrap_or(0);
    eprintln!();
    eprintln!("  {OK} Backfilled nicknames for {ACCENT}{count}{RESET} session(s)");
    eprintln!();
    Ok(())
}