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