use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::error::SkillError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "kebab-case")]
pub enum SkillRuntime {
Lua,
Rhai,
Wasm,
ToolSequence,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CreateToolDirective {
pub name: String,
pub description: String,
pub runtime: SkillRuntime,
pub script: String,
}
#[derive(Debug, Deserialize)]
struct RawFrontmatter {
name: String,
description: String,
#[serde(default)]
license: Option<String>,
#[serde(default)]
compatibility: Option<String>,
#[serde(default)]
metadata: HashMap<String, String>,
#[serde(rename = "allowed-tools", default)]
allowed_tools: Option<String>,
#[serde(default)]
runtime: Option<SkillRuntime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SkillManifest {
pub name: String,
pub description: String,
pub license: Option<String>,
pub compatibility: Option<String>,
pub metadata: HashMap<String, String>,
pub allowed_tools: Vec<String>,
pub runtime: Option<SkillRuntime>,
}
pub fn parse_skill_md(content: &str) -> Result<SkillManifest, SkillError> {
let after_first = content
.strip_prefix("---")
.and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
.ok_or_else(|| {
SkillError::InvalidManifest(
"SKILL.md must begin with a '---' frontmatter delimiter".to_owned(),
)
})?;
let end = after_first.find("\n---").ok_or_else(|| {
SkillError::InvalidManifest(
"SKILL.md frontmatter is not closed with a '---' delimiter".to_owned(),
)
})?;
let frontmatter = &after_first[..end];
let raw: RawFrontmatter = serde_yaml::from_str(frontmatter)?;
validate_name(&raw.name)?;
validate_description(&raw.description)?;
let allowed_tools = raw
.allowed_tools
.as_deref()
.map(|s| {
s.split_whitespace()
.filter(|t| !t.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
Ok(SkillManifest {
name: raw.name,
description: raw.description,
license: raw.license,
compatibility: raw.compatibility,
metadata: raw.metadata,
allowed_tools,
runtime: raw.runtime,
})
}
fn validate_name(name: &str) -> Result<(), SkillError> {
if name.is_empty() {
return Err(SkillError::InvalidManifest(
"skill name must not be empty".to_owned(),
));
}
if name.len() > 64 {
return Err(SkillError::InvalidManifest(format!(
"skill name '{name}' exceeds 64 characters"
)));
}
let valid = name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !valid {
return Err(SkillError::InvalidManifest(format!(
"skill name '{name}' contains invalid characters (only lowercase letters, digits, and hyphens are allowed)"
)));
}
Ok(())
}
fn validate_description(description: &str) -> Result<(), SkillError> {
if description.is_empty() {
return Err(SkillError::InvalidManifest(
"skill description must not be empty".to_owned(),
));
}
if description.len() > 1024 {
return Err(SkillError::InvalidManifest(format!(
"skill description exceeds 1024 characters (got {})",
description.len()
)));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
const VALID_SKILL: &str = r#"---
name: my-skill
description: "Does something useful"
license: MIT
allowed-tools: read write grep
runtime: lua
metadata:
author: test
---
## Instructions
Use this skill to do something.
"#;
#[test]
fn valid_frontmatter_parses_correctly() {
let manifest = parse_skill_md(VALID_SKILL).expect("should parse valid SKILL.md");
assert_eq!(manifest.name, "my-skill");
assert_eq!(manifest.description, "Does something useful");
assert_eq!(manifest.license.as_deref(), Some("MIT"));
assert_eq!(
manifest.allowed_tools,
vec!["read".to_owned(), "write".to_owned(), "grep".to_owned()]
);
assert_eq!(manifest.runtime, Some(SkillRuntime::Lua));
assert_eq!(
manifest.metadata.get("author").map(String::as_str),
Some("test")
);
}
#[test]
fn invalid_name_uppercase_returns_error() {
let content = "---\nname: MySkill\ndescription: \"desc\"\n---\n";
let err = parse_skill_md(content).expect_err("uppercase name should fail");
assert!(
matches!(err, SkillError::InvalidManifest(_)),
"expected InvalidManifest, got {err}"
);
}
#[test]
fn empty_description_returns_error() {
let content = "---\nname: my-skill\ndescription: \"\"\n---\n";
let err = parse_skill_md(content).expect_err("empty description should fail");
assert!(
matches!(err, SkillError::InvalidManifest(_)),
"expected InvalidManifest, got {err}"
);
}
#[test]
fn missing_delimiters_returns_error() {
let content = "name: my-skill\ndescription: desc\n";
let err = parse_skill_md(content).expect_err("missing delimiters should fail");
assert!(
matches!(err, SkillError::InvalidManifest(_)),
"expected InvalidManifest, got {err}"
);
}
#[test]
fn allowed_tools_split_correctly() {
let content =
"---\nname: my-skill\ndescription: \"desc\"\nallowed-tools: read write grep\n---\n";
let manifest = parse_skill_md(content).expect("should parse");
assert_eq!(
manifest.allowed_tools,
vec!["read".to_owned(), "write".to_owned(), "grep".to_owned()]
);
}
#[test]
fn runtime_lua_parses_correctly() {
let content = "---\nname: my-skill\ndescription: \"desc\"\nruntime: lua\n---\n";
let manifest = parse_skill_md(content).expect("should parse");
assert_eq!(manifest.runtime, Some(SkillRuntime::Lua));
}
}