roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
//! Agent/subagent migration transforms.

use std::fs;
use std::path::Path;

use super::{AreaResult, MigrationArea, err, uuid_v4};

const SKIP_AGENT_DIRS: &[&str] = &["duncan", "main"];

fn is_model_wrapper(name: &str) -> bool {
    let lower = name.to_lowercase();
    lower.starts_with("anthropic-")
        || lower.starts_with("google-")
        || lower.starts_with("moonshot-")
        || lower.starts_with("ollama-")
        || lower.starts_with("openai-")
}

fn agent_display_name(name: &str) -> String {
    name.split('-')
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                None => String::new(),
                Some(f) => f.to_uppercase().to_string() + c.as_str(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn detect_agent_model(agent_dir: &Path) -> String {
    let models_path = agent_dir.join("agent").join("models.json");
    if let Ok(content) = fs::read_to_string(&models_path)
        && let Ok(val) = serde_json::from_str::<serde_json::Value>(&content)
    {
        if let Some(p) = val.get("primary").and_then(|v| v.as_str())
            && !p.is_empty()
        {
            return p.to_string();
        }
        if let Some(providers) = val.get("providers").and_then(|v| v.as_object()) {
            for (prov_name, prov) in providers {
                if let Some(models) = prov.get("models").and_then(|v| v.as_array())
                    && let Some(first) = models.first()
                    && let Some(id) = first.get("id").and_then(|v| v.as_str())
                {
                    return format!("{prov_name}/{id}");
                }
            }
        }
    }
    String::new()
}

pub(crate) fn import_agents(oc_root: &Path, ic_root: &Path) -> AreaResult {
    let agents_dir = oc_root.join("agents");
    if !agents_dir.exists() {
        return AreaResult {
            area: MigrationArea::Agents,
            success: true,
            items_processed: 0,
            warnings: vec!["No agents directory found in Legacy root".into()],
            error: None,
        };
    }

    let db_path = ic_root.join("state.db");
    let db = match roboticus_db::Database::new(&db_path.to_string_lossy()) {
        Ok(d) => d,
        Err(e) => {
            return err(
                MigrationArea::Agents,
                format!("Failed to open database: {e}"),
            );
        }
    };

    let mut items = 0;
    let mut warnings = Vec::new();

    let entries = match fs::read_dir(&agents_dir) {
        Ok(e) => e,
        Err(e) => {
            return err(
                MigrationArea::Agents,
                format!("Failed to read agents directory: {e}"),
            );
        }
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let name = match entry.file_name().to_str() {
            Some(n) => n.to_string(),
            None => continue,
        };

        if SKIP_AGENT_DIRS.contains(&name.as_str()) {
            continue;
        }

        let role = if is_model_wrapper(&name) {
            "model-proxy"
        } else {
            "specialist"
        };

        let model = detect_agent_model(&path);

        let session_count = path
            .join("sessions")
            .read_dir()
            .map(|rd| rd.count() as i64)
            .unwrap_or(0);

        let display_name = agent_display_name(&name);

        let agent = roboticus_db::agents::SubAgentRow {
            id: uuid_v4(),
            name: name.clone(),
            display_name: Some(display_name),
            model,
            fallback_models_json: Some("[]".to_string()),
            role: role.to_string(),
            description: None,
            skills_json: None,
            enabled: role == "specialist",
            session_count,
            last_used_at: None,
        };

        match roboticus_db::agents::upsert_sub_agent(&db, &agent) {
            Ok(()) => items += 1,
            Err(e) => warnings.push(format!("Failed to import agent '{name}': {e}")),
        }
    }

    AreaResult {
        area: MigrationArea::Agents,
        success: true,
        items_processed: items,
        warnings,
        error: None,
    }
}

pub(crate) fn export_agents(ic_root: &Path, _oc_root: &Path) -> AreaResult {
    let db_path = ic_root.join("state.db");
    if !db_path.exists() {
        return AreaResult {
            area: MigrationArea::Agents,
            success: true,
            items_processed: 0,
            warnings: vec!["No database found".into()],
            error: None,
        };
    }

    let db = match roboticus_db::Database::new(&db_path.to_string_lossy()) {
        Ok(d) => d,
        Err(e) => {
            return err(
                MigrationArea::Agents,
                format!("Failed to open database: {e}"),
            );
        }
    };

    let agents = match roboticus_db::agents::list_sub_agents(&db) {
        Ok(a) => a,
        Err(e) => {
            return err(MigrationArea::Agents, format!("Failed to list agents: {e}"));
        }
    };

    AreaResult {
        area: MigrationArea::Agents,
        success: true,
        items_processed: agents.len(),
        warnings: vec![],
        error: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn is_model_wrapper_detects_provider_prefixed_names() {
        assert!(is_model_wrapper("anthropic-claude"));
        assert!(is_model_wrapper("google-gemini"));
        assert!(is_model_wrapper("moonshot-v1"));
        assert!(is_model_wrapper("ollama-qwen"));
        assert!(is_model_wrapper("openai-gpt4"));
        assert!(is_model_wrapper("OpenAI-GPT4"));
    }

    #[test]
    fn is_model_wrapper_rejects_non_provider_names() {
        assert!(!is_model_wrapper("geo-specialist"));
        assert!(!is_model_wrapper("risk-analyst"));
        assert!(!is_model_wrapper("duncan"));
        assert!(!is_model_wrapper(""));
    }

    #[test]
    fn agent_display_name_capitalizes_and_joins() {
        assert_eq!(agent_display_name("geo-specialist"), "Geo Specialist");
        assert_eq!(agent_display_name("risk"), "Risk");
        assert_eq!(agent_display_name("multi-word-name"), "Multi Word Name");
    }

    #[test]
    fn agent_display_name_empty_segments() {
        assert_eq!(agent_display_name("-test-"), " Test ");
    }

    #[test]
    fn detect_agent_model_returns_empty_for_nonexistent_dir() {
        assert_eq!(detect_agent_model(Path::new("/nonexistent/agent")), "");
    }

    #[test]
    fn detect_agent_model_reads_primary_field() {
        let dir = TempDir::new().unwrap();
        let agent_dir = dir.path().join("agent");
        fs::create_dir_all(&agent_dir).unwrap();
        let models = serde_json::json!({"primary": "openai/gpt-4o"});
        fs::write(
            agent_dir.join("models.json"),
            serde_json::to_string(&models).unwrap(),
        )
        .unwrap();
        assert_eq!(detect_agent_model(dir.path()), "openai/gpt-4o");
    }

    #[test]
    fn detect_agent_model_reads_from_providers() {
        let dir = TempDir::new().unwrap();
        let agent_dir = dir.path().join("agent");
        fs::create_dir_all(&agent_dir).unwrap();
        let models = serde_json::json!({
            "providers": {
                "openai": {
                    "models": [{"id": "gpt-4"}]
                }
            }
        });
        fs::write(
            agent_dir.join("models.json"),
            serde_json::to_string(&models).unwrap(),
        )
        .unwrap();
        assert_eq!(detect_agent_model(dir.path()), "openai/gpt-4");
    }

    #[test]
    fn detect_agent_model_returns_empty_for_empty_json() {
        let dir = TempDir::new().unwrap();
        let agent_dir = dir.path().join("agent");
        fs::create_dir_all(&agent_dir).unwrap();
        fs::write(agent_dir.join("models.json"), "{}").unwrap();
        assert_eq!(detect_agent_model(dir.path()), "");
    }
}