use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
pub const DISCOVERY_SKILL_MAX_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredSkill {
pub name: String,
pub path: PathBuf,
pub frontmatter: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDiagnostic {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SkillDiscovery {
pub skills: BTreeMap<String, DiscoveredSkill>,
pub diagnostics: Vec<SkillDiagnostic>,
}
#[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_descriptor(&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.clone()))
.collect();
SkillDiscovery {
skills,
diagnostics: discovery.diagnostics.clone(),
}
}
pub fn load_skill_descriptor(path: &Path) -> anyhow::Result<DiscoveredSkill> {
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 frontmatter = parse_skill_frontmatter(&file.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(DiscoveredSkill {
name,
path: path.to_path_buf(),
frontmatter,
})
}
pub fn parse_skill_frontmatter(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
let normalized;
let text = if text.contains("\r\n") {
normalized = text.replace("\r\n", "\n");
normalized.as_str()
} else {
text
};
if !text.starts_with("---\n") {
return Ok(BTreeMap::new());
}
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 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}");
};
let key = key.trim();
let value = parse_flat_frontmatter_scalar(value.trim())?;
frontmatter.insert(key.to_string(), value);
}
Ok(frontmatter)
}
fn parse_flat_frontmatter_scalar(value: &str) -> anyhow::Result<String> {
let value = value.trim();
let Some(&first) = value.as_bytes().first() else {
return Ok(String::new());
};
if matches!(first, b'"' | b'\'') {
if value.len() < 2 || value.as_bytes().last() != Some(&first) {
anyhow::bail!("frontmatter scalar has an unmatched quote");
}
return Ok(value[1..value.len() - 1].to_string());
}
Ok(value.to_string())
}