mod discovery;
mod error;
pub use discovery::{default_search_paths, SkillRegistry};
pub use error::SkillError;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::sync::OnceCell;
const MAX_NAME_LENGTH: usize = 64;
const MAX_DESCRIPTION_LENGTH: usize = 1024;
const RESERVED_WORDS: &[&str] = &["anthropic", "claude"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
}
impl SkillMetadata {
fn validate(&self) -> Result<(), SkillError> {
validate_skill_name(&self.name)?;
validate_skill_description(&self.description)?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Skill {
metadata: SkillMetadata,
path: PathBuf,
instructions: OnceCell<String>,
}
impl Skill {
pub async fn from_path(path: PathBuf) -> Result<Self, SkillError> {
if !path.is_dir() {
return Err(SkillError::DirectoryNotFound(path));
}
let skill_file = path.join("SKILL.md");
if !skill_file.exists() {
return Err(SkillError::FileNotFound(skill_file));
}
let content = fs::read_to_string(&skill_file)
.await
.map_err(|e| SkillError::read_error(skill_file.clone(), e))?;
let metadata = parse_frontmatter(&skill_file, &content)?;
metadata.validate()?;
Ok(Self {
metadata,
path,
instructions: OnceCell::new(),
})
}
pub fn metadata(&self) -> &SkillMetadata {
&self.metadata
}
pub fn name(&self) -> &str {
&self.metadata.name
}
pub fn description(&self) -> &str {
&self.metadata.description
}
pub fn path(&self) -> &Path {
&self.path
}
pub async fn load_instructions(&self) -> Result<&str, SkillError> {
self.instructions
.get_or_try_init(|| async {
let skill_file = self.path.join("SKILL.md");
let content = fs::read_to_string(&skill_file)
.await
.map_err(|e| SkillError::load_instructions_error(&self.metadata.name, e))?;
let instructions = extract_content_after_frontmatter(&content);
Ok::<String, SkillError>(instructions.to_string())
})
.await
.map(|s| s.as_str())
}
pub fn matches(&self, request: &str) -> bool {
let request_lower = request.to_lowercase();
let description_lower = self.description().to_lowercase();
if request_lower.contains(&description_lower) || description_lower.contains(&request_lower)
{
return true;
}
let keywords: Vec<&str> = description_lower
.split_whitespace()
.filter(|w| w.len() > 2) .collect();
let request_words: Vec<&str> = request_lower.split_whitespace().collect();
keywords.iter().any(|kw| {
if request_lower.contains(kw) {
return true;
}
request_words.iter().any(|req_word| {
let min_len = std::cmp::min(kw.len(), req_word.len());
if min_len >= 5 {
let common_prefix_len = kw
.chars()
.zip(req_word.chars())
.take_while(|(a, b)| a == b)
.count();
common_prefix_len >= 5
} else {
false
}
})
})
}
pub async fn list_resources(&self) -> Result<Vec<String>, SkillError> {
list_skill_resources(&self.path, &self.metadata.name).await
}
}
fn parse_frontmatter(path: &Path, content: &str) -> Result<SkillMetadata, SkillError> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() || lines[0] != "---" {
return Err(SkillError::InvalidFrontmatter {
path: path.to_path_buf(),
source: serde_yaml::from_str::<()>("invalid").unwrap_err(),
});
}
let end_idx = lines
.iter()
.skip(1)
.position(|&line| line == "---")
.ok_or_else(|| SkillError::InvalidFrontmatter {
path: path.to_path_buf(),
source: serde_yaml::from_str::<()>("invalid").unwrap_err(),
})?
+ 1;
let frontmatter_content = lines[1..end_idx].join("\n");
let metadata: SkillMetadata = serde_yaml::from_str(&frontmatter_content)
.map_err(|e| SkillError::invalid_frontmatter(path.to_path_buf(), e))?;
Ok(metadata)
}
fn extract_content_after_frontmatter(content: &str) -> &str {
let lines: Vec<&str> = content.lines().collect();
if let Some(end_idx) = lines.iter().skip(1).position(|&line| line == "---") {
let start_idx = end_idx + 2; if start_idx < lines.len() {
let offset: usize = lines[..start_idx].iter().map(|l| l.len() + 1).sum();
return &content[offset..];
}
}
content
}
fn validate_skill_name(name: &str) -> Result<(), SkillError> {
if name.is_empty() {
return Err(SkillError::invalid_name(name, "Name cannot be empty"));
}
if name.len() > MAX_NAME_LENGTH {
return Err(SkillError::invalid_name(
name,
format!("Name exceeds maximum length of {}", MAX_NAME_LENGTH),
));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SkillError::invalid_name(
name,
"Name must contain only lowercase letters, numbers, and hyphens",
));
}
for reserved in RESERVED_WORDS {
if name.contains(reserved) {
return Err(SkillError::invalid_name(
name,
format!("Name cannot contain reserved word '{}'", reserved),
));
}
}
Ok(())
}
fn validate_skill_description(description: &str) -> Result<(), SkillError> {
if description.trim().is_empty() {
return Err(SkillError::InvalidDescription(
"Description cannot be empty".to_string(),
));
}
if description.len() > MAX_DESCRIPTION_LENGTH {
return Err(SkillError::InvalidDescription(format!(
"Description exceeds maximum length of {}",
MAX_DESCRIPTION_LENGTH
)));
}
if description.contains('<') && description.contains('>') {
return Err(SkillError::InvalidDescription(
"Description cannot contain XML tags".to_string(),
));
}
Ok(())
}
async fn list_skill_resources(
skill_path: &Path,
skill_name: &str,
) -> Result<Vec<String>, SkillError> {
let mut resources = Vec::new();
let mut entries = fs::read_dir(skill_path)
.await
.map_err(|e| SkillError::list_resources_error(skill_name, e))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| SkillError::list_resources_error(skill_name, e))?
{
let path = entry.path();
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if file_name == "SKILL.md" {
continue;
}
if path.is_file() {
resources.push(file_name);
} else if path.is_dir() {
let subdir_resources =
list_directory_recursive(path, skill_name.to_string(), file_name).await?;
resources.extend(subdir_resources);
}
}
resources.sort();
Ok(resources)
}
fn list_directory_recursive(
dir_path: PathBuf,
skill_name: String,
prefix: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<String>, SkillError>> + Send>> {
Box::pin(async move {
let mut resources = Vec::new();
let mut entries = fs::read_dir(&dir_path)
.await
.map_err(|e| SkillError::list_resources_error(&skill_name, e))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| SkillError::list_resources_error(&skill_name, e))?
{
let path = entry.path();
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let relative_path = format!("{}/{}", prefix, file_name);
if path.is_file() {
resources.push(relative_path);
} else if path.is_dir() {
let subdir_resources =
list_directory_recursive(path, skill_name.clone(), relative_path).await?;
resources.extend(subdir_resources);
}
}
Ok(resources)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_skill_name() {
assert!(validate_skill_name("hello-world").is_ok());
assert!(validate_skill_name("test123").is_ok());
assert!(validate_skill_name("my-skill-v2").is_ok());
assert!(validate_skill_name("").is_err()); assert!(validate_skill_name("Hello-World").is_err()); assert!(validate_skill_name("hello_world").is_err()); assert!(validate_skill_name("hello world").is_err()); assert!(validate_skill_name("anthropic-skill").is_err()); assert!(validate_skill_name("claude-helper").is_err()); assert!(validate_skill_name(&"a".repeat(65)).is_err()); }
#[test]
fn test_validate_skill_description() {
assert!(validate_skill_description("A simple test skill").is_ok());
assert!(validate_skill_description("Use when you need to process files").is_ok());
assert!(validate_skill_description("").is_err()); assert!(validate_skill_description(" ").is_err()); assert!(validate_skill_description("<script>alert('xss')</script>").is_err()); assert!(validate_skill_description(&"a".repeat(1025)).is_err()); }
#[test]
fn test_extract_content_after_frontmatter() {
let content = r#"---
name: test
description: A test skill
---
# Test Skill
This is the content.
"#;
let extracted = extract_content_after_frontmatter(content);
assert!(extracted.starts_with("\n# Test Skill"));
assert!(extracted.contains("This is the content."));
}
}