use crate::skill::{
LoadedSkill, SkillDiagnostic, SkillLocation, SkillMetadata, SkillOrigin, SkillResourceEntry,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
pub trait SkillSource: Send + Sync {
fn discover(&self) -> HashMap<String, SkillMetadata>;
fn load(&self, name: &str) -> Option<LoadedSkill>;
fn diagnostics(&self) -> Vec<SkillDiagnostic> {
Vec::new()
}
}
pub struct FilesystemSkillSource {
root: PathBuf,
origin: SkillOrigin,
diagnostics: std::sync::Mutex<Vec<SkillDiagnostic>>,
}
#[derive(serde::Deserialize)]
struct SkillFrontmatter {
id: Option<String>,
#[serde(alias = "name")]
display_name: String,
description: String,
#[serde(default)]
auto_activate: bool,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
requires_tools: Vec<String>,
#[serde(default)]
requires_capabilities: Vec<String>,
#[serde(default)]
requires_trust: bool,
}
impl SkillFrontmatter {
fn into_metadata(self, derived_id: String, origin: SkillOrigin) -> SkillMetadata {
SkillMetadata {
id: self.id.unwrap_or(derived_id),
display_name: self.display_name,
description: self.description,
origin,
auto_activate: self.auto_activate,
tags: self.tags,
requires_tools: self.requires_tools,
requires_capabilities: self.requires_capabilities,
requires_trust: self.requires_trust,
}
}
}
impl FilesystemSkillSource {
pub fn new(root: impl Into<PathBuf>, origin: SkillOrigin) -> Self {
Self {
root: root.into(),
origin,
diagnostics: std::sync::Mutex::new(Vec::new()),
}
}
pub fn root(&self) -> &Path {
&self.root
}
fn parse_skill_file(
&self,
path: &Path,
) -> Option<(SkillMetadata, String, Vec<SkillResourceEntry>)> {
let content = std::fs::read_to_string(path).ok()?;
let (frontmatter, body) = split_frontmatter(&content)?;
let derived_id = derive_skill_id(path);
let metadata: SkillMetadata = match serde_yaml::from_str::<SkillFrontmatter>(frontmatter) {
Ok(m) => m.into_metadata(derived_id, self.origin),
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to parse skill frontmatter, using fallback metadata");
let skill_name = derive_skill_id(path);
self.diagnostics.lock().unwrap().push(SkillDiagnostic {
level: crate::skill::DiagnosticLevel::Warning,
message: format!(
"Failed to parse frontmatter for '{}': {}. Using fallback metadata.",
skill_name, e
),
skill_name: Some(skill_name.clone()),
});
SkillMetadata {
id: skill_name.clone(),
display_name: skill_name.clone(),
description: format!("Skill '{}' with unparsable frontmatter", skill_name),
origin: self.origin,
auto_activate: false,
tags: Vec::new(),
requires_tools: Vec::new(),
requires_capabilities: Vec::new(),
requires_trust: false,
}
}
};
let resources = self.discover_resources(path.parent()?);
Some((metadata, body.to_string(), resources))
}
fn discover_resources(&self, skill_dir: &Path) -> Vec<SkillResourceEntry> {
let resources_dir = skill_dir.join("resources");
if !resources_dir.exists() || !resources_dir.is_dir() {
return Vec::new();
}
let mut resources = Vec::new();
if let Ok(entries) = std::fs::read_dir(&resources_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let rel_path = path
.strip_prefix(&resources_dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
resources.push(SkillResourceEntry {
path: rel_path,
description: String::new(), });
}
}
}
resources
}
}
impl SkillSource for FilesystemSkillSource {
fn discover(&self) -> HashMap<String, SkillMetadata> {
let mut skills = HashMap::new();
if !self.root.exists() || !self.root.is_dir() {
return skills;
}
let entries = match std::fs::read_dir(&self.root) {
Ok(e) => e,
Err(e) => {
warn!(root = %self.root.display(), error = %e, "Failed to read skills directory");
return skills;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_file = path.join("SKILL.md");
if !skill_file.exists() {
continue;
}
if let Some((mut metadata, _, _)) = self.parse_skill_file(&skill_file) {
metadata.origin = self.origin;
skills.insert(metadata.id.clone(), metadata);
}
}
debug!(count = skills.len(), root = %self.root.display(), "Discovered skills");
skills
}
fn load(&self, name: &str) -> Option<LoadedSkill> {
let skill_dir = self.root.join(name);
let skill_file = skill_dir.join("SKILL.md");
if !skill_file.exists() {
return None;
}
let (mut metadata, body, resources) = self.parse_skill_file(&skill_file)?;
metadata.origin = self.origin;
Some(LoadedSkill {
metadata,
location: Some(SkillLocation {
directory: skill_dir.clone(),
skill_file,
resources_dir: skill_dir
.join("resources")
.exists()
.then(|| skill_dir.join("resources")),
}),
body,
resources,
})
}
fn diagnostics(&self) -> Vec<SkillDiagnostic> {
self.diagnostics.lock().unwrap().drain(..).collect()
}
}
pub struct StaticSkillSource {
skills: HashMap<String, LoadedSkill>,
}
impl StaticSkillSource {
pub fn new() -> Self {
Self {
skills: HashMap::new(),
}
}
pub fn register(&mut self, skill: LoadedSkill) {
self.skills.insert(skill.metadata.id.clone(), skill);
}
pub fn register_raw(
&mut self,
name: impl Into<String>,
description: impl Into<String>,
body: impl Into<String>,
) {
let name = name.into();
let skill = LoadedSkill {
metadata: SkillMetadata {
id: name.clone(),
display_name: name.clone(),
description: description.into(),
origin: SkillOrigin::ClientProvided,
auto_activate: false,
tags: Vec::new(),
requires_tools: Vec::new(),
requires_capabilities: Vec::new(),
requires_trust: false,
},
location: None,
body: body.into(),
resources: Vec::new(),
};
self.skills.insert(name, skill);
}
}
impl Default for StaticSkillSource {
fn default() -> Self {
Self::new()
}
}
impl SkillSource for StaticSkillSource {
fn discover(&self) -> HashMap<String, SkillMetadata> {
self.skills
.values()
.map(|s| (s.metadata.id.clone(), s.metadata.clone()))
.collect()
}
fn load(&self, name: &str) -> Option<LoadedSkill> {
self.skills.get(name).cloned()
}
}
fn derive_skill_id(path: &Path) -> String {
path.parent()
.and_then(|p| p.file_name())
.or_else(|| path.file_stem())
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let content = content.trim_start();
if !content.starts_with("---") {
return Some(("", content));
}
let after_open = &content[3..];
let close_idx = after_open.find("---")?;
let frontmatter = after_open[..close_idx].trim();
let body = after_open[close_idx + 3..].trim_start();
Some((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_frontmatter_with_valid_delimiters() {
let content = "---\nname: test\n---\n# Body\nContent here.";
let (frontmatter, body) = split_frontmatter(content).unwrap();
assert_eq!(frontmatter.trim(), "name: test");
assert_eq!(body, "# Body\nContent here.");
}
#[test]
fn split_frontmatter_without_delimiters() {
let content = "# Just body\nNo frontmatter.";
let (frontmatter, body) = split_frontmatter(content).unwrap();
assert_eq!(frontmatter, "");
assert_eq!(body, "# Just body\nNo frontmatter.");
}
#[test]
fn static_source_register_and_load() {
let mut source = StaticSkillSource::new();
source.register_raw(
"test-skill",
"A test skill",
"# Instructions\nDo something.",
);
let skills = source.discover();
assert_eq!(skills.len(), 1);
assert!(skills.contains_key("test-skill"));
let loaded = source.load("test-skill").unwrap();
assert_eq!(loaded.metadata.display_name, "test-skill");
assert_eq!(loaded.body, "# Instructions\nDo something.");
}
#[test]
fn filesystem_source_discovers_skills_from_directory() {
let temp_dir =
std::env::temp_dir().join(format!("iron-core-test-skills-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
let skill_dir = temp_dir.join("test-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nid: test-skill\nname: Test Skill\ndescription: A test skill\n---\n# Instructions\nDo something."
).unwrap();
let source = FilesystemSkillSource::new(&temp_dir, SkillOrigin::ProjectFilesystem);
let skills = source.discover();
assert_eq!(skills.len(), 1);
assert!(skills.contains_key("test-skill"));
let metadata = skills.get("test-skill").unwrap();
assert_eq!(metadata.display_name, "Test Skill");
assert_eq!(metadata.description, "A test skill");
let loaded = source.load("test-skill").unwrap();
assert_eq!(loaded.body, "# Instructions\nDo something.");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn filesystem_source_derives_missing_id_from_skill_directory() {
let temp_dir = std::env::temp_dir().join(format!(
"iron-core-test-skills-missing-id-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&temp_dir);
let skill_dir = temp_dir.join("architecture-patterns");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: architecture-patterns\ndescription: Implement backend architecture patterns\nrequires_trust: true\n---\n# Instructions\nDo something.",
)
.unwrap();
let source = FilesystemSkillSource::new(&temp_dir, SkillOrigin::ProjectFilesystem);
let skills = source.discover();
assert_eq!(skills.len(), 1);
assert!(skills.contains_key("architecture-patterns"));
let metadata = skills.get("architecture-patterns").unwrap();
assert_eq!(metadata.id, "architecture-patterns");
assert_eq!(metadata.display_name, "architecture-patterns");
assert_eq!(
metadata.description,
"Implement backend architecture patterns"
);
assert!(metadata.requires_trust);
assert!(source.diagnostics().is_empty());
let loaded = source.load("architecture-patterns").unwrap();
assert_eq!(loaded.metadata.id, "architecture-patterns");
assert_eq!(loaded.body, "# Instructions\nDo something.");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn filesystem_source_preserves_explicit_id() {
let temp_dir = std::env::temp_dir().join(format!(
"iron-core-test-skills-explicit-id-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&temp_dir);
let skill_dir = temp_dir.join("folder-name");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nid: explicit-id\nname: Explicit Skill\ndescription: A test skill\n---\n# Instructions",
)
.unwrap();
let source = FilesystemSkillSource::new(&temp_dir, SkillOrigin::ProjectFilesystem);
let skills = source.discover();
assert_eq!(skills.len(), 1);
assert!(skills.contains_key("explicit-id"));
assert!(!skills.contains_key("folder-name"));
assert!(source.diagnostics().is_empty());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn filesystem_source_handles_malformed_yaml_gracefully() {
let temp_dir =
std::env::temp_dir().join(format!("iron-core-test-malformed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
let skill_dir = temp_dir.join("bad-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nthis is not: valid: yaml: [\n---\n# Instructions\nDo something.",
)
.unwrap();
let source = FilesystemSkillSource::new(&temp_dir, SkillOrigin::ProjectFilesystem);
let skills = source.discover();
assert_eq!(skills.len(), 1);
assert!(skills.contains_key("bad-skill"));
let metadata = skills.get("bad-skill").unwrap();
assert_eq!(metadata.display_name, "bad-skill");
assert!(metadata.description.contains("unparsable"));
let diagnostics = source.diagnostics();
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].level, crate::skill::DiagnosticLevel::Warning);
assert!(diagnostics[0]
.message
.contains("Failed to parse frontmatter"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn filesystem_source_skips_directories_without_skill_md() {
let temp_dir =
std::env::temp_dir().join(format!("iron-core-test-empty-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
let empty_dir = temp_dir.join("empty-dir");
std::fs::create_dir_all(&empty_dir).unwrap();
std::fs::write(empty_dir.join("README.md"), "# Not a skill").unwrap();
let source = FilesystemSkillSource::new(&temp_dir, SkillOrigin::ProjectFilesystem);
let skills = source.discover();
assert_eq!(skills.len(), 0);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}