reagent-rs 0.2.9

A Rust library for building AI agents with MCP, custom tools and skills
Documentation
use std::{
    collections::{HashMap, HashSet},
    fs,
    path::{Path, PathBuf},
};

use super::{Skill, SkillLoadError, SkillResource, SkillResourceKind};

pub fn load_skill(path: impl AsRef<Path>) -> Result<Skill, SkillLoadError> {
    let skill_md = resolve_skill_file(path.as_ref())?;
    let skill_md = skill_md.canonicalize()?;
    let root = skill_md
        .parent()
        .ok_or_else(|| {
            SkillLoadError::InvalidPath(format!(
                "Skill file has no parent directory: {}",
                skill_md.display()
            ))
        })?
        .to_path_buf();

    let raw_content = fs::read_to_string(&skill_md)?;
    let (frontmatter, instructions) = split_frontmatter(&skill_md, &raw_content)?;
    let parsed = parse_frontmatter(&skill_md, frontmatter)?;
    validate_skill_name(&skill_md, &root, &parsed.name)?;
    let instructions = instructions.to_string();

    Ok(Skill {
        name: parsed.name,
        description: parsed.description,
        resources: discover_resources(&root, &skill_md)?,
        root,
        skill_md,
        raw_content,
        instructions,
        license: parsed.license,
        compatibility: parsed.compatibility,
        metadata: parsed.metadata,
        allowed_tools: parsed.allowed_tools,
    })
}

pub fn load_skill_collection(path: impl AsRef<Path>) -> Result<Vec<Skill>, SkillLoadError> {
    let root = path.as_ref().canonicalize()?;
    if !root.is_dir() {
        return Err(SkillLoadError::InvalidPath(format!(
            "Skill collection must be a directory: {}",
            root.display()
        )));
    }

    let mut child_dirs = Vec::new();
    for entry in fs::read_dir(&root)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() || is_hidden_path(&path) {
            continue;
        }
        if find_skill_file_in_dir(&path).is_some() {
            child_dirs.push(path);
        }
    }

    child_dirs.sort();

    let mut skills = Vec::new();
    for child in child_dirs {
        skills.push(load_skill(child)?);
    }

    if skills.is_empty() {
        return Err(SkillLoadError::NoSkillsInCollection(root));
    }

    Ok(skills)
}

pub fn load_skill_sources(
    skill_paths: &[PathBuf],
    collection_paths: &[PathBuf],
) -> Result<Vec<Skill>, SkillLoadError> {
    let mut roots = HashSet::new();
    let mut names = HashSet::new();
    let mut skills = Vec::new();

    for path in skill_paths {
        push_unique_skill(load_skill(path)?, &mut roots, &mut names, &mut skills)?;
    }

    for path in collection_paths {
        for skill in load_skill_collection(path)? {
            push_unique_skill(skill, &mut roots, &mut names, &mut skills)?;
        }
    }

    Ok(skills)
}

fn push_unique_skill(
    skill: Skill,
    roots: &mut HashSet<PathBuf>,
    names: &mut HashSet<String>,
    skills: &mut Vec<Skill>,
) -> Result<(), SkillLoadError> {
    if !names.insert(skill.name.clone()) {
        return Err(SkillLoadError::DuplicateSkillName(skill.name));
    }
    if roots.insert(skill.root.clone()) {
        skills.push(skill);
    }
    Ok(())
}

fn resolve_skill_file(path: &Path) -> Result<PathBuf, SkillLoadError> {
    if path.is_file() {
        let file_name = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or_default();
        if file_name.eq_ignore_ascii_case("skill.md") {
            return Ok(path.to_path_buf());
        }
        return Err(SkillLoadError::InvalidPath(format!(
            "Skill file path must point to SKILL.md or skill.md: {}",
            path.display()
        )));
    }

    if path.is_dir() {
        return find_skill_file_in_dir(path)
            .ok_or_else(|| SkillLoadError::MissingSkillFile(path.to_path_buf()));
    }

    Err(SkillLoadError::InvalidPath(format!(
        "Skill path does not exist: {}",
        path.display()
    )))
}

fn find_skill_file_in_dir(path: &Path) -> Option<PathBuf> {
    let upper = path.join("SKILL.md");
    if upper.is_file() {
        return Some(upper);
    }

    let lower = path.join("skill.md");
    if lower.is_file() {
        return Some(lower);
    }

    None
}

fn split_frontmatter<'a>(
    path: &Path,
    content: &'a str,
) -> Result<(&'a str, &'a str), SkillLoadError> {
    let mut lines = content.lines();
    if lines.next() != Some("---") {
        return Err(SkillLoadError::MissingFrontmatter(path.to_path_buf()));
    }

    let frontmatter_start = content
        .find('\n')
        .ok_or_else(|| SkillLoadError::MissingFrontmatter(path.to_path_buf()))?
        + 1;

    let mut offset = frontmatter_start;
    for line in content[frontmatter_start..].lines() {
        let line_len = line.len();
        if line == "---" {
            let body_start = offset + line_len;
            let body_start = if content[body_start..].starts_with("\r\n") {
                body_start + 2
            } else if content[body_start..].starts_with('\n') {
                body_start + 1
            } else {
                body_start
            };
            return Ok((&content[frontmatter_start..offset], &content[body_start..]));
        }
        offset += line_len;
        if content[offset..].starts_with("\r\n") {
            offset += 2;
        } else if content[offset..].starts_with('\n') {
            offset += 1;
        }
    }

    Err(SkillLoadError::MissingFrontmatter(path.to_path_buf()))
}

