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 all supported formats:
60/// - Format 1: directories with `SKILL.md` (backward compatible)
61/// - Format 2: directories with multiple `.md` files (Claude Code style)
62/// - Format 3: standalone `.md` files directly in the root directory
63///
64/// Missing roots are silently skipped so users can keep a personal
65/// `~/.matrix/skills` directory without the project-local one (or
66/// vice versa). Skills with duplicate names: first one wins, later ones
67/// are dropped with a stderr warning so precedence is predictable.
68pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
69    let mut out: Vec<Skill> = Vec::new();
70
71    for root in roots {
72        if !root.is_dir() {
73            continue;
74        }
75        let entries = match std::fs::read_dir(root) {
76            Ok(e) => e,
77            Err(e) => {
78                eprintln!("[warn] could not read skills dir {}: {e}", root.display());
79                continue;
80            }
81        };
82        for entry in entries.flatten() {
83            let path = entry.path();
84
85            if path.is_dir() {
86                // Try Format 1: SKILL.md exists
87                let skill_md = path.join("SKILL.md");
88                if skill_md.is_file() {
89                    match load_skill_from_file(&skill_md, &path) {
90                        Ok(skill) => {
91                            add_skill(&mut out, skill);
92                        }
93                        Err(e) => {
94                            eprintln!("[warn] skipping skill at {}: {e}", path.display());
95                        }
96                    }
97                    continue;
98                }
99
100                // Try Format 2: multiple .md files in directory
101                load_multi_file_skills(&path, &mut out);
102            } else if path.is_file() {
103                // Format 3: standalone .md file directly in root
104                let ext = path.extension().and_then(|e| e.to_str());
105                if ext != Some("md") {
106                    continue;
107                }
108                match load_skill_from_file(&path, root) {
109                    Ok(skill) => {
110                        add_skill(&mut out, skill);
111                    }
112                    Err(e) => {
113                        let raw = std::fs::read_to_string(&path).unwrap_or_default();
114                        if raw.trim_start().starts_with("---") {
115                            eprintln!("[warn] skipping skill file {}: {e}", path.display());
116                        }
117                    }
118                }
119            }
120        }
121    }
122
123    out.sort_by(|a, b| a.name.cmp(&b.name));
124    out
125}
126
127/// Add a skill to the list, checking for duplicates.
128fn add_skill(out: &mut Vec<Skill>, skill: Skill) {
129    if out.iter().any(|s| s.name == skill.name) {
130        eprintln!(
131            "[warn] duplicate skill name '{}' at {} (ignored)",
132            skill.name,
133            skill.source_file.display()
134        );
135        return;
136    }
137    out.push(skill);
138}
139
140/// Load skills from a directory containing multiple .md files.
141/// Each .md file with frontmatter becomes a separate skill.
142fn load_multi_file_skills(dir: &Path, out: &mut Vec<Skill>) {
143    let entries = match std::fs::read_dir(dir) {
144        Ok(e) => e,
145        Err(e) => {
146            eprintln!("[warn] could not read skill dir {}: {e}", dir.display());
147            return;
148        }
149    };
150
151    for entry in entries.flatten() {
152        let path = entry.path();
153        if !path.is_file() {
154            continue;
155        }
156
157        // Only process .md files
158        let ext = path.extension().and_then(|e| e.to_str());
159        if ext != Some("md") {
160            continue;
161        }
162
163        // Skip SKILL.md (already handled in Format 1)
164        if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
165            continue;
166        }
167
168        match load_skill_from_file(&path, dir) {
169            Ok(skill) => {
170                add_skill(out, skill);
171            }
172            Err(e) => {
173                // Only warn if the file has frontmatter (indicating it's meant to be a skill)
174                let raw = std::fs::read_to_string(&path).unwrap_or_default();
175                if raw.trim_start().starts_with("---") {
176                    eprintln!("[warn] skipping skill file {}: {e}", path.display());
177                }
178            }
179        }
180    }
181}
182
183/// Load a skill from a specific .md file.
184/// Public so tests and the `skill` tool can reload a specific skill.
185pub fn load_skill_from_file(md_path: &Path, dir: &Path) -> Result<Skill> {
186    let raw = std::fs::read_to_string(md_path)
187        .with_context(|| format!("reading {}", md_path.display()))?;
188    let (front, body) = split_frontmatter(&raw)
189        .with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
190
191    // Name: frontmatter > filename (without .md) > directory name
192    let name = front
193        .get("name")
194        .cloned()
195        .filter(|s| !s.is_empty())
196        .or_else(|| {
197            md_path
198                .file_stem()
199                .and_then(|n| n.to_str())
200                .map(|s| s.to_string())
201        })
202        .or_else(|| {
203            dir.file_name()
204                .and_then(|n| n.to_str())
205                .map(|s| s.to_string())
206        })
207        .ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
208
209    let description = front
210        .get("description")
211        .cloned()
212        .unwrap_or_else(|| "(no description)".to_string());
213
214    Ok(Skill {
215        name,
216        description,
217        dir: dir.to_path_buf(),
218        body: body.to_string(),
219        source_file: md_path.to_path_buf(),
220    })
221}
222
223/// Load a skill from a directory with SKILL.md (backward compatible).
224pub fn load_skill(dir: &Path) -> Result<Skill> {
225    let md_path = dir.join("SKILL.md");
226    load_skill_from_file(&md_path, dir)
227}
228
229/// Minimal YAML-frontmatter parser: supports the shape
230/// ```text
231/// ---
232/// name: foo
233/// description: bar baz
234/// ---
235/// body...
236/// ```
237/// Only flat `key: value` pairs are recognised. Values may be wrapped
238/// in matching single or double quotes. Missing frontmatter is treated
239/// as empty (body == whole file), which keeps bare markdown files from
240/// being rejected outright — though they'll fall back to the directory
241/// name and generic description.
242fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
243    let mut front = std::collections::BTreeMap::new();
244
245    let trimmed = raw.trim_start_matches('\u{feff}'); // strip BOM if any
246    let Some(rest) = trimmed.strip_prefix("---") else {
247        return Ok((front, trimmed));
248    };
249    // Require newline right after opening ---
250    let rest = rest
251        .strip_prefix('\n')
252        .or_else(|| rest.strip_prefix("\r\n"));
253    let Some(rest) = rest else {
254        return Ok((front, trimmed));
255    };
256
257    // Find closing --- on its own line.
258    let mut end_idx: Option<usize> = None;
259    let mut cursor = 0usize;
260    for line in rest.split_inclusive('\n') {
261        let trimmed_line = line.trim_end_matches(['\n', '\r']);
262        if trimmed_line == "---" {
263            end_idx = Some(cursor + line.len());
264            break;
265        }
266        cursor += line.len();
267    }
268    let Some(end) = end_idx else {
269        // No closing delimiter — treat whole file as body, no frontmatter.
270        return Ok((front, trimmed));
271    };
272
273    let front_block = &rest[..cursor];
274    let body = rest[end..].trim_start_matches(['\n', '\r']);
275
276    for line in front_block.lines() {
277        let line = line.trim();
278        if line.is_empty() || line.starts_with('#') {
279            continue;
280        }
281        let Some((k, v)) = line.split_once(':') else {
282            continue;
283        };
284        let key = k.trim().to_string();
285        let val = unquote(v.trim());
286        if !key.is_empty() {
287            front.insert(key, val);
288        }
289    }
290
291    Ok((front, body))
292}
293
294fn unquote(s: &str) -> String {
295    let bytes = s.as_bytes();
296    if bytes.len() >= 2
297        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
298            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
299    {
300        return s[1..s.len() - 1].to_string();
301    }
302    s.to_string()
303}
304
305/// Render the skills catalogue body for insertion into a prompt section.
306/// Returns `None` if there are no skills, so callers can skip injection
307/// entirely rather than bolting on an empty section.
308pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
309    if skills.is_empty() {
310        return None;
311    }
312    let mut s = String::from(
313        "Use the `skill` tool with the skill's name to load its full instructions:
314",
315    );
316    for sk in skills {
317        s.push_str(&format!(
318            "- {}: {}
319",
320            sk.name, sk.description
321        ));
322    }
323    Some(s)
324}
325
326/// List files inside a skill directory (recursive), relative to the
327/// skill root. Used by the `skill` tool so the model knows what
328/// supporting files it can `read` next.
329pub fn list_skill_files(dir: &Path) -> Vec<String> {
330    let mut out = Vec::new();
331    walk(dir, dir, &mut out);
332    out.sort();
333    out
334}
335
336fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
337    let entries = match std::fs::read_dir(cur) {
338        Ok(e) => e,
339        Err(_) => return,
340    };
341    for entry in entries.flatten() {
342        let p = entry.path();
343        let file_type = match entry.file_type() {
344            Ok(t) => t,
345            Err(_) => continue,
346        };
347        if file_type.is_dir() {
348            walk(root, &p, out);
349        } else if file_type.is_file()
350            && let Ok(rel) = p.strip_prefix(root)
351        {
352            out.push(rel.display().to_string());
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use tempfile::tempdir;
361
362    fn write_file(path: &Path, body: &str) {
363        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
364        std::fs::write(path, body).unwrap();
365    }
366
367    #[test]
368    fn parses_basic_frontmatter() {
369        let (front, body) =
370            split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
371        assert_eq!(front.get("name").unwrap(), "foo");
372        assert_eq!(front.get("description").unwrap(), "hi there");
373        assert_eq!(body, "body text\n");
374    }
375
376    #[test]
377    fn quoted_values_are_unwrapped() {
378        let (front, _) =
379            split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
380        assert_eq!(front.get("name").unwrap(), "foo bar");
381        assert_eq!(front.get("description").unwrap(), "baz");
382    }
383
384    #[test]
385    fn missing_frontmatter_returns_whole_body() {
386        let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
387        assert!(front.is_empty());
388        assert_eq!(body, "just markdown\nno front");
389    }
390
391    #[test]
392    fn unclosed_frontmatter_falls_back_to_body() {
393        let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
394        assert!(front.is_empty());
395        assert!(body.starts_with("---"));
396    }
397
398    #[test]
399    fn discover_loads_skill_directory() {
400        let tmp = tempdir().unwrap();
401        let root = tmp.path().join("skills");
402        write_file(
403            &root.join("greet/SKILL.md"),
404            "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
405        );
406        write_file(&root.join("greet/extra.txt"), "support file");
407
408        let skills = discover_skills(&[root]);
409        assert_eq!(skills.len(), 1);
410        assert_eq!(skills[0].name, "greet");
411        assert_eq!(skills[0].description, "say hi");
412        assert!(skills[0].body.contains("Say hello"));
413        let files = list_skill_files(&skills[0].dir);
414        assert!(files.iter().any(|f| f == "SKILL.md"));
415        assert!(files.iter().any(|f| f == "extra.txt"));
416    }
417
418    #[test]
419    fn discover_loads_multi_file_skills() {
420        let tmp = tempdir().unwrap();
421        let root = tmp.path().join("skills");
422        write_file(
423            &root.join("om/debug.md"),
424            "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
425        );
426        write_file(
427            &root.join("om/feature.md"),
428            "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
429        );
430
431        let skills = discover_skills(&[root]);
432        assert_eq!(skills.len(), 2);
433
434        let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
435        assert_eq!(debug_skill.description, "debug issues");
436        assert!(debug_skill.body.contains("Debug workflow"));
437
438        let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
439        assert_eq!(feature_skill.description, "build features");
440        assert!(feature_skill.body.contains("Feature workflow"));
441    }
442
443    #[test]
444    fn multi_file_skill_name_from_filename() {
445        let tmp = tempdir().unwrap();
446        let root = tmp.path().join("skills");
447        // No 'name' in frontmatter - should use filename
448        write_file(
449            &root.join("utils/helper.md"),
450            "---\ndescription: a helper\n---\nHelper content.\n",
451        );
452
453        let skills = discover_skills(&[root]);
454        assert_eq!(skills.len(), 1);
455        assert_eq!(skills[0].name, "helper");
456    }
457
458    #[test]
459    fn duplicate_names_are_dropped() {
460        let tmp = tempdir().unwrap();
461        let a = tmp.path().join("a");
462        let b = tmp.path().join("b");
463        write_file(
464            &a.join("x/SKILL.md"),
465            "---\nname: x\ndescription: first\n---\nA\n",
466        );
467        write_file(
468            &b.join("x/SKILL.md"),
469            "---\nname: x\ndescription: second\n---\nB\n",
470        );
471        let skills = discover_skills(&[a, b]);
472        assert_eq!(skills.len(), 1);
473        assert_eq!(skills[0].description, "first");
474    }
475
476    #[test]
477    fn missing_root_is_skipped() {
478        let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
479        assert!(skills.is_empty());
480    }
481
482    #[test]
483    fn discover_loads_standalone_md_files() {
484        let tmp = tempdir().unwrap();
485        let root = tmp.path().join("skills");
486        // Standalone .md file directly in root
487        write_file(
488            &root.join("om.md"),
489            "---\nname: om\ndescription: main entry\n---\nOpenMatrix entry point.\n",
490        );
491        write_file(
492            &root.join("openmatrix.md"),
493            "---\nname: openmatrix\ndescription: detect dev tasks\n---\nDetect development tasks.\n",
494        );
495
496        let skills = discover_skills(&[root]);
497        assert_eq!(skills.len(), 2);
498
499        let om = skills.iter().find(|s| s.name == "om").unwrap();
500        assert_eq!(om.description, "main entry");
501        assert!(om.body.contains("OpenMatrix entry point"));
502
503        let openmatrix = skills.iter().find(|s| s.name == "openmatrix").unwrap();
504        assert_eq!(openmatrix.description, "detect dev tasks");
505    }
506
507    #[test]
508    fn discover_mixed_formats() {
509        let tmp = tempdir().unwrap();
510        let root = tmp.path().join("skills");
511        // Format 1: directory with SKILL.md
512        write_file(
513            &root.join("debug/SKILL.md"),
514            "---\nname: debug\ndescription: debug tool\n---\nDebug.\n",
515        );
516        // Format 2: directory with multiple .md files
517        write_file(
518            &root.join("om/feature.md"),
519            "---\nname: om:feature\ndescription: build features\n---\nFeature.\n",
520        );
521        // Format 3: standalone .md file
522        write_file(
523            &root.join("openmatrix.md"),
524            "---\nname: openmatrix\ndescription: detect tasks\n---\nDetect.\n",
525        );
526
527        let skills = discover_skills(&[root]);
528        assert_eq!(skills.len(), 3);
529        assert!(skills.iter().any(|s| s.name == "debug"));
530        assert!(skills.iter().any(|s| s.name == "om:feature"));
531        assert!(skills.iter().any(|s| s.name == "openmatrix"));
532    }
533
534    #[test]
535    fn catalogue_renders_or_skips() {
536        assert!(format_catalogue(&[]).is_none());
537        let s = Skill {
538            name: "demo".into(),
539            description: "does stuff".into(),
540            dir: PathBuf::from("/tmp"),
541            body: String::new(),
542            source_file: PathBuf::from("/tmp/demo.md"),
543        };
544        let cat = format_catalogue(&[s]).unwrap();
545        assert!(cat.contains("Use the `skill` tool"));
546        assert!(cat.contains("demo: does stuff"));
547        assert!(!cat.contains("Available skills"));
548    }
549}