Skip to main content

apollo/skills/
mod.rs

1//! Skills system — scan SKILL.md files, match against user requests,
2//! inject matched skill instructions into the system prompt.
3//! Supports template variables and inline shell execution.
4
5pub 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
25/// Scan known skill directories for SKILL.md files
26pub 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
91/// Parse YAML-like frontmatter from SKILL.md
92fn 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
115// ── Matching ──────────────────────────────────────────────────────────────
116
117const 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
177// ── Template variable substitution / inline shell ─────────────────
178// Ported from hermes-agent skill_preprocessing.py
179
180use 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
193/// Load skill content raw (no preprocessing).
194pub fn load_skill_content(skill: &Skill) -> Option<String> {
195    std::fs::read_to_string(&skill.location).ok()
196}
197
198/// Preprocess skill content: substitute template vars and expand inline shell.
199pub 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
209/// Substitute `${HERMES_SKILL_DIR}` and `${HERMES_SESSION_ID}` in skill content.
210/// Unresolved tokens are left as-is so the author can debug them.
211pub 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: &regex::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
231/// Execute inline shell snippets (`!\`command\``) in skill content.
232/// Replaces each snippet with its stdout (trimmed).
233/// Failures produce a short `[inline-shell error: ...]` marker.
234pub fn expand_inline_shell(content: &str, cwd: Option<&Path>, _timeout_secs: u64) -> String {
235    inline_shell_re()
236        .replace_all(content, |caps: &regex::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
269// ── Save managed skill ────────────────────────────────────────────────────
270
271pub 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}