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