use std::path::PathBuf;
const BUILTIN_SKILLS: &[(&str, &str)] = &[
(
"cost-estimate",
include_str!("../docs/reference/templates/skills/cost-estimate/SKILL.md"),
),
(
"security-audit",
include_str!("../docs/reference/templates/skills/security-audit/SKILL.md"),
),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource {
Builtin,
User,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub slash_name: String,
pub description: String,
pub body: String,
pub source: SkillSource,
}
impl Skill {
pub fn parse(name: &str, raw: &str, source: SkillSource) -> Result<Self, String> {
let raw = raw.strip_prefix('\u{FEFF}').unwrap_or(raw);
let normalised = raw.replace("\r\n", "\n");
let (frontmatter, body) = split_frontmatter(&normalised)
.ok_or_else(|| format!("skill '{name}': missing or malformed frontmatter"))?;
let mut fm_name: Option<String> = None;
let mut fm_description: Option<String> = None;
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
match key {
"name" => fm_name = Some(value.to_string()),
"description" => fm_description = Some(value.to_string()),
_ => {}
}
}
let resolved_name = fm_name.unwrap_or_else(|| name.to_string());
let description = fm_description
.ok_or_else(|| format!("skill '{name}': frontmatter missing 'description'"))?;
let slash_name = format!("/{resolved_name}");
Ok(Self {
name: resolved_name,
slash_name,
description,
body: body.trim().to_string(),
source,
})
}
}
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
let after_open = raw.strip_prefix("---\n")?;
let close_idx = after_open
.lines()
.scan(0usize, |acc, line| {
let start = *acc;
*acc += line.len() + 1;
Some((start, line))
})
.find(|(_, line)| line.trim() == "---")
.map(|(idx, _)| idx)?;
let frontmatter = &after_open[..close_idx];
let body_start = (close_idx + 4).min(after_open.len());
let body = &after_open[body_start..];
Some((frontmatter, body))
}
fn user_skills_dir() -> PathBuf {
crate::config::opencrabs_home().join("skills")
}
pub fn load_all_skills() -> Vec<Skill> {
let mut by_name: std::collections::BTreeMap<String, Skill> = std::collections::BTreeMap::new();
for (name, raw) in BUILTIN_SKILLS {
match Skill::parse(name, raw, SkillSource::Builtin) {
Ok(skill) => {
by_name.insert(skill.name.clone(), skill);
}
Err(e) => {
tracing::error!("skills: built-in '{name}' failed to parse: {e}");
}
}
}
let dir = user_skills_dir();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let skill_path = path.join("SKILL.md");
if !skill_path.exists() {
continue;
}
let raw = match std::fs::read_to_string(&skill_path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"skills: failed to read user skill '{name}' at {}: {e}",
skill_path.display()
);
continue;
}
};
match Skill::parse(name, &raw, SkillSource::User) {
Ok(skill) => {
by_name.insert(skill.name.clone(), skill);
}
Err(e) => {
tracing::warn!("skills: user skill '{name}' has bad frontmatter: {e}");
}
}
}
}
by_name.into_values().collect()
}
pub fn resolve_skill(name: &str) -> Option<Skill> {
load_all_skills().into_iter().find(|s| s.name == name)
}