apollo-agent 0.4.1

Local-first Rust AI agent runtime — Telegram-first, trait-driven, SurrealDB + RocksDB state layer.
Documentation
//! OpenClaw + Hermes plugin discovery (host-agnostic).

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HostPluginKind {
    OpenClawSkill,
    Hermes,
    ManifestJson,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostPluginEntry {
    pub id: String,
    pub kind: HostPluginKind,
    pub path: PathBuf,
    pub name: Option<String>,
    pub description: Option<String>,
}

pub fn discover_host_plugins(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<HostPluginEntry> {
    let mut roots = vec![workspace.to_path_buf()];
    roots.extend(extra_roots.iter().cloned());
    let mut out = Vec::new();
    for root in &roots {
        scan_plugins_dir(&root.join("plugins"), &mut out);
        scan_plugins_dir(&root.join(".openclaw/plugins"), &mut out);
        scan_plugins_dir(&root.join(".hermes/plugins"), &mut out);
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out.dedup_by(|a, b| a.path == b.path);
    out
}

fn scan_plugins_dir(dir: &Path, out: &mut Vec<HostPluginEntry>) {
    if !dir.is_dir() {
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let p = entry.path();
        if p.is_dir() {
            let skill = p.join("SKILL.md");
            if skill.is_file() {
                out.push(entry_from_skill(&skill));
            }
            let hermes = p.join("plugin.json");
            if hermes.is_file() {
                if let Ok(e) = entry_from_hermes(&hermes) {
                    out.push(e);
                }
            }
        }
    }
    let skill_root = dir.join("SKILL.md");
    if skill_root.is_file() {
        out.push(entry_from_skill(&skill_root));
    }
}

fn entry_from_skill(path: &Path) -> HostPluginEntry {
    let (name, description) = parse_yaml_frontmatter(path);
    HostPluginEntry {
        id: format!(
            "openclaw:{}",
            name.clone().unwrap_or_else(|| path.display().to_string())
        ),
        kind: HostPluginKind::OpenClawSkill,
        path: path.to_path_buf(),
        name,
        description,
    }
}

fn parse_yaml_frontmatter(path: &Path) -> (Option<String>, Option<String>) {
    let Ok(body) = std::fs::read_to_string(path) else {
        return (None, None);
    };
    if !body.starts_with("---") {
        return (None, None);
    }
    let mut name = None;
    let mut desc = None;
    for line in body.lines().skip(1) {
        if line.trim() == "---" {
            break;
        }
        if let Some(v) = line.strip_prefix("name:") {
            name = Some(v.trim().to_string());
        }
        if let Some(v) = line.strip_prefix("description:") {
            desc = Some(v.trim().to_string());
        }
    }
    (name, desc)
}

#[derive(Debug, Deserialize)]
struct HermesFile {
    id: String,
    name: Option<String>,
    description: Option<String>,
}

fn entry_from_hermes(path: &Path) -> anyhow::Result<HostPluginEntry> {
    let raw = std::fs::read_to_string(path)?;
    let m: HermesFile = serde_json::from_str(&raw)?;
    Ok(HostPluginEntry {
        id: format!("hermes:{}", m.id),
        kind: HostPluginKind::Hermes,
        path: path.to_path_buf(),
        name: m.name,
        description: m.description,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn finds_openclaw_skill_in_workspace() {
        let dir = tempdir().unwrap();
        let plug = dir.path().join("plugins/demo");
        fs::create_dir_all(&plug).unwrap();
        fs::write(
            plug.join("SKILL.md"),
            "---\nname: demo\ndescription: test skill\n---\n",
        )
        .unwrap();
        let found = discover_host_plugins(dir.path(), &[]);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].kind, HostPluginKind::OpenClawSkill);
    }

    #[test]
    fn finds_hermes_json() {
        let dir = tempdir().unwrap();
        let plug = dir.path().join("plugins/brain");
        fs::create_dir_all(&plug).unwrap();
        fs::write(
            plug.join("plugin.json"),
            r#"{"id":"brain","name":"brain","description":"x"}"#,
        )
        .unwrap();
        let found = discover_host_plugins(dir.path(), &[]);
        assert!(found.iter().any(|p| p.kind == HostPluginKind::Hermes));
    }
}