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 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 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 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#[derive(Default)]
156pub struct InMemorySkillLoader {
157 skills: std::sync::RwLock<std::collections::HashMap<String, Skill>>,
158}
159
160impl InMemorySkillLoader {
161 #[must_use]
163 pub fn new() -> Self {
164 Self::default()
165 }
166
167 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 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 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 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}