Skip to main content

matrixcode_core/
skills.rs

1//! Skill discovery and loading.
2//!
3//! A "skill" is a markdown file with a YAML frontmatter block, or a directory
4//! containing skill files. Two formats are supported:
5//!
6//! **Format 1: Single-file skill (SKILL.md)**
7//! ```text
8//! skills/
9//!   my-skill/
10//!     SKILL.md            # required, with frontmatter
11//!     helper.py           # optional support files
12//!     templates/...       # optional subdirs
13//! ```
14//!
15//! **Format 2: Multi-file skills (like Claude Code plugins)**
16//! ```text
17//! skills/
18//!   om/
19//!     debug.md            # each .md file is a separate skill
20//!     feature.md
21//!     plan.md
22//!     ...
23//! ```
24//!
25//! Skills are surfaced to the model in two stages:
26//! 1. At startup every skill's name + description is injected into the
27//!    system prompt so the model knows what's available.
28//! 2. The `skill` tool loads the full skill body (and lists the
29//!    skill's files) when the model decides to use one. This keeps
30//!    baseline token cost low even with many skills installed.
31
32use std::path::{Path, PathBuf};
33
34use anyhow::{Context, Result};
35
36/// A loaded skill ready to be advertised to the model.
37#[derive(Debug, Clone)]
38pub struct Skill {
39    /// Canonical identifier, taken from frontmatter `name` or file name.
40    /// Used as the argument to the `skill` tool.
41    pub name: String,
42    /// Short one-line description shown in the system prompt.
43    pub description: String,
44    /// Absolute path to the skill directory.
45    pub dir: PathBuf,
46    /// Full markdown body (without frontmatter).
47    pub body: String,
48    /// The source .md file path (SKILL.md or other .md file).
49    pub source_file: PathBuf,
50}
51
52impl Skill {
53    /// Path to this skill's source file.
54    pub fn skill_md(&self) -> PathBuf {
55        self.source_file.clone()
56    }
57}
58
59/// Walk the given roots and load skills from both formats:
60/// - Format 1: directories with `SKILL.md` (backward compatible)
61/// - Format 2: directories with multiple `.md` files (Claude Code style)
62///
63/// Missing roots are silently skipped so users can keep a personal
64/// `~/.matrix/skills` directory without the project-local one (or
65/// vice versa). Skills with duplicate names: first one wins, later ones
66/// are dropped with a stderr warning so precedence is predictable.
67pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
68    let mut out: Vec<Skill> = Vec::new();
69
70    for root in roots {
71        if !root.is_dir() {
72            continue;
73        }
74        let entries = match std::fs::read_dir(root) {
75            Ok(e) => e,
76            Err(e) => {
77                eprintln!("[warn] could not read skills dir {}: {e}", root.display());
78                continue;
79            }
80        };
81        for entry in entries.flatten() {
82            let path = entry.path();
83            if !path.is_dir() {
84                continue;
85            }
86            
87            // Try Format 1: SKILL.md exists
88            let skill_md = path.join("SKILL.md");
89            if skill_md.is_file() {
90                match load_skill_from_file(&skill_md, &path) {
91                    Ok(skill) => {
92                        add_skill(&mut out, skill);
93                    }
94                    Err(e) => {
95                        eprintln!("[warn] skipping skill at {}: {e}", path.display());
96                    }
97                }
98                continue;
99            }
100            
101            // Try Format 2: multiple .md files in directory
102            load_multi_file_skills(&path, &mut out);
103        }
104    }
105
106    out.sort_by(|a, b| a.name.cmp(&b.name));
107    out
108}
109
110/// Add a skill to the list, checking for duplicates.
111fn add_skill(out: &mut Vec<Skill>, skill: Skill) {
112    if out.iter().any(|s| s.name == skill.name) {
113        eprintln!(
114            "[warn] duplicate skill name '{}' at {} (ignored)",
115            skill.name,
116            skill.source_file.display()
117        );
118        return;
119    }
120    out.push(skill);
121}
122
123/// Load skills from a directory containing multiple .md files.
124/// Each .md file with frontmatter becomes a separate skill.
125fn load_multi_file_skills(dir: &Path, out: &mut Vec<Skill>) {
126    let entries = match std::fs::read_dir(dir) {
127        Ok(e) => e,
128        Err(e) => {
129            eprintln!("[warn] could not read skill dir {}: {e}", dir.display());
130            return;
131        }
132    };
133    
134    for entry in entries.flatten() {
135        let path = entry.path();
136        if !path.is_file() {
137            continue;
138        }
139        
140        // Only process .md files
141        let ext = path.extension().and_then(|e| e.to_str());
142        if ext != Some("md") {
143            continue;
144        }
145        
146        // Skip SKILL.md (already handled in Format 1)
147        if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
148            continue;
149        }
150        
151        match load_skill_from_file(&path, dir) {
152            Ok(skill) => {
153                add_skill(out, skill);
154            }
155            Err(e) => {
156                // Only warn if the file has frontmatter (indicating it's meant to be a skill)
157                let raw = std::fs::read_to_string(&path).unwrap_or_default();
158                if raw.trim_start().starts_with("---") {
159                    eprintln!("[warn] skipping skill file {}: {e}", path.display());
160                }
161            }
162        }
163    }
164}
165
166/// Load a skill from a specific .md file.
167/// Public so tests and the `skill` tool can reload a specific skill.
168pub fn load_skill_from_file(md_path: &Path, dir: &Path) -> Result<Skill> {
169    let raw = std::fs::read_to_string(md_path)
170        .with_context(|| format!("reading {}", md_path.display()))?;
171    let (front, body) = split_frontmatter(&raw)
172        .with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
173
174    // Name: frontmatter > filename (without .md) > directory name
175    let name = front
176        .get("name")
177        .cloned()
178        .filter(|s| !s.is_empty())
179        .or_else(|| {
180            md_path
181                .file_stem()
182                .and_then(|n| n.to_str())
183                .map(|s| s.to_string())
184        })
185        .or_else(|| {
186            dir.file_name()
187                .and_then(|n| n.to_str())
188                .map(|s| s.to_string())
189        })
190        .ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
191
192    let description = front
193        .get("description")
194        .cloned()
195        .unwrap_or_else(|| "(no description)".to_string());
196
197    Ok(Skill {
198        name,
199        description,
200        dir: dir.to_path_buf(),
201        body: body.to_string(),
202        source_file: md_path.to_path_buf(),
203    })
204}
205
206/// Load a skill from a directory with SKILL.md (backward compatible).
207pub fn load_skill(dir: &Path) -> Result<Skill> {
208    let md_path = dir.join("SKILL.md");
209    load_skill_from_file(&md_path, dir)
210}
211
212/// Minimal YAML-frontmatter parser: supports the shape
213/// ```text
214/// ---
215/// name: foo
216/// description: bar baz
217/// ---
218/// body...
219/// ```
220/// Only flat `key: value` pairs are recognised. Values may be wrapped
221/// in matching single or double quotes. Missing frontmatter is treated
222/// as empty (body == whole file), which keeps bare markdown files from
223/// being rejected outright — though they'll fall back to the directory
224/// name and generic description.
225fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
226    let mut front = std::collections::BTreeMap::new();
227
228    let trimmed = raw.trim_start_matches('\u{feff}'); // strip BOM if any
229    let Some(rest) = trimmed.strip_prefix("---") else {
230        return Ok((front, trimmed));
231    };
232    // Require newline right after opening ---
233    let rest = rest.strip_prefix('\n').or_else(|| rest.strip_prefix("\r\n"));
234    let Some(rest) = rest else {
235        return Ok((front, trimmed));
236    };
237
238    // Find closing --- on its own line.
239    let mut end_idx: Option<usize> = None;
240    let mut cursor = 0usize;
241    for line in rest.split_inclusive('\n') {
242        let trimmed_line = line.trim_end_matches(['\n', '\r']);
243        if trimmed_line == "---" {
244            end_idx = Some(cursor + line.len());
245            break;
246        }
247        cursor += line.len();
248    }
249    let Some(end) = end_idx else {
250        // No closing delimiter — treat whole file as body, no frontmatter.
251        return Ok((front, trimmed));
252    };
253
254    let front_block = &rest[..cursor];
255    let body = rest[end..].trim_start_matches(['\n', '\r']);
256
257    for line in front_block.lines() {
258        let line = line.trim();
259        if line.is_empty() || line.starts_with('#') {
260            continue;
261        }
262        let Some((k, v)) = line.split_once(':') else {
263            continue;
264        };
265        let key = k.trim().to_string();
266        let val = unquote(v.trim());
267        if !key.is_empty() {
268            front.insert(key, val);
269        }
270    }
271
272    Ok((front, body))
273}
274
275fn unquote(s: &str) -> String {
276    let bytes = s.as_bytes();
277    if bytes.len() >= 2
278        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
279            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
280    {
281        return s[1..s.len() - 1].to_string();
282    }
283    s.to_string()
284}
285
286/// Render the skills catalogue body for insertion into a prompt section.
287/// Returns `None` if there are no skills, so callers can skip injection
288/// entirely rather than bolting on an empty section.
289pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
290    if skills.is_empty() {
291        return None;
292    }
293    let mut s = String::from(
294        "Use the `skill` tool with the skill's name to load its full instructions:
295",
296    );
297    for sk in skills {
298        s.push_str(&format!("- {}: {}
299", sk.name, sk.description));
300    }
301    Some(s)
302}
303
304/// List files inside a skill directory (recursive), relative to the
305/// skill root. Used by the `skill` tool so the model knows what
306/// supporting files it can `read` next.
307pub fn list_skill_files(dir: &Path) -> Vec<String> {
308    let mut out = Vec::new();
309    walk(dir, dir, &mut out);
310    out.sort();
311    out
312}
313
314fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
315    let entries = match std::fs::read_dir(cur) {
316        Ok(e) => e,
317        Err(_) => return,
318    };
319    for entry in entries.flatten() {
320        let p = entry.path();
321        let file_type = match entry.file_type() {
322            Ok(t) => t,
323            Err(_) => continue,
324        };
325        if file_type.is_dir() {
326            walk(root, &p, out);
327        } else if file_type.is_file()
328            && let Ok(rel) = p.strip_prefix(root) {
329                out.push(rel.display().to_string());
330            }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use tempfile::tempdir;
338
339    fn write_file(path: &Path, body: &str) {
340        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
341        std::fs::write(path, body).unwrap();
342    }
343
344    #[test]
345    fn parses_basic_frontmatter() {
346        let (front, body) =
347            split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
348        assert_eq!(front.get("name").unwrap(), "foo");
349        assert_eq!(front.get("description").unwrap(), "hi there");
350        assert_eq!(body, "body text\n");
351    }
352
353    #[test]
354    fn quoted_values_are_unwrapped() {
355        let (front, _) =
356            split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
357        assert_eq!(front.get("name").unwrap(), "foo bar");
358        assert_eq!(front.get("description").unwrap(), "baz");
359    }
360
361    #[test]
362    fn missing_frontmatter_returns_whole_body() {
363        let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
364        assert!(front.is_empty());
365        assert_eq!(body, "just markdown\nno front");
366    }
367
368    #[test]
369    fn unclosed_frontmatter_falls_back_to_body() {
370        let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
371        assert!(front.is_empty());
372        assert!(body.starts_with("---"));
373    }
374
375    #[test]
376    fn discover_loads_skill_directory() {
377        let tmp = tempdir().unwrap();
378        let root = tmp.path().join("skills");
379        write_file(
380            &root.join("greet/SKILL.md"),
381            "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
382        );
383        write_file(&root.join("greet/extra.txt"), "support file");
384
385        let skills = discover_skills(&[root]);
386        assert_eq!(skills.len(), 1);
387        assert_eq!(skills[0].name, "greet");
388        assert_eq!(skills[0].description, "say hi");
389        assert!(skills[0].body.contains("Say hello"));
390        let files = list_skill_files(&skills[0].dir);
391        assert!(files.iter().any(|f| f == "SKILL.md"));
392        assert!(files.iter().any(|f| f == "extra.txt"));
393    }
394
395    #[test]
396    fn discover_loads_multi_file_skills() {
397        let tmp = tempdir().unwrap();
398        let root = tmp.path().join("skills");
399        write_file(
400            &root.join("om/debug.md"),
401            "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
402        );
403        write_file(
404            &root.join("om/feature.md"),
405            "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
406        );
407
408        let skills = discover_skills(&[root]);
409        assert_eq!(skills.len(), 2);
410        
411        let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
412        assert_eq!(debug_skill.description, "debug issues");
413        assert!(debug_skill.body.contains("Debug workflow"));
414        
415        let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
416        assert_eq!(feature_skill.description, "build features");
417        assert!(feature_skill.body.contains("Feature workflow"));
418    }
419
420    #[test]
421    fn multi_file_skill_name_from_filename() {
422        let tmp = tempdir().unwrap();
423        let root = tmp.path().join("skills");
424        // No 'name' in frontmatter - should use filename
425        write_file(
426            &root.join("utils/helper.md"),
427            "---\ndescription: a helper\n---\nHelper content.\n",
428        );
429
430        let skills = discover_skills(&[root]);
431        assert_eq!(skills.len(), 1);
432        assert_eq!(skills[0].name, "helper");
433    }
434
435    #[test]
436    fn duplicate_names_are_dropped() {
437        let tmp = tempdir().unwrap();
438        let a = tmp.path().join("a");
439        let b = tmp.path().join("b");
440        write_file(
441            &a.join("x/SKILL.md"),
442            "---\nname: x\ndescription: first\n---\nA\n",
443        );
444        write_file(
445            &b.join("x/SKILL.md"),
446            "---\nname: x\ndescription: second\n---\nB\n",
447        );
448        let skills = discover_skills(&[a, b]);
449        assert_eq!(skills.len(), 1);
450        assert_eq!(skills[0].description, "first");
451    }
452
453    #[test]
454    fn missing_root_is_skipped() {
455        let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
456        assert!(skills.is_empty());
457    }
458
459    #[test]
460    fn catalogue_renders_or_skips() {
461        assert!(format_catalogue(&[]).is_none());
462        let s = Skill {
463            name: "demo".into(),
464            description: "does stuff".into(),
465            dir: PathBuf::from("/tmp"),
466            body: String::new(),
467            source_file: PathBuf::from("/tmp/demo.md"),
468        };
469        let cat = format_catalogue(&[s]).unwrap();
470        assert!(cat.contains("Use the `skill` tool"));
471        assert!(cat.contains("demo: does stuff"));
472        assert!(!cat.contains("Available skills"));
473    }
474}