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 match crate::text::truncate_chars_counted(&stdout, 4000) {
250 Some((head, dropped)) => {
251 format!("{}… [truncated {} chars]", head, dropped)
252 }
253 None => stdout,
254 }
255 }
256 Ok(output) => {
257 let stderr = String::from_utf8_lossy(&output.stderr);
258 format!(
259 "[inline-shell error: {}]",
260 stderr.trim().chars().take(120).collect::<String>()
261 )
262 }
263 Err(e) => format!("[inline-shell error: {}]", e),
264 }
265 })
266 .to_string()
267}
268
269pub fn save_managed_skill(workspace: &Path, input: &ManagedSkillInput) -> anyhow::Result<PathBuf> {
272 let dir = managed_skills_dir(workspace).join(slugify(&input.name));
273 std::fs::create_dir_all(&dir)?;
274 let path = dir.join("SKILL.md");
275 let content = format!(
276 "---\nname: {}\ndescription: {}\n---\n\n{}\n",
277 input.name, input.description, input.body
278 );
279 std::fs::write(&path, content)?;
280 Ok(path)
281}
282
283fn slugify(name: &str) -> String {
284 name.to_lowercase()
285 .chars()
286 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
287 .collect::<String>()
288 .trim_matches('-')
289 .to_string()
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn test_parse_frontmatter() {
298 let content = "---\nname: test-skill\ndescription: A test skill\n---\n# Content";
299 let skill = parse_skill_frontmatter(content, Path::new("/tmp/test/SKILL.md"));
300 assert!(skill.is_some());
301 let s = skill.unwrap();
302 assert_eq!(s.name, "test-skill");
303 assert_eq!(s.description, "A test skill");
304 }
305
306 #[test]
307 fn test_match_skill() {
308 let skills = vec![
309 Skill {
310 name: "weather".to_string(),
311 description: "Get weather forecasts for any location".to_string(),
312 location: PathBuf::from("/tmp/weather/SKILL.md"),
313 },
314 Skill {
315 name: "github".to_string(),
316 description: "GitHub operations, PRs, issues, code review".to_string(),
317 location: PathBuf::from("/tmp/github/SKILL.md"),
318 },
319 ];
320
321 let matched = match_skill(&skills, "what's the weather in Melbourne?");
322 assert!(matched.is_some());
323 assert_eq!(matched.unwrap().name, "weather");
324
325 let matched = match_skill(&skills, "review the github issues");
326 assert!(matched.is_some());
327 assert_eq!(matched.unwrap().name, "github");
328 }
329
330 #[test]
331 fn test_template_substitution() {
332 let result = substitute_template_vars(
333 "Run from ${HERMES_SKILL_DIR} for session ${HERMES_SESSION_ID}",
334 Some(Path::new("/tmp/myskill")),
335 Some("sess_123"),
336 );
337 assert_eq!(result, "Run from /tmp/myskill for session sess_123");
338 }
339
340 #[test]
341 fn test_unknown_template_preserved() {
342 let content = "Token ${UNKNOWN} stays";
343 let result = substitute_template_vars(content, None, None);
344 assert_eq!(result, "Token ${UNKNOWN} stays");
345 }
346
347 #[test]
348 fn test_inline_shell_expansion() {
349 let result = expand_inline_shell("Date is !`echo hello`", None, 5);
350 assert!(result.contains("hello"));
351 }
352}