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