Skip to main content

acp_agent/
registry.rs

1//! Facilities for decoding the shared agent registry that `acp-agent` consumes.
2//!
3//! This module exposes the registry schema, platform selectors, and helpers to
4//! search, validate, and fetch the remote JSON catalog that powers the
5//! `acp-agent` CLI.
6
7use std::collections::BTreeMap;
8use std::str::FromStr;
9
10use anyhow::{Result, anyhow};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// URL for the canonical agent registry payload consumed by the CLI.
15pub const REGISTRY_URL: &str =
16    "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
17
18/// CLI arguments forwarded to an agent's executable or package entry point.
19pub type CommandArgs = Vec<String>;
20
21/// Environment overrides applied before starting an agent.
22pub type Environment = BTreeMap<String, String>;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25/// Supported runtime targets for agent binaries.
26///
27/// Every agent catalog entry may include platform-specific binaries. The enum
28/// mirrors the coordinator's operating system + architecture matrix so the
29/// CLI can pick the fitting binary for the host that runs it.
30pub enum Platform {
31    /// macOS on Apple Silicon (`aarch64`).
32    #[serde(rename = "darwin-aarch64")]
33    DarwinAarch64,
34    /// macOS on Intel (`x86_64`).
35    #[serde(rename = "darwin-x86_64")]
36    DarwinX86_64,
37    /// Linux on arm64.
38    #[serde(rename = "linux-aarch64")]
39    LinuxAarch64,
40    /// Linux on x86_64.
41    #[serde(rename = "linux-x86_64")]
42    LinuxX86_64,
43    /// Windows on arm64.
44    #[serde(rename = "windows-aarch64")]
45    WindowsAarch64,
46    /// Windows on x86_64.
47    #[serde(rename = "windows-x86_64")]
48    WindowsX86_64,
49}
50
51/// A single downloadable binary distribution for a particular platform.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct BinaryTarget {
54    /// Remote archive that contains the binary package.
55    pub archive: String,
56    /// Relative path within the archive to the command that should be executed.
57    pub cmd: String,
58    /// Optional default command-line arguments that accompany the executable.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub args: Option<CommandArgs>,
61    /// Optional environment variables that will be injected before execution.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub env: Option<Environment>,
64}
65
66/// Binary references keyed by platform so the CLI can resolve the right file.
67#[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    /// Binary target published for macOS on Apple Silicon.
75    pub darwin_aarch64: Option<BinaryTarget>,
76    #[serde(
77        rename = "darwin-x86_64",
78        default,
79        skip_serializing_if = "Option::is_none"
80    )]
81    /// Binary target published for macOS on Intel.
82    pub darwin_x86_64: Option<BinaryTarget>,
83    #[serde(
84        rename = "linux-aarch64",
85        default,
86        skip_serializing_if = "Option::is_none"
87    )]
88    /// Binary target published for Linux on arm64.
89    pub linux_aarch64: Option<BinaryTarget>,
90    #[serde(
91        rename = "linux-x86_64",
92        default,
93        skip_serializing_if = "Option::is_none"
94    )]
95    /// Binary target published for Linux on x86_64.
96    pub linux_x86_64: Option<BinaryTarget>,
97    #[serde(
98        rename = "windows-aarch64",
99        default,
100        skip_serializing_if = "Option::is_none"
101    )]
102    /// Binary target published for Windows on arm64.
103    pub windows_aarch64: Option<BinaryTarget>,
104    #[serde(
105        rename = "windows-x86_64",
106        default,
107        skip_serializing_if = "Option::is_none"
108    )]
109    /// Binary target published for Windows on x86_64.
110    pub windows_x86_64: Option<BinaryTarget>,
111}
112
113/// Metadata for package-based distributions (npm/uvx).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct PackageDistribution {
116    /// Identifier that the package manager understands.
117    pub package: String,
118    /// Default arguments appended to the package manager invocation.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub args: Option<CommandArgs>,
121    /// Environment overrides that should apply when invoking the package manager.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub env: Option<Environment>,
124}
125
126/// Alias for npm-based package distributions.
127pub type NpxDistribution = PackageDistribution;
128/// Alias for `uvx`-based package distributions.
129pub type UvxDistribution = PackageDistribution;
130
131/// Distribution channels that may be published for an agent.
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct AgentDistribution {
134    /// Platform-specific binaries that can be downloaded and executed directly.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub binary: Option<BinaryDistribution>,
137    /// `npx` package metadata when the agent ships as an npm package.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub npx: Option<NpxDistribution>,
140    /// `uvx` package metadata for `uv` installed agents.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub uvx: Option<UvxDistribution>,
143}
144
145impl AgentDistribution {
146    /// Returns `true` if the agent references at least one install/run source.
147    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    /// Returns the binary target registered for the given platform, if any.
164    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/// Published metadata for a single ACP agent entry in the registry.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct RegistryAgent {
179    /// Unique identifier for the published agent.
180    pub id: String,
181    /// Human-readable name for lists (`list` command sorting uses this).
182    pub name: String,
183    /// Semantic version string describing the published agent release.
184    pub version: String,
185    /// Short summary that surfaces in search and list output.
186    pub description: String,
187    /// Repository URL that correlates with the source code or project page.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub repository: Option<String>,
190    /// Optional marketing or documentation website for the agent.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub website: Option<String>,
193    /// Author credits declared by the agent publisher.
194    pub authors: Vec<String>,
195    /// SPDX or free-form license declaration.
196    pub license: String,
197    /// Optional emoji or image URL used as an icon when rendering the CLI list.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub icon: Option<String>,
200    /// Distribution metadata to determine how the agent is installed/run.
201    pub distribution: AgentDistribution,
202}
203
204/// Top-level registry payload fetched from [`REGISTRY_URL`].
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct Registry {
207    /// Version label published alongside the registry payload.
208    pub version: String,
209    /// List of every registered agent exposed by the catalog.
210    pub agents: Vec<RegistryAgent>,
211    /// Optional extension data the catalog producer may append.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub extensions: Option<Vec<Value>>,
214}
215
216impl Registry {
217    /// Decodes the registry payload from a byte slice and validates it.
218    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    /// Decodes the registry from a `serde_json::Value` and validates it.
226    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    /// Ensures every agent has at least one distribution source.
234    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    /// Returns the raw `RegistryAgent` list that backs CLI commands/queries.
244    pub fn list_agents(&self) -> &[RegistryAgent] {
245        &self.agents
246    }
247
248    /// Finds an agent by `id`, returning `None` if no match exists.
249    pub fn find_agent(&self, agent_id: &str) -> Option<&RegistryAgent> {
250        self.agents.iter().find(|agent| agent.id == agent_id)
251    }
252
253    /// Retrieves an agent, failing if the `agent_id` is unknown.
254    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    /// Case-insensitive search across `id`, `name`, and `description`.
260    ///
261    /// An empty query returns all agents, just like the `list` command.
262    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    /// Detects the platform of the running process using `std::env::consts`.
296    /// Returns an error when the OS/ARCH combination is not listed in the enum.
297    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
310/// Downloads the registry JSON and resolves it into a `Registry`.
311pub 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
325/// Normalizes decode failure errors so the caller knows which URL failed.
326fn 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}