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                    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
268// ── Save managed skill ────────────────────────────────────────────────────
269
270pub 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}