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        // Read directly and map the NotFound error to a friendly message. This
84        // drops the redundant `exists()` stat (one fewer syscall) and closes
85        // the TOCTOU window between the pre-check and the read.
86        let content = match tokio::fs::read_to_string(&path).await {
87            Ok(content) => content,
88            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
89                bail!("Skill file not found: {}", path.display());
90            }
91            Err(err) => {
92                return Err(err)
93                    .with_context(|| format!("Failed to read skill file: {}", path.display()));
94            }
95        };
96
97        let skill = parse_skill_file(&content)
98            .with_context(|| format!("Failed to parse skill file: {}", path.display()))?;
99
100        // Verify the parsed name matches the filename
101        if skill.name != name {
102            log::warn!(
103                "Skill name '{}' in file doesn't match filename '{}'",
104                skill.name,
105                name
106            );
107        }
108
109        Ok(skill)
110    }
111
112    async fn list(&self) -> Result<Vec<String>> {
113        if !self.base_path.exists() {
114            return Ok(Vec::new());
115        }
116
117        let mut entries = tokio::fs::read_dir(&self.base_path)
118            .await
119            .with_context(|| {
120                format!(
121                    "Failed to read skills directory: {}",
122                    self.base_path.display()
123                )
124            })?;
125
126        let mut skills = Vec::new();
127
128        while let Some(entry) = entries.next_entry().await? {
129            let path = entry.path();
130
131            if path.extension().is_some_and(|ext| ext == "md")
132                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
133            {
134                skills.push(name.to_string());
135            }
136        }
137
138        skills.sort();
139        Ok(skills)
140    }
141
142    async fn exists(&self, name: &str) -> bool {
143        // Cheap existence check: stat the file instead of reading, parsing, and
144        // sanitizing it via the default `load(..).is_ok()` implementation.
145        let Ok(path) = self.skill_path(name) else {
146            return false;
147        };
148        tokio::fs::try_exists(&path).await.unwrap_or(false)
149    }
150}
151
152/// In-memory skill loader for testing.
153///
154/// Stores skills in memory rather than loading from files.
155#[derive(Default)]
156pub struct InMemorySkillLoader {
157    skills: std::sync::RwLock<std::collections::HashMap<String, Skill>>,
158}
159
160impl InMemorySkillLoader {
161    /// Create a new empty in-memory skill loader.
162    #[must_use]
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Add a skill to the loader.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if the internal lock is poisoned.
172    pub fn add(&self, skill: Skill) -> Result<()> {
173        self.skills
174            .write()
175            .ok()
176            .context("lock poisoned")?
177            .insert(skill.name.clone(), skill);
178        Ok(())
179    }
180
181    /// Remove a skill from the loader.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the internal lock is poisoned.
186    pub fn remove(&self, name: &str) -> Result<Option<Skill>> {
187        let mut skills = self.skills.write().ok().context("lock poisoned")?;
188        Ok(skills.remove(name))
189    }
190}
191
192#[async_trait]
193impl SkillLoader for InMemorySkillLoader {
194    async fn load(&self, name: &str) -> Result<Skill> {
195        let skills = self.skills.read().ok().context("lock poisoned")?;
196        skills
197            .get(name)
198            .cloned()
199            .with_context(|| format!("Skill not found: {name}"))
200    }
201
202    async fn list(&self) -> Result<Vec<String>> {
203        let mut names: Vec<_> = self
204            .skills
205            .read()
206            .ok()
207            .context("lock poisoned")?
208            .keys()
209            .cloned()
210            .collect();
211        names.sort();
212        Ok(names)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::io::Write;
220    use tempfile::TempDir;
221
222    #[tokio::test]
223    async fn test_file_loader_load() -> Result<()> {
224        let dir = TempDir::new()?;
225        let skill_path = dir.path().join("test-skill.md");
226
227        let mut file = std::fs::File::create(&skill_path)?;
228        writeln!(
229            file,
230            "---
231name: test-skill
232description: A test skill
233---
234
235You are a test assistant."
236        )?;
237
238        let loader = FileSkillLoader::new(dir.path());
239        let skill = loader.load("test-skill").await?;
240
241        assert_eq!(skill.name, "test-skill");
242        assert_eq!(skill.description, "A test skill");
243        assert!(skill.system_prompt.contains("test assistant"));
244
245        Ok(())
246    }
247
248    #[tokio::test]
249    async fn test_file_loader_load_not_found() {
250        let dir = TempDir::new().unwrap();
251        let loader = FileSkillLoader::new(dir.path());
252
253        let result = loader.load("nonexistent").await;
254        assert!(result.is_err());
255        assert!(result.unwrap_err().to_string().contains("not found"));
256    }
257
258    #[tokio::test]
259    async fn test_file_loader_list() -> Result<()> {
260        let dir = TempDir::new()?;
261
262        // Create some skill files
263        for name in ["alpha", "beta", "gamma"] {
264            let path = dir.path().join(format!("{name}.md"));
265            let mut file = std::fs::File::create(&path)?;
266            writeln!(
267                file,
268                "---
269name: {name}
270---
271
272Content"
273            )?;
274        }
275
276        // Create a non-skill file
277        let _ = std::fs::File::create(dir.path().join("readme.txt"))?;
278
279        let loader = FileSkillLoader::new(dir.path());
280        let skills = loader.list().await?;
281
282        assert_eq!(skills, vec!["alpha", "beta", "gamma"]);
283
284        Ok(())
285    }
286
287    #[tokio::test]
288    async fn test_file_loader_list_empty_dir() -> Result<()> {
289        let dir = TempDir::new()?;
290        let loader = FileSkillLoader::new(dir.path());
291
292        let skills = loader.list().await?;
293        assert!(skills.is_empty());
294
295        Ok(())
296    }
297
298    #[tokio::test]
299    async fn test_file_loader_list_nonexistent_dir() -> Result<()> {
300        let loader = FileSkillLoader::new("/nonexistent/path");
301        let skills = loader.list().await?;
302        assert!(skills.is_empty());
303
304        Ok(())
305    }
306
307    #[tokio::test]
308    async fn test_file_loader_exists() -> Result<()> {
309        let dir = TempDir::new()?;
310        let skill_path = dir.path().join("exists.md");
311
312        let mut file = std::fs::File::create(&skill_path)?;
313        writeln!(
314            file,
315            "---
316name: exists
317---
318
319Content"
320        )?;
321
322        let loader = FileSkillLoader::new(dir.path());
323
324        assert!(loader.exists("exists").await);
325        assert!(!loader.exists("not-exists").await);
326
327        Ok(())
328    }
329
330    #[tokio::test]
331    async fn test_in_memory_loader() -> Result<()> {
332        let loader = InMemorySkillLoader::new();
333
334        loader.add(Skill::new("skill1", "Prompt 1").with_description("First skill"))?;
335        loader.add(Skill::new("skill2", "Prompt 2").with_description("Second skill"))?;
336
337        let first = loader.load("skill1").await?;
338        assert_eq!(first.name, "skill1");
339        assert_eq!(first.description, "First skill");
340
341        let skill_names = loader.list().await?;
342        assert_eq!(skill_names, vec!["skill1", "skill2"]);
343
344        loader.remove("skill1")?;
345        assert!(!loader.exists("skill1").await);
346
347        Ok(())
348    }
349
350    #[tokio::test]
351    async fn test_in_memory_loader_not_found() {
352        let loader = InMemorySkillLoader::new();
353        let result = loader.load("nonexistent").await;
354        assert!(result.is_err());
355    }
356
357    #[tokio::test]
358    async fn test_file_loader_blocks_path_traversal() -> Result<()> {
359        let dir = TempDir::new()?;
360        let loader = FileSkillLoader::new(dir.path());
361
362        let traversal_names = [
363            "../etc/passwd",
364            "..\\windows\\system32",
365            "foo/../bar",
366            "foo/bar",
367            "foo\\bar",
368            "skill\0name",
369        ];
370
371        for name in &traversal_names {
372            let result = loader.load(name).await;
373            assert!(result.is_err(), "Expected error for skill name: {name}");
374            assert!(
375                result
376                    .unwrap_err()
377                    .to_string()
378                    .contains("Invalid skill name"),
379                "Expected 'Invalid skill name' error for: {name}"
380            );
381        }
382
383        Ok(())
384    }
385}