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}