Skip to main content

edgecrab_plugins/
discovery.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use edgecrab_types::Platform;
5
6use crate::config::PluginsConfig;
7use crate::error::PluginError;
8use crate::hermes::{
9    HermesCliCommand, discover_entrypoint_plugins, hermes_declared_hooks, hermes_declared_tools,
10    introspect_runtime_surface, introspect_runtime_surface_for_entrypoint,
11    looks_like_hermes_plugin, missing_required_env as missing_hermes_env, parse_hermes_manifest,
12    synthesize_entrypoint_manifest, synthesize_manifest as synthesize_hermes_manifest,
13};
14use crate::manifest::{PluginManifest, parse_plugin_manifest};
15use crate::skill::inject::build_prompt_fragment;
16use crate::skill::loader::{LoadedSkill, scan_skills_dir};
17use crate::types::{PluginKind, PluginStatus, SkillReadinessStatus, SkillSource, TrustLevel};
18
19#[derive(Debug, Clone)]
20pub struct DiscoveredPlugin {
21    pub name: String,
22    pub version: String,
23    pub description: String,
24    pub compatibility: Option<String>,
25    pub kind: PluginKind,
26    pub status: PluginStatus,
27    pub path: PathBuf,
28    pub manifest: Option<PluginManifest>,
29    pub skill: Option<LoadedSkill>,
30    pub tools: Vec<String>,
31    pub hooks: Vec<String>,
32    pub trust_level: TrustLevel,
33    pub install_source: Option<String>,
34    pub enabled: bool,
35    pub source: SkillSource,
36    pub missing_env: Vec<String>,
37    pub related_skills: Vec<String>,
38    pub cli_commands: Vec<HermesCliCommand>,
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct PluginDiscovery {
43    pub plugins: Vec<DiscoveredPlugin>,
44}
45
46pub fn discover_plugins(
47    config: &PluginsConfig,
48    platform: Platform,
49) -> Result<PluginDiscovery, PluginError> {
50    let mut seen = HashSet::new();
51    let mut plugins = Vec::new();
52    for (root, source) in plugin_dirs(config) {
53        if !root.exists() {
54            continue;
55        }
56        for entry in std::fs::read_dir(&root)? {
57            let entry = entry?;
58            let path = entry.path();
59            if !path.is_dir() {
60                continue;
61            }
62            let manifest_path = path.join("plugin.toml");
63            let plugin = if manifest_path.is_file() {
64                load_plugin_from_manifest(config, &path, source, platform, &manifest_path)?
65            } else if looks_like_hermes_plugin(&path) {
66                load_hermes_plugin(config, &path, source, platform)?
67            } else if path.join("SKILL.md").is_file() {
68                load_skill_only_plugin(config, &path, source, platform)?
69            } else {
70                continue;
71            };
72            if seen.insert(plugin.name.clone()) {
73                plugins.push(plugin);
74            }
75        }
76    }
77    if let Ok(entrypoints) = discover_entrypoint_plugins() {
78        for entrypoint in entrypoints {
79            if seen.contains(&entrypoint.name) {
80                continue;
81            }
82            let plugin = load_hermes_entrypoint_plugin(config, entrypoint, platform)?;
83            if seen.insert(plugin.name.clone()) {
84                plugins.push(plugin);
85            }
86        }
87    }
88    plugins.sort_by(|left, right| left.name.cmp(&right.name));
89    Ok(PluginDiscovery { plugins })
90}
91
92pub fn build_plugin_skill_prompt(discovery: &PluginDiscovery) -> Option<String> {
93    let sections: Vec<String> = discovery
94        .plugins
95        .iter()
96        .filter_map(|plugin| {
97            if !plugin.enabled {
98                return None;
99            }
100            let skill = plugin.skill.as_ref()?;
101            if !skill.platform_visible {
102                return None;
103            }
104            match &skill.readiness {
105                SkillReadinessStatus::Available => Some(build_prompt_fragment(
106                    &skill.manifest.name,
107                    &skill.manifest.body,
108                )),
109                _ => None,
110            }
111        })
112        .collect();
113    if sections.is_empty() {
114        None
115    } else {
116        Some(format!("# Plugin Skills\n\n{}", sections.join("\n\n")))
117    }
118}
119
120fn load_plugin_from_manifest(
121    config: &PluginsConfig,
122    path: &Path,
123    source: SkillSource,
124    platform: Platform,
125    manifest_path: &Path,
126) -> Result<DiscoveredPlugin, PluginError> {
127    let manifest = parse_plugin_manifest(manifest_path)?;
128    if manifest.plugin.kind == PluginKind::Hermes && looks_like_hermes_plugin(path) {
129        return load_hermes_plugin_with_install_metadata(config, path, source, platform, manifest);
130    }
131    let enabled = config.is_plugin_enabled(&manifest.plugin.name, Some(&platform.to_string()));
132    let skill = if manifest.plugin.kind == PluginKind::Skill && path.join("SKILL.md").is_file() {
133        scan_skills_dir(path, source, &[], platform)?
134            .into_iter()
135            .find(|skill| skill.path == path.join("SKILL.md"))
136    } else {
137        None
138    };
139    let status = skill.as_ref().map_or_else(
140        || {
141            if enabled {
142                PluginStatus::Available
143            } else {
144                PluginStatus::Disabled
145            }
146        },
147        |skill| status_for_skill(enabled, skill),
148    );
149    let compatibility = skill
150        .as_ref()
151        .and_then(|loaded| loaded.manifest.compatibility.clone());
152    let related_skills = skill
153        .as_ref()
154        .map(|loaded| loaded.manifest.related_skills.clone())
155        .unwrap_or_default();
156    let missing_env = skill.as_ref().map(skill_missing_env).unwrap_or_default();
157    Ok(DiscoveredPlugin {
158        name: manifest.plugin.name.clone(),
159        version: manifest.plugin.version.clone(),
160        description: manifest.plugin.description.clone(),
161        compatibility,
162        kind: manifest.plugin.kind,
163        status,
164        path: path.to_path_buf(),
165        tools: manifest
166            .tools
167            .iter()
168            .map(|tool| tool.name.clone())
169            .collect(),
170        hooks: Vec::new(),
171        trust_level: manifest
172            .trust
173            .as_ref()
174            .map(|trust| trust.level)
175            .unwrap_or_default(),
176        install_source: manifest
177            .trust
178            .as_ref()
179            .and_then(|trust| trust.source.clone()),
180        manifest: Some(manifest),
181        skill,
182        enabled,
183        source,
184        missing_env,
185        related_skills,
186        cli_commands: Vec::new(),
187    })
188}
189
190fn load_skill_only_plugin(
191    config: &PluginsConfig,
192    path: &Path,
193    source: SkillSource,
194    platform: Platform,
195) -> Result<DiscoveredPlugin, PluginError> {
196    let loaded = scan_skills_dir(path, source, &[], platform)?
197        .into_iter()
198        .find(|skill| skill.path == path.join("SKILL.md"))
199        .ok_or_else(|| PluginError::InvalidSkill {
200            path: path.join("SKILL.md"),
201            message: "failed to load skill plugin".into(),
202        })?;
203    let missing_env = skill_missing_env(&loaded);
204    let enabled = config.is_plugin_enabled(&loaded.manifest.name, Some(&platform.to_string()));
205    let compatibility = loaded.manifest.compatibility.clone();
206    let related_skills = loaded.manifest.related_skills.clone();
207    Ok(DiscoveredPlugin {
208        name: loaded.manifest.name.clone(),
209        version: loaded
210            .manifest
211            .version
212            .clone()
213            .unwrap_or_else(|| "0.1.0".into()),
214        description: loaded.manifest.description.clone(),
215        compatibility,
216        kind: PluginKind::Skill,
217        status: status_for_skill(enabled, &loaded),
218        path: path.to_path_buf(),
219        manifest: None,
220        skill: Some(loaded),
221        tools: Vec::new(),
222        hooks: Vec::new(),
223        trust_level: TrustLevel::Unverified,
224        install_source: None,
225        enabled,
226        source,
227        missing_env,
228        related_skills,
229        cli_commands: Vec::new(),
230    })
231}
232
233fn plugin_dirs(config: &PluginsConfig) -> Vec<(PathBuf, SkillSource)> {
234    let mut seen = HashSet::new();
235    let mut dirs = Vec::new();
236
237    let mut push_dir = |path: PathBuf, source: SkillSource| {
238        if seen.insert(path.clone()) {
239            dirs.push((path, source));
240        }
241    };
242
243    push_dir(config.install_dir.clone(), SkillSource::User);
244    let legacy_user_plugins = dirs::home_dir()
245        .map(|home| home.join(".hermes").join("plugins"))
246        .unwrap_or_else(|| PathBuf::from("~/.hermes/plugins"));
247    push_dir(legacy_user_plugins, SkillSource::User);
248    for path in config.expanded_external_dirs() {
249        push_dir(path, SkillSource::User);
250    }
251    push_dir(PathBuf::from(".edgecrab/plugins"), SkillSource::Project);
252    if env_var_enabled("HERMES_ENABLE_PROJECT_PLUGINS") {
253        push_dir(PathBuf::from(".hermes/plugins"), SkillSource::Project);
254    }
255    #[cfg(unix)]
256    push_dir(
257        PathBuf::from("/usr/share/edgecrab/plugins"),
258        SkillSource::System,
259    );
260    dirs
261}
262
263fn status_for_skill(enabled: bool, loaded: &LoadedSkill) -> PluginStatus {
264    if !enabled {
265        return PluginStatus::Disabled;
266    }
267    if !loaded.platform_visible {
268        return PluginStatus::PlatformExcluded;
269    }
270    match loaded.readiness {
271        SkillReadinessStatus::Available => PluginStatus::Available,
272        SkillReadinessStatus::SetupNeeded { .. } => PluginStatus::SetupNeeded,
273        SkillReadinessStatus::Unsupported { .. } => PluginStatus::Unsupported,
274    }
275}
276
277#[cfg(test)]
278#[allow(clippy::items_after_test_module)]
279mod tests {
280    use super::*;
281    use tempfile::TempDir;
282
283    #[test]
284    fn builds_prompt_only_for_enabled_available_skills() {
285        let discovery = PluginDiscovery {
286            plugins: vec![DiscoveredPlugin {
287                name: "demo".into(),
288                version: "0.1.0".into(),
289                description: "Demo".into(),
290                compatibility: None,
291                kind: PluginKind::Skill,
292                status: PluginStatus::Available,
293                path: PathBuf::from("/tmp/demo"),
294                manifest: None,
295                skill: Some(LoadedSkill {
296                    path: PathBuf::from("/tmp/demo/SKILL.md"),
297                    source: SkillSource::User,
298                    manifest: crate::skill::manifest::SkillManifest {
299                        name: "demo".into(),
300                        description: "Demo".into(),
301                        body: "Follow demo steps.".into(),
302                        ..Default::default()
303                    },
304                    readiness: SkillReadinessStatus::Available,
305                    platform_visible: true,
306                }),
307                tools: Vec::new(),
308                hooks: Vec::new(),
309                trust_level: TrustLevel::Unverified,
310                install_source: None,
311                enabled: true,
312                source: SkillSource::User,
313                missing_env: Vec::new(),
314                related_skills: Vec::new(),
315                cli_commands: Vec::new(),
316            }],
317        };
318
319        let prompt = build_plugin_skill_prompt(&discovery).expect("prompt exists");
320        assert!(prompt.contains("# Plugin Skills"));
321        assert!(prompt.contains("Follow demo steps."));
322    }
323
324    #[test]
325    fn hermes_plugin_with_missing_env_is_setup_needed() {
326        let temp = TempDir::new().expect("tempdir");
327        let plugin_dir = temp.path().join("demo");
328        std::fs::create_dir_all(&plugin_dir).expect("plugin dir");
329        std::fs::write(
330            plugin_dir.join("plugin.yaml"),
331            r#"
332name: hermes-demo
333version: "1.0.0"
334description: Demo Hermes plugin
335provides_tools:
336  - hello_world
337requires_env:
338  - EDGECRAB_TEST_HERMES_TOKEN
339"#,
340        )
341        .expect("manifest");
342        std::fs::write(
343            plugin_dir.join("__init__.py"),
344            "def register(ctx):\n    pass\n",
345        )
346        .expect("plugin");
347
348        let config = PluginsConfig {
349            install_dir: temp.path().join("empty-install-root"),
350            external_dirs: vec![
351                plugin_dir
352                    .parent()
353                    .expect("parent")
354                    .to_string_lossy()
355                    .to_string(),
356            ],
357            ..PluginsConfig::default()
358        };
359
360        let discovery = discover_plugins(&config, Platform::Cli).expect("discovery");
361        let plugin = discovery
362            .plugins
363            .iter()
364            .find(|plugin| plugin.name == "hermes-demo")
365            .expect("plugin discovered");
366
367        assert_eq!(plugin.status, PluginStatus::SetupNeeded);
368        assert_eq!(plugin.missing_env, vec!["EDGECRAB_TEST_HERMES_TOKEN"]);
369    }
370
371    #[test]
372    fn hermes_plugin_loads_bundled_skill_metadata() {
373        let temp = TempDir::new().expect("tempdir");
374        let plugin_dir = temp.path().join("calculator");
375        std::fs::create_dir_all(&plugin_dir).expect("plugin dir");
376        std::fs::write(
377            plugin_dir.join("plugin.yaml"),
378            r#"
379name: calculator
380version: "1.0.0"
381description: Calculator plugin
382provides_tools:
383  - calculate
384"#,
385        )
386        .expect("manifest");
387        std::fs::write(
388            plugin_dir.join("__init__.py"),
389            "def register(ctx):\n    pass\n",
390        )
391        .expect("plugin");
392        std::fs::write(
393            plugin_dir.join("SKILL.md"),
394            r#"---
395name: calculator-skill
396description: Use calculator for exact arithmetic.
397compatibility: Requires calculator plugin
398metadata:
399  hermes:
400    related_skills: [math-notes]
401---
402
403# Calculator
404
405Use `calculate` for exact arithmetic.
406"#,
407        )
408        .expect("skill");
409
410        let config = PluginsConfig {
411            install_dir: temp.path().join("empty-install-root"),
412            external_dirs: vec![
413                plugin_dir
414                    .parent()
415                    .expect("parent")
416                    .to_string_lossy()
417                    .to_string(),
418            ],
419            ..PluginsConfig::default()
420        };
421
422        let discovery = discover_plugins(&config, Platform::Cli).expect("discovery");
423        let plugin = discovery
424            .plugins
425            .iter()
426            .find(|plugin| plugin.name == "calculator")
427            .expect("plugin discovered");
428
429        assert_eq!(
430            plugin.compatibility.as_deref(),
431            Some("Requires calculator plugin")
432        );
433        assert_eq!(plugin.related_skills, vec!["math-notes"]);
434        assert!(plugin.skill.is_some());
435    }
436}
437
438fn env_var_enabled(name: &str) -> bool {
439    std::env::var(name)
440        .ok()
441        .map(|value| {
442            matches!(
443                value.trim().to_ascii_lowercase().as_str(),
444                "1" | "true" | "yes" | "on"
445            )
446        })
447        .unwrap_or(false)
448}
449
450fn load_hermes_plugin(
451    config: &PluginsConfig,
452    path: &Path,
453    source: SkillSource,
454    platform: Platform,
455) -> Result<DiscoveredPlugin, PluginError> {
456    let hermes_manifest = parse_hermes_manifest(path)?;
457    load_hermes_plugin_with_install_metadata(
458        config,
459        path,
460        source,
461        platform,
462        synthesize_hermes_manifest(path, &hermes_manifest),
463    )
464}
465
466fn load_hermes_plugin_with_install_metadata(
467    config: &PluginsConfig,
468    path: &Path,
469    source: SkillSource,
470    platform: Platform,
471    install_manifest: PluginManifest,
472) -> Result<DiscoveredPlugin, PluginError> {
473    let hermes_manifest = parse_hermes_manifest(path)?;
474    let mut manifest = synthesize_hermes_manifest(path, &hermes_manifest);
475    if let (Some(exec), Some(installed_exec)) =
476        (manifest.exec.as_mut(), install_manifest.exec.as_ref())
477    {
478        exec.env = installed_exec.env.clone();
479        exec.cwd = installed_exec.cwd.clone();
480    }
481    manifest.trust = install_manifest.trust.clone();
482    manifest.integrity = install_manifest.integrity.clone();
483    let runtime_surface = introspect_runtime_surface(path, &hermes_manifest);
484    let mut tools = hermes_declared_tools(&hermes_manifest);
485    for tool in runtime_surface.tools {
486        if !tools.iter().any(|existing| existing == &tool.name) {
487            tools.push(tool.name);
488        }
489    }
490    tools.sort();
491    let mut hooks = hermes_declared_hooks(&hermes_manifest);
492    for hook in runtime_surface.hooks {
493        if !hooks.iter().any(|existing| existing == &hook) {
494            hooks.push(hook);
495        }
496    }
497    hooks.sort();
498    let enabled = config.is_plugin_enabled(&manifest.plugin.name, Some(&platform.to_string()));
499    let missing_env = missing_hermes_env(&hermes_manifest);
500    let skill = if path.join("SKILL.md").is_file() {
501        scan_skills_dir(path, source, &[], platform)?
502            .into_iter()
503            .find(|loaded| loaded.path == path.join("SKILL.md"))
504    } else {
505        None
506    };
507    let compatibility = skill
508        .as_ref()
509        .and_then(|loaded| loaded.manifest.compatibility.clone());
510    let related_skills = skill
511        .as_ref()
512        .map(|loaded| loaded.manifest.related_skills.clone())
513        .unwrap_or_default();
514    let trust_level = manifest
515        .trust
516        .as_ref()
517        .map(|trust| trust.level)
518        .unwrap_or(TrustLevel::Unverified);
519    let install_source = manifest
520        .trust
521        .as_ref()
522        .and_then(|trust| trust.source.clone());
523    Ok(DiscoveredPlugin {
524        name: manifest.plugin.name.clone(),
525        version: manifest.plugin.version.clone(),
526        description: manifest.plugin.description.clone(),
527        compatibility,
528        kind: manifest.plugin.kind,
529        status: if !enabled {
530            PluginStatus::Disabled
531        } else if missing_env.is_empty() {
532            PluginStatus::Available
533        } else {
534            PluginStatus::SetupNeeded
535        },
536        path: path.to_path_buf(),
537        manifest: Some(manifest),
538        skill,
539        tools,
540        hooks,
541        trust_level,
542        install_source,
543        enabled,
544        source,
545        missing_env,
546        related_skills,
547        cli_commands: runtime_surface.cli_commands,
548    })
549}
550
551fn load_hermes_entrypoint_plugin(
552    config: &PluginsConfig,
553    entrypoint: crate::hermes::HermesEntrypointPlugin,
554    platform: Platform,
555) -> Result<DiscoveredPlugin, PluginError> {
556    let manifest = synthesize_entrypoint_manifest(&entrypoint, None);
557    let runtime_surface = introspect_runtime_surface_for_entrypoint(&entrypoint);
558    let mut tools = runtime_surface
559        .tools
560        .iter()
561        .map(|tool| tool.name.clone())
562        .collect::<Vec<_>>();
563    tools.sort();
564    tools.dedup();
565    let mut hooks = runtime_surface.hooks.clone();
566    hooks.sort();
567    hooks.dedup();
568    let enabled = config.is_plugin_enabled(&manifest.plugin.name, Some(&platform.to_string()));
569
570    Ok(DiscoveredPlugin {
571        name: manifest.plugin.name.clone(),
572        version: manifest.plugin.version.clone(),
573        description: manifest.plugin.description.clone(),
574        compatibility: None,
575        kind: manifest.plugin.kind,
576        status: if enabled {
577            PluginStatus::Available
578        } else {
579            PluginStatus::Disabled
580        },
581        path: entrypoint
582            .module_path
583            .clone()
584            .unwrap_or_else(|| PathBuf::from(format!("entrypoint:{}", entrypoint.name))),
585        manifest: Some(manifest),
586        skill: None,
587        tools,
588        hooks,
589        trust_level: TrustLevel::Unverified,
590        install_source: Some(format!("entrypoint:{}", entrypoint.value)),
591        enabled,
592        source: SkillSource::System,
593        missing_env: Vec::new(),
594        related_skills: Vec::new(),
595        cli_commands: runtime_surface.cli_commands,
596    })
597}
598
599fn skill_missing_env(loaded: &LoadedSkill) -> Vec<String> {
600    match &loaded.readiness {
601        SkillReadinessStatus::SetupNeeded { missing } => missing.clone(),
602        _ => Vec::new(),
603    }
604}