Skip to main content

agent_sdk/skills/
loader.rs

1//! Skill loader implementations.
2
3use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use std::path::{Path, PathBuf};
6
7use super::{Skill, parser::parse_skill_file};
8
9/// Trait for loading skills from various sources.
10#[async_trait]
11pub trait SkillLoader: Send + Sync {
12    /// Load a skill by name.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the skill cannot be found or loaded.
17    async fn load(&self, name: &str) -> Result<Skill>;
18
19    /// List all available skill names.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if the skill list cannot be retrieved.
24    async fn list(&self) -> Result<Vec<String>>;
25
26    /// Check if a skill exists.
27    async fn exists(&self, name: &str) -> bool {
28        self.load(name).await.is_ok()
29    }
30}
31
32/// File-based skill loader.
33///
34/// Loads skills from markdown files in a directory. Each skill file should be
35/// named `{skill-name}.md` and contain YAML frontmatter.
36///
37/// # Example
38///
39/// ```ignore
40/// let loader = FileSkillLoader::new("./skills");
41/// let skill = loader.load("code-review").await?;
42/// ```
43pub struct FileSkillLoader {
44    base_path: PathBuf,
45}
46
47impl FileSkillLoader {
48    /// Create a new file-based skill loader.
49    ///
50    /// # Arguments
51    ///
52    /// * `base_path` - Directory containing skill files
53    #[must_use]
54    pub fn new(base_path: impl Into<PathBuf>) -> Self {
55        Self {
56            base_path: base_path.into(),
57        }
58    }
59
60    /// Get the base path for skill files.
61    #[must_use]
62    pub fn base_path(&self) -> &Path {
63        &self.base_path
64    }
65
66    /// Get the file path for a skill by name.
67    ///
68    /// Validates that the name does not contain path separators, `..`, or null
69    /// bytes to prevent directory-traversal attacks via crafted skill names.
70    fn skill_path(&self, name: &str) -> Result<PathBuf> {
71        if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
72            bail!("Invalid skill name: must not contain path separators, '..', or null bytes");
73        }
74        Ok(self.base_path.join(format!("{name}.md")))
75    }
76}
77
78#[async_trait]
79impl SkillLoader for FileSkillLoader {
80    async fn load(&self, name: &str) -> Result<Skill> {
81        let path = self.skill_path(name)?;
82
83        if !path.exists() {
84            bail!("Skill file not found: {}", path.display());
85        }
86
87        let content = tokio::fs::read_to_string(&path)
88            .await
89            .with_context(|| format!("Failed to read skill file: {}", path.display()))?;
90
91        let skill = parse_skill_file(&content)
92            .with_context(|| format!("Failed to parse skill file: {}", path.display()))?;
93
94        // Verify the parsed name matches the filename
95        if skill.name != name {
96            log::warn!(
97                "Skill name '{}' in file doesn't match filename '{}'",
98                skill.name,
99                name
100            );
101        }
102
103        Ok(skill)
104    }
105
106    async fn list(&self) -> Result<Vec<String>> {
107        if !self.base_path.exists() {
108            return Ok(Vec::new());
109        }
110
111        let mut entries = tokio::fs::read_dir(&self.base_path)
112            .await
113            .with_context(|| {
114                format!(
115                    "Failed to read skills directory: {}",
116                    self.base_path.display()
117                )
118            })?;
119
120        let mut skills = Vec::new();
121
122        while let Some(entry) = entries.next_entry().await? {
123            let path = entry.path();
124
125            if path.extension().is_some_and(|ext| ext == "md")
126                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
127            {
128                skills.push(name.to_string());
129            }
130        }
131
132        skills.sort();
133        Ok(skills)
134    }
135}
136
137/// In-memory skill loader for testing.
138///
139/// Stores skills in memory rather than loading from files.
140#[derive(Default)]
141pub struct InMemorySkillLoader {
142    skills: std::sync::RwLock<std::collections::HashMap<String, Skill>>,
143}
144
145impl InMemorySkillLoader {
146    /// Create a new empty in-memory skill loader.
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Add a skill to the loader.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the internal lock is poisoned.
157    pub fn add(&self, skill: Skill) -> Result<()> {
158        self.skills
159            .write()
160            .ok()
161            .context("lock poisoned")?
162            .insert(skill.name.clone(), skill);
163        Ok(())
164    }
165
166    /// Remove a skill from the loader.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the internal lock is poisoned.
171    pub fn remove(&self, name: &str) -> Result<Option<Skill>> {
172        let mut skills = self.skills.write().ok().context("lock poisoned")?;
173        Ok(skills.remove(name))
174    }
175}
176
177#[async_trait]
178impl SkillLoader for InMemorySkillLoader {
179    async fn load(&self, name: &str) -> Result<Skill> {
180        let skills = self.skills.read().ok().context("lock poisoned")?;
181        skills
182            .get(name)
183            .cloned()
184            .with_context(|| format!("Skill not found: {name}"))
185    }
186
187    async fn list(&self) -> Result<Vec<String>> {
188        let mut names: Vec<_> = self
189            .skills
190            .read()
191            .ok()
192            .context("lock poisoned")?
193            .keys()
194            .cloned()
195            .collect();
196        names.sort();
197        Ok(names)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::io::Write;
205    use tempfile::TempDir;
206
207    #[tokio::test]
208    async fn test_file_loader_load() -> Result<()> {
209        let dir = TempDir::new()?;
210        let skill_path = dir.path().join("test-skill.md");
211
212        let mut file = std::fs::File::create(&skill_path)?;
213        writeln!(
214            file,
215            "---
216name: test-skill
217description: A test skill
218---
219
220You are a test assistant."
221        )?;
222
223        let loader = FileSkillLoader::new(dir.path());
224        let skill = loader.load("test-skill").await?;
225
226        assert_eq!(skill.name, "test-skill");
227        assert_eq!(skill.description, "A test skill");
228        assert!(skill.system_prompt.contains("test assistant"));
229
230        Ok(())
231    }
232
233    #[tokio::test]
234    async fn test_file_loader_load_not_found() {
235        let dir = TempDir::new().unwrap();
236        let loader = FileSkillLoader::new(dir.path());
237
238        let result = loader.load("nonexistent").await;
239        assert!(result.is_err());
240        assert!(result.unwrap_err().to_string().contains("not found"));
241    }
242
243    #[tokio::test]
244    async fn test_file_loader_list() -> Result<()> {
245        let dir = TempDir::new()?;
246
247        // Create some skill files
248        for name in ["alpha", "beta", "gamma"] {
249            let path = dir.path().join(format!("{name}.md"));
250            let mut file = std::fs::File::create(&path)?;
251            writeln!(
252                file,
253                "---
254name: {name}
255---
256
257Content"
258            )?;
259        }
260
261        // Create a non-skill file
262        let _ = std::fs::File::create(dir.path().join("readme.txt"))?;
263
264        let loader = FileSkillLoader::new(dir.path());
265        let skills = loader.list().await?;
266
267        assert_eq!(skills, vec!["alpha", "beta", "gamma"]);
268
269        Ok(())
270    }
271
272    #[tokio::test]
273    async fn test_file_loader_list_empty_dir() -> Result<()> {
274        let dir = TempDir::new()?;
275        let loader = FileSkillLoader::new(dir.path());
276
277        let skills = loader.list().await?;
278        assert!(skills.is_empty());
279
280        Ok(())
281    }
282
283    #[tokio::test]
284    async fn test_file_loader_list_nonexistent_dir() -> Result<()> {
285        let loader = FileSkillLoader::new("/nonexistent/path");
286        let skills = loader.list().await?;
287        assert!(skills.is_empty());
288
289        Ok(())
290    }
291
292    #[tokio::test]
293    async fn test_file_loader_exists() -> Result<()> {
294        let dir = TempDir::new()?;
295        let skill_path = dir.path().join("exists.md");
296
297        let mut file = std::fs::File::create(&skill_path)?;
298        writeln!(
299            file,
300            "---
301name: exists
302---
303
304Content"
305        )?;
306
307        let loader = FileSkillLoader::new(dir.path());
308
309        assert!(loader.exists("exists").await);
310        assert!(!loader.exists("not-exists").await);
311
312        Ok(())
313    }
314
315    #[tokio::test]
316    async fn test_in_memory_loader() -> Result<()> {
317        let loader = InMemorySkillLoader::new();
318
319        loader.add(Skill::new("skill1", "Prompt 1").with_description("First skill"))?;
320        loader.add(Skill::new("skill2", "Prompt 2").with_description("Second skill"))?;
321
322        let first = loader.load("skill1").await?;
323        assert_eq!(first.name, "skill1");
324        assert_eq!(first.description, "First skill");
325
326        let skill_names = loader.list().await?;
327        assert_eq!(skill_names, vec!["skill1", "skill2"]);
328
329        loader.remove("skill1")?;
330        assert!(!loader.exists("skill1").await);
331
332        Ok(())
333    }
334
335    #[tokio::test]
336    async fn test_in_memory_loader_not_found() {
337        let loader = InMemorySkillLoader::new();
338        let result = loader.load("nonexistent").await;
339        assert!(result.is_err());
340    }
341
342    #[tokio::test]
343    async fn test_file_loader_blocks_path_traversal() -> Result<()> {
344        let dir = TempDir::new()?;
345        let loader = FileSkillLoader::new(dir.path());
346
347        let traversal_names = [
348            "../etc/passwd",
349            "..\\windows\\system32",
350            "foo/../bar",
351            "foo/bar",
352            "foo\\bar",
353            "skill\0name",
354        ];
355
356        for name in &traversal_names {
357            let result = loader.load(name).await;
358            assert!(result.is_err(), "Expected error for skill name: {name}");
359            assert!(
360                result
361                    .unwrap_err()
362                    .to_string()
363                    .contains("Invalid skill name"),
364                "Expected 'Invalid skill name' error for: {name}"
365            );
366        }
367
368        Ok(())
369    }
370}