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