claude_agent/skills/
index_loader.rs1use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use super::SkillIndex;
11use crate::common::{ContentSource, SourceType, is_skill_file, parse_frontmatter};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillFrontmatter {
16 pub name: String,
17 pub description: String,
18 #[serde(default)]
19 pub triggers: Vec<String>,
20 #[serde(default, alias = "allowed-tools")]
21 pub allowed_tools: Vec<String>,
22 #[serde(default)]
23 pub source_type: Option<String>,
24 #[serde(default)]
25 pub model: Option<String>,
26 #[serde(default, alias = "argument-hint")]
27 pub argument_hint: Option<String>,
28}
29
30#[derive(Debug, Clone, Copy, Default)]
35pub struct SkillIndexLoader;
36
37impl SkillIndexLoader {
38 pub fn new() -> Self {
40 Self
41 }
42
43 pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SkillIndex> {
48 let doc = parse_frontmatter::<SkillFrontmatter>(content)?;
49 Ok(self.build_index(doc.frontmatter, path))
50 }
51
52 pub fn parse_frontmatter_only(&self, content: &str, path: &Path) -> crate::Result<SkillIndex> {
54 self.parse_index(content, path)
55 }
56
57 fn build_index(&self, fm: SkillFrontmatter, path: &Path) -> SkillIndex {
58 let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
59
60 let mut index = SkillIndex::new(fm.name, fm.description)
61 .with_source(ContentSource::file(path))
62 .with_source_type(source_type);
63
64 if !fm.triggers.is_empty() {
65 index = index.with_triggers(fm.triggers);
66 }
67
68 if !fm.allowed_tools.is_empty() {
69 index = index.with_allowed_tools(fm.allowed_tools);
70 }
71
72 if let Some(model) = fm.model {
73 index = index.with_model(model);
74 }
75
76 if let Some(hint) = fm.argument_hint {
77 index = index.with_argument_hint(hint);
78 }
79
80 index
81 }
82
83 pub async fn load_file(&self, path: &Path) -> crate::Result<SkillIndex> {
85 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
86 crate::Error::Config(format!("Failed to read skill file {:?}: {}", path, e))
87 })?;
88
89 self.parse_index(&content, path)
90 }
91
92 pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SkillIndex>> {
97 let mut indices = Vec::new();
98
99 if !dir.exists() {
100 return Ok(indices);
101 }
102
103 self.scan_directory_recursive(dir, &mut indices).await?;
104 Ok(indices)
105 }
106
107 fn scan_directory_recursive<'a>(
108 &'a self,
109 dir: &'a Path,
110 indices: &'a mut Vec<SkillIndex>,
111 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<()>> + Send + 'a>> {
112 Box::pin(async move {
113 let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
114 crate::Error::Config(format!("Failed to read directory {:?}: {}", dir, e))
115 })?;
116
117 while let Some(entry) = entries.next_entry().await.map_err(|e| {
118 crate::Error::Config(format!("Failed to read directory entry: {}", e))
119 })? {
120 let path = entry.path();
121
122 if path.is_dir() {
123 let skill_file = path.join("SKILL.md");
125 if skill_file.exists() {
126 if let Ok(index) = self.load_file(&skill_file).await {
127 indices.push(index);
128 }
129 } else {
130 self.scan_directory_recursive(&path, indices).await?;
132 }
133 } else if is_skill_file(&path)
134 && let Ok(index) = self.load_file(&path).await
135 {
136 indices.push(index);
137 }
138 }
139
140 Ok(())
141 })
142 }
143
144 pub fn create_inline(
146 &self,
147 name: impl Into<String>,
148 description: impl Into<String>,
149 content: impl Into<String>,
150 ) -> SkillIndex {
151 SkillIndex::new(name, description).with_source(ContentSource::in_memory(content))
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn test_parse_frontmatter() {
161 let content = r#"---
162name: test-skill
163description: A test skill
164triggers:
165 - /test
166 - test please
167allowed-tools:
168 - Read
169 - Grep
170model: claude-haiku-4-5-20251001
171---
172
173This is the skill content that should NOT be loaded into memory during indexing.
174"#;
175
176 let loader = SkillIndexLoader::new();
177 let index = loader
178 .parse_index(content, Path::new("/skills/test.skill.md"))
179 .unwrap();
180
181 assert_eq!(index.name, "test-skill");
182 assert_eq!(index.description, "A test skill");
183 assert_eq!(index.triggers, vec!["/test", "test please"]);
184 assert_eq!(index.allowed_tools, vec!["Read", "Grep"]);
185 assert_eq!(index.model, Some("claude-haiku-4-5-20251001".to_string()));
186
187 assert!(index.source.is_file());
189 }
190
191 #[test]
192 fn test_parse_minimal_frontmatter() {
193 let content = r#"---
194name: minimal
195description: Minimal skill
196---
197
198Content here.
199"#;
200
201 let loader = SkillIndexLoader::new();
202 let index = loader
203 .parse_index(content, Path::new("/skills/minimal.skill.md"))
204 .unwrap();
205
206 assert_eq!(index.name, "minimal");
207 assert!(index.triggers.is_empty());
208 assert!(index.allowed_tools.is_empty());
209 assert!(index.model.is_none());
210 }
211
212 #[test]
213 fn test_create_inline() {
214 let loader = SkillIndexLoader::new();
215 let index = loader.create_inline("inline", "Inline skill", "Full content");
216
217 assert_eq!(index.name, "inline");
218 assert!(index.source.is_in_memory());
219 }
220
221 #[tokio::test]
222 async fn test_load_file() {
223 use std::io::Write;
224 use tempfile::NamedTempFile;
225
226 let mut file = NamedTempFile::with_suffix(".skill.md").unwrap();
227 writeln!(
228 file,
229 r#"---
230name: file-skill
231description: From file
232---
233
234Content."#
235 )
236 .unwrap();
237
238 let loader = SkillIndexLoader::new();
239 let index = loader.load_file(file.path()).await.unwrap();
240
241 assert_eq!(index.name, "file-skill");
242 assert!(index.source.is_file());
243 }
244
245 #[tokio::test]
246 async fn test_scan_directory() {
247 use tempfile::tempdir;
248 use tokio::fs;
249
250 let dir = tempdir().unwrap();
251
252 fs::write(
254 dir.path().join("skill1.skill.md"),
255 r#"---
256name: skill1
257description: First skill
258---
259Content 1"#,
260 )
261 .await
262 .unwrap();
263
264 fs::write(
265 dir.path().join("skill2.skill.md"),
266 r#"---
267name: skill2
268description: Second skill
269---
270Content 2"#,
271 )
272 .await
273 .unwrap();
274
275 let loader = SkillIndexLoader::new();
276 let indices = loader.scan_directory(dir.path()).await.unwrap();
277
278 assert_eq!(indices.len(), 2);
279 let names: Vec<&str> = indices.iter().map(|i| i.name.as_str()).collect();
280 assert!(names.contains(&"skill1"));
281 assert!(names.contains(&"skill2"));
282 }
283
284 #[tokio::test]
285 async fn test_scan_directory_with_skill_folder() {
286 use tempfile::tempdir;
287 use tokio::fs;
288
289 let dir = tempdir().unwrap();
290
291 let skill_dir = dir.path().join("my-skill");
293 fs::create_dir(&skill_dir).await.unwrap();
294 fs::write(
295 skill_dir.join("SKILL.md"),
296 r#"---
297name: folder-skill
298description: From folder
299---
300Content"#,
301 )
302 .await
303 .unwrap();
304
305 let loader = SkillIndexLoader::new();
306 let indices = loader.scan_directory(dir.path()).await.unwrap();
307
308 assert_eq!(indices.len(), 1);
309 assert_eq!(indices[0].name, "folder-skill");
310 }
311
312 #[tokio::test]
313 async fn test_scan_nonexistent_directory() {
314 let loader = SkillIndexLoader::new();
315 let indices = loader
316 .scan_directory(Path::new("/nonexistent/path"))
317 .await
318 .unwrap();
319 assert!(indices.is_empty());
320 }
321}