1use 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}