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