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