use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
pub const DISCOVERY_SKILL_MAX_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct Skill {
pub name: String,
pub path: PathBuf,
pub frontmatter: BTreeMap<String, String>,
pub body: String,
}
impl Skill {
pub(crate) fn metadata_only(&self) -> Self {
Self {
name: self.name.clone(),
path: self.path.clone(),
frontmatter: self.frontmatter.clone(),
body: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDiagnostic {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SkillDiscovery {
pub skills: BTreeMap<String, Skill>,
pub diagnostics: Vec<SkillDiagnostic>,
}
impl SkillDiscovery {
pub(crate) fn metadata_only(&self) -> Self {
let skills = self
.skills
.iter()
.map(|(name, skill)| (name.clone(), skill.metadata_only()))
.collect();
Self {
skills,
diagnostics: self.diagnostics.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRoots {
pub mc_skills: PathBuf,
pub repo_skills: PathBuf,
pub legacy_agents_skills: PathBuf,
pub additional_paths: Vec<PathBuf>,
}
impl SkillRoots {
pub fn from_paths(mc_root: &Path, project_root: &Path) -> Self {
let home = dirs::home_dir();
Self {
mc_skills: mc_root.join("skills"),
repo_skills: project_root.join(".agents").join("skills"),
legacy_agents_skills: legacy_agents_skills_root(home),
additional_paths: Vec::new(),
}
}
}
fn legacy_agents_skills_root(home: Option<PathBuf>) -> PathBuf {
home.map(|home| home.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from("/.magi-code-no-home-skills-sentinel"))
}
pub fn discover_skills(roots: &SkillRoots) -> SkillDiscovery {
let mut discovery = SkillDiscovery::default();
discover_root(&roots.legacy_agents_skills, &mut discovery);
discover_root(&roots.mc_skills, &mut discovery);
for root in &roots.additional_paths {
discover_additional_root(root, &mut discovery);
}
discover_root(&roots.repo_skills, &mut discovery);
discovery
}
fn discover_additional_root(root: &Path, discovery: &mut SkillDiscovery) {
if !root.is_absolute() {
discovery.diagnostics.push(SkillDiagnostic {
path: root.to_path_buf(),
message: "additional skill path must be absolute".to_string(),
});
return;
}
discover_root(root, discovery);
}
fn discover_root(root: &Path, discovery: &mut SkillDiscovery) {
let Ok(root_metadata) = fs::metadata(root) else {
return;
};
if !root_metadata.is_dir() {
return;
}
let mut skill_files = Vec::new();
collect_shallow_skill_files(root, discovery, &mut skill_files);
skill_files.sort();
let mut seen_in_root = BTreeMap::new();
for path in skill_files {
match load_skill(&path) {
Ok(skill) => {
if let Some(previous_path) = seen_in_root.insert(skill.name.clone(), path.clone()) {
discovery.diagnostics.push(SkillDiagnostic {
path: path.clone(),
message: format!(
"duplicate skill name '{}' in same root: {} and {}",
skill.name,
previous_path.display(),
path.display()
),
});
}
discovery.skills.insert(skill.name.clone(), skill);
}
Err(error) => discovery.diagnostics.push(SkillDiagnostic {
path,
message: error.to_string(),
}),
}
}
}
fn collect_shallow_skill_files(
root: &Path,
discovery: &mut SkillDiscovery,
skill_files: &mut Vec<PathBuf>,
) {
let entries = match sorted_read_dir(root) {
Ok(entries) => entries,
Err(error) => {
discovery.diagnostics.push(SkillDiagnostic {
path: root.to_path_buf(),
message: error.to_string(),
});
return;
}
};
for entry in entries {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
discovery.diagnostics.push(SkillDiagnostic {
path,
message: "could not read skill path file type".to_string(),
});
continue;
};
if !file_type.is_dir() {
continue;
}
let nested_entries = match sorted_read_dir(&path) {
Ok(entries) => entries,
Err(error) => {
discovery.diagnostics.push(SkillDiagnostic {
path,
message: error.to_string(),
});
continue;
}
};
let mut nested_dirs = Vec::new();
for nested in nested_entries {
let nested_path = nested.path();
let Ok(nested_type) = nested.file_type() else {
discovery.diagnostics.push(SkillDiagnostic {
path: nested_path,
message: "could not read skill path file type".to_string(),
});
continue;
};
if nested.file_name() == "SKILL.md" {
skill_files.push(nested_path);
} else if nested_type.is_dir() {
nested_dirs.push(nested_path);
}
}
for nested_path in nested_dirs {
let nested_entries = match sorted_read_dir(&nested_path) {
Ok(entries) => entries,
Err(error) => {
discovery.diagnostics.push(SkillDiagnostic {
path: nested_path,
message: error.to_string(),
});
continue;
}
};
for nested in nested_entries {
let nested_skill_path = nested.path();
let Ok(_nested_type) = nested.file_type() else {
discovery.diagnostics.push(SkillDiagnostic {
path: nested_skill_path,
message: "could not read skill path file type".to_string(),
});
continue;
};
if nested.file_name() == "SKILL.md" {
skill_files.push(nested_skill_path);
}
}
}
}
}
fn sorted_read_dir(path: &Path) -> std::io::Result<Vec<fs::DirEntry>> {
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.path());
Ok(entries)
}
pub fn filter_enabled_skills(
discovery: &SkillDiscovery,
disabled: &BTreeSet<String>,
) -> SkillDiscovery {
let skills = discovery
.skills
.iter()
.filter(|(name, _)| !disabled.contains(*name))
.map(|(name, skill)| (name.clone(), skill.metadata_only()))
.collect();
SkillDiscovery {
skills,
diagnostics: discovery.diagnostics.clone(),
}
}
pub fn load_skill(path: &Path) -> anyhow::Result<Skill> {
let file = crate::prompt_file::read_prompt_file(path, DISCOVERY_SKILL_MAX_BYTES, true)
.map_err(|error| {
let message = error.to_string();
if message.contains("symlink") {
anyhow::anyhow!("skill discovery requires regular file: {}", path.display())
} else if message.contains("exceeds") {
anyhow::anyhow!("skill discovery read limit exceeded: {message}")
} else {
error
}
})?;
let text = file.text;
let (frontmatter, body) = parse_skill_markdown(&text)?;
let name = path
.parent()
.and_then(Path::file_name)
.ok_or_else(|| {
anyhow::anyhow!(
"skill path has no parent directory name: {}",
path.display()
)
})?
.to_string_lossy()
.to_string();
Ok(Skill {
name,
path: path.to_path_buf(),
frontmatter,
body,
})
}
pub fn parse_skill_markdown(text: &str) -> anyhow::Result<(BTreeMap<String, String>, String)> {
let text = text.replace("\r\n", "\n");
if !text.starts_with("---\n") {
return Ok((BTreeMap::new(), text));
}
let rest = &text[4..];
let Some(end) = rest.find("\n---\n") else {
anyhow::bail!("frontmatter start marker without closing marker");
};
let frontmatter_text = &rest[..end];
let body = rest[end + 5..].to_string();
let mut frontmatter = BTreeMap::new();
for line in frontmatter_text
.lines()
.filter(|line| !line.trim().is_empty())
{
let Some((key, value)) = line.split_once(':') else {
anyhow::bail!("malformed frontmatter line: {line}");
};
frontmatter.insert(
key.trim().to_string(),
value.trim().trim_matches('"').to_string(),
);
}
Ok((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_skill_markdown_accepts_crlf_frontmatter() {
let (frontmatter, body) =
parse_skill_markdown("---\r\ndescription: Windows authored\r\n---\r\n# Body\r\n")
.unwrap();
assert_eq!(frontmatter["description"], "Windows authored");
assert_eq!(body, "# Body\n");
}
#[test]
fn legacy_agents_skills_root_without_home_is_not_cwd_relative() {
let root = legacy_agents_skills_root(None);
assert!(root.is_absolute());
assert!(!root.starts_with(".agents"));
assert_eq!(
legacy_agents_skills_root(Some(PathBuf::from("/home/test"))),
PathBuf::from("/home/test/.agents/skills")
);
}
#[test]
fn skill_discovery_parses_frontmatter_and_skips_malformed() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
let legacy_skills = temp.path().join("legacy/skills");
fs::create_dir_all(mc_skills.join("review")).unwrap();
fs::create_dir_all(repo_skills.join("plan")).unwrap();
fs::create_dir_all(legacy_skills.join("bad")).unwrap();
fs::write(
mc_skills.join("review/SKILL.md"),
"---\ndescription: Review code\n---\n# Review\n",
)
.unwrap();
fs::write(repo_skills.join("plan/SKILL.md"), "# Plan\n").unwrap();
fs::write(legacy_skills.join("bad/SKILL.md"), "---\nbad-line\n").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills,
legacy_agents_skills: legacy_skills,
additional_paths: Vec::new(),
});
assert!(discovery.skills.contains_key("review"));
assert!(discovery.skills.contains_key("plan"));
assert_eq!(
discovery.skills["review"].frontmatter["description"],
"Review code"
);
assert_eq!(discovery.diagnostics.len(), 1);
}
#[test]
fn nested_skill_layouts_are_discovered_across_all_roots() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
let extra_skills = temp.path().join("extra-skills");
let legacy_skills = temp.path().join("legacy/skills");
fs::create_dir_all(mc_skills.join("direct")).unwrap();
fs::create_dir_all(mc_skills.join("category/review")).unwrap();
fs::create_dir_all(mc_skills.join("a/b/too_deep")).unwrap();
fs::create_dir_all(repo_skills.join("group/repo_nested")).unwrap();
fs::create_dir_all(extra_skills.join("group/configured_nested")).unwrap();
fs::create_dir_all(legacy_skills.join("group/legacy_nested")).unwrap();
fs::write(mc_skills.join("direct/SKILL.md"), "direct").unwrap();
fs::write(mc_skills.join("category/review/SKILL.md"), "review").unwrap();
fs::write(mc_skills.join("a/b/too_deep/SKILL.md"), "too deep").unwrap();
fs::write(repo_skills.join("group/repo_nested/SKILL.md"), "repo").unwrap();
fs::write(
extra_skills.join("group/configured_nested/SKILL.md"),
"configured",
)
.unwrap();
fs::write(legacy_skills.join("group/legacy_nested/SKILL.md"), "legacy").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills,
legacy_agents_skills: legacy_skills,
additional_paths: vec![extra_skills],
});
assert!(discovery.skills.contains_key("direct"));
assert_eq!(discovery.skills["review"].body, "review");
assert_eq!(discovery.skills["review"].name, "review");
assert!(discovery.skills.contains_key("repo_nested"));
assert!(discovery.skills.contains_key("configured_nested"));
assert!(discovery.skills.contains_key("legacy_nested"));
assert!(!discovery.skills.contains_key("category"));
assert!(!discovery.skills.contains_key("too_deep"));
}
#[test]
fn nested_skill_collision_preserves_root_priority() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
let extra_skills = temp.path().join("extra-skills");
let legacy_skills = temp.path().join("legacy/skills");
fs::create_dir_all(legacy_skills.join("group/same")).unwrap();
fs::create_dir_all(mc_skills.join("same")).unwrap();
fs::create_dir_all(extra_skills.join("group/same")).unwrap();
fs::create_dir_all(repo_skills.join("group/same")).unwrap();
fs::write(legacy_skills.join("group/same/SKILL.md"), "legacy").unwrap();
fs::write(mc_skills.join("same/SKILL.md"), "mc").unwrap();
fs::write(extra_skills.join("group/same/SKILL.md"), "extra").unwrap();
fs::write(repo_skills.join("group/same/SKILL.md"), "repo").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills: repo_skills.clone(),
legacy_agents_skills: legacy_skills,
additional_paths: vec![extra_skills],
});
assert_eq!(discovery.skills["same"].body, "repo");
assert_eq!(
discovery.skills["same"].path,
repo_skills.join("group/same/SKILL.md")
);
}
#[test]
fn discover_root_reports_same_root_skill_name_collision() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("skills");
let first = root.join("alpha/same/SKILL.md");
let second = root.join("beta/same/SKILL.md");
fs::create_dir_all(first.parent().unwrap()).unwrap();
fs::create_dir_all(second.parent().unwrap()).unwrap();
fs::write(&first, "first").unwrap();
fs::write(&second, "second").unwrap();
let mut discovery = SkillDiscovery::default();
discover_root(&root, &mut discovery);
assert_eq!(discovery.skills["same"].path, second);
let diagnostic = discovery
.diagnostics
.iter()
.find(|diagnostic| diagnostic.message.contains("duplicate skill name 'same'"))
.expect("duplicate diagnostic");
assert!(diagnostic.message.contains(&first.display().to_string()));
assert!(diagnostic.message.contains(&second.display().to_string()));
}
#[test]
fn additional_skill_paths_are_discovered_between_mc_and_repo_priority() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
let extra_one = temp.path().join("extra-one");
let extra_two = temp.path().join("extra-two");
let legacy_skills = temp.path().join("legacy/skills");
for root in [
&mc_skills,
&repo_skills,
&extra_one,
&extra_two,
&legacy_skills,
] {
fs::create_dir_all(root.join("same")).unwrap();
fs::create_dir_all(root.join("configured")).unwrap();
}
fs::write(legacy_skills.join("same/SKILL.md"), "legacy").unwrap();
fs::write(mc_skills.join("same/SKILL.md"), "mc").unwrap();
fs::write(extra_one.join("same/SKILL.md"), "extra-one").unwrap();
fs::write(extra_two.join("same/SKILL.md"), "extra-two").unwrap();
fs::write(repo_skills.join("same/SKILL.md"), "repo").unwrap();
fs::write(legacy_skills.join("configured/SKILL.md"), "legacy").unwrap();
fs::write(mc_skills.join("configured/SKILL.md"), "mc").unwrap();
fs::write(extra_one.join("configured/SKILL.md"), "extra-one").unwrap();
fs::write(extra_two.join("configured/SKILL.md"), "extra-two").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills: repo_skills.clone(),
legacy_agents_skills: legacy_skills,
additional_paths: vec![extra_one, extra_two],
});
assert_eq!(discovery.skills["same"].body, "repo");
assert_eq!(discovery.skills["configured"].body, "extra-two");
assert_eq!(
discovery.skills["same"].path,
repo_skills.join("same/SKILL.md")
);
}
#[test]
fn relative_additional_skill_paths_are_diagnostic_only() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
fs::create_dir_all(mc_skills.join("valid")).unwrap();
fs::write(mc_skills.join("valid/SKILL.md"), "valid").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills: temp.path().join("repo/.agents/skills"),
legacy_agents_skills: temp.path().join("legacy/skills"),
additional_paths: vec![PathBuf::from("relative-skills")],
});
assert!(discovery.skills.contains_key("valid"));
assert!(discovery.diagnostics.iter().any(|diagnostic| {
diagnostic.path == Path::new("relative-skills")
&& diagnostic
.message
.contains("additional skill path must be absolute")
}));
}
#[test]
fn skill_collision_prefers_repo_then_mc_then_legacy() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
let legacy_skills = temp.path().join("legacy/skills");
for root in [&mc_skills, &repo_skills, &legacy_skills] {
fs::create_dir_all(root.join("same")).unwrap();
}
fs::write(legacy_skills.join("same/SKILL.md"), "legacy").unwrap();
fs::write(mc_skills.join("same/SKILL.md"), "mc").unwrap();
fs::write(repo_skills.join("same/SKILL.md"), "repo").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills: repo_skills.clone(),
legacy_agents_skills: legacy_skills,
additional_paths: Vec::new(),
});
assert_eq!(discovery.skills["same"].body, "repo");
assert_eq!(
discovery.skills["same"].path,
repo_skills.join("same/SKILL.md")
);
}
#[test]
fn discover_skills_rejects_oversized_skill_file_non_fatally() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
fs::create_dir_all(mc_skills.join("big")).unwrap();
fs::create_dir_all(repo_skills.join("valid")).unwrap();
fs::write(
mc_skills.join("big/SKILL.md"),
vec![b'x'; DISCOVERY_SKILL_MAX_BYTES as usize + 1],
)
.unwrap();
fs::write(repo_skills.join("valid/SKILL.md"), "valid").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills,
legacy_agents_skills: temp.path().join("legacy/skills"),
additional_paths: Vec::new(),
});
assert!(discovery.skills.contains_key("valid"));
assert!(!discovery.skills.contains_key("big"));
assert!(discovery.diagnostics.iter().any(|diagnostic| {
diagnostic.path.ends_with("big/SKILL.md")
&& diagnostic.message.contains("skill discovery read limit")
}));
}
#[test]
fn load_skill_rejects_oversized_skill_file() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("skills/big");
fs::create_dir_all(&skill_dir).unwrap();
let skill_path = skill_dir.join("SKILL.md");
fs::write(
&skill_path,
vec![b'x'; DISCOVERY_SKILL_MAX_BYTES as usize + 1],
)
.unwrap();
let error = load_skill(&skill_path).unwrap_err().to_string();
assert!(error.contains("skill discovery read limit"), "{error}");
}
#[cfg(unix)]
#[test]
fn load_skill_rejects_symlink_skill_file() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("skills/link");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("real.md"), "real").unwrap();
let skill_path = skill_dir.join("SKILL.md");
symlink(skill_dir.join("real.md"), &skill_path).unwrap();
let error = load_skill(&skill_path).unwrap_err().to_string();
assert!(error.contains("regular file"), "{error}");
}
#[test]
fn discover_skills_reports_non_regular_skill_file_non_fatally() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
let repo_skills = temp.path().join("repo/.agents/skills");
fs::create_dir_all(mc_skills.join("dir/SKILL.md")).unwrap();
fs::create_dir_all(repo_skills.join("valid")).unwrap();
fs::write(repo_skills.join("valid/SKILL.md"), "valid").unwrap();
let discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills,
legacy_agents_skills: temp.path().join("legacy/skills"),
additional_paths: Vec::new(),
});
assert!(discovery.skills.contains_key("valid"));
assert!(!discovery.skills.contains_key("dir"));
assert!(discovery.diagnostics.iter().any(|diagnostic| {
diagnostic.path.ends_with("dir/SKILL.md") && diagnostic.message.contains("regular file")
}));
}
#[test]
fn filter_enabled_skills_omits_disabled_and_preserves_diagnostics() {
let temp = TempDir::new().unwrap();
let mc_skills = temp.path().join("mc/skills");
fs::create_dir_all(mc_skills.join("review")).unwrap();
fs::create_dir_all(mc_skills.join("plan")).unwrap();
fs::write(mc_skills.join("review/SKILL.md"), "review").unwrap();
fs::write(mc_skills.join("plan/SKILL.md"), "plan").unwrap();
let mut discovery = discover_skills(&SkillRoots {
mc_skills,
repo_skills: temp.path().join("missing-repo"),
legacy_agents_skills: temp.path().join("missing-legacy"),
additional_paths: Vec::new(),
});
discovery.diagnostics.push(SkillDiagnostic {
path: temp.path().join("bad"),
message: "kept".to_string(),
});
let enabled = filter_enabled_skills(
&discovery,
&BTreeSet::from(["review".to_string(), "unknown".to_string()]),
);
assert!(!enabled.skills.contains_key("review"));
assert!(enabled.skills.contains_key("plan"));
assert_eq!(enabled.diagnostics, discovery.diagnostics);
assert!(discovery.skills.contains_key("review"));
}
#[test]
fn skill_discovery_metadata_only_strips_bodies_but_preserves_diagnostics() {
let mut discovery = SkillDiscovery::default();
discovery.skills.insert(
"enabled".to_string(),
Skill {
name: "enabled".to_string(),
path: PathBuf::from("/skills/enabled/SKILL.md"),
frontmatter: BTreeMap::from([(
"description".to_string(),
"Prompt visible".to_string(),
)]),
body: "large skill body".to_string(),
},
);
discovery.diagnostics.push(SkillDiagnostic {
path: PathBuf::from("/bad"),
message: "kept".to_string(),
});
let metadata = discovery.metadata_only();
assert_eq!(
metadata.skills["enabled"].frontmatter,
discovery.skills["enabled"].frontmatter
);
assert!(metadata.skills["enabled"].body.is_empty());
assert_eq!(metadata.diagnostics, discovery.diagnostics);
assert_eq!(discovery.skills["enabled"].body, "large skill body");
}
#[test]
fn filter_enabled_skills_strips_bodies_but_preserves_prompt_metadata() {
let mut discovery = SkillDiscovery::default();
let mut frontmatter = BTreeMap::new();
frontmatter.insert("description".to_string(), "Prompt visible".to_string());
discovery.skills.insert(
"enabled".to_string(),
Skill {
name: "enabled".to_string(),
path: PathBuf::from("/skills/enabled/SKILL.md"),
frontmatter: frontmatter.clone(),
body: "large skill body".to_string(),
},
);
let enabled = filter_enabled_skills(&discovery, &BTreeSet::new());
assert_eq!(enabled.skills["enabled"].frontmatter, frontmatter);
assert_eq!(
enabled.skills["enabled"].path,
PathBuf::from("/skills/enabled/SKILL.md")
);
assert!(enabled.skills["enabled"].body.is_empty());
assert_eq!(discovery.skills["enabled"].body, "large skill body");
}
}