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
45 skills
46}
47
48pub fn managed_skills_dir(workspace: &Path) -> PathBuf {
49 workspace.join(".apollo/skills")
50}
51
52fn scan_skill_dir(dir: &Path, skills: &mut Vec<Skill>) {
53 if !dir.is_dir() {
54 return;
55 }
56
57 if let Ok(entries) = std::fs::read_dir(dir) {
58 for entry in entries.flatten() {
59 let skill_dir = entry.path();
60 if !skill_dir.is_dir() {
61 continue;
62 }
63
64 let skill_md = skill_dir.join("SKILL.md");
65 if !skill_md.exists() {
66 continue;
67 }
68
69 if let Ok(content) = std::fs::read_to_string(&skill_md) {
70 if let Some(skill) = parse_skill_frontmatter(&content, &skill_md) {
71 skills.push(skill);
72 }
73 }
74 }
75 }
76}
77
78fn extract_frontmatter(content: &str) -> Option<&str> {
79 let rest = content.strip_prefix("---")?;
80 let end = rest.find("---")?;
81 Some(&rest[..end])
82}
83
84fn default_skill_name(path: &Path) -> String {
85 path.parent()
86 .and_then(|p| p.file_name())
87 .map(|n| n.to_string_lossy().to_string())
88 .unwrap_or_else(|| "unknown".to_string())
89}
90
91fn parse_skill_frontmatter(content: &str, path: &Path) -> Option<Skill> {
93 let frontmatter = extract_frontmatter(content)?;
94
95 let mut name = None;
96 let mut description = None;
97
98 for line in frontmatter.lines() {
99 let trimmed = line.trim();
100 if let Some(val) = trimmed.strip_prefix("name:") {
101 name = Some(val.trim().trim_matches('\'').trim_matches('"').to_string());
102 }
103 if let Some(val) = trimmed.strip_prefix("description:") {
104 description = Some(val.trim().trim_matches('\'').trim_matches('"').to_string());
105 }
106 }
107
108 Some(Skill {
109 name: name.unwrap_or_else(|| default_skill_name(path)),
110 description: description.unwrap_or_default(),
111 location: path.to_path_buf(),
112 })
113}
114
115const STOPWORDS: &[&str] = &[
118 "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
119 "our", "out", "has", "have", "been", "some", "them", "than", "its", "over", "such", "that",
120 "this", "with", "will", "each", "from", "they", "were", "which", "their", "said", "what",
121 "when", "who", "how", "use", "new", "now", "way", "may", "get", "got", "set", "let", "any",
122 "also", "into", "just", "only", "very", "even", "most", "other", "need", "make", "like",
123 "does", "your", "more", "want", "should",
124];
125
126fn is_stopword(word: &str) -> bool {
127 STOPWORDS.contains(&word)
128}
129
130pub fn match_skill<'a>(skills: &'a [Skill], user_message: &str) -> Option<&'a Skill> {
131 let msg_lower = user_message.to_lowercase();
132 let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
133
134 let mut best_score = 0.0f32;
135 let mut best_skill = None;
136
137 for skill in skills {
138 let desc_lower = skill.description.to_lowercase();
139 let name_lower = skill.name.to_lowercase();
140 let mut score = 0.0f32;
141
142 if msg_lower.contains(&name_lower) && name_lower.len() >= 4 {
143 score += 10.0;
144 }
145
146 for word in &msg_words {
147 if word.len() < 4 || is_stopword(word) {
148 continue;
149 }
150 if desc_lower.contains(word) {
151 score += 1.0;
152 }
153 }
154
155 for word in desc_lower.split(|c: char| !c.is_alphanumeric()) {
156 if word.len() < 4 || is_stopword(word) {
157 continue;
158 }
159 if msg_lower.contains(word) {
160 score += 1.0;
161 }
162 }
163
164 if score > best_score {
165 best_score = score;
166 best_skill = Some(skill);
167 }
168 }
169
170 if best_score >= 5.0 {
171 best_skill
172 } else {
173 None
174 }
175}
176
177use regex::Regex;
181use std::sync::OnceLock;
182
183fn skill_template_re() -> &'static Regex {
184 static RE: OnceLock<Regex> = OnceLock::new();
185 RE.get_or_init(|| Regex::new(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}").unwrap())
186}
187
188fn inline_shell_re() -> &'static Regex {
189 static RE: OnceLock<Regex> = OnceLock::new();
190 RE.get_or_init(|| Regex::new(r"!`([^`\n]+)`").unwrap())
191}
192
193pub fn load_skill_content(skill: &Skill) -> Option<String> {
195 std::fs::read_to_string(&skill.location).ok()
196}
197
198pub fn preprocess_skill_content(
200 content: &str,
201 skill_dir: Option<&Path>,
202 session_id: Option<&str>,
203 cwd: Option<&Path>,
204) -> String {
205 let content = substitute_template_vars(content, skill_dir, session_id);
206 expand_inline_shell(&content, cwd, 30)
207}
208
209pub fn substitute_template_vars(
212 content: &str,
213 skill_dir: Option<&Path>,
214 session_id: Option<&str>,
215) -> String {
216 skill_template_re()
217 .replace_all(content, |caps: ®ex::Captures| {
218 match caps.get(1).map(|m| m.as_str()) {
219 Some("HERMES_SKILL_DIR") => skill_dir
220 .map(|p| p.to_string_lossy().to_string())
221 .unwrap_or_else(|| caps[0].to_string()),
222 Some("HERMES_SESSION_ID") => session_id
223 .map(|s| s.to_string())
224 .unwrap_or_else(|| caps[0].to_string()),
225 _ => caps[0].to_string(),
226 }
227 })
228 .to_string()
229}
230
231pub fn expand_inline_shell(content: &str, cwd: Option<&Path>, _timeout_secs: u64) -> String {
235 inline_shell_re()
236 .replace_all(content, |caps: ®ex::Captures| {
237 let cmd = caps.get(1).map(|m| m.as_str()).unwrap_or("");
238 if cmd.is_empty() {
239 return String::new();
240 }
241 match std::process::Command::new("sh")
242 .arg("-c")
243 .arg(cmd)
244 .current_dir(cwd.unwrap_or(Path::new(".")))
245 .output()
246 {
247 Ok(output) if output.status.success() => {
248 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
249 if stdout.len() > 4000 {
250 format!("{}… [truncated]", &stdout[..4000])
251 } else {
252 stdout
253 }
254 }
255 Ok(output) => {
256 let stderr = String::from_utf8_lossy(&output.stderr);
257 format!(
258 "[inline-shell error: {}]",
259 stderr.trim().chars().take(120).collect::<String>()
260 )
261 }
262 Err(e) => format!("[inline-shell error: {}]", e),
263 }
264 })
265 .to_string()
266}
267
268pub fn save_managed_skill(workspace: &Path, input: &ManagedSkillInput) -> anyhow::Result<PathBuf> {
271 let dir = managed_skills_dir(workspace).join(slugify(&input.name));
272 std::fs::create_dir_all(&dir)?;
273 let path = dir.join("SKILL.md");
274 let content = format!(
275 "---\nname: {}\ndescription: {}\n---\n\n{}\n",
276 input.name, input.description, input.body
277 );
278 std::fs::write(&path, content)?;
279 Ok(path)
280}
281
282fn slugify(name: &str) -> String {
283 name.to_lowercase()
284 .chars()
285 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
286 .collect::<String>()
287 .trim_matches('-')
288 .to_string()
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_parse_frontmatter() {
297 let content = "---\nname: test-skill\ndescription: A test skill\n---\n# Content";
298 let skill = parse_skill_frontmatter(content, Path::new("/tmp/test/SKILL.md"));
299 assert!(skill.is_some());
300 let s = skill.unwrap();
301 assert_eq!(s.name, "test-skill");
302 assert_eq!(s.description, "A test skill");
303 }
304
305 #[test]
306 fn test_match_skill() {
307 let skills = vec![
308 Skill {
309 name: "weather".to_string(),
310 description: "Get weather forecasts for any location".to_string(),
311 location: PathBuf::from("/tmp/weather/SKILL.md"),
312 },
313 Skill {
314 name: "github".to_string(),
315 description: "GitHub operations, PRs, issues, code review".to_string(),
316 location: PathBuf::from("/tmp/github/SKILL.md"),
317 },
318 ];
319
320 let matched = match_skill(&skills, "what's the weather in Melbourne?");
321 assert!(matched.is_some());
322 assert_eq!(matched.unwrap().name, "weather");
323
324 let matched = match_skill(&skills, "review the github issues");
325 assert!(matched.is_some());
326 assert_eq!(matched.unwrap().name, "github");
327 }
328
329 #[test]
330 fn test_template_substitution() {
331 let result = substitute_template_vars(
332 "Run from ${HERMES_SKILL_DIR} for session ${HERMES_SESSION_ID}",
333 Some(Path::new("/tmp/myskill")),
334 Some("sess_123"),
335 );
336 assert_eq!(result, "Run from /tmp/myskill for session sess_123");
337 }
338
339 #[test]
340 fn test_unknown_template_preserved() {
341 let content = "Token ${UNKNOWN} stays";
342 let result = substitute_template_vars(content, None, None);
343 assert_eq!(result, "Token ${UNKNOWN} stays");
344 }
345
346 #[test]
347 fn test_inline_shell_expansion() {
348 let result = expand_inline_shell("Date is !`echo hello`", None, 5);
349 assert!(result.contains("hello"));
350 }
351}