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                "changelog",
274                "Update CHANGELOG.md from the current diff",
275                true,
276                "Read CHANGELOG.md to learn the project's format (Keep a Changelog is \
277                 common). Inspect the current git diff and recent commits since the last \
278                 release entry. Classify changes into Added / Changed / Fixed / Removed / \
279                 Security. Draft entries that describe user-visible impact, not internal \
280                 refactors. Insert them under an Unreleased section, preserving existing \
281                 formatting. Do not invent changes that aren't in the diff.",
282            ),
283            (
284                "release",
285                "Orchestrate a version release",
286                true,
287                "Follow the project's RELEASING.md if present. Determine the next version \
288                 (patch / minor / major) from the nature of the changes since the last tag. \
289                 Bump version numbers in all manifest files (Cargo.toml, package.json, \
290                 pyproject.toml, etc.) consistently. Stamp CHANGELOG.md with the new version \
291                 and today's date. Run the full test and lint gate before tagging. Create \
292                 the release branch, open a PR, and on merge create the git tag. Never push \
293                 tags without user confirmation.",
294            ),
295            (
296                "benchmark",
297                "Run benchmarks and compare results",
298                true,
299                "Locate the project's benchmark suite (cargo bench, pytest-benchmark, \
300                 criterion, etc.). Run it on the current branch and capture results. If a \
301                 baseline exists (from main or a stored snapshot), compare and report \
302                 regressions and improvements as percentages. Flag any metric that \
303                 regressed more than 5% with file:line context for the likely cause. \
304                 Do not claim a speedup without a baseline to compare against.",
305            ),
306            (
307                "coverage",
308                "Produce a test coverage report and narrative",
309                true,
310                "Run the project's coverage tool (cargo llvm-cov, pytest --cov, c8, etc.). \
311                 Summarize overall coverage and identify the lowest-covered modules. For \
312                 each gap, classify: (a) untested happy path, (b) untested error path, \
313                 (c) untestable boilerplate. Recommend 3-5 high-value tests to add, with \
314                 specific function names. Do not propose tests for generated code or \
315                 trivial getters.",
316            ),
317            (
318                "migrate",
319                "Analyze a dependency upgrade or breaking API migration",
320                true,
321                "Given a target dependency version or API change, read the dependency's \
322                 release notes or migration guide. Grep the codebase for every call site \
323                 affected by the change. Produce a migration plan listing each call site \
324                 with file:line, the old pattern, the new pattern, and whether the change \
325                 is mechanical or requires judgement. Flag any ambiguous call sites for \
326                 human review. Do not perform the migration without an approved plan.",
327            ),
328            (
329                "docs",
330                "Sync documentation with code changes",
331                true,
332                "Inspect the current diff. For every public API that changed (function \
333                 signatures, config keys, CLI flags, tool contracts), find the corresponding \
334                 documentation (rustdoc comments, README sections, docs/ pages, Mintlify \
335                 mdx files) and update it to match. Flag any documented behavior that the \
336                 diff silently breaks. Do not add documentation for code that isn't part \
337                 of the public surface.",
338            ),
339        ];
340
341        for (name, description, user_invocable, body) in bundled {
342            // Don't override user-defined skills with the same name.
343            if self.skills.iter().any(|s| s.name == name) {
344                continue;
345            }
346            self.skills.push(Skill {
347                name: name.to_string(),
348                metadata: SkillMetadata {
349                    description: Some(description.to_string()),
350                    when_to_use: None,
351                    user_invocable,
352                    disable_non_interactive: false,
353                    paths: None,
354                },
355                body: body.to_string(),
356                source: std::path::PathBuf::new(),
357            });
358        }
359    }
360
361    /// Load skills from a single directory.
362    fn load_from_dir(&mut self, dir: &Path) {
363        let entries = match std::fs::read_dir(dir) {
364            Ok(entries) => entries,
365            Err(e) => {
366                warn!("Failed to read skills directory {}: {e}", dir.display());
367                return;
368            }
369        };
370
371        for entry in entries.flatten() {
372            let path = entry.path();
373
374            // Skills can be single .md files or directories with a SKILL.md.
375            let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
376                path.clone()
377            } else if path.is_dir() {
378                let skill_md = path.join("SKILL.md");
379                if skill_md.exists() {
380                    skill_md
381                } else {
382                    continue;
383                }
384            } else {
385                continue;
386            };
387
388            match load_skill_file(&skill_path) {
389                Ok(skill) => {
390                    debug!(
391                        "Loaded skill '{}' from {}",
392                        skill.name,
393                        skill_path.display()
394                    );
395                    self.skills.push(skill);
396                }
397                Err(e) => {
398                    warn!("Failed to load skill {}: {e}", skill_path.display());
399                }
400            }
401        }
402    }
403
404    /// Find a skill by name.
405    pub fn find(&self, name: &str) -> Option<&Skill> {
406        self.skills.iter().find(|s| s.name == name)
407    }
408
409    /// Get all user-invocable skills.
410    pub fn user_invocable(&self) -> Vec<&Skill> {
411        self.skills
412            .iter()
413            .filter(|s| s.metadata.user_invocable)
414            .collect()
415    }
416
417    /// Get all skills.
418    pub fn all(&self) -> &[Skill] {
419        &self.skills
420    }
421}
422
423/// Load a single skill file, parsing frontmatter and body.
424fn load_skill_file(path: &Path) -> Result<Skill, String> {
425    let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
426
427    // Derive skill name from path.
428    let name = path
429        .parent()
430        .and_then(|p| {
431            // If this is SKILL.md in a directory, use the directory name.
432            if path.file_name().is_some_and(|f| f == "SKILL.md") {
433                p.file_name().and_then(|n| n.to_str())
434            } else {
435                None
436            }
437        })
438        .or_else(|| path.file_stem().and_then(|s| s.to_str()))
439        .unwrap_or("unknown")
440        .to_string();
441
442    // Parse YAML frontmatter (between --- delimiters).
443    let (metadata, body) = parse_frontmatter(&content)?;
444
445    Ok(Skill {
446        name,
447        metadata,
448        body,
449        source: path.to_path_buf(),
450    })
451}
452
453/// Parse YAML frontmatter from markdown content.
454///
455/// Expects format:
456/// ```text
457/// ---
458/// key: value
459/// ---
460/// body content
461/// ```
462fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
463    let trimmed = content.trim_start();
464
465    if !trimmed.starts_with("---") {
466        // No frontmatter — entire content is the body.
467        return Ok((SkillMetadata::default(), content.to_string()));
468    }
469
470    // Find the closing ---.
471    let after_first = &trimmed[3..];
472    let closing = after_first
473        .find("\n---")
474        .ok_or("Frontmatter not closed (missing closing ---)")?;
475
476    let yaml = &after_first[..closing].trim();
477    let body = &after_first[closing + 4..].trim_start();
478
479    let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
480
481    Ok((metadata, body.to_string()))
482}
483
484/// Parse YAML using a simple key-value approach.
485/// (Avoids adding a full YAML parser dependency.)
486fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
487    // Build a JSON object from simple YAML key: value pairs.
488    let mut map = serde_json::Map::new();
489
490    for line in yaml.lines() {
491        let line = line.trim();
492        if line.is_empty() || line.starts_with('#') {
493            continue;
494        }
495        if let Some((key, value)) = line.split_once(':') {
496            let key = key.trim();
497            let value = value.trim().trim_matches('"').trim_matches('\'');
498
499            // Handle booleans.
500            let json_value = match value {
501                "true" => serde_json::Value::Bool(true),
502                "false" => serde_json::Value::Bool(false),
503                _ => serde_json::Value::String(value.to_string()),
504            };
505            map.insert(key.to_string(), json_value);
506        }
507    }
508
509    let json = serde_json::Value::Object(map);
510    serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
511}
512
513/// Get the user-level skills directory.
514fn user_skills_dir() -> Option<PathBuf> {
515    dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn test_parse_frontmatter() {
524        let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
525        let (meta, body) = parse_frontmatter(content).unwrap();
526        assert_eq!(meta.description, Some("Test skill".to_string()));
527        assert!(meta.user_invocable);
528        assert_eq!(body, "Do the thing.");
529    }
530
531    #[test]
532    fn test_parse_no_frontmatter() {
533        let content = "Just a prompt with no frontmatter.";
534        let (meta, body) = parse_frontmatter(content).unwrap();
535        assert!(meta.description.is_none());
536        assert_eq!(body, content);
537    }
538
539    #[test]
540    fn test_skill_expand() {
541        let skill = Skill {
542            name: "test".into(),
543            metadata: SkillMetadata::default(),
544            body: "Review {{arg}} carefully.".into(),
545            source: PathBuf::from("test.md"),
546        };
547        assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
548    }
549
550    #[test]
551    fn test_expand_safe_allows_shell_by_default() {
552        let skill = Skill {
553            name: "deploy".into(),
554            metadata: SkillMetadata::default(),
555            body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
556            source: PathBuf::from("deploy.md"),
557        };
558        let result = skill.expand_safe(None, false);
559        assert!(result.contains("cargo build"));
560    }
561
562    #[test]
563    fn test_expand_safe_strips_shell_when_disabled() {
564        let skill = Skill {
565            name: "deploy".into(),
566            metadata: SkillMetadata::default(),
567            body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
568            source: PathBuf::from("deploy.md"),
569        };
570        let result = skill.expand_safe(None, true);
571        assert!(!result.contains("cargo build"));
572        assert!(result.contains("Shell execution disabled"));
573        assert!(result.contains("Done."));
574    }
575
576    #[test]
577    fn test_strip_shell_blocks_multiple_langs() {
578        let text = "a\n```sh\nls\n```\nb\n```zsh\necho hi\n```\nc\n";
579        let result = strip_shell_blocks(text);
580        assert!(!result.contains("ls"));
581        assert!(!result.contains("echo hi"));
582        assert!(result.contains("a\n"));
583        assert!(result.contains("b\n"));
584        assert!(result.contains("c\n"));
585    }
586
587    #[test]
588    fn test_strip_shell_blocks_preserves_non_shell() {
589        let text = "```rust\nfn main() {}\n```\n";
590        let result = strip_shell_blocks(text);
591        assert!(result.contains("fn main()"));
592    }
593}