Skip to main content

edgecrab_plugins/
hub.rs

1use std::cmp::Ordering;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::config::{HubSource, PluginsConfig};
11use crate::error::PluginError;
12use crate::types::{PluginKind, TrustLevel};
13
14const CURATED_SOURCES: &[CuratedSource] = &[
15    CuratedSource {
16        name: "edgecrab-official",
17        url: "https://github.com/raphaelmansuy/edgecrab",
18        trust_level: TrustLevel::Official,
19        description: "Official EdgeCrab plugin registry",
20    },
21    CuratedSource {
22        name: "hermes-plugins",
23        url: "https://github.com/NousResearch/hermes-agent",
24        trust_level: TrustLevel::Official,
25        description: "Hermes Agent compatible plugins",
26    },
27    CuratedSource {
28        name: "hermes-evey",
29        url: "https://github.com/42-evey/hermes-plugins",
30        trust_level: TrustLevel::Community,
31        description: "Community Hermes plugins from 42-evey",
32    },
33];
34
35const GITHUB_SKIP_NAMES: &[&str] = &[".", "..", ".git", ".github", ".hub"];
36const MAX_ZIP_BYTES: usize = 32 * 1024 * 1024;
37
38const EDGECRAB_OFFICIAL_PLUGIN_ROOTS: &[RepoCatalogRoot] = &[RepoCatalogRoot {
39    kind: PluginKind::Hermes,
40    location: RepoCatalogLocation::Tree("plugins"),
41}];
42
43const EDGECRAB_OFFICIAL_ALL_ROOTS: &[RepoCatalogRoot] = &[
44    RepoCatalogRoot {
45        kind: PluginKind::Skill,
46        location: RepoCatalogLocation::Tree("skills"),
47    },
48    RepoCatalogRoot {
49        kind: PluginKind::Skill,
50        location: RepoCatalogLocation::Tree("optional-skills"),
51    },
52    RepoCatalogRoot {
53        kind: PluginKind::Hermes,
54        location: RepoCatalogLocation::Tree("plugins"),
55    },
56];
57
58const HERMES_ALL_ROOTS: &[RepoCatalogRoot] = &[
59    RepoCatalogRoot {
60        kind: PluginKind::Skill,
61        location: RepoCatalogLocation::Tree("skills"),
62    },
63    RepoCatalogRoot {
64        kind: PluginKind::Skill,
65        location: RepoCatalogLocation::Tree("optional-skills"),
66    },
67    RepoCatalogRoot {
68        kind: PluginKind::Hermes,
69        location: RepoCatalogLocation::Tree("plugins"),
70    },
71];
72
73const HERMES_PLUGIN_ROOTS: &[RepoCatalogRoot] = &[RepoCatalogRoot {
74    kind: PluginKind::Hermes,
75    location: RepoCatalogLocation::Tree("plugins"),
76}];
77
78const HERMES_EVEY_ROOTS: &[RepoCatalogRoot] = &[RepoCatalogRoot {
79    kind: PluginKind::Hermes,
80    location: RepoCatalogLocation::RepoRoot,
81}];
82
83#[derive(Debug, Clone, Copy)]
84pub struct CuratedSource {
85    pub name: &'static str,
86    pub url: &'static str,
87    pub trust_level: TrustLevel,
88    pub description: &'static str,
89}
90
91#[derive(Debug, Clone, Copy)]
92struct RepoBackedSource {
93    repo: &'static str,
94    roots: &'static [RepoCatalogRoot],
95    shared_root_files: &'static [&'static str],
96}
97
98#[derive(Debug, Clone, Copy)]
99struct RepoCatalogRoot {
100    kind: PluginKind,
101    location: RepoCatalogLocation,
102}
103
104#[derive(Debug, Clone, Copy)]
105enum RepoCatalogLocation {
106    Tree(&'static str),
107    RepoRoot,
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct HubIndex {
112    pub version: String,
113    pub generated_at: String,
114    pub source_name: String,
115    #[serde(default)]
116    pub plugins: Vec<HubIndexPlugin>,
117}
118
119#[derive(Debug, Clone, Deserialize, Serialize)]
120pub struct HubIndexPlugin {
121    pub name: String,
122    pub version: String,
123    pub description: String,
124    pub kind: PluginKind,
125    pub install_url: String,
126    pub checksum: String,
127    #[serde(default)]
128    pub author: String,
129    #[serde(default)]
130    pub license: String,
131    #[serde(default)]
132    pub homepage: Option<String>,
133    #[serde(default)]
134    pub tools: Vec<String>,
135    #[serde(default)]
136    pub requires_env: Vec<String>,
137    #[serde(default)]
138    pub tags: Vec<String>,
139}
140
141#[derive(Debug, Clone)]
142pub struct PluginHubSourceInfo {
143    pub name: String,
144    pub label: String,
145    pub trust_level: TrustLevel,
146    pub description: String,
147    pub url: String,
148}
149
150#[derive(Debug, Clone)]
151pub struct PluginMeta {
152    pub name: String,
153    pub identifier: String,
154    pub description: String,
155    pub version: String,
156    pub kind: PluginKind,
157    pub origin: String,
158    pub trust_level: String,
159    pub tags: Vec<String>,
160    pub install_url: String,
161    pub requires_env: Vec<String>,
162}
163
164#[derive(Debug, Clone)]
165pub struct PluginSearchGroup {
166    pub source: PluginHubSourceInfo,
167    pub results: Vec<PluginMeta>,
168    pub notice: Option<String>,
169}
170
171#[derive(Debug, Clone, Default)]
172pub struct PluginSearchReport {
173    pub groups: Vec<PluginSearchGroup>,
174}
175
176#[derive(Debug, Clone)]
177pub struct PluginSearchResult {
178    pub identifier: String,
179    pub source_name: String,
180    pub trust_level: TrustLevel,
181    pub plugin: HubIndexPlugin,
182    pub score: f32,
183}
184
185#[derive(Debug, Clone)]
186struct FetchOutcome<T> {
187    value: T,
188    notice: Option<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct PluginAuditEntry {
193    pub timestamp: String,
194    pub action: String,
195    pub plugin: String,
196    pub source: String,
197    pub trust_level: TrustLevel,
198    pub checksum: String,
199    pub forced: bool,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum InstallSourceKind {
204    GitHub,
205    Local,
206    HttpsArchive,
207    Hub,
208}
209
210#[derive(Debug, Clone)]
211pub struct ResolvedInstallSource {
212    pub kind: InstallSourceKind,
213    pub display: String,
214    pub materialized_source: String,
215    pub trust_level: TrustLevel,
216    pub plugin_name_hint: String,
217    pub expected_checksum: Option<String>,
218    pub source_name: Option<String>,
219    pub shared_files: Vec<SharedInstallFile>,
220}
221
222#[derive(Debug, Clone)]
223pub struct SharedInstallFile {
224    pub relative_path: String,
225    pub source: SharedInstallFileSource,
226}
227
228#[derive(Debug, Clone)]
229pub enum SharedInstallFileSource {
230    Local(PathBuf),
231    GitHub { repo: String, path: String },
232}
233
234pub async fn search_hub(
235    config: &PluginsConfig,
236    query: &str,
237    source_filter: Option<&str>,
238    limit: usize,
239) -> Result<Vec<PluginSearchResult>, PluginError> {
240    let sources = configured_sources(config);
241    let client = hub_client()?;
242    let mut results = Vec::new();
243    for source in &sources {
244        if !source_matches_filter(source_filter, &source.name) {
245            continue;
246        }
247        if repo_backed_source(&source.name).is_some()
248            && repo_backed_plugin_source(&source.name).is_none()
249        {
250            continue;
251        }
252        if let Some(mapping) = repo_backed_plugin_source(&source.name) {
253            let skill_results =
254                search_repo_backed_source(config, &client, source, mapping, query, limit).await?;
255            for (score, identifier, plugin) in skill_results {
256                results.push(PluginSearchResult {
257                    identifier,
258                    source_name: source.name.clone(),
259                    trust_level: source.trust_level,
260                    plugin,
261                    score,
262                });
263            }
264            continue;
265        }
266
267        let index = fetch_index(config, &client, source).await?.value;
268        for plugin in index.plugins {
269            let base_score = search_score(query, &plugin);
270            if base_score > 0.0 {
271                let score = if matches!(source.trust_level, TrustLevel::Official) {
272                    base_score + 0.5
273                } else {
274                    base_score
275                };
276                results.push(PluginSearchResult {
277                    identifier: format!("hub:{}/{}", source.name, plugin.name),
278                    source_name: source.name.clone(),
279                    trust_level: source.trust_level,
280                    plugin,
281                    score,
282                });
283            }
284        }
285    }
286
287    results.sort_by(|left, right| {
288        right
289            .score
290            .partial_cmp(&left.score)
291            .unwrap_or(Ordering::Equal)
292            .then_with(|| left.plugin.name.cmp(&right.plugin.name))
293    });
294    results.dedup_by(|left, right| left.plugin.name == right.plugin.name);
295    results.truncate(limit);
296    Ok(results)
297}
298
299pub async fn search_hub_report(
300    config: &PluginsConfig,
301    query: &str,
302    source_filter: Option<&str>,
303    limit: usize,
304) -> Result<PluginSearchReport, PluginError> {
305    let sources = configured_sources(config);
306    let client = hub_client()?;
307    let mut groups = Vec::new();
308    let per_source_limit = limit.max(1);
309
310    for source in &sources {
311        if !source_matches_filter(source_filter, &source.name) {
312            continue;
313        }
314        if repo_backed_source(&source.name).is_some()
315            && repo_backed_plugin_source(&source.name).is_none()
316        {
317            continue;
318        }
319
320        let source_info = source_info(source);
321        let group = if let Some(mapping) = repo_backed_plugin_source(&source.name) {
322            match search_repo_backed_meta(config, &client, source, mapping, query, per_source_limit)
323                .await
324            {
325                Ok(outcome) => PluginSearchGroup {
326                    source: source_info,
327                    results: outcome.value,
328                    notice: outcome.notice,
329                },
330                Err(error) => PluginSearchGroup {
331                    source: source_info,
332                    results: Vec::new(),
333                    notice: Some(error.to_string()),
334                },
335            }
336        } else {
337            match fetch_index(config, &client, source).await {
338                Ok(outcome) => {
339                    let index = outcome.value;
340                    let mut results: Vec<(f32, HubIndexPlugin)> = index
341                        .plugins
342                        .into_iter()
343                        .filter_map(|plugin| {
344                            let base_score = search_score(query, &plugin);
345                            (base_score > 0.0).then_some((
346                                if matches!(source.trust_level, TrustLevel::Official) {
347                                    base_score + 0.5
348                                } else {
349                                    base_score
350                                },
351                                plugin,
352                            ))
353                        })
354                        .collect();
355                    results.sort_by(|left, right| {
356                        right
357                            .0
358                            .partial_cmp(&left.0)
359                            .unwrap_or(Ordering::Equal)
360                            .then_with(|| left.1.name.cmp(&right.1.name))
361                    });
362                    results.truncate(per_source_limit);
363
364                    PluginSearchGroup {
365                        source: source_info,
366                        results: results
367                            .into_iter()
368                            .map(|(_, plugin)| plugin_meta(source, plugin))
369                            .collect(),
370                        notice: outcome.notice,
371                    }
372                }
373                Err(error) => PluginSearchGroup {
374                    source: source_info,
375                    results: Vec::new(),
376                    notice: Some(error.to_string()),
377                },
378            }
379        };
380
381        if !group.results.is_empty() || group.notice.is_some() {
382            groups.push(group);
383        }
384    }
385
386    Ok(PluginSearchReport { groups })
387}
388
389pub fn hub_source_names(config: &PluginsConfig) -> Vec<String> {
390    configured_sources(config)
391        .into_iter()
392        .map(|source| source.name)
393        .collect()
394}
395
396pub fn hub_source_summaries(config: &PluginsConfig) -> Vec<PluginHubSourceInfo> {
397    configured_sources(config)
398        .into_iter()
399        .filter(|source| {
400            repo_backed_source(&source.name).is_none()
401                || repo_backed_plugin_source(&source.name).is_some()
402        })
403        .map(|source| source_info(&source))
404        .collect()
405}
406
407pub fn clear_hub_cache(config: &PluginsConfig) -> Result<usize, PluginError> {
408    let dir = hub_cache_dir(config);
409    if !dir.exists() {
410        return Ok(0);
411    }
412    let mut removed = 0;
413    for entry in std::fs::read_dir(dir)? {
414        let entry = entry?;
415        if entry.path().is_file() {
416            std::fs::remove_file(entry.path())?;
417            removed += 1;
418        }
419    }
420    Ok(removed)
421}
422
423pub fn append_audit_entry(
424    config: &PluginsConfig,
425    entry: &PluginAuditEntry,
426) -> Result<(), PluginError> {
427    let path = audit_log_path(config);
428    if let Some(parent) = path.parent() {
429        std::fs::create_dir_all(parent)?;
430    }
431    use std::io::Write;
432    let mut file = std::fs::OpenOptions::new()
433        .create(true)
434        .append(true)
435        .open(path)?;
436    writeln!(
437        file,
438        "{}",
439        serde_json::to_string(entry).map_err(|error| PluginError::Hub(error.to_string()))?
440    )?;
441    Ok(())
442}
443
444pub fn read_audit_entries(
445    config: &PluginsConfig,
446    lines: usize,
447) -> Result<Vec<PluginAuditEntry>, PluginError> {
448    let path = audit_log_path(config);
449    let content = match std::fs::read_to_string(path) {
450        Ok(content) => content,
451        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
452        Err(error) => return Err(error.into()),
453    };
454    let mut entries = Vec::new();
455    for line in content.lines().rev().take(lines) {
456        if let Ok(entry) = serde_json::from_str(line) {
457            entries.push(entry);
458        }
459    }
460    entries.reverse();
461    Ok(entries)
462}
463
464pub fn resolve_install_source(source: &str) -> ResolvedInstallSource {
465    let trimmed = source.trim();
466    if let Some(rest) = trimmed.strip_prefix("hub:") {
467        let plugin_name_hint = rest
468            .trim_end_matches('/')
469            .rsplit('/')
470            .next()
471            .unwrap_or("plugin")
472            .to_string();
473        return ResolvedInstallSource {
474            kind: InstallSourceKind::Hub,
475            display: trimmed.to_string(),
476            materialized_source: trimmed.to_string(),
477            trust_level: TrustLevel::Community,
478            plugin_name_hint,
479            expected_checksum: None,
480            source_name: None,
481            shared_files: Vec::new(),
482        };
483    }
484    if trimmed.starts_with("https://") {
485        let plugin_name_hint = trimmed
486            .rsplit('/')
487            .next()
488            .unwrap_or("plugin.zip")
489            .trim_end_matches(".zip")
490            .to_string();
491        return ResolvedInstallSource {
492            kind: InstallSourceKind::HttpsArchive,
493            display: trimmed.to_string(),
494            materialized_source: trimmed.to_string(),
495            trust_level: TrustLevel::Unverified,
496            plugin_name_hint,
497            expected_checksum: None,
498            source_name: None,
499            shared_files: Vec::new(),
500        };
501    }
502    if trimmed.starts_with("github:") || looks_like_github_source(trimmed) {
503        let path = trimmed.trim_start_matches("github:");
504        return ResolvedInstallSource {
505            kind: InstallSourceKind::GitHub,
506            display: format!("github:{path}"),
507            materialized_source: format!("github:{path}"),
508            trust_level: TrustLevel::Unverified,
509            plugin_name_hint: path
510                .trim_end_matches('/')
511                .rsplit('/')
512                .next()
513                .unwrap_or("plugin")
514                .to_string(),
515            expected_checksum: None,
516            source_name: None,
517            shared_files: shared_files_for_github_source(path),
518        };
519    }
520
521    let local = trimmed.trim_start_matches("local:");
522    let plugin_name_hint = Path::new(local)
523        .file_name()
524        .and_then(|name| name.to_str())
525        .unwrap_or("plugin")
526        .to_string();
527    ResolvedInstallSource {
528        kind: InstallSourceKind::Local,
529        display: trimmed.to_string(),
530        materialized_source: trimmed.to_string(),
531        trust_level: TrustLevel::Unverified,
532        plugin_name_hint,
533        expected_checksum: None,
534        source_name: None,
535        shared_files: Vec::new(),
536    }
537}
538
539pub async fn materialize_source_to_dir(
540    config: &PluginsConfig,
541    source: &str,
542    destination: &Path,
543) -> Result<ResolvedInstallSource, PluginError> {
544    let parsed = resolve_install_source(source);
545    let resolved = match parsed.kind {
546        InstallSourceKind::Hub => resolve_hub_source(config, parsed.display.as_str()).await?,
547        _ => parsed,
548    };
549
550    match resolved.kind {
551        InstallSourceKind::Local => {
552            let raw = resolved.materialized_source.trim_start_matches("local:");
553            copy_dir(Path::new(raw), destination)?;
554        }
555        InstallSourceKind::GitHub => {
556            download_github_tree(
557                resolved.materialized_source.trim_start_matches("github:"),
558                destination,
559            )
560            .await?;
561        }
562        InstallSourceKind::HttpsArchive => {
563            download_https_archive(&resolved.materialized_source, destination).await?;
564        }
565        InstallSourceKind::Hub => unreachable!("hub sources are resolved before download"),
566    }
567
568    Ok(resolved)
569}
570
571pub async fn install_shared_files(
572    shared_files: &[SharedInstallFile],
573    install_root: &Path,
574) -> Result<(), PluginError> {
575    if shared_files.is_empty() {
576        return Ok(());
577    }
578
579    let client = hub_client()?;
580    for shared in shared_files {
581        let target = install_root.join(&shared.relative_path);
582        if let Some(parent) = target.parent() {
583            std::fs::create_dir_all(parent)?;
584        }
585        match &shared.source {
586            SharedInstallFileSource::Local(path) => {
587                std::fs::copy(path, &target)?;
588            }
589            SharedInstallFileSource::GitHub { repo, path } => {
590                let url = format!("https://raw.githubusercontent.com/{repo}/main/{path}");
591                let mut request = client.get(url);
592                if let Some(token) = resolve_github_token() {
593                    request = request.header("Authorization", format!("Bearer {token}"));
594                }
595                let bytes = request
596                    .send()
597                    .await
598                    .map_err(|error| PluginError::Hub(error.to_string()))?
599                    .error_for_status()
600                    .map_err(|error| PluginError::Hub(error.to_string()))?
601                    .bytes()
602                    .await
603                    .map_err(|error| PluginError::Hub(error.to_string()))?;
604                std::fs::write(&target, bytes)?;
605            }
606        }
607    }
608    Ok(())
609}
610
611pub fn sha256_dir(dir: &Path) -> Result<String, PluginError> {
612    let mut files = Vec::new();
613    collect_files(dir, dir, &mut files)?;
614    files.sort_by(|left, right| left.0.cmp(&right.0));
615
616    let mut hasher = Sha256::new();
617    for (relative, path) in files {
618        hasher.update(relative.as_bytes());
619        hasher.update([0]);
620        hasher.update(std::fs::read(path)?);
621        hasher.update([0xff]);
622    }
623    Ok(format!("sha256:{:x}", hasher.finalize()))
624}
625
626fn configured_sources(config: &PluginsConfig) -> Vec<RuntimeSource> {
627    let mut sources: Vec<RuntimeSource> = CURATED_SOURCES
628        .iter()
629        .map(|source| RuntimeSource {
630            name: source.name.to_string(),
631            url: source.url.to_string(),
632            trust_level: source.trust_level,
633        })
634        .collect();
635    for source in &config.hub.sources {
636        sources.push(runtime_source_from_hub(source));
637    }
638    sources
639}
640
641fn runtime_source_from_hub(source: &HubSource) -> RuntimeSource {
642    RuntimeSource {
643        name: source.name.clone(),
644        url: source.url.clone(),
645        trust_level: source.trust_override.unwrap_or(TrustLevel::Community),
646    }
647}
648
649fn source_info(source: &RuntimeSource) -> PluginHubSourceInfo {
650    PluginHubSourceInfo {
651        name: source.name.clone(),
652        label: source.name.clone(),
653        trust_level: source.trust_level,
654        description: source.description().to_string(),
655        url: source.url.clone(),
656    }
657}
658
659fn plugin_meta(source: &RuntimeSource, plugin: HubIndexPlugin) -> PluginMeta {
660    PluginMeta {
661        identifier: format!("hub:{}/{}", source.name, plugin.name),
662        name: plugin.name.clone(),
663        description: plugin.description.clone(),
664        version: plugin.version.clone(),
665        kind: plugin.kind,
666        origin: plugin
667            .homepage
668            .clone()
669            .unwrap_or_else(|| plugin.install_url.clone()),
670        trust_level: format!("{:?}", source.trust_level).to_ascii_lowercase(),
671        tags: plugin.tags.clone(),
672        install_url: plugin.install_url.clone(),
673        requires_env: plugin.requires_env.clone(),
674    }
675}
676
677fn plugin_meta_from_repo_entry(
678    source: &RuntimeSource,
679    repo: &str,
680    entry: &CachedRepoSourceEntry,
681    description: String,
682) -> PluginMeta {
683    PluginMeta {
684        identifier: format!("hub:{}/{}", source.name, entry.repo_path),
685        name: entry.name.clone(),
686        description,
687        version: "skill".into(),
688        kind: entry.kind,
689        origin: format!("https://github.com/{repo}/tree/main/{}", entry.repo_path),
690        trust_level: format!("{:?}", source.trust_level).to_ascii_lowercase(),
691        tags: entry.tags.clone(),
692        install_url: format!("github:{repo}/{}", entry.repo_path),
693        requires_env: entry.requires_env.clone(),
694    }
695}
696
697#[derive(Debug, Clone)]
698struct RuntimeSource {
699    name: String,
700    url: String,
701    trust_level: TrustLevel,
702}
703
704impl RuntimeSource {
705    fn description(&self) -> &'static str {
706        CURATED_SOURCES
707            .iter()
708            .find(|source| source.name == self.name)
709            .map(|source| source.description)
710            .unwrap_or("Configured plugin registry")
711    }
712}
713
714fn repo_backed_source(source_name: &str) -> Option<RepoBackedSource> {
715    match source_name {
716        "edgecrab-official" => Some(RepoBackedSource {
717            repo: "raphaelmansuy/edgecrab",
718            roots: EDGECRAB_OFFICIAL_ALL_ROOTS,
719            shared_root_files: &[],
720        }),
721        "hermes-plugins" => Some(RepoBackedSource {
722            repo: "NousResearch/hermes-agent",
723            roots: HERMES_ALL_ROOTS,
724            shared_root_files: &[],
725        }),
726        "hermes-evey" => Some(RepoBackedSource {
727            repo: "42-evey/hermes-plugins",
728            roots: HERMES_EVEY_ROOTS,
729            shared_root_files: &["evey_utils.py"],
730        }),
731        _ => None,
732    }
733}
734
735fn repo_backed_plugin_source(source_name: &str) -> Option<RepoBackedSource> {
736    match source_name {
737        "edgecrab-official" => Some(RepoBackedSource {
738            repo: "raphaelmansuy/edgecrab",
739            roots: EDGECRAB_OFFICIAL_PLUGIN_ROOTS,
740            shared_root_files: &[],
741        }),
742        "hermes-plugins" => Some(RepoBackedSource {
743            repo: "NousResearch/hermes-agent",
744            roots: HERMES_PLUGIN_ROOTS,
745            shared_root_files: &[],
746        }),
747        "hermes-evey" => Some(RepoBackedSource {
748            repo: "42-evey/hermes-plugins",
749            roots: HERMES_EVEY_ROOTS,
750            shared_root_files: &["evey_utils.py"],
751        }),
752        _ => None,
753    }
754}
755
756fn repo_backed_source_for_repo(repo: &str) -> Option<RepoBackedSource> {
757    CURATED_SOURCES
758        .iter()
759        .filter_map(|source| repo_backed_source(source.name))
760        .find(|mapping| mapping.repo.eq_ignore_ascii_case(repo))
761}
762
763fn source_matches_filter(filter: Option<&str>, source_name: &str) -> bool {
764    let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) else {
765        return true;
766    };
767    let normalized_filter = normalize_source_name(filter);
768    let normalized_source = normalize_source_name(source_name);
769    if normalized_filter == "official" {
770        return matches!(source_name, "edgecrab-official" | "hermes-plugins");
771    }
772    normalized_filter == normalized_source
773        || normalized_source.contains(&normalized_filter)
774        || normalized_filter.contains(&normalized_source)
775}
776
777fn normalize_source_name(value: &str) -> String {
778    match value.trim().to_ascii_lowercase().as_str() {
779        "edgecrab" | "edgecrab-official" | "edgecrab-plugin" | "edgecrab-plugins" => {
780            "edgecrabofficial".into()
781        }
782        "official" => "official".into(),
783        "hermes" | "hermes-plugin" | "hermes-plugins" => "hermesplugins".into(),
784        "hermes-agent" => "hermesplugins".into(),
785        "evey" | "42-evey" | "hermes-evey" | "evey-hermes" => "hermesevey".into(),
786        other => other
787            .chars()
788            .filter(|ch| ch.is_ascii_alphanumeric())
789            .collect(),
790    }
791}
792
793fn mapping_contains_repo_path(mapping: RepoBackedSource, repo_path: &str) -> bool {
794    mapping.roots.iter().any(|root| match root.location {
795        RepoCatalogLocation::Tree(prefix) => {
796            repo_path == prefix || repo_path.starts_with(&format!("{prefix}/"))
797        }
798        RepoCatalogLocation::RepoRoot => !repo_path.is_empty() && !repo_path.contains('/'),
799    })
800}
801
802fn shared_files_for_mapping(mapping: RepoBackedSource) -> Vec<SharedInstallFile> {
803    mapping
804        .shared_root_files
805        .iter()
806        .map(|path| SharedInstallFile {
807            relative_path: (*path).to_string(),
808            source: SharedInstallFileSource::GitHub {
809                repo: mapping.repo.to_string(),
810                path: (*path).to_string(),
811            },
812        })
813        .collect()
814}
815
816fn shared_files_for_github_source(source: &str) -> Vec<SharedInstallFile> {
817    let path = source.trim_matches('/');
818    let mut parts = path.splitn(3, '/');
819    let Some(owner) = parts.next() else {
820        return Vec::new();
821    };
822    let Some(repo) = parts.next() else {
823        return Vec::new();
824    };
825    let Some(repo_path) = parts.next() else {
826        return Vec::new();
827    };
828    let repo = format!("{owner}/{repo}");
829    let Some(mapping) = repo_backed_source_for_repo(&repo) else {
830        return Vec::new();
831    };
832    if mapping_contains_repo_path(mapping, repo_path) {
833        return shared_files_for_mapping(mapping);
834    }
835    Vec::new()
836}
837
838async fn resolve_hub_source(
839    config: &PluginsConfig,
840    source: &str,
841) -> Result<ResolvedInstallSource, PluginError> {
842    let spec = source.trim_start_matches("hub:");
843    let (source_name, plugin_name) = spec
844        .split_once('/')
845        .ok_or_else(|| PluginError::Hub("hub source must be hub:<source>/<plugin>".into()))?;
846    let runtime_source = configured_sources(config)
847        .into_iter()
848        .find(|candidate| candidate.name == source_name)
849        .ok_or_else(|| PluginError::Hub(format!("unknown plugin hub source: {source_name}")))?;
850    if let Some(mapping) = repo_backed_source(source_name) {
851        let client = hub_client()?;
852        let entry = fetch_repo_source_entries(config, &client, &runtime_source, mapping)
853            .await?
854            .value
855            .into_iter()
856            .find(|candidate| candidate.repo_path == plugin_name)
857            .ok_or_else(|| {
858                PluginError::Hub(format!("plugin '{plugin_name}' not found in {source_name}"))
859            })?;
860        if !mapping.roots.iter().any(|root| match root.location {
861            RepoCatalogLocation::Tree(prefix) => {
862                plugin_name == prefix || plugin_name.starts_with(&format!("{prefix}/"))
863            }
864            RepoCatalogLocation::RepoRoot => !plugin_name.contains('/'),
865        }) {
866            return Err(PluginError::Hub(format!(
867                "plugin '{plugin_name}' not found in {source_name}"
868            )));
869        }
870        return Ok(ResolvedInstallSource {
871            kind: InstallSourceKind::GitHub,
872            display: format!("hub:{source_name}/{plugin_name}"),
873            materialized_source: format!("github:{}/{plugin_name}", mapping.repo),
874            trust_level: runtime_source.trust_level,
875            plugin_name_hint: entry.name,
876            expected_checksum: None,
877            source_name: Some(source_name.to_string()),
878            shared_files: shared_files_for_mapping(mapping),
879        });
880    }
881    let client = hub_client()?;
882    let index = fetch_index(config, &client, &runtime_source).await?.value;
883    let plugin = index
884        .plugins
885        .into_iter()
886        .find(|candidate| candidate.name == plugin_name)
887        .ok_or_else(|| {
888            PluginError::Hub(format!("plugin '{plugin_name}' not found in {source_name}"))
889        })?;
890    Ok(ResolvedInstallSource {
891        kind: resolve_install_source(&plugin.install_url).kind,
892        display: format!("hub:{source_name}/{}", plugin.name),
893        materialized_source: plugin.install_url.clone(),
894        trust_level: runtime_source.trust_level,
895        plugin_name_hint: plugin.name,
896        expected_checksum: Some(plugin.checksum),
897        source_name: Some(source_name.to_string()),
898        shared_files: Vec::new(),
899    })
900}
901
902async fn search_repo_backed_meta(
903    config: &PluginsConfig,
904    client: &reqwest::Client,
905    source: &RuntimeSource,
906    mapping: RepoBackedSource,
907    query: &str,
908    limit: usize,
909) -> Result<FetchOutcome<Vec<PluginMeta>>, PluginError> {
910    let outcome = ranked_repo_entries(config, client, source, mapping, query, limit).await?;
911    Ok(FetchOutcome {
912        value: outcome
913            .value
914            .into_iter()
915            .map(|(_, entry, description)| {
916                plugin_meta_from_repo_entry(source, mapping.repo, &entry, description)
917            })
918            .collect(),
919        notice: outcome.notice,
920    })
921}
922
923async fn search_repo_backed_source(
924    config: &PluginsConfig,
925    client: &reqwest::Client,
926    source: &RuntimeSource,
927    mapping: RepoBackedSource,
928    query: &str,
929    limit: usize,
930) -> Result<Vec<(f32, String, HubIndexPlugin)>, PluginError> {
931    let ranked = ranked_repo_entries(config, client, source, mapping, query, limit).await?;
932    Ok(ranked
933        .value
934        .into_iter()
935        .map(|(score, entry, description)| {
936            let origin = format!(
937                "https://github.com/{}/tree/main/{}",
938                mapping.repo, entry.repo_path
939            );
940            (
941                score,
942                format!("hub:{}/{}", source.name, entry.repo_path),
943                HubIndexPlugin {
944                    name: entry.name,
945                    version: if entry.kind == PluginKind::Skill {
946                        "skill".into()
947                    } else {
948                        "0.1.0".into()
949                    },
950                    description,
951                    kind: entry.kind,
952                    install_url: format!("github:{}/{}", mapping.repo, entry.repo_path),
953                    checksum: String::new(),
954                    author: String::new(),
955                    license: String::new(),
956                    homepage: Some(origin),
957                    tools: entry.tools,
958                    requires_env: entry.requires_env,
959                    tags: entry.tags,
960                },
961            )
962        })
963        .collect())
964}
965
966async fn ranked_repo_entries(
967    config: &PluginsConfig,
968    client: &reqwest::Client,
969    source: &RuntimeSource,
970    mapping: RepoBackedSource,
971    query: &str,
972    limit: usize,
973) -> Result<FetchOutcome<Vec<(f32, CachedRepoSourceEntry, String)>>, PluginError> {
974    let source_entries = fetch_repo_source_entries(config, client, source, mapping).await?;
975    let mut ranked: Vec<(f32, CachedRepoSourceEntry)> = source_entries
976        .value
977        .iter()
978        .cloned()
979        .filter_map(|entry| {
980            let base_score = search_score_for_repo_entry(query, &entry);
981            (base_score > 0.0).then_some((
982                if matches!(source.trust_level, TrustLevel::Official) {
983                    base_score + 0.5
984                } else {
985                    base_score
986                },
987                entry,
988            ))
989        })
990        .collect();
991    ranked.sort_by(|left, right| {
992        right
993            .0
994            .partial_cmp(&left.0)
995            .unwrap_or(Ordering::Equal)
996            .then_with(|| left.1.repo_path.cmp(&right.1.repo_path))
997    });
998    ranked.truncate(limit.max(1));
999
1000    let mut results = Vec::with_capacity(ranked.len());
1001    for (score, entry) in ranked {
1002        let description =
1003            fetch_repo_entry_description(config, client, source, mapping.repo, &entry)
1004                .await
1005                .unwrap_or_else(|_| default_repo_entry_description(&entry));
1006        results.push((score, entry, description));
1007    }
1008    Ok(FetchOutcome {
1009        value: results,
1010        notice: source_entries.notice,
1011    })
1012}
1013
1014async fn fetch_repo_source_entries(
1015    config: &PluginsConfig,
1016    client: &reqwest::Client,
1017    source: &RuntimeSource,
1018    mapping: RepoBackedSource,
1019) -> Result<FetchOutcome<Vec<CachedRepoSourceEntry>>, PluginError> {
1020    let cache_path = repo_source_cache_path(config, source, mapping);
1021    let cached = read_repo_source_cache(config, source, mapping);
1022    if let Some(cached) = cached
1023        .as_ref()
1024        .filter(|cached| cache_is_fresh(cached.fetched_at, config))
1025    {
1026        return Ok(FetchOutcome {
1027            value: cached.entries.clone(),
1028            notice: None,
1029        });
1030    }
1031
1032    let tree_url = format!(
1033        "https://api.github.com/repos/{}/git/trees/main?recursive=1",
1034        mapping.repo
1035    );
1036    let tree = match async {
1037        let response = client
1038            .get(tree_url)
1039            .send()
1040            .await
1041            .map_err(|error| PluginError::Hub(error.to_string()))?
1042            .error_for_status()
1043            .map_err(|error| PluginError::Hub(error.to_string()))?;
1044        response
1045            .json::<GitTreeResponse>()
1046            .await
1047            .map_err(|error| PluginError::Hub(error.to_string()))
1048    }
1049    .await
1050    {
1051        Ok(tree) => tree,
1052        Err(error) => {
1053            if let Some(cached) = cached {
1054                return Ok(FetchOutcome {
1055                    value: cached.entries,
1056                    notice: Some(format!(
1057                        "{} using stale cached index after refresh failed",
1058                        source.name
1059                    )),
1060                });
1061            }
1062            return Err(error);
1063        }
1064    };
1065
1066    let mut entries = Vec::new();
1067    for item in tree.tree {
1068        if item.kind != "blob" {
1069            continue;
1070        }
1071        for root in mapping.roots {
1072            match root.location {
1073                RepoCatalogLocation::Tree(prefix) => {
1074                    let prefix = format!("{prefix}/");
1075                    let Some(relative) = item.path.strip_prefix(&prefix) else {
1076                        continue;
1077                    };
1078                    match root.kind {
1079                        PluginKind::Skill => {
1080                            let Some(relative) = relative.strip_suffix("/SKILL.md") else {
1081                                continue;
1082                            };
1083                            if relative.is_empty() {
1084                                continue;
1085                            }
1086                            let repo_path =
1087                                format!("{}/{}", prefix.trim_end_matches('/'), relative);
1088                            let name = relative.rsplit('/').next().unwrap_or(relative).to_string();
1089                            let tags = relative
1090                                .split('/')
1091                                .take(relative.split('/').count().saturating_sub(1))
1092                                .map(ToOwned::to_owned)
1093                                .collect();
1094                            entries.push(CachedRepoSourceEntry {
1095                                name,
1096                                repo_path,
1097                                tags,
1098                                kind: PluginKind::Skill,
1099                                tools: Vec::new(),
1100                                requires_env: Vec::new(),
1101                            });
1102                        }
1103                        PluginKind::Hermes => {
1104                            if !(relative.ends_with("/plugin.yaml")
1105                                || relative.ends_with("/plugin.yml"))
1106                            {
1107                                continue;
1108                            }
1109                            let Some(relative_dir) = relative.rsplit_once('/').map(|(dir, _)| dir)
1110                            else {
1111                                continue;
1112                            };
1113                            if relative_dir.is_empty() {
1114                                continue;
1115                            }
1116                            let repo_path =
1117                                format!("{}/{}", prefix.trim_end_matches('/'), relative_dir);
1118                            let name = relative_dir
1119                                .rsplit('/')
1120                                .next()
1121                                .unwrap_or(relative_dir)
1122                                .to_string();
1123                            let tags = relative_dir
1124                                .split('/')
1125                                .take(relative_dir.split('/').count().saturating_sub(1))
1126                                .map(ToOwned::to_owned)
1127                                .collect();
1128                            entries.push(CachedRepoSourceEntry {
1129                                name,
1130                                repo_path,
1131                                tags,
1132                                kind: PluginKind::Hermes,
1133                                tools: Vec::new(),
1134                                requires_env: Vec::new(),
1135                            });
1136                        }
1137                        _ => {}
1138                    }
1139                }
1140                RepoCatalogLocation::RepoRoot => {
1141                    if root.kind != PluginKind::Hermes {
1142                        continue;
1143                    }
1144                    let Some((dir_name, file_name)) = item.path.split_once('/') else {
1145                        continue;
1146                    };
1147                    if file_name != "plugin.yaml" && file_name != "plugin.yml" {
1148                        continue;
1149                    }
1150                    if dir_name.is_empty() || dir_name.starts_with('.') {
1151                        continue;
1152                    }
1153                    entries.push(CachedRepoSourceEntry {
1154                        name: dir_name.to_string(),
1155                        repo_path: dir_name.to_string(),
1156                        tags: Vec::new(),
1157                        kind: PluginKind::Hermes,
1158                        tools: Vec::new(),
1159                        requires_env: Vec::new(),
1160                    });
1161                }
1162            }
1163        }
1164    }
1165    entries.sort_by(|left, right| left.repo_path.cmp(&right.repo_path));
1166    entries.dedup_by(|left, right| left.repo_path == right.repo_path);
1167
1168    let cached = CachedRepoSource {
1169        fetched_at: current_epoch_secs(),
1170        entries: entries.clone(),
1171    };
1172    write_cached_json(&cache_path, &cached)?;
1173    Ok(FetchOutcome {
1174        value: entries,
1175        notice: None,
1176    })
1177}
1178
1179fn read_repo_source_cache(
1180    config: &PluginsConfig,
1181    source: &RuntimeSource,
1182    mapping: RepoBackedSource,
1183) -> Option<CachedRepoSource> {
1184    read_cached_json::<CachedRepoSource>(&repo_source_cache_path(config, source, mapping))
1185        .or_else(|| {
1186            read_cached_json::<CachedRepoSource>(&legacy_repo_source_cache_path(config, source))
1187        })
1188        .map(|cached| filter_repo_source_cache(cached, mapping))
1189}
1190
1191fn filter_repo_source_cache(
1192    cached: CachedRepoSource,
1193    mapping: RepoBackedSource,
1194) -> CachedRepoSource {
1195    CachedRepoSource {
1196        fetched_at: cached.fetched_at,
1197        entries: filter_repo_entries_for_mapping(cached.entries, mapping),
1198    }
1199}
1200
1201fn filter_repo_entries_for_mapping(
1202    entries: Vec<CachedRepoSourceEntry>,
1203    mapping: RepoBackedSource,
1204) -> Vec<CachedRepoSourceEntry> {
1205    entries
1206        .into_iter()
1207        .filter(|entry| repo_entry_matches_mapping(entry, mapping))
1208        .collect()
1209}
1210
1211fn repo_entry_matches_mapping(entry: &CachedRepoSourceEntry, mapping: RepoBackedSource) -> bool {
1212    mapping.roots.iter().any(|root| {
1213        if root.kind != entry.kind {
1214            return false;
1215        }
1216        match root.location {
1217            RepoCatalogLocation::Tree(prefix) => {
1218                entry.repo_path == prefix || entry.repo_path.starts_with(&format!("{prefix}/"))
1219            }
1220            RepoCatalogLocation::RepoRoot => {
1221                !entry.repo_path.is_empty() && !entry.repo_path.contains('/')
1222            }
1223        }
1224    })
1225}
1226
1227fn search_score_for_repo_entry(query: &str, entry: &CachedRepoSourceEntry) -> f32 {
1228    let q = query.trim().to_ascii_lowercase();
1229    if q.is_empty() {
1230        return 1.0;
1231    }
1232    let mut score = 0.0;
1233    let name = entry.name.to_ascii_lowercase();
1234    let repo_path = entry.repo_path.to_ascii_lowercase();
1235    if name.contains(&q) {
1236        score += 2.0;
1237    }
1238    if repo_path.contains(&q) {
1239        score += 1.0;
1240    }
1241    for tag in &entry.tags {
1242        if tag.to_ascii_lowercase().contains(&q) {
1243            score += 1.5;
1244        }
1245    }
1246    score
1247}
1248
1249async fn fetch_repo_entry_description(
1250    config: &PluginsConfig,
1251    client: &reqwest::Client,
1252    source: &RuntimeSource,
1253    repo: &str,
1254    entry: &CachedRepoSourceEntry,
1255) -> Result<String, PluginError> {
1256    let cache_path = repo_entry_description_cache_path(config, source, repo, entry);
1257    let cached = read_cached_json::<CachedRepoDescription>(&cache_path);
1258    if let Some(cached) = cached
1259        .as_ref()
1260        .filter(|cached| cache_is_fresh(cached.fetched_at, config))
1261    {
1262        return Ok(cached.description.clone());
1263    }
1264
1265    let file_name = if entry.kind == PluginKind::Skill {
1266        "SKILL.md"
1267    } else {
1268        "plugin.yaml"
1269    };
1270    let url = format!(
1271        "https://raw.githubusercontent.com/{repo}/main/{}/{}",
1272        entry.repo_path, file_name
1273    );
1274    let text = match async {
1275        let response = client
1276            .get(url)
1277            .send()
1278            .await
1279            .map_err(|error| PluginError::Hub(error.to_string()))?
1280            .error_for_status()
1281            .map_err(|error| PluginError::Hub(error.to_string()))?;
1282        response
1283            .text()
1284            .await
1285            .map_err(|error| PluginError::Hub(error.to_string()))
1286    }
1287    .await
1288    {
1289        Ok(text) => text,
1290        Err(error) => {
1291            if let Some(cached) = cached {
1292                return Ok(cached.description);
1293            }
1294            return Err(error);
1295        }
1296    };
1297    let description = if entry.kind == PluginKind::Skill {
1298        extract_skill_description(&text)
1299    } else {
1300        extract_hermes_plugin_description(&text)
1301    };
1302    write_cached_json(
1303        &cache_path,
1304        &CachedRepoDescription {
1305            fetched_at: current_epoch_secs(),
1306            description: description.clone(),
1307        },
1308    )?;
1309    Ok(description)
1310}
1311
1312fn extract_skill_description(markdown: &str) -> String {
1313    let mut lines = markdown.lines();
1314    let mut in_frontmatter = false;
1315    if matches!(lines.clone().next().map(str::trim), Some("---")) {
1316        in_frontmatter = true;
1317        lines.next();
1318    }
1319
1320    let mut paragraph = Vec::new();
1321    let mut seen_heading = false;
1322    for line in lines {
1323        let trimmed = line.trim();
1324        if trimmed.is_empty() {
1325            if !paragraph.is_empty() {
1326                break;
1327            }
1328            continue;
1329        }
1330        if in_frontmatter {
1331            if trimmed == "---" {
1332                in_frontmatter = false;
1333            }
1334            continue;
1335        }
1336        if trimmed.starts_with('#') {
1337            seen_heading = true;
1338            continue;
1339        }
1340        if trimmed.starts_with("```") {
1341            break;
1342        }
1343        if seen_heading || paragraph.is_empty() {
1344            paragraph.push(trimmed.trim_start_matches("- ").to_string());
1345        }
1346    }
1347    paragraph.join(" ")
1348}
1349
1350fn extract_hermes_plugin_description(yaml: &str) -> String {
1351    serde_yml::from_str::<serde_json::Value>(yaml)
1352        .ok()
1353        .and_then(|value| {
1354            value
1355                .get("description")
1356                .and_then(serde_json::Value::as_str)
1357                .map(str::trim)
1358                .filter(|text| !text.is_empty())
1359                .map(ToString::to_string)
1360        })
1361        .unwrap_or_else(|| "Hermes plugin".into())
1362}
1363
1364async fn fetch_index(
1365    config: &PluginsConfig,
1366    client: &reqwest::Client,
1367    source: &RuntimeSource,
1368) -> Result<FetchOutcome<HubIndex>, PluginError> {
1369    let cache_path = hub_cache_dir(config).join(format!("{}.json", source.name));
1370    let cached = read_cached_json::<CachedIndex>(&cache_path);
1371    if let Some(cached) = cached
1372        .as_ref()
1373        .filter(|cached| cache_is_fresh(cached.fetched_at, config))
1374    {
1375        return Ok(FetchOutcome {
1376            value: cached.index.clone(),
1377            notice: None,
1378        });
1379    }
1380
1381    let response = match client
1382        .get(&source.url)
1383        .send()
1384        .await
1385        .map_err(|error| PluginError::Hub(error.to_string()))
1386        .and_then(|response| {
1387            response
1388                .error_for_status()
1389                .map_err(|error| PluginError::Hub(error.to_string()))
1390        }) {
1391        Ok(response) => response,
1392        Err(error) => {
1393            if let Some(cached) = cached {
1394                return Ok(FetchOutcome {
1395                    value: cached.index,
1396                    notice: Some(format!(
1397                        "{} using stale cached index after refresh failed",
1398                        source.name
1399                    )),
1400                });
1401            }
1402            return Err(error);
1403        }
1404    };
1405    let bytes = response
1406        .bytes()
1407        .await
1408        .map_err(|error| PluginError::Hub(error.to_string()))?;
1409    let index = serde_json::from_slice::<HubIndex>(&bytes)
1410        .map_err(|error| PluginError::Hub(error.to_string()))?;
1411
1412    let cached = CachedIndex {
1413        fetched_at: current_epoch_secs(),
1414        index: index.clone(),
1415    };
1416    write_cached_json(&cache_path, &cached)?;
1417    Ok(FetchOutcome {
1418        value: index,
1419        notice: None,
1420    })
1421}
1422
1423#[derive(Debug, Clone, Serialize, Deserialize)]
1424struct CachedIndex {
1425    fetched_at: i64,
1426    index: HubIndex,
1427}
1428
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1430struct CachedRepoSource {
1431    fetched_at: i64,
1432    #[serde(default)]
1433    entries: Vec<CachedRepoSourceEntry>,
1434}
1435
1436#[derive(Debug, Clone, Serialize, Deserialize)]
1437struct CachedRepoSourceEntry {
1438    name: String,
1439    repo_path: String,
1440    #[serde(default)]
1441    tags: Vec<String>,
1442    kind: PluginKind,
1443    #[serde(default)]
1444    tools: Vec<String>,
1445    #[serde(default)]
1446    requires_env: Vec<String>,
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1450struct CachedRepoDescription {
1451    fetched_at: i64,
1452    description: String,
1453}
1454
1455#[derive(Debug, Deserialize)]
1456struct GitTreeResponse {
1457    #[serde(default)]
1458    tree: Vec<GitTreeEntry>,
1459}
1460
1461#[derive(Debug, Deserialize)]
1462struct GitTreeEntry {
1463    path: String,
1464    #[serde(rename = "type")]
1465    kind: String,
1466}
1467
1468fn search_score(query: &str, plugin: &HubIndexPlugin) -> f32 {
1469    let q = query.trim().to_ascii_lowercase();
1470    if q.is_empty() {
1471        return 1.0;
1472    }
1473    let mut score = 0.0;
1474    let name = plugin.name.to_ascii_lowercase();
1475    let desc = plugin.description.to_ascii_lowercase();
1476    if name.contains(&q) {
1477        score += 2.0;
1478    }
1479    if desc.contains(&q) {
1480        score += 1.0;
1481    }
1482    for tag in &plugin.tags {
1483        if tag.to_ascii_lowercase().contains(&q) {
1484            score += 1.5;
1485        }
1486    }
1487    score
1488}
1489
1490fn default_repo_entry_description(entry: &CachedRepoSourceEntry) -> String {
1491    match entry.kind {
1492        PluginKind::Hermes => "Hermes plugin".into(),
1493        PluginKind::Skill => "Skill".into(),
1494        PluginKind::ToolServer => "Tool-server plugin".into(),
1495        PluginKind::Script => "Script plugin".into(),
1496    }
1497}
1498
1499fn current_epoch_secs() -> i64 {
1500    chrono::Utc::now().timestamp()
1501}
1502
1503fn cache_is_fresh(fetched_at: i64, config: &PluginsConfig) -> bool {
1504    current_epoch_secs().saturating_sub(fetched_at) <= config.hub.cache_ttl_secs as i64
1505}
1506
1507fn read_cached_json<T>(path: &Path) -> Option<T>
1508where
1509    T: for<'de> Deserialize<'de>,
1510{
1511    let content = std::fs::read_to_string(path).ok()?;
1512    serde_json::from_str(&content).ok()
1513}
1514
1515fn write_cached_json<T>(path: &Path, value: &T) -> Result<(), PluginError>
1516where
1517    T: Serialize,
1518{
1519    if let Some(parent) = path.parent() {
1520        std::fs::create_dir_all(parent)?;
1521    }
1522    std::fs::write(
1523        path,
1524        serde_json::to_vec(value).map_err(|error| PluginError::Hub(error.to_string()))?,
1525    )?;
1526    Ok(())
1527}
1528
1529fn repo_entry_description_cache_path(
1530    config: &PluginsConfig,
1531    source: &RuntimeSource,
1532    repo: &str,
1533    entry: &CachedRepoSourceEntry,
1534) -> PathBuf {
1535    let key = format!(
1536        "{}:{}:{}:{:?}",
1537        source.name, repo, entry.repo_path, entry.kind
1538    );
1539    let digest = Sha256::digest(key.as_bytes());
1540    hub_cache_dir(config)
1541        .join("descriptions")
1542        .join(format!("{}-{:x}.json", source.name, digest))
1543}
1544
1545fn repo_source_cache_path(
1546    config: &PluginsConfig,
1547    source: &RuntimeSource,
1548    mapping: RepoBackedSource,
1549) -> PathBuf {
1550    let digest = Sha256::digest(repo_source_cache_key(mapping).as_bytes());
1551    hub_cache_dir(config).join(format!("{}-repo-{:x}.json", source.name, digest))
1552}
1553
1554fn legacy_repo_source_cache_path(config: &PluginsConfig, source: &RuntimeSource) -> PathBuf {
1555    hub_cache_dir(config).join(format!("{}-skills.json", source.name))
1556}
1557
1558fn repo_source_cache_key(mapping: RepoBackedSource) -> String {
1559    let mut key = format!("repo={}", mapping.repo);
1560    for root in mapping.roots {
1561        let location = match root.location {
1562            RepoCatalogLocation::Tree(prefix) => format!("tree:{prefix}"),
1563            RepoCatalogLocation::RepoRoot => "repo-root".into(),
1564        };
1565        key.push('|');
1566        key.push_str(root.kind.as_tag());
1567        key.push(':');
1568        key.push_str(&location);
1569    }
1570    key
1571}
1572
1573fn hub_client() -> Result<reqwest::Client, PluginError> {
1574    reqwest::Client::builder()
1575        .user_agent("edgecrab-plugin-hub/0.2")
1576        .timeout(Duration::from_secs(12))
1577        .build()
1578        .map_err(|error| PluginError::Hub(error.to_string()))
1579}
1580
1581fn hub_cache_dir(config: &PluginsConfig) -> PathBuf {
1582    config.install_dir.join(".hub").join("cache")
1583}
1584
1585fn audit_log_path(config: &PluginsConfig) -> PathBuf {
1586    config.install_dir.join(".hub").join("audit.log")
1587}
1588
1589fn looks_like_github_source(source: &str) -> bool {
1590    if source.starts_with('.') || source.starts_with('/') || source.starts_with('~') {
1591        return false;
1592    }
1593    let parts: Vec<&str> = source.split('/').collect();
1594    parts.len() >= 3 && !source.contains("://") && parts.iter().all(|part| !part.is_empty())
1595}
1596
1597async fn download_github_tree(path: &str, destination: &Path) -> Result<(), PluginError> {
1598    let path = path.trim_matches('/');
1599    let mut parts = path.splitn(3, '/');
1600    let owner = parts
1601        .next()
1602        .ok_or_else(|| PluginError::Hub("github source missing owner".into()))?;
1603    let repo = parts
1604        .next()
1605        .ok_or_else(|| PluginError::Hub("github source missing repo".into()))?;
1606    let root = parts
1607        .next()
1608        .ok_or_else(|| PluginError::Hub("github source missing path".into()))?;
1609
1610    let client = hub_client()?;
1611    let mut pending = vec![(root.to_string(), 0usize)];
1612    while let Some((dir, depth)) = pending.pop() {
1613        if depth > 2 {
1614            continue;
1615        }
1616        let url = format!("https://api.github.com/repos/{owner}/{repo}/contents/{dir}");
1617        let mut request = client.get(url);
1618        if let Some(token) = resolve_github_token() {
1619            request = request.header("Authorization", format!("Bearer {token}"));
1620        }
1621        let entries = request
1622            .send()
1623            .await
1624            .map_err(|error| PluginError::Hub(error.to_string()))?
1625            .error_for_status()
1626            .map_err(|error| PluginError::Hub(error.to_string()))?
1627            .json::<Vec<GitHubEntry>>()
1628            .await
1629            .map_err(|error| PluginError::Hub(error.to_string()))?;
1630
1631        for entry in entries {
1632            if GITHUB_SKIP_NAMES.contains(&entry.name.as_str()) {
1633                continue;
1634            }
1635            match entry.kind.as_str() {
1636                "file" => {
1637                    let relative = entry
1638                        .path
1639                        .strip_prefix(root)
1640                        .unwrap_or(entry.path.as_str())
1641                        .trim_start_matches('/');
1642                    let target = destination.join(relative);
1643                    if let Some(parent) = target.parent() {
1644                        std::fs::create_dir_all(parent)?;
1645                    }
1646                    let mut download =
1647                        client.get(entry.download_url.ok_or_else(|| {
1648                            PluginError::Hub("missing GitHub download_url".into())
1649                        })?);
1650                    if let Some(token) = resolve_github_token() {
1651                        download = download.header("Authorization", format!("Bearer {token}"));
1652                    }
1653                    let bytes = download
1654                        .send()
1655                        .await
1656                        .map_err(|error| PluginError::Hub(error.to_string()))?
1657                        .error_for_status()
1658                        .map_err(|error| PluginError::Hub(error.to_string()))?
1659                        .bytes()
1660                        .await
1661                        .map_err(|error| PluginError::Hub(error.to_string()))?;
1662                    std::fs::write(target, bytes)?;
1663                }
1664                "dir" => pending.push((entry.path, depth + 1)),
1665                _ => {}
1666            }
1667        }
1668    }
1669    Ok(())
1670}
1671
1672async fn download_https_archive(url: &str, destination: &Path) -> Result<(), PluginError> {
1673    let client = hub_client()?;
1674    let bytes = client
1675        .get(url)
1676        .send()
1677        .await
1678        .map_err(|error| PluginError::Hub(error.to_string()))?
1679        .error_for_status()
1680        .map_err(|error| PluginError::Hub(error.to_string()))?
1681        .bytes()
1682        .await
1683        .map_err(|error| PluginError::Hub(error.to_string()))?;
1684    if bytes.len() > MAX_ZIP_BYTES {
1685        return Err(PluginError::Hub(format!(
1686            "archive exceeds maximum size: {} bytes",
1687            bytes.len()
1688        )));
1689    }
1690
1691    let cursor = Cursor::new(bytes);
1692    let mut archive =
1693        zip::ZipArchive::new(cursor).map_err(|error| PluginError::Hub(error.to_string()))?;
1694    for index in 0..archive.len() {
1695        let mut entry = archive
1696            .by_index(index)
1697            .map_err(|error| PluginError::Hub(error.to_string()))?;
1698        let Some(path) = entry.enclosed_name().map(|path| path.to_path_buf()) else {
1699            continue;
1700        };
1701        let components = path.components().collect::<Vec<_>>();
1702        let relative = if components.len() > 1 {
1703            components[1..].iter().collect::<PathBuf>()
1704        } else {
1705            path.clone()
1706        };
1707        if relative.as_os_str().is_empty() {
1708            continue;
1709        }
1710        let target = destination.join(relative);
1711        if entry.is_dir() {
1712            std::fs::create_dir_all(&target)?;
1713            continue;
1714        }
1715        if let Some(parent) = target.parent() {
1716            std::fs::create_dir_all(parent)?;
1717        }
1718        let mut out = std::fs::File::create(target)?;
1719        std::io::copy(&mut entry, &mut out)?;
1720    }
1721    Ok(())
1722}
1723
1724#[derive(Debug, Deserialize)]
1725struct GitHubEntry {
1726    #[serde(rename = "type")]
1727    kind: String,
1728    name: String,
1729    path: String,
1730    download_url: Option<String>,
1731}
1732
1733fn resolve_github_token() -> Option<String> {
1734    if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
1735        return Some(token);
1736    }
1737    if let Ok(output) = Command::new("gh").args(["auth", "token"]).output()
1738        && output.status.success()
1739    {
1740        let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
1741        if !token.is_empty() {
1742            return Some(token);
1743        }
1744    }
1745    None
1746}
1747
1748fn copy_dir(source: &Path, destination: &Path) -> Result<(), PluginError> {
1749    if !source.is_dir() {
1750        return Err(PluginError::Hub(format!(
1751            "local plugin source is not a directory: {}",
1752            source.display()
1753        )));
1754    }
1755    std::fs::create_dir_all(destination)?;
1756    for entry in std::fs::read_dir(source)? {
1757        let entry = entry?;
1758        let path = entry.path();
1759        let target = destination.join(entry.file_name());
1760        if path.is_dir() {
1761            copy_dir(&path, &target)?;
1762        } else if path.is_file() {
1763            std::fs::copy(path, target)?;
1764        }
1765    }
1766    Ok(())
1767}
1768
1769fn collect_files(
1770    root: &Path,
1771    dir: &Path,
1772    files: &mut Vec<(String, PathBuf)>,
1773) -> Result<(), PluginError> {
1774    for entry in std::fs::read_dir(dir)? {
1775        let entry = entry?;
1776        let path = entry.path();
1777        if path.is_dir() {
1778            collect_files(root, &path, files)?;
1779            continue;
1780        }
1781        let relative = path
1782            .strip_prefix(root)
1783            .map_err(|error| PluginError::Hub(error.to_string()))?
1784            .to_string_lossy()
1785            .replace('\\', "/");
1786        files.push((relative, path));
1787    }
1788    Ok(())
1789}
1790
1791#[cfg(test)]
1792mod tests {
1793    use super::*;
1794    use tempfile::TempDir;
1795
1796    #[test]
1797    fn resolves_github_sources() {
1798        let resolved = resolve_install_source("owner/repo/path/to/plugin");
1799        assert_eq!(resolved.kind, InstallSourceKind::GitHub);
1800        assert_eq!(resolved.plugin_name_hint, "plugin");
1801    }
1802
1803    #[test]
1804    fn resolves_local_sources() {
1805        let resolved = resolve_install_source("./plugins/demo");
1806        assert_eq!(resolved.kind, InstallSourceKind::Local);
1807        assert_eq!(resolved.plugin_name_hint, "demo");
1808    }
1809
1810    #[test]
1811    fn resolves_hub_sources() {
1812        let resolved = resolve_install_source("hub:community/demo");
1813        assert_eq!(resolved.kind, InstallSourceKind::Hub);
1814        assert_eq!(resolved.plugin_name_hint, "demo");
1815    }
1816
1817    #[test]
1818    fn source_filter_matches_hermes_aliases() {
1819        assert!(source_matches_filter(Some("hermes"), "hermes-plugins"));
1820        assert!(source_matches_filter(
1821            Some("hermes-plugins"),
1822            "hermes-plugins"
1823        ));
1824        assert!(source_matches_filter(Some("evey"), "hermes-evey"));
1825        assert!(source_matches_filter(Some("42-evey"), "hermes-evey"));
1826        assert!(!source_matches_filter(Some("edgecrab"), "hermes-plugins"));
1827    }
1828
1829    #[test]
1830    fn source_filter_matches_edgecrab_and_official_aliases() {
1831        assert!(source_matches_filter(Some("edgecrab"), "edgecrab-official"));
1832        assert!(source_matches_filter(Some("official"), "edgecrab-official"));
1833        assert!(source_matches_filter(Some("official"), "hermes-plugins"));
1834    }
1835
1836    #[test]
1837    fn plugin_source_summaries_include_official_repo_sources_with_plugin_roots() {
1838        let sources = hub_source_summaries(&crate::config::PluginsConfig::default());
1839        assert!(
1840            sources
1841                .iter()
1842                .any(|source| source.name == "edgecrab-official"),
1843            "plugin browser should advertise official repo sources when plugin roots exist"
1844        );
1845        assert!(
1846            sources.iter().any(|source| source.name == "hermes-plugins"),
1847            "hermes plugin source should remain visible"
1848        );
1849    }
1850
1851    #[test]
1852    fn extracts_skill_description_after_heading() {
1853        let markdown = "# Demo Skill\n\nUseful summary line.\n\n## Steps\n- One";
1854        assert_eq!(extract_skill_description(markdown), "Useful summary line.");
1855    }
1856
1857    #[tokio::test]
1858    async fn cached_hermes_repo_index_includes_python_plugin_directories() {
1859        let temp = TempDir::new().expect("tempdir");
1860        let config = crate::config::PluginsConfig {
1861            install_dir: temp.path().join("plugins"),
1862            ..crate::config::PluginsConfig::default()
1863        };
1864        let source = RuntimeSource {
1865            name: "hermes-plugins".into(),
1866            url: "https://github.com/NousResearch/hermes-agent".into(),
1867            trust_level: TrustLevel::Official,
1868        };
1869        let mapping = repo_backed_plugin_source("hermes-plugins").expect("plugin mapping");
1870        let cache_path = repo_source_cache_path(&config, &source, mapping);
1871        std::fs::create_dir_all(cache_path.parent().expect("hub cache parent")).expect("cache dir");
1872        let cached = CachedRepoSource {
1873            fetched_at: chrono::Utc::now().timestamp(),
1874            entries: vec![CachedRepoSourceEntry {
1875                name: "holographic".into(),
1876                repo_path: "plugins/memory/holographic".into(),
1877                tags: vec!["memory".into()],
1878                kind: PluginKind::Hermes,
1879                tools: vec!["fact_store".into()],
1880                requires_env: Vec::new(),
1881            }],
1882        };
1883        std::fs::write(
1884            &cache_path,
1885            serde_json::to_vec(&cached).expect("serialize cache"),
1886        )
1887        .expect("write cache");
1888
1889        let report = search_hub_report(&config, "holographic", Some("hermes"), 10)
1890            .await
1891            .expect("search report");
1892        let result = report
1893            .groups
1894            .iter()
1895            .flat_map(|group| group.results.iter())
1896            .find(|result| result.name == "holographic")
1897            .expect("holographic result");
1898
1899        assert_eq!(result.kind, PluginKind::Hermes);
1900        assert_eq!(
1901            result.install_url,
1902            "github:NousResearch/hermes-agent/plugins/memory/holographic"
1903        );
1904    }
1905
1906    #[tokio::test]
1907    async fn cached_official_repo_index_includes_python_plugin_directories() {
1908        let temp = TempDir::new().expect("tempdir");
1909        let config = crate::config::PluginsConfig {
1910            install_dir: temp.path().join("plugins"),
1911            ..crate::config::PluginsConfig::default()
1912        };
1913        let source = RuntimeSource {
1914            name: "edgecrab-official".into(),
1915            url: "https://github.com/raphaelmansuy/edgecrab".into(),
1916            trust_level: TrustLevel::Official,
1917        };
1918        let mapping = repo_backed_plugin_source("edgecrab-official").expect("plugin mapping");
1919        let cache_path = repo_source_cache_path(&config, &source, mapping);
1920        std::fs::create_dir_all(cache_path.parent().expect("hub cache parent")).expect("cache dir");
1921        let cached = CachedRepoSource {
1922            fetched_at: chrono::Utc::now().timestamp(),
1923            entries: vec![CachedRepoSourceEntry {
1924                name: "calculator".into(),
1925                repo_path: "plugins/productivity/calculator".into(),
1926                tags: vec!["productivity".into()],
1927                kind: PluginKind::Hermes,
1928                tools: vec!["calculate".into(), "unit_convert".into()],
1929                requires_env: Vec::new(),
1930            }],
1931        };
1932        std::fs::write(
1933            &cache_path,
1934            serde_json::to_vec(&cached).expect("serialize cache"),
1935        )
1936        .expect("write cache");
1937
1938        let report = search_hub_report(&config, "calculator", Some("edgecrab"), 10)
1939            .await
1940            .expect("search report");
1941        let result = report
1942            .groups
1943            .iter()
1944            .flat_map(|group| group.results.iter())
1945            .find(|result| result.name == "calculator")
1946            .expect("calculator result");
1947
1948        assert_eq!(result.kind, PluginKind::Hermes);
1949        assert_eq!(
1950            result.install_url,
1951            "github:raphaelmansuy/edgecrab/plugins/productivity/calculator"
1952        );
1953    }
1954
1955    #[test]
1956    fn github_repo_root_hermes_sources_include_declared_shared_files() {
1957        let resolved = resolve_install_source("github:42-evey/hermes-plugins/evey-cache");
1958        assert_eq!(resolved.kind, InstallSourceKind::GitHub);
1959        assert_eq!(resolved.plugin_name_hint, "evey-cache");
1960        assert_eq!(resolved.shared_files.len(), 1);
1961        assert_eq!(resolved.shared_files[0].relative_path, "evey_utils.py");
1962        match &resolved.shared_files[0].source {
1963            SharedInstallFileSource::GitHub { repo, path } => {
1964                assert_eq!(repo, "42-evey/hermes-plugins");
1965                assert_eq!(path, "evey_utils.py");
1966            }
1967            other => panic!("unexpected shared file source: {other:?}"),
1968        }
1969    }
1970
1971    #[tokio::test]
1972    async fn cached_evey_repo_index_includes_repo_root_plugin_directories() {
1973        let temp = TempDir::new().expect("tempdir");
1974        let config = crate::config::PluginsConfig {
1975            install_dir: temp.path().join("plugins"),
1976            ..crate::config::PluginsConfig::default()
1977        };
1978        let source = RuntimeSource {
1979            name: "hermes-evey".into(),
1980            url: "https://github.com/42-evey/hermes-plugins".into(),
1981            trust_level: TrustLevel::Community,
1982        };
1983        let mapping = repo_backed_plugin_source("hermes-evey").expect("plugin mapping");
1984        let cache_path = repo_source_cache_path(&config, &source, mapping);
1985        std::fs::create_dir_all(cache_path.parent().expect("hub cache parent")).expect("cache dir");
1986        let cached = CachedRepoSource {
1987            fetched_at: chrono::Utc::now().timestamp(),
1988            entries: vec![CachedRepoSourceEntry {
1989                name: "evey-telemetry".into(),
1990                repo_path: "evey-telemetry".into(),
1991                tags: Vec::new(),
1992                kind: PluginKind::Hermes,
1993                tools: vec!["telemetry_query".into()],
1994                requires_env: Vec::new(),
1995            }],
1996        };
1997        std::fs::write(
1998            &cache_path,
1999            serde_json::to_vec(&cached).expect("serialize cache"),
2000        )
2001        .expect("write cache");
2002
2003        let report = search_hub_report(&config, "telemetry", Some("evey"), 10)
2004            .await
2005            .expect("search report");
2006        let result = report
2007            .groups
2008            .iter()
2009            .flat_map(|group| group.results.iter())
2010            .find(|result| result.name == "evey-telemetry")
2011            .expect("evey-telemetry result");
2012
2013        assert_eq!(result.kind, PluginKind::Hermes);
2014        assert_eq!(
2015            result.install_url,
2016            "github:42-evey/hermes-plugins/evey-telemetry"
2017        );
2018    }
2019
2020    #[tokio::test]
2021    async fn stale_cached_index_is_used_when_refresh_fails() {
2022        let temp = TempDir::new().expect("tempdir");
2023        let mut config = crate::config::PluginsConfig {
2024            install_dir: temp.path().join("plugins"),
2025            ..crate::config::PluginsConfig::default()
2026        };
2027        config.hub.cache_ttl_secs = 1;
2028
2029        let source = RuntimeSource {
2030            name: "custom-source".into(),
2031            url: "http://127.0.0.1:9/index.json".into(),
2032            trust_level: TrustLevel::Community,
2033        };
2034        let cache_path = hub_cache_dir(&config).join("custom-source.json");
2035        write_cached_json(
2036            &cache_path,
2037            &CachedIndex {
2038                fetched_at: current_epoch_secs() - 120,
2039                index: HubIndex {
2040                    version: "1".into(),
2041                    generated_at: "2026-04-10T00:00:00Z".into(),
2042                    source_name: "custom-source".into(),
2043                    plugins: vec![HubIndexPlugin {
2044                        name: "cached-plugin".into(),
2045                        version: "0.1.0".into(),
2046                        description: "From stale cache".into(),
2047                        kind: PluginKind::Hermes,
2048                        install_url: "github:owner/repo/cached-plugin".into(),
2049                        checksum: "sha256:test".into(),
2050                        author: String::new(),
2051                        license: String::new(),
2052                        homepage: None,
2053                        tools: Vec::new(),
2054                        requires_env: Vec::new(),
2055                        tags: vec!["cached".into()],
2056                    }],
2057                },
2058            },
2059        )
2060        .expect("write cache");
2061
2062        let outcome = fetch_index(&config, &hub_client().expect("client"), &source)
2063            .await
2064            .expect("fetch index");
2065
2066        assert_eq!(outcome.value.plugins.len(), 1);
2067        assert_eq!(outcome.value.plugins[0].name, "cached-plugin");
2068        assert!(
2069            outcome
2070                .notice
2071                .as_deref()
2072                .is_some_and(|notice| notice.contains("stale cached index")),
2073            "notice should explain stale cache fallback: {:?}",
2074            outcome.notice
2075        );
2076    }
2077
2078    #[tokio::test]
2079    async fn repo_entry_description_uses_fresh_cache_without_network() {
2080        let temp = TempDir::new().expect("tempdir");
2081        let config = crate::config::PluginsConfig {
2082            install_dir: temp.path().join("plugins"),
2083            ..crate::config::PluginsConfig::default()
2084        };
2085        let source = RuntimeSource {
2086            name: "hermes-plugins".into(),
2087            url: "https://github.com/NousResearch/hermes-agent".into(),
2088            trust_level: TrustLevel::Official,
2089        };
2090        let entry = CachedRepoSourceEntry {
2091            name: "secure-reader".into(),
2092            repo_path: "plugins/security/secure-reader".into(),
2093            tags: vec!["security".into()],
2094            kind: PluginKind::Hermes,
2095            tools: Vec::new(),
2096            requires_env: Vec::new(),
2097        };
2098        let cache_path = repo_entry_description_cache_path(&config, &source, "owner/repo", &entry);
2099        write_cached_json(
2100            &cache_path,
2101            &CachedRepoDescription {
2102                fetched_at: current_epoch_secs(),
2103                description: "Cached plugin description".into(),
2104            },
2105        )
2106        .expect("write cached description");
2107
2108        let description = fetch_repo_entry_description(
2109            &config,
2110            &hub_client().expect("client"),
2111            &source,
2112            "owner/repo",
2113            &entry,
2114        )
2115        .await
2116        .expect("description");
2117
2118        assert_eq!(description, "Cached plugin description");
2119    }
2120
2121    #[tokio::test]
2122    async fn plugin_search_report_ignores_skill_only_repo_results_and_keeps_hermes_plugins_visible()
2123    {
2124        let temp = TempDir::new().expect("tempdir");
2125        let config = crate::config::PluginsConfig {
2126            install_dir: temp.path().join("plugins"),
2127            ..crate::config::PluginsConfig::default()
2128        };
2129        let cache_dir = hub_cache_dir(&config);
2130        std::fs::create_dir_all(&cache_dir).expect("cache dir");
2131
2132        let edgecrab_plugin_source = RuntimeSource {
2133            name: "edgecrab-official".into(),
2134            url: "https://github.com/raphaelmansuy/edgecrab".into(),
2135            trust_level: TrustLevel::Official,
2136        };
2137        let edgecrab_plugin_mapping =
2138            repo_backed_plugin_source("edgecrab-official").expect("edgecrab plugin mapping");
2139        let edgecrab_plugin_cache = CachedRepoSource {
2140            fetched_at: chrono::Utc::now().timestamp(),
2141            entries: vec![CachedRepoSourceEntry {
2142                name: "calculator".into(),
2143                repo_path: "plugins/productivity/calculator".into(),
2144                tags: vec!["productivity".into()],
2145                kind: PluginKind::Hermes,
2146                tools: vec!["calculate".into()],
2147                requires_env: Vec::new(),
2148            }],
2149        };
2150        std::fs::write(
2151            repo_source_cache_path(&config, &edgecrab_plugin_source, edgecrab_plugin_mapping),
2152            serde_json::to_vec(&edgecrab_plugin_cache).expect("serialize edgecrab plugin cache"),
2153        )
2154        .expect("write edgecrab plugin cache");
2155
2156        let edgecrab_legacy_cache = CachedRepoSource {
2157            fetched_at: chrono::Utc::now().timestamp(),
2158            entries: vec![CachedRepoSourceEntry {
2159                name: "blackbox".into(),
2160                repo_path: "optional-skills/autonomous-ai-agents/blackbox".into(),
2161                tags: vec!["autonomous-ai-agents".into()],
2162                kind: PluginKind::Skill,
2163                tools: Vec::new(),
2164                requires_env: Vec::new(),
2165            }],
2166        };
2167        std::fs::write(
2168            legacy_repo_source_cache_path(&config, &edgecrab_plugin_source),
2169            serde_json::to_vec(&edgecrab_legacy_cache).expect("serialize edgecrab legacy cache"),
2170        )
2171        .expect("write edgecrab legacy cache");
2172
2173        let hermes_source = RuntimeSource {
2174            name: "hermes-plugins".into(),
2175            url: "https://github.com/NousResearch/hermes-agent".into(),
2176            trust_level: TrustLevel::Official,
2177        };
2178        let hermes_mapping = repo_backed_plugin_source("hermes-plugins").expect("hermes mapping");
2179        let hermes_cache = CachedRepoSource {
2180            fetched_at: chrono::Utc::now().timestamp(),
2181            entries: vec![CachedRepoSourceEntry {
2182                name: "holographic".into(),
2183                repo_path: "plugins/memory/holographic".into(),
2184                tags: vec!["memory".into()],
2185                kind: PluginKind::Hermes,
2186                tools: vec!["fact_store".into()],
2187                requires_env: Vec::new(),
2188            }],
2189        };
2190        std::fs::write(
2191            repo_source_cache_path(&config, &hermes_source, hermes_mapping),
2192            serde_json::to_vec(&hermes_cache).expect("serialize hermes cache"),
2193        )
2194        .expect("write hermes cache");
2195
2196        let evey_source = RuntimeSource {
2197            name: "hermes-evey".into(),
2198            url: "https://github.com/42-evey/hermes-plugins".into(),
2199            trust_level: TrustLevel::Community,
2200        };
2201        let evey_mapping = repo_backed_plugin_source("hermes-evey").expect("evey mapping");
2202        let evey_cache = CachedRepoSource {
2203            fetched_at: chrono::Utc::now().timestamp(),
2204            entries: Vec::new(),
2205        };
2206        std::fs::write(
2207            repo_source_cache_path(&config, &evey_source, evey_mapping),
2208            serde_json::to_vec(&evey_cache).expect("serialize evey cache"),
2209        )
2210        .expect("write evey cache");
2211
2212        let report = search_hub_report(&config, "o", None, 12)
2213            .await
2214            .expect("search report");
2215
2216        assert!(
2217            report
2218                .groups
2219                .iter()
2220                .any(|group| group.source.name == "edgecrab-official"),
2221            "official repo source should appear once it has plugin roots"
2222        );
2223        let names: Vec<&str> = report
2224            .groups
2225            .iter()
2226            .flat_map(|group| group.results.iter().map(|result| result.name.as_str()))
2227            .collect();
2228        assert!(
2229            names.contains(&"calculator"),
2230            "expected official EdgeCrab plugin to be visible: {names:?}"
2231        );
2232        assert!(
2233            names.contains(&"holographic"),
2234            "expected real Hermes plugin to remain visible: {names:?}"
2235        );
2236        assert!(
2237            !names.contains(&"blackbox"),
2238            "skill entry should not leak into plugin browser: {names:?}"
2239        );
2240    }
2241
2242    #[tokio::test]
2243    #[ignore = "network-backed smoke test"]
2244    async fn live_official_edgecrab_search_returns_real_plugins() {
2245        let report = search_hub_report(
2246            &crate::config::PluginsConfig::default(),
2247            "calculator",
2248            Some("edgecrab"),
2249            5,
2250        )
2251        .await
2252        .expect("live edgecrab search");
2253        assert!(report.groups.iter().any(|group| !group.results.is_empty()));
2254        assert!(
2255            report
2256                .groups
2257                .iter()
2258                .flat_map(|group| &group.results)
2259                .any(|result| result.name == "calculator")
2260        );
2261    }
2262
2263    #[tokio::test]
2264    #[ignore = "network-backed smoke test"]
2265    async fn live_official_hermes_search_returns_real_plugins() {
2266        let report = search_hub_report(
2267            &crate::config::PluginsConfig::default(),
2268            "github",
2269            Some("hermes"),
2270            5,
2271        )
2272        .await
2273        .expect("live hermes search");
2274        assert!(report.groups.iter().any(|group| !group.results.is_empty()));
2275        assert!(
2276            report
2277                .groups
2278                .iter()
2279                .flat_map(|group| &group.results)
2280                .all(|result| result.identifier.starts_with("hub:hermes-plugins/"))
2281        );
2282    }
2283}