mod builtin;
mod registry;
pub mod validator;
pub use builtin::builtin_skills;
pub use registry::SkillRegistry;
pub use validator::{
DefaultSkillValidator, SkillValidationError, SkillValidator, ValidationErrorKind,
};
use serde::{de, Deserialize, Deserializer, Serialize};
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SkillKind {
#[default]
Instruction,
Persona,
Tool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ToolPermission {
pub tool: String,
pub pattern: String,
}
impl ToolPermission {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
let open = s.find('(')?;
let close = s.rfind(')')?;
if close <= open {
return None;
}
let tool = s[..open].trim().to_string();
let pattern = s[open + 1..close].trim().to_string();
Some(ToolPermission { tool, pattern })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(
default,
rename = "allowed-tools",
deserialize_with = "deserialize_allowed_tools"
)]
pub allowed_tools: Option<String>,
#[serde(default, rename = "disable-model-invocation")]
pub disable_model_invocation: bool,
#[serde(default)]
pub kind: SkillKind,
#[serde(skip)]
pub content: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub version: Option<String>,
}
impl Skill {
pub fn parse(content: &str) -> Option<Self> {
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return None;
}
let frontmatter = parts[1].trim();
let body = parts[2].trim();
let mut skill: Skill = serde_yaml::from_str(frontmatter).ok()?;
skill.content = body.to_string();
Some(skill)
}
pub fn from_file(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path.as_ref())?;
let mut skill =
Self::parse(&content).ok_or_else(|| anyhow::anyhow!("Failed to parse skill file"))?;
if skill.name.is_empty() {
if let Some(stem) = path.as_ref().file_stem() {
skill.name = stem.to_string_lossy().to_string();
}
}
Ok(skill)
}
pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
let mut permissions = HashSet::new();
let Some(allowed) = &self.allowed_tools else {
return permissions;
};
let parts: Vec<&str> = if allowed.contains(',') {
allowed.split(',').collect()
} else if ToolPermission::parse(allowed).is_some() {
vec![allowed.as_str()]
} else {
let parts: Vec<&str> = allowed.split_whitespace().collect();
if parts.len() > 1 {
tracing::warn!(
skill = %self.name,
allowed_tools = %allowed,
"Legacy whitespace-separated allowed-tools is deprecated; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
);
}
parts
};
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(perm) = ToolPermission::parse(part) {
permissions.insert(perm);
} else {
permissions.insert(ToolPermission {
tool: part.to_string(),
pattern: "*".to_string(),
});
}
}
permissions
}
pub fn uses_legacy_allowed_tools_syntax(&self) -> bool {
let Some(allowed) = &self.allowed_tools else {
return false;
};
!allowed.contains(',')
&& ToolPermission::parse(allowed).is_none()
&& allowed.split_whitespace().count() > 1
}
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
let permissions = self.parse_allowed_tools();
if permissions.is_empty() {
return false;
}
permissions
.iter()
.any(|perm| perm.tool.eq_ignore_ascii_case(tool_name) && perm.pattern == "*")
}
pub fn to_system_prompt(&self) -> String {
format!(
"# Skill: {}\n\n{}\n\n{}",
self.name, self.description, self.content
)
}
}
fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_yaml::Value::Null) => Ok(None),
Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
Some(serde_yaml::Value::Sequence(items)) => {
let mut tools = Vec::new();
for item in items {
match item {
serde_yaml::Value::String(s) => tools.push(s),
other => {
return Err(de::Error::custom(format!(
"allowed-tools list entries must be strings, got {other:?}"
)));
}
}
}
Ok(Some(tools.join(", ")))
}
Some(other) => Err(de::Error::custom(format!(
"allowed-tools must be a string or a list of strings, got {other:?}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_skill() {
let content = r#"---
name: test-skill
description: A test skill
allowed-tools: "read(*), grep(*)"
kind: instruction
---
# Instructions
You are a test assistant.
"#;
let skill = Skill::parse(content).unwrap();
assert_eq!(skill.name, "test-skill");
assert_eq!(skill.description, "A test skill");
assert_eq!(skill.kind, SkillKind::Instruction);
assert!(skill.content.contains("You are a test assistant"));
}
#[test]
fn test_parse_tool_permission() {
let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
assert_eq!(perm.tool, "Bash");
assert_eq!(perm.pattern, "gh issue view:*");
let perm = ToolPermission::parse("read(*)").unwrap();
assert_eq!(perm.tool, "read");
assert_eq!(perm.pattern, "*");
}
#[test]
fn test_parse_allowed_tools() {
let skill = Skill {
name: "test".to_string(),
description: "test".to_string(),
allowed_tools: Some("read(*), grep(*), Bash(gh:*)".to_string()),
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};
let permissions = skill.parse_allowed_tools();
assert_eq!(permissions.len(), 3);
}
#[test]
fn test_parse_legacy_whitespace_allowed_tools() {
let skill = Skill {
name: "test".to_string(),
description: "test".to_string(),
allowed_tools: Some("Read Write Edit Bash".to_string()),
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};
let permissions = skill.parse_allowed_tools();
assert_eq!(permissions.len(), 4);
assert!(skill.uses_legacy_allowed_tools_syntax());
assert!(permissions
.iter()
.any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
}
#[test]
fn test_parse_single_allowed_tool_with_spaces() {
let skill = Skill {
name: "test".to_string(),
description: "test".to_string(),
allowed_tools: Some("Bash(uv run skills analyze-ci:*)".to_string()),
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};
let permissions = skill.parse_allowed_tools();
assert_eq!(permissions.len(), 1);
assert!(permissions
.iter()
.any(|perm| { perm.tool == "Bash" && perm.pattern == "uv run skills analyze-ci:*" }));
assert!(!skill.uses_legacy_allowed_tools_syntax());
}
#[test]
fn test_parse_allowed_tools_yaml_list() {
let content = r#"---
name: test-skill
description: A test skill
allowed-tools:
- Read
- Write
- Bash(uv run skills analyze-ci:*)
---
# Instructions
"#;
let skill = Skill::parse(content).unwrap();
assert_eq!(
skill.allowed_tools.as_deref(),
Some("Read, Write, Bash(uv run skills analyze-ci:*)")
);
let permissions = skill.parse_allowed_tools();
assert_eq!(permissions.len(), 3);
assert!(permissions
.iter()
.any(|perm| perm.tool == "Read" && perm.pattern == "*"));
}
#[test]
fn test_is_tool_allowed() {
let skill = Skill {
name: "test".to_string(),
description: "test".to_string(),
allowed_tools: Some("read(*), grep(*)".to_string()),
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};
assert!(skill.is_tool_allowed("read"));
assert!(skill.is_tool_allowed("grep"));
assert!(!skill.is_tool_allowed("write"));
}
#[test]
fn test_omitted_allowed_tools_does_not_allow_tools() {
let skill = Skill {
name: "test".to_string(),
description: "test".to_string(),
allowed_tools: None,
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};
assert!(!skill.is_tool_allowed("read"));
}
}