ai-agent-sdk 0.5.0

Idiomatic agent sdk inspired by the claude code source leak
Documentation
//! Skill loader - loads skills from SKILL.md files
//!
//! Loads external skills from directories containing SKILL.md files.

use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::AgentError;

/// Skill metadata parsed from SKILL.md frontmatter
#[derive(Debug, Clone)]
pub struct SkillMetadata {
    pub name: String,
    pub description: String,
    pub allowed_tools: Option<Vec<String>>,
    pub argument_hint: Option<String>,
    pub when_to_use: Option<String>,
    pub user_invocable: Option<bool>,
}

/// Loaded skill with its metadata and content
#[derive(Debug, Clone)]
pub struct LoadedSkill {
    pub metadata: SkillMetadata,
    pub content: String,
    pub base_dir: String,
}

/// Parse simple frontmatter (key: value format)
fn parse_frontmatter(content: &str) -> (HashMap<String, String>, String) {
    let mut fields = HashMap::new();
    let trimmed = content.trim();

    if !trimmed.starts_with("---") {
        return (fields, content.to_string());
    }

    if let Some(end_pos) = trimmed[3..].find("---") {
        let frontmatter = &trimmed[3..end_pos + 3];
        for line in frontmatter.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if let Some(colon_pos) = line.find(':') {
                let key = line[..colon_pos].trim().to_string();
                let value = line[colon_pos + 1..].trim().to_string();
                fields.insert(key, value);
            }
        }
        let body = trimmed[end_pos + 6..].trim_start().to_string();
        return (fields, body);
    }

    (fields, content.to_string())
}

/// Load a skill from a directory containing SKILL.md
pub fn load_skill_from_dir(dir_path: &Path) -> Result<LoadedSkill, AgentError> {
    let skill_file = dir_path.join("SKILL.md");
    if !skill_file.exists() {
        return Err(AgentError::Skill(format!("SKILL.md not found in {}", dir_path.display())));
    }

    let content = fs::read_to_string(&skill_file)
        .map_err(|e| AgentError::Io(e))?;

    let (fields, body) = parse_frontmatter(&content);

    let name = dir_path.file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("unknown")
        .to_string();

    let description = fields.get("description")
        .cloned()
        .unwrap_or_default();

    let allowed_tools = fields.get("allowed-tools")
        .map(|s| s.split(',').map(|x| x.trim().to_string()).collect());

    let argument_hint = fields.get("argument-hint").cloned();
    let when_to_use = fields.get("when_to_use").cloned();
    let user_invocable = fields.get("user-invocable")
        .and_then(|v| match v.as_str() {
            "true" | "1" => Some(true),
            "false" | "0" => Some(false),
            _ => None,
        });

    let metadata = SkillMetadata {
        name,
        description,
        allowed_tools,
        argument_hint,
        when_to_use,
        user_invocable,
    };

    Ok(LoadedSkill {
        metadata,
        content: body,
        base_dir: dir_path.to_string_lossy().to_string(),
    })
}

/// Load all skills from a skills directory (skill-name/SKILL.md format)
pub fn load_skills_from_dir(base_path: &Path) -> Result<Vec<LoadedSkill>, AgentError> {
    if !base_path.exists() {
        return Ok(Vec::new());
    }

    let mut skills = Vec::new();

    let entries = fs::read_dir(base_path)
        .map_err(|e| AgentError::Io(e))?;

    for entry in entries {
        let entry = entry.map_err(|e| AgentError::Io(e))?;
        let path = entry.path();

        if path.is_dir() {
            if let Ok(skill) = load_skill_from_dir(&path) {
                skills.push(skill);
            }
        }
    }

    Ok(skills)
}