#[derive(Default)]
struct ParsedFrontmatter {
    name: String,
    description: String,
    license: Option<String>,
    compatibility: Option<String>,
    metadata: HashMap<String, String>,
    allowed_tools: Option<String>,
}

fn parse_frontmatter(path: &Path, frontmatter: &str) -> Result<ParsedFrontmatter, SkillLoadError> {
    let mut parsed = ParsedFrontmatter::default();
    let mut in_metadata = false;

    for line in frontmatter.lines() {
        if line.trim().is_empty() || line.trim_start().starts_with('#') {
            continue;
        }

        let is_indented = line.starts_with(' ') || line.starts_with('\t');
        if in_metadata && is_indented {
            if let Some((key, value)) = split_key_value(line.trim()) {
                parsed
                    .metadata
                    .insert(key.to_string(), normalize_yaml_scalar(value));
            }
            continue;
        }

        in_metadata = false;
        let Some((key, value)) = split_key_value(line) else {
            continue;
        };

        match key {
            "name" => parsed.name = normalize_yaml_scalar(value),
            "description" => parsed.description = normalize_yaml_scalar(value),
            "license" => parsed.license = Some(normalize_yaml_scalar(value)),
            "compatibility" => parsed.compatibility = Some(normalize_yaml_scalar(value)),
            "allowed-tools" => parsed.allowed_tools = Some(normalize_yaml_scalar(value)),
            "metadata" => in_metadata = true,
            _ => {}
        }
    }

    if parsed.name.is_empty() {
        return Err(SkillLoadError::MissingField {
            path: path.to_path_buf(),
            field: "name",
        });
    }
    if parsed.description.is_empty() {
        return Err(SkillLoadError::MissingField {
            path: path.to_path_buf(),
            field: "description",
        });
    }
    if parsed.description.chars().count() > 1024 {
        return Err(SkillLoadError::InvalidField {
            path: path.to_path_buf(),
            field: "description",
            message: "must be 1024 characters or fewer".into(),
        });
    }
    if let Some(compatibility) = &parsed.compatibility {
        if compatibility.chars().count() > 500 {
            return Err(SkillLoadError::InvalidField {
                path: path.to_path_buf(),
                field: "compatibility",
                message: "must be 500 characters or fewer".into(),
            });
        }
    }

    Ok(parsed)
}

fn split_key_value(line: &str) -> Option<(&str, &str)> {
    let (key, value) = line.split_once(':')?;
    Some((key.trim(), value.trim()))
}

fn normalize_yaml_scalar(value: &str) -> String {
    let value = value.trim();
    if value.len() >= 2 {
        if let Some(stripped) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
            return stripped.to_string();
        }
        if let Some(stripped) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
            return stripped.to_string();
        }
    }
    value.to_string()
}

fn validate_skill_name(path: &Path, root: &Path, name: &str) -> Result<(), SkillLoadError> {
    if name.chars().count() > 64 {
        return Err(invalid_name(path, "must be 64 characters or fewer"));
    }
    if name.starts_with('-') || name.ends_with('-') {
        return Err(invalid_name(path, "must not start or end with a hyphen"));
    }
    if name.contains("--") {
        return Err(invalid_name(path, "must not contain consecutive hyphens"));
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        return Err(invalid_name(
            path,
            "must contain only lowercase ASCII letters, numbers, and hyphens",
        ));
    }

    let dir_name = root
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or_default();

    if dir_name != name {
        return Err(invalid_name(
            path,
            "Skill name must match parent directory name",
        ));
    }

    Ok(())
}

fn invalid_name(path: &Path, message: &str) -> SkillLoadError {
    SkillLoadError::InvalidField {
        path: path.to_path_buf(),
        field: "name",
        message: message.into(),
    }
}

fn discover_resources(root: &Path, skill_md: &Path) -> Result<Vec<SkillResource>, SkillLoadError> {
    let mut resources = Vec::new();
    discover_resources_inner(root, root, skill_md, &mut resources)?;
    resources.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
    Ok(resources)
}

fn discover_resources_inner(
    root: &Path,
    current: &Path,
    skill_md: &Path,
    resources: &mut Vec<SkillResource>,
) -> Result<(), SkillLoadError> {
    for entry in fs::read_dir(current)? {
        let entry = entry?;
        let path = entry.path();
        if is_hidden_path(&path) || path == skill_md {
            continue;
        }

        if path.is_dir() {
            discover_resources_inner(root, &path, skill_md, resources)?;
            continue;
        }

        if path.is_file() {
            let relative_path = path
                .strip_prefix(root)
                .map_err(|e| SkillLoadError::InvalidPath(e.to_string()))?
                .to_path_buf();
            let size_bytes = entry.metadata()?.len();
            let kind = classify_resource(&relative_path);
            resources.push(SkillResource {
                relative_path,
                kind,
                size_bytes,
            });
        }
    }

    Ok(())
}

fn classify_resource(path: &Path) -> SkillResourceKind {
    let first_component = path
        .components()
        .next()
        .and_then(|component| component.as_os_str().to_str());
    match first_component {
        Some("scripts") => return SkillResourceKind::Script,
        Some("references") => return SkillResourceKind::Reference,
        Some("assets") => return SkillResourceKind::Asset,
        _ => {}
    }

    match path.extension().and_then(|ext| ext.to_str()) {
        Some("md") | Some("markdown") => SkillResourceKind::Markdown,
        Some("txt") | Some("json") | Some("toml") | Some("yaml") | Some("yml") => {
            SkillResourceKind::Text
        }
        _ => SkillResourceKind::Other,
    }
}

fn is_hidden_path(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.starts_with('.'))
}