use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
pub const REGISTRY_URL: &str =
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Deserialize)]
pub struct Registry {
#[serde(default)]
pub agents: Vec<RegistryAgent>,
}
#[derive(Debug, Deserialize)]
pub struct RegistryAgent {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub distribution: Option<Distribution>,
}
#[derive(Debug, Deserialize)]
pub struct Distribution {
#[serde(default)]
pub npx: Option<PackageRun>,
#[serde(default)]
pub uvx: Option<PackageRun>,
#[serde(default)]
pub binary: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct PackageRun {
pub package: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StdioInvocation {
pub command: &'static str,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallSupport {
Stub(&'static str),
Manual,
None,
}
impl RegistryAgent {
pub fn stdio_invocation(&self) -> Option<StdioInvocation> {
let dist = self.distribution.as_ref()?;
if let Some(npx) = &dist.npx {
let mut args = vec!["-y".to_string(), npx.package.clone()];
args.extend(npx.args.iter().cloned());
return Some(StdioInvocation {
command: "npx",
args,
env: npx.env.clone(),
});
}
if let Some(uvx) = &dist.uvx {
let mut args = vec![uvx.package.clone()];
args.extend(uvx.args.iter().cloned());
return Some(StdioInvocation {
command: "uvx",
args,
env: uvx.env.clone(),
});
}
None
}
pub fn install_support(&self) -> InstallSupport {
match &self.distribution {
None => InstallSupport::None,
Some(d) if d.npx.is_some() => InstallSupport::Stub("npx"),
Some(d) if d.uvx.is_some() => InstallSupport::Stub("uvx"),
Some(d) if d.binary.is_some() => InstallSupport::Manual,
Some(_) => InstallSupport::None,
}
}
}
pub fn parse(json: &str) -> Result<Registry> {
serde_json::from_str(json).context("parsing ACP agent registry JSON")
}
pub async fn fetch(url: &str) -> Result<Registry> {
let client = reqwest::Client::builder()
.timeout(FETCH_TIMEOUT)
.build()
.context("building registry http client")?;
let body = client
.get(url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.with_context(|| format!("fetching ACP agent registry from {url}"))?
.text()
.await
.context("reading ACP agent registry body")?;
parse(&body)
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = r#"{
"version": "1",
"agents": [
{
"id": "gemini",
"name": "Gemini CLI",
"version": "0.50.0",
"description": "Google's official CLI for Gemini",
"repository": "https://github.com/google-gemini/gemini-cli",
"license": "Apache-2.0",
"distribution": { "npx": { "package": "@google/gemini-cli@0.50.0", "args": ["--acp"] } }
},
{
"id": "pytool",
"name": "PyTool",
"distribution": { "uvx": { "package": "pytool-acp@1.2.0", "env": { "PYTOOL_API_KEY": "" } } }
},
{
"id": "opencode",
"name": "OpenCode",
"version": "1.17.15",
"repository": "https://github.com/anomalyco/opencode",
"distribution": {
"binary": {
"darwin-aarch64": { "archive": "https://example.com/opencode.zip", "cmd": "./opencode", "args": ["acp"] }
}
}
},
{ "id": "mystery", "name": "Mystery", "unknown_future_field": 42 }
]
}"#;
#[test]
fn parses_real_shape_and_ignores_unknown_fields() {
let reg = parse(FIXTURE).expect("fixture parses");
assert_eq!(reg.agents.len(), 4);
assert_eq!(reg.agents[0].id, "gemini");
assert_eq!(reg.agents[0].version.as_deref(), Some("0.50.0"));
}
#[test]
fn npx_maps_to_stdio_invocation_with_dash_y() {
let reg = parse(FIXTURE).expect("parse");
let inv = reg.agents[0].stdio_invocation().expect("npx maps");
assert_eq!(inv.command, "npx");
assert_eq!(inv.args, vec!["-y", "@google/gemini-cli@0.50.0", "--acp"]);
assert!(inv.env.is_empty());
}
#[test]
fn uvx_maps_to_stdio_invocation_with_env() {
let reg = parse(FIXTURE).expect("parse");
let inv = reg.agents[1].stdio_invocation().expect("uvx maps");
assert_eq!(inv.command, "uvx");
assert_eq!(inv.args, vec!["pytool-acp@1.2.0"]);
assert!(inv.env.contains_key("PYTOOL_API_KEY"));
}
#[test]
fn binary_only_is_manual_and_has_no_invocation() {
let reg = parse(FIXTURE).expect("parse");
assert_eq!(reg.agents[2].stdio_invocation(), None);
assert_eq!(reg.agents[2].install_support(), InstallSupport::Manual);
}
#[test]
fn missing_distribution_classifies_as_none() {
let reg = parse(FIXTURE).expect("parse");
assert_eq!(reg.agents[3].install_support(), InstallSupport::None);
assert_eq!(reg.agents[3].stdio_invocation(), None);
}
}