use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use echo_core::error::{ReactError, Result};
use super::types::{RawFrontmatter, SkillDescriptor};
const SKILL_FILE: &str = "SKILL.md";
const MAX_SCAN_DEPTH: usize = 4;
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"__pycache__",
".venv",
"dist",
"build",
];
#[derive(Debug, Clone)]
pub enum DiscoveryScope {
Project(PathBuf),
User,
Custom(PathBuf),
}
pub struct SkillLoader {
descriptors: HashMap<String, SkillDescriptor>,
legacy_instructions: HashMap<String, String>,
}
impl SkillLoader {
pub fn new() -> Self {
Self {
descriptors: HashMap::new(),
legacy_instructions: HashMap::new(),
}
}
pub async fn discover(&mut self, scopes: &[DiscoveryScope]) -> Result<Vec<SkillDescriptor>> {
let mut results = Vec::new();
for scope in scopes {
let dirs = scope_to_dirs(scope);
for dir in dirs {
if !dir.exists() {
debug!(
"Skill directory does not exist, skipping: {}",
dir.display()
);
continue;
}
let found = self.scan_directory(&dir, 0).await?;
for (desc, legacy_instr) in found {
if let Some(existing) = self.descriptors.get(&desc.name) {
warn!(
"Skill '{}' at '{}' shadowed by existing at '{}'",
desc.name,
desc.location.display(),
existing.location.display()
);
} else {
if !legacy_instr.is_empty() {
self.legacy_instructions
.insert(desc.name.clone(), legacy_instr);
}
self.descriptors.insert(desc.name.clone(), desc.clone());
results.push(desc);
}
}
}
}
info!("Skill discovery complete: {} skills found", results.len());
Ok(results)
}
pub async fn discover_from_dir(
&mut self,
dir: impl Into<PathBuf>,
) -> Result<Vec<SkillDescriptor>> {
self.discover(&[DiscoveryScope::Custom(dir.into())]).await
}
async fn scan_directory(
&self,
dir: &Path,
depth: usize,
) -> Result<Vec<(SkillDescriptor, String)>> {
if depth > MAX_SCAN_DEPTH {
return Ok(vec![]);
}
let mut found = Vec::new();
let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
ReactError::Other(format!("Cannot read directory '{}': {}", dir.display(), e))
})?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| ReactError::Other(format!("Error reading directory entry: {}", e)))?
{
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if SKIP_DIRS.contains(&dir_name.as_str()) {
continue;
}
let skill_file = path.join(SKILL_FILE);
if skill_file.exists() {
match parse_skill_file(&skill_file, &dir_name).await {
Ok((desc, legacy_instr)) => {
info!(
"Discovered skill '{}' at {}",
desc.name,
skill_file.display()
);
found.push((desc, legacy_instr));
}
Err(e) => {
warn!(
"Failed to parse '{}', skipping: {}",
skill_file.display(),
e
);
}
}
}
}
Ok(found)
}
pub fn get_descriptor(&self, name: &str) -> Option<&SkillDescriptor> {
self.descriptors.get(name)
}
pub fn list_descriptors(&self) -> Vec<&SkillDescriptor> {
let mut descs: Vec<&SkillDescriptor> = self.descriptors.values().collect();
descs.sort_by_key(|d| &d.name);
descs
}
pub fn into_descriptors(self) -> Vec<SkillDescriptor> {
let mut descs: Vec<SkillDescriptor> = self.descriptors.into_values().collect();
descs.sort_by(|a, b| a.name.cmp(&b.name));
descs
}
pub fn skill_count(&self) -> usize {
self.descriptors.len()
}
pub fn get_legacy_instructions(&self, name: &str) -> Option<&String> {
self.legacy_instructions.get(name)
}
}
impl Default for SkillLoader {
fn default() -> Self {
Self::new()
}
}
async fn parse_skill_file(path: &Path, parent_dir_name: &str) -> Result<(SkillDescriptor, String)> {
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| ReactError::Other(format!("Failed to read '{}': {}", path.display(), e)))?;
let raw = parse_frontmatter(&content)?;
if raw.description.trim().is_empty() {
return Err(ReactError::Other(format!(
"Skill at '{}': description is empty (required per spec)",
path.display()
)));
}
let legacy_instr = raw.instructions.clone().unwrap_or_default();
let descriptor = raw.clone().into_descriptor(
path.to_path_buf()
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf()),
);
if descriptor.name != parent_dir_name {
warn!(
"Skill '{}' name does not match directory '{}' (loading anyway)",
descriptor.name, parent_dir_name
);
}
for warning in descriptor.validate_name() {
warn!("Skill '{}': {}", descriptor.name, warning);
}
if raw.is_legacy_format() {
warn!(
"Skill '{}' uses legacy SKILL.md format (instructions/resources in frontmatter). \
Consider migrating to agentskills.io format where the body is the instructions.",
descriptor.name
);
}
Ok((descriptor, legacy_instr))
}
pub fn parse_skill_md(content: &str) -> Result<SkillDescriptor> {
let raw = parse_frontmatter(content)?;
Ok(raw.into_descriptor(std::path::PathBuf::new()))
}
fn parse_frontmatter(content: &str) -> Result<RawFrontmatter> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err(ReactError::Other(
"SKILL.md must begin with YAML frontmatter (---)".to_string(),
));
}
let after_open = trimmed
.get(3..)
.unwrap_or("")
.trim_start_matches('\r')
.trim_start_matches('\n');
let close_idx = after_open
.find("\n---")
.ok_or_else(|| ReactError::Other("SKILL.md frontmatter missing closing ---".to_string()))?;
let yaml_str = &after_open[..close_idx];
let after_close_start = &after_open[close_idx + 4..]; let close_line_remainder = &after_close_start[..after_close_start
.find('\n')
.unwrap_or(after_close_start.len())];
if !close_line_remainder.trim().is_empty() {
return Err(ReactError::Other(
"SKILL.md frontmatter closing --- has trailing content on same line".to_string(),
));
}
serde_yaml::from_str(yaml_str)
.map_err(|e| ReactError::Other(format!("SKILL.md YAML parse error: {}", e)))
}
pub fn extract_instructions(content: &str) -> String {
if let Ok(raw) = parse_frontmatter(content)
&& let Some(instructions) = raw.instructions
{
return instructions;
}
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return content.to_string();
}
let after_open = trimmed
.get(3..)
.unwrap_or("")
.trim_start_matches('\r')
.trim_start_matches('\n');
if let Some(close_idx) = after_open.find("\n---") {
let after_close = &after_open[close_idx + 4..];
after_close
.trim_start_matches('\r')
.trim_start_matches('\n')
.to_string()
} else {
content.to_string()
}
}
fn scope_to_dirs(scope: &DiscoveryScope) -> Vec<PathBuf> {
match scope {
DiscoveryScope::Project(root) => {
vec![root.join("skills"), root.join(".agents").join("skills")]
}
DiscoveryScope::User => {
if let Some(home) = dirs::home_dir() {
vec![home.join(".agents").join("skills")]
} else {
warn!("Cannot determine home directory for user-level skill discovery");
vec![]
}
}
DiscoveryScope::Custom(path) => {
vec![path.clone()]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_frontmatter_standard() {
let content = r#"---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
license: Apache-2.0
metadata:
author: example-org
version: "1.0"
---
# PDF Processing
Instructions here.
"#;
let raw = parse_frontmatter(content).unwrap();
assert_eq!(raw.name, "pdf-processing");
assert_eq!(raw.license, Some("Apache-2.0".into()));
assert!(!raw.is_legacy_format());
}
#[test]
fn test_parse_frontmatter_legacy() {
let content = r#"---
name: code_review
version: "1.0.0"
description: "Code review skill"
author: "team"
tags: [code, review]
instructions: |
Review the code carefully.
resources:
- name: checklist
path: checklist.md
description: "Review checklist"
---
"#;
let raw = parse_frontmatter(content).unwrap();
assert_eq!(raw.name, "code_review");
assert!(raw.is_legacy_format());
assert!(raw.instructions.is_some());
}
#[test]
fn test_parse_frontmatter_missing_description() {
let content = "---\nname: test\ndescription: \"\"\n---\n";
let raw = parse_frontmatter(content).unwrap();
assert!(raw.description.is_empty());
}
#[test]
fn test_parse_frontmatter_no_frontmatter() {
let content = "# Just markdown";
assert!(parse_frontmatter(content).is_err());
}
#[test]
fn test_parse_frontmatter_unclosed() {
let content = "---\nname: test\ndescription: Test\n";
assert!(parse_frontmatter(content).is_err());
}
#[test]
fn test_extract_instructions_body() {
let content = "---\nname: test\ndescription: Test\n---\n\n# Instructions\n\nDo stuff.";
let body = extract_instructions(content);
assert_eq!(body, "# Instructions\n\nDo stuff.");
}
#[test]
fn test_extract_instructions_legacy() {
let content =
"---\nname: test\ndescription: Test\ninstructions: |\n Do stuff.\n---\n\n# Body";
let body = extract_instructions(content);
assert_eq!(body.trim(), "Do stuff.");
}
#[test]
fn test_scope_to_dirs_project() {
let dirs = scope_to_dirs(&DiscoveryScope::Project(PathBuf::from("/my/project")));
assert_eq!(dirs.len(), 2);
assert_eq!(dirs[0], PathBuf::from("/my/project/skills"));
assert_eq!(dirs[1], PathBuf::from("/my/project/.agents/skills"));
}
#[test]
fn test_scope_to_dirs_custom() {
let dirs = scope_to_dirs(&DiscoveryScope::Custom(PathBuf::from("/custom/path")));
assert_eq!(dirs, vec![PathBuf::from("/custom/path")]);
}
#[test]
fn test_allowed_tools_string() {
let content = "---\nname: test\ndescription: Test\nallowed-tools: Bash(git:*) Read\n---\n";
let raw = parse_frontmatter(content).unwrap();
let desc = raw.into_descriptor(PathBuf::from("/test/SKILL.md"));
assert_eq!(desc.allowed_tools, vec!["Bash(git:*)", "Read"]);
}
}