claude_agent/subagents/
index_loader.rs1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use super::SubagentIndex;
9use crate::client::ModelType;
10use crate::common::{ContentSource, SourceType, is_markdown, parse_frontmatter};
11use crate::hooks::HookRule;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SubagentFrontmatter {
16 pub name: String,
17 pub description: String,
18 #[serde(default)]
19 pub tools: Option<String>,
20 #[serde(default)]
21 pub model: Option<String>,
22 #[serde(default)]
23 pub model_type: Option<String>,
24 #[serde(default)]
25 pub skills: Option<String>,
26 #[serde(default, rename = "source-type")]
27 pub source_type: Option<String>,
28 #[serde(default, alias = "disallowedTools")]
29 pub disallowed_tools: Option<String>,
30 #[serde(default, alias = "permissionMode")]
31 pub permission_mode: Option<String>,
32 #[serde(default)]
33 pub hooks: Option<HashMap<String, Vec<HookRule>>>,
34}
35
36fn split_csv(s: Option<String>) -> Vec<String> {
37 s.map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
38 .unwrap_or_default()
39}
40
41#[derive(Debug, Clone, Copy, Default)]
42pub struct SubagentIndexLoader;
43
44impl SubagentIndexLoader {
45 pub fn new() -> Self {
46 Self
47 }
48
49 pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SubagentIndex> {
50 let doc = parse_frontmatter::<SubagentFrontmatter>(content)?;
51 Ok(self.build_index(doc.frontmatter, path))
52 }
53
54 fn build_index(&self, fm: SubagentFrontmatter, path: &Path) -> SubagentIndex {
55 let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
56
57 let tools = split_csv(fm.tools);
58 let skills = split_csv(fm.skills);
59 let disallowed_tools = split_csv(fm.disallowed_tools);
60
61 let mut index = SubagentIndex::new(fm.name, fm.description)
62 .with_source(ContentSource::file(path))
63 .with_source_type(source_type)
64 .with_tools(tools)
65 .with_skills(skills);
66
67 index.disallowed_tools = disallowed_tools;
68 index.permission_mode = fm.permission_mode;
69 index.hooks = fm.hooks;
70
71 if let Some(model) = fm.model {
72 index = index.with_model(model);
73 }
74
75 if let Some(model_type) = fm.model_type {
76 match model_type.to_lowercase().as_str() {
77 "small" | "haiku" => index = index.with_model_type(ModelType::Small),
78 "primary" | "sonnet" => index = index.with_model_type(ModelType::Primary),
79 "reasoning" | "opus" => index = index.with_model_type(ModelType::Reasoning),
80 _ => {}
81 }
82 }
83
84 index
85 }
86
87 pub async fn load_file(&self, path: &Path) -> crate::Result<SubagentIndex> {
89 crate::common::index_loader::load_file(path, |c, p| self.parse_index(c, p), "subagent")
90 .await
91 }
92
93 pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SubagentIndex>> {
95 use crate::common::index_loader::{self, DirAction};
96
97 let loader = Self::new();
98 index_loader::scan_directory(
99 dir,
100 |p| Box::pin(async move { loader.load_file(p).await }),
101 is_markdown,
102 |_| DirAction::Recurse,
103 )
104 .await
105 }
106
107 pub fn create_inline(
109 name: impl Into<String>,
110 description: impl Into<String>,
111 prompt: impl Into<String>,
112 ) -> SubagentIndex {
113 SubagentIndex::new(name, description).with_source(ContentSource::in_memory(prompt))
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn test_parse_subagent_index() {
123 let content = r#"---
124name: code-reviewer
125description: Expert code reviewer for quality checks
126tools: Read, Grep, Glob
127model: haiku
128---
129
130You are a senior code reviewer focusing on:
131- Code quality and best practices
132- Security vulnerabilities
133"#;
134
135 let loader = SubagentIndexLoader::new();
136 let index = loader
137 .parse_index(content, Path::new("/test/reviewer.md"))
138 .unwrap();
139
140 assert_eq!(index.name, "code-reviewer");
141 assert_eq!(index.description, "Expert code reviewer for quality checks");
142 assert_eq!(index.allowed_tools, vec!["Read", "Grep", "Glob"]);
143 assert_eq!(index.model, Some("haiku".to_string()));
144 assert!(index.source.is_file());
146 }
147
148 #[test]
149 fn test_parse_subagent_with_skills() {
150 let content = r#"---
151name: full-agent
152description: Full featured agent
153tools: Read, Write, Bash(git:*)
154model: sonnet
155skills: security-check, linting
156---
157
158Full agent prompt.
159"#;
160
161 let loader = SubagentIndexLoader::new();
162 let index = loader
163 .parse_index(content, Path::new("/test/full.md"))
164 .unwrap();
165
166 assert_eq!(index.skills, vec!["security-check", "linting"]);
167 assert_eq!(index.model, Some("sonnet".to_string()));
168 }
169
170 #[test]
171 fn test_create_inline() {
172 let index = SubagentIndexLoader::create_inline(
173 "test-agent",
174 "Test description",
175 "You are a test agent.",
176 );
177
178 assert_eq!(index.name, "test-agent");
179 assert!(index.source.is_in_memory());
180 }
181
182 #[test]
183 fn test_parse_without_frontmatter() {
184 let content = "Just content without frontmatter";
185 let loader = SubagentIndexLoader::new();
186 assert!(loader.parse_index(content, Path::new("/test.md")).is_err());
187 }
188
189 #[test]
190 fn test_parse_disallowed_tools() {
191 let content = r#"---
192name: restricted-agent
193description: Agent with disallowed tools
194disallowedTools: Write, Edit
195---
196Restricted prompt"#;
197
198 let loader = SubagentIndexLoader::new();
199 let index = loader
200 .parse_index(content, Path::new("/test/restricted.md"))
201 .unwrap();
202
203 assert_eq!(index.disallowed_tools, vec!["Write", "Edit"]);
204 }
205
206 #[test]
207 fn test_parse_permission_mode() {
208 let content = r#"---
209name: auto-agent
210description: Agent with permission mode
211permissionMode: dontAsk
212---
213Auto prompt"#;
214
215 let loader = SubagentIndexLoader::new();
216 let index = loader
217 .parse_index(content, Path::new("/test/auto.md"))
218 .unwrap();
219
220 assert_eq!(index.permission_mode, Some("dontAsk".to_string()));
221 }
222
223 #[test]
224 fn test_defaults_for_new_subagent_fields() {
225 let content = r#"---
226name: basic-agent
227description: Basic agent
228---
229Prompt"#;
230
231 let loader = SubagentIndexLoader::new();
232 let index = loader
233 .parse_index(content, Path::new("/test/basic.md"))
234 .unwrap();
235
236 assert!(index.disallowed_tools.is_empty());
237 assert!(index.permission_mode.is_none());
238 }
239}