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) -> 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 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#[derive(Default)]
141pub struct InMemorySkillLoader {
142 skills: std::sync::RwLock<std::collections::HashMap<String, Skill>>,
143}
144
145impl InMemorySkillLoader {
146 #[must_use]
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 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 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 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 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}