Skip to main content

agent_code_lib/skills/
mod.rs

1//! Skill system.
2//!
3//! Skills are reusable, user-defined workflows loaded from markdown
4//! files in `.agent/skills/` or `~/.config/agent-code/skills/`. Each
5//! skill is a markdown file with YAML frontmatter that defines:
6//!
7//! - `description`: what the skill does
8//! - `whenToUse`: when to invoke it
9//! - `userInvocable`: whether users can invoke it via `/skill-name`
10//!
11//! The body of the skill file is a prompt template that gets expanded
12//! when the skill is invoked. Supports `{{arg}}` substitution.
13
14use serde::Deserialize;
15use std::path::{Path, PathBuf};
16use tracing::{debug, warn};
17
18/// A loaded skill definition.
19#[derive(Debug, Clone)]
20pub struct Skill {
21    /// Skill name (derived from filename without extension).
22    pub name: String,
23    /// Metadata from frontmatter.
24    pub metadata: SkillMetadata,
25    /// The prompt template body.
26    pub body: String,
27    /// Source file path.
28    pub source: PathBuf,
29}
30
31/// Frontmatter metadata for a skill.
32#[derive(Debug, Clone, Default, Deserialize)]
33#[serde(default)]
34pub struct SkillMetadata {
35    /// What this skill does.
36    pub description: Option<String>,
37    /// When to invoke this skill.
38    #[serde(rename = "whenToUse")]
39    pub when_to_use: Option<String>,
40    /// Whether users can invoke this via `/name`.
41    #[serde(rename = "userInvocable")]
42    pub user_invocable: bool,
43    /// Whether to disable in non-interactive sessions.
44    #[serde(rename = "disableNonInteractive")]
45    pub disable_non_interactive: bool,
46    /// File patterns that trigger this skill suggestion.
47    pub paths: Option<Vec<String>>,
48}
49
50impl Skill {
51    /// Expand the skill body with argument substitution.
52    pub fn expand(&self, args: Option<&str>) -> String {
53        let mut body = self.body.clone();
54        if let Some(args) = args {
55            body = body.replace("{{arg}}", args);
56            body = body.replace("{{ arg }}", args);
57        }
58        body
59    }
60}
61
62/// Skill registry holding all loaded skills.
63pub struct SkillRegistry {
64    skills: Vec<Skill>,
65}
66
67impl SkillRegistry {
68    pub fn new() -> Self {
69        Self { skills: Vec::new() }
70    }
71
72    /// Load skills from all configured directories.
73    pub fn load_all(project_root: Option<&Path>) -> Self {
74        let mut registry = Self::new();
75
76        // Load from project-level skills directory.
77        if let Some(root) = project_root {
78            let project_skills = root.join(".agent").join("skills");
79            if project_skills.is_dir() {
80                registry.load_from_dir(&project_skills);
81            }
82        }
83
84        // Load from user-level skills directory.
85        if let Some(dir) = user_skills_dir()
86            && dir.is_dir()
87        {
88            registry.load_from_dir(&dir);
89        }
90
91        debug!("Loaded {} skills", registry.skills.len());
92        registry
93    }
94
95    /// Load skills from a single directory.
96    fn load_from_dir(&mut self, dir: &Path) {
97        let entries = match std::fs::read_dir(dir) {
98            Ok(entries) => entries,
99            Err(e) => {
100                warn!("Failed to read skills directory {}: {e}", dir.display());
101                return;
102            }
103        };
104
105        for entry in entries.flatten() {
106            let path = entry.path();
107
108            // Skills can be single .md files or directories with a SKILL.md.
109            let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
110                path.clone()
111            } else if path.is_dir() {
112                let skill_md = path.join("SKILL.md");
113                if skill_md.exists() {
114                    skill_md
115                } else {
116                    continue;
117                }
118            } else {
119                continue;
120            };
121
122            match load_skill_file(&skill_path) {
123                Ok(skill) => {
124                    debug!(
125                        "Loaded skill '{}' from {}",
126                        skill.name,
127                        skill_path.display()
128                    );
129                    self.skills.push(skill);
130                }
131                Err(e) => {
132                    warn!("Failed to load skill {}: {e}", skill_path.display());
133                }
134            }
135        }
136    }
137
138    /// Find a skill by name.
139    pub fn find(&self, name: &str) -> Option<&Skill> {
140        self.skills.iter().find(|s| s.name == name)
141    }
142
143    /// Get all user-invocable skills.
144    pub fn user_invocable(&self) -> Vec<&Skill> {
145        self.skills
146            .iter()
147            .filter(|s| s.metadata.user_invocable)
148            .collect()
149    }
150
151    /// Get all skills.
152    pub fn all(&self) -> &[Skill] {
153        &self.skills
154    }
155}
156
157/// Load a single skill file, parsing frontmatter and body.
158fn load_skill_file(path: &Path) -> Result<Skill, String> {
159    let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
160
161    // Derive skill name from path.
162    let name = path
163        .parent()
164        .and_then(|p| {
165            // If this is SKILL.md in a directory, use the directory name.
166            if path.file_name().is_some_and(|f| f == "SKILL.md") {
167                p.file_name().and_then(|n| n.to_str())
168            } else {
169                None
170            }
171        })
172        .or_else(|| path.file_stem().and_then(|s| s.to_str()))
173        .unwrap_or("unknown")
174        .to_string();
175
176    // Parse YAML frontmatter (between --- delimiters).
177    let (metadata, body) = parse_frontmatter(&content)?;
178
179    Ok(Skill {
180        name,
181        metadata,
182        body,
183        source: path.to_path_buf(),
184    })
185}
186
187/// Parse YAML frontmatter from markdown content.
188///
189/// Expects format:
190/// ```text
191/// ---
192/// key: value
193/// ---
194/// body content
195/// ```
196fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
197    let trimmed = content.trim_start();
198
199    if !trimmed.starts_with("---") {
200        // No frontmatter — entire content is the body.
201        return Ok((SkillMetadata::default(), content.to_string()));
202    }
203
204    // Find the closing ---.
205    let after_first = &trimmed[3..];
206    let closing = after_first
207        .find("\n---")
208        .ok_or("Frontmatter not closed (missing closing ---)")?;
209
210    let yaml = &after_first[..closing].trim();
211    let body = &after_first[closing + 4..].trim_start();
212
213    let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
214
215    Ok((metadata, body.to_string()))
216}
217
218/// Parse YAML using a simple key-value approach.
219/// (Avoids adding a full YAML parser dependency.)
220fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
221    // Build a JSON object from simple YAML key: value pairs.
222    let mut map = serde_json::Map::new();
223
224    for line in yaml.lines() {
225        let line = line.trim();
226        if line.is_empty() || line.starts_with('#') {
227            continue;
228        }
229        if let Some((key, value)) = line.split_once(':') {
230            let key = key.trim();
231            let value = value.trim().trim_matches('"').trim_matches('\'');
232
233            // Handle booleans.
234            let json_value = match value {
235                "true" => serde_json::Value::Bool(true),
236                "false" => serde_json::Value::Bool(false),
237                _ => serde_json::Value::String(value.to_string()),
238            };
239            map.insert(key.to_string(), json_value);
240        }
241    }
242
243    let json = serde_json::Value::Object(map);
244    serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
245}
246
247/// Get the user-level skills directory.
248fn user_skills_dir() -> Option<PathBuf> {
249    dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_parse_frontmatter() {
258        let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
259        let (meta, body) = parse_frontmatter(content).unwrap();
260        assert_eq!(meta.description, Some("Test skill".to_string()));
261        assert!(meta.user_invocable);
262        assert_eq!(body, "Do the thing.");
263    }
264
265    #[test]
266    fn test_parse_no_frontmatter() {
267        let content = "Just a prompt with no frontmatter.";
268        let (meta, body) = parse_frontmatter(content).unwrap();
269        assert!(meta.description.is_none());
270        assert_eq!(body, content);
271    }
272
273    #[test]
274    fn test_skill_expand() {
275        let skill = Skill {
276            name: "test".into(),
277            metadata: SkillMetadata::default(),
278            body: "Review {{arg}} carefully.".into(),
279            source: PathBuf::from("test.md"),
280        };
281        assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
282    }
283}