agent_registry_store.rs

  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}