1pub mod curator;
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
12pub struct Skill {
13 pub name: String,
14 pub description: String,
15 pub location: PathBuf,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ManagedSkillInput {
20 pub name: String,
21 pub description: String,
22 pub body: String,
23}
24
25pub fn discover_skills() -> Vec<Skill> {
27 discover_skills_for_workspace(None)
28}
29
30pub fn discover_skills_for_workspace(workspace: Option<&Path>) -> Vec<Skill> {
31 let mut skills = Vec::new();
32 let home = dirs::home_dir().unwrap_or_default();
33
34 let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
35 scan_skill_dir(&openclaw_skills, &mut skills);
36
37 let workspace_skills = home.join(".openclaw/workspace/skills");
38 scan_skill_dir(&workspace_skills, &mut skills);
39
40 if let Some(workspace) = workspace {
41 let managed = managed_skills_dir(workspace);
42 scan_skill_dir(&managed, &mut skills);
43
44 for dir in host_plugin_skill_dirs(workspace) {
49 scan_skill_dir(&dir, &mut skills);
50 }
51 }
52
53 skills
54}
55
56fn host_plugin_skill_dirs(workspace: &Path) -> Vec<PathBuf> {
59 vec![
60 workspace.join("plugins"),
61 workspace.join(".openclaw/plugins"),
62 workspace.join(".hermes/plugins"),
63 ]
64}
65
66pub fn managed_skills_dir(workspace: &Path) -> PathBuf {
67 workspace.join(".apollo/skills")
68}
69
70fn scan_skill_dir(dir: &Path, skills: &mut Vec<Skill>) {
71 if !dir.is_dir() {
72 return;
73 }
74
75 if let Ok(entries) = std::fs::read_dir(dir) {
76 for entry in entries.flatten() {
77 let skill_dir = entry.path();
78 if !skill_dir.is_dir() {
79 continue;
80 }
81
82 let skill_md = skill_dir.join("SKILL.md");
83 if !skill_md.exists() {
84 continue;
85 }
86
87 if let Ok(content) = std::fs::read_to_string(&skill_md) {
88 if let Some(skill) = parse_skill_frontmatter(&content, &skill_md) {
89 skills.push(skill);
90 }
91 }
92 }
93 }
94}
95
96fn extract_frontmatter(content: &str) -> Option<&str> {
97 let rest = content.strip_prefix("---")?;
98 let end = rest.find("---")?;
99 Some(&rest[..end])
100}
101
102fn default_skill_name(path: &Path) -> String {
103 path.parent()
104 .and_then(|p| p.file_name())
105 .map(|n| n.to_string_lossy().to_string())
106 .unwrap_or_else(|| "unknown".to_string())
107}
108
109fn parse_skill_frontmatter(content: &str, path: &Path) -> Option<Skill> {
111 let frontmatter = extract_frontmatter(content)?;
112
113 let mut name = None;
114 let mut description = None;
115
116 for line in frontmatter.lines() {
117 let trimmed = line.trim();
118 if let Some(val) = trimmed.strip_prefix("name:") {
119 name = Some(val.trim().trim_matches('\'').trim_matches('"').to_string());
120 }
121 if let Some(val) = trimmed.strip_prefix("description:") {
122 description = Some(val.trim().trim_matches('\'').trim_matches('"').to_string());
123 }
124 }
125
126 Some(Skill {
127 name: name.unwrap_or_else(|| default_skill_name(path)),
128 description: description.unwrap_or_default(),
129 location: path.to_path_buf(),
130 })
131}
132
133const STOPWORDS: &[&str] = &[
136 "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
137 "our", "out", "has", "have", "been", "some", "them", "than", "its", "over", "such", "that",
138 "this", "with", "will", "each", "from", "they", "were", "which", "their", "said", "what",
139 "when", "who", "how", "use", "new", "now", "way", "may", "get", "got", "set", "let", "any",
140 "also", "into", "just", "only", "very", "even", "most", "other", "need", "make", "like",
141 "does", "your", "more", "want", "should",
142];
143
144fn is_stopword(word: &str) -> bool {
145 STOPWORDS.contains(&word)
146}
147
148pub fn match_skill<'a>(skills: &'a [Skill], user_message: &str) -> Option<&'a Skill> {
149 let msg_lower = user_message.to_lowercase();
150 let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
151
152 let mut best_score = 0.0f32;
153 let mut best_skill = None;
154
155 for skill in skills {
156 let desc_lower = skill.description.to_lowercase();
157 let name_lower = skill.name.to_lowercase();
158 let mut score = 0.0f32;
159
160 if msg_lower.contains(&name_lower) && name_lower.len() >= 4 {
161 score += 10.0;
162 }
163
164 for word in &msg_words {
165 if word.len() < 4 || is_stopword(word) {
166 continue;
167 }
168 if desc_lower.contains(word) {
169 score += 1.0;
170 }
171 }
172
173 for word in desc_lower.split(|c: char| !c.is_alphanumeric()) {
174 if word.len() < 4 || is_stopword(word) {
175 continue;
176 }
177 if msg_lower.contains(word) {
178 score += 1.0;
179 }
180 }
181
182 if score > best_score {
183 best_score = score;
184 best_skill = Some(skill);
185 }
186 }
187
188 if best_score >= 5.0 {
189 best_skill
190 } else {
191 None
192 }
193}
194
195use regex::Regex;
199use std::sync::OnceLock;
200
201fn skill_template_re() -> &'static Regex {
202 static RE: OnceLock<Regex> = OnceLock::new();
203 RE.get_or_init(|| Regex::new(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}").unwrap())
204}
205
206fn inline_shell_re() -> &'static Regex {
207 static RE: OnceLock<Regex> = OnceLock::new();
208 RE.get_or_init(|| Regex::new(r"!`([^`\n]+)`").unwrap())
209}
210
211pub fn load_skill_content(skill: &Skill) -> Option<String> {
213 std::fs::read_to_string(&skill.location).ok()
214}
215
216pub fn preprocess_skill_content(
218 content: &str,
219 skill_dir: Option<&Path>,
220 session_id: Option<&str>,
221 cwd: Option<&Path>,
222) -> String {
223 let content = substitute_template_vars(content, skill_dir, session_id);
224 expand_inline_shell(&content, cwd, 30)
225}
226
227pub fn substitute_template_vars(
230 content: &str,
231 skill_dir: Option<&Path>,
232 session_id: Option<&str>,
233) -> String {
234 skill_template_re()
235 .replace_all(content, |caps: ®ex::Captures| {
236 match caps.get(1).map(|m| m.as_str()) {
237 Some("HERMES_SKILL_DIR") => skill_dir
238 .map(|p| p.to_string_lossy().to_string())
239 .unwrap_or_else(|| caps[0].to_string()),
240 Some("HERMES_SESSION_ID") => session_id
241 .map(|s| s.to_string())
242 .unwrap_or_else(|| caps[0].to_string()),
243 _ => caps[0].to_string(),
244 }
245 })
246 .to_string()
247}
248
249pub fn expand_inline_shell(content: &str, cwd: Option<&Path>, _timeout_secs: u64) -> String {
253 inline_shell_re()
254 .replace_all(content, |caps: ®ex::Captures| {
255 let cmd = caps.get(1).map(|m| m.as_str()).unwrap_or("");
256 if cmd.is_empty() {
257 return String::new();
258 }
259 match std::process::Command::new("sh")
260 .arg("-c")
261 .arg(cmd)
262 .current_dir(cwd.unwrap_or(Path::new(".")))
263 .output()
264 {
265 Ok(output) if output.status.success() => {
266 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
267 match crate::text::truncate_chars_counted(&stdout, 4000) {
268 Some((head, dropped)) => {
269 format!("{}… [truncated {} chars]", head, dropped)
270 }
271 None => stdout,
272 }
273 }
274 Ok(output) => {
275 let stderr = String::from_utf8_lossy(&output.stderr);
276 format!(
277 "[inline-shell error: {}]",
278 stderr.trim().chars().take(120).collect::<String>()
279 )
280 }
281 Err(e) => format!("[inline-shell error: {}]", e),
282 }
283 })
284 .to_string()
285}
286
287pub fn save_managed_skill(workspace: &Path, input: &ManagedSkillInput) -> anyhow::Result<PathBuf> {
290 let dir = managed_skills_dir(workspace).join(slugify(&input.name));
291 std::fs::create_dir_all(&dir)?;
292 let path = dir.join("SKILL.md");
293 let content = format!(
294 "---\nname: {}\ndescription: {}\n---\n\n{}\n",
295 input.name, input.description, input.body
296 );
297 std::fs::write(&path, content)?;
298 Ok(path)
299}
300
301fn slugify(name: &str) -> String {
302 name.to_lowercase()
303 .chars()
304 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
305 .collect::<String>()
306 .trim_matches('-')
307 .to_string()
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn host_plugin_skills_are_discovered() {
316 let dir = tempfile::tempdir().unwrap();
317 let workspace = dir.path();
318
319 let plugin = workspace.join("plugins/weather");
322 std::fs::create_dir_all(&plugin).unwrap();
323 std::fs::write(
324 plugin.join("SKILL.md"),
325 "---\nname: weather\ndescription: forecast lookup\n---\n# Weather\n",
326 )
327 .unwrap();
328
329 let skills = discover_skills_for_workspace(Some(workspace));
330 assert!(
331 skills.iter().any(|s| s.name == "weather"),
332 "host plugin SKILL.md was discovered but never loaded; got {:?}",
333 skills.iter().map(|s| &s.name).collect::<Vec<_>>()
334 );
335 }
336
337 #[test]
338 fn hermes_and_openclaw_plugin_roots_are_both_scanned() {
339 let dir = tempfile::tempdir().unwrap();
340 let workspace = dir.path();
341
342 for (root, name) in [
343 (".hermes/plugins", "hermes-skill"),
344 (".openclaw/plugins", "openclaw-skill"),
345 ] {
346 let plugin = workspace.join(root).join(name);
347 std::fs::create_dir_all(&plugin).unwrap();
348 std::fs::write(
349 plugin.join("SKILL.md"),
350 format!("---\nname: {name}\ndescription: d\n---\n"),
351 )
352 .unwrap();
353 }
354
355 let found: Vec<String> = discover_skills_for_workspace(Some(workspace))
356 .into_iter()
357 .map(|s| s.name)
358 .collect();
359 assert!(found.contains(&"hermes-skill".to_string()), "{found:?}");
360 assert!(found.contains(&"openclaw-skill".to_string()), "{found:?}");
361 }
362
363 #[test]
364 fn test_parse_frontmatter() {
365 let content = "---\nname: test-skill\ndescription: A test skill\n---\n# Content";
366 let skill = parse_skill_frontmatter(content, Path::new("/tmp/test/SKILL.md"));
367 assert!(skill.is_some());
368 let s = skill.unwrap();
369 assert_eq!(s.name, "test-skill");
370 assert_eq!(s.description, "A test skill");
371 }
372
373 #[test]
374 fn test_match_skill() {
375 let skills = vec![
376 Skill {
377 name: "weather".to_string(),
378 description: "Get weather forecasts for any location".to_string(),
379 location: PathBuf::from("/tmp/weather/SKILL.md"),
380 },
381 Skill {
382 name: "github".to_string(),
383 description: "GitHub operations, PRs, issues, code review".to_string(),
384 location: PathBuf::from("/tmp/github/SKILL.md"),
385 },
386 ];
387
388 let matched = match_skill(&skills, "what's the weather in Melbourne?");
389 assert!(matched.is_some());
390 assert_eq!(matched.unwrap().name, "weather");
391
392 let matched = match_skill(&skills, "review the github issues");
393 assert!(matched.is_some());
394 assert_eq!(matched.unwrap().name, "github");
395 }
396
397 #[test]
398 fn test_template_substitution() {
399 let result = substitute_template_vars(
400 "Run from ${HERMES_SKILL_DIR} for session ${HERMES_SESSION_ID}",
401 Some(Path::new("/tmp/myskill")),
402 Some("sess_123"),
403 );
404 assert_eq!(result, "Run from /tmp/myskill for session sess_123");
405 }
406
407 #[test]
408 fn test_unknown_template_preserved() {
409 let content = "Token ${UNKNOWN} stays";
410 let result = substitute_template_vars(content, None, None);
411 assert_eq!(result, "Token ${UNKNOWN} stays");
412 }
413
414 #[test]
415 fn test_inline_shell_expansion() {
416 let result = expand_inline_shell("Date is !`echo hello`", None, 5);
417 assert!(result.contains("hello"));
418 }
419}