use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum SkillDiscoveryError {
#[error("invalid frontmatter in {path}: {reason}")]
InvalidFrontmatter { path: PathBuf, reason: String },
#[error("missing required field '{field}' in skill at {path}")]
MissingField { field: String, path: PathBuf },
#[error("duplicate skill name '{name}' in discovery layer at {path}")]
DuplicateName { name: String, path: PathBuf },
#[error("invalid skill name in {path}: {reason}")]
InvalidName { path: PathBuf, reason: String },
#[error("invalid description in skill at {path}: {reason}")]
InvalidDescription { path: PathBuf, reason: String },
#[error("I/O error discovering skills: {0}")]
Io(#[from] std::io::Error),
}
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct SkillManifest {
pub name: String,
pub description: String,
pub disable_model_invocation: bool,
}
impl SkillManifest {
pub fn from_skill_md(content: &str, path: &Path) -> Result<Self, SkillDiscoveryError> {
let fm = extract_frontmatter(content, path)?;
let name = parse_field(fm, "name")
.map(strip_yaml_quotes)
.filter(|n| !n.is_empty())
.ok_or_else(|| SkillDiscoveryError::MissingField {
field: "name".into(),
path: path.to_path_buf(),
})?;
validate_name(name, path)?;
let description = parse_field(fm, "description")
.map(strip_yaml_quotes)
.filter(|d| !d.is_empty())
.ok_or_else(|| SkillDiscoveryError::MissingField {
field: "description".into(),
path: path.to_path_buf(),
})?;
validate_description(description, path)?;
let disable_model_invocation = parse_field(fm, "disable-model-invocation")
.map(|v| strip_yaml_quotes(v).eq_ignore_ascii_case("true"))
.unwrap_or(false);
Ok(Self {
name: name.to_string(),
description: description.to_string(),
disable_model_invocation,
})
}
}
fn extract_frontmatter<'a>(content: &'a str, path: &Path) -> Result<&'a str, SkillDiscoveryError> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err(SkillDiscoveryError::InvalidFrontmatter {
path: path.to_path_buf(),
reason: "SKILL.md must start with '---' frontmatter delimiter".into(),
});
}
let after_open = trimmed.get(3..).unwrap_or("");
let after_open = after_open.trim_start_matches(['\r', '\n']);
let close_pos = after_open
.find("\n---")
.or_else(|| after_open.find("\r\n---"));
let frontmatter = match close_pos {
Some(pos) => &after_open[..pos],
None => {
return Err(SkillDiscoveryError::InvalidFrontmatter {
path: path.to_path_buf(),
reason: "SKILL.md frontmatter is missing closing '---' delimiter".into(),
});
}
};
Ok(frontmatter)
}
fn parse_field<'a>(frontmatter: &'a str, key: &str) -> Option<&'a str> {
let prefix = format!("{key}:");
for line in frontmatter.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(&prefix) {
return Some(rest.trim());
}
}
None
}
fn strip_yaml_quotes(value: &str) -> &str {
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len().saturating_sub(1)]
} else {
value
}
}
fn validate_name(name: &str, path: &Path) -> Result<(), SkillDiscoveryError> {
if name.len() > MAX_NAME_LEN {
return Err(SkillDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
name.len()
),
});
}
for ch in name.chars() {
let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
if !valid {
return Err(SkillDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name contains invalid character '{ch}': \
only lowercase a-z, 0-9, and hyphens are allowed"
),
});
}
}
Ok(())
}
fn validate_description(desc: &str, path: &Path) -> Result<(), SkillDiscoveryError> {
if desc.len() > MAX_DESCRIPTION_LEN {
return Err(SkillDiscoveryError::InvalidDescription {
path: path.to_path_buf(),
reason: format!(
"description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
({} found)",
desc.len()
),
});
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct SkillResource {
pub manifest: SkillManifest,
pub path: PathBuf,
pub skill_md_path: PathBuf,
pub layer_precedence: u32,
}
impl SkillResource {
pub fn load_body(&self) -> Result<String, SkillDiscoveryError> {
let content = std::fs::read_to_string(&self.skill_md_path)?;
Ok(extract_body(&content))
}
}
fn extract_body(content: &str) -> String {
let trimmed = content.trim_start();
let after_open = trimmed.get(3..).unwrap_or("");
let after_open = after_open.trim_start_matches(['\r', '\n']);
let close_pos = after_open
.find("\n---")
.or_else(|| after_open.find("\r\n---"));
match close_pos {
Some(pos) => {
let after_close = &after_open[pos..];
let delimiter_end = after_close.find("---").map(|i| i + 3).unwrap_or(pos + 4);
let body_start = after_close.get(delimiter_end..).unwrap_or("");
body_start.trim_start_matches(['\r', '\n']).to_string()
}
None => String::new(),
}
}
pub fn discover_skills(
layers: &[crate::resource::DiscoveryLayer],
) -> Result<Vec<SkillResource>, SkillDiscoveryError> {
let mut seen: std::collections::HashMap<String, SkillResource> =
std::collections::HashMap::new();
for layer in layers {
let scan_dir = layer.scan_dir();
if !scan_dir.is_dir() {
continue;
}
if scan_dir.join("SKILL.md").exists() {
discover_skill_dir(&scan_dir, layer, &mut seen)?;
continue;
}
let entries = match std::fs::read_dir(&scan_dir) {
Ok(entries) => entries,
Err(e) => return Err(SkillDiscoveryError::Io(e)),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
discover_skill_dir(&path, layer, &mut seen)?;
}
}
let mut resources: Vec<SkillResource> = seen.into_values().collect();
resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Ok(resources)
}
fn discover_skill_dir(
path: &Path,
layer: &crate::resource::DiscoveryLayer,
seen: &mut std::collections::HashMap<String, SkillResource>,
) -> Result<(), SkillDiscoveryError> {
let skill_md = path.join("SKILL.md");
let content = std::fs::read_to_string(&skill_md)?;
let manifest = SkillManifest::from_skill_md(&content, &skill_md)?;
let canonical = path.canonicalize()?;
match seen.get(&manifest.name) {
Some(existing) if layer.precedence == existing.layer_precedence => {
return Err(SkillDiscoveryError::DuplicateName {
name: manifest.name,
path: canonical,
});
}
Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
Some(_) | None => {
seen.insert(
manifest.name.clone(),
SkillResource {
manifest,
path: canonical,
skill_md_path: skill_md,
layer_precedence: layer.precedence,
},
);
}
}
Ok(())
}
pub struct SkillRegistry {
resources: Vec<SkillResource>,
}
impl SkillRegistry {
pub fn from_resources(resources: Vec<SkillResource>) -> Self {
Self { resources }
}
pub fn names(&self) -> Vec<&str> {
self.resources
.iter()
.map(|r| r.manifest.name.as_str())
.collect()
}
pub fn get(&self, name: &str) -> Option<&SkillResource> {
self.resources.iter().find(|r| r.manifest.name == name)
}
pub fn auto_invocable(&self) -> Vec<&SkillResource> {
self.resources
.iter()
.filter(|r| !r.manifest.disable_model_invocation)
.collect()
}
pub fn load_body(&self, name: &str) -> Option<Result<String, SkillDiscoveryError>> {
self.get(name).map(|r| r.load_body())
}
pub fn format_for_prompt(&self) -> String {
if self.resources.is_empty() {
return String::new();
}
let mut parts = Vec::new();
for r in &self.resources {
let flag = if r.manifest.disable_model_invocation {
" [manual-only]"
} else {
""
};
parts.push(format!(
"- {}: {}{}",
r.manifest.name, r.manifest.description, flag
));
}
parts.join("\n")
}
}