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