use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub name: String,
pub version: String,
#[allow(dead_code)]
pub description: String,
pub file_path: String,
}
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(®istry_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 ®istry.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)
}
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('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[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&b<c>d"e'f"
);
}
}