clawgarden-agent 0.11.1

Agent runtime with persona/memory loader, judge, and pi RPC for ClawGarden
Documentation
//! Skill loader — loads available skills from the garden's shared registry.
//!
//! Reads <workspace>/skills/registry.json for metadata and
//! <workspace>/skills/<name>/skill.md for each skill's prompt body.

use anyhow::Result;
use std::path::Path;

/// A single skill with its metadata. Body is NOT loaded by default
/// — the LLM reads skill.md via the read_file tool when needed.
#[derive(Debug, Clone)]
pub struct SkillEntry {
    pub name: String,
    pub version: String,
    #[allow(dead_code)]
    pub description: String,
    /// SKILL.md file path — LLM uses read_file to load on demand
    pub file_path: String,
}

/// Load all skills from the shared registry.
///
/// Reads registry.json for metadata, then loads each skill's skill.md.
/// Returns a formatted string for inclusion in LLM prompts.
/// Returns empty string if the file is missing (non-fatal).
pub async fn load_skills() -> Result<Vec<SkillEntry>> {
    let config = clawgarden_proto::AppConfig::load();
    let skills_dir = config.skills_dir();
    let registry_path = Path::new(&skills_dir).join("registry.json");

    let content = match tokio::fs::read_to_string(&registry_path).await {
        Ok(c) => c,
        Err(_) => {
            log::info!("No skill registry found at {:?}", registry_path);
            return Ok(Vec::new());
        }
    };

    let registry: clawgarden_proto::SkillRegistry = match serde_json::from_str(&content) {
        Ok(r) => r,
        Err(e) => {
            log::warn!("Failed to parse skill registry: {}", e);
            return Ok(Vec::new());
        }
    };

    if registry.skills.is_empty() {
        return Ok(Vec::new());
    }

    let mut entries = Vec::new();

    for skill in &registry.skills {
        let skill_md_path = Path::new(&skills_dir).join(&skill.name).join("skill.md");
        let _body = match tokio::fs::read_to_string(&skill_md_path).await {
            Ok(c) => c,
            Err(_) => {
                log::warn!("skill.md not found for skill '{}'", skill.name);
                String::new()
            }
        };
        entries.push(SkillEntry {
            name: skill.name.clone(),
            version: skill.version.clone(),
            description: skill.description.clone(),
            file_path: skill_md_path.to_string_lossy().to_string(),
        });
    }

    log::info!("Loaded {} skills", entries.len());
    Ok(entries)
}

/// Format skills into a compact XML catalog for LLM prompt injection.
/// Only name + description + file_path — body is loaded on demand by read_file.
pub fn format_skills(entries: &[SkillEntry]) -> String {
    if entries.is_empty() {
        return String::new();
    }

    let mut out = String::from("<available_skills>\n");

    for entry in entries {
        out.push_str(&format!(
            "  <skill>\n    <name>{}</name>\n    <description>{}</description>\n    <location>{}</location>\n  </skill>\n",
            xml_escape(&entry.name),
            xml_escape(&entry.description),
            xml_escape(&entry.file_path),
        ));
    }

    out.push_str("</available_skills>");
    out
}

fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

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

    #[tokio::test]
    async fn test_load_skills_missing_file() {
        let result = load_skills().await;
        assert!(result.is_ok());
        assert!(result.unwrap().is_empty());
    }

    #[test]
    fn test_format_skills_empty() {
        assert!(format_skills(&[]).is_empty());
    }

    #[test]
    fn test_format_skills_entries() {
        let entries = vec![SkillEntry {
            name: "code_search".into(),
            version: "1".into(),
            description: "Search files".into(),
            file_path: "/workspace/skills/code_search/SKILL.md".into(),
        }];
        let out = format_skills(&entries);
        assert!(out.contains("<available_skills>"));
        assert!(out.contains("<name>code_search</name>"));
        assert!(out.contains("<description>Search files</description>"));
        assert!(out.contains("<location>/workspace/skills/code_search/SKILL.md</location>"));
    }

    #[test]
    fn test_xml_escape() {
        assert_eq!(
            xml_escape("a&b<c>d\"e'f"),
            "a&amp;b&lt;c&gt;d&quot;e&apos;f"
        );
    }
}