use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
const MAX_NAME: usize = 64;
const MAX_DESCRIPTION: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
pub name: String,
pub description: String,
pub triggers: Vec<String>,
pub tools: Option<Vec<String>>,
pub body: String,
pub dir: PathBuf,
}
impl Skill {
pub fn summary_line(&self) -> String {
let mut line = format!("- `{}` — {}", self.name, self.description);
if !self.triggers.is_empty() {
line.push_str(&format!(" (keywords: {})", self.triggers.join(", ")));
}
line
}
pub fn load(dir: &Path) -> Result<Skill> {
let path = dir.join("SKILL.md");
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let mut skill = Skill::parse(&raw, dir)?;
skill.dir = dir.to_path_buf();
let folder = dir.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if folder != skill.name {
bail!(
"{}: frontmatter says `name = {}` but the directory is `{folder}` — \
they have to match, since the directory is how the skill is found \
and the name is how it is called",
path.display(),
skill.name
);
}
Ok(skill)
}
pub fn parse(raw: &str, dir: &Path) -> Result<Skill> {
let (fm, body) = split_frontmatter(raw)?;
let options = serde_saphyr::options!(
duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
budget: serde_saphyr::budget!(max_depth: 8, max_documents: 1)
);
let fm: Frontmatter = serde_saphyr::from_str_with_options(&fm, options)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("parsing the YAML frontmatter")?;
validate_name(&fm.name)?;
validate_description(&fm.description)?;
if fm.tools.as_ref().is_some_and(|t| t.is_empty()) {
bail!(
"`tools` is present but empty — omit the key to leave the surface \
alone, or name the tools this skill needs"
);
}
Ok(Skill {
name: fm.name,
description: fm.description,
triggers: fm.triggers.unwrap_or_default(),
tools: fm.tools,
body: body.trim().to_string(),
dir: dir.to_path_buf(),
})
}
}
#[derive(Debug, Deserialize)]
struct Frontmatter {
name: String,
description: String,
triggers: Option<Vec<String>>,
tools: Option<Vec<String>>,
}
fn split_frontmatter(raw: &str) -> Result<(String, String)> {
let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
let mut lines = text.lines();
if lines.next().map(str::trim_end) != Some("---") {
bail!("no frontmatter — a SKILL.md opens with a `---` line");
}
let mut fm = String::new();
let mut body = String::new();
let mut closed = false;
for line in lines {
if !closed && line.trim_end() == "---" {
closed = true;
continue;
}
if closed {
body.push_str(line);
body.push('\n');
} else {
fm.push_str(line);
fm.push('\n');
}
}
if !closed {
bail!("frontmatter opened with `---` and was never closed");
}
Ok((fm, body))
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("`name` is empty");
}
if name.chars().count() > MAX_NAME {
bail!("`name` is longer than {MAX_NAME} characters");
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
bail!("`name` may hold only lowercase letters, digits and hyphens: got `{name}`");
}
let lower = name.to_ascii_lowercase();
if lower.contains("anthropic") || lower.contains("claude") {
bail!("`name` may not contain a vendor name (`{name}`) — the standard reserves those");
}
Ok(())
}
fn validate_description(description: &str) -> Result<()> {
if description.trim().is_empty() {
bail!("`description` is empty — it is the only thing the model sees before loading");
}
if description.chars().count() > MAX_DESCRIPTION {
bail!("`description` is longer than {MAX_DESCRIPTION} characters");
}
if description.contains('<') || description.contains('>') {
bail!("`description` may not contain `<` or `>`");
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct SkillStore {
skills: Vec<Skill>,
}
#[derive(Debug, Clone)]
pub struct SkillError {
pub dir: PathBuf,
pub why: String,
}
impl SkillStore {
pub fn default_dir() -> Result<PathBuf> {
Ok(crate::work::mecha_home()?.join("skills"))
}
pub fn load(dir: &Path) -> (SkillStore, Vec<SkillError>) {
let mut skills = Vec::new();
let mut errors = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return (SkillStore::default(), errors);
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if !path.join("SKILL.md").is_file() {
continue;
}
match Skill::load(&path) {
Ok(skill) => skills.push(skill),
Err(e) => errors.push(SkillError {
dir: path,
why: format!("{e:#}"),
}),
}
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
(SkillStore { skills }, errors)
}
pub fn all(&self) -> &[Skill] {
&self.skills
}
pub fn get(&self, name: &str) -> Option<&Skill> {
self.skills.iter().find(|s| s.name == name)
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn select(&self, enabled: &[String], disabled: &[String]) -> Vec<Skill> {
let disabled: BTreeSet<&str> = disabled.iter().map(String::as_str).collect();
self.skills
.iter()
.filter(|s| enabled.is_empty() || enabled.iter().any(|e| e == &s.name))
.filter(|s| !disabled.contains(s.name.as_str()))
.cloned()
.collect()
}
pub fn unknown_names<'a>(&self, names: &'a [String]) -> Vec<&'a str> {
names
.iter()
.map(String::as_str)
.filter(|n| self.get(n).is_none())
.collect()
}
}
pub fn prompt_block(skills: &[Skill]) -> Option<String> {
if skills.is_empty() {
return None;
}
let mut out = String::from(
"## Skills\n\n\
Procedures the user has written for you. Each is a name and when to use it; \
the steps arrive only when you ask for them. Call the `skill` tool with the \
name to load one *before* starting work it covers, and then follow it — it is \
the user's own instruction, more specific than your general judgement.\n\n",
);
for skill in skills {
out.push_str(&skill.summary_line());
out.push('\n');
}
Some(out.trim_end().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn dir() -> PathBuf {
PathBuf::from("/tmp/skills/x")
}
#[test]
fn the_standard_spelling_parses() {
let raw = "---\nname: handoff\ndescription: Update the handoff docs. Use at the end of a session.\n---\n\n# Closing out\n\nStep one.\n";
let s = Skill::parse(raw, &dir()).unwrap();
assert_eq!(s.name, "handoff");
assert!(s.description.starts_with("Update the handoff"));
assert_eq!(s.body, "# Closing out\n\nStep one.");
assert!(s.triggers.is_empty());
assert_eq!(s.tools, None);
}
#[test]
fn the_optional_fields_parse_in_both_list_spellings() {
let flow = "---\nname: a\ndescription: d\ntriggers: [one, \"two\"]\n---\nbody\n";
assert_eq!(
Skill::parse(flow, &dir()).unwrap().triggers,
vec!["one", "two"]
);
let block = "---\nname: a\ndescription: d\ntools:\n - fs_read\n - fs_list\n---\nbody\n";
assert_eq!(
Skill::parse(block, &dir()).unwrap().tools.unwrap(),
vec!["fs_read", "fs_list"]
);
}
#[test]
fn real_yaml_means_folded_scalars_work() {
let raw = "---\nname: a\ndescription: >-\n a long description\n folded over two lines\n---\nbody\n";
let s = Skill::parse(raw, &dir()).unwrap();
assert_eq!(s.description, "a long description folded over two lines");
}
#[test]
fn a_key_another_harness_understands_does_not_stop_it_loading_here() {
let raw = "---\nname: a\ndescription: d\nlicense: MIT\nallowed-tools: [Bash]\n---\nbody\n";
assert_eq!(Skill::parse(raw, &dir()).unwrap().name, "a");
}
#[test]
fn a_field_mecha_knows_and_cannot_use_is_refused() {
for bad in [
"---\nname: a\ndescription:\n nested: map\n---\nbody\n",
"---\nname: [a, b]\ndescription: d\n---\nbody\n",
"---\nname: a\ndescription: d\ntools: fs_read\n---\nbody\n",
"---\ndescription: d\n---\nbody\n",
"---\nname: a\n---\nbody\n",
] {
assert!(
Skill::parse(bad, &dir()).is_err(),
"should have refused: {bad:?}"
);
}
}
#[test]
fn a_repeated_key_is_refused_rather_than_silently_resolved() {
let raw = "---\nname: a\ndescription: first\ndescription: second\n---\nbody\n";
assert!(Skill::parse(raw, &dir()).is_err());
}
#[test]
fn frontmatter_that_never_closes_is_refused() {
let raw = "---\nname: a\ndescription: d\n\n# body with no close\n";
let e = Skill::parse(raw, &dir()).unwrap_err().to_string();
assert!(e.contains("never closed"), "{e}");
}
#[test]
fn a_file_with_no_frontmatter_says_so() {
let e = Skill::parse("# just a document\n", &dir())
.unwrap_err()
.to_string();
assert!(e.contains("no frontmatter"), "{e}");
}
#[test]
fn the_names_the_standard_reserves_are_refused() {
assert!(validate_name("claude-helper").is_err());
assert!(validate_name("my-anthropic-thing").is_err());
assert!(validate_name("Rec-Letter").is_err(), "uppercase");
assert!(validate_name("rec letter").is_err(), "space");
assert!(validate_name(&"a".repeat(65)).is_err(), "too long");
assert!(validate_name("rec-letter-2").is_ok());
}
#[test]
fn a_description_that_could_close_a_prompt_section_is_refused() {
assert!(validate_description("does <thing>").is_err());
assert!(validate_description(" ").is_err());
assert!(validate_description(&"d".repeat(1025)).is_err());
}
#[test]
fn an_empty_tool_list_is_refused_rather_than_read_as_no_tools() {
let raw = "---\nname: a\ndescription: d\ntools: []\n---\nbody\n";
let e = Skill::parse(raw, &dir()).unwrap_err().to_string();
assert!(e.contains("omit the key"), "{e}");
}
#[test]
fn selection_is_all_by_default_and_disabled_wins() {
let store = SkillStore {
skills: vec![skill("a"), skill("b"), skill("c")],
};
let names = |v: Vec<Skill>| v.into_iter().map(|s| s.name).collect::<Vec<_>>();
assert_eq!(names(store.select(&[], &[])), vec!["a", "b", "c"]);
assert_eq!(
names(store.select(&["a".into(), "b".into()], &[])),
vec!["a", "b"]
);
assert_eq!(
names(store.select(&["a".into(), "b".into()], &["b".into()])),
vec!["a"],
"disabled is applied after enabled, so it wins"
);
}
#[test]
fn a_name_nothing_on_disk_matches_is_reported() {
let store = SkillStore {
skills: vec![skill("a")],
};
assert_eq!(
store.unknown_names(&["a".into(), "typo".into()]),
vec!["typo"]
);
}
#[test]
fn an_empty_store_contributes_no_block_at_all() {
assert_eq!(prompt_block(&[]), None);
}
#[test]
fn the_block_lists_skills_in_the_order_it_was_given() {
let block = prompt_block(&[skill("alpha"), skill("beta")]).unwrap();
let a = block.find("alpha").unwrap();
let b = block.find("beta").unwrap();
assert!(a < b, "{block}");
}
fn skill(name: &str) -> Skill {
Skill {
name: name.to_string(),
description: "does a thing. Use when a thing is needed.".into(),
triggers: Vec::new(),
tools: None,
body: "step one".into(),
dir: dir(),
}
}
}