1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use anyhow::{Context as _, Result, bail};
6use collections::HashMap;
7use fs::Fs;
8use futures::AsyncReadExt;
9use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task};
10use http_client::{AsyncBody, HttpClient};
11use serde::Deserialize;
12use settings::Settings;
13
14use crate::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings};
15
16const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
17const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60);
18
19#[derive(Clone, Debug)]
20pub struct RegistryAgentMetadata {
21 pub id: SharedString,
22 pub name: SharedString,
23 pub description: SharedString,
24 pub version: SharedString,
25 pub repository: Option<SharedString>,
26 pub icon_path: Option<SharedString>,
27}
28
29#[derive(Clone, Debug)]
30pub struct RegistryBinaryAgent {
31 pub metadata: RegistryAgentMetadata,
32 pub targets: HashMap<String, RegistryTargetConfig>,
33 pub supports_current_platform: bool,
34}
35
36#[derive(Clone, Debug)]
37pub struct RegistryNpxAgent {
38 pub metadata: RegistryAgentMetadata,
39 pub package: SharedString,
40 pub args: Vec<String>,
41 pub env: HashMap<String, String>,
42}
43
44#[derive(Clone, Debug)]
45pub enum RegistryAgent {
46 Binary(RegistryBinaryAgent),
47 Npx(RegistryNpxAgent),
48}
49
50impl RegistryAgent {
51 pub fn metadata(&self) -> &RegistryAgentMetadata {
52 match self {
53 RegistryAgent::Binary(agent) => &agent.metadata,
54 RegistryAgent::Npx(agent) => &agent.metadata,
55 }
56 }
57
58 pub fn id(&self) -> &SharedString {
59 &self.metadata().id
60 }
61
62 pub fn name(&self) -> &SharedString {
63 &self.metadata().name
64 }
65
66 pub fn description(&self) -> &SharedString {
67 &self.metadata().description
68 }
69
70 pub fn version(&self) -> &SharedString {
71 &self.metadata().version
72 }
73
74 pub fn repository(&self) -> Option<&SharedString> {
75 self.metadata().repository.as_ref()
76 }
77
78 pub fn icon_path(&self) -> Option<&SharedString> {
79 self.metadata().icon_path.as_ref()
80 }
81
82 pub fn supports_current_platform(&self) -> bool {
83 match self {
84 RegistryAgent::Binary(agent) => agent.supports_current_platform,
85 RegistryAgent::Npx(_) => true,
86 }
87 }
88}
89
90#[derive(Clone, Debug)]
91pub struct RegistryTargetConfig {
92 pub archive: String,
93 pub cmd: String,
94 pub args: Vec<String>,
95 pub sha256: Option<String>,
96 pub env: HashMap<String, String>,
97}
98
99struct GlobalAgentRegistryStore(Entity<AgentRegistryStore>);
100
101impl Global for GlobalAgentRegistryStore {}
102
103pub struct AgentRegistryStore {
104 fs: Arc<dyn Fs>,
105 http_client: Arc<dyn HttpClient>,
106 agents: Vec<RegistryAgent>,
107 is_fetching: bool,
108 fetch_error: Option<SharedString>,
109 pending_refresh: Option<Task<()>>,
110 last_refresh: Option<Instant>,
111}
112
113impl AgentRegistryStore {
114 /// Initialize the global AgentRegistryStore.
115 ///
116 /// This loads the cached registry from disk. If the cache is empty but there
117 /// are registry agents configured in settings, it will trigger a network fetch.
118 /// Otherwise, call `refresh()` explicitly when you need fresh data
119 /// (e.g., when opening the Agent Registry page).
120 pub fn init_global(cx: &mut App) -> Entity<Self> {
121 if let Some(store) = Self::try_global(cx) {
122 return store;
123 }
124
125 let fs = <dyn Fs>::global(cx);
126 let http_client: Arc<dyn HttpClient> = cx.http_client();
127
128 let store = cx.new(|cx| Self::new(fs, http_client, cx));
129 cx.set_global(GlobalAgentRegistryStore(store.clone()));
130
131 let has_registry_agents_in_settings = AllAgentServersSettings::get_global(cx)
132 .custom
133 .values()
134 .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }));
135
136 if has_registry_agents_in_settings {
137 store.update(cx, |store, cx| {
138 if store.agents.is_empty() {
139 store.refresh(cx);
140 }
141 });
142 }
143
144 store
145 }
146
147 pub fn global(cx: &App) -> Entity<Self> {
148 cx.global::<GlobalAgentRegistryStore>().0.clone()
149 }
150
151 pub fn try_global(cx: &App) -> Option<Entity<Self>> {
152 cx.try_global::<GlobalAgentRegistryStore>()
153 .map(|store| store.0.clone())
154 }
155
156 pub fn agents(&self) -> &[RegistryAgent] {
157 &self.agents
158 }
159
160 pub fn agent(&self, id: &str) -> Option<&RegistryAgent> {
161 self.agents.iter().find(|agent| agent.id().as_ref() == id)
162 }
163
164 pub fn is_fetching(&self) -> bool {
165 self.is_fetching
166 }
167
168 pub fn fetch_error(&self) -> Option<SharedString> {
169 self.fetch_error.clone()
170 }
171
172 /// Refresh the registry from the network.
173 ///
174 /// This will fetch the latest registry data and update the cache.
175 pub fn refresh(&mut self, cx: &mut Context<Self>) {
176 if self.pending_refresh.is_some() {
177 return;
178 }
179
180 self.is_fetching = true;
181 self.fetch_error = None;
182 self.last_refresh = Some(Instant::now());
183 cx.notify();
184
185 let fs = self.fs.clone();
186 let http_client = self.http_client.clone();
187
188 self.pending_refresh = Some(cx.spawn(async move |this, cx| {
189 let result = match fetch_registry_index(http_client.clone()).await {
190 Ok(data) => {
191 build_registry_agents(fs.clone(), http_client, data.index, data.raw_body, true)
192 .await
193 }
194 Err(error) => Err(error),
195 };
196
197 this.update(cx, |this, cx| {
198 this.pending_refresh = None;
199 this.is_fetching = false;
200 match result {
201 Ok(agents) => {
202 this.agents = agents;
203 this.fetch_error = None;
204 }
205 Err(error) => {
206 this.fetch_error = Some(SharedString::from(error.to_string()));
207 }
208 }
209 cx.notify();
210 })
211 .ok();
212 }));
213 }
214
215 /// Refresh the registry if it hasn't been refreshed recently.
216 ///
217 /// This is useful to call when using a registry-based agent to check for
218 /// updates without making too many network requests. The refresh is
219 /// throttled to at most once per hour.
220 pub fn refresh_if_stale(&mut self, cx: &mut Context<Self>) {
221 let should_refresh = self
222 .last_refresh
223 .map(|last| last.elapsed() >= REFRESH_THROTTLE_DURATION)
224 .unwrap_or(true);
225
226 if should_refresh {
227 self.refresh(cx);
228 }
229 }
230
231 fn new(fs: Arc<dyn Fs>, http_client: Arc<dyn HttpClient>, cx: &mut Context<Self>) -> Self {
232 let mut store = Self {
233 fs: fs.clone(),
234 http_client,
235 agents: Vec::new(),
236 is_fetching: false,
237 fetch_error: None,
238 pending_refresh: None,
239 last_refresh: None,
240 };
241
242 store.load_cached_registry(fs, store.http_client.clone(), cx);
243
244 store
245 }
246
247 fn load_cached_registry(
248 &mut self,
249 fs: Arc<dyn Fs>,
250 http_client: Arc<dyn HttpClient>,
251 cx: &mut Context<Self>,
252 ) {
253 cx.spawn(async move |this, cx| -> Result<()> {
254 let cache_path = registry_cache_path();
255 if !fs.is_file(&cache_path).await {
256 return Ok(());
257 }
258
259 let bytes = fs
260 .load_bytes(&cache_path)
261 .await
262 .context("reading cached registry")?;
263 let index: RegistryIndex =
264 serde_json::from_slice(&bytes).context("parsing cached registry")?;
265
266 let agents = build_registry_agents(fs, http_client, index, bytes, false).await?;
267
268 this.update(cx, |this, cx| {
269 this.agents = agents;
270 cx.notify();
271 })?;
272
273 Ok(())
274 })
275 .detach_and_log_err(cx);
276 }
277}
278
279struct RegistryFetchResult {
280 index: RegistryIndex,
281 raw_body: Vec<u8>,
282}
283
284async fn fetch_registry_index(http_client: Arc<dyn HttpClient>) -> Result<RegistryFetchResult> {
285 let mut response = http_client
286 .get(REGISTRY_URL, AsyncBody::default(), true)
287 .await
288 .context("requesting ACP registry")?;
289
290 let mut body = Vec::new();
291 response
292 .body_mut()
293 .read_to_end(&mut body)
294 .await
295 .context("reading ACP registry response")?;
296
297 if response.status().is_client_error() {
298 let text = String::from_utf8_lossy(body.as_slice());
299 bail!(
300 "registry status error {}, response: {text:?}",
301 response.status().as_u16()
302 );
303 }
304
305 let index: RegistryIndex = serde_json::from_slice(&body).context("parsing ACP registry")?;
306 Ok(RegistryFetchResult {
307 index,
308 raw_body: body,
309 })
310}
311
312async fn build_registry_agents(
313 fs: Arc<dyn Fs>,
314 http_client: Arc<dyn HttpClient>,
315 index: RegistryIndex,
316 raw_body: Vec<u8>,
317 update_cache: bool,
318) -> Result<Vec<RegistryAgent>> {
319 let cache_dir = registry_cache_dir();
320 fs.create_dir(&cache_dir).await?;
321
322 let cache_path = cache_dir.join("registry.json");
323 if update_cache {
324 fs.write(&cache_path, &raw_body).await?;
325 }
326
327 let icons_dir = cache_dir.join("icons");
328 if update_cache {
329 fs.create_dir(&icons_dir).await?;
330 }
331
332 let current_platform = current_platform_key();
333
334 let mut agents = Vec::new();
335 for entry in index.agents {
336 let icon_path = resolve_icon_path(
337 &entry,
338 &icons_dir,
339 update_cache,
340 fs.clone(),
341 http_client.clone(),
342 )
343 .await?;
344
345 let metadata = RegistryAgentMetadata {
346 id: entry.id.into(),
347 name: entry.name.into(),
348 description: entry.description.into(),
349 version: entry.version.into(),
350 repository: entry.repository.map(Into::into),
351 icon_path,
352 };
353
354 let binary_agent = entry.distribution.binary.as_ref().and_then(|binary| {
355 if binary.is_empty() {
356 return None;
357 }
358
359 let mut targets = HashMap::default();
360 for (platform, target) in binary.iter() {
361 targets.insert(
362 platform.clone(),
363 RegistryTargetConfig {
364 archive: target.archive.clone(),
365 cmd: target.cmd.clone(),
366 args: target.args.clone(),
367 sha256: None,
368 env: target.env.clone(),
369 },
370 );
371 }
372
373 let supports_current_platform = current_platform
374 .as_ref()
375 .is_some_and(|platform| targets.contains_key(*platform));
376
377 Some(RegistryBinaryAgent {
378 metadata: metadata.clone(),
379 targets,
380 supports_current_platform,
381 })
382 });
383
384 let npx_agent = entry.distribution.npx.as_ref().map(|npx| RegistryNpxAgent {
385 metadata: metadata.clone(),
386 package: npx.package.clone().into(),
387 args: npx.args.clone(),
388 env: npx.env.clone(),
389 });
390
391 let agent = match (binary_agent, npx_agent) {
392 (Some(binary_agent), Some(npx_agent)) => {
393 if binary_agent.supports_current_platform {
394 RegistryAgent::Binary(binary_agent)
395 } else {
396 RegistryAgent::Npx(npx_agent)
397 }
398 }
399 (Some(binary_agent), None) => RegistryAgent::Binary(binary_agent),
400 (None, Some(npx_agent)) => RegistryAgent::Npx(npx_agent),
401 (None, None) => continue,
402 };
403
404 agents.push(agent);
405 }
406
407 Ok(agents)
408}
409
410async fn resolve_icon_path(
411 entry: &RegistryEntry,
412 icons_dir: &Path,
413 update_cache: bool,
414 fs: Arc<dyn Fs>,
415 http_client: Arc<dyn HttpClient>,
416) -> Result<Option<SharedString>> {
417 let icon_url = resolve_icon_url(entry);
418 let Some(icon_url) = icon_url else {
419 return Ok(None);
420 };
421
422 let icon_path = icons_dir.join(format!("{}.svg", entry.id));
423 if update_cache && !fs.is_file(&icon_path).await {
424 if let Err(error) = download_icon(fs.clone(), http_client, &icon_url, entry).await {
425 log::warn!(
426 "Failed to download ACP registry icon for {}: {error:#}",
427 entry.id
428 );
429 }
430 }
431
432 if fs.is_file(&icon_path).await {
433 Ok(Some(SharedString::from(
434 icon_path.to_string_lossy().into_owned(),
435 )))
436 } else {
437 Ok(None)
438 }
439}
440
441async fn download_icon(
442 fs: Arc<dyn Fs>,
443 http_client: Arc<dyn HttpClient>,
444 icon_url: &str,
445 entry: &RegistryEntry,
446) -> Result<()> {
447 let mut response = http_client
448 .get(icon_url, AsyncBody::default(), true)
449 .await
450 .with_context(|| format!("requesting icon for {}", entry.id))?;
451
452 let mut body = Vec::new();
453 response
454 .body_mut()
455 .read_to_end(&mut body)
456 .await
457 .with_context(|| format!("reading icon for {}", entry.id))?;
458
459 if response.status().is_client_error() {
460 let text = String::from_utf8_lossy(body.as_slice());
461 bail!(
462 "icon status error {}, response: {text:?}",
463 response.status().as_u16()
464 );
465 }
466
467 let icon_path = registry_cache_dir()
468 .join("icons")
469 .join(format!("{}.svg", entry.id));
470 fs.write(&icon_path, &body).await?;
471 Ok(())
472}
473
474fn resolve_icon_url(entry: &RegistryEntry) -> Option<String> {
475 let icon = entry.icon.as_ref()?;
476 if icon.starts_with("https://") || icon.starts_with("http://") {
477 return Some(icon.to_string());
478 }
479
480 let relative_icon = icon.trim_start_matches("./");
481 Some(format!(
482 "https://raw.githubusercontent.com/agentclientprotocol/registry/main/{}/{relative_icon}",
483 entry.id
484 ))
485}
486
487fn current_platform_key() -> Option<&'static str> {
488 let os = if cfg!(target_os = "macos") {
489 "darwin"
490 } else if cfg!(target_os = "linux") {
491 "linux"
492 } else if cfg!(target_os = "windows") {
493 "windows"
494 } else {
495 return None;
496 };
497
498 let arch = if cfg!(target_arch = "aarch64") {
499 "aarch64"
500 } else if cfg!(target_arch = "x86_64") {
501 "x86_64"
502 } else {
503 return None;
504 };
505
506 Some(match os {
507 "darwin" => match arch {
508 "aarch64" => "darwin-aarch64",
509 "x86_64" => "darwin-x86_64",
510 _ => return None,
511 },
512 "linux" => match arch {
513 "aarch64" => "linux-aarch64",
514 "x86_64" => "linux-x86_64",
515 _ => return None,
516 },
517 "windows" => match arch {
518 "aarch64" => "windows-aarch64",
519 "x86_64" => "windows-x86_64",
520 _ => return None,
521 },
522 _ => return None,
523 })
524}
525
526fn registry_cache_dir() -> PathBuf {
527 paths::external_agents_dir().join("registry")
528}
529
530fn registry_cache_path() -> PathBuf {
531 registry_cache_dir().join("registry.json")
532}
533
534#[derive(Deserialize)]
535struct RegistryIndex {
536 #[serde(rename = "version")]
537 _version: String,
538 agents: Vec<RegistryEntry>,
539 #[serde(rename = "extensions")]
540 _extensions: Vec<RegistryEntry>,
541}
542
543#[derive(Deserialize)]
544struct RegistryEntry {
545 id: String,
546 name: String,
547 version: String,
548 description: String,
549 #[serde(default)]
550 repository: Option<String>,
551 #[serde(default)]
552 icon: Option<String>,
553 distribution: RegistryDistribution,
554}
555
556#[derive(Deserialize)]
557struct RegistryDistribution {
558 #[serde(default)]
559 binary: Option<HashMap<String, RegistryBinaryTarget>>,
560 #[serde(default)]
561 npx: Option<RegistryNpxDistribution>,
562}
563
564#[derive(Deserialize)]
565struct RegistryBinaryTarget {
566 archive: String,
567 cmd: String,
568 #[serde(default)]
569 args: Vec<String>,
570 #[serde(default)]
571 env: HashMap<String, String>,
572}
573
574#[derive(Deserialize)]
575struct RegistryNpxDistribution {
576 package: String,
577 #[serde(default)]
578 args: Vec<String>,
579 #[serde(default)]
580 env: HashMap<String, String>,
581}