1use std::collections::BTreeMap;
8use std::str::FromStr;
9
10use anyhow::{Result, anyhow};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14pub const REGISTRY_URL: &str =
16 "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
17
18pub type CommandArgs = Vec<String>;
20
21pub type Environment = BTreeMap<String, String>;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub enum Platform {
31 #[serde(rename = "darwin-aarch64")]
33 DarwinAarch64,
34 #[serde(rename = "darwin-x86_64")]
36 DarwinX86_64,
37 #[serde(rename = "linux-aarch64")]
39 LinuxAarch64,
40 #[serde(rename = "linux-x86_64")]
42 LinuxX86_64,
43 #[serde(rename = "windows-aarch64")]
45 WindowsAarch64,
46 #[serde(rename = "windows-x86_64")]
48 WindowsX86_64,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct BinaryTarget {
54 pub archive: String,
56 pub cmd: String,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub args: Option<CommandArgs>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub env: Option<Environment>,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct BinaryDistribution {
69 #[serde(
70 rename = "darwin-aarch64",
71 default,
72 skip_serializing_if = "Option::is_none"
73 )]
74 pub darwin_aarch64: Option<BinaryTarget>,
76 #[serde(
77 rename = "darwin-x86_64",
78 default,
79 skip_serializing_if = "Option::is_none"
80 )]
81 pub darwin_x86_64: Option<BinaryTarget>,
83 #[serde(
84 rename = "linux-aarch64",
85 default,
86 skip_serializing_if = "Option::is_none"
87 )]
88 pub linux_aarch64: Option<BinaryTarget>,
90 #[serde(
91 rename = "linux-x86_64",
92 default,
93 skip_serializing_if = "Option::is_none"
94 )]
95 pub linux_x86_64: Option<BinaryTarget>,
97 #[serde(
98 rename = "windows-aarch64",
99 default,
100 skip_serializing_if = "Option::is_none"
101 )]
102 pub windows_aarch64: Option<BinaryTarget>,
104 #[serde(
105 rename = "windows-x86_64",
106 default,
107 skip_serializing_if = "Option::is_none"
108 )]
109 pub windows_x86_64: Option<BinaryTarget>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct PackageDistribution {
116 pub package: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub args: Option<CommandArgs>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub env: Option<Environment>,
124}
125
126pub type NpxDistribution = PackageDistribution;
128pub type UvxDistribution = PackageDistribution;
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct AgentDistribution {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub binary: Option<BinaryDistribution>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub npx: Option<NpxDistribution>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub uvx: Option<UvxDistribution>,
143}
144
145impl AgentDistribution {
146 pub fn has_distribution_source(&self) -> bool {
148 self.binary.is_some() || self.npx.is_some() || self.uvx.is_some()
149 }
150
151 fn validate(&self, path: &str) -> Result<()> {
152 if self.has_distribution_source() {
153 Ok(())
154 } else {
155 Err(registry_decode_error(format!(
156 "{path}.distribution must contain at least one of binary, npx, or uvx"
157 )))
158 }
159 }
160}
161
162impl BinaryDistribution {
163 pub fn for_platform(&self, platform: Platform) -> Option<&BinaryTarget> {
165 match platform {
166 Platform::DarwinAarch64 => self.darwin_aarch64.as_ref(),
167 Platform::DarwinX86_64 => self.darwin_x86_64.as_ref(),
168 Platform::LinuxAarch64 => self.linux_aarch64.as_ref(),
169 Platform::LinuxX86_64 => self.linux_x86_64.as_ref(),
170 Platform::WindowsAarch64 => self.windows_aarch64.as_ref(),
171 Platform::WindowsX86_64 => self.windows_x86_64.as_ref(),
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct RegistryAgent {
179 pub id: String,
181 pub name: String,
183 pub version: String,
185 pub description: String,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub repository: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub website: Option<String>,
193 pub authors: Vec<String>,
195 pub license: String,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub icon: Option<String>,
200 pub distribution: AgentDistribution,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct Registry {
207 pub version: String,
209 pub agents: Vec<RegistryAgent>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub extensions: Option<Vec<Value>>,
214}
215
216impl Registry {
217 pub fn from_slice(input: &[u8]) -> Result<Self> {
219 let registry: Self =
220 serde_json::from_slice(input).map_err(|error| registry_decode_error(error))?;
221 registry.validate()?;
222 Ok(registry)
223 }
224
225 pub fn from_value(input: Value) -> Result<Self> {
227 let registry: Self =
228 serde_json::from_value(input).map_err(|error| registry_decode_error(error))?;
229 registry.validate()?;
230 Ok(registry)
231 }
232
233 pub fn validate(&self) -> Result<()> {
235 for (index, agent) in self.agents.iter().enumerate() {
236 let path = format!("agents[{index}]");
237 agent.distribution.validate(&path)?;
238 }
239
240 Ok(())
241 }
242
243 pub fn list_agents(&self) -> &[RegistryAgent] {
245 &self.agents
246 }
247
248 pub fn find_agent(&self, agent_id: &str) -> Option<&RegistryAgent> {
250 self.agents.iter().find(|agent| agent.id == agent_id)
251 }
252
253 pub fn get_agent(&self, agent_id: &str) -> Result<&RegistryAgent> {
255 self.find_agent(agent_id)
256 .ok_or_else(|| anyhow!("agent with id \"{agent_id}\" was not found"))
257 }
258
259 pub fn search_agents(&self, query: &str) -> Vec<&RegistryAgent> {
263 let needle = query.trim().to_ascii_lowercase();
264 if needle.is_empty() {
265 return self.agents.iter().collect();
266 }
267
268 self.agents
269 .iter()
270 .filter(|agent| {
271 [
272 agent.id.as_str(),
273 agent.name.as_str(),
274 agent.description.as_str(),
275 ]
276 .into_iter()
277 .any(|value| value.to_ascii_lowercase().contains(&needle))
278 })
279 .collect()
280 }
281}
282
283impl FromStr for Registry {
284 type Err = anyhow::Error;
285
286 fn from_str(input: &str) -> Result<Self, Self::Err> {
287 let registry: Self =
288 serde_json::from_str(input).map_err(|error| registry_decode_error(error))?;
289 registry.validate()?;
290 Ok(registry)
291 }
292}
293
294impl Platform {
295 pub fn current() -> Result<Self> {
298 match (std::env::consts::OS, std::env::consts::ARCH) {
299 ("macos", "aarch64") => Ok(Self::DarwinAarch64),
300 ("macos", "x86_64") => Ok(Self::DarwinX86_64),
301 ("linux", "aarch64") => Ok(Self::LinuxAarch64),
302 ("linux", "x86_64") => Ok(Self::LinuxX86_64),
303 ("windows", "aarch64") => Ok(Self::WindowsAarch64),
304 ("windows", "x86_64") => Ok(Self::WindowsX86_64),
305 (os, arch) => Err(anyhow!("unsupported platform: {os}-{arch}")),
306 }
307 }
308}
309
310pub async fn fetch_registry() -> Result<Registry> {
312 let response = reqwest::get(REGISTRY_URL)
313 .await
314 .map_err(|error| anyhow!("failed to fetch registry payload: {error}"))?;
315 let response = response
316 .error_for_status()
317 .map_err(|error| anyhow!("failed to fetch registry payload: {error}"))?;
318 let bytes = response
319 .bytes()
320 .await
321 .map_err(|error| anyhow!("failed to fetch registry payload: {error}"))?;
322 Registry::from_slice(bytes.as_ref())
323}
324
325fn registry_decode_error(reason: impl std::fmt::Display) -> anyhow::Error {
327 anyhow!("failed to decode registry payload from {REGISTRY_URL}: {reason}")
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use serde_json::json;
334
335 #[test]
336 fn decodes_registry_with_binary_distribution() {
337 let registry = Registry::from_value(json!({
338 "version": "1",
339 "agents": [
340 {
341 "id": "test-agent",
342 "name": "Test Agent",
343 "version": "0.1.0",
344 "description": "Example agent",
345 "authors": ["ACP"],
346 "license": "MIT",
347 "distribution": {
348 "binary": {
349 "linux-x86_64": {
350 "archive": "https://example.com/test-agent.tar.gz",
351 "cmd": "test-agent"
352 }
353 }
354 }
355 }
356 ]
357 }))
358 .expect("registry should decode");
359
360 let agent = registry
361 .get_agent("test-agent")
362 .expect("agent should exist");
363 assert!(agent.distribution.binary.is_some());
364 assert!(registry.search_agents("example").len() == 1);
365 }
366
367 #[test]
368 fn rejects_distribution_without_any_source() {
369 let error = Registry::from_value(json!({
370 "version": "1",
371 "agents": [
372 {
373 "id": "broken-agent",
374 "name": "Broken Agent",
375 "version": "0.1.0",
376 "description": "Missing distribution payload",
377 "authors": ["ACP"],
378 "license": "MIT",
379 "distribution": {}
380 }
381 ]
382 }))
383 .expect_err("registry should reject empty distribution");
384
385 assert!(
386 error
387 .to_string()
388 .contains("distribution must contain at least one of binary, npx, or uvx")
389 );
390 }
391
392 #[test]
393 fn finds_agents_case_insensitively() {
394 let registry = Registry::from_value(json!({
395 "version": "1",
396 "agents": [
397 {
398 "id": "alpha",
399 "name": "Alpha Agent",
400 "version": "0.1.0",
401 "description": "First result",
402 "authors": ["ACP"],
403 "license": "MIT",
404 "distribution": {
405 "npx": {
406 "package": "@acp/alpha"
407 }
408 }
409 },
410 {
411 "id": "beta",
412 "name": "Beta Agent",
413 "version": "0.1.0",
414 "description": "Second result",
415 "authors": ["ACP"],
416 "license": "MIT",
417 "distribution": {
418 "uvx": {
419 "package": "acp-beta"
420 }
421 }
422 }
423 ]
424 }))
425 .expect("registry should decode");
426
427 let results = registry.search_agents("ALPHA");
428 assert_eq!(results.len(), 1);
429 assert_eq!(results[0].id, "alpha");
430 }
431
432 #[test]
433 fn selects_binary_target_for_platform() {
434 let distribution = BinaryDistribution {
435 linux_x86_64: Some(BinaryTarget {
436 archive: "https://example.com/tool.tar.gz".to_string(),
437 cmd: "./tool".to_string(),
438 args: None,
439 env: None,
440 }),
441 ..Default::default()
442 };
443
444 let target = distribution
445 .for_platform(Platform::LinuxX86_64)
446 .expect("target should exist");
447 assert_eq!(target.cmd, "./tool");
448 }
449}