Skip to main content

routa_core/skills/
mod.rs

1//! Skills discovery and registry.
2//!
3//! Discovers SKILL.md files from well-known directories on the filesystem,
4//! matching the TypeScript implementation's behavior.
5//!
6//! SKILL.md files use YAML frontmatter for metadata:
7//! ```markdown
8//! ---
9//! name: skill-name
10//! description: What this skill does.
11//! metadata:
12//!   short-description: Brief label
13//! ---
14//!
15//! Full instructions for the agent...
16//! ```
17
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::Path;
21use std::sync::RwLock;
22
23/// YAML frontmatter parsed from a SKILL.md file.
24#[derive(Debug, Deserialize)]
25struct SkillFrontmatter {
26    name: String,
27    description: String,
28    #[serde(default)]
29    license: Option<String>,
30    #[serde(default)]
31    compatibility: Option<String>,
32    #[serde(default)]
33    metadata: SkillFrontmatterMetadata,
34}
35
36#[derive(Debug, Default, Deserialize)]
37struct SkillFrontmatterMetadata {
38    #[serde(default, rename = "short-description")]
39    short_description: Option<String>,
40}
41
42/// A discovered skill definition.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct SkillDefinition {
46    pub name: String,
47    pub description: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub short_description: Option<String>,
50    pub content: String,
51    pub source: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub license: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub compatibility: Option<String>,
56    #[serde(default)]
57    pub metadata: HashMap<String, String>,
58}
59
60/// Well-known directory patterns where skills can be found.
61const SKILL_DIRS: &[&str] = &[
62    ".opencode/skills",
63    ".claude/skills",
64    ".agents/skills",
65    ".codex/skills",
66    ".cursor/skills",
67    ".gemini/skills",
68];
69
70const SKILL_FILENAME: &str = "SKILL.md";
71
72/// In-memory registry for discovered skills.
73pub struct SkillRegistry {
74    skills: RwLock<HashMap<String, SkillDefinition>>,
75}
76
77impl Default for SkillRegistry {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl SkillRegistry {
84    pub fn new() -> Self {
85        Self {
86            skills: RwLock::new(HashMap::new()),
87        }
88    }
89
90    /// Discover and load skills from well-known directories.
91    pub fn reload(&self, cwd: &str) {
92        let mut discovered = HashMap::new();
93
94        let cwd_path = Path::new(cwd);
95
96        // Scan well-known directories relative to cwd
97        for dir_pattern in SKILL_DIRS {
98            let skill_dir = cwd_path.join(dir_pattern);
99            if skill_dir.is_dir() {
100                discover_skills_in_dir(&skill_dir, &mut discovered);
101            }
102        }
103
104        // Also scan home directory skill locations
105        if let Some(home) = dirs::home_dir() {
106            for dir_pattern in SKILL_DIRS {
107                let skill_dir = home.join(dir_pattern);
108                if skill_dir.is_dir() {
109                    discover_skills_in_dir(&skill_dir, &mut discovered);
110                }
111            }
112        }
113
114        let count = discovered.len();
115        if let Ok(mut skills) = self.skills.write() {
116            *skills = discovered;
117        }
118        tracing::info!("Discovered {} skills", count);
119    }
120
121    /// Get a skill by name.
122    pub fn get_skill(&self, name: &str) -> Option<SkillDefinition> {
123        self.skills.read().ok().and_then(|s| s.get(name).cloned())
124    }
125
126    /// List all discovered skills.
127    pub fn list_skills(&self) -> Vec<SkillDefinition> {
128        self.skills
129            .read()
130            .map(|s| s.values().cloned().collect())
131            .unwrap_or_default()
132    }
133}
134
135/// Recursively discover SKILL.md files in a directory (max 2 levels deep).
136fn discover_skills_in_dir(dir: &Path, out: &mut HashMap<String, SkillDefinition>) {
137    discover_skills_recursive(dir, out, 0, 2);
138}
139
140fn discover_skills_recursive(
141    dir: &Path,
142    out: &mut HashMap<String, SkillDefinition>,
143    depth: usize,
144    max_depth: usize,
145) {
146    if depth > max_depth {
147        return;
148    }
149
150    let entries = match std::fs::read_dir(dir) {
151        Ok(entries) => entries,
152        Err(_) => return,
153    };
154
155    for entry in entries.flatten() {
156        let path = entry.path();
157        if path.is_dir() {
158            let skill_file = path.join(SKILL_FILENAME);
159            if skill_file.is_file() {
160                if let Some(skill) = parse_skill_file(&skill_file) {
161                    out.insert(skill.name.clone(), skill);
162                }
163            }
164            // Recurse deeper (handles .system subdirs, nested structures)
165            discover_skills_recursive(&path, out, depth + 1, max_depth);
166        } else if path
167            .file_name()
168            .map(|f| f == SKILL_FILENAME)
169            .unwrap_or(false)
170        {
171            if let Some(skill) = parse_skill_file(&path) {
172                out.insert(skill.name.clone(), skill);
173            }
174        }
175    }
176}
177
178/// Extract YAML frontmatter from between `---` delimiters.
179fn extract_frontmatter(contents: &str) -> Option<(String, String)> {
180    let mut lines = contents.lines();
181    if !matches!(lines.next(), Some(line) if line.trim() == "---") {
182        return None;
183    }
184
185    let mut frontmatter_lines: Vec<&str> = Vec::new();
186    let mut body_start = false;
187    let mut body_lines: Vec<&str> = Vec::new();
188
189    for line in lines {
190        if !body_start {
191            if line.trim() == "---" {
192                body_start = true;
193            } else {
194                frontmatter_lines.push(line);
195            }
196        } else {
197            body_lines.push(line);
198        }
199    }
200
201    if frontmatter_lines.is_empty() || !body_start {
202        return None;
203    }
204
205    Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
206}
207
208/// Parse a SKILL.md file into a SkillDefinition.
209///
210/// Supports two formats:
211/// 1. YAML frontmatter (preferred): `---\nname: ...\ndescription: ...\n---\n<body>`
212/// 2. Legacy fallback: directory name as name, first paragraph as description
213fn parse_skill_file(path: &Path) -> Option<SkillDefinition> {
214    let raw = std::fs::read_to_string(path).ok()?;
215
216    // Try YAML frontmatter first
217    if let Some((frontmatter_str, body)) = extract_frontmatter(&raw) {
218        if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&frontmatter_str) {
219            let short_desc = fm.metadata.short_description.filter(|s| !s.is_empty());
220
221            return Some(SkillDefinition {
222                name: fm.name,
223                description: fm.description,
224                short_description: short_desc,
225                content: body.trim().to_string(),
226                source: path.to_string_lossy().to_string(),
227                license: fm.license,
228                compatibility: fm.compatibility,
229                metadata: HashMap::new(),
230            });
231        }
232    }
233
234    // Fallback: extract name from directory, description from first paragraph
235    let name = path
236        .parent()
237        .and_then(|p| p.file_name())
238        .map(|n| n.to_string_lossy().to_string())
239        .unwrap_or_else(|| "unknown".to_string());
240
241    let description = raw
242        .lines()
243        .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
244        .take_while(|l| !l.trim().is_empty())
245        .collect::<Vec<_>>()
246        .join(" ");
247
248    Some(SkillDefinition {
249        name,
250        description: if description.is_empty() {
251            "No description".to_string()
252        } else {
253            description
254        },
255        short_description: None,
256        content: raw,
257        source: path.to_string_lossy().to_string(),
258        license: None,
259        compatibility: None,
260        metadata: HashMap::new(),
261    })
262}