Skip to main content

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}