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    /// Executables the manifest declares. Discovering these does **not** make
22    /// them runnable — see `plugin_layer.trusted_host_plugins`.
23    #[serde(default)]
24    pub tools: Vec<HostPluginTool>,
25}
26
27/// A command a host plugin manifest offers as an agent tool.
28///
29/// `command` is executed directly, never through a shell, and its JSON
30/// arguments arrive on stdin rather than in argv, so a crafted argument cannot
31/// become another flag or another command.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct HostPluginTool {
34    pub name: String,
35    #[serde(default)]
36    pub description: String,
37    pub command: String,
38    #[serde(default)]
39    pub args: Vec<String>,
40}
41
42pub fn discover_host_plugins(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<HostPluginEntry> {
43    let mut roots = vec![workspace.to_path_buf()];
44    roots.extend(extra_roots.iter().cloned());
45    let mut out = Vec::new();
46    for root in &roots {
47        scan_plugins_dir(&root.join("plugins"), &mut out);
48        scan_plugins_dir(&root.join(".openclaw/plugins"), &mut out);
49        scan_plugins_dir(&root.join(".hermes/plugins"), &mut out);
50    }
51    out.sort_by(|a, b| a.id.cmp(&b.id));
52    out.dedup_by(|a, b| a.path == b.path);
53    out
54}
55
56fn scan_plugins_dir(dir: &Path, out: &mut Vec<HostPluginEntry>) {
57    if !dir.is_dir() {
58        return;
59    }
60    let Ok(entries) = std::fs::read_dir(dir) else {
61        return;
62    };
63    for entry in entries.flatten() {
64        let p = entry.path();
65        if p.is_dir() {
66            let skill = p.join("SKILL.md");
67            if skill.is_file() {
68                out.push(entry_from_skill(&skill));
69            }
70            let hermes = p.join("plugin.json");
71            if hermes.is_file() {
72                if let Ok(e) = entry_from_hermes(&hermes) {
73                    out.push(e);
74                }
75            }
76        }
77    }
78    let skill_root = dir.join("SKILL.md");
79    if skill_root.is_file() {
80        out.push(entry_from_skill(&skill_root));
81    }
82}
83
84fn entry_from_skill(path: &Path) -> HostPluginEntry {
85    let (name, description) = parse_yaml_frontmatter(path);
86    HostPluginEntry {
87        id: format!(
88            "openclaw:{}",
89            name.clone().unwrap_or_else(|| path.display().to_string())
90        ),
91        kind: HostPluginKind::OpenClawSkill,
92        path: path.to_path_buf(),
93        name,
94        description,
95        // A SKILL.md is instructions, not an executable.
96        tools: Vec::new(),
97    }
98}
99
100fn parse_yaml_frontmatter(path: &Path) -> (Option<String>, Option<String>) {
101    let Ok(body) = std::fs::read_to_string(path) else {
102        return (None, None);
103    };
104    if !body.starts_with("---") {
105        return (None, None);
106    }
107    let mut name = None;
108    let mut desc = None;
109    for line in body.lines().skip(1) {
110        if line.trim() == "---" {
111            break;
112        }
113        if let Some(v) = line.strip_prefix("name:") {
114            name = Some(v.trim().to_string());
115        }
116        if let Some(v) = line.strip_prefix("description:") {
117            desc = Some(v.trim().to_string());
118        }
119    }
120    (name, desc)
121}
122
123#[derive(Debug, Deserialize)]
124struct HermesFile {
125    id: String,
126    name: Option<String>,
127    description: Option<String>,
128    #[serde(default)]
129    tools: Vec<HostPluginTool>,
130}
131
132fn entry_from_hermes(path: &Path) -> anyhow::Result<HostPluginEntry> {
133    let raw = std::fs::read_to_string(path)?;
134    let m: HermesFile = serde_json::from_str(&raw)?;
135    Ok(HostPluginEntry {
136        id: format!("hermes:{}", m.id),
137        kind: HostPluginKind::Hermes,
138        path: path.to_path_buf(),
139        name: m.name,
140        description: m.description,
141        tools: m.tools,
142    })
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::fs;
149    use tempfile::tempdir;
150
151    #[test]
152    fn finds_openclaw_skill_in_workspace() {
153        let dir = tempdir().unwrap();
154        let plug = dir.path().join("plugins/demo");
155        fs::create_dir_all(&plug).unwrap();
156        fs::write(
157            plug.join("SKILL.md"),
158            "---\nname: demo\ndescription: test skill\n---\n",
159        )
160        .unwrap();
161        let found = discover_host_plugins(dir.path(), &[]);
162        assert_eq!(found.len(), 1);
163        assert_eq!(found[0].kind, HostPluginKind::OpenClawSkill);
164    }
165
166    #[test]
167    fn finds_hermes_json() {
168        let dir = tempdir().unwrap();
169        let plug = dir.path().join("plugins/brain");
170        fs::create_dir_all(&plug).unwrap();
171        fs::write(
172            plug.join("plugin.json"),
173            r#"{"id":"brain","name":"brain","description":"x"}"#,
174        )
175        .unwrap();
176        let found = discover_host_plugins(dir.path(), &[]);
177        assert!(found.iter().any(|p| p.kind == HostPluginKind::Hermes));
178    }
179}