1use std::collections::BTreeMap;
36use std::fs;
37use std::path::{Path, PathBuf};
38
39pub const DEFAULT_PROJECT_DIR: &str = ".apr/skills";
41
42pub const CLAUDE_COMPAT_DIR: &str = ".claude/skills";
44
45#[derive(Debug)]
47pub enum SkillError {
48 MissingFrontmatter,
50 MissingName,
52 MissingDescription,
54 EmptyBody,
56 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#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Skill {
79 pub name: String,
81 pub description: String,
83 pub when_to_use: Option<String>,
85 pub allowed_tools: Vec<String>,
87 pub instructions: String,
89}
90
91#[derive(Debug, Clone, Default)]
93pub struct SkillRegistry {
94 by_name: BTreeMap<String, Skill>,
95}
96
97impl SkillRegistry {
98 pub fn new() -> Self {
100 Self { by_name: BTreeMap::new() }
101 }
102
103 pub fn register(&mut self, skill: Skill) {
105 self.by_name.insert(skill.name.clone(), skill);
106 }
107
108 pub fn resolve(&self, name: &str) -> Option<&Skill> {
110 self.by_name.get(name)
111 }
112
113 pub fn len(&self) -> usize {
115 self.by_name.len()
116 }
117
118 pub fn is_empty(&self) -> bool {
120 self.by_name.is_empty()
121 }
122
123 pub fn names(&self) -> Vec<String> {
125 self.by_name.keys().cloned().collect()
126 }
127
128 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
153pub 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 _ => {}
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
217pub 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
250pub 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
276pub 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;