#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::path::Path;
use gray_matter::engine::YAML;
use gray_matter::Matter;
use crate::skills::types::{LoadSkillsResult, Skill, SkillDiagnostic, SkillSource};
use crate::skills::validate::{validate_description, validate_name};
#[derive(Debug, Default)]
pub(super) struct ParsedFrontmatter {
pub name: Option<String>,
pub description: Option<String>,
pub disable_model_invocation: bool,
}
pub(super) fn parse_frontmatter(raw: &str) -> ParsedFrontmatter {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(raw);
let matter_text = parsed.matter.clone();
let map = parsed.data.and_then(|data| data.as_hashmap().ok());
let str_field = |k: &str| -> Option<String> {
map.as_ref()
.and_then(|m| m.get(k))
.and_then(|v| v.as_string().ok())
.or_else(|| frontmatter_line_field(&matter_text, k))
};
let bool_field = |k: &str| -> bool {
map.as_ref()
.and_then(|m| m.get(k))
.and_then(|v| v.as_bool().ok())
.or_else(|| frontmatter_line_field(&matter_text, k).and_then(|v| v.parse().ok()))
.unwrap_or(false)
};
ParsedFrontmatter {
name: str_field("name"),
description: str_field("description"),
disable_model_invocation: bool_field("disable-model-invocation"),
}
}
pub(crate) fn strip_frontmatter_body(raw: &str) -> String {
let Some(rest) = raw.strip_prefix("---\n") else {
return raw.to_string();
};
let Some(end) = rest.find("\n---\n") else {
return raw.to_string();
};
rest[end + "\n---\n".len()..].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_minimal_frontmatter() {
let raw = "---\nname: foo\ndescription: bar\n---\nbody\n";
let fm = parse_frontmatter(raw);
assert_eq!(fm.name.as_deref(), Some("foo"));
assert_eq!(fm.description.as_deref(), Some("bar"));
assert!(!fm.disable_model_invocation);
}
#[test]
fn parses_disable_model_invocation() {
let raw = "---\nname: foo\ndescription: bar\ndisable-model-invocation: true\n---\nbody\n";
let fm = parse_frontmatter(raw);
assert!(fm.disable_model_invocation);
}
#[test]
fn missing_frontmatter_returns_default() {
let fm = parse_frontmatter("just body\n");
assert!(fm.name.is_none());
assert!(fm.description.is_none());
assert!(!fm.disable_model_invocation);
}
#[test]
fn strips_frontmatter_body() {
let raw = "---\nname: foo\ndescription: bar\n---\nhello body\n";
assert_eq!(strip_frontmatter_body(raw), "hello body\n");
}
#[test]
fn passes_through_when_no_frontmatter() {
assert_eq!(
strip_frontmatter_body("no frontmatter here\n"),
"no frontmatter here\n"
);
}
}
pub(super) fn parse_skill_file(
file_path: &Path,
base_dir: &Path,
source: SkillSource,
fallback_name: Option<&str>,
) -> Result<Skill, SkillDiagnostic> {
let raw = std::fs::read_to_string(file_path).map_err(|err| SkillDiagnostic {
file_path: file_path.to_path_buf(),
message: format!("read failed: {err}"),
})?;
let fm = parse_frontmatter(&raw);
let name = fm
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| fallback_name.map(str::to_string));
let name = match name {
Some(n) => n,
None => {
return Err(SkillDiagnostic {
file_path: file_path.to_path_buf(),
message: "name is required (no frontmatter `name` and no filename fallback)".into(),
});
}
};
let name_errors = validate_name(&name);
if !name_errors.is_empty() {
return Err(SkillDiagnostic {
file_path: file_path.to_path_buf(),
message: name_errors.join("; "),
});
}
let desc_errors = validate_description(fm.description.as_deref());
if !desc_errors.is_empty() {
return Err(SkillDiagnostic {
file_path: file_path.to_path_buf(),
message: desc_errors.join("; "),
});
}
Ok(Skill {
name,
description: fm.description.unwrap_or_default().trim().to_string(),
file_path: file_path.to_path_buf(),
base_dir: base_dir.to_path_buf(),
disable_model_invocation: fm.disable_model_invocation,
source,
})
}
#[cfg(test)]
mod parse_skill_file_tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
let p = dir.join(name);
fs::write(&p, body).unwrap();
p
}
#[test]
fn parses_valid_skill() {
let tmp = tempdir().unwrap();
let p = write(
tmp.path(),
"x.md",
"---\nname: foo\ndescription: bar\n---\nbody\n",
);
let skill = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap();
assert_eq!(skill.name, "foo");
assert_eq!(skill.description, "bar");
assert!(!skill.disable_model_invocation);
assert_eq!(skill.source, SkillSource::Global);
assert_eq!(skill.file_path, p);
assert_eq!(skill.base_dir, tmp.path());
}
#[test]
fn flat_md_uses_fallback_name() {
let tmp = tempdir().unwrap();
let p = write(
tmp.path(),
"quick-tip.md",
"---\ndescription: bar\n---\nbody\n",
);
let skill =
parse_skill_file(&p, tmp.path(), SkillSource::Global, Some("quick-tip")).unwrap();
assert_eq!(skill.name, "quick-tip");
}
#[test]
fn invalid_name_produces_diagnostic() {
let tmp = tempdir().unwrap();
let p = write(
tmp.path(),
"x.md",
"---\nname: BAD_NAME\ndescription: bar\n---\nbody\n",
);
let diag = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap_err();
assert!(
diag.message.contains("invalid characters"),
"{}",
diag.message
);
assert_eq!(diag.file_path, p);
}
#[test]
fn missing_description_produces_diagnostic() {
let tmp = tempdir().unwrap();
let p = write(tmp.path(), "x.md", "---\nname: foo\n---\nbody\n");
let diag = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap_err();
assert!(
diag.message.contains("description is required"),
"{}",
diag.message
);
}
#[test]
fn missing_name_with_no_fallback_produces_diagnostic() {
let tmp = tempdir().unwrap();
let p = write(tmp.path(), "x.md", "---\ndescription: bar\n---\nbody\n");
let diag = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap_err();
assert!(
diag.message.contains("name is required"),
"{}",
diag.message
);
}
#[test]
fn disable_model_invocation_flag_propagates() {
let tmp = tempdir().unwrap();
let p = write(
tmp.path(),
"x.md",
"---\nname: foo\ndescription: bar\ndisable-model-invocation: true\n---\nbody\n",
);
let skill = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap();
assert!(skill.disable_model_invocation);
}
}
pub fn load_from_dir(dir: &Path, source: SkillSource) -> LoadSkillsResult {
let mut result = LoadSkillsResult::default();
let entries = match std::fs::read_dir(dir) {
Ok(it) => it,
Err(_) => return result,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if file_type.is_dir() {
let skill_md = path.join("SKILL.md");
if !skill_md.is_file() {
result.diagnostics.push(SkillDiagnostic {
file_path: path.clone(),
message: "skill directory missing SKILL.md; skipped".into(),
});
continue;
}
match parse_skill_file(&skill_md, &path, source, file_stem(&path).as_deref()) {
Ok(skill) => result.skills.push(skill),
Err(d) => result.diagnostics.push(d),
}
} else if file_type.is_file() {
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let fallback = file_stem(&path);
match parse_skill_file(&path, dir, source, fallback.as_deref()) {
Ok(skill) => result.skills.push(skill),
Err(d) => result.diagnostics.push(d),
}
}
}
result
}
fn frontmatter_line_field(matter: &str, key: &str) -> Option<String> {
matter.lines().find_map(|line| {
let (k, v) = line.split_once(':')?;
(k.trim() == key).then(|| v.trim().to_string())
})
}
fn file_stem(p: &Path) -> Option<String> {
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim_start_matches('.').to_string())
}
#[cfg(test)]
mod load_from_dir_tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn touch(p: &Path, body: &str) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
#[test]
fn missing_dir_returns_empty() {
let r = load_from_dir(Path::new("/no/such/path/for-test"), SkillSource::Global);
assert!(r.skills.is_empty() && r.diagnostics.is_empty());
}
#[test]
fn loads_directory_skill_via_skill_md() {
let tmp = tempdir().unwrap();
touch(
&tmp.path().join("foo").join("SKILL.md"),
"---\nname: foo\ndescription: d\n---\nbody\n",
);
let r = load_from_dir(tmp.path(), SkillSource::Global);
assert_eq!(r.skills.len(), 1);
assert!(r.diagnostics.is_empty());
assert_eq!(r.skills[0].name, "foo");
assert_eq!(r.skills[0].base_dir, tmp.path().join("foo"));
}
#[test]
fn loads_flat_md_skill() {
let tmp = tempdir().unwrap();
touch(
&tmp.path().join("quick-tip.md"),
"---\ndescription: d\n---\nbody\n",
);
let r = load_from_dir(tmp.path(), SkillSource::Global);
assert_eq!(r.skills.len(), 1, "{r:?}");
assert_eq!(r.skills[0].name, "quick-tip");
}
#[test]
fn directory_without_skill_md_emits_diagnostic() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("empty-dir")).unwrap();
let r = load_from_dir(tmp.path(), SkillSource::Global);
assert!(r.skills.is_empty());
assert_eq!(r.diagnostics.len(), 1);
assert!(r.diagnostics[0].message.contains("SKILL.md"));
}
#[test]
fn invalid_skill_emits_diagnostic_and_continues() {
let tmp = tempdir().unwrap();
touch(&tmp.path().join("good.md"), "---\ndescription: d\n---\n");
touch(
&tmp.path().join("bad.md"),
"---\nname: BAD\ndescription: d\n---\n",
);
let r = load_from_dir(tmp.path(), SkillSource::Global);
assert_eq!(r.skills.len(), 1);
assert_eq!(r.diagnostics.len(), 1);
assert!(r.diagnostics[0].file_path.ends_with("bad.md"));
}
#[test]
fn ignores_non_md_files() {
let tmp = tempdir().unwrap();
touch(&tmp.path().join("README.txt"), "ignored");
touch(&tmp.path().join(".hidden.md"), "---\ndescription:d\n---\n");
let r = load_from_dir(tmp.path(), SkillSource::Global);
assert_eq!(r.skills.len(), 1);
}
}
pub fn load_all(cwd: &Path, agent_dir: &Path) -> LoadSkillsResult {
let mut result = LoadSkillsResult::default();
let global = load_from_dir(&agent_dir.join("skills"), SkillSource::Global);
let project = load_from_dir(&cwd.join(".capo").join("skills"), SkillSource::Project);
let project_names: std::collections::HashSet<_> =
project.skills.iter().map(|s| s.name.clone()).collect();
for s in global.skills {
if project_names.contains(&s.name) {
result.diagnostics.push(SkillDiagnostic {
file_path: s.file_path.clone(),
message: format!(
"global skill `{}` overridden by project (project/.capo/skills wins)",
s.name
),
});
} else {
result.skills.push(s);
}
}
result.skills.extend(project.skills);
result.diagnostics.extend(global.diagnostics);
result.diagnostics.extend(project.diagnostics);
result
}
#[cfg(test)]
mod load_all_tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn project_overrides_global_same_name() {
let agent_dir = tempdir().unwrap();
let cwd = tempdir().unwrap();
let global = agent_dir.path().join("skills");
fs::create_dir_all(&global).unwrap();
fs::write(global.join("foo.md"), "---\ndescription: GLOBAL\n---\n").unwrap();
let project = cwd.path().join(".capo").join("skills");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("foo.md"), "---\ndescription: PROJECT\n---\n").unwrap();
let r = load_all(cwd.path(), agent_dir.path());
let names: Vec<_> = r
.skills
.iter()
.map(|s| (&s.name, &s.description, s.source))
.collect();
assert_eq!(names.len(), 1);
assert_eq!(names[0].0, "foo");
assert_eq!(names[0].1, "PROJECT");
assert_eq!(names[0].2, SkillSource::Project);
assert!(r
.diagnostics
.iter()
.any(|d| d.message.contains("overridden by project")));
}
#[test]
fn global_and_project_skills_coexist_when_distinct_names() {
let agent_dir = tempdir().unwrap();
let cwd = tempdir().unwrap();
let global = agent_dir.path().join("skills");
fs::create_dir_all(&global).unwrap();
fs::write(global.join("a.md"), "---\ndescription: A\n---\n").unwrap();
let project = cwd.path().join(".capo").join("skills");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("b.md"), "---\ndescription: B\n---\n").unwrap();
let r = load_all(cwd.path(), agent_dir.path());
let names: Vec<&str> = r.skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
}
#[test]
fn missing_dirs_return_empty() {
let agent_dir = tempdir().unwrap();
let cwd = tempdir().unwrap();
let r = load_all(cwd.path(), agent_dir.path());
assert!(r.skills.is_empty() && r.diagnostics.is_empty());
}
}