1use crate::error::SettingsError;
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, ToolFilter};
5use aether_core::core::Prompt;
6use glob::glob;
7use llm::{LlmModel, ReasoningEffort};
8use std::collections::HashSet;
9use std::path::Path;
10
11#[derive(Debug, Clone, Default, serde::Deserialize)]
13#[serde(default, rename_all = "camelCase")]
14struct Settings {
15 prompts: Vec<String>,
17 mcp_servers: Option<String>,
19 agents: Vec<AgentEntry>,
21}
22
23#[derive(Debug, Clone, serde::Deserialize)]
25#[serde(rename_all = "camelCase")]
26struct AgentEntry {
27 name: String,
28 description: String,
29 model: String,
30 #[serde(default)]
31 reasoning_effort: Option<String>,
32 #[serde(default)]
33 user_invocable: bool,
34 #[serde(default)]
35 agent_invocable: bool,
36 #[serde(default)]
37 prompts: Vec<String>,
38 mcp_servers: Option<String>,
39 #[serde(default)]
40 tools: ToolFilter,
41}
42
43pub fn load_agent_catalog(project_root: &Path) -> Result<super::catalog::AgentCatalog, SettingsError> {
48 let settings_path = project_root.join(".aether/settings.json");
49
50 let settings = match std::fs::read_to_string(&settings_path) {
51 Ok(content) => {
52 if content.trim().is_empty() {
53 Settings::default()
54 } else {
55 serde_json::from_str(&content).map_err(|e| SettingsError::ParseError(e.to_string()))?
56 }
57 }
58 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
59 return Ok(super::catalog::AgentCatalog::empty(project_root.to_path_buf()));
60 }
61 Err(e) => {
62 return Err(SettingsError::IoError(format!("Failed to read {}: {}", settings_path.display(), e)));
63 }
64 };
65
66 resolve_settings(project_root, settings)
67}
68
69fn resolve_settings(project_root: &Path, settings: Settings) -> Result<super::catalog::AgentCatalog, SettingsError> {
71 let Settings { prompts: inherited_patterns, mcp_servers, agents } = settings;
72
73 validate_prompt_entries(project_root, &inherited_patterns, None)?;
74 let inherited_mcp_config_path = resolve_mcp_config_path(project_root, mcp_servers.as_deref())?;
75 let inherited_prompts = build_inherited_prompts(&inherited_patterns, project_root);
76
77 let mut seen_names = HashSet::new();
78 let mut specs = Vec::with_capacity(agents.len());
79
80 for (index, entry) in agents.into_iter().enumerate() {
81 specs.push(resolve_agent_entry(project_root, &inherited_prompts, entry, index, &mut seen_names)?);
82 }
83
84 Ok(super::catalog::AgentCatalog::new(
85 project_root.to_path_buf(),
86 inherited_prompts,
87 inherited_mcp_config_path,
88 specs,
89 ))
90}
91
92fn resolve_agent_entry(
93 project_root: &Path,
94 inherited_prompts: &[Prompt],
95 entry: AgentEntry,
96 index: usize,
97 seen_names: &mut HashSet<String>,
98) -> Result<AgentSpec, SettingsError> {
99 let name = entry.name.trim().to_string();
100 if name.is_empty() {
101 return Err(SettingsError::EmptyAgentName { index });
102 }
103
104 if name == "__default__" {
105 return Err(SettingsError::ReservedAgentName { name });
106 }
107
108 if !seen_names.insert(name.clone()) {
109 return Err(SettingsError::DuplicateAgentName { name });
110 }
111
112 let description = entry.description.trim().to_string();
113 if description.is_empty() {
114 return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
115 }
116
117 let model = parse_model(&name, &entry.model)?;
118 let reasoning_effort = parse_reasoning_effort(&name, entry.reasoning_effort)?;
119
120 if !entry.user_invocable && !entry.agent_invocable {
121 return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
122 }
123
124 validate_prompt_entries(project_root, &entry.prompts, Some(&name))?;
125
126 if inherited_prompts.is_empty() && entry.prompts.is_empty() {
127 return Err(SettingsError::NoPrompts { agent: name.clone() });
128 }
129
130 let mcp_config_path = resolve_mcp_config_path(project_root, entry.mcp_servers.as_deref())?;
131
132 let mut prompts = Vec::with_capacity(inherited_prompts.len() + entry.prompts.len());
133 prompts.extend_from_slice(inherited_prompts);
134 for pattern in &entry.prompts {
135 prompts.push(Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf()));
136 }
137
138 Ok(AgentSpec {
139 name,
140 description,
141 model,
142 reasoning_effort,
143 prompts,
144 mcp_config_path,
145 exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
146 tools: entry.tools,
147 })
148}
149
150fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
151 canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
152 agent: agent.to_string(),
153 model: model.to_string(),
154 error,
155 })
156}
157
158fn canonicalize_model_spec(model: &str) -> Result<String, String> {
159 let trimmed = model.trim();
160 if trimmed.is_empty() {
161 return Err("Model spec cannot be empty".to_string());
162 }
163
164 let mut canonical_parts = Vec::new();
165 for part in trimmed.split(',').map(str::trim) {
166 if part.is_empty() {
167 return Err("Model spec contains an empty entry".to_string());
168 }
169
170 part.parse::<LlmModel>().map_err(|error: String| error)?;
171 canonical_parts.push(part.to_string());
172 }
173
174 Ok(canonical_parts.join(","))
175}
176
177fn parse_reasoning_effort(
178 agent: &str,
179 reasoning_effort: Option<String>,
180) -> Result<Option<ReasoningEffort>, SettingsError> {
181 match reasoning_effort {
182 None => Ok(None),
183 Some(value) => {
184 let value = value.trim();
185 if value.is_empty() {
186 return Ok(None);
187 }
188
189 ReasoningEffort::parse(value).map_err(|error| SettingsError::InvalidReasoningEffort {
190 agent: agent.to_string(),
191 effort: value.to_string(),
192 error,
193 })
194 }
195 }
196}
197
198fn validate_prompt_entries(
199 project_root: &Path,
200 patterns: &[String],
201 agent_name: Option<&str>,
202) -> Result<(), SettingsError> {
203 for pattern in patterns {
204 validate_prompt_entry(project_root, pattern, agent_name)?;
205 }
206 Ok(())
207}
208
209fn resolve_mcp_config_path(
210 project_root: &Path,
211 mcp_path: Option<&str>,
212) -> Result<Option<std::path::PathBuf>, SettingsError> {
213 match mcp_path {
214 None => Ok(None),
215 Some(path) => {
216 let full_path = project_root.join(path);
217 if full_path.is_file() {
218 Ok(Some(full_path))
219 } else {
220 Err(SettingsError::InvalidMcpConfigPath { path: path.to_string() })
221 }
222 }
223 }
224}
225
226fn validate_prompt_entry(project_root: &Path, pattern: &str, agent_name: Option<&str>) -> Result<(), SettingsError> {
228 let full_pattern = if Path::new(pattern).is_absolute() {
229 pattern.to_string()
230 } else {
231 project_root.join(pattern).to_string_lossy().to_string()
232 };
233
234 let has_file_match = glob(&full_pattern)
235 .map_err(|e| {
236 if let Some(agent) = agent_name {
237 SettingsError::InvalidGlobPattern {
238 agent: agent.to_string(),
239 pattern: pattern.to_string(),
240 error: e.to_string(),
241 }
242 } else {
243 SettingsError::InvalidInheritedGlobPattern { pattern: pattern.to_string(), error: e.to_string() }
244 }
245 })?
246 .filter_map(Result::ok)
247 .any(|path| path.is_file());
248
249 if has_file_match {
250 Ok(())
251 } else if let Some(agent) = agent_name {
252 Err(SettingsError::ZeroMatchPrompt { agent: agent.to_string(), pattern: pattern.to_string() })
253 } else {
254 Err(SettingsError::ZeroMatchInheritedPrompt { pattern: pattern.to_string() })
255 }
256}
257
258fn build_inherited_prompts(patterns: &[String], project_root: &Path) -> Vec<Prompt> {
262 patterns.iter().map(|pattern| Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf())).collect()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::fs;
269
270 fn create_temp_project() -> tempfile::TempDir {
271 tempfile::tempdir().unwrap()
272 }
273
274 fn write_settings(dir: &Path, content: &str) {
275 let aether_dir = dir.join(".aether");
276 fs::create_dir_all(&aether_dir).unwrap();
277 fs::write(aether_dir.join("settings.json"), content).unwrap();
278 }
279
280 fn write_file(dir: &Path, path: &str, content: &str) {
281 let full_path = dir.join(path);
282 if let Some(parent) = full_path.parent() {
283 fs::create_dir_all(parent).unwrap();
284 }
285 fs::write(full_path, content).unwrap();
286 }
287
288 fn agent_settings(extra: &str) -> String {
290 let comma = if extra.is_empty() { "" } else { "," };
291 format!(
292 r#"{{"agents": [{{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]{comma} {extra}}}]}}"#
293 )
294 }
295
296 fn setup_and_load(json: &str) -> (tempfile::TempDir, Result<super::super::catalog::AgentCatalog, SettingsError>) {
298 let dir = create_temp_project();
299 write_file(dir.path(), "AGENTS.md", "Be helpful");
300 write_settings(dir.path(), json);
301 let result = load_agent_catalog(dir.path());
302 (dir, result)
303 }
304
305 fn setup_and_load_ok(json: &str) -> (tempfile::TempDir, super::super::catalog::AgentCatalog) {
306 let (dir, result) = setup_and_load(json);
307 (dir, result.unwrap())
308 }
309
310 #[test]
311 fn missing_settings_yields_empty_catalog() {
312 let dir = create_temp_project();
313 let catalog = load_agent_catalog(dir.path()).unwrap();
314 assert!(catalog.all().is_empty());
315 }
316
317 #[test]
318 fn exposure_flags_parsed_correctly() {
319 for (user, agent) in [(true, true), (true, false), (false, true)] {
320 let json = format!(
321 r#"{{"agents": [{{
322 "name": "planner", "description": "Planner agent",
323 "model": "anthropic:claude-sonnet-4-5",
324 "userInvocable": {user}, "agentInvocable": {agent},
325 "prompts": ["AGENTS.md"]
326 }}]}}"#
327 );
328 let (_, catalog) = setup_and_load_ok(&json);
329 let spec = catalog.get("planner").unwrap();
330 assert_eq!(spec.exposure.user_invocable, user);
331 assert_eq!(spec.exposure.agent_invocable, agent);
332 }
333 }
334
335 #[test]
336 fn invalid_model_string_rejected() {
337 let (_, result) = setup_and_load(
338 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
339 );
340 assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
341 }
342
343 #[test]
344 fn alloy_model_string_is_accepted() {
345 let json = r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#;
346 let (_, catalog) = setup_and_load_ok(json);
347 assert_eq!(catalog.get("alloy").unwrap().model.clone(), "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat");
348 }
349
350 #[test]
351 fn alloy_model_string_with_unknown_member_is_rejected() {
352 let (_, result) = setup_and_load(
353 r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,mystery:nope", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
354 );
355 assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
356 }
357
358 #[test]
359 fn invalid_reasoning_effort_rejected() {
360 let (_, result) = setup_and_load(&agent_settings(r#""reasoningEffort": "invalid""#));
361 assert!(matches!(result, Err(SettingsError::InvalidReasoningEffort { .. })));
362 }
363
364 #[test]
365 fn duplicate_agent_names_rejected() {
366 let (_, result) = setup_and_load(
367 r#"{"agents": [
368 {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
369 {"name": "planner", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
370 ]}"#,
371 );
372 assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
373 }
374
375 #[test]
376 fn top_level_prompts_inherited_by_all_agents() {
377 let dir = create_temp_project();
378 write_file(dir.path(), "BASE.md", "Base instructions");
379 write_file(dir.path(), "AGENTS.md", "Agent instructions");
380 write_settings(
381 dir.path(),
382 r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "agentInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
383 );
384
385 let catalog = load_agent_catalog(dir.path()).unwrap();
386 assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
388 }
389
390 #[test]
391 fn one_prompt_globs_per_entry() {
392 let dir = create_temp_project();
393 write_file(dir.path(), "a.md", "A");
394 write_file(dir.path(), "b.md", "B");
395 write_settings(
396 dir.path(),
397 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["a.md", "b.md"]}]}"#,
398 );
399
400 let catalog = load_agent_catalog(dir.path()).unwrap();
401 assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
403 }
404
405 #[test]
406 fn zero_match_prompt_rejected() {
407 let dir = create_temp_project();
408 write_settings(
410 dir.path(),
411 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["nonexistent.md"]}]}"#,
412 );
413 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
414 }
415
416 #[test]
417 fn prompt_matching_only_directories_is_rejected() {
418 let dir = create_temp_project();
419 std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
420 write_settings(
421 dir.path(),
422 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["prompts/*"]}]}"#,
423 );
424 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
425 }
426
427 #[test]
428 fn no_invocation_surface_rejected() {
429 let (_, result) = setup_and_load(
430 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": false, "agentInvocable": false, "prompts": ["AGENTS.md"]}]}"#,
431 );
432 assert!(matches!(result, Err(SettingsError::NoInvocationSurface { .. })));
433 }
434
435 #[test]
436 fn empty_and_whitespace_names_rejected() {
437 for name in ["", " "] {
438 let json = format!(
439 r#"{{"agents": [{{"name": "{name}", "description": "Agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
440 );
441 let (_, result) = setup_and_load(&json);
442 assert!(
443 matches!(result, Err(SettingsError::EmptyAgentName { .. })),
444 "expected EmptyAgentName for name={name:?}"
445 );
446 }
447 }
448
449 #[test]
450 fn empty_and_whitespace_descriptions_rejected() {
451 for desc in ["", " "] {
452 let json = format!(
453 r#"{{"agents": [{{"name": "planner", "description": "{desc}", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
454 );
455 let (_, result) = setup_and_load(&json);
456 assert!(
457 matches!(result, Err(SettingsError::MissingField { .. })),
458 "expected MissingField for desc={desc:?}"
459 );
460 }
461 }
462
463 #[test]
464 fn duplicate_agent_names_after_trim_rejected() {
465 let (_, result) = setup_and_load(
466 r#"{"agents": [
467 {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
468 {"name": " planner ", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
469 ]}"#,
470 );
471 assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
472 }
473
474 #[test]
475 fn agent_name_and_description_are_trimmed() {
476 let (_, catalog) = setup_and_load_ok(
477 r#"{"agents": [{"name": " planner ", "description": " Planner agent ", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
478 );
479 let spec = catalog.get("planner").unwrap();
480 assert_eq!(spec.name, "planner");
481 assert_eq!(spec.description, "Planner agent");
482 }
483
484 #[test]
485 fn no_prompts_rejected() {
486 let dir = create_temp_project();
487 write_settings(
488 dir.path(),
489 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
490 );
491 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::NoPrompts { .. })));
492 }
493
494 #[test]
495 fn malformed_json_rejected() {
496 let dir = create_temp_project();
497 write_settings(dir.path(), "not valid json");
498 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ParseError(_))));
499 }
500
501 #[test]
502 fn invalid_mcp_servers_path_rejected() {
503 let (_, result) = setup_and_load(
504 r#"{"mcpServers": "nonexistent.json", "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
505 );
506 assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
507 }
508
509 #[test]
510 fn invalid_agent_mcp_servers_path_rejected() {
511 let (_, result) = setup_and_load(&agent_settings(r#""mcpServers": "nonexistent.json""#));
512 assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
513 }
514
515 #[test]
516 fn valid_mcp_servers_path_accepted() {
517 let dir = create_temp_project();
518 write_file(dir.path(), "AGENTS.md", "Be helpful");
519 write_file(dir.path(), ".aether/mcp/default.json", "{}");
520 write_settings(
521 dir.path(),
522 r#"{"mcpServers": ".aether/mcp/default.json", "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
523 );
524
525 let catalog = load_agent_catalog(dir.path()).unwrap();
526 assert!(catalog.resolve("planner", dir.path()).unwrap().mcp_config_path.is_some());
527 }
528
529 #[test]
530 fn any_invalid_agent_entry_fails_catalog_load() {
531 let (_, result) = setup_and_load(
532 r#"{"agents": [
533 {"name": "valid", "description": "Valid agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
534 {"name": "invalid", "description": "Invalid agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}
535 ]}"#,
536 );
537 assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
538 }
539
540 fn two_agent_json() -> &'static str {
541 r#"{"agents": [
542 {"name": "zebra", "description": "Z agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
543 {"name": "alpha", "description": "A agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
544 ]}"#
545 }
546
547 #[test]
548 fn preserves_authored_agent_order_and_lookup() {
549 let (_, catalog) = setup_and_load_ok(two_agent_json());
550 let names: Vec<_> = catalog.all().iter().map(|s| s.name.as_str()).collect();
551 assert_eq!(names, vec!["zebra", "alpha"]); assert_eq!(catalog.get("alpha").unwrap().name, "alpha");
553 assert_eq!(catalog.get("zebra").unwrap().name, "zebra");
554 }
555
556 #[test]
557 fn tools_filter_parsed_from_settings() {
558 let (_, catalog) = setup_and_load_ok(
559 r#"{"agents": [{"name": "researcher", "description": "Read-only agent", "model": "anthropic:claude-sonnet-4-5", "agentInvocable": true, "prompts": ["AGENTS.md"], "tools": {"allow": ["coding__grep", "coding__read_file"], "deny": ["coding__write*"]}}]}"#,
560 );
561 let spec = catalog.get("researcher").unwrap();
562 assert_eq!(spec.tools.allow, vec!["coding__grep", "coding__read_file"]);
563 assert_eq!(spec.tools.deny, vec!["coding__write*"]);
564 }
565
566 #[test]
567 fn absent_tools_field_yields_default_filter() {
568 let (_, catalog) = setup_and_load_ok(&agent_settings(""));
569 let spec = catalog.get("planner").unwrap();
570 assert!(spec.tools.allow.is_empty());
571 assert!(spec.tools.deny.is_empty());
572 }
573
574 #[test]
575 fn reserved_agent_name_rejected() {
576 let (_, result) = setup_and_load(
577 r#"{"agents": [{"name": "__default__", "description": "Sneaky agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
578 );
579 assert!(matches!(result, Err(SettingsError::ReservedAgentName { .. })));
580 }
581}