agent_sdk/skills/
loader.rs1use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use std::path::{Path, PathBuf};
6
7use super::{Skill, parser::parse_skill_file};
8
9#[async_trait]
11pub trait SkillLoader: Send + Sync {
12 async fn load(&self, name: &str) -> Result<Skill>;
18
19 async fn list(&self) -> Result<Vec<String>>;
25
26 async fn exists(&self, name: &str) -> bool {
28 self.load(name).await.is_ok()
29 }
30}
31
32pub struct FileSkillLoader {
44 base_path: PathBuf,
45}
46
47impl FileSkillLoader {
48 #[must_use]
54 pub fn new(base_path: impl Into<PathBuf>) -> Self {
55 Self {
56 base_path: base_path.into(),
57 }
58 }
59
60 #[must_use]
62 pub fn base_path(&self) -> &Path {
63 &self.base_path
64 }
65
66 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 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#[derive(Default)]
135pub struct InMemorySkillLoader {
136 skills: std::sync::RwLock<std::collections::HashMap<String, Skill>>,
137}
138
139impl InMemorySkillLoader {
140 #[must_use]
142 pub fn new() -> Self {
143 Self::default()
144 }
145
146 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 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 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 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}