use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Skill {
pub name: String,
pub description: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(skip)]
pub content: String,
#[serde(skip)]
pub source: Option<PathBuf>,
}
impl Skill {
pub fn from_markdown(source: &str) -> Result<Self, String> {
let trimmed = source.trim_start();
if !trimmed.starts_with("---") {
return Err("Missing YAML frontmatter".to_string());
}
let after_open = &trimmed[3..];
let Some(end_idx) = after_open.find("\n---") else {
return Err("Unclosed YAML frontmatter".to_string());
};
let yaml_text = &after_open[..end_idx].trim();
let content = after_open[end_idx + 4..].trim_start().to_string();
let mut skill: Skill = serde_yaml::from_str(yaml_text)
.map_err(|e| format!("Invalid YAML frontmatter: {e}"))?;
skill.content = content;
if skill.name.is_empty() {
return Err("Skill name cannot be empty".to_string());
}
Ok(skill)
}
pub fn from_file(path: &Path) -> Result<Self, String> {
let source =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?;
let mut skill = Self::from_markdown(&source)?;
skill.source = Some(path.to_path_buf());
Ok(skill)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SkillRegistry {
skills: HashMap<String, Skill>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn discover_dir(&mut self, dir: &Path) {
if !dir.is_dir() {
debug!("Skill directory does not exist: {}", dir.display());
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
warn!("Failed to read skill directory {}: {e}", dir.display());
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
match Skill::from_file(&path) {
Ok(skill) => {
debug!("Discovered skill '{}' from {}", skill.name, path.display());
self.skills.insert(skill.name.clone(), skill);
}
Err(e) => {
warn!("Failed to load skill from {}: {e}", path.display());
}
}
}
}
pub fn discover() -> Self {
let mut registry = Self::new();
if let Some(home) = dirs::home_dir() {
registry.discover_dir(&home.join(".selfware").join("skills"));
}
if let Ok(cwd) = std::env::current_dir() {
registry.discover_dir(&cwd.join(".selfware").join("skills"));
}
registry
}
pub fn get(&self, name: &str) -> Option<&Skill> {
self.skills.get(name)
}
pub fn list(&self) -> Vec<&Skill> {
let mut skills: Vec<_> = self.skills.values().collect();
skills.sort_by_key(|s| &s.name);
skills
}
pub fn wrap_task_with_skill(&self, task: &str, skill_name: &str) -> Option<String> {
self.get(skill_name).map(|skill| {
format!(
"[Skill: {}]\n{}\n\n[Task]\n{}",
skill.name, skill.content, task
)
})
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
}
#[cfg(test)]
#[path = "../../tests/unit/skills/mod_test.rs"]
mod tests;