use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::RwLock;
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
#[serde(default)]
license: Option<String>,
#[serde(default)]
compatibility: Option<String>,
#[serde(default)]
metadata: SkillFrontmatterMetadata,
}
#[derive(Debug, Default, Deserialize)]
struct SkillFrontmatterMetadata {
#[serde(default, rename = "short-description")]
short_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillDefinition {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub short_description: Option<String>,
pub content: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
const SKILL_DIRS: &[&str] = &[
".opencode/skills",
".claude/skills",
".agents/skills",
".codex/skills",
".cursor/skills",
".gemini/skills",
];
const SKILL_FILENAME: &str = "SKILL.md";
pub struct SkillRegistry {
skills: RwLock<HashMap<String, SkillDefinition>>,
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: RwLock::new(HashMap::new()),
}
}
pub fn reload(&self, cwd: &str) {
let mut discovered = HashMap::new();
let cwd_path = Path::new(cwd);
for dir_pattern in SKILL_DIRS {
let skill_dir = cwd_path.join(dir_pattern);
if skill_dir.is_dir() {
discover_skills_in_dir(&skill_dir, &mut discovered);
}
}
if let Some(home) = dirs::home_dir() {
for dir_pattern in SKILL_DIRS {
let skill_dir = home.join(dir_pattern);
if skill_dir.is_dir() {
discover_skills_in_dir(&skill_dir, &mut discovered);
}
}
}
let count = discovered.len();
if let Ok(mut skills) = self.skills.write() {
*skills = discovered;
}
tracing::info!("Discovered {} skills", count);
}
pub fn get_skill(&self, name: &str) -> Option<SkillDefinition> {
self.skills.read().ok().and_then(|s| s.get(name).cloned())
}
pub fn list_skills(&self) -> Vec<SkillDefinition> {
self.skills
.read()
.map(|s| s.values().cloned().collect())
.unwrap_or_default()
}
}
fn discover_skills_in_dir(dir: &Path, out: &mut HashMap<String, SkillDefinition>) {
discover_skills_recursive(dir, out, 0, 2);
}
fn discover_skills_recursive(
dir: &Path,
out: &mut HashMap<String, SkillDefinition>,
depth: usize,
max_depth: usize,
) {
if depth > max_depth {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let skill_file = path.join(SKILL_FILENAME);
if skill_file.is_file() {
if let Some(skill) = parse_skill_file(&skill_file) {
out.insert(skill.name.clone(), skill);
}
}
discover_skills_recursive(&path, out, depth + 1, max_depth);
} else if path
.file_name()
.map(|f| f == SKILL_FILENAME)
.unwrap_or(false)
{
if let Some(skill) = parse_skill_file(&path) {
out.insert(skill.name.clone(), skill);
}
}
}
}
fn extract_frontmatter(contents: &str) -> Option<(String, String)> {
let mut lines = contents.lines();
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
return None;
}
let mut frontmatter_lines: Vec<&str> = Vec::new();
let mut body_start = false;
let mut body_lines: Vec<&str> = Vec::new();
for line in lines {
if !body_start {
if line.trim() == "---" {
body_start = true;
} else {
frontmatter_lines.push(line);
}
} else {
body_lines.push(line);
}
}
if frontmatter_lines.is_empty() || !body_start {
return None;
}
Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
}
fn parse_skill_file(path: &Path) -> Option<SkillDefinition> {
let raw = std::fs::read_to_string(path).ok()?;
if let Some((frontmatter_str, body)) = extract_frontmatter(&raw) {
if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&frontmatter_str) {
let short_desc = fm.metadata.short_description.filter(|s| !s.is_empty());
return Some(SkillDefinition {
name: fm.name,
description: fm.description,
short_description: short_desc,
content: body.trim().to_string(),
source: path.to_string_lossy().to_string(),
license: fm.license,
compatibility: fm.compatibility,
metadata: HashMap::new(),
});
}
}
let name = path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let description = raw
.lines()
.skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
.take_while(|l| !l.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
Some(SkillDefinition {
name,
description: if description.is_empty() {
"No description".to_string()
} else {
description
},
short_description: None,
content: raw,
source: path.to_string_lossy().to_string(),
license: None,
compatibility: None,
metadata: HashMap::new(),
})
}