Skip to main content

oxi/skills/
mod.rs

1//! Skills system for oxi CLI
2//!
3//! Skills are on-demand capability packages stored as markdown files.
4//! Each skill lives in its own directory under `~/.oxi/skills/<name>/SKILL.md`.
5//!
6//! When a skill is invoked, its content is appended to the system prompt,
7//! giving the agent additional context and instructions for that skill.
8
9use anyhow::{Context, Result};
10use std::collections::HashMap;
11use std::fmt;
12use std::path::{Path, PathBuf};
13
14/// A single skill loaded from a SKILL.md file.
15#[derive(Debug, Clone)]
16pub struct Skill {
17    /// Skill name (directory name, e.g. "my-skill")
18    pub name: String,
19    /// Short description extracted from the first line or frontmatter
20    pub description: String,
21    /// Full markdown content from SKILL.md
22    pub content: String,
23}
24
25impl fmt::Display for Skill {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "{}: {}", self.name, self.description)
28    }
29}
30
31/// Manages loading and querying skills from the filesystem.
32pub struct SkillManager {
33    skills: HashMap<String, Skill>,
34}
35
36impl SkillManager {
37    /// Create an empty skill manager.
38    pub fn new() -> Self {
39        Self {
40            skills: HashMap::new(),
41        }
42    }
43
44    /// Load all skills from the given directory.
45    ///
46    /// Expected structure:
47    /// ```text
48    /// dir/
49    ///   ├── my-skill/
50    ///   │   └── SKILL.md
51    ///   └── another-skill/
52    ///       └── SKILL.md
53    /// ```
54    pub fn load_from_dir(dir: &Path) -> Result<Self> {
55        let mut skills = HashMap::new();
56
57        if !dir.exists() {
58            tracing::debug!("Skills directory does not exist: {}", dir.display());
59            return Ok(Self { skills });
60        }
61
62        let entries = std::fs::read_dir(dir)
63            .with_context(|| format!("Failed to read skills directory: {}", dir.display()))?;
64
65        for entry in entries {
66            let entry = entry?;
67            let path = entry.path();
68
69            // Only process directories
70            if !path.is_dir() {
71                continue;
72            }
73
74            let skill_file = path.join("SKILL.md");
75            if !skill_file.exists() {
76                tracing::debug!(
77                    "No SKILL.md found in {}",
78                    path.file_name().unwrap_or_default().to_string_lossy()
79                );
80                continue;
81            }
82
83            let name = path
84                .file_name()
85                .unwrap_or_default()
86                .to_string_lossy()
87                .to_string();
88
89            match Self::load_skill(&name, &skill_file) {
90                Ok(skill) => {
91                    tracing::debug!("Loaded skill: {}", skill.name);
92                    skills.insert(name.to_lowercase(), skill);
93                }
94                Err(e) => {
95                    tracing::warn!("Failed to load skill from {}: {}", skill_file.display(), e);
96                }
97            }
98        }
99
100        tracing::info!("Loaded {} skill(s) from {}", skills.len(), dir.display());
101        Ok(Self { skills })
102    }
103
104    /// Load a single skill from its SKILL.md file.
105    fn load_skill(name: &str, path: &Path) -> Result<Skill> {
106        let content = std::fs::read_to_string(path)
107            .with_context(|| format!("Failed to read {}", path.display()))?;
108
109        let description = Self::extract_description(&content);
110
111        Ok(Skill {
112            name: name.to_string(),
113            description,
114            content,
115        })
116    }
117
118    /// Extract a short description from markdown content.
119    ///
120    /// Looks for the first non-empty line that looks like a heading or paragraph.
121    /// Falls back to "No description" if nothing suitable is found.
122    fn extract_description(content: &str) -> String {
123        for line in content.lines() {
124            let trimmed = line.trim();
125            // Skip empty lines and frontmatter delimiters
126            if trimmed.is_empty() || trimmed == "---" {
127                continue;
128            }
129            // Skip YAML frontmatter lines (key: value)
130            if trimmed.contains(':') && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
131                continue;
132            }
133            // Use first heading (strip # prefix) or first text line
134            if let Some(heading) = trimmed.strip_prefix('#') {
135                return heading.trim().to_string();
136            }
137            // Use first paragraph line
138            if !trimmed.starts_with('-') && !trimmed.starts_with('>') && trimmed.len() > 3 {
139                return trimmed.to_string();
140            }
141        }
142        "No description".to_string()
143    }
144
145    /// Look up a skill by exact name (case-insensitive).
146    pub fn get(&self, name: &str) -> Option<&Skill> {
147        self.skills.get(&name.to_lowercase())
148    }
149
150    /// List all loaded skills, sorted alphabetically by name.
151    pub fn all(&self) -> Vec<&Skill> {
152        let mut skills: Vec<&Skill> = self.skills.values().collect();
153        skills.sort_by(|a, b| a.name.cmp(&b.name));
154        skills
155    }
156
157    /// Search skills by query string.
158    ///
159    /// Matches against name and description (case-insensitive).
160    /// Returns skills sorted by relevance (name match first, then description match).
161    pub fn search(&self, query: &str) -> Vec<&Skill> {
162        let query_lower = query.to_lowercase();
163        let mut name_matches: Vec<&Skill> = Vec::new();
164        let mut desc_matches: Vec<&Skill> = Vec::new();
165
166        for skill in self.skills.values() {
167            let name_lower = skill.name.to_lowercase();
168            let desc_lower = skill.description.to_lowercase();
169
170            if name_lower.contains(&query_lower) {
171                name_matches.push(skill);
172            } else if desc_lower.contains(&query_lower) {
173                desc_matches.push(skill);
174            }
175        }
176
177        // Also check full content
178        for skill in self.skills.values() {
179            if !name_matches.iter().any(|s| s.name == skill.name)
180                && !desc_matches.iter().any(|s| s.name == skill.name)
181                && skill.content.to_lowercase().contains(&query_lower)
182            {
183                desc_matches.push(skill);
184            }
185        }
186
187        name_matches.sort_by(|a, b| a.name.cmp(&b.name));
188        desc_matches.sort_by(|a, b| a.name.cmp(&b.name));
189
190        name_matches.extend(desc_matches);
191        name_matches
192    }
193
194    /// Number of loaded skills.
195    pub fn len(&self) -> usize {
196        self.skills.len()
197    }
198
199    /// Whether any skills are loaded.
200    pub fn is_empty(&self) -> bool {
201        self.skills.is_empty()
202    }
203
204    /// Get the default skills directory path (~/.oxi/skills/).
205    pub fn skills_dir() -> Result<PathBuf> {
206        let home = dirs::home_dir().context("Cannot determine home directory")?;
207        Ok(home.join(".oxi").join("skills"))
208    }
209}
210
211impl fmt::Debug for SkillManager {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        f.debug_struct("SkillManager")
214            .field("count", &self.skills.len())
215            .field(
216                "names",
217                &self.skills.keys().cloned().collect::<Vec<String>>(),
218            )
219            .finish()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::fs;
227
228    #[test]
229    fn test_load_from_empty_dir() {
230        let tmp = tempfile::tempdir().unwrap();
231        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
232        assert!(manager.is_empty());
233        assert_eq!(manager.len(), 0);
234    }
235
236    #[test]
237    fn test_load_from_nonexistent_dir() {
238        let manager = SkillManager::load_from_dir(Path::new("/nonexistent/skills")).unwrap();
239        assert!(manager.is_empty());
240    }
241
242    #[test]
243    fn test_load_single_skill() {
244        let tmp = tempfile::tempdir().unwrap();
245        let skill_dir = tmp.path().join("my-skill");
246        fs::create_dir_all(&skill_dir).unwrap();
247        fs::write(
248            skill_dir.join("SKILL.md"),
249            "# My Skill\n\nThis skill does something cool.\n\n## Usage\nDo X then Y.",
250        )
251        .unwrap();
252
253        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
254        assert_eq!(manager.len(), 1);
255
256        let skill = manager.get("my-skill").unwrap();
257        assert_eq!(skill.name, "my-skill");
258        assert_eq!(skill.description, "My Skill");
259        assert!(skill.content.contains("This skill does something cool"));
260    }
261
262    #[test]
263    fn test_load_multiple_skills() {
264        let tmp = tempfile::tempdir().unwrap();
265
266        // Create skill-a
267        let dir_a = tmp.path().join("skill-a");
268        fs::create_dir_all(&dir_a).unwrap();
269        fs::write(dir_a.join("SKILL.md"), "# Skill A\nDescription A").unwrap();
270
271        // Create skill-b
272        let dir_b = tmp.path().join("skill-b");
273        fs::create_dir_all(&dir_b).unwrap();
274        fs::write(dir_b.join("SKILL.md"), "# Skill B\nDescription B").unwrap();
275
276        // Create directory without SKILL.md
277        let dir_empty = tmp.path().join("empty-dir");
278        fs::create_dir_all(&dir_empty).unwrap();
279
280        // Create a file (not a directory) — should be skipped
281        fs::write(tmp.path().join("not-a-dir.txt"), "ignore me").unwrap();
282
283        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
284        assert_eq!(manager.len(), 2);
285        assert!(manager.get("skill-a").is_some());
286        assert!(manager.get("skill-b").is_some());
287        assert!(manager.get("empty-dir").is_none());
288    }
289
290    #[test]
291    fn test_get_case_insensitive() {
292        let tmp = tempfile::tempdir().unwrap();
293        let dir = tmp.path().join("My-Skill");
294        fs::create_dir_all(&dir).unwrap();
295        fs::write(dir.join("SKILL.md"), "# Test\nContent").unwrap();
296
297        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
298        // The key is stored as the directory name verbatim
299        assert!(manager.get("My-Skill").is_some());
300    }
301
302    #[test]
303    fn test_search_by_name() {
304        let tmp = tempfile::tempdir().unwrap();
305        let dir = tmp.path().join("rust-expert");
306        fs::create_dir_all(&dir).unwrap();
307        fs::write(dir.join("SKILL.md"), "# Rust Expert\nAn expert in Rust").unwrap();
308
309        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
310        let results = manager.search("rust");
311        assert_eq!(results.len(), 1);
312        assert_eq!(results[0].name, "rust-expert");
313    }
314
315    #[test]
316    fn test_search_by_description() {
317        let tmp = tempfile::tempdir().unwrap();
318        let dir = tmp.path().join("helper");
319        fs::create_dir_all(&dir).unwrap();
320        fs::write(
321            dir.join("SKILL.md"),
322            "# Helper\nA database optimization expert",
323        )
324        .unwrap();
325
326        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
327        let results = manager.search("database");
328        assert_eq!(results.len(), 1);
329        assert_eq!(results[0].name, "helper");
330    }
331
332    #[test]
333    fn test_search_by_content() {
334        let tmp = tempfile::tempdir().unwrap();
335        let dir = tmp.path().join("coder");
336        fs::create_dir_all(&dir).unwrap();
337        fs::write(
338            dir.join("SKILL.md"),
339            "# Coder\nA coding assistant\n\n## Details\nFocuses on async patterns",
340        )
341        .unwrap();
342
343        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
344        let results = manager.search("async");
345        assert_eq!(results.len(), 1);
346    }
347
348    #[test]
349    fn test_search_no_results() {
350        let tmp = tempfile::tempdir().unwrap();
351        let dir = tmp.path().join("skill");
352        fs::create_dir_all(&dir).unwrap();
353        fs::write(dir.join("SKILL.md"), "# Skill\nA skill").unwrap();
354
355        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
356        let results = manager.search("nonexistent");
357        assert!(results.is_empty());
358    }
359
360    #[test]
361    fn test_all_sorted() {
362        let tmp = tempfile::tempdir().unwrap();
363
364        for name in &["zebra", "alpha", "middle"] {
365            let dir = tmp.path().join(name);
366            fs::create_dir_all(&dir).unwrap();
367            fs::write(dir.join("SKILL.md"), format!("# {}\nDesc", name)).unwrap();
368        }
369
370        let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
371        let all = manager.all();
372        assert_eq!(all.len(), 3);
373        assert_eq!(all[0].name, "alpha");
374        assert_eq!(all[1].name, "middle");
375        assert_eq!(all[2].name, "zebra");
376    }
377
378    #[test]
379    fn test_extract_description_from_heading() {
380        let content = "# My Cool Skill\n\nSome body text";
381        assert_eq!(SkillManager::extract_description(content), "My Cool Skill");
382    }
383
384    #[test]
385    fn test_extract_description_from_paragraph() {
386        let content = "This is a skill that does things.\n\nMore text.";
387        assert_eq!(
388            SkillManager::extract_description(content),
389            "This is a skill that does things."
390        );
391    }
392
393    #[test]
394    fn test_extract_description_empty() {
395        let content = "---\n---\n";
396        assert_eq!(SkillManager::extract_description(content), "No description");
397    }
398
399    #[test]
400    fn test_skills_dir() {
401        let dir = SkillManager::skills_dir().unwrap();
402        assert!(dir.to_string_lossy().contains(".oxi"));
403        assert!(dir.to_string_lossy().contains("skills"));
404    }
405}