use std::path::{Path, PathBuf};
use super::parser::parse_skill_file;
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
pub body: String,
pub has_customize: bool,
pub version: Option<String>,
pub digest: String,
}
impl Skill {
pub fn provenance(&self) -> crate::knowledge::Provenance {
crate::knowledge::Provenance {
name: self.name.clone(),
version: self.version.clone(),
digest: self.digest.clone(),
origin: self.path.clone(),
}
}
}
pub struct SkillLoader;
impl SkillLoader {
pub fn discover(dirs: &[PathBuf]) -> (Vec<Skill>, Vec<String>) {
let mut skills = Vec::new();
let mut warnings = Vec::new();
for dir in dirs {
if !dir.exists() {
continue;
}
Self::scan_dir(dir, &mut skills, &mut warnings);
}
let mut seen = std::collections::HashSet::new();
skills.retain(|s| seen.insert(s.name.clone()));
(skills, warnings)
}
fn scan_dir(dir: &Path, skills: &mut Vec<Skill>, warnings: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
warnings.push(format!("Failed to read {}: {}", dir.display(), e));
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let raw = std::fs::read_to_string(&skill_md).unwrap_or_default();
match parse_skill_file(&skill_md) {
Ok(parsed) => {
let has_customize = path.join("customize.toml").exists();
let name = parsed.frontmatter.get("name").cloned().unwrap_or_else(|| {
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
});
skills.push(Skill {
name,
description: parsed
.frontmatter
.get("description")
.cloned()
.unwrap_or_default(),
path,
body: parsed.body,
has_customize,
version: parsed.frontmatter.get("version").cloned(),
digest: crate::knowledge::digest(&raw),
});
}
Err(e) => {
warnings.push(format!("Failed to parse {}: {}", skill_md.display(), e));
}
}
}
}
pub fn default_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".claude").join("skills"));
}
if let Some(config_dir) = dirs::config_dir() {
dirs.push(config_dir.join("procyon").join("skills"));
}
dirs
}
pub fn project_dirs(cwd: &Path) -> Vec<PathBuf> {
vec![
cwd.join(".procyon").join("skills"),
cwd.join(".stellar-build").join("skills"),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn discover_returns_empty_for_nonexistent_dirs() {
let (skills, warnings) = SkillLoader::discover(&[PathBuf::from("/nonexistent/path")]);
assert!(skills.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn a_discovered_skill_carries_its_version_digest_and_origin() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("soroban");
fs::create_dir_all(&skill_dir).unwrap();
let content = "---\nname: soroban\nversion: 2.1.0\ndescription: Contracts\n---\n\nStorage.";
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
let (skills, _) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
let skill = &skills[0];
assert_eq!(skill.version.as_deref(), Some("2.1.0"));
assert_eq!(skill.digest, crate::knowledge::digest(content));
assert_eq!(skill.path, skill_dir);
assert_eq!(
skill.provenance().cite(),
"soroban v2.1.0 ".to_string() + &format!("({})", skill.digest)
);
}
#[test]
fn editing_the_frontmatter_changes_the_digest() {
let digest_of = |content: &str| {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("s");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), content).unwrap();
SkillLoader::discover(&[tmp.path().to_path_buf()]).0[0]
.digest
.clone()
};
assert_ne!(
digest_of("---\nname: s\ndescription: use for testnet\n---\n\nBody."),
digest_of("---\nname: s\ndescription: use for mainnet\n---\n\nBody.")
);
}
#[test]
fn a_skill_without_a_declared_version_reports_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("s");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), "---\nname: s\n---\n\nBody.").unwrap();
assert!(SkillLoader::discover(&[tmp.path().to_path_buf()]).0[0]
.version
.is_none());
}
#[test]
fn discover_finds_skills_in_directory() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: A test skill\n---\n\n# Test\n\nContent.",
)
.unwrap();
let (skills, warnings) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
assert_eq!(skills.len(), 1);
assert!(warnings.is_empty());
let skill = &skills[0];
assert_eq!(skill.name, "test-skill");
assert_eq!(skill.description, "A test skill");
assert!(skill.body.contains("# Test"));
assert!(!skill.has_customize);
}
#[test]
fn discover_detects_customize_toml() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("with-customize");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: custom\ndescription: Has customize\n---\n\nBody.",
)
.unwrap();
fs::write(
skill_dir.join("customize.toml"),
"[agent]\nname = \"Custom\"\n",
)
.unwrap();
let (skills, _) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
assert_eq!(skills.len(), 1);
assert!(skills[0].has_customize);
}
#[test]
fn discover_deduplicates_by_name() {
let tmp = tempfile::tempdir().unwrap();
let dir1 = tmp.path().join("dir1").join("dup");
let dir2 = tmp.path().join("dir2").join("dup");
fs::create_dir_all(&dir1).unwrap();
fs::create_dir_all(&dir2).unwrap();
let md = "---\nname: dup\ndescription: Duplicate\n---\n\nBody.";
fs::write(dir1.join("SKILL.md"), md).unwrap();
fs::write(dir2.join("SKILL.md"), md).unwrap();
let (skills, _) =
SkillLoader::discover(&[tmp.path().join("dir1"), tmp.path().join("dir2")]);
assert_eq!(skills.len(), 1, "should deduplicate by name");
}
#[test]
fn discover_skips_directories_without_skill_md() {
let tmp = tempfile::tempdir().unwrap();
let no_skill = tmp.path().join("not-a-skill");
fs::create_dir_all(&no_skill).unwrap();
fs::write(no_skill.join("README.md"), "just a readme").unwrap();
let (skills, _) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
assert!(skills.is_empty());
}
#[test]
fn discover_warns_on_broken_skill_md() {
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken");
fs::create_dir_all(&broken).unwrap();
fs::write(broken.join("SKILL.md"), "no frontmatter here").unwrap();
let (skills, warnings) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
assert!(skills.is_empty());
assert!(!warnings.is_empty());
assert!(warnings[0].contains("broken"));
}
#[test]
fn default_dirs_contains_home_claude_skills() {
let dirs = SkillLoader::default_dirs();
assert!(!dirs.is_empty());
assert!(dirs
.iter()
.any(|d| d.to_string_lossy().contains(".claude/skills")));
}
#[test]
fn project_dirs_contains_stellar_build() {
let dirs = SkillLoader::project_dirs(Path::new("/workspace"));
assert!(dirs.contains(&PathBuf::from("/workspace/.stellar-build/skills")));
assert!(dirs.contains(&PathBuf::from("/workspace/.procyon/skills")));
}
}