Skip to main content

claude_agent/subagents/
index_loader.rs

1//! Subagent index loader.
2
3use 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/// Frontmatter for subagent files.
14#[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    /// Load a subagent index from a file.
88    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    /// Scan a directory for subagent files and create indices.
94    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    /// Create an inline subagent index with in-memory content.
108    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        // Note: prompt content is NOT loaded here - it's in ContentSource
145        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}