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