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