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 #[serde(default)]
24 pub tools: Vec<HostPluginTool>,
25}
26
27#[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 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}