skill-registry 0.2.0

Autonomous skill discovery agent with LLM tool chaining, powered by Rust.
Documentation
use crate::error::RegistryError;
use skill_core::{ResourceType, Skill, SkillResource};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use walkdir::WalkDir;

pub mod error;

#[derive(Debug)]
pub struct SkillRegistry {
    skills: HashMap<String, Skill>,
    skills_dir: PathBuf,
}

impl SkillRegistry {
    pub fn new(skills_dir: PathBuf) -> Self {
        Self {
            skills: HashMap::new(),
            skills_dir,
        }
    }

    pub async fn load(&mut self) -> Result<(), RegistryError> {
        self.skills.clear();

        if !self.skills_dir.exists() {
            warn!("Skills directory does not exist: {:?}", self.skills_dir);
            return Ok(());
        }

        info!("Loading skills from: {:?}", self.skills_dir);

        for entry in WalkDir::new(&self.skills_dir)
            .max_depth(2)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            let path = entry.path();
            if path.file_name().map(|n| n == "SKILL.md").unwrap_or(false) {
                if let Some(skill) = self.load_skill(path).await? {
                    let id = skill.id.clone();
                    debug!("Loaded skill: {} ({})", skill.name, id);
                    self.skills.insert(id, skill);
                }
            }
        }

        info!("Loaded {} skills", self.skills.len());
        Ok(())
    }

    async fn load_skill(&self, skill_md_path: &Path) -> Result<Option<Skill>, RegistryError> {
        let skill_dir = skill_md_path
            .parent()
            .ok_or_else(|| RegistryError::InvalidPath("SKILL.md has no parent".into()))?;

        let content = tokio::fs::read_to_string(skill_md_path).await?;

        let (frontmatter, body) = parse_skill_md(&content)?;

        let name = frontmatter
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("Unnamed")
            .to_string();

        let description = frontmatter
            .get("description")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let triggers: Vec<String> = get_string_array(&frontmatter, "trigger");

        let capabilities: Vec<String> = get_string_array(&frontmatter, "capabilities");

        let id = slugify(&name);

        let resources = self.load_resources(skill_dir).await?;

        let skill = Skill::new(id, name, description, body, skill_dir.to_path_buf())
            .with_triggers(triggers)
            .with_capabilities(capabilities)
            .with_resources(resources);

        Ok(Some(skill))
    }

    async fn load_resources(&self, skill_dir: &Path) -> Result<Vec<SkillResource>, RegistryError> {
        let mut resources = Vec::new();

        let scripts_dir = skill_dir.join("scripts");
        if scripts_dir.is_dir() {
            for entry in WalkDir::new(&scripts_dir)
                .into_iter()
                .filter_map(|e| e.ok())
                .filter(|e| e.file_type().is_file())
            {
                resources.push(SkillResource {
                    name: entry.file_name().to_string_lossy().to_string(),
                    path: entry.path().to_path_buf(),
                    resource_type: ResourceType::Script,
                });
            }
        }

        let references_dir = skill_dir.join("references");
        if references_dir.is_dir() {
            for entry in WalkDir::new(&references_dir)
                .into_iter()
                .filter_map(|e| e.ok())
                .filter(|e| e.file_type().is_file())
            {
                resources.push(SkillResource {
                    name: entry.file_name().to_string_lossy().to_string(),
                    path: entry.path().to_path_buf(),
                    resource_type: ResourceType::Reference,
                });
            }
        }

        Ok(resources)
    }

    pub fn get(&self, id: &str) -> Option<&Skill> {
        self.skills.get(id)
    }

    pub fn get_all(&self) -> Vec<&Skill> {
        self.skills.values().collect()
    }

    pub fn count(&self) -> usize {
        self.skills.len()
    }

    pub fn skills_dir(&self) -> &Path {
        &self.skills_dir
    }
}

fn parse_skill_md(content: &str) -> Result<(serde_yaml::Value, String), RegistryError> {
    let trimmed = content.trim();

    if !trimmed.starts_with("---") {
        return Ok((serde_yaml::Value::Null, trimmed.to_string()));
    }

    let end_marker = trimmed[3..]
        .find("---")
        .ok_or_else(|| RegistryError::ParseError("Missing closing ---".into()))?;

    let frontmatter_str = &trimmed[3..3 + end_marker];
    let body = trimmed[3 + end_marker + 3..].trim().to_string();

    let frontmatter: serde_yaml::Value = serde_yaml::from_str(frontmatter_str)
        .map_err(|e| RegistryError::ParseError(e.to_string()))?;

    Ok((frontmatter, body))
}

fn get_string_array(value: &serde_yaml::Value, key: &str) -> Vec<String> {
    value
        .get(key)
        .and_then(|v| {
            if let Some(arr) = v.as_sequence() {
                Some(
                    arr.iter()
                        .filter_map(|item| item.as_str().map(String::from))
                        .collect(),
                )
            } else if let Some(s) = v.as_str() {
                Some(vec![s.to_string()])
            } else {
                None
            }
        })
        .unwrap_or_default()
}

fn slugify(s: &str) -> String {
    s.to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect::<String>()
        .split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_slugify() {
        assert_eq!(slugify("Hello World"), "hello-world");
        assert_eq!(slugify("RSS Fetcher"), "rss-fetcher");
    }

    #[test]
    fn test_parse_skill_md_without_frontmatter() {
        let content = "# Hello\n\nThis is the body.";
        let (fm, body) = parse_skill_md(content).unwrap();
        assert_eq!(fm, serde_yaml::Value::Null);
        assert!(body.contains("Hello"));
    }
}