use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT_PROJECT_DIR: &str = ".apr/skills";
pub const CLAUDE_COMPAT_DIR: &str = ".claude/skills";
#[derive(Debug)]
pub enum SkillError {
MissingFrontmatter,
MissingName,
MissingDescription,
EmptyBody,
Io(String),
}
impl std::fmt::Display for SkillError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingFrontmatter => write!(f, "missing `---`-fenced frontmatter"),
Self::MissingName => write!(f, "required field `name` missing or empty"),
Self::MissingDescription => {
write!(f, "required field `description` missing or empty")
}
Self::EmptyBody => write!(f, "body (instructions) is empty"),
Self::Io(msg) => write!(f, "I/O error: {msg}"),
}
}
}
impl std::error::Error for SkillError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
pub name: String,
pub description: String,
pub when_to_use: Option<String>,
pub allowed_tools: Vec<String>,
pub instructions: String,
}
#[derive(Debug, Clone, Default)]
pub struct SkillRegistry {
by_name: BTreeMap<String, Skill>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self { by_name: BTreeMap::new() }
}
pub fn register(&mut self, skill: Skill) {
self.by_name.insert(skill.name.clone(), skill);
}
pub fn resolve(&self, name: &str) -> Option<&Skill> {
self.by_name.get(name)
}
pub fn len(&self) -> usize {
self.by_name.len()
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
pub fn names(&self) -> Vec<String> {
self.by_name.keys().cloned().collect()
}
pub fn auto_match(&self, turn: &str) -> Option<&Skill> {
let hay = turn.to_ascii_lowercase();
self.by_name.values().find(|s| {
let Some(needle) = s.when_to_use.as_ref() else {
return false;
};
let hits = needle
.split_whitespace()
.filter(|t| t.len() >= 4)
.map(|t| t.to_ascii_lowercase())
.filter(|t| hay.contains(t))
.count();
hits >= 2
})
}
}
pub fn parse_skill_md(source: &str) -> Result<Skill, SkillError> {
let trimmed = source.trim_start_matches('\u{feff}');
let rest = trimmed
.strip_prefix("---\n")
.or_else(|| trimmed.strip_prefix("---\r\n"))
.ok_or(SkillError::MissingFrontmatter)?;
let (front, body) = split_at_fence(rest).ok_or(SkillError::MissingFrontmatter)?;
let mut name = String::new();
let mut description = String::new();
let mut when_to_use: Option<String> = None;
let mut allowed_tools: Vec<String> = Vec::new();
for line in front.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
match key {
"name" => name = value.to_string(),
"description" => description = value.to_string(),
"when_to_use" | "when-to-use" => {
if !value.is_empty() {
when_to_use = Some(value.to_string());
}
}
"allowed-tools" | "allowed_tools" => {
allowed_tools = value
.split([',', ' '])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
_ => {}
}
}
if name.is_empty() {
return Err(SkillError::MissingName);
}
if description.is_empty() {
return Err(SkillError::MissingDescription);
}
let instructions = body.trim().to_string();
if instructions.is_empty() {
return Err(SkillError::EmptyBody);
}
Ok(Skill { name, description, when_to_use, allowed_tools, instructions })
}
pub fn load_skills_from(dir: &Path) -> Vec<Skill> {
let mut out = Vec::new();
let Ok(entries) = fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if path.extension().is_some_and(|e| e == "md") {
if let Some(s) = try_parse(&path) {
out.push(s);
}
}
} else if path.is_dir() {
let skill_md = path.join("SKILL.md");
if skill_md.is_file() {
if let Some(s) = try_parse(&skill_md) {
out.push(s);
}
}
}
}
out
}
pub fn discover_skills(cwd: &Path) -> Vec<Skill> {
let mut merged: Vec<Skill> = Vec::new();
if let Some(u) = user_skills_dir().as_deref() {
merged.extend(load_skills_from(u));
}
for rel in [DEFAULT_PROJECT_DIR, CLAUDE_COMPAT_DIR] {
let project_dir = cwd.join(rel);
if project_dir.is_dir() {
let project_skills = load_skills_from(&project_dir);
for s in project_skills {
merged.retain(|existing| existing.name != s.name);
merged.push(s);
}
break;
}
}
merged
}
pub fn register_discovered_skills_into(registry: &mut SkillRegistry, cwd: &Path) -> usize {
let skills = discover_skills(cwd);
let n = skills.len();
for s in skills {
registry.register(s);
}
n
}
fn try_parse(path: &Path) -> Option<Skill> {
let src = fs::read_to_string(path).ok()?;
parse_skill_md(&src).ok()
}
fn split_at_fence(after_open: &str) -> Option<(&str, &str)> {
for line_start in line_starts(after_open) {
let rest_at = &after_open[line_start..];
if let Some(line_end) = rest_at.find('\n') {
let line = &rest_at[..line_end];
if line.trim_end_matches('\r') == "---" {
let body_start = line_start + line_end + 1;
return Some((&after_open[..line_start], &after_open[body_start..]));
}
} else if rest_at.trim_end_matches('\r') == "---" {
return Some((&after_open[..line_start], ""));
}
}
None
}
fn line_starts(s: &str) -> impl Iterator<Item = usize> + '_ {
std::iter::once(0usize).chain(s.match_indices('\n').map(|(pos, _)| pos + 1))
}
fn user_skills_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let candidate = PathBuf::from(home).join(".config").join("apr").join("skills");
candidate.is_dir().then_some(candidate)
}
#[cfg(test)]
mod tests;