Skip to main content

batuta/agent/
skill.rs

1//! User-invocable + auto-loadable skill definitions (Claude-Code parity).
2//!
3//! PMAT-CODE-SKILLS-001: Claude Code loads `.claude/skills/<name>/SKILL.md`
4//! (and `~/.claude/skills/` at user scope). Each `SKILL.md` carries
5//! markdown frontmatter declaring a `name`, `description`, optional
6//! `when_to_use` heuristic, and optional `allowed-tools` allowlist. The
7//! body is treated as the skill's instructions — injected into the
8//! model's system context when the skill is invoked either
9//! (a) explicitly by the user via `/<skill-name>` in the REPL, or
10//! (b) automatically by matching `when_to_use` against the pending turn.
11//!
12//! `apr code` mirrors the same pattern at `.apr/skills/` with identical
13//! frontmatter schema so Claude-native projects can share skills with
14//! `apr code` unchanged.
15//!
16//! # Example — `.apr/skills/rust-test.md`
17//!
18//! ```markdown
19//! ---
20//! name: rust-test
21//! description: Run the Rust test suite and interpret failures
22//! when_to_use: User asks about failing cargo tests
23//! allowed-tools: shell, grep
24//! ---
25//!
26//! Run `cargo test -p <crate> --lib <pattern>` from the repo root.
27//! When a test fails, read the span around the assertion and report
28//! the minimal reproduction.
29//! ```
30//!
31//! After discovery, `registry.resolve("rust-test")` returns the
32//! [`Skill`] whose `instructions` field can be appended to the system
33//! prompt of the active agent turn.
34
35use std::collections::BTreeMap;
36use std::fs;
37use std::path::{Path, PathBuf};
38
39/// Default project-scope skills directory (relative to cwd).
40pub const DEFAULT_PROJECT_DIR: &str = ".apr/skills";
41
42/// Legacy / cross-compat directory — share skills tree with Claude Code.
43pub const CLAUDE_COMPAT_DIR: &str = ".claude/skills";
44
45/// Errors that can arise while parsing or loading a skill file.
46#[derive(Debug)]
47pub enum SkillError {
48    /// No `---`-fenced frontmatter at the top of the file.
49    MissingFrontmatter,
50    /// Required field `name` absent or empty.
51    MissingName,
52    /// Required field `description` absent or empty.
53    MissingDescription,
54    /// Body (instructions) after frontmatter was empty.
55    EmptyBody,
56    /// Filesystem error.
57    Io(String),
58}
59
60impl std::fmt::Display for SkillError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::MissingFrontmatter => write!(f, "missing `---`-fenced frontmatter"),
64            Self::MissingName => write!(f, "required field `name` missing or empty"),
65            Self::MissingDescription => {
66                write!(f, "required field `description` missing or empty")
67            }
68            Self::EmptyBody => write!(f, "body (instructions) is empty"),
69            Self::Io(msg) => write!(f, "I/O error: {msg}"),
70        }
71    }
72}
73
74impl std::error::Error for SkillError {}
75
76/// A parsed Skill definition.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Skill {
79    /// Identifier used by `/<name>` slash invocation.
80    pub name: String,
81    /// One-line purpose, shown in UI listings.
82    pub description: String,
83    /// Heuristic the auto-loader matches against the current turn.
84    pub when_to_use: Option<String>,
85    /// Tool-allowlist enforcement when the skill runs. Empty → inherit.
86    pub allowed_tools: Vec<String>,
87    /// Markdown body — the actual skill text injected into context.
88    pub instructions: String,
89}
90
91/// Registry of discovered skills, keyed by `name` (alphabetical iteration).
92#[derive(Debug, Clone, Default)]
93pub struct SkillRegistry {
94    by_name: BTreeMap<String, Skill>,
95}
96
97impl SkillRegistry {
98    /// Empty registry.
99    pub fn new() -> Self {
100        Self { by_name: BTreeMap::new() }
101    }
102
103    /// Register (or replace) a skill.
104    pub fn register(&mut self, skill: Skill) {
105        self.by_name.insert(skill.name.clone(), skill);
106    }
107
108    /// Resolve by skill name.
109    pub fn resolve(&self, name: &str) -> Option<&Skill> {
110        self.by_name.get(name)
111    }
112
113    /// Total number of registered skills.
114    pub fn len(&self) -> usize {
115        self.by_name.len()
116    }
117
118    /// True when no skills are registered.
119    pub fn is_empty(&self) -> bool {
120        self.by_name.is_empty()
121    }
122
123    /// Alphabetical list of registered skill names.
124    pub fn names(&self) -> Vec<String> {
125        self.by_name.keys().cloned().collect()
126    }
127
128    /// Find the first skill whose `when_to_use` heuristic lexically
129    /// overlaps with the given turn. A skill matches when **at least
130    /// two** whitespace-separated tokens of length ≥ 4 in its
131    /// `when_to_use` appear (case-insensitive substring) in the turn.
132    /// Two-token threshold keeps single-word false positives
133    /// (e.g. "about", "tests") from spuriously triggering. Callers
134    /// inject the resolved skill's `instructions` into the active
135    /// system prompt.
136    pub fn auto_match(&self, turn: &str) -> Option<&Skill> {
137        let hay = turn.to_ascii_lowercase();
138        self.by_name.values().find(|s| {
139            let Some(needle) = s.when_to_use.as_ref() else {
140                return false;
141            };
142            let hits = needle
143                .split_whitespace()
144                .filter(|t| t.len() >= 4)
145                .map(|t| t.to_ascii_lowercase())
146                .filter(|t| hay.contains(t))
147                .count();
148            hits >= 2
149        })
150    }
151}
152
153/// Parse a `SKILL.md` document into a [`Skill`].
154///
155/// Frontmatter format: leading `---\n` (or `---\r\n`), then lines of
156/// `key: value`, then `---\n`, then the body (skill instructions).
157/// `allowed-tools` (or `allowed_tools`) may be comma- or space-separated.
158pub fn parse_skill_md(source: &str) -> Result<Skill, SkillError> {
159    let trimmed = source.trim_start_matches('\u{feff}');
160    let rest = trimmed
161        .strip_prefix("---\n")
162        .or_else(|| trimmed.strip_prefix("---\r\n"))
163        .ok_or(SkillError::MissingFrontmatter)?;
164
165    let (front, body) = split_at_fence(rest).ok_or(SkillError::MissingFrontmatter)?;
166
167    let mut name = String::new();
168    let mut description = String::new();
169    let mut when_to_use: Option<String> = None;
170    let mut allowed_tools: Vec<String> = Vec::new();
171
172    for line in front.lines() {
173        let line = line.trim();
174        if line.is_empty() || line.starts_with('#') {
175            continue;
176        }
177        let Some((key, value)) = line.split_once(':') else {
178            continue;
179        };
180        let key = key.trim();
181        let value = value.trim().trim_matches('"').trim_matches('\'');
182        match key {
183            "name" => name = value.to_string(),
184            "description" => description = value.to_string(),
185            "when_to_use" | "when-to-use" => {
186                if !value.is_empty() {
187                    when_to_use = Some(value.to_string());
188                }
189            }
190            "allowed-tools" | "allowed_tools" => {
191                allowed_tools = value
192                    .split([',', ' '])
193                    .map(str::trim)
194                    .filter(|s| !s.is_empty())
195                    .map(str::to_string)
196                    .collect();
197            }
198            // Claude-compat: `context: fork` etc. silently tolerated.
199            _ => {}
200        }
201    }
202
203    if name.is_empty() {
204        return Err(SkillError::MissingName);
205    }
206    if description.is_empty() {
207        return Err(SkillError::MissingDescription);
208    }
209    let instructions = body.trim().to_string();
210    if instructions.is_empty() {
211        return Err(SkillError::EmptyBody);
212    }
213
214    Ok(Skill { name, description, when_to_use, allowed_tools, instructions })
215}
216
217/// Scan a directory for skill `.md` files. Both layouts supported:
218///   * `dir/<name>.md` — flat file per skill.
219///   * `dir/<name>/SKILL.md` — subdirectory per skill (Claude default).
220///
221/// Silently skips files that fail to parse (malformed skill should not
222/// disable all skills).
223pub fn load_skills_from(dir: &Path) -> Vec<Skill> {
224    let mut out = Vec::new();
225    let Ok(entries) = fs::read_dir(dir) else {
226        return out;
227    };
228
229    for entry in entries.flatten() {
230        let path = entry.path();
231        if path.is_file() {
232            if path.extension().is_some_and(|e| e == "md") {
233                if let Some(s) = try_parse(&path) {
234                    out.push(s);
235                }
236            }
237        } else if path.is_dir() {
238            let skill_md = path.join("SKILL.md");
239            if skill_md.is_file() {
240                if let Some(s) = try_parse(&skill_md) {
241                    out.push(s);
242                }
243            }
244        }
245    }
246
247    out
248}
249
250/// Discover skills from the standard locations: user scope
251/// (`~/.config/apr/skills/`) then project scope (`.apr/skills/` or,
252/// as fallback, `.claude/skills/` — cross-compat). Project scope wins
253/// on name collision.
254pub fn discover_skills(cwd: &Path) -> Vec<Skill> {
255    let mut merged: Vec<Skill> = Vec::new();
256
257    if let Some(u) = user_skills_dir().as_deref() {
258        merged.extend(load_skills_from(u));
259    }
260
261    for rel in [DEFAULT_PROJECT_DIR, CLAUDE_COMPAT_DIR] {
262        let project_dir = cwd.join(rel);
263        if project_dir.is_dir() {
264            let project_skills = load_skills_from(&project_dir);
265            for s in project_skills {
266                merged.retain(|existing| existing.name != s.name);
267                merged.push(s);
268            }
269            break;
270        }
271    }
272
273    merged
274}
275
276/// Register all discovered skills into the given [`SkillRegistry`].
277/// Returns the number of skills added.
278pub fn register_discovered_skills_into(registry: &mut SkillRegistry, cwd: &Path) -> usize {
279    let skills = discover_skills(cwd);
280    let n = skills.len();
281    for s in skills {
282        registry.register(s);
283    }
284    n
285}
286
287fn try_parse(path: &Path) -> Option<Skill> {
288    let src = fs::read_to_string(path).ok()?;
289    parse_skill_md(&src).ok()
290}
291
292fn split_at_fence(after_open: &str) -> Option<(&str, &str)> {
293    for line_start in line_starts(after_open) {
294        let rest_at = &after_open[line_start..];
295        if let Some(line_end) = rest_at.find('\n') {
296            let line = &rest_at[..line_end];
297            if line.trim_end_matches('\r') == "---" {
298                let body_start = line_start + line_end + 1;
299                return Some((&after_open[..line_start], &after_open[body_start..]));
300            }
301        } else if rest_at.trim_end_matches('\r') == "---" {
302            return Some((&after_open[..line_start], ""));
303        }
304    }
305    None
306}
307
308fn line_starts(s: &str) -> impl Iterator<Item = usize> + '_ {
309    std::iter::once(0usize).chain(s.match_indices('\n').map(|(pos, _)| pos + 1))
310}
311
312fn user_skills_dir() -> Option<PathBuf> {
313    let home = std::env::var_os("HOME")?;
314    let candidate = PathBuf::from(home).join(".config").join("apr").join("skills");
315    candidate.is_dir().then_some(candidate)
316}
317
318#[cfg(test)]
319mod tests;