use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub file_path: PathBuf,
pub base_dir: PathBuf,
pub source: String,
}
#[derive(Debug, Clone, Default)]
pub struct SkillSet {
skills: Vec<Skill>,
}
#[derive(Debug, thiserror::Error)]
pub enum SkillError {
#[error("IO error reading {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("SKILL.md in {path} missing required frontmatter field: {field}")]
MissingField { path: PathBuf, field: &'static str }, #[error("SKILL.md in {path} has invalid frontmatter: {detail}")]
InvalidFrontmatter { path: PathBuf, detail: String },
}
impl SkillSet {
pub fn load(
dirs: &[impl AsRef<Path>], ) -> Result<Self, SkillError> {
let mut by_name: HashMap<String, Skill> = HashMap::new();
for (i, dir) in dirs.iter().enumerate() {
let dir = dir.as_ref(); if !dir.exists() {
continue; }
let source = format!("dir:{}", i);
let skills = load_skills_from_dir(dir, &source)?;
for skill in skills {
by_name.insert(skill.name.clone(), skill); }
}
let mut skills: Vec<Skill> = by_name.into_values().collect();
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Self { skills }) }
pub fn load_dir(
dir: impl AsRef<Path>, source: &str, ) -> Result<Self, SkillError> {
let skills = load_skills_from_dir(dir.as_ref(), source)?;
Ok(Self { skills })
}
pub fn empty() -> Self {
Self { skills: Vec::new() }
}
pub fn merge(
&mut self,
other: SkillSet, ) {
let mut by_name: HashMap<String, Skill> =
self.skills.drain(..).map(|s| (s.name.clone(), s)).collect();
for skill in other.skills {
by_name.insert(skill.name.clone(), skill);
}
self.skills = by_name.into_values().collect();
self.skills.sort_by(|a, b| a.name.cmp(&b.name));
}
pub fn skills(&self) -> &[Skill] {
&self.skills
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn format_for_prompt(&self) -> String {
if self.skills.is_empty() {
return String::new();
}
let mut out = String::from("<available_skills>\n");
for skill in &self.skills {
out.push_str(" <skill>\n");
out.push_str(&format!(" <name>{}</name>\n", xml_escape(&skill.name)));
out.push_str(&format!(
" <description>{}</description>\n",
xml_escape(&skill.description)
));
out.push_str(&format!(
" <location>{}</location>\n",
xml_escape(&skill.file_path.to_string_lossy())
));
out.push_str(" </skill>\n");
}
out.push_str("</available_skills>");
out
}
}
fn load_skills_from_dir(
dir: &Path, source: &str, ) -> Result<Vec<Skill>, SkillError> {
let mut skills = Vec::new();
let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
path: dir.to_path_buf(),
source: e,
})?;
for entry in entries {
let entry = entry.map_err(|e| SkillError::Io {
path: dir.to_path_buf(),
source: e,
})?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let content = fs::read_to_string(&skill_md).map_err(|e| SkillError::Io {
path: skill_md.clone(),
source: e,
})?;
let (name, description) = parse_frontmatter(&content, &skill_md)?;
let dir_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy() .to_string();
let name = if name == dir_name { name } else { dir_name };
let base_dir = fs::canonicalize(&path).unwrap_or(path);
let file_path = base_dir.join("SKILL.md");
skills.push(Skill {
name,
description,
file_path,
base_dir,
source: source.to_string(),
});
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(skills)
}
fn parse_frontmatter(
content: &str, path: &Path, ) -> Result<(String, String), SkillError> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err(SkillError::InvalidFrontmatter {
path: path.to_path_buf(),
detail: "missing opening ---".into(),
});
}
let after_open = &trimmed[3..];
let end = after_open
.find("\n---")
.ok_or(SkillError::InvalidFrontmatter {
path: path.to_path_buf(),
detail: "missing closing ---".into(),
})?;
let yaml_block = &after_open[..end];
let mut name = None;
let mut description = None;
for line in yaml_block.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("name:") {
name = Some(unquote(rest.trim()));
} else if let Some(rest) = line.strip_prefix("description:") {
description = Some(unquote(rest.trim()));
}
}
let name = name.ok_or(SkillError::MissingField {
path: path.to_path_buf(),
field: "name",
})?;
let description = description.ok_or(SkillError::MissingField {
path: path.to_path_buf(),
field: "description",
})?;
if name.is_empty() {
return Err(SkillError::MissingField {
path: path.to_path_buf(),
field: "name",
});
}
if description.is_empty() {
return Err(SkillError::MissingField {
path: path.to_path_buf(),
field: "description",
});
}
Ok((name, description))
}
fn unquote(s: &str) -> String {
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_skill(dir: &Path, name: &str, description: &str) {
let skill_dir = dir.join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: {}\ndescription: {}\n---\n\n# {}\n\nInstructions here.\n",
name, description, name
),
)
.unwrap();
}
#[test]
fn load_skills_from_directory() {
let tmp = TempDir::new().unwrap();
create_skill(tmp.path(), "weather", "Get current weather and forecasts.");
create_skill(tmp.path(), "git", "Git operations: commit, branch, merge.");
let skills = SkillSet::load(&[tmp.path()]).unwrap();
assert_eq!(skills.len(), 2);
assert_eq!(skills.skills()[0].name, "git");
assert_eq!(skills.skills()[1].name, "weather");
}
#[test]
fn format_for_prompt_xml() {
let tmp = TempDir::new().unwrap();
create_skill(tmp.path(), "weather", "Get weather.");
let skills = SkillSet::load(&[tmp.path()]).unwrap();
let prompt = skills.format_for_prompt();
assert!(prompt.contains("<available_skills>"));
assert!(prompt.contains("<name>weather</name>"));
assert!(prompt.contains("<description>Get weather.</description>"));
assert!(prompt.contains("SKILL.md</location>"));
assert!(prompt.contains("</available_skills>"));
}
#[test]
fn empty_when_no_skills() {
let tmp = TempDir::new().unwrap();
let skills = SkillSet::load(&[tmp.path()]).unwrap();
assert!(skills.is_empty());
assert_eq!(skills.format_for_prompt(), "");
}
#[test]
fn later_dirs_override_earlier() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
create_skill(dir1.path(), "weather", "Old description.");
create_skill(dir2.path(), "weather", "New description.");
let skills = SkillSet::load(&[dir1.path(), dir2.path()]).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills.skills()[0].description, "New description.");
}
#[test]
fn skips_nonexistent_dirs() {
let skills = SkillSet::load(&[Path::new("/nonexistent/path")]).unwrap();
assert!(skills.is_empty());
}
#[test]
fn skips_dirs_without_skill_md() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
fs::write(tmp.path().join("not-a-skill/README.md"), "hello").unwrap();
let skills = SkillSet::load(&[tmp.path()]).unwrap();
assert!(skills.is_empty());
}
#[test]
fn error_on_missing_frontmatter() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("bad-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# No frontmatter\n").unwrap();
let result = SkillSet::load(&[tmp.path()]);
assert!(result.is_err());
}
#[test]
fn error_on_missing_name() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("no-name");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: Has desc but no name.\n---\n",
)
.unwrap();
let result = SkillSet::load(&[tmp.path()]);
assert!(result.is_err());
}
#[test]
fn error_on_missing_description() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("no-desc");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "---\nname: no-desc\n---\n").unwrap();
let result = SkillSet::load(&[tmp.path()]);
assert!(result.is_err());
}
#[test]
fn quoted_frontmatter_values() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("quoted");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: \"quoted\"\ndescription: 'A quoted description.'\n---\n",
)
.unwrap();
let skills = SkillSet::load(&[tmp.path()]).unwrap();
assert_eq!(skills.skills()[0].name, "quoted");
assert_eq!(skills.skills()[0].description, "A quoted description.");
}
#[test]
fn xml_escaping() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("escape-test");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: escape-test\ndescription: Uses <tags> & \"quotes\"\n---\n",
)
.unwrap();
let skills = SkillSet::load(&[tmp.path()]).unwrap();
let prompt = skills.format_for_prompt();
assert!(prompt.contains("<tags>"));
assert!(prompt.contains("&"));
assert!(prompt.contains(""quotes""));
}
#[test]
fn merge_skill_sets() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
create_skill(dir1.path(), "weather", "Weather v1.");
create_skill(dir1.path(), "git", "Git operations.");
create_skill(dir2.path(), "weather", "Weather v2.");
create_skill(dir2.path(), "docker", "Docker management.");
let mut set1 = SkillSet::load(&[dir1.path()]).unwrap();
let set2 = SkillSet::load(&[dir2.path()]).unwrap();
set1.merge(set2);
assert_eq!(set1.len(), 3);
let names: Vec<&str> = set1.skills().iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["docker", "git", "weather"]);
assert_eq!(
set1.skills()
.iter()
.find(|s| s.name == "weather")
.unwrap()
.description,
"Weather v2."
);
}
#[test]
fn load_real_agentskills_format() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("nano-banana-pro");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: nano-banana-pro
description: Generate or edit images via Gemini 3 Pro Image.
metadata:
{
"openclaw":
{
"emoji": "🍌",
"requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] },
},
}
---
# Nano Banana Pro
Use the bundled script to generate images.
"#,
)
.unwrap();
let skills = SkillSet::load(&[tmp.path()]).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills.skills()[0].name, "nano-banana-pro");
assert_eq!(
skills.skills()[0].description,
"Generate or edit images via Gemini 3 Pro Image."
);
}
}