Skip to main content

apollo/
plugin_hosts.rs

1//! OpenClaw + Hermes plugin discovery (host-agnostic).
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum HostPluginKind {
9    OpenClawSkill,
10    Hermes,
11    ManifestJson,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct HostPluginEntry {
16    pub id: String,
17    pub kind: HostPluginKind,
18    pub path: PathBuf,
19    pub name: Option<String>,
20    pub description: Option<String>,
21}
22
23pub fn discover_host_plugins(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<HostPluginEntry> {
24    let mut roots = vec![workspace.to_path_buf()];
25    roots.extend(extra_roots.iter().cloned());
26    let mut out = Vec::new();
27    for root in &roots {
28        scan_plugins_dir(&root.join("plugins"), &mut out);
29        scan_plugins_dir(&root.join(".openclaw/plugins"), &mut out);
30        scan_plugins_dir(&root.join(".hermes/plugins"), &mut out);
31    }
32    out.sort_by(|a, b| a.id.cmp(&b.id));
33    out.dedup_by(|a, b| a.path == b.path);
34    out
35}
36
37fn scan_plugins_dir(dir: &Path, out: &mut Vec<HostPluginEntry>) {
38    if !dir.is_dir() {
39        return;
40    }
41    let Ok(entries) = std::fs::read_dir(dir) else {
42        return;
43    };
44    for entry in entries.flatten() {
45        let p = entry.path();
46        if p.is_dir() {
47            let skill = p.join("SKILL.md");
48            if skill.is_file() {
49                out.push(entry_from_skill(&skill));
50            }
51            let hermes = p.join("plugin.json");
52            if hermes.is_file() {
53                if let Ok(e) = entry_from_hermes(&hermes) {
54                    out.push(e);
55                }
56            }
57        }
58    }
59    let skill_root = dir.join("SKILL.md");
60    if skill_root.is_file() {
61        out.push(entry_from_skill(&skill_root));
62    }
63}
64
65fn entry_from_skill(path: &Path) -> HostPluginEntry {
66    let (name, description) = parse_yaml_frontmatter(path);
67    HostPluginEntry {
68        id: format!(
69            "openclaw:{}",
70            name.clone().unwrap_or_else(|| path.display().to_string())
71        ),
72        kind: HostPluginKind::OpenClawSkill,
73        path: path.to_path_buf(),
74        name,
75        description,
76    }
77}
78
79fn parse_yaml_frontmatter(path: &Path) -> (Option<String>, Option<String>) {
80    let Ok(body) = std::fs::read_to_string(path) else {
81        return (None, None);
82    };
83    if !body.starts_with("---") {
84        return (None, None);
85    }
86    let mut name = None;
87    let mut desc = None;
88    for line in body.lines().skip(1) {
89        if line.trim() == "---" {
90            break;
91        }
92        if let Some(v) = line.strip_prefix("name:") {
93            name = Some(v.trim().to_string());
94        }
95        if let Some(v) = line.strip_prefix("description:") {
96            desc = Some(v.trim().to_string());
97        }
98    }
99    (name, desc)
100}
101
102#[derive(Debug, Deserialize)]
103struct HermesFile {
104    id: String,
105    name: Option<String>,
106    description: Option<String>,
107}
108
109fn entry_from_hermes(path: &Path) -> anyhow::Result<HostPluginEntry> {
110    let raw = std::fs::read_to_string(path)?;
111    let m: HermesFile = serde_json::from_str(&raw)?;
112    Ok(HostPluginEntry {
113        id: format!("hermes:{}", m.id),
114        kind: HostPluginKind::Hermes,
115        path: path.to_path_buf(),
116        name: m.name,
117        description: m.description,
118    })
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::fs;
125    use tempfile::tempdir;
126
127    #[test]
128    fn finds_openclaw_skill_in_workspace() {
129        let dir = tempdir().unwrap();
130        let plug = dir.path().join("plugins/demo");
131        fs::create_dir_all(&plug).unwrap();
132        fs::write(
133            plug.join("SKILL.md"),
134            "---\nname: demo\ndescription: test skill\n---\n",
135        )
136        .unwrap();
137        let found = discover_host_plugins(dir.path(), &[]);
138        assert_eq!(found.len(), 1);
139        assert_eq!(found[0].kind, HostPluginKind::OpenClawSkill);
140    }
141
142    #[test]
143    fn finds_hermes_json() {
144        let dir = tempdir().unwrap();
145        let plug = dir.path().join("plugins/brain");
146        fs::create_dir_all(&plug).unwrap();
147        fs::write(
148            plug.join("plugin.json"),
149            r#"{"id":"brain","name":"brain","description":"x"}"#,
150        )
151        .unwrap();
152        let found = discover_host_plugins(dir.path(), &[]);
153        assert!(found.iter().any(|p| p.kind == HostPluginKind::Hermes));
154    }
155}