spec-ai 0.8.4

A framework for building AI agents with structured outputs, policy enforcement, and execution tracking
Documentation
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

/// Metadata for an Agent Skill
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata {
    pub name: String,
    pub description: String,
    pub path: PathBuf,
}

/// Scanner for finding Agent Skills in the filesystem
pub struct SkillScanner {
    search_dirs: Vec<PathBuf>,
}

impl SkillScanner {
    pub fn new(search_dirs: Vec<PathBuf>) -> Self {
        Self { search_dirs }
    }

    /// Scan all configured directories for SKILL.md files
    pub fn scan(&self) -> Vec<SkillMetadata> {
        let mut skills = Vec::new();

        for dir in &self.search_dirs {
            let expanded_dir = crate::spec_ai_plugin::expand_tilde(dir);
            if !expanded_dir.exists() {
                continue;
            }

            for entry in WalkDir::new(expanded_dir)
                .max_depth(3) // Skills should be reasonably shallow
                .into_iter()
                .filter_map(|e| e.ok())
            {
                if entry.file_name() == "SKILL.md" {
                    if let Ok(metadata) = self.parse_skill_file(entry.path()) {
                        skills.push(metadata);
                    }
                }
            }
        }

        skills
    }

    /// Parse a SKILL.md file to extract name and description
    fn parse_skill_file(&self, path: &Path) -> anyhow::Result<SkillMetadata> {
        let content = std::fs::read_to_string(path)?;

        // Simple extraction logic:
        // Name is usually the first H1 or from frontmatter if we wanted to be fancy.
        // Description is usually the first paragraph after the name.

        let mut name = String::new();
        let mut description = String::new();

        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }

            if line.starts_with("# ") && name.is_empty() {
                name = line.trim_start_matches("# ").to_string();
            } else if !name.is_empty() && description.is_empty() && !line.starts_with('#') {
                description = line.to_string();
            }

            if !name.is_empty() && !description.is_empty() {
                break;
            }
        }

        if name.is_empty() {
            // Fallback to directory name
            name = path
                .parent()
                .and_then(|p| p.file_name())
                .and_then(|s| s.to_str())
                .unwrap_or("unknown-skill")
                .to_string();
        }

        Ok(SkillMetadata {
            name,
            description,
            path: path.to_path_buf(),
        })
    }
}