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///
20/// Skills are markdown files with YAML frontmatter. The body is a
21/// prompt template supporting `{{arg}}` substitution. Invoke via
22/// `/skill-name` in the REPL or programmatically via the Skill tool.
23#[derive(Debug, Clone)]
24pub struct Skill {
25    /// Skill name (derived from filename without extension).
26    pub name: String,
27    /// Metadata from frontmatter.
28    pub metadata: SkillMetadata,
29    /// The prompt template body.
30    pub body: String,
31    /// Source file path.
32    pub source: PathBuf,
33}
34
35/// Frontmatter metadata for a skill.
36#[derive(Debug, Clone, Default, Deserialize)]
37#[serde(default)]
38pub struct SkillMetadata {
39    /// What this skill does.
40    pub description: Option<String>,
41    /// When to invoke this skill.
42    #[serde(rename = "whenToUse")]
43    pub when_to_use: Option<String>,
44    /// Whether users can invoke this via `/name`.
45    #[serde(rename = "userInvocable")]
46    pub user_invocable: bool,
47    /// Whether to disable in non-interactive sessions.
48    #[serde(rename = "disableNonInteractive")]
49    pub disable_non_interactive: bool,
50    /// File patterns that trigger this skill suggestion.
51    pub paths: Option<Vec<String>>,
52}
53
54impl Skill {
55    /// Expand the skill body with argument substitution.
56    pub fn expand(&self, args: Option<&str>) -> String {
57        let mut body = self.body.clone();
58        if let Some(args) = args {
59            body = body.replace("{{arg}}", args);
60            body = body.replace("{{ arg }}", args);
61        }
62        body
63    }
64
65    /// Expand the skill body, stripping fenced shell blocks if disabled.
66    ///
67    /// When `disable_shell` is true, any fenced code block with a shell
68    /// language tag (```sh, ```bash, ```shell, ```zsh) is replaced with
69    /// a notice that shell execution is disabled.
70    pub fn expand_safe(&self, args: Option<&str>, disable_shell: bool) -> String {
71        let body = self.expand(args);
72        if !disable_shell {
73            return body;
74        }
75        strip_shell_blocks(&body)
76    }
77}
78
79/// Remove fenced shell code blocks from text.
80fn strip_shell_blocks(text: &str) -> String {
81    let mut result = String::with_capacity(text.len());
82    let mut lines = text.lines().peekable();
83
84    while let Some(line) = lines.next() {
85        if is_shell_fence(line) {
86            // Skip until closing fence.
87            result.push_str("[Shell execution disabled by security policy]\n");
88            for inner in lines.by_ref() {
89                if inner.trim_start().starts_with("```") {
90                    break;
91                }
92            }
93        } else {
94            result.push_str(line);
95            result.push('\n');
96        }
97    }
98
99    result
100}
101
102fn is_shell_fence(line: &str) -> bool {
103    let trimmed = line.trim_start();
104    trimmed.starts_with("```sh")
105        || trimmed.starts_with("```bash")
106        || trimmed.starts_with("```shell")
107        || trimmed.starts_with("```zsh")
108}
109
110/// Registry of loaded skills from bundled, project, and user directories.
111///
112/// Load with [`SkillRegistry::load_all`]. Skills are searched in order:
113/// project (`.agent/skills/`), user (`~/.config/agent-code/skills/`),
114/// then bundled. A project skill with the same name overrides a bundled one.
115pub struct SkillRegistry {
116    skills: Vec<Skill>,
117}
118
119impl SkillRegistry {
120    pub fn new() -> Self {
121        Self { skills: Vec::new() }
122    }
123
124    /// Load skills from all configured directories.
125    pub fn load_all(project_root: Option<&Path>) -> Self {
126        let mut registry = Self::new();
127
128        // Load from project-level skills directory.
129        if let Some(root) = project_root {
130            let project_skills = root.join(".agent").join("skills");
131            if project_skills.is_dir() {
132                registry.load_from_dir(&project_skills);
133            }
134        }
135
136        // Load from user-level skills directory.
137        if let Some(dir) = user_skills_dir()
138            && dir.is_dir()
139        {
140            registry.load_from_dir(&dir);
141        }
142
143        // Load bundled skills (shipped with the binary).
144        registry.load_bundled();
145
146        debug!("Loaded {} skills", registry.skills.len());
147        registry
148    }
149
150    /// Load built-in skills that ship with agent-code.
151    fn load_bundled(&mut self) {
152        let bundled = [
153            (
154                "commit",
155                "Create a well-crafted git commit",
156                true,
157                "Review the current git diff carefully. Create a commit with a clear, \
158                 concise message that explains WHY the change was made, not just WHAT changed. \
159                 Follow the repository's existing commit style. Stage specific files \
160                 (don't use git add -A). Never commit .env or credentials.",
161            ),
162            (
163                "review",
164                "Review code changes for bugs and issues",
165                true,
166                "Review the current git diff against the base branch. Look for: bugs, \
167                 security issues (injection, XSS, OWASP top 10), race conditions, \
168                 error handling gaps, performance problems (N+1 queries, missing indexes), \
169                 and code quality issues. Report findings with file:line references.",
170            ),
171            (
172                "test",
173                "Run tests and fix failures",
174                true,
175                "Run the project's test suite. If any tests fail, read the failing test \
176                 and the source code it tests. Identify the root cause. Fix the issue. \
177                 Run the tests again to verify the fix. Repeat until all tests pass.",
178            ),
179            (
180                "explain",
181                "Explain how a piece of code works",
182                true,
183                "Read the file or function the user is asking about. Explain what it does, \
184                 how it works, and why it's designed that way. Use clear language. \
185                 Reference specific line numbers. If there are non-obvious design decisions, \
186                 explain the tradeoffs.",
187            ),
188            (
189                "debug",
190                "Debug an error or unexpected behavior",
191                true,
192                "Investigate the error systematically. Read the error message and stack trace. \
193                 Find the relevant source code. Identify the root cause (don't guess). \
194                 Propose a fix with explanation. Apply the fix and verify it works.",
195            ),
196            (
197                "pr",
198                "Create a pull request",
199                true,
200                "Check git status and diff against the base branch. Analyze ALL commits \
201                 on this branch. Draft a PR title (under 70 chars) and body with a summary \
202                 section (bullet points) and a test plan. Push to remote and create the PR \
203                 using gh pr create. Return the PR URL.",
204            ),
205            (
206                "refactor",
207                "Refactor code for better quality",
208                true,
209                "Read the code the user wants refactored. Identify specific improvements: \
210                 extract functions, reduce duplication, simplify conditionals, improve naming, \
211                 add missing error handling. Make changes incrementally. Run tests after \
212                 each change to verify nothing broke.",
213            ),
214            (
215                "init",
216                "Initialize project configuration",
217                true,
218                "Create an AGENTS.md file in the project root with project context: \
219                 tech stack, architecture overview, coding conventions, test commands, \
220                 and important file locations. This helps the agent understand the project \
221                 in future sessions.",
222            ),
223            (
224                "security-review",
225                "Review code for security vulnerabilities",
226                true,
227                "Perform a security review of the current changes or specified files. \
228                 Check for: SQL injection (parameterized queries), XSS (output escaping), \
229                 command injection (shell argument safety), hardcoded secrets (API keys, \
230                 passwords, tokens), insecure deserialization, broken authentication, \
231                 path traversal, and SSRF. Verify input validation at system boundaries. \
232                 Report each finding with file:line, severity (critical/high/medium/low), \
233                 and a concrete fix.",
234            ),
235            (
236                "advisor",
237                "Analyze project architecture and suggest improvements",
238                true,
239                "Read the project structure, key entry points, and dependency manifest. \
240                 Evaluate: code organization (cohesion, coupling), dependency health \
241                 (outdated, unused, or vulnerable packages), test coverage gaps, error \
242                 handling patterns, and performance bottlenecks. Prioritize findings by \
243                 impact. For each suggestion, explain the current state, the risk of \
244                 inaction, and a specific next step.",
245            ),
246            (
247                "bughunter",
248                "Systematically search for bugs",
249                true,
250                "Hunt for bugs methodically. Run the test suite and analyze failures. \
251                 Read error handling paths and look for: unchecked return values, \
252                 off-by-one errors, null/nil/undefined dereferences, resource leaks \
253                 (files, connections, locks), race conditions, integer overflow, and \
254                 boundary conditions. For each bug found, provide: file:line, a minimal \
255                 reproduction, the root cause, and a fix. Verify fixes don't break \
256                 existing tests.",
257            ),
258            (
259                "plan",
260                "Create a detailed implementation plan",
261                true,
262                "Explore the codebase to understand the relevant architecture before \
263                 planning. Identify all files that need changes. For each change, specify: \
264                 the file path, what to modify, and why. Note dependencies between changes \
265                 (what must happen first). Flag risks: breaking changes, migration needs, \
266                 performance implications. Estimate scope (small/medium/large per file). \
267                 Present the plan as an ordered checklist the user can approve before \
268                 implementation begins.",
269            ),
270        ];
271
272        for (name, description, user_invocable, body) in bundled {
273            // Don't override user-defined skills with the same name.
274            if self.skills.iter().any(|s| s.name == name) {
275                continue;
276            }
277            self.skills.push(Skill {
278                name: name.to_string(),
279                metadata: SkillMetadata {
280                    description: Some(description.to_string()),
281                    when_to_use: None,
282                    user_invocable,
283                    disable_non_interactive: false,
284                    paths: None,
285                },
286                body: body.to_string(),
287                source: std::path::PathBuf::new(),
288            });
289        }
290    }
291
292    /// Load skills from a single directory.
293    fn load_from_dir(&mut self, dir: &Path) {
294        let entries = match std::fs::read_dir(dir) {
295            Ok(entries) => entries,
296            Err(e) => {
297                warn!("Failed to read skills directory {}: {e}", dir.display());
298                return;
299            }
300        };
301
302        for entry in entries.flatten() {
303            let path = entry.path();
304
305            // Skills can be single .md files or directories with a SKILL.md.
306            let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
307                path.clone()
308            } else if path.is_dir() {
309                let skill_md = path.join("SKILL.md");
310                if skill_md.exists() {
311                    skill_md
312                } else {
313                    continue;
314                }
315            } else {
316                continue;
317            };
318
319            match load_skill_file(&skill_path) {
320                Ok(skill) => {
321                    debug!(
322                        "Loaded skill '{}' from {}",
323                        skill.name,
324                        skill_path.display()
325                    );
326                    self.skills.push(skill);
327                }
328                Err(e) => {
329                    warn!("Failed to load skill {}: {e}", skill_path.display());
330                }
331            }
332        }
333    }
334
335    /// Find a skill by name.
336    pub fn find(&self, name: &str) -> Option<&Skill> {
337        self.skills.iter().find(|s| s.name == name)
338    }
339
340    /// Get all user-invocable skills.
341    pub fn user_invocable(&self) -> Vec<&Skill> {
342        self.skills
343            .iter()
344            .filter(|s| s.metadata.user_invocable)
345            .collect()
346    }
347
348    /// Get all skills.
349    pub fn all(&self) -> &[Skill] {
350        &self.skills
351    }
352}
353
354/// Load a single skill file, parsing frontmatter and body.
355fn load_skill_file(path: &Path) -> Result<Skill, String> {
356    let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
357
358    // Derive skill name from path.
359    let name = path
360        .parent()
361        .and_then(|p| {
362            // If this is SKILL.md in a directory, use the directory name.
363            if path.file_name().is_some_and(|f| f == "SKILL.md") {
364                p.file_name().and_then(|n| n.to_str())
365            } else {
366                None
367            }
368        })
369        .or_else(|| path.file_stem().and_then(|s| s.to_str()))
370        .unwrap_or("unknown")
371        .to_string();
372
373    // Parse YAML frontmatter (between --- delimiters).
374    let (metadata, body) = parse_frontmatter(&content)?;
375
376    Ok(Skill {
377        name,
378        metadata,
379        body,
380        source: path.to_path_buf(),
381    })
382}
383
384/// Parse YAML frontmatter from markdown content.
385///
386/// Expects format:
387/// ```text
388/// ---
389/// key: value
390/// ---
391/// body content
392/// ```
393fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
394    let trimmed = content.trim_start();
395
396    if !trimmed.starts_with("---") {
397        // No frontmatter — entire content is the body.
398        return Ok((SkillMetadata::default(), content.to_string()));
399    }
400
401    // Find the closing ---.
402    let after_first = &trimmed[3..];
403    let closing = after_first
404        .find("\n---")
405        .ok_or("Frontmatter not closed (missing closing ---)")?;
406
407    let yaml = &after_first[..closing].trim();
408    let body = &after_first[closing + 4..].trim_start();
409
410    let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
411
412    Ok((metadata, body.to_string()))
413}
414
415/// Parse YAML using a simple key-value approach.
416/// (Avoids adding a full YAML parser dependency.)
417fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
418    // Build a JSON object from simple YAML key: value pairs.
419    let mut map = serde_json::Map::new();
420
421    for line in yaml.lines() {
422        let line = line.trim();
423        if line.is_empty() || line.starts_with('#') {
424            continue;
425        }
426        if let Some((key, value)) = line.split_once(':') {
427            let key = key.trim();
428            let value = value.trim().trim_matches('"').trim_matches('\'');
429
430            // Handle booleans.
431            let json_value = match value {
432                "true" => serde_json::Value::Bool(true),
433                "false" => serde_json::Value::Bool(false),
434                _ => serde_json::Value::String(value.to_string()),
435            };
436            map.insert(key.to_string(), json_value);
437        }
438    }
439
440    let json = serde_json::Value::Object(map);
441    serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
442}
443
444/// Get the user-level skills directory.
445fn user_skills_dir() -> Option<PathBuf> {
446    dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_parse_frontmatter() {
455        let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
456        let (meta, body) = parse_frontmatter(content).unwrap();
457        assert_eq!(meta.description, Some("Test skill".to_string()));
458        assert!(meta.user_invocable);
459        assert_eq!(body, "Do the thing.");
460    }
461
462    #[test]
463    fn test_parse_no_frontmatter() {
464        let content = "Just a prompt with no frontmatter.";
465        let (meta, body) = parse_frontmatter(content).unwrap();
466        assert!(meta.description.is_none());
467        assert_eq!(body, content);
468    }
469
470    #[test]
471    fn test_skill_expand() {
472        let skill = Skill {
473            name: "test".into(),
474            metadata: SkillMetadata::default(),
475            body: "Review {{arg}} carefully.".into(),
476            source: PathBuf::from("test.md"),
477        };
478        assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
479    }
480
481    #[test]
482    fn test_expand_safe_allows_shell_by_default() {
483        let skill = Skill {
484            name: "deploy".into(),
485            metadata: SkillMetadata::default(),
486            body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
487            source: PathBuf::from("deploy.md"),
488        };
489        let result = skill.expand_safe(None, false);
490        assert!(result.contains("cargo build"));
491    }
492
493    #[test]
494    fn test_expand_safe_strips_shell_when_disabled() {
495        let skill = Skill {
496            name: "deploy".into(),
497            metadata: SkillMetadata::default(),
498            body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
499            source: PathBuf::from("deploy.md"),
500        };
501        let result = skill.expand_safe(None, true);
502        assert!(!result.contains("cargo build"));
503        assert!(result.contains("Shell execution disabled"));
504        assert!(result.contains("Done."));
505    }
506
507    #[test]
508    fn test_strip_shell_blocks_multiple_langs() {
509        let text = "a\n```sh\nls\n```\nb\n```zsh\necho hi\n```\nc\n";
510        let result = strip_shell_blocks(text);
511        assert!(!result.contains("ls"));
512        assert!(!result.contains("echo hi"));
513        assert!(result.contains("a\n"));
514        assert!(result.contains("b\n"));
515        assert!(result.contains("c\n"));
516    }
517
518    #[test]
519    fn test_strip_shell_blocks_preserves_non_shell() {
520        let text = "```rust\nfn main() {}\n```\n";
521        let result = strip_shell_blocks(text);
522        assert!(result.contains("fn main()"));
523    }
524}