claude_agent/subagents/
index_loader.rs1use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use super::SubagentIndex;
8use crate::client::ModelType;
9use crate::common::{ContentSource, SourceType, is_markdown, parse_frontmatter};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SubagentFrontmatter {
14 pub name: String,
15 pub description: String,
16 #[serde(default)]
17 pub tools: Option<String>,
18 #[serde(default)]
19 pub model: Option<String>,
20 #[serde(default)]
21 pub model_type: Option<String>,
22 #[serde(default)]
23 pub skills: Option<String>,
24 #[serde(default, rename = "source-type")]
25 pub source_type: Option<String>,
26}
27
28#[derive(Debug, Clone, Copy, Default)]
30pub struct SubagentIndexLoader;
31
32impl SubagentIndexLoader {
33 pub fn new() -> Self {
34 Self
35 }
36
37 pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SubagentIndex> {
40 let doc = parse_frontmatter::<SubagentFrontmatter>(content)?;
41 Ok(self.build_index(doc.frontmatter, path))
42 }
43
44 pub fn parse_frontmatter_only(
46 &self,
47 content: &str,
48 path: &Path,
49 ) -> crate::Result<SubagentIndex> {
50 self.parse_index(content, path)
51 }
52
53 fn build_index(&self, fm: SubagentFrontmatter, path: &Path) -> SubagentIndex {
54 let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
55
56 let tools: Vec<String> = fm
57 .tools
58 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
59 .unwrap_or_default();
60
61 let skills: Vec<String> = fm
62 .skills
63 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
64 .unwrap_or_default();
65
66 let mut index = SubagentIndex::new(fm.name, fm.description)
67 .with_source(ContentSource::file(path))
68 .with_source_type(source_type)
69 .with_tools(tools)
70 .with_skills(skills);
71
72 if let Some(model) = fm.model {
73 index = index.with_model(model);
74 }
75
76 if let Some(model_type) = fm.model_type {
77 match model_type.to_lowercase().as_str() {
78 "small" | "haiku" => index = index.with_model_type(ModelType::Small),
79 "primary" | "sonnet" => index = index.with_model_type(ModelType::Primary),
80 "reasoning" | "opus" => index = index.with_model_type(ModelType::Reasoning),
81 _ => {}
82 }
83 }
84
85 index
86 }
87
88 pub async fn load_file(&self, path: &Path) -> crate::Result<SubagentIndex> {
90 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
91 crate::Error::Config(format!("Failed to read subagent file {:?}: {}", path, e))
92 })?;
93
94 self.parse_index(&content, path)
95 }
96
97 pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SubagentIndex>> {
99 let mut indices = Vec::new();
100
101 if !dir.exists() {
102 return Ok(indices);
103 }
104
105 self.scan_directory_recursive(dir, &mut indices).await?;
106 Ok(indices)
107 }
108
109 fn scan_directory_recursive<'a>(
110 &'a self,
111 dir: &'a Path,
112 indices: &'a mut Vec<SubagentIndex>,
113 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<()>> + Send + 'a>> {
114 Box::pin(async move {
115 let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
116 crate::Error::Config(format!("Failed to read directory {:?}: {}", dir, e))
117 })?;
118
119 while let Some(entry) = entries.next_entry().await.map_err(|e| {
120 crate::Error::Config(format!("Failed to read directory entry: {}", e))
121 })? {
122 let path = entry.path();
123
124 if path.is_dir() {
125 self.scan_directory_recursive(&path, indices).await?;
126 } else if is_markdown(&path) {
127 match self.load_file(&path).await {
128 Ok(index) => indices.push(index),
129 Err(e) => {
130 tracing::warn!("Failed to load subagent from {:?}: {}", path, e);
131 }
132 }
133 }
134 }
135
136 Ok(())
137 })
138 }
139
140 pub fn create_inline(
142 name: impl Into<String>,
143 description: impl Into<String>,
144 prompt: impl Into<String>,
145 ) -> SubagentIndex {
146 SubagentIndex::new(name, description).with_source(ContentSource::in_memory(prompt))
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn test_parse_subagent_index() {
156 let content = r#"---
157name: code-reviewer
158description: Expert code reviewer for quality checks
159tools: Read, Grep, Glob
160model: haiku
161---
162
163You are a senior code reviewer focusing on:
164- Code quality and best practices
165- Security vulnerabilities
166"#;
167
168 let loader = SubagentIndexLoader::new();
169 let index = loader
170 .parse_index(content, Path::new("/test/reviewer.md"))
171 .unwrap();
172
173 assert_eq!(index.name, "code-reviewer");
174 assert_eq!(index.description, "Expert code reviewer for quality checks");
175 assert_eq!(index.allowed_tools, vec!["Read", "Grep", "Glob"]);
176 assert_eq!(index.model, Some("haiku".to_string()));
177 assert!(index.source.is_file());
179 }
180
181 #[test]
182 fn test_parse_subagent_with_skills() {
183 let content = r#"---
184name: full-agent
185description: Full featured agent
186tools: Read, Write, Bash(git:*)
187model: sonnet
188skills: security-check, linting
189---
190
191Full agent prompt.
192"#;
193
194 let loader = SubagentIndexLoader::new();
195 let index = loader
196 .parse_index(content, Path::new("/test/full.md"))
197 .unwrap();
198
199 assert_eq!(index.skills, vec!["security-check", "linting"]);
200 assert_eq!(index.model, Some("sonnet".to_string()));
201 }
202
203 #[test]
204 fn test_create_inline() {
205 let index = SubagentIndexLoader::create_inline(
206 "test-agent",
207 "Test description",
208 "You are a test agent.",
209 );
210
211 assert_eq!(index.name, "test-agent");
212 assert!(index.source.is_in_memory());
213 }
214
215 #[test]
216 fn test_parse_without_frontmatter() {
217 let content = "Just content without frontmatter";
218 let loader = SubagentIndexLoader::new();
219 assert!(loader.parse_index(content, Path::new("/test.md")).is_err());
220 }
221}