mod registry;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use serde::Deserialize;
use thiserror::Error;
pub(crate) use registry::{SkillRegistry, SkillRoot};
#[must_use]
pub fn skill_root_key(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct SkillLoader {
skills: BTreeMap<String, SkillEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillEntry {
description: String,
body: String,
path: PathBuf,
model_invocable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillInfo {
pub name: String,
pub description: String,
pub model_invocable: bool,
pub path: PathBuf,
pub root: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SkillLoadError {
#[error("failed to read skills directory {path}: {message}")]
ReadDir { path: PathBuf, message: String },
#[error("failed to read skill file {path}: {message}")]
ReadFile { path: PathBuf, message: String },
#[error("invalid skill frontmatter in {path}: {message}")]
InvalidFrontmatter { path: PathBuf, message: String },
#[error("duplicate skill name '{name}' in {first_path} and {second_path}")]
DuplicateSkillName {
name: String,
first_path: PathBuf,
second_path: PathBuf,
},
}
#[derive(Debug, Clone, Default, Deserialize)]
struct SkillFrontmatter {
name: Option<String>,
description: Option<String>,
#[serde(
default,
rename = "disable-model-invocation",
alias = "disable_model_invocation"
)]
disable_model_invocation: bool,
}
impl SkillLoader {
pub(crate) fn from_dir(path: impl AsRef<Path>) -> Result<Self, SkillLoadError> {
let root = path.as_ref().to_path_buf();
let mut files = Vec::new();
collect_skill_files(&root, &mut files)?;
files.sort();
let mut skills = BTreeMap::new();
let mut skill_paths = BTreeMap::new();
for file in files {
let raw = fs::read_to_string(&file).map_err(|error| SkillLoadError::ReadFile {
path: file.clone(),
message: error.to_string(),
})?;
let (meta, body) = parse_skill_file(&file, &raw)?;
let fallback_name = file
.parent()
.and_then(Path::file_name)
.and_then(|value| value.to_str())
.unwrap_or("skill");
let name = meta
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback_name)
.to_string();
if let Some(first_path) = skill_paths.insert(name.clone(), file.clone()) {
return Err(SkillLoadError::DuplicateSkillName {
name,
first_path,
second_path: file,
});
}
let description = meta.description.unwrap_or_default().trim().to_string();
skills.insert(
name,
SkillEntry {
description,
body,
path: file,
model_invocable: !meta.disable_model_invocation,
},
);
}
Ok(Self { skills })
}
fn entry(&self, name: &str) -> Option<&SkillEntry> {
self.skills.get(name)
}
fn entries(&self) -> impl Iterator<Item = (&String, &SkillEntry)> {
self.skills.iter()
}
}
fn render_skill(name: &str, body: &str) -> String {
let body = body.trim_end_matches(['\n', '\r']);
format!("<skill name=\"{name}\">\n{body}\n</skill>")
}
fn collect_skill_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), SkillLoadError> {
let entries = fs::read_dir(path).map_err(|error| SkillLoadError::ReadDir {
path: path.to_path_buf(),
message: error.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|error| SkillLoadError::ReadDir {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let entry_path = entry.path();
let file_type = entry.file_type().map_err(|error| SkillLoadError::ReadDir {
path: entry_path.clone(),
message: error.to_string(),
})?;
if file_type.is_dir() {
collect_skill_files(&entry_path, files)?;
} else if file_type.is_file() && entry.file_name() == "SKILL.md" {
files.push(entry_path);
}
}
Ok(())
}
fn parse_skill_file(path: &Path, raw: &str) -> Result<(SkillFrontmatter, String), SkillLoadError> {
let Some(opening_len) = raw
.strip_prefix("---\r\n")
.map(|_| 5)
.or_else(|| raw.strip_prefix("---\n").map(|_| 4))
else {
return Ok((SkillFrontmatter::default(), raw.to_string()));
};
let rest = &raw[opening_len..];
let mut cursor = 0usize;
for segment in rest.split_inclusive('\n') {
let line = segment.trim_end_matches(['\n', '\r']);
if line == "---" {
let frontmatter = &rest[..cursor];
let body = &rest[cursor + segment.len()..];
let meta = serde_yaml_ng::from_str(frontmatter).map_err(|error| {
SkillLoadError::InvalidFrontmatter {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
return Ok((meta, body.to_string()));
}
cursor += segment.len();
}
if rest[cursor..].trim_end_matches('\r') == "---" {
let frontmatter = &rest[..cursor];
let meta = serde_yaml_ng::from_str(frontmatter).map_err(|error| {
SkillLoadError::InvalidFrontmatter {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
return Ok((meta, String::new()));
}
Err(SkillLoadError::InvalidFrontmatter {
path: path.to_path_buf(),
message: "missing closing frontmatter delimiter".to_string(),
})
}
#[cfg(test)]
pub(crate) mod tests {
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use super::{SkillLoadError, SkillLoader};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
#[test]
fn frontmatter_decides_whether_the_model_may_reach_a_skill() {
let root = temp_skills_dir("disabled");
write_skill(
&root,
"release",
"---\nname: release\ndescription: cuts a release\ndisable-model-invocation: true\n---\nSteps\n",
);
write_skill(
&root,
"git",
"---\nname: git\ndescription: Git helpers\n---\nStep 1\n",
);
let loader = SkillLoader::from_dir(&root).expect("load skills");
assert!(!loader.entry("release").expect("loaded").model_invocable);
assert!(loader.entry("git").expect("loaded").model_invocable);
}
#[test]
fn the_underscore_spelling_of_the_flag_is_accepted_too() {
let root = temp_skills_dir("all-disabled");
write_skill(
&root,
"release",
"---\nname: release\ndescription: cuts a release\ndisable_model_invocation: true\n---\nSteps\n",
);
let loader = SkillLoader::from_dir(&root).expect("load skills");
assert!(!loader.entry("release").expect("loaded").model_invocable);
}
#[test]
fn parses_frontmatter_and_strips_it_from_content() {
let root = temp_skills_dir("frontmatter");
write_skill(
&root,
"git",
"---\nname: git\ndescription: Git helpers\n---\nStep 1\nStep 2\n",
);
let loader = SkillLoader::from_dir(&root).expect("load skills");
let entry = loader.entry("git").expect("git skill");
assert_eq!(entry.description, "Git helpers");
assert_eq!(entry.body, "Step 1\nStep 2\n");
assert_eq!(entry.path, root.join("git").join("SKILL.md"));
}
#[test]
fn falls_back_to_directory_name_when_name_is_missing() {
let root = temp_skills_dir("fallback-name");
write_skill(
&root,
"pdf",
"---\ndescription: Process PDFs\n---\nRead pages\n",
);
let loader = SkillLoader::from_dir(&root).expect("load skills");
assert!(loader.entry("pdf").is_some());
}
#[test]
fn entries_are_name_ordered_regardless_of_directory_names() {
let root = temp_skills_dir("sorted");
write_skill(
&root,
"b-skill",
"---\nname: zebra\ndescription: Last\n---\nB\n",
);
write_skill(
&root,
"a-skill",
"---\nname: alpha\ndescription: First\n---\nA\n",
);
let loader = SkillLoader::from_dir(&root).expect("load skills");
let names: Vec<&str> = loader.entries().map(|(name, _)| name.as_str()).collect();
assert_eq!(names, vec!["alpha", "zebra"]);
}
#[test]
fn rejects_duplicate_skill_names() {
let root = temp_skills_dir("duplicate");
write_skill(&root, "one", "---\nname: shared\n---\nA\n");
write_skill(&root, "two", "---\nname: shared\n---\nB\n");
let error = SkillLoader::from_dir(&root).expect_err("duplicate error");
assert!(matches!(
error,
SkillLoadError::DuplicateSkillName { ref name, .. } if name == "shared"
));
}
#[test]
fn rejects_malformed_frontmatter() {
let root = temp_skills_dir("invalid-frontmatter");
write_skill(&root, "broken", "---\nname: [oops\n---\nBody\n");
let error = SkillLoader::from_dir(&root).expect_err("frontmatter error");
assert!(matches!(error, SkillLoadError::InvalidFrontmatter { .. }));
assert!(error.to_string().contains("invalid skill frontmatter"));
}
pub(crate) fn temp_skills_dir(label: &str) -> PathBuf {
let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path =
std::env::temp_dir().join(format!("mentra-skill-tests-{label}-{timestamp}-{unique}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
pub(crate) fn write_skill(root: &Path, name: &str, content: &str) {
let skill_dir = root.join(name);
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), content).expect("write skill");
}
}