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}