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    fn skill_path(&self, name: &str) -> PathBuf {
68        self.base_path.join(format!("{name}.md"))
69    }
70}
71
72#[async_trait]
73impl SkillLoader for FileSkillLoader {
74    async fn load(&self, name: &str) -> Result<Skill> {
75        let path = self.skill_path(name);
76
77        if !path.exists() {
78            bail!("Skill file not found: {}", path.display());
79        }
80
81        let content = tokio::fs::read_to_string(&path)
82            .await
83            .with_context(|| format!("Failed to read skill file: {}", path.display()))?;
84
85        let skill = parse_skill_file(&content)
86            .with_context(|| format!("Failed to parse skill file: {}", path.display()))?;
87
88        // Verify the parsed name matches the filename
89        if skill.name != name {
90            tracing::warn!(
91                "Skill name '{}' in file doesn't match filename '{}'",
92                skill.name,
93                name
94            );
95        }
96
97        Ok(skill)
98    }
99
100    async fn list(&self) -> Result<Vec<String>> {
101        if !self.base_path.exists() {
102            return Ok(Vec::new());
103        }
104
105        let mut entries = tokio::fs::read_dir(&self.base_path)
106            .await
107            .with_context(|| {
108                format!(
109                    "Failed to read skills directory: {}",
110                    self.base_path.display()
111                )
112            })?;
113
114        let mut skills = Vec::new();
115
116        while let Some(entry) = entries.next_entry().await? {
117            let path = entry.path();
118
119            if path.extension().is_some_and(|ext| ext == "md")
120                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
121            {
122                skills.push(name.to_string());
123            }
124        }
125
126        skills.sort();
127        Ok(skills)
128    }
129}
130
131/// In-memory skill loader for testing.
132///
133/// Stores skills in memory rather than loading from files.
134#[derive(Default)]
135pub struct InMemorySkillLoader {
136    skills: std::sync::RwLock<std::collections::HashMap<String, Skill>>,
137}
138
139impl InMemorySkillLoader {
140    /// Create a new empty in-memory skill loader.
141    #[must_use]
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Add a skill to the loader.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the internal lock is poisoned.
151    pub fn add(&self, skill: Skill) -> Result<()> {
152        self.skills
153            .write()
154            .ok()
155            .context("lock poisoned")?
156            .insert(skill.name.clone(), skill);
157        Ok(())
158    }
159
160    /// Remove a skill from the loader.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the internal lock is poisoned.
165    pub fn remove(&self, name: &str) -> Result<Option<Skill>> {
166        let mut skills = self.skills.write().ok().context("lock poisoned")?;
167        Ok(skills.remove(name))
168    }
169}
170
171#[async_trait]
172impl SkillLoader for InMemorySkillLoader {
173    async fn load(&self, name: &str) -> Result<Skill> {
174        let skills = self.skills.read().ok().context("lock poisoned")?;
175        skills
176            .get(name)
177            .cloned()
178            .with_context(|| format!("Skill not found: {name}"))
179    }
180
181    async fn list(&self) -> Result<Vec<String>> {
182        let mut names: Vec<_> = self
183            .skills
184            .read()
185            .ok()
186            .context("lock poisoned")?
187            .keys()
188            .cloned()
189            .collect();
190        names.sort();
191        Ok(names)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::io::Write;
199    use tempfile::TempDir;
200
201    #[tokio::test]
202    async fn test_file_loader_load() -> Result<()> {
203        let dir = TempDir::new()?;
204        let skill_path = dir.path().join("test-skill.md");
205
206        let mut file = std::fs::File::create(&skill_path)?;
207        writeln!(
208            file,
209            "---
210name: test-skill
211description: A test skill
212---
213
214You are a test assistant."
215        )?;
216
217        let loader = FileSkillLoader::new(dir.path());
218        let skill = loader.load("test-skill").await?;
219
220        assert_eq!(skill.name, "test-skill");
221        assert_eq!(skill.description, "A test skill");
222        assert!(skill.system_prompt.contains("test assistant"));
223
224        Ok(())
225    }
226
227    #[tokio::test]
228    async fn test_file_loader_load_not_found() {
229        let dir = TempDir::new().unwrap();
230        let loader = FileSkillLoader::new(dir.path());
231
232        let result = loader.load("nonexistent").await;
233        assert!(result.is_err());
234        assert!(result.unwrap_err().to_string().contains("not found"));
235    }
236
237    #[tokio::test]
238    async fn test_file_loader_list() -> Result<()> {
239        let dir = TempDir::new()?;
240
241        // Create some skill files
242        for name in ["alpha", "beta", "gamma"] {
243            let path = dir.path().join(format!("{name}.md"));
244            let mut file = std::fs::File::create(&path)?;
245            writeln!(
246                file,
247                "---
248name: {name}
249---
250
251Content"
252            )?;
253        }
254
255        // Create a non-skill file
256        let _ = std::fs::File::create(dir.path().join("readme.txt"))?;
257
258        let loader = FileSkillLoader::new(dir.path());
259        let skills = loader.list().await?;
260
261        assert_eq!(skills, vec!["alpha", "beta", "gamma"]);
262
263        Ok(())
264    }
265
266    #[tokio::test]
267    async fn test_file_loader_list_empty_dir() -> Result<()> {
268        let dir = TempDir::new()?;
269        let loader = FileSkillLoader::new(dir.path());
270
271        let skills = loader.list().await?;
272        assert!(skills.is_empty());
273
274        Ok(())
275    }
276
277    #[tokio::test]
278    async fn test_file_loader_list_nonexistent_dir() -> Result<()> {
279        let loader = FileSkillLoader::new("/nonexistent/path");
280        let skills = loader.list().await?;
281        assert!(skills.is_empty());
282
283        Ok(())
284    }
285
286    #[tokio::test]
287    async fn test_file_loader_exists() -> Result<()> {
288        let dir = TempDir::new()?;
289        let skill_path = dir.path().join("exists.md");
290
291        let mut file = std::fs::File::create(&skill_path)?;
292        writeln!(
293            file,
294            "---
295name: exists
296---
297
298Content"
299        )?;
300
301        let loader = FileSkillLoader::new(dir.path());
302
303        assert!(loader.exists("exists").await);
304        assert!(!loader.exists("not-exists").await);
305
306        Ok(())
307    }
308
309    #[tokio::test]
310    async fn test_in_memory_loader() -> Result<()> {
311        let loader = InMemorySkillLoader::new();
312
313        loader.add(Skill::new("skill1", "Prompt 1").with_description("First skill"))?;
314        loader.add(Skill::new("skill2", "Prompt 2").with_description("Second skill"))?;
315
316        let first = loader.load("skill1").await?;
317        assert_eq!(first.name, "skill1");
318        assert_eq!(first.description, "First skill");
319
320        let skill_names = loader.list().await?;
321        assert_eq!(skill_names, vec!["skill1", "skill2"]);
322
323        loader.remove("skill1")?;
324        assert!(!loader.exists("skill1").await);
325
326        Ok(())
327    }
328
329    #[tokio::test]
330    async fn test_in_memory_loader_not_found() {
331        let loader = InMemorySkillLoader::new();
332        let result = loader.load("nonexistent").await;
333        assert!(result.is_err());
334    }
335}