batuta/agent/
custom_agents.rs1use std::fs;
31use std::path::{Path, PathBuf};
32
33use super::task_tool::{SubagentRegistry, SubagentSpec};
34
35pub const DEFAULT_PROJECT_DIR: &str = ".apr/agents";
37
38pub const CLAUDE_COMPAT_DIR: &str = ".claude/agents";
41
42#[derive(Debug)]
44pub enum CustomAgentError {
45 MissingFrontmatter,
47 MissingName,
49 MissingDescription,
51 EmptyBody,
53 Io(String),
55}
56
57impl std::fmt::Display for CustomAgentError {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::MissingFrontmatter => write!(f, "missing `---`-fenced frontmatter"),
61 Self::MissingName => write!(f, "required field `name` missing or empty"),
62 Self::MissingDescription => {
63 write!(f, "required field `description` missing or empty")
64 }
65 Self::EmptyBody => write!(f, "body (system prompt) is empty"),
66 Self::Io(msg) => write!(f, "I/O error: {msg}"),
67 }
68 }
69}
70
71impl std::error::Error for CustomAgentError {}
72
73pub fn parse_agent_md(source: &str) -> Result<SubagentSpec, CustomAgentError> {
78 let trimmed = source.trim_start_matches('\u{feff}');
79 let rest = trimmed
80 .strip_prefix("---\n")
81 .or_else(|| trimmed.strip_prefix("---\r\n"))
82 .ok_or(CustomAgentError::MissingFrontmatter)?;
83
84 let (front, body) = split_at_fence(rest).ok_or(CustomAgentError::MissingFrontmatter)?;
85
86 let mut name = String::new();
87 let mut description = String::new();
88 let mut max_iterations: u32 = 8;
89
90 for line in front.lines() {
91 let line = line.trim();
92 if line.is_empty() || line.starts_with('#') {
93 continue;
94 }
95 let Some((key, value)) = line.split_once(':') else {
96 continue;
97 };
98 let key = key.trim();
99 let value = value.trim().trim_matches('"').trim_matches('\'');
100 match key {
101 "name" => name = value.to_string(),
102 "description" => description = value.to_string(),
103 "max_iterations" => {
104 if let Ok(n) = value.parse::<u32>() {
105 if n > 0 {
106 max_iterations = n;
107 }
108 }
109 }
110 _ => {}
112 }
113 }
114
115 if name.is_empty() {
116 return Err(CustomAgentError::MissingName);
117 }
118 if description.is_empty() {
119 return Err(CustomAgentError::MissingDescription);
120 }
121 let system_prompt = body.trim().to_string();
122 if system_prompt.is_empty() {
123 return Err(CustomAgentError::EmptyBody);
124 }
125
126 Ok(SubagentSpec { name, description, system_prompt, max_iterations })
127}
128
129pub fn load_custom_agents_from(dir: &Path) -> Vec<SubagentSpec> {
138 let mut specs = Vec::new();
139 let Ok(entries) = fs::read_dir(dir) else {
140 return specs;
141 };
142
143 for entry in entries.flatten() {
144 let path = entry.path();
145 if path.is_file() {
146 if path.extension().is_some_and(|e| e == "md") {
147 if let Some(spec) = try_parse_file(&path) {
148 specs.push(spec);
149 }
150 }
151 } else if path.is_dir() {
152 let agent_md = path.join("AGENT.md");
153 if agent_md.is_file() {
154 if let Some(spec) = try_parse_file(&agent_md) {
155 specs.push(spec);
156 }
157 }
158 }
159 }
160 specs
161}
162
163pub fn discover_standard_locations(cwd: &Path) -> Vec<SubagentSpec> {
168 let mut merged: Vec<SubagentSpec> = Vec::new();
169
170 let user_dir = user_agents_dir();
171 if let Some(u) = user_dir.as_deref() {
172 merged.extend(load_custom_agents_from(u));
173 }
174
175 for dir_rel in [DEFAULT_PROJECT_DIR, CLAUDE_COMPAT_DIR] {
176 let project_dir = cwd.join(dir_rel);
177 if project_dir.is_dir() {
178 let project_specs = load_custom_agents_from(&project_dir);
179 for spec in project_specs {
180 merged.retain(|s| s.name != spec.name);
181 merged.push(spec);
182 }
183 break;
184 }
185 }
186
187 merged
188}
189
190pub fn register_discovered_into(registry: &mut SubagentRegistry, cwd: &Path) -> usize {
193 let specs = discover_standard_locations(cwd);
194 let n = specs.len();
195 for spec in specs {
196 registry.register(spec);
197 }
198 n
199}
200
201fn try_parse_file(path: &Path) -> Option<SubagentSpec> {
202 let content = fs::read_to_string(path).ok()?;
203 parse_agent_md(&content).ok()
204}
205
206fn split_at_fence(after_open: &str) -> Option<(&str, &str)> {
207 for (idx, line_start) in line_starts(after_open) {
208 let rest_at = &after_open[line_start..];
209 if let Some(line_end) = rest_at.find('\n') {
210 let line = &rest_at[..line_end];
211 if line.trim_end_matches('\r') == "---" {
212 let front_end = line_start;
213 let body_start = line_start + line_end + 1;
214 let _ = idx;
215 return Some((&after_open[..front_end], &after_open[body_start..]));
216 }
217 } else if rest_at.trim_end_matches('\r') == "---" {
218 return Some((&after_open[..line_start], ""));
219 }
220 }
221 None
222}
223
224fn line_starts(s: &str) -> impl Iterator<Item = (usize, usize)> + '_ {
225 std::iter::once((0usize, 0usize))
226 .chain(s.match_indices('\n').enumerate().map(|(i, (pos, _))| (i + 1, pos + 1)))
227}
228
229fn user_agents_dir() -> Option<PathBuf> {
230 let home = std::env::var_os("HOME")?;
231 let home = PathBuf::from(home);
232 let candidate = home.join(".config").join("apr").join("agents");
233 if candidate.is_dir() {
234 Some(candidate)
235 } else {
236 None
237 }
238}
239
240#[cfg(test)]
241mod tests;