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| {
38        let mut items = Vec::new();
39        let mut current = String::new();
40        let mut depth = 0u32;
41        for ch in v.chars() {
42            match ch {
43                '(' => {
44                    depth += 1;
45                    current.push(ch);
46                }
47                ')' => {
48                    depth = depth.saturating_sub(1);
49                    current.push(ch);
50                }
51                ',' if depth == 0 => {
52                    let trimmed = current.trim().to_string();
53                    if !trimmed.is_empty() {
54                        items.push(trimmed);
55                    }
56                    current.clear();
57                }
58                _ => current.push(ch),
59            }
60        }
61        let trimmed = current.trim().to_string();
62        if !trimmed.is_empty() {
63            items.push(trimmed);
64        }
65        items
66    })
67    .unwrap_or_default()
68}
69
70#[derive(Debug, Clone, Copy, Default)]
71pub struct SubagentIndexLoader;
72
73impl SubagentIndexLoader {
74    pub fn new() -> Self {
75        Self
76    }
77
78    pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SubagentIndex> {
79        let doc = parse_frontmatter::<SubagentFrontmatter>(content)?;
80        Ok(self.build_index(doc.frontmatter, path))
81    }
82
83    fn build_index(&self, fm: SubagentFrontmatter, path: &Path) -> SubagentIndex {
84        let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
85
86        let tools = split_csv(fm.tools);
87        let skills = split_csv(fm.skills);
88        let disallowed_tools = split_csv(fm.disallowed_tools);
89
90        let mut index = SubagentIndex::new(fm.name, fm.description)
91            .source(ContentSource::file(path))
92            .source_type(source_type)
93            .tools(tools)
94            .skills(skills);
95
96        index.disallowed_tools = disallowed_tools;
97        index.permission_mode = fm.permission_mode;
98        index.hooks = fm.hooks;
99
100        if let Some(m) = fm.model {
101            index = index.model(m);
102        }
103
104        if let Some(mt) = fm.model_type {
105            match mt.to_lowercase().as_str() {
106                "small" | "haiku" => index = index.model_type(ModelType::Small),
107                "primary" | "sonnet" => index = index.model_type(ModelType::Primary),
108                "reasoning" | "opus" => index = index.model_type(ModelType::Reasoning),
109                _ => {}
110            }
111        }
112
113        index
114    }
115
116    /// Load a subagent index from a file.
117    pub async fn load_file(&self, path: &Path) -> crate::Result<SubagentIndex> {
118        crate::common::index_loader::load_file(path, |c, p| self.parse_index(c, p), "subagent")
119            .await
120    }
121
122    /// Scan a directory for subagent files and create indices.
123    pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SubagentIndex>> {
124        use crate::common::index_loader::{self, DirAction};
125
126        let loader = Self::new();
127        index_loader::scan_directory(
128            dir,
129            |p| Box::pin(async move { loader.load_file(p).await }),
130            is_markdown,
131            |_| DirAction::Recurse,
132        )
133        .await
134    }
135
136    /// Create an inline subagent index with in-memory content.
137    pub fn create_inline(
138        name: impl Into<String>,
139        description: impl Into<String>,
140        prompt: impl Into<String>,
141    ) -> SubagentIndex {
142        SubagentIndex::new(name, description).source(ContentSource::in_memory(prompt))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_parse_subagent_index() {
152        let content = r#"---
153name: code-reviewer
154description: Expert code reviewer for quality checks
155tools: Read, Grep, Glob
156model: haiku
157---
158
159You are a senior code reviewer focusing on:
160- Code quality and best practices
161- Security vulnerabilities
162"#;
163
164        let loader = SubagentIndexLoader::new();
165        let index = loader
166            .parse_index(content, Path::new("/test/reviewer.md"))
167            .unwrap();
168
169        assert_eq!(index.name, "code-reviewer");
170        assert_eq!(index.description, "Expert code reviewer for quality checks");
171        assert_eq!(index.allowed_tools, vec!["Read", "Grep", "Glob"]);
172        assert_eq!(index.model, Some("haiku".to_string()));
173        assert!(index.source.is_file());
174    }
175
176    #[test]
177    fn test_parse_subagent_with_skills() {
178        let content = r#"---
179name: full-agent
180description: Full featured agent
181tools: Read, Write, Bash(git:*)
182model: sonnet
183skills: security-check, linting
184---
185
186Full agent prompt.
187"#;
188
189        let loader = SubagentIndexLoader::new();
190        let index = loader
191            .parse_index(content, Path::new("/test/full.md"))
192            .unwrap();
193
194        assert_eq!(index.skills, vec!["security-check", "linting"]);
195        assert_eq!(index.model, Some("sonnet".to_string()));
196    }
197
198    #[test]
199    fn test_create_inline() {
200        let index = SubagentIndexLoader::create_inline(
201            "test-agent",
202            "Test description",
203            "You are a test agent.",
204        );
205
206        assert_eq!(index.name, "test-agent");
207        assert!(index.source.is_in_memory());
208    }
209
210    #[test]
211    fn test_parse_without_frontmatter() {
212        let content = "Just content without frontmatter";
213        let loader = SubagentIndexLoader::new();
214        assert!(loader.parse_index(content, Path::new("/test.md")).is_err());
215    }
216
217    #[test]
218    fn test_parse_disallowed_tools() {
219        let content = r#"---
220name: restricted-agent
221description: Agent with disallowed tools
222disallowedTools: Write, Edit
223---
224Restricted prompt"#;
225
226        let loader = SubagentIndexLoader::new();
227        let index = loader
228            .parse_index(content, Path::new("/test/restricted.md"))
229            .unwrap();
230
231        assert_eq!(index.disallowed_tools, vec!["Write", "Edit"]);
232    }
233
234    #[test]
235    fn test_parse_permission_mode() {
236        let content = r#"---
237name: auto-agent
238description: Agent with permission mode
239permissionMode: dontAsk
240---
241Auto prompt"#;
242
243        let loader = SubagentIndexLoader::new();
244        let index = loader
245            .parse_index(content, Path::new("/test/auto.md"))
246            .unwrap();
247
248        assert_eq!(index.permission_mode, Some("dontAsk".to_string()));
249    }
250
251    #[test]
252    fn test_split_csv_with_parens() {
253        let result = split_csv(Some("Read, Bash(git:*,docker:*), Write".to_string()));
254        assert_eq!(result, vec!["Read", "Bash(git:*,docker:*)", "Write"]);
255    }
256
257    #[test]
258    fn test_split_csv_simple() {
259        let result = split_csv(Some("Read, Grep, Glob".to_string()));
260        assert_eq!(result, vec!["Read", "Grep", "Glob"]);
261    }
262
263    #[test]
264    fn test_defaults_for_new_subagent_fields() {
265        let content = r#"---
266name: basic-agent
267description: Basic agent
268---
269Prompt"#;
270
271        let loader = SubagentIndexLoader::new();
272        let index = loader
273            .parse_index(content, Path::new("/test/basic.md"))
274            .unwrap();
275
276        assert!(index.disallowed_tools.is_empty());
277        assert!(index.permission_mode.is_none());
278    }
279}