use indexmap::IndexMap;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, RwLock};
use tokio::io::AsyncReadExt;
use crate::tool::{SharedState, Tool, ToolError, ToolSchema};
const MAX_SKILL_FILE_BYTES: u64 = 1024 * 1024;
const MAX_SKILL_BODY_CHARS: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowedTool {
pub name: String,
pub scope: Option<String>,
}
impl AllowedTool {
pub fn permits(&self, tool: &str, args: &str) -> bool {
if self.name != tool {
return false;
}
match &self.scope {
None => true,
Some(scope) => {
let prefix = scope.strip_suffix('*').unwrap_or(scope);
args.starts_with(prefix)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
name: String,
description: String,
body: String,
license: Option<String>,
compatibility: Option<String>,
metadata: Vec<(String, String)>,
allowed_tools: Vec<AllowedTool>,
resources: Vec<PathBuf>,
base_dir: Option<PathBuf>,
}
impl Skill {
pub fn parse(content: &str) -> Result<Skill, SkillError> {
let (fm, body) = parse_frontmatter(content)?;
let name = fm
.name
.ok_or_else(|| SkillError::InvalidName("missing name field".into()))?;
validate_name(&name)?;
let description = fm
.description
.ok_or_else(|| SkillError::InvalidDescription("missing description field".into()))?;
validate_description(&description)?;
if body.chars().count() > MAX_SKILL_BODY_CHARS {
return Err(SkillError::InvalidBody(format!(
"body exceeds size limit ({MAX_SKILL_BODY_CHARS} chars)"
)));
}
Ok(Skill {
name,
description,
body,
license: fm.license,
compatibility: fm.compatibility,
metadata: fm.metadata,
allowed_tools: fm.allowed_tools,
resources: Vec::new(),
base_dir: None,
})
}
pub async fn from_dir(path: &Path) -> Result<Skill, SkillError> {
let dir = tokio::fs::canonicalize(path)
.await
.map_err(|e| SkillError::Io(format!("cannot resolve skill root: {e}")))?;
let content = {
let skill_md = match tokio::fs::canonicalize(dir.join("SKILL.md")).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(SkillError::NotFound(format!(
"no SKILL.md in directory: {}",
dir.display()
)));
}
Err(e) => return Err(e.into()),
};
if !skill_md.starts_with(&dir) {
return Err(SkillError::NotFound("SKILL.md escapes skill root".into()));
}
let mut buf = String::new();
let file = tokio::fs::File::open(&skill_md).await?;
file.take(MAX_SKILL_FILE_BYTES + 1)
.read_to_string(&mut buf)
.await?;
if buf.len() > MAX_SKILL_FILE_BYTES as usize {
return Err(SkillError::InvalidBody(format!(
"SKILL.md exceeds size limit ({MAX_SKILL_FILE_BYTES} bytes)"
)));
}
buf
};
let mut skill = Skill::parse(&content)?;
let dir_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if skill.name != dir_name {
return Err(SkillError::NameMismatch {
name: skill.name.clone(),
dir: dir_name,
});
}
skill.resources = collect_resources(&dir).await;
skill.base_dir = Some(dir);
Ok(skill)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> &str {
&self.description
}
pub fn body(&self) -> &str {
&self.body
}
pub fn license(&self) -> Option<&str> {
self.license.as_deref()
}
pub fn compatibility(&self) -> Option<&str> {
self.compatibility.as_deref()
}
pub fn metadata(&self) -> &[(String, String)] {
&self.metadata
}
pub fn allowed_tools(&self) -> &[AllowedTool] {
&self.allowed_tools
}
pub fn resources(&self) -> &[PathBuf] {
&self.resources
}
pub fn base_dir(&self) -> Option<&Path> {
self.base_dir.as_deref()
}
pub async fn load_reference(&self, name: &str) -> Result<String, SkillError> {
let Some(base) = &self.base_dir else {
return Err(SkillError::NotFound(
"no resource directory: skill parsed from text".into(),
));
};
let name_path = Path::new(name);
if name_path.is_absolute()
|| name_path.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(SkillError::NotFound(format!(
"invalid resource path: {name}"
)));
}
let base = tokio::fs::canonicalize(base)
.await
.map_err(|e| SkillError::Io(format!("cannot resolve skill root: {e}")))?;
let canonical = tokio::fs::canonicalize(base.join(name_path))
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
SkillError::NotFound(format!("resource not found: {name}"))
}
_ => SkillError::Io(e.to_string()),
})?;
if !canonical.starts_with(&base) {
return Err(SkillError::NotFound(format!(
"resource escapes skill root: {name}"
)));
}
tokio::fs::read_to_string(canonical)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
SkillError::NotFound(format!("resource not found: {name}"))
}
_ => SkillError::Io(e.to_string()),
})
}
}
#[derive(Default)]
pub struct SkillRegistry {
skills: RwLock<IndexMap<String, Skill>>,
}
impl Clone for SkillRegistry {
fn clone(&self) -> Self {
let skills = self
.skills
.read()
.expect("SkillRegistry internal lock poisoned")
.clone();
Self {
skills: RwLock::new(skills),
}
}
}
impl SkillRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn add(&self, skill: Skill) -> &Self {
let mut guard = self
.skills
.write()
.expect("SkillRegistry internal lock poisoned");
guard.insert(skill.name.clone(), skill);
self
}
pub fn remove(&self, name: &str) -> bool {
let mut guard = self
.skills
.write()
.expect("SkillRegistry internal lock poisoned");
guard.shift_remove(name).is_some()
}
pub fn get(&self, name: &str) -> Option<Skill> {
let guard = self
.skills
.read()
.expect("SkillRegistry internal lock poisoned");
guard.get(name).cloned()
}
pub async fn from_dir(path: &Path) -> Result<Self, SkillError> {
let registry = SkillRegistry::new();
let mut entries = tokio::fs::read_dir(path).await?;
let mut dirs = Vec::new();
loop {
match entries.next_entry().await {
Ok(Some(entry)) => {
let is_dir = match entry.file_type().await {
Ok(ft) => ft.is_dir(),
Err(_) => false,
};
if is_dir {
dirs.push(entry.path());
}
}
Ok(None) => break,
Err(e) => {
tracing::warn!("failed to read skill directory entry: {e}");
continue;
}
}
}
for dir in dirs {
match Skill::from_dir(&dir).await {
Ok(skill) => {
registry.add(skill);
}
Err(err) => tracing::warn!("skipping skill directory {}: {err}", dir.display()),
}
}
Ok(registry)
}
pub async fn from_dirs<P: AsRef<Path>>(paths: &[P]) -> Self {
let registry = SkillRegistry::new();
for path in paths {
let path = path.as_ref();
match Self::from_dir(path).await {
Ok(found) => {
for skill in found.skills() {
registry.add(skill);
}
}
Err(err) => {
tracing::warn!("skipping skill source directory {}: {err}", path.display());
}
}
}
registry
}
pub fn menu(&self) -> String {
let guard = self
.skills
.read()
.expect("SkillRegistry internal lock poisoned");
let mut out = String::new();
for (i, skill) in guard.values().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!("- {}: {}", skill.name, skill.description));
}
out
}
pub fn skills(&self) -> Vec<Skill> {
let guard = self
.skills
.read()
.expect("SkillRegistry internal lock poisoned");
guard.values().cloned().collect()
}
}
impl std::fmt::Debug for SkillRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.skills.try_read() {
Ok(guard) => f
.debug_list()
.entries(guard.values().map(|s| s.name.as_str()))
.finish(),
Err(_) => f.write_str("<locked>"),
}
}
}
#[derive(Debug, Clone)]
pub struct LoadSkillTool {
registry: Arc<SkillRegistry>,
enabled: Option<Arc<HashSet<String>>>,
activated: Arc<RwLock<HashSet<String>>>,
}
impl LoadSkillTool {
pub fn new(registry: Arc<SkillRegistry>, enabled: Option<Arc<HashSet<String>>>) -> Self {
Self {
registry,
enabled,
activated: Arc::new(RwLock::new(HashSet::new())),
}
}
}
#[async_trait::async_trait]
impl Tool for LoadSkillTool {
fn schema(&self) -> ToolSchema {
let mut parameters = serde_json::to_value(schemars::schema_for!(LoadSkillArgs))
.expect("LoadSkillArgs JSON Schema serialization must not fail");
let available: Vec<String> = self
.registry
.skills()
.iter()
.filter(|s| self.is_enabled(s.name()))
.map(|s| s.name().to_string())
.collect();
parameters["properties"]["name"]["enum"] = serde_json::json!(available);
ToolSchema {
name: "load_skill".into(),
description:
"Load and activate a skill: the name argument is the skill name, and the skill body is returned. The available skills are listed in the system prompt."
.into(),
parameters,
}
}
async fn call(
&self,
arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
let name = serde_json::from_value::<LoadSkillArgs>(arguments)
.map_err(ToolError::from)?
.name;
if !self.is_enabled(&name) {
return Err(ToolError::Execution(format!(
"skill '{name}' is not enabled"
)));
}
let skill = match self.registry.get(&name) {
Some(skill) => skill,
None => return Err(ToolError::Execution(format!("skill '{name}' not found"))),
};
let mut activated = self
.activated
.write()
.expect("LoadSkillTool internal lock poisoned");
if activated.contains(&name) {
return Ok(format!(
"skill '{name}' is already active in this conversation"
));
}
activated.insert(name);
Ok(format_skill_content(&skill))
}
fn protected_output(&self) -> bool {
true
}
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct LoadSkillArgs {
name: String,
}
impl LoadSkillTool {
fn is_enabled(&self, name: &str) -> bool {
match &self.enabled {
None => true,
Some(enabled) => enabled.contains(name),
}
}
}
fn format_skill_content(skill: &Skill) -> String {
let mut out = String::new();
out.push_str(&format!("<skill_content name=\"{}\">\n", skill.name()));
out.push_str(skill.body());
if skill.base_dir().is_some() {
out.push_str("\n\nRelative paths in this skill are relative to the skill directory.");
}
if !skill.resources().is_empty() {
out.push_str("\n\n<skill_resources>");
for resource in skill.resources() {
out.push_str(&format!("\n <file>{}</file>", resource.display()));
}
out.push_str("\n</skill_resources>");
}
out.push_str("\n</skill_content>");
out
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SkillError {
#[error("invalid frontmatter: {0}")]
InvalidFrontmatter(String),
#[error("invalid skill name: {0}")]
InvalidName(String),
#[error("invalid skill description: {0}")]
InvalidDescription(String),
#[error("skill name '{name}' does not match directory name '{dir}'")]
NameMismatch {
name: String,
dir: String,
},
#[error("skill not found: {0}")]
NotFound(String),
#[error("invalid skill body: {0}")]
InvalidBody(String),
#[error("io error: {0}")]
Io(String),
}
impl From<std::io::Error> for SkillError {
fn from(err: std::io::Error) -> Self {
SkillError::Io(err.to_string())
}
}
fn validate_name(name: &str) -> Result<(), SkillError> {
if name.is_empty() {
return Err(SkillError::InvalidName("name must not be empty".into()));
}
if name.chars().count() > 64 {
return Err(SkillError::InvalidName("name exceeds 64 characters".into()));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SkillError::InvalidName(
"name may only contain lowercase letters, digits, and hyphens".into(),
));
}
if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
return Err(SkillError::InvalidName(
"name is not kebab-case: must not start or end with a hyphen, and must not contain consecutive hyphens".into(),
));
}
Ok(())
}
fn validate_description(description: &str) -> Result<(), SkillError> {
if description.is_empty() {
return Err(SkillError::InvalidDescription(
"description must not be empty".into(),
));
}
if description.chars().count() > 1024 {
return Err(SkillError::InvalidDescription(
"description exceeds 1024 characters".into(),
));
}
Ok(())
}
struct Frontmatter {
name: Option<String>,
description: Option<String>,
license: Option<String>,
compatibility: Option<String>,
metadata: Vec<(String, String)>,
allowed_tools: Vec<AllowedTool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Block {
None,
Metadata,
AllowedTools,
}
fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), SkillError> {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let content = content.trim_start_matches(['\n', '\r']);
let rest = content.strip_prefix("---").ok_or_else(|| {
SkillError::InvalidFrontmatter("missing frontmatter start delimiter ---".into())
})?;
let (first_line, mut rest) = match rest.split_once('\n') {
Some((line, tail)) => (line, tail),
None => (rest, ""),
};
if !first_line.trim().is_empty() {
return Err(SkillError::InvalidFrontmatter(
"start delimiter --- must be followed by a newline".into(),
));
}
let mut fm = Frontmatter {
name: None,
description: None,
license: None,
compatibility: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
};
let mut block = Block::None;
loop {
let (line, tail) = match rest.split_once('\n') {
Some((line, tail)) => (line, tail),
None => (rest, ""),
};
if line.trim_end().trim() == "---" {
let body = tail.strip_prefix('\n').unwrap_or(tail);
return Ok((fm, body.to_string()));
}
if tail.is_empty() {
return Err(SkillError::InvalidFrontmatter(
"missing frontmatter end delimiter ---".into(),
));
}
let trimmed = line.trim_end_matches('\r').trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
} else if line.starts_with(' ') || line.starts_with('\t') {
if let Some(item) = trimmed.strip_prefix("- ") {
let item = item.trim();
if block == Block::Metadata {
return Err(SkillError::InvalidFrontmatter(
"metadata does not support nested lists".into(),
));
}
fm.allowed_tools.push(parse_allowed_tool(item)?);
} else if block == Block::Metadata {
let (key, value) = split_kv(trimmed)?;
fm.metadata
.push((key.to_string(), stringify(value.unwrap_or_default())));
} else {
return Err(SkillError::InvalidFrontmatter(format!(
"unsupported nested structure: {trimmed}"
)));
}
} else {
block = Block::None;
let (key, value) = split_kv(trimmed)?;
let value = value.map(stringify);
match key {
"name" => fm.name = Some(value.unwrap_or_default().to_string()),
"description" => fm.description = Some(value.unwrap_or_default().to_string()),
"license" => fm.license = Some(value.unwrap_or_default().to_string()),
"compatibility" => {
let v = value.unwrap_or_default().to_string();
if v.chars().count() > 500 {
return Err(SkillError::InvalidFrontmatter(
"compatibility exceeds 500 characters".into(),
));
}
fm.compatibility = Some(v);
}
"allowed-tools" => {
block = Block::AllowedTools;
if let Some(v) = value {
let v = v.trim();
if v.starts_with('[') {
let inner = v
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.ok_or_else(|| {
SkillError::InvalidFrontmatter(format!(
"allowed-tools flow list has unbalanced brackets: {v}"
))
})?;
for item in inner.split(',') {
fm.allowed_tools.push(parse_allowed_tool(item.trim())?);
}
} else {
for item in v.split_whitespace() {
fm.allowed_tools.push(parse_allowed_tool(item)?);
}
}
}
}
"metadata" => {
block = Block::Metadata;
if value.is_some() {
return Err(SkillError::InvalidFrontmatter(
"metadata value must be a key-value block (inline form is not supported)".into(),
));
}
}
_ => {
fm.metadata
.push((key.to_string(), value.unwrap_or_default()));
}
}
}
rest = tail;
}
}
fn split_kv(line: &str) -> Result<(&str, Option<&str>), SkillError> {
let Some((key, value)) = line.split_once(':') else {
return Err(SkillError::InvalidFrontmatter(format!(
"frontmatter line missing colon: {line}"
)));
};
let key = key.trim();
if key.is_empty() {
return Err(SkillError::InvalidFrontmatter(
"frontmatter line missing field name".into(),
));
}
if !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(SkillError::InvalidFrontmatter(format!(
"invalid field name: {key}"
)));
}
let value = value.trim();
Ok((key, if value.is_empty() { None } else { Some(value) }))
}
fn stringify(value: &str) -> String {
let value = value.trim();
let stripped = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2)
|| (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2)
{
&value[1..value.len() - 1]
} else {
value
};
stripped.to_string()
}
fn parse_allowed_tool(item: &str) -> Result<AllowedTool, SkillError> {
if let Some(open) = item.find('(') {
if !item.ends_with(')') || item[open + 1..].contains('(') {
return Err(SkillError::InvalidFrontmatter(format!(
"invalid allowed-tools entry: {item}"
)));
}
let name = item[..open].trim();
if name.is_empty() {
return Err(SkillError::InvalidFrontmatter(format!(
"allowed-tools entry missing tool name: {item}"
)));
}
let scope = item[open + 1..item.len() - 1].trim();
Ok(AllowedTool {
name: name.to_string(),
scope: (!scope.is_empty()).then(|| scope.to_string()),
})
} else if item.contains(')') {
Err(SkillError::InvalidFrontmatter(format!(
"invalid allowed-tools entry: {item}"
)))
} else if item.contains(['[', ']', ',']) {
Err(SkillError::InvalidFrontmatter(format!(
"invalid allowed-tools entry: {item}"
)))
} else if item.is_empty() {
Err(SkillError::InvalidFrontmatter(
"empty allowed-tools entry".into(),
))
} else {
Ok(AllowedTool {
name: item.to_string(),
scope: None,
})
}
}
async fn collect_resources(base: &Path) -> Vec<PathBuf> {
let mut resources = Vec::new();
for dir in ["references", "scripts", "assets"] {
walk_dir(base.join(dir), base, &mut resources).await;
}
resources.sort();
resources
}
async fn walk_dir(dir: PathBuf, base: &Path, out: &mut Vec<PathBuf>) {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(_) => return, };
loop {
match entries.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
let is_dir = match entry.file_type().await {
Ok(ft) => ft.is_dir(),
Err(_) => false,
};
if is_dir {
Box::pin(walk_dir(path, base, out)).await;
} else if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_path_buf());
}
}
Ok(None) => break,
Err(_) => return,
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::{AllowedTool, LoadSkillTool, Skill, SkillError, SkillRegistry};
use crate::tool::Tool;
use std::collections::HashSet;
fn temp_dir(tag: &str) -> TempDir {
TempDir::new(tag)
}
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("molo-skill-test-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
}
impl std::ops::Deref for TempDir {
type Target = PathBuf;
fn deref(&self) -> &PathBuf {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
let skill_dir = dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n{body}"),
)
.unwrap();
skill_dir
}
fn minimal(name: &str) -> Skill {
Skill::parse(&format!(
"---\nname: {name}\ndescription: description\n---\nbody"
))
.unwrap()
}
#[test]
fn parse_minimal() {
let skill = Skill::parse("---\nname: greet\ndescription: Say hello\n---\nHello!").unwrap();
assert_eq!(skill.name(), "greet");
assert_eq!(skill.description(), "Say hello");
assert_eq!(skill.body(), "Hello!");
assert!(skill.license().is_none());
assert!(skill.metadata().is_empty());
assert!(skill.allowed_tools().is_empty());
assert!(skill.resources().is_empty());
}
#[test]
fn parse_full_fields() {
let content = r#"---
name: code-review
description: Review code changes
license: MIT
compatibility: rust-1.80+
metadata:
author: team
public: true
allowed-tools:
- Bash(git:*)
- Python
user-invocable: true
---
Review steps.
"#;
let skill = Skill::parse(content).unwrap();
assert_eq!(skill.name(), "code-review");
assert_eq!(skill.license(), Some("MIT"));
assert_eq!(skill.compatibility(), Some("rust-1.80+"));
assert_eq!(
skill.metadata(),
&[
("author".to_string(), "team".to_string()),
("public".to_string(), "true".to_string()),
("user-invocable".to_string(), "true".to_string()),
]
);
assert_eq!(
skill.allowed_tools(),
&[
AllowedTool {
name: "Bash".into(),
scope: Some("git:*".into())
},
AllowedTool {
name: "Python".into(),
scope: None
},
]
);
}
#[test]
fn parse_allowed_tools_string_form() {
let skill = Skill::parse(
"---\nname: a\ndescription: description\nallowed-tools: Bash(git:*) Python\n---\nbody",
)
.unwrap();
assert_eq!(skill.allowed_tools().len(), 2);
assert_eq!(skill.allowed_tools()[0].name, "Bash");
assert_eq!(skill.allowed_tools()[0].scope.as_deref(), Some("git:*"));
assert_eq!(skill.allowed_tools()[1].name, "Python");
}
#[test]
fn parse_allowed_tools_flow_list_form() {
let skill = Skill::parse(
"---\nname: a\ndescription: description\nallowed-tools: [Bash, Python]\n---\nbody",
)
.unwrap();
assert_eq!(skill.allowed_tools().len(), 2);
assert_eq!(skill.allowed_tools()[0].name, "Bash");
assert_eq!(skill.allowed_tools()[1].name, "Python");
}
#[test]
fn parse_allowed_tools_flow_list_unbalanced_brackets_rejected() {
let err =
Skill::parse("---\nname: a\ndescription: description\nallowed-tools: [Bash\n---\nbody")
.unwrap_err();
assert!(err.to_string().contains("unbalanced brackets"));
}
#[test]
fn frontmatter_underscore_keys_tolerated_into_metadata() {
let skill =
Skill::parse("---\nname: a\ndescription: description\nuser_invocable: true\n---\nbody")
.unwrap();
assert_eq!(
skill.metadata(),
&[("user_invocable".to_string(), "true".to_string())]
);
}
#[test]
fn parse_body_with_blank_line_after_delimiter() {
let skill = Skill::parse(
"---\nname: a\ndescription: description\n---\n\nfirst body line\n\nsecond body line",
)
.unwrap();
assert_eq!(skill.body(), "first body line\n\nsecond body line");
}
#[test]
fn parse_empty_body_allowed() {
let skill = Skill::parse("---\nname: a\ndescription: description\n---").unwrap();
assert_eq!(skill.body(), "");
}
#[test]
fn parse_bom_and_leading_blank_lines_tolerated() {
let content = "\u{feff}\n\n---\nname: a\ndescription: description\n---\nbody";
let skill = Skill::parse(content).unwrap();
assert_eq!(skill.name(), "a");
}
#[test]
fn parse_quoted_values_stripped() {
let skill =
Skill::parse("---\nname: a\ndescription: \"quoted description\"\n---\n").unwrap();
assert_eq!(skill.description(), "quoted description");
}
#[test]
fn parse_comments_and_blank_lines_ignored() {
let content = "---\n# this is a comment\n\nname: a\ndescription: description\n---\nbody";
let skill = Skill::parse(content).unwrap();
assert_eq!(skill.name(), "a");
}
#[test]
fn parse_duplicate_field_last_wins() {
let content = "---\nname: a\ndescription: first\ndescription: second\n---\n";
let skill = Skill::parse(content).unwrap();
assert_eq!(skill.description(), "second");
}
#[test]
fn parse_missing_frontmatter() {
let err = Skill::parse("plain text, no frontmatter").unwrap_err();
assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
}
#[test]
fn parse_missing_end_delimiter() {
let err =
Skill::parse("---\nname: a\ndescription: description\nbody without end delimiter")
.unwrap_err();
assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
}
#[test]
fn parse_missing_name() {
let err = Skill::parse("---\ndescription: description\n---\n").unwrap_err();
assert!(matches!(err, SkillError::InvalidName(_)));
}
#[test]
fn parse_invalid_names() {
for bad in [
"Bad-name",
"bad_name",
"bad name",
&"a".repeat(65),
"-bad",
"bad-",
"ba--d",
] {
let content = format!("---\nname: {bad}\ndescription: description\n---\n");
assert!(
matches!(Skill::parse(&content), Err(SkillError::InvalidName(_))),
"name should be rejected: {bad}"
);
}
}
#[test]
fn parse_invalid_descriptions() {
let missing = Skill::parse("---\nname: a\n---\n").unwrap_err();
assert!(matches!(missing, SkillError::InvalidDescription(_)));
let long = Skill::parse(&format!(
"---\nname: a\ndescription: {}\n---\n",
"x".repeat(1025)
))
.unwrap_err();
assert!(matches!(long, SkillError::InvalidDescription(_)));
}
#[test]
fn parse_compatibility_too_long() {
let err = Skill::parse(&format!(
"---\nname: a\ndescription: description\ncompatibility: {}\n---\n",
"x".repeat(501)
))
.unwrap_err();
assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
}
#[test]
fn parse_metadata_nested_rejected() {
let content = "---\nname: a\ndescription: description\nmetadata:\n tags:\n - x\n---\n";
let err = Skill::parse(content).unwrap_err();
assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
}
#[test]
fn parse_metadata_inline_value_rejected() {
let err = Skill::parse("---\nname: a\ndescription: description\nmetadata: foo\n---\n")
.unwrap_err();
assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
}
#[test]
fn parse_unknown_field_empty_value() {
let skill =
Skill::parse("---\nname: a\ndescription: description\nuser-invocable:\n---\n").unwrap();
assert_eq!(
skill.metadata(),
&[("user-invocable".to_string(), String::new())]
);
}
#[test]
fn parse_invalid_allowed_tool_entries() {
for bad in ["Bash(git:*", "Bash)git:*", "()", "(x)"] {
let content =
format!("---\nname: a\ndescription: description\nallowed-tools: {bad}\n---\n");
assert!(
matches!(
Skill::parse(&content),
Err(SkillError::InvalidFrontmatter(_))
),
"entry should be rejected: {bad}"
);
}
}
#[tokio::test]
async fn from_dir_ok_with_resources() {
let dir = temp_dir("from-dir-ok");
let skill_dir = write_skill(&dir, "code-review", "Review code", "Step one");
std::fs::create_dir_all(skill_dir.join("references/nested")).unwrap();
std::fs::write(skill_dir.join("references/style.md"), "# style").unwrap();
std::fs::write(skill_dir.join("references/nested/check.md"), "# checklist").unwrap();
std::fs::create_dir_all(skill_dir.join("scripts")).unwrap();
std::fs::write(skill_dir.join("scripts/run.sh"), "#!/bin/sh").unwrap();
std::fs::write(skill_dir.join("README.md"), "not a resource").unwrap();
let skill = Skill::from_dir(&skill_dir).await.unwrap();
assert_eq!(skill.name(), "code-review");
assert_eq!(skill.body(), "Step one");
assert_eq!(
skill.resources(),
&[
PathBuf::from("references/nested/check.md"),
PathBuf::from("references/style.md"),
PathBuf::from("scripts/run.sh"),
]
);
}
#[tokio::test]
async fn from_dir_name_mismatch() {
let dir = temp_dir("from-dir-mismatch");
let skill_dir = dir.join("wrong-dir");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: right-name\ndescription: description\n---\nbody",
)
.unwrap();
let err = Skill::from_dir(&skill_dir).await.unwrap_err();
assert!(matches!(
err,
SkillError::NameMismatch { name, dir: _ } if name == "right-name"
));
}
#[tokio::test]
async fn from_dir_missing_skill_md() {
let dir = temp_dir("from-dir-missing");
let empty = dir.join("empty-skill");
std::fs::create_dir_all(&empty).unwrap();
let err = Skill::from_dir(&empty).await.unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
}
#[tokio::test]
async fn load_reference_ok() {
let dir = temp_dir("load-ref");
let skill_dir = write_skill(&dir, "a", "description", "body");
std::fs::create_dir_all(skill_dir.join("references")).unwrap();
std::fs::write(skill_dir.join("references/style.md"), "style content").unwrap();
let skill = Skill::from_dir(&skill_dir).await.unwrap();
assert_eq!(
skill.load_reference("references/style.md").await.unwrap(),
"style content"
);
}
#[tokio::test]
async fn load_reference_missing_or_invalid() {
let dir = temp_dir("load-ref-missing");
let skill_dir = write_skill(&dir, "a", "description", "body");
let skill = Skill::from_dir(&skill_dir).await.unwrap();
let err = skill.load_reference("nope.md").await.unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
let err = skill.load_reference("../SKILL.md").await.unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
let err = skill.load_reference("/etc/passwd").await.unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
let parsed = Skill::parse("---\nname: a\ndescription: description\n---\nbody").unwrap();
let err = parsed.load_reference("x.md").await.unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
}
#[cfg(unix)]
#[tokio::test]
async fn load_reference_rejects_symlink_escape() {
let dir = temp_dir("load-ref-symlink");
let skill_dir = write_skill(&dir, "a", "description", "body");
std::fs::create_dir_all(skill_dir.join("references")).unwrap();
let secret = dir.join("secret.txt");
std::fs::write(&secret, "secret content").unwrap();
std::os::unix::fs::symlink(&secret, skill_dir.join("references/leak")).unwrap();
let skill = Skill::from_dir(&skill_dir).await.unwrap();
let err = skill.load_reference("references/leak").await.unwrap_err();
assert!(
matches!(err, SkillError::NotFound(_)),
"symlink escape must be rejected, got: {err:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn load_reference_allows_internal_symlink() {
let dir = temp_dir("load-ref-symlink-in");
let skill_dir = write_skill(&dir, "a", "description", "body");
std::fs::create_dir_all(skill_dir.join("references")).unwrap();
std::fs::write(skill_dir.join("references/real.md"), "real content").unwrap();
std::os::unix::fs::symlink("real.md", skill_dir.join("references/alias.md")).unwrap();
let skill = Skill::from_dir(&skill_dir).await.unwrap();
assert_eq!(
skill.load_reference("references/alias.md").await.unwrap(),
"real content"
);
}
#[cfg(unix)]
#[tokio::test]
async fn from_dir_rejects_symlinked_skill_md() {
let dir = temp_dir("from-dir-symlink");
let skill_dir = dir.join("a");
std::fs::create_dir_all(&skill_dir).unwrap();
let secret = dir.join("secret.md");
std::fs::write(
&secret,
"---\nname: a\ndescription: description\n---\nsecret body",
)
.unwrap();
std::os::unix::fs::symlink(&secret, skill_dir.join("SKILL.md")).unwrap();
let err = Skill::from_dir(&skill_dir).await.unwrap_err();
assert!(
matches!(err, SkillError::NotFound(_)),
"SKILL.md symlink escape must be rejected, got: {err:?}"
);
}
#[test]
fn registry_add_get_remove() {
let registry = SkillRegistry::new();
assert!(registry.get("a").is_none());
registry.add(minimal("a"));
assert_eq!(registry.get("a").unwrap().name(), "a");
assert!(registry.remove("a"));
assert!(registry.get("a").is_none());
assert!(!registry.remove("a"));
}
#[test]
fn registry_add_duplicate_replaces_in_place() {
let registry = SkillRegistry::new();
registry
.add(minimal("a"))
.add(minimal("b"))
.add(minimal("c"));
let v2 = Skill::parse("---\nname: b\ndescription: new description\n---\nnew body").unwrap();
registry.add(v2);
let names: Vec<String> = registry
.skills()
.iter()
.map(|s| s.name().to_string())
.collect();
assert_eq!(names, vec!["a", "b", "c"]);
assert_eq!(registry.get("b").unwrap().body(), "new body");
}
#[test]
fn registry_menu_format() {
let registry = SkillRegistry::new();
registry.add(minimal("a")).add(minimal("b"));
assert_eq!(registry.menu(), "- a: description\n- b: description");
assert_eq!(SkillRegistry::new().menu(), "");
}
#[tokio::test]
async fn registry_from_dir_skips_bad_skills() {
let dir = temp_dir("registry-from-dir");
write_skill(&dir, "good-one", "good skill", "body");
let bad = dir.join("bad-one");
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(
bad.join("SKILL.md"),
"---\nname: other-name\ndescription: description\n---\nbody",
)
.unwrap();
std::fs::create_dir_all(dir.join("empty-dir")).unwrap();
std::fs::write(dir.join("notes.md"), "not a skill").unwrap();
let registry = SkillRegistry::from_dir(&dir).await.unwrap();
let names: Vec<String> = registry
.skills()
.iter()
.map(|s| s.name().to_string())
.collect();
assert_eq!(names, vec!["good-one"]);
assert!(registry.get("bad-one").is_none());
}
#[tokio::test]
async fn from_dirs_merges_with_later_override() {
let dir = temp_dir("from-dirs-merge");
let user = dir.join("user");
let project = dir.join("project");
std::fs::create_dir_all(&user).unwrap();
std::fs::create_dir_all(&project).unwrap();
write_skill(&user, "greet", "user version", "user body");
write_skill(&user, "user-only", "user only", "body");
write_skill(&project, "greet", "project version", "project body");
write_skill(&project, "project-only", "project only", "body");
let registry = SkillRegistry::from_dirs(&[user, project]).await;
let names: Vec<String> = registry
.skills()
.iter()
.map(|s| s.name().to_string())
.collect();
assert_eq!(names, vec!["greet", "user-only", "project-only"]);
assert_eq!(registry.get("greet").unwrap().body(), "project body");
assert_eq!(registry.get("user-only").unwrap().body(), "body");
}
#[tokio::test]
async fn from_dirs_skips_missing_sources() {
let dir = temp_dir("from-dirs-missing");
let exists = dir.join("exists");
std::fs::create_dir_all(&exists).unwrap();
write_skill(&exists, "a", "description", "body");
let registry =
SkillRegistry::from_dirs(&[dir.join("missing-a"), exists, dir.join("missing-b")]).await;
assert_eq!(registry.skills().len(), 1);
let empty = SkillRegistry::from_dirs(&[dir.join("missing-a"), dir.join("missing-b")]).await;
assert!(empty.skills().is_empty());
}
#[tokio::test]
async fn from_dirs_empty_list() {
let registry = SkillRegistry::from_dirs::<&str>(&[]).await;
assert!(registry.skills().is_empty());
}
#[tokio::test]
async fn registry_from_dir_root_io_error() {
let missing = temp_dir("registry-root").join("does-not-exist");
let err = SkillRegistry::from_dir(&missing).await.unwrap_err();
assert!(matches!(err, SkillError::Io(_)));
}
#[test]
fn registry_hot_swap_add_remove() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
let handle = Arc::clone(®istry);
handle.add(minimal("a"));
assert!(registry.get("a").is_some());
handle.remove("a");
assert!(registry.get("a").is_none());
}
#[test]
fn allowed_tool_permits_rules() {
let bash_git = AllowedTool {
name: "Bash".into(),
scope: Some("git:*".into()),
};
assert!(bash_git.permits("Bash", "git:diff --stat"));
assert!(bash_git.permits("Bash", "git:log"));
assert!(!bash_git.permits("Bash", "rm -rf /"));
assert!(!bash_git.permits("Python", "git:log"));
let exact = AllowedTool {
name: "Bash".into(),
scope: Some("git:status".into()),
};
assert!(exact.permits("Bash", "git:status"));
assert!(!exact.permits("Bash", "git:log"));
let python = AllowedTool {
name: "Python".into(),
scope: None,
};
assert!(python.permits("Python", "print('hello')"));
assert!(!python.permits("Bash", "echo hi"));
}
#[tokio::test]
async fn load_skill_returns_wrapped_body() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
registry.add(minimal("a"));
let tool = LoadSkillTool::new(Arc::clone(®istry), None);
let result = tool
.call(
serde_json::json!({ "name": "a" }),
&crate::SharedState::new(),
)
.await
.unwrap();
assert_eq!(result, "<skill_content name=\"a\">\nbody\n</skill_content>");
}
#[tokio::test]
async fn load_skill_deduplicates_activations() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
registry.add(minimal("a"));
let tool = LoadSkillTool::new(Arc::clone(®istry), None);
let state = crate::SharedState::new();
let first = tool
.call(serde_json::json!({ "name": "a" }), &state)
.await
.unwrap();
assert!(first.contains("body"));
let second = tool
.call(serde_json::json!({ "name": "a" }), &state)
.await
.unwrap();
assert!(second.contains("already active"));
assert!(!second.contains("body"));
let fresh = LoadSkillTool::new(Arc::clone(®istry), None);
let again = fresh
.call(serde_json::json!({ "name": "a" }), &state)
.await
.unwrap();
assert!(again.contains("body"));
}
#[tokio::test]
async fn load_skill_schema_enum_lists_enabled_skills() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
registry
.add(minimal("a"))
.add(minimal("b"))
.add(minimal("c"));
let enabled: Arc<HashSet<String>> =
Arc::new(["a".to_string(), "b".to_string()].into_iter().collect());
let tool = LoadSkillTool::new(registry, Some(enabled));
let schema = tool.schema();
let names = schema.parameters["properties"]["name"]["enum"]
.as_array()
.expect("name should be an enum")
.iter()
.map(|v| v.as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(names, vec!["a", "b"]);
}
#[tokio::test]
async fn load_skill_content_lists_resources() {
let dir = temp_dir("load-skill-resources");
let skill_dir = write_skill(&dir, "a", "description", "body");
std::fs::create_dir_all(skill_dir.join("references")).unwrap();
std::fs::write(skill_dir.join("references/style.md"), "# style").unwrap();
let skill = Skill::from_dir(&skill_dir).await.unwrap();
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
registry.add(skill);
let tool = LoadSkillTool::new(registry, None);
let result = tool
.call(
serde_json::json!({ "name": "a" }),
&crate::SharedState::new(),
)
.await
.unwrap();
assert!(result.contains("<skill_content name=\"a\">"));
assert!(
result.contains("Relative paths in this skill are relative to the skill directory.")
);
assert!(!result.contains(&skill_dir.display().to_string()));
assert!(result.contains("<skill_resources>"));
assert!(result.contains("<file>references/style.md</file>"));
assert!(result.ends_with("</skill_content>"));
}
#[tokio::test]
async fn load_skill_not_found() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
let tool = LoadSkillTool::new(registry, None);
let err = tool
.call(
serde_json::json!({ "name": "ghost" }),
&crate::SharedState::new(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[tokio::test]
async fn load_skill_not_enabled() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
registry.add(minimal("a")).add(minimal("b"));
let enabled: Arc<std::collections::HashSet<String>> =
Arc::new(["a".to_string()].into_iter().collect());
let tool = LoadSkillTool::new(registry, Some(enabled));
let err = tool
.call(
serde_json::json!({ "name": "b" }),
&crate::SharedState::new(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("not enabled"));
let ok = tool
.call(
serde_json::json!({ "name": "a" }),
&crate::SharedState::new(),
)
.await
.unwrap();
assert!(ok.contains("body"));
}
#[tokio::test]
async fn load_skill_missing_name_argument() {
let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
let tool = LoadSkillTool::new(registry, None);
let err = tool
.call(serde_json::json!({}), &crate::SharedState::new())
.await
.unwrap_err();
assert!(matches!(err, crate::tool::ToolError::InvalidArguments(_)));
}
}