acpr 0.3.0

Run agents from the ACP registry
Documentation
use crate::cli::ForceOption;
use anstyle::{Color, Style};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{debug, info};

#[derive(Deserialize)]
pub struct Registry {
    pub agents: Vec<Agent>,
}

#[derive(Deserialize)]
pub struct Agent {
    pub id: String,
    pub distribution: Distribution,
}

#[derive(Deserialize)]
pub struct Distribution {
    #[serde(default)]
    pub binary: HashMap<String, BinaryDist>,
    #[serde(default)]
    pub npx: Option<NpxDist>,
    #[serde(default)]
    pub uvx: Option<UvxDist>,
}

#[derive(Deserialize)]
pub struct BinaryDist {
    pub archive: String,
    pub cmd: String,
    #[serde(default)]
    pub args: Vec<String>,
}

#[derive(Deserialize)]
pub struct NpxDist {
    pub package: String,
    #[serde(default)]
    pub args: Vec<String>,
}

#[derive(Deserialize)]
pub struct UvxDist {
    pub package: String,
    #[serde(default)]
    pub args: Vec<String>,
}

#[derive(Serialize, Deserialize)]
pub struct CacheInfo {
    pub timestamp: u64,
    pub version: String,
}

pub async fn fetch_registry(
    cache_dir: &PathBuf,
    force: Option<&ForceOption>,
    registry_file: Option<&PathBuf>,
) -> Result<Registry, Box<dyn std::error::Error>> {
    if let Some(file_path) = registry_file {
        debug!("Using custom registry file: {:?}", file_path);
        let registry_content = tokio::fs::read_to_string(file_path).await?;
        return Ok(serde_json::from_str(&registry_content)?);
    }

    let registry_file = cache_dir.join("registry.json");
    let cache_info_file = cache_dir.join("registry_cache.json");

    let should_fetch = match force {
        Some(ForceOption::All | ForceOption::Registry) => {
            debug!("Force refresh requested for registry");
            true
        }
        _ => {
            if let Ok(info_content) = tokio::fs::read_to_string(&cache_info_file).await {
                if let Ok(cache_info) = serde_json::from_str::<CacheInfo>(&info_content) {
                    let now = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)?
                        .as_secs();
                    let age_hours = (now - cache_info.timestamp) / 3600;
                    debug!("Registry cache age: {} hours", age_hours);
                    now - cache_info.timestamp > 3 * 3600 // 3 hours
                } else {
                    debug!("Invalid cache info file, will fetch");
                    true
                }
            } else {
                debug!("No cache info file found, will fetch");
                true
            }
        }
    };

    if should_fetch {
        info!("Fetching registry from ACP...");
        let response =
            reqwest::get("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json")
                .await?;
        let registry_content = response.text().await?;
        debug!("Writing registry to cache: {:?}", registry_file);
        tokio::fs::write(&registry_file, &registry_content).await?;

        let cache_info = CacheInfo {
            timestamp: std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)?
                .as_secs(),
            version: "1.0.0".to_string(),
        };
        tokio::fs::write(&cache_info_file, serde_json::to_string(&cache_info)?).await?;
        debug!("Registry cache updated");
    } else {
        debug!("Using cached registry: {:?}", registry_file);
    }

    let registry_content = tokio::fs::read_to_string(&registry_file).await?;
    let registry: Registry = serde_json::from_str(&registry_content)?;
    debug!("Loaded {} agents from registry", registry.agents.len());
    Ok(registry)
}

pub fn list_agents(registry: &Registry) {
    let header_style = Style::new()
        .fg_color(Some(Color::Ansi(anstyle::AnsiColor::Cyan)))
        .bold();
    let name_style = Style::new().fg_color(Some(Color::Ansi(anstyle::AnsiColor::Green)));
    let desc_style = Style::new().fg_color(Some(Color::Ansi(anstyle::AnsiColor::White)));

    println!("{header_style}Available ACP Agents:{header_style:#}");
    println!();

    for agent in &registry.agents {
        let dist_types = get_distribution_types(&agent.distribution);
        println!(
            "{name_style}{}{name_style:#} {desc_style}({}){desc_style:#}",
            agent.id,
            dist_types.join(", ")
        );
    }
}

fn get_distribution_types(dist: &Distribution) -> Vec<&'static str> {
    let mut types = Vec::new();
    if !dist.binary.is_empty() {
        types.push("binary");
    }
    if dist.npx.is_some() {
        types.push("npx");
    }
    if dist.uvx.is_some() {
        types.push("uvx");
    }
    types
}