synwire_agent_skills/loader.rs
1//! Directory scanner that discovers `SKILL.md` files and produces [`SkillEntry`] values.
2
3use std::path::{Path, PathBuf};
4
5use tokio::fs;
6use tracing::debug;
7
8use crate::{
9 error::SkillError,
10 manifest::{SkillManifest, parse_skill_md},
11};
12
13/// A fully-loaded skill entry, combining the parsed manifest with the raw body
14/// text and the directory it was loaded from.
15#[derive(Debug, Clone)]
16pub struct SkillEntry {
17 /// The parsed manifest from the SKILL.md frontmatter.
18 pub manifest: SkillManifest,
19 /// The full SKILL.md content (instructions after the frontmatter).
20 pub body: String,
21 /// The directory that contains `SKILL.md`.
22 pub skill_dir: PathBuf,
23}
24
25/// Scans directories for `SKILL.md` files and loads them as [`SkillEntry`]
26/// values.
27#[derive(Debug, Default)]
28pub struct SkillLoader {}
29
30impl SkillLoader {
31 /// Create a new [`SkillLoader`].
32 pub const fn new() -> Self {
33 Self {}
34 }
35
36 /// Scan `dir` for immediate child directories that contain a `SKILL.md`
37 /// file, parse each manifest, and return the resulting entries.
38 ///
39 /// Only one level of subdirectories is examined — nested skill trees are
40 /// not walked recursively.
41 ///
42 /// # Errors
43 ///
44 /// Returns [`SkillError::Io`] if `dir` cannot be read.
45 /// Returns [`SkillError::InvalidManifest`] or [`SkillError::Yaml`] if a
46 /// `SKILL.md` file is malformed.
47 pub async fn scan(&self, dir: &Path) -> Result<Vec<SkillEntry>, SkillError> {
48 let mut entries: Vec<SkillEntry> = Vec::new();
49
50 let mut read_dir = fs::read_dir(dir).await?;
51 while let Some(child) = read_dir.next_entry().await? {
52 let child_path = child.path();
53 if !child_path.is_dir() {
54 continue;
55 }
56 let skill_file = child_path.join("SKILL.md");
57 if !skill_file.exists() {
58 continue;
59 }
60
61 debug!(path = %skill_file.display(), "loading skill");
62 let content = fs::read_to_string(&skill_file).await?;
63 let manifest = parse_skill_md(&content)?;
64 let body = extract_body(&content);
65
66 let entry = SkillEntry {
67 manifest,
68 body: body.to_owned(),
69 skill_dir: child_path,
70 };
71
72 self.validate(&entry)?;
73 entries.push(entry);
74 }
75
76 Ok(entries)
77 }
78
79 /// Validate a [`SkillEntry`] against structural invariants.
80 ///
81 /// Currently enforced:
82 /// - The skill directory name must match `manifest.name`.
83 ///
84 /// # Errors
85 ///
86 /// Returns [`SkillError::InvalidManifest`] if any constraint is violated.
87 pub fn validate(&self, entry: &SkillEntry) -> Result<(), SkillError> {
88 let dir_name = entry
89 .skill_dir
90 .file_name()
91 .and_then(|n| n.to_str())
92 .unwrap_or("");
93
94 if dir_name != entry.manifest.name {
95 return Err(SkillError::InvalidManifest(format!(
96 "directory name '{}' does not match skill name '{}'",
97 dir_name, entry.manifest.name
98 )));
99 }
100
101 Ok(())
102 }
103}
104
105/// Extract the body text from a SKILL.md file (everything after the closing
106/// `---` delimiter).
107fn extract_body(content: &str) -> &str {
108 // Skip the opening `---\n`
109 let Some(after_open) = content
110 .strip_prefix("---")
111 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
112 else {
113 return content;
114 };
115
116 // Find the closing `\n---`
117 after_open.find("\n---").map_or("", |pos| {
118 let remainder = &after_open[pos + 4..]; // skip `\n---`
119 // Skip the optional newline after the closing delimiter
120 remainder
121 .strip_prefix('\n')
122 .or_else(|| remainder.strip_prefix("\r\n"))
123 .unwrap_or(remainder)
124 })
125}