use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
pub const MAX_NAME_LENGTH: usize = 64;
pub const MAX_DESCRIPTION_LENGTH: usize = 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<toml::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "allowed-tools")]
pub allowed_tools: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
#[serde(rename = "disable-model-invocation")]
pub disable_model_invocation: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationSeverity {
Error,
Warning,
}
impl fmt::Display for ValidationSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationSeverity::Error => write!(f, "error"),
ValidationSeverity::Warning => write!(f, "warning"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationFinding {
pub severity: ValidationSeverity,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub valid: bool,
pub findings: Vec<ValidationFinding>,
}
impl ValidationResult {
pub fn pass() -> Self {
Self {
valid: true,
findings: Vec::new(),
}
}
pub fn fail(findings: Vec<ValidationFinding>) -> Self {
let has_errors = findings
.iter()
.any(|f| f.severity == ValidationSeverity::Error);
Self {
valid: !has_errors,
findings,
}
}
pub fn add(&mut self, severity: ValidationSeverity, message: impl Into<String>) {
if severity == ValidationSeverity::Error {
self.valid = false;
}
self.findings.push(ValidationFinding {
severity,
message: message.into(),
path: None,
});
}
pub fn add_with_path(
&mut self,
severity: ValidationSeverity,
message: impl Into<String>,
path: impl Into<String>,
) {
if severity == ValidationSeverity::Error {
self.valid = false;
}
self.findings.push(ValidationFinding {
severity,
message: message.into(),
path: Some(path.into()),
});
}
pub fn has_errors(&self) -> bool {
self.findings
.iter()
.any(|f| f.severity == ValidationSeverity::Error)
}
pub fn has_warnings(&self) -> bool {
self.findings
.iter()
.any(|f| f.severity == ValidationSeverity::Warning)
}
}
pub struct SkillValidator;
impl SkillValidator {
pub fn validate_name(name: &str) -> Vec<ValidationFinding> {
let mut findings = Vec::new();
if name.is_empty() {
findings.push(ValidationFinding {
severity: ValidationSeverity::Error,
message: "name is required".to_string(),
path: None,
});
return findings;
}
if name.len() > MAX_NAME_LENGTH {
findings.push(ValidationFinding {
severity: ValidationSeverity::Warning,
message: format!(
"name exceeds {} characters ({} chars)",
MAX_NAME_LENGTH,
name.len()
),
path: None,
});
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
findings.push(ValidationFinding {
severity: ValidationSeverity::Warning,
message:
"name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"
.to_string(),
path: None,
});
}
if name.starts_with('-') || name.ends_with('-') {
findings.push(ValidationFinding {
severity: ValidationSeverity::Warning,
message: "name must not start or end with a hyphen".to_string(),
path: None,
});
}
if name.contains("--") {
findings.push(ValidationFinding {
severity: ValidationSeverity::Warning,
message: "name must not contain consecutive hyphens".to_string(),
path: None,
});
}
findings
}
pub fn validate_description(description: &str) -> Vec<ValidationFinding> {
let mut findings = Vec::new();
if description.trim().is_empty() {
findings.push(ValidationFinding {
severity: ValidationSeverity::Error,
message: "description is required".to_string(),
path: None,
});
} else if description.len() > MAX_DESCRIPTION_LENGTH {
findings.push(ValidationFinding {
severity: ValidationSeverity::Warning,
message: format!(
"description exceeds {} characters ({} chars)",
MAX_DESCRIPTION_LENGTH,
description.len()
),
path: None,
});
}
findings
}
pub fn validate_name_matches_dir(name: &str, dir_path: &Path) -> Vec<ValidationFinding> {
let dir_name = dir_path.file_name().unwrap_or_default().to_string_lossy();
if name != dir_name {
vec![ValidationFinding {
severity: ValidationSeverity::Warning,
message: format!(
"name \"{}\" does not match parent directory \"{}\"",
name, dir_name
),
path: Some(dir_path.to_string_lossy().to_string()),
}]
} else {
Vec::new()
}
}
pub fn validate_skill_dir(dir: &Path) -> ValidationResult {
let mut result = ValidationResult::pass();
let skill_file = dir.join("SKILL.md");
if !skill_file.exists() {
result.add_with_path(
ValidationSeverity::Error,
"SKILL.md not found in skill directory",
dir.to_string_lossy(),
);
return result;
}
let content = match fs::read_to_string(&skill_file) {
Ok(c) => c,
Err(e) => {
result.add_with_path(
ValidationSeverity::Error,
format!("Failed to read SKILL.md: {}", e),
skill_file.to_string_lossy(),
);
return result;
}
};
let frontmatter = match Self::parse_frontmatter(&content) {
Ok(fm) => fm,
Err(e) => {
result.add_with_path(
ValidationSeverity::Error,
format!("Failed to parse frontmatter: {}", e),
skill_file.to_string_lossy(),
);
return result;
}
};
for finding in Self::validate_name(&frontmatter.name) {
result.add(finding.severity, finding.message);
}
for finding in Self::validate_name_matches_dir(&frontmatter.name, dir) {
result.findings.push(finding);
if result.has_errors() {
result.valid = false;
}
}
for finding in Self::validate_description(&frontmatter.description) {
result.add(finding.severity, finding.message);
}
if let Some(ref compat) = frontmatter.compatibility {
if compat.len() > 500 {
result.add(
ValidationSeverity::Warning,
format!(
"compatibility exceeds 500 characters ({} chars)",
compat.len()
),
);
}
}
result
}
fn parse_frontmatter(content: &str) -> Result<SkillFrontmatter> {
let trimmed = content.trim();
if !trimmed.starts_with("---") {
bail!("SKILL.md must start with YAML frontmatter (---)");
}
let rest = &trimmed[3..];
let end = rest
.find("---")
.context("Unclosed frontmatter — missing closing ---")?;
let yaml_str = &rest[..end];
let frontmatter: SkillFrontmatter =
serde_yaml::from_str(yaml_str).context("Invalid YAML frontmatter")?;
Ok(frontmatter)
}
}
pub struct SkillBuilder {
name: String,
description: String,
body: String,
license: Option<String>,
compatibility: Option<String>,
allowed_tools: Vec<String>,
disable_model_invocation: bool,
}
impl SkillBuilder {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
body: String::new(),
license: None,
compatibility: None,
allowed_tools: Vec::new(),
disable_model_invocation: false,
}
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
pub fn license(mut self, license: impl Into<String>) -> Self {
self.license = Some(license.into());
self
}
pub fn compatibility(mut self, compat: impl Into<String>) -> Self {
self.compatibility = Some(compat.into());
self
}
pub fn allowed_tool(mut self, tool: impl Into<String>) -> Self {
self.allowed_tools.push(tool.into());
self
}
pub fn disable_model_invocation(mut self, disabled: bool) -> Self {
self.disable_model_invocation = disabled;
self
}
pub fn validate(&self) -> ValidationResult {
let mut result = ValidationResult::pass();
for finding in SkillValidator::validate_name(&self.name) {
result.add(finding.severity, finding.message);
}
for finding in SkillValidator::validate_description(&self.description) {
result.add(finding.severity, finding.message);
}
if self.body.trim().is_empty() {
result.add(ValidationSeverity::Warning, "skill body is empty");
}
result
}
pub fn render_skill_md(&self) -> String {
let mut md = String::with_capacity(1024);
md.push_str("---\n");
md.push_str(&format!("name: {}\n", self.name));
md.push_str(&format!(
"description: {}\n",
escape_yaml_string(&self.description)
));
if let Some(ref license) = self.license {
md.push_str(&format!("license: {}\n", escape_yaml_string(license)));
}
if let Some(ref compat) = self.compatibility {
md.push_str(&format!("compatibility: {}\n", escape_yaml_string(compat)));
}
if !self.allowed_tools.is_empty() {
md.push_str(&format!(
"allowed-tools: {}\n",
self.allowed_tools.join(" ")
));
}
if self.disable_model_invocation {
md.push_str("disable-model-invocation: true\n");
}
md.push_str("---\n\n");
if !self.body.is_empty() {
md.push_str(&self.body);
if !self.body.ends_with('\n') {
md.push('\n');
}
} else {
md.push_str(&format!("# {}\n\n", capitalize_words(&self.name)));
md.push_str(&self.description);
md.push_str("\n\n## Usage\n\nTODO: Add usage instructions.\n");
}
md
}
pub fn build(&self, parent_dir: &Path) -> Result<PathBuf> {
let validation = self.validate();
if validation.has_errors() {
let errors: Vec<&str> = validation
.findings
.iter()
.filter(|f| f.severity == ValidationSeverity::Error)
.map(|f| f.message.as_str())
.collect();
bail!("Validation errors: {}", errors.join("; "));
}
let skill_dir = parent_dir.join(&self.name);
fs::create_dir_all(&skill_dir)
.with_context(|| format!("Failed to create skill directory {}", skill_dir.display()))?;
let skill_md = self.render_skill_md();
let skill_path = skill_dir.join("SKILL.md");
fs::write(&skill_path, &skill_md)
.with_context(|| format!("Failed to write {}", skill_path.display()))?;
Ok(skill_dir)
}
}
pub struct AgentSkill;
impl AgentSkill {
pub fn new() -> Self {
Self
}
pub fn skill_prompt() -> String {
r#"# Agent Skill
You are running the **agent-skill** skill. You create, validate, and
manage Agent Skills following the [Agent Skills standard](https://agentskills.io/specification).
## Skill Structure
A skill is a directory with a `SKILL.md` file:
```
my-skill/
├── SKILL.md # Required: frontmatter + instructions
├── scripts/ # Optional: helper scripts
│ └── process.sh
├── references/ # Optional: detailed docs loaded on-demand
│ └── api-reference.md
└── assets/ # Optional: templates, configs
└── template.json
```
## SKILL.md Format
```markdown
---
name: my-skill
description: What this skill does and when to use it. Be specific.
---
# My Skill
## Usage
Do X then Y.
```
## Frontmatter Rules
| Field | Required | Rules |
|-------|----------|-------|
| `name` | Yes | 1-64 chars, lowercase a-z/0-9/hyphens, no leading/trailing hyphens, no consecutive hyphens, must match parent directory |
| `description` | Yes | 1-1024 chars, describes what the skill does and when to use it |
| `license` | No | License identifier |
| `compatibility` | No | Max 500 chars, environment requirements |
| `allowed-tools` | No | Space-delimited pre-approved tools |
| `disable-model-invocation` | No | When true, hidden from system prompt |
## Validation Checklist
- [ ] Name matches parent directory name
- [ ] Name is lowercase, 1-64 chars, valid characters only
- [ ] Description is present and specific
- [ ] SKILL.md starts and ends with `---` frontmatter delimiters
- [ ] Instructions are clear and actionable
- [ ] Relative paths are used for files within the skill directory
"#
.to_string()
}
}
impl Default for AgentSkill {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for AgentSkill {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentSkill").finish()
}
}
fn escape_yaml_string(s: &str) -> String {
if s.contains(':') || s.contains('#') || s.contains('"') || s.contains('\'') || s.contains('\n')
{
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
fn capitalize_words(s: &str) -> String {
s.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + &chars.as_str().to_lowercase()
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_name_valid() {
let findings = SkillValidator::validate_name("pdf-tools");
assert!(findings.is_empty());
}
#[test]
fn test_validate_name_empty() {
let findings = SkillValidator::validate_name("");
assert!(findings
.iter()
.any(|f| f.severity == ValidationSeverity::Error));
}
#[test]
fn test_validate_name_too_long() {
let name = "a".repeat(65);
let findings = SkillValidator::validate_name(&name);
assert!(findings
.iter()
.any(|f| f.message.contains("exceeds 64 characters")));
}
#[test]
fn test_validate_name_at_limit() {
let name = "a".repeat(64);
let findings = SkillValidator::validate_name(&name);
assert!(!findings.iter().any(|f| f.message.contains("exceeds")));
}
#[test]
fn test_validate_name_uppercase() {
let findings = SkillValidator::validate_name("My-Skill");
assert!(findings
.iter()
.any(|f| f.message.contains("invalid characters")));
}
#[test]
fn test_validate_name_leading_hyphen() {
let findings = SkillValidator::validate_name("-skill");
assert!(findings
.iter()
.any(|f| f.message.contains("start or end with a hyphen")));
}
#[test]
fn test_validate_name_trailing_hyphen() {
let findings = SkillValidator::validate_name("skill-");
assert!(findings
.iter()
.any(|f| f.message.contains("start or end with a hyphen")));
}
#[test]
fn test_validate_name_consecutive_hyphens() {
let findings = SkillValidator::validate_name("my--skill");
assert!(findings
.iter()
.any(|f| f.message.contains("consecutive hyphens")));
}
#[test]
fn test_validate_name_with_numbers() {
let findings = SkillValidator::validate_name("pdf2text");
assert!(findings.is_empty());
}
#[test]
fn test_validate_description_valid() {
let findings = SkillValidator::validate_description("A useful skill");
assert!(findings.is_empty());
}
#[test]
fn test_validate_description_empty() {
let findings = SkillValidator::validate_description("");
assert!(findings
.iter()
.any(|f| f.severity == ValidationSeverity::Error));
}
#[test]
fn test_validate_description_whitespace_only() {
let findings = SkillValidator::validate_description(" ");
assert!(findings
.iter()
.any(|f| f.severity == ValidationSeverity::Error));
}
#[test]
fn test_validate_description_too_long() {
let desc = "x".repeat(1025);
let findings = SkillValidator::validate_description(&desc);
assert!(findings
.iter()
.any(|f| f.message.contains("exceeds 1024 characters")));
}
#[test]
fn test_validate_description_at_limit() {
let desc = "x".repeat(1024);
let findings = SkillValidator::validate_description(&desc);
assert!(!findings.iter().any(|f| f.message.contains("exceeds")));
}
#[test]
fn test_validate_name_matches_dir() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("my-skill");
fs::create_dir_all(&dir).unwrap();
let findings = SkillValidator::validate_name_matches_dir("my-skill", &dir);
assert!(findings.is_empty());
}
#[test]
fn test_validate_name_mismatches_dir() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("other-skill");
fs::create_dir_all(&dir).unwrap();
let findings = SkillValidator::validate_name_matches_dir("my-skill", &dir);
assert!(!findings.is_empty());
assert!(findings[0]
.message
.contains("does not match parent directory"));
}
#[test]
fn test_validate_skill_dir_valid() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("my-skill");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: A test skill\n---\n\n# My Skill\n",
)
.unwrap();
let result = SkillValidator::validate_skill_dir(&dir);
assert!(result.valid);
assert!(result.findings.is_empty());
}
#[test]
fn test_validate_skill_dir_no_skill_md() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("my-skill");
fs::create_dir_all(&dir).unwrap();
let result = SkillValidator::validate_skill_dir(&dir);
assert!(!result.valid);
assert!(result
.findings
.iter()
.any(|f| f.message.contains("SKILL.md not found")));
}
#[test]
fn test_validation_result_pass() {
let result = ValidationResult::pass();
assert!(result.valid);
assert!(result.findings.is_empty());
}
#[test]
fn test_validation_result_add_error() {
let mut result = ValidationResult::pass();
result.add(ValidationSeverity::Error, "something wrong");
assert!(!result.valid);
assert!(result.has_errors());
}
#[test]
fn test_validation_result_add_warning() {
let mut result = ValidationResult::pass();
result.add(ValidationSeverity::Warning, "minor issue");
assert!(result.valid);
assert!(!result.has_errors());
assert!(result.has_warnings());
}
#[test]
fn test_builder_new() {
let builder = SkillBuilder::new("my-skill", "A test skill");
assert_eq!(builder.name, "my-skill");
assert_eq!(builder.description, "A test skill");
}
#[test]
fn test_builder_validate_valid() {
let builder = SkillBuilder::new("my-skill", "A test skill");
let result = builder.validate();
assert!(result.valid);
}
#[test]
fn test_builder_validate_bad_name() {
let builder = SkillBuilder::new("MY-SKILL", "A test skill");
let result = builder.validate();
assert!(result.has_warnings());
}
#[test]
fn test_builder_validate_empty_description() {
let builder = SkillBuilder::new("my-skill", "");
let result = builder.validate();
assert!(result.has_errors());
}
#[test]
fn test_builder_render_skill_md() {
let builder = SkillBuilder::new("pdf-tools", "Extract text from PDFs")
.body("# PDF Tools\n\n## Usage\n\n```bash\npdftotext input.pdf\n```");
let md = builder.render_skill_md();
assert!(md.starts_with("---\n"));
assert!(md.contains("name: pdf-tools"));
assert!(md.contains("description: Extract text from PDFs"));
assert!(md.contains("# PDF Tools"));
}
#[test]
fn test_builder_render_with_options() {
let builder = SkillBuilder::new("my-skill", "Test")
.license("MIT")
.compatibility("Node.js >= 18")
.allowed_tool("read")
.allowed_tool("bash")
.disable_model_invocation(true);
let md = builder.render_skill_md();
assert!(md.contains("license: MIT"));
assert!(md.contains("compatibility: Node.js >= 18"));
assert!(md.contains("allowed-tools: read bash"));
assert!(md.contains("disable-model-invocation: true"));
}
#[test]
fn test_builder_build() {
let tmp = tempfile::tempdir().unwrap();
let builder = SkillBuilder::new("my-skill", "A test skill for building");
let dir = builder.build(tmp.path()).unwrap();
assert!(dir.exists());
assert_eq!(dir.file_name().unwrap(), "my-skill");
let skill_md = dir.join("SKILL.md");
assert!(skill_md.exists());
let content = fs::read_to_string(&skill_md).unwrap();
assert!(content.contains("name: my-skill"));
}
#[test]
fn test_builder_build_invalid_fails() {
let tmp = tempfile::tempdir().unwrap();
let builder = SkillBuilder::new("MY BAD SKILL", "");
assert!(builder.build(tmp.path()).is_err());
}
#[test]
fn test_skill_prompt_not_empty() {
let prompt = AgentSkill::skill_prompt();
assert!(prompt.contains("Agent Skill"));
assert!(prompt.contains("SKILL.md"));
}
#[test]
fn test_parse_frontmatter_valid() {
let content = "---\nname: my-skill\ndescription: A test\n---\n\n# Body";
let fm = SkillValidator::parse_frontmatter(content).unwrap();
assert_eq!(fm.name, "my-skill");
assert_eq!(fm.description, "A test");
}
#[test]
fn test_parse_frontmatter_no_delimiter() {
let content = "# Just markdown";
assert!(SkillValidator::parse_frontmatter(content).is_err());
}
#[test]
fn test_parse_frontmatter_unclosed() {
let content = "---\nname: my-skill";
assert!(SkillValidator::parse_frontmatter(content).is_err());
}
#[test]
fn test_capitalize_words() {
assert_eq!(capitalize_words("pdf-tools"), "Pdf Tools");
assert_eq!(capitalize_words("my-skill"), "My Skill");
assert_eq!(capitalize_words("a"), "A");
assert_eq!(capitalize_words(""), "");
}
#[test]
fn test_full_create_and_validate() {
let tmp = tempfile::tempdir().unwrap();
let builder = SkillBuilder::new("web-search", "Search the web using Brave Search API")
.body("# Web Search\n\n## Usage\n\n```bash\n./scripts/search.sh \"query\"\n```")
.allowed_tool("bash")
.allowed_tool("read");
let dir = builder.build(tmp.path()).unwrap();
let result = SkillValidator::validate_skill_dir(&dir);
assert!(result.valid, "Validation failed: {:?}", result.findings);
}
}