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        // Load bundled skills (shipped with the binary).
92        registry.load_bundled();
93
94        debug!("Loaded {} skills", registry.skills.len());
95        registry
96    }
97
98    /// Load built-in skills that ship with agent-code.
99    fn load_bundled(&mut self) {
100        let bundled = [
101            (
102                "commit",
103                "Create a well-crafted git commit",
104                true,
105                "Review the current git diff carefully. Create a commit with a clear, \
106                 concise message that explains WHY the change was made, not just WHAT changed. \
107                 Follow the repository's existing commit style. Stage specific files \
108                 (don't use git add -A). Never commit .env or credentials.",
109            ),
110            (
111                "review",
112                "Review code changes for bugs and issues",
113                true,
114                "Review the current git diff against the base branch. Look for: bugs, \
115                 security issues (injection, XSS, OWASP top 10), race conditions, \
116                 error handling gaps, performance problems (N+1 queries, missing indexes), \
117                 and code quality issues. Report findings with file:line references.",
118            ),
119            (
120                "test",
121                "Run tests and fix failures",
122                true,
123                "Run the project's test suite. If any tests fail, read the failing test \
124                 and the source code it tests. Identify the root cause. Fix the issue. \
125                 Run the tests again to verify the fix. Repeat until all tests pass.",
126            ),
127            (
128                "explain",
129                "Explain how a piece of code works",
130                true,
131                "Read the file or function the user is asking about. Explain what it does, \
132                 how it works, and why it's designed that way. Use clear language. \
133                 Reference specific line numbers. If there are non-obvious design decisions, \
134                 explain the tradeoffs.",
135            ),
136            (
137                "debug",
138                "Debug an error or unexpected behavior",
139                true,
140                "Investigate the error systematically. Read the error message and stack trace. \
141                 Find the relevant source code. Identify the root cause (don't guess). \
142                 Propose a fix with explanation. Apply the fix and verify it works.",
143            ),
144            (
145                "pr",
146                "Create a pull request",
147                true,
148                "Check git status and diff against the base branch. Analyze ALL commits \
149                 on this branch. Draft a PR title (under 70 chars) and body with a summary \
150                 section (bullet points) and a test plan. Push to remote and create the PR \
151                 using gh pr create. Return the PR URL.",
152            ),
153            (
154                "refactor",
155                "Refactor code for better quality",
156                true,
157                "Read the code the user wants refactored. Identify specific improvements: \
158                 extract functions, reduce duplication, simplify conditionals, improve naming, \
159                 add missing error handling. Make changes incrementally. Run tests after \
160                 each change to verify nothing broke.",
161            ),
162            (
163                "init",
164                "Initialize project configuration",
165                true,
166                "Create an AGENTS.md file in the project root with project context: \
167                 tech stack, architecture overview, coding conventions, test commands, \
168                 and important file locations. This helps the agent understand the project \
169                 in future sessions.",
170            ),
171        ];
172
173        for (name, description, user_invocable, body) in bundled {
174            // Don't override user-defined skills with the same name.
175            if self.skills.iter().any(|s| s.name == name) {
176                continue;
177            }
178            self.skills.push(Skill {
179                name: name.to_string(),
180                metadata: SkillMetadata {
181                    description: Some(description.to_string()),
182                    when_to_use: None,
183                    user_invocable,
184                    disable_non_interactive: false,
185                    paths: None,
186                },
187                body: body.to_string(),
188                source: std::path::PathBuf::new(),
189            });
190        }
191    }
192
193    /// Load skills from a single directory.
194    fn load_from_dir(&mut self, dir: &Path) {
195        let entries = match std::fs::read_dir(dir) {
196            Ok(entries) => entries,
197            Err(e) => {
198                warn!("Failed to read skills directory {}: {e}", dir.display());
199                return;
200            }
201        };
202
203        for entry in entries.flatten() {
204            let path = entry.path();
205
206            // Skills can be single .md files or directories with a SKILL.md.
207            let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
208                path.clone()
209            } else if path.is_dir() {
210                let skill_md = path.join("SKILL.md");
211                if skill_md.exists() {
212                    skill_md
213                } else {
214                    continue;
215                }
216            } else {
217                continue;
218            };
219
220            match load_skill_file(&skill_path) {
221                Ok(skill) => {
222                    debug!(
223                        "Loaded skill '{}' from {}",
224                        skill.name,
225                        skill_path.display()
226                    );
227                    self.skills.push(skill);
228                }
229                Err(e) => {
230                    warn!("Failed to load skill {}: {e}", skill_path.display());
231                }
232            }
233        }
234    }
235
236    /// Find a skill by name.
237    pub fn find(&self, name: &str) -> Option<&Skill> {
238        self.skills.iter().find(|s| s.name == name)
239    }
240
241    /// Get all user-invocable skills.
242    pub fn user_invocable(&self) -> Vec<&Skill> {
243        self.skills
244            .iter()
245            .filter(|s| s.metadata.user_invocable)
246            .collect()
247    }
248
249    /// Get all skills.
250    pub fn all(&self) -> &[Skill] {
251        &self.skills
252    }
253}
254
255/// Load a single skill file, parsing frontmatter and body.
256fn load_skill_file(path: &Path) -> Result<Skill, String> {
257    let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
258
259    // Derive skill name from path.
260    let name = path
261        .parent()
262        .and_then(|p| {
263            // If this is SKILL.md in a directory, use the directory name.
264            if path.file_name().is_some_and(|f| f == "SKILL.md") {
265                p.file_name().and_then(|n| n.to_str())
266            } else {
267                None
268            }
269        })
270        .or_else(|| path.file_stem().and_then(|s| s.to_str()))
271        .unwrap_or("unknown")
272        .to_string();
273
274    // Parse YAML frontmatter (between --- delimiters).
275    let (metadata, body) = parse_frontmatter(&content)?;
276
277    Ok(Skill {
278        name,
279        metadata,
280        body,
281        source: path.to_path_buf(),
282    })
283}
284
285/// Parse YAML frontmatter from markdown content.
286///
287/// Expects format:
288/// ```text
289/// ---
290/// key: value
291/// ---
292/// body content
293/// ```
294fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
295    let trimmed = content.trim_start();
296
297    if !trimmed.starts_with("---") {
298        // No frontmatter — entire content is the body.
299        return Ok((SkillMetadata::default(), content.to_string()));
300    }
301
302    // Find the closing ---.
303    let after_first = &trimmed[3..];
304    let closing = after_first
305        .find("\n---")
306        .ok_or("Frontmatter not closed (missing closing ---)")?;
307
308    let yaml = &after_first[..closing].trim();
309    let body = &after_first[closing + 4..].trim_start();
310
311    let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
312
313    Ok((metadata, body.to_string()))
314}
315
316/// Parse YAML using a simple key-value approach.
317/// (Avoids adding a full YAML parser dependency.)
318fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
319    // Build a JSON object from simple YAML key: value pairs.
320    let mut map = serde_json::Map::new();
321
322    for line in yaml.lines() {
323        let line = line.trim();
324        if line.is_empty() || line.starts_with('#') {
325            continue;
326        }
327        if let Some((key, value)) = line.split_once(':') {
328            let key = key.trim();
329            let value = value.trim().trim_matches('"').trim_matches('\'');
330
331            // Handle booleans.
332            let json_value = match value {
333                "true" => serde_json::Value::Bool(true),
334                "false" => serde_json::Value::Bool(false),
335                _ => serde_json::Value::String(value.to_string()),
336            };
337            map.insert(key.to_string(), json_value);
338        }
339    }
340
341    let json = serde_json::Value::Object(map);
342    serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
343}
344
345/// Get the user-level skills directory.
346fn user_skills_dir() -> Option<PathBuf> {
347    dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_parse_frontmatter() {
356        let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
357        let (meta, body) = parse_frontmatter(content).unwrap();
358        assert_eq!(meta.description, Some("Test skill".to_string()));
359        assert!(meta.user_invocable);
360        assert_eq!(body, "Do the thing.");
361    }
362
363    #[test]
364    fn test_parse_no_frontmatter() {
365        let content = "Just a prompt with no frontmatter.";
366        let (meta, body) = parse_frontmatter(content).unwrap();
367        assert!(meta.description.is_none());
368        assert_eq!(body, content);
369    }
370
371    #[test]
372    fn test_skill_expand() {
373        let skill = Skill {
374            name: "test".into(),
375            metadata: SkillMetadata::default(),
376            body: "Review {{arg}} carefully.".into(),
377            source: PathBuf::from("test.md"),
378        };
379        assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
380    }
381}