Skip to main content

oxios_kernel/
skill.rs

1//! Skill system: markdown-based instructions for agents.
2//!
3//! Skills are markdown files with YAML frontmatter that define
4//! reusable instruction templates. Agents read skills to understand
5//! expected behaviors and patterns.
6//!
7//! Skill files are structured as:
8//! ```markdown
9//! ---
10//! name: skill-name
11//! description: Brief description of what this skill provides
12//! ---
13//!
14//! # Skill Title
15//!
16//! Detailed instructions and guidelines...
17//! ```
18
19use anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22use tokio::fs;
23
24/// Metadata extracted from SKILL.md frontmatter.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SkillMeta {
27    /// Unique name for this skill.
28    pub name: String,
29    /// Human-readable description.
30    pub description: String,
31}
32
33/// A loaded skill with its metadata and content.
34#[derive(Debug, Clone)]
35pub struct Skill {
36    /// Metadata extracted from frontmatter.
37    pub meta: SkillMeta,
38    /// The full markdown content (including frontmatter).
39    pub content: String,
40    /// Path to the source file.
41    pub path: PathBuf,
42}
43
44/// Simple frontmatter parser for skill metadata.
45///
46/// Parses YAML frontmatter from the beginning of a markdown file.
47/// Returns the metadata and remaining content.
48fn parse_frontmatter(content: &str) -> Result<(SkillMeta, String)> {
49    let trimmed = content.trim_start();
50    if !trimmed.starts_with("---") {
51        // No frontmatter, use defaults
52        return Ok((
53            SkillMeta {
54                name: String::new(),
55                description: String::new(),
56            },
57            content.to_string(),
58        ));
59    }
60
61    // Find closing ---
62    let after_open = &trimmed[3..];
63    let closing_pos = after_open.find("---").context("unclosed frontmatter")?;
64    let yaml_content = &after_open[..closing_pos];
65    let rest = &after_open[closing_pos + 3..];
66
67    // Parse YAML manually (simple key: value parsing)
68    let mut name = String::new();
69    let mut description = String::new();
70
71    for line in yaml_content.lines() {
72        let line = line.trim();
73        if line.is_empty() || line.starts_with('#') {
74            continue;
75        }
76        if let Some(val) = line.strip_prefix("name:") {
77            name = val.trim().trim_matches('"').trim_matches('\'').to_string();
78        } else if let Some(val) = line.strip_prefix("description:") {
79            description = val.trim().trim_matches('"').trim_matches('\'').to_string();
80        }
81    }
82
83    Ok((
84        SkillMeta { name, description },
85        rest.trim_start().to_string(),
86    ))
87}
88
89/// Store for managing skills as markdown files.
90#[derive(Clone)]
91pub struct SkillStore {
92    /// Directory containing skill files.
93    skills_dir: PathBuf,
94}
95
96impl std::fmt::Debug for SkillStore {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("SkillStore")
99            .field("skills_dir", &self.skills_dir)
100            .finish()
101    }
102}
103
104impl SkillStore {
105    /// Creates a new skill store pointing to the given directory.
106    ///
107    /// The directory will be created if it doesn't exist.
108    ///
109    /// # Example
110    ///
111    /// ```ignore
112    /// use oxios_kernel::SkillStore;
113    /// use std::path::PathBuf;
114    ///
115    /// let store = SkillStore::new(PathBuf::from("/tmp/skills")).unwrap();
116    /// ```
117    pub fn new(skills_dir: PathBuf) -> Result<Self> {
118        Ok(Self { skills_dir })
119    }
120
121    /// Initialize the skills directory with default skills if empty.
122    pub async fn init_defaults(&self, defaults_dir: &PathBuf) -> Result<()> {
123        if !self.skills_dir.exists() {
124            fs::create_dir_all(&self.skills_dir).await?;
125        }
126
127        // Check if any skills exist
128        {
129            let mut entries = fs::read_dir(&self.skills_dir).await?;
130            let mut count = 0;
131            while entries.next_entry().await?.is_some() {
132                count += 1;
133            }
134            if count > 0 {
135                return Ok(()); // Already has skills
136            }
137        }
138
139        // Copy default skills from embedded or provided defaults directory
140        if defaults_dir.exists() {
141            let mut entries = fs::read_dir(defaults_dir).await?;
142            while let Some(entry) = entries.next_entry().await? {
143                let src = entry.path();
144                if src.is_dir() {
145                    let skill_name = src
146                        .file_name()
147                        .and_then(|n| n.to_str())
148                        .unwrap_or("unknown");
149                    let dest = self.skills_dir.join(skill_name);
150                    fs::create_dir_all(&dest).await?;
151
152                    let mut skill_files = fs::read_dir(&src).await?;
153                    while let Some(sfile) = skill_files.next_entry().await? {
154                        if sfile.file_name() == "SKILL.md" {
155                            let content = fs::read_to_string(sfile.path()).await?;
156                            let dest_file = dest.join("SKILL.md");
157                            if !dest_file.exists() {
158                                fs::write(&dest_file, content).await?;
159                            }
160                        }
161                    }
162                }
163            }
164        }
165
166        Ok(())
167    }
168
169    /// List all available skills with their metadata.
170    pub async fn list_skills(&self) -> Result<Vec<SkillMeta>> {
171        let mut skills = Vec::new();
172
173        if !self.skills_dir.exists() {
174            return Ok(skills);
175        }
176
177        let mut entries = fs::read_dir(&self.skills_dir).await?;
178        while let Some(entry) = entries.next_entry().await? {
179            let path = entry.path();
180            if path.is_dir() {
181                let skill_file = path.join("SKILL.md");
182                if skill_file.exists() {
183                    if let Ok(content) = fs::read_to_string(&skill_file).await {
184                        if let Ok((meta, _)) = parse_frontmatter(&content) {
185                            if !meta.name.is_empty() {
186                                skills.push(meta);
187                            }
188                        }
189                    }
190                }
191            }
192        }
193
194        skills.sort_by(|a, b| a.name.cmp(&b.name));
195        Ok(skills)
196    }
197
198    /// Load a specific skill by name.
199    ///
200    /// Looks for `<name>/SKILL.md` in the skills directory.
201    pub async fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
202        let skill_path = self.skills_dir.join(name).join("SKILL.md");
203
204        if !skill_path.exists() {
205            return Ok(None);
206        }
207
208        let content = fs::read_to_string(&skill_path).await?;
209        let (meta, content) = parse_frontmatter(&content)?;
210
211        Ok(Some(Skill {
212            meta,
213            content,
214            path: skill_path,
215        }))
216    }
217
218    /// Create a new skill with the given metadata and content.
219    ///
220    /// The skill will be saved as `<skills_dir>/<name>/SKILL.md`.
221    pub async fn create_skill(&self, name: &str, description: &str, content: &str) -> Result<()> {
222        fs::create_dir_all(self.skills_dir.join(name)).await?;
223
224        let skill_file = self.skills_dir.join(name).join("SKILL.md");
225        let frontmatter = format!(
226            "---\nname: {}\ndescription: {}\n---\n\n{}",
227            name, description, content
228        );
229
230        fs::write(&skill_file, frontmatter).await?;
231        Ok(())
232    }
233
234    /// Delete a skill by name.
235    ///
236    /// Removes the entire `<name>/` directory.
237    pub async fn delete_skill(&self, name: &str) -> Result<()> {
238        let skill_dir = self.skills_dir.join(name);
239        if skill_dir.exists() {
240            fs::remove_dir_all(&skill_dir).await?;
241        }
242        Ok(())
243    }
244
245    /// Get the path to the skills directory.
246    pub fn path(&self) -> &PathBuf {
247        &self.skills_dir
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_parse_frontmatter_with_metadata() {
257        let content = r#"---
258name: code-review
259description: Guidelines for reviewing code changes
260---
261
262# Code Review
263
264Follow these steps to review code effectively.
265"#;
266        let (meta, rest) = parse_frontmatter(content).unwrap();
267        assert_eq!(meta.name, "code-review");
268        assert_eq!(meta.description, "Guidelines for reviewing code changes");
269        assert!(rest.contains("Code Review"));
270    }
271
272    #[test]
273    fn test_parse_frontmatter_no_metadata() {
274        let content = "# Just a Title\n\nSome content";
275        let (meta, rest) = parse_frontmatter(content).unwrap();
276        assert!(meta.name.is_empty());
277        assert!(rest.contains("Just a Title"));
278    }
279
280    #[test]
281    fn test_parse_frontmatter_quoted_values() {
282        let content = r#"---
283name: "test-skill"
284description: 'A test skill'
285---
286
287Content here
288"#;
289        let (meta, _) = parse_frontmatter(content).unwrap();
290        assert_eq!(meta.name, "test-skill");
291        assert_eq!(meta.description, "A test skill");
292    }
293}