1use crate::error::SettingsError;
4use aether_core::agent_spec::{AgentSpec, McpJsonFileRef};
5use aether_core::core::Prompt;
6use llm::{LlmModel, ReasoningEffort};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
13pub struct AgentCatalog {
14 project_root: PathBuf,
15 inherited_prompts: Vec<Prompt>,
16 inherited_mcp_config_refs: Vec<McpJsonFileRef>,
17 specs: Vec<AgentSpec>,
18}
19
20impl AgentCatalog {
21 pub(crate) fn new(
23 project_root: PathBuf,
24 inherited_prompts: Vec<Prompt>,
25 inherited_mcp_config_refs: Vec<McpJsonFileRef>,
26 specs: Vec<AgentSpec>,
27 ) -> Self {
28 Self { project_root, inherited_prompts, inherited_mcp_config_refs, specs }
29 }
30
31 pub fn empty(project_root: PathBuf) -> Self {
33 Self::new(project_root, Vec::new(), Vec::new(), Vec::new())
34 }
35
36 pub fn project_root(&self) -> &Path {
38 &self.project_root
39 }
40
41 pub fn all(&self) -> &[AgentSpec] {
43 &self.specs
44 }
45
46 pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> {
48 self.specs
49 .iter()
50 .find(|spec| spec.name == name)
51 .ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() })
52 }
53
54 pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
56 self.specs.iter().filter(|s| s.exposure.user_invocable)
57 }
58
59 pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
61 self.specs.iter().filter(|s| s.exposure.agent_invocable)
62 }
63
64 pub fn resolve(&self, name: &str, cwd: &Path) -> Result<AgentSpec, SettingsError> {
66 let mut spec = self.get(name)?.clone();
67 spec.resolve_mcp_config(&self.inherited_mcp_config_refs, cwd);
68 Ok(spec)
69 }
70
71 pub fn resolve_default(
73 &self,
74 model: &LlmModel,
75 reasoning_effort: Option<ReasoningEffort>,
76 cwd: &Path,
77 ) -> AgentSpec {
78 let mut spec = AgentSpec::default_spec(model, reasoning_effort, self.inherited_prompts.clone());
79 spec.resolve_mcp_config(&self.inherited_mcp_config_refs, cwd);
80 spec
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use aether_core::agent_spec::{AgentSpecExposure, McpJsonFileRef, ToolFilter};
88 use std::fs;
89
90 fn create_temp_project() -> tempfile::TempDir {
91 tempfile::tempdir().unwrap()
92 }
93
94 fn write_file(dir: &Path, path: &str, content: &str) {
95 let full_path = dir.join(path);
96 if let Some(parent) = full_path.parent() {
97 fs::create_dir_all(parent).unwrap();
98 }
99 fs::write(full_path, content).unwrap();
100 }
101
102 fn make_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
103 AgentSpec {
104 name: name.to_string(),
105 description: format!("{name} agent"),
106 model: "anthropic:claude-sonnet-4-5".to_string(),
107 reasoning_effort: None,
108 prompts: vec![],
109 mcp_config_refs: Vec::new(),
110 exposure,
111 tools: ToolFilter::default(),
112 }
113 }
114
115 fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
116 let inherited_prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], project_root.clone())];
117 let planner = AgentSpec {
118 name: "planner".to_string(),
119 description: "Planner agent".to_string(),
120 model: "anthropic:claude-sonnet-4-5".to_string(),
121 reasoning_effort: None,
122 prompts: vec![
123 Prompt::from_globs(vec!["BASE.md".to_string()], project_root.clone()),
124 Prompt::from_globs(vec!["AGENTS.md".to_string()], project_root.clone()),
125 ],
126 mcp_config_refs: Vec::new(),
127 exposure: AgentSpecExposure::both(),
128 tools: ToolFilter::default(),
129 };
130 AgentCatalog::new(project_root, inherited_prompts, Vec::new(), vec![planner])
131 }
132
133 #[test]
134 fn user_invocable_filters_correctly() {
135 let dir = create_temp_project();
136 let root = dir.path().to_path_buf();
137 let catalog = AgentCatalog::new(
138 root,
139 vec![],
140 Vec::new(),
141 vec![
142 make_spec("planner", AgentSpecExposure::both()),
143 make_spec("internal", AgentSpecExposure::agent_only()),
144 ],
145 );
146
147 let user_invocable: Vec<_> = catalog.user_invocable().collect();
148 assert_eq!(user_invocable.len(), 1);
149 assert_eq!(user_invocable[0].name, "planner");
150 }
151
152 #[test]
153 fn agent_invocable_filters_correctly() {
154 let dir = create_temp_project();
155 let root = dir.path().to_path_buf();
156 let catalog = AgentCatalog::new(
157 root,
158 vec![],
159 Vec::new(),
160 vec![
161 make_spec("planner", AgentSpecExposure::both()),
162 make_spec("user-only", AgentSpecExposure::user_only()),
163 ],
164 );
165
166 let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
167 assert_eq!(agent_invocable.len(), 1);
168 assert_eq!(agent_invocable[0].name, "planner");
169 }
170
171 #[test]
172 fn get_returns_error_for_missing_agent() {
173 let dir = create_temp_project();
174 let catalog = create_test_catalog(dir.path().to_path_buf());
175 let result = catalog.get("nonexistent");
176 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
177 }
178
179 #[test]
180 fn resolve_missing_agent_returns_error() {
181 let dir = create_temp_project();
182 let catalog = create_test_catalog(dir.path().to_path_buf());
183 let result = catalog.resolve("missing", dir.path());
184 assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
185 }
186
187 #[test]
188 fn resolve_selects_agent_mcp_over_inherited() {
189 let dir = create_temp_project();
190 write_file(dir.path(), "agent-mcp.json", "{}");
191 write_file(dir.path(), "inherited-mcp.json", "{}");
192
193 let mut planner = make_spec("planner", AgentSpecExposure::both());
194 planner.mcp_config_refs = vec![McpJsonFileRef::direct(dir.path().join("agent-mcp.json"))];
195
196 let catalog = AgentCatalog::new(
197 dir.path().to_path_buf(),
198 vec![],
199 vec![McpJsonFileRef::direct(dir.path().join("inherited-mcp.json"))],
200 vec![planner],
201 );
202
203 let spec = catalog.resolve("planner", dir.path()).unwrap();
204 assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("agent-mcp.json"))]);
205 }
206
207 #[test]
208 fn resolve_selects_inherited_mcp_over_cwd() {
209 let dir = create_temp_project();
210 write_file(dir.path(), "inherited-mcp.json", "{}");
211 write_file(dir.path(), "mcp.json", "{}");
212
213 let catalog = AgentCatalog::new(
214 dir.path().to_path_buf(),
215 vec![],
216 vec![McpJsonFileRef::direct(dir.path().join("inherited-mcp.json"))],
217 vec![make_spec("planner", AgentSpecExposure::both())],
218 );
219
220 let spec = catalog.resolve("planner", dir.path()).unwrap();
221 assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("inherited-mcp.json"))]);
222 }
223
224 #[test]
225 fn resolve_falls_back_to_cwd_mcp() {
226 let dir = create_temp_project();
227 write_file(dir.path(), "mcp.json", "{}");
228
229 let catalog = AgentCatalog::new(
230 dir.path().to_path_buf(),
231 vec![],
232 Vec::new(),
233 vec![make_spec("planner", AgentSpecExposure::both())],
234 );
235
236 let spec = catalog.resolve("planner", dir.path()).unwrap();
237 assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("mcp.json"))]);
238 }
239
240 #[test]
241 fn resolve_no_mcp_config_is_valid() {
242 let dir = create_temp_project();
243 let catalog = AgentCatalog::new(
244 dir.path().to_path_buf(),
245 vec![],
246 Vec::new(),
247 vec![make_spec("planner", AgentSpecExposure::both())],
248 );
249
250 let spec = catalog.resolve("planner", dir.path()).unwrap();
251 assert!(spec.mcp_config_refs.is_empty());
252 }
253
254 #[test]
255 fn resolve_default_includes_inherited_prompts() {
256 let dir = create_temp_project();
257 let catalog = create_test_catalog(dir.path().to_path_buf());
258
259 let model: llm::LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
260 let spec = catalog.resolve_default(&model, None, dir.path());
261
262 assert_eq!(spec.name, "__default__");
263 assert_eq!(spec.model, model.to_string());
264 assert_eq!(spec.prompts.len(), 1);
265 assert!(spec.mcp_config_refs.is_empty());
266 }
267}