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