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