1use crate::agent::NewArgs;
2use crate::error::CliError;
3use llm::LlmModel;
4use llm::ReasoningEffort;
5use llm::catalog::available_models;
6use llm::providers::local::discovery::discover_local_models;
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::io;
10use std::path::Path;
11use tokio::sync::mpsc::UnboundedReceiver;
12use tui::{
13 Component, CrosstermEvent, Event, Form, FormField, FormFieldKind, FormMessage, KeyCode, KeyModifiers, Line,
14 MouseCapture, MultiSelect, Renderer, SelectOption, TerminalSession, TextField, Theme, ViewContext,
15 spawn_terminal_event_task, terminal_size,
16};
17use wisp::components::model_selector::{ModelEntry, ModelSelector};
18
19const SYSTEM_MD_TEMPLATE: &str = include_str!("../../templates/SYSTEM.md");
20
21pub async fn run_new(args: NewArgs) -> Result<(), CliError> {
22 let project_root = args.path.canonicalize().unwrap_or(args.path);
23 let settings_path = project_root.join(".aether/settings.json");
24 let is_existing_project = settings_path.is_file();
25
26 let (form_values, selection) = {
27 let size = terminal_size().unwrap_or((80, 24));
28 let mut renderer = Renderer::new(io::stdout(), Theme::default(), size);
29 let _session = TerminalSession::new(false, MouseCapture::Disabled).map_err(CliError::IoError)?;
30 let mut terminal_rx = spawn_terminal_event_task();
31
32 let discovery_handle = tokio::spawn(discover_local_models());
33
34 let mut form = build_form(is_existing_project);
35 let form_result = run_form(&mut form, &mut renderer, &mut terminal_rx).await?;
36 let Some(form_values) = form_result else {
37 renderer.clear_screen().map_err(CliError::IoError)?;
38 println!("Cancelled.");
39 return Ok(());
40 };
41
42 renderer.clear_screen().map_err(CliError::IoError)?;
43
44 let discovered = discovery_handle.await.unwrap_or_default();
45 run_provider_screen(&discovered, &mut renderer, &mut terminal_rx).await?;
46
47 renderer.clear_screen().map_err(CliError::IoError)?;
48
49 let entries = build_model_entries(&discovered);
50 if entries.is_empty() {
51 renderer.clear_screen().map_err(CliError::IoError)?;
52 println!("No providers detected. Set an API key environment variable and try again.");
53 return Ok(());
54 }
55
56 let mut selector = ModelSelector::new(entries, "model".to_string(), None, None);
57
58 let Some(selection) = run_model_selector(&mut selector, &mut renderer, &mut terminal_rx).await? else {
59 renderer.clear_screen().map_err(CliError::IoError)?;
60 println!("Cancelled.");
61 return Ok(());
62 };
63
64 renderer.clear_screen().map_err(CliError::IoError)?;
65 (form_values, selection)
66 };
67
68 let input = WizardInput::from_form_and_selection(&form_values, selection);
69
70 if is_existing_project {
71 add_agent(&settings_path, &input)?;
72 } else {
73 scaffold(&project_root, &input)?;
74 }
75
76 Ok(())
77}
78
79fn build_model_entries(discovered: &[LlmModel]) -> Vec<ModelEntry> {
80 available_models()
81 .into_iter()
82 .chain(discovered.iter().cloned())
83 .map(|m| ModelEntry {
84 value: m.to_string(),
85 name: format!("{} / {}", m.provider_display_name(), m.display_name()),
86 reasoning_levels: m.reasoning_levels().to_vec(),
87 supports_image: m.supports_image(),
88 supports_audio: m.supports_audio(),
89 })
90 .collect()
91}
92
93struct ProviderStatus {
94 display_name: String,
95 detected: bool,
96 config_hint: String,
97}
98
99fn detect_providers(discovered: &[LlmModel]) -> Vec<ProviderStatus> {
100 let mut providers: BTreeMap<String, ProviderStatus> = BTreeMap::new();
101
102 for model in LlmModel::all() {
103 let display = model.provider_display_name().to_string();
104 providers.entry(display.clone()).or_insert_with(|| {
105 let env_var = model.required_env_var();
106 let detected = env_var.is_none_or(|var| std::env::var(var).is_ok());
107 let config_hint = env_var.unwrap_or("(no key required)").to_string();
108 ProviderStatus { display_name: display, detected, config_hint }
109 });
110 }
111
112 if !discovered.is_empty() {
113 let mut ollama_count = 0;
114 let mut llamacpp_count = 0;
115 for m in discovered {
116 match m {
117 LlmModel::Ollama(_) => ollama_count += 1,
118 LlmModel::LlamaCpp(_) => llamacpp_count += 1,
119 _ => {}
120 }
121 }
122 if ollama_count > 0 {
123 providers.insert(
124 "Ollama".to_string(),
125 ProviderStatus {
126 display_name: "Ollama".to_string(),
127 detected: true,
128 config_hint: format!("localhost:11434 ({ollama_count} models)"),
129 },
130 );
131 }
132 if llamacpp_count > 0 {
133 providers.insert(
134 "LlamaCpp".to_string(),
135 ProviderStatus {
136 display_name: "LlamaCpp".to_string(),
137 detected: true,
138 config_hint: format!("localhost:8080 ({llamacpp_count} models)"),
139 },
140 );
141 }
142 }
143
144 providers.into_values().collect()
145}
146
147fn format_provider_lines(statuses: &[ProviderStatus], theme: &Theme) -> Vec<Line> {
148 let (detected, missing): (Vec<_>, Vec<_>) = statuses.iter().partition(|p| p.detected);
149
150 let name_width = detected.iter().chain(missing.iter()).map(|p| p.display_name.len()).max().unwrap_or(0).max(8);
151
152 let mut lines = Vec::new();
153
154 if detected.is_empty() {
155 lines.push(Line::styled(" No providers detected.".to_string(), theme.text_primary()));
156 } else {
157 lines.push(Line::styled(" Available Providers".to_string(), theme.text_primary()));
158 lines.push(Line::new(String::new()));
159 lines.push(Line::styled(
160 " Models from these providers will be shown in the next step.".to_string(),
161 theme.text_secondary(),
162 ));
163 lines.push(Line::new(String::new()));
164
165 let header = format!(" {:<name_width$} {}", "Provider", "Configuration");
166 lines.push(Line::styled(header, theme.text_secondary()));
167 let separator = format!(" {:-<name_width$} {:-<15}", "", "");
168 lines.push(Line::styled(separator, theme.muted()));
169
170 for p in &detected {
171 let row = format!(" {:<name_width$} {}", p.display_name, p.config_hint);
172 lines.push(Line::styled(row, theme.text_primary()));
173 }
174 }
175
176 if !missing.is_empty() {
177 let dim = theme.text_secondary();
178 lines.push(Line::new(String::new()));
179 lines.push(Line::styled(" Not Configured".to_string(), dim));
180 lines.push(Line::new(String::new()));
181 lines.push(Line::styled(" Set the environment variable to enable these providers.".to_string(), dim));
182 lines.push(Line::new(String::new()));
183
184 let header = format!(" {:<name_width$} {}", "Provider", "Environment Variable");
185 lines.push(Line::styled(header, dim));
186 let separator = format!(" {:-<name_width$} {:-<20}", "", "");
187 lines.push(Line::styled(separator, dim));
188
189 for p in &missing {
190 let row = format!(" {:<name_width$} {}", p.display_name, p.config_hint);
191 lines.push(Line::styled(row, dim));
192 }
193 }
194
195 lines.push(Line::new(String::new()));
196 lines.push(Line::styled(" Press any key to continue.".to_string(), theme.muted()));
197
198 lines
199}
200
201async fn run_provider_screen<W: io::Write>(
202 discovered: &[LlmModel],
203 renderer: &mut Renderer<W>,
204 terminal_rx: &mut UnboundedReceiver<CrosstermEvent>,
205) -> Result<(), CliError> {
206 let statuses = detect_providers(discovered);
207
208 renderer
209 .render_frame(|ctx| tui::Frame::new(format_provider_lines(&statuses, &ctx.theme)))
210 .map_err(CliError::IoError)?;
211
212 loop {
213 let Some(event) = terminal_rx.recv().await else {
214 return Ok(());
215 };
216 if let CrosstermEvent::Resize(c, r) = &event {
217 renderer.on_resize((*c, *r));
218 renderer
219 .render_frame(|ctx| tui::Frame::new(format_provider_lines(&statuses, &ctx.theme)))
220 .map_err(CliError::IoError)?;
221 continue;
222 }
223 if let CrosstermEvent::Key(_) = event {
224 return Ok(());
225 }
226 }
227}
228
229struct WizardInput {
230 name: String,
231 description: String,
232 model: String,
233 reasoning_effort: Option<ReasoningEffort>,
234 servers: Vec<String>,
235}
236
237impl WizardInput {
238 fn from_form_and_selection(json: &Value, selection: ModelSelection) -> Self {
239 let name = json["name"].as_str().unwrap_or("").to_string();
240 let description = json["description"].as_str().unwrap_or("").to_string();
241 let servers = json["servers"]
242 .as_array()
243 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
244 .unwrap_or_default();
245
246 Self { name, description, model: selection.model, reasoning_effort: selection.reasoning_effort, servers }
247 }
248}
249
250fn build_form(is_existing_project: bool) -> Form {
251 let title =
252 if is_existing_project { "Add a new agent".to_string() } else { "Create a new Aether project".to_string() };
253
254 let mut fields = vec![
255 FormField {
256 name: "name".to_string(),
257 label: "Agent Name".to_string(),
258 description: None,
259 required: true,
260 kind: FormFieldKind::Text(TextField::new(String::new())),
261 },
262 FormField {
263 name: "description".to_string(),
264 label: "Description".to_string(),
265 description: None,
266 required: true,
267 kind: FormFieldKind::Text(TextField::new(String::new())),
268 },
269 ];
270
271 if !is_existing_project {
272 let server_options = vec![
273 SelectOption {
274 value: "coding".to_string(),
275 title: "Coding".to_string(),
276 description: Some("Filesystem, search, and bash tools".to_string()),
277 },
278 SelectOption {
279 value: "lsp".to_string(),
280 title: "Lsp".to_string(),
281 description: Some("Language Server Protocol integration".to_string()),
282 },
283 SelectOption {
284 value: "skills".to_string(),
285 title: "Skills".to_string(),
286 description: Some("Skills and slash-commands".to_string()),
287 },
288 SelectOption {
289 value: "subagents".to_string(),
290 title: "Subagents".to_string(),
291 description: Some("Spawn sub-agents in parallel".to_string()),
292 },
293 SelectOption {
294 value: "tasks".to_string(),
295 title: "Tasks".to_string(),
296 description: Some("Task management tools, backed by JSONL files".to_string()),
297 },
298 SelectOption {
299 value: "survey".to_string(),
300 title: "Survey".to_string(),
301 description: Some("Allow your agent to ask you structured questions".to_string()),
302 },
303 ];
304
305 fields.push(FormField {
306 name: "servers".to_string(),
307 label: "MCP Servers".to_string(),
308 description: None,
309 required: true,
310 kind: FormFieldKind::MultiSelect(MultiSelect::new(
311 server_options,
312 vec![true, true, true, true, true, true],
313 )),
314 });
315 }
316
317 Form::new(title, fields)
318}
319
320async fn run_form<W: io::Write>(
321 form: &mut Form,
322 renderer: &mut Renderer<W>,
323 terminal_rx: &mut UnboundedReceiver<CrosstermEvent>,
324) -> Result<Option<Value>, CliError> {
325 renderer.render_frame(|ctx| form.render(ctx)).map_err(CliError::IoError)?;
326
327 loop {
328 let Some(event) = terminal_rx.recv().await else {
329 return Ok(None);
330 };
331 if let CrosstermEvent::Resize(c, r) = &event {
332 renderer.on_resize((*c, *r));
333 }
334 if let Ok(tui_event) = Event::try_from(event) {
335 if let Some(msg) = form.on_event(&tui_event).await.and_then(|msgs| msgs.into_iter().next()) {
336 match msg {
337 FormMessage::Submit => return Ok(Some(form.to_json())),
338 FormMessage::Close => return Ok(None),
339 }
340 }
341 renderer.render_frame(|ctx| form.render(ctx)).map_err(CliError::IoError)?;
342 }
343 }
344}
345
346fn render_selector_with_footer(selector: &mut ModelSelector, ctx: &ViewContext) -> tui::Frame {
347 selector.update_viewport(ctx.size.height.saturating_sub(2) as usize);
348 let frame = selector.render(ctx);
349 let mut lines = frame.into_lines();
350 lines.push(Line::new(String::new()));
351 lines.push(Line::styled(
352 " [enter] toggle [tab] reasoning [ctrl+s] done [esc] cancel".to_string(),
353 ctx.theme.muted(),
354 ));
355 tui::Frame::new(lines)
356}
357
358struct ModelSelection {
359 model: String,
360 reasoning_effort: Option<ReasoningEffort>,
361}
362
363async fn run_model_selector<W: io::Write>(
364 selector: &mut ModelSelector,
365 renderer: &mut Renderer<W>,
366 terminal_rx: &mut UnboundedReceiver<CrosstermEvent>,
367) -> Result<Option<ModelSelection>, CliError> {
368 renderer.render_frame(|ctx| render_selector_with_footer(selector, ctx)).map_err(CliError::IoError)?;
369
370 loop {
371 let Some(event) = terminal_rx.recv().await else {
372 return Ok(None);
373 };
374 if let CrosstermEvent::Resize(c, r) = &event {
375 renderer.on_resize((*c, *r));
376 }
377 if let CrosstermEvent::Key(key) = &event {
378 if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
379 let selected = selector.selected_values();
380 if selected.is_empty() {
381 return Ok(None);
382 }
383 let joined = selected.iter().cloned().collect::<Vec<_>>().join(",");
384 return Ok(Some(ModelSelection { model: joined, reasoning_effort: selector.reasoning_effort() }));
385 }
386 if key.code == KeyCode::Esc {
387 return Ok(None);
388 }
389 }
390 if let Ok(tui_event) = Event::try_from(event) {
391 let _ = selector.on_event(&tui_event).await;
392 renderer.render_frame(|ctx| render_selector_with_footer(selector, ctx)).map_err(CliError::IoError)?;
393 }
394 }
395}
396
397fn scaffold(project_root: &Path, input: &WizardInput) -> Result<(), CliError> {
398 std::fs::create_dir_all(project_root).map_err(CliError::IoError)?;
399
400 write_if_absent(&project_root.join(".aether/SYSTEM.md"), SYSTEM_MD_TEMPLATE)?;
401 write_if_absent(&project_root.join(".aether/mcp.json"), &build_mcp_json(input))?;
402 write_if_absent(&project_root.join("AGENTS.md"), &build_agents_md(input))?;
403 write_if_absent(&project_root.join(".aether/settings.json"), &build_settings_json(input))?;
404
405 Ok(())
406}
407
408fn add_agent(settings_path: &Path, input: &WizardInput) -> Result<(), CliError> {
409 let content = std::fs::read_to_string(settings_path).map_err(CliError::IoError)?;
410 let mut settings: Value = serde_json::from_str(&content).map_err(|e| CliError::AgentError(e.to_string()))?;
411
412 let agents = settings
413 .as_object_mut()
414 .and_then(|obj| obj.entry("agents").or_insert_with(|| Value::Array(Vec::new())).as_array_mut())
415 .ok_or_else(|| CliError::AgentError("settings.json is not a valid object".to_string()))?;
416
417 agents.push(build_agent_json(input));
418
419 let output = serde_json::to_string_pretty(&settings).map_err(|e| CliError::AgentError(e.to_string()))?;
420 std::fs::write(settings_path, output).map_err(CliError::IoError)?;
421 println!("Added agent '{}' to {}", input.name, settings_path.display());
422
423 Ok(())
424}
425
426fn write_if_absent(path: &Path, content: &str) -> Result<(), CliError> {
427 if path.exists() {
428 println!("Skipping: {}", path.display());
429 return Ok(());
430 }
431 if let Some(parent) = path.parent() {
432 std::fs::create_dir_all(parent).map_err(CliError::IoError)?;
433 }
434 std::fs::write(path, content).map_err(CliError::IoError)?;
435 println!("Created: {}", path.display());
436 Ok(())
437}
438
439fn build_agent_json(input: &WizardInput) -> Value {
440 let mut agent = serde_json::json!({
441 "name": input.name,
442 "description": input.description,
443 "model": input.model,
444 "userInvocable": true,
445 "agentInvocable": true,
446 "prompts": []
447 });
448 if let Some(effort) = input.reasoning_effort {
449 agent["reasoningEffort"] = Value::String(effort.as_str().to_string());
450 }
451 agent
452}
453
454fn build_settings_json(input: &WizardInput) -> String {
455 let value = serde_json::json!({
456 "prompts": [".aether/SYSTEM.md", "AGENTS.md"],
457 "mcpServers": ".aether/mcp.json",
458 "agents": [build_agent_json(input)]
459 });
460 serde_json::to_string_pretty(&value).expect("settings serialization cannot fail")
461}
462
463fn build_mcp_json(input: &WizardInput) -> String {
464 let mut servers = serde_json::Map::new();
465 for server in &input.servers {
466 let mut entry = serde_json::Map::new();
467 entry.insert("type".to_string(), serde_json::json!("in-memory"));
468 if server == "skills" {
469 entry.insert("args".to_string(), serde_json::json!(["--dir", "$HOME/.aether"]));
470 }
471 servers.insert(server.clone(), Value::Object(entry));
472 }
473 let value = serde_json::json!({ "servers": servers });
474 serde_json::to_string_pretty(&value).expect("mcp serialization cannot fail")
475}
476
477fn build_agents_md(input: &WizardInput) -> String {
478 format!("# {}\n\n{}\n\nYou are an expert coding assistant.\n", input.name, input.description)
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use aether_project::load_agent_catalog;
485 use mcp_utils::client::config::RawMcpConfig;
486 use std::fs;
487
488 fn default_input() -> WizardInput {
489 WizardInput {
490 name: "Default".to_string(),
491 description: "Default coding agent".to_string(),
492 model: "anthropic:claude-sonnet-4-5".to_string(),
493 reasoning_effort: None,
494 servers: vec!["coding".to_string(), "skills".to_string(), "tasks".to_string()],
495 }
496 }
497
498 #[test]
499 fn scaffold_writes_all_files() {
500 let dir = tempfile::tempdir().unwrap();
501 scaffold(dir.path(), &default_input()).unwrap();
502
503 assert!(dir.path().join(".aether/settings.json").exists());
504 assert!(dir.path().join(".aether/mcp.json").exists());
505 assert!(dir.path().join(".aether/SYSTEM.md").exists());
506 assert!(dir.path().join("AGENTS.md").exists());
507 }
508
509 #[test]
510 fn scaffold_skips_existing_files() {
511 let dir = tempfile::tempdir().unwrap();
512 let agents_path = dir.path().join("AGENTS.md");
513 fs::write(&agents_path, "My custom prompt").unwrap();
514
515 scaffold(dir.path(), &default_input()).unwrap();
516
517 let content = fs::read_to_string(&agents_path).unwrap();
518 assert_eq!(content, "My custom prompt");
519 }
520
521 #[test]
522 fn scaffold_rejects_invalid_model() {
523 let input = WizardInput { model: "invalid:nope".to_string(), ..default_input() };
524 let dir = tempfile::tempdir().unwrap();
525 scaffold(dir.path(), &input).unwrap();
526
527 let result = load_agent_catalog(dir.path());
528 assert!(result.is_err());
529 }
530
531 #[test]
532 fn scaffold_settings_json_is_valid() {
533 let dir = tempfile::tempdir().unwrap();
534 scaffold(dir.path(), &default_input()).unwrap();
535
536 let catalog = load_agent_catalog(dir.path()).unwrap();
537 assert_eq!(catalog.all().len(), 1);
538 assert_eq!(catalog.all()[0].name, "Default");
539 }
540
541 #[test]
542 fn scaffold_mcp_json_is_valid() {
543 let dir = tempfile::tempdir().unwrap();
544 scaffold(dir.path(), &default_input()).unwrap();
545
546 let mcp_path = dir.path().join(".aether/mcp.json");
547 let raw = RawMcpConfig::from_json_file(&mcp_path).unwrap();
548 assert_eq!(raw.servers.len(), 3);
549 assert!(raw.servers.contains_key("coding"));
550 assert!(raw.servers.contains_key("skills"));
551 assert!(raw.servers.contains_key("tasks"));
552 }
553
554 #[test]
555 fn scaffold_is_idempotent() {
556 let dir = tempfile::tempdir().unwrap();
557 let input = default_input();
558 scaffold(dir.path(), &input).unwrap();
559 scaffold(dir.path(), &input).unwrap();
560
561 assert!(dir.path().join(".aether/settings.json").exists());
562 }
563
564 #[test]
565 fn scaffold_creates_parent_dirs() {
566 let dir = tempfile::tempdir().unwrap();
567 let nested = dir.path().join("deep/nested/project");
568 scaffold(&nested, &default_input()).unwrap();
569
570 assert!(nested.join(".aether/settings.json").exists());
571 assert!(nested.join(".aether/mcp.json").exists());
572 assert!(nested.join(".aether/SYSTEM.md").exists());
573 assert!(nested.join("AGENTS.md").exists());
574 }
575
576 #[test]
577 fn scaffold_custom_servers() {
578 let dir = tempfile::tempdir().unwrap();
579 let input = WizardInput { servers: vec!["coding".to_string(), "lsp".to_string()], ..default_input() };
580 scaffold(dir.path(), &input).unwrap();
581
582 let raw = RawMcpConfig::from_json_file(dir.path().join(".aether/mcp.json")).unwrap();
583 assert_eq!(raw.servers.len(), 2);
584 assert!(raw.servers.contains_key("coding"));
585 assert!(raw.servers.contains_key("lsp"));
586 assert!(!raw.servers.contains_key("tasks"));
587 }
588
589 #[test]
590 fn build_model_entries_includes_available() {
591 let items = build_model_entries(&[]);
592 for item in &items {
594 let model: LlmModel = item.value.parse().unwrap();
595 assert!(
596 model.required_env_var().is_none_or(|var| std::env::var(var).is_ok()),
597 "model {} should be available",
598 item.value
599 );
600 }
601 }
602
603 #[test]
604 fn generated_settings_reference_aether_paths() {
605 let dir = tempfile::tempdir().unwrap();
606 scaffold(dir.path(), &default_input()).unwrap();
607
608 let settings_path = dir.path().join(".aether/settings.json");
609 let content = fs::read_to_string(&settings_path).unwrap();
610 let settings: Value = serde_json::from_str(&content).unwrap();
611
612 let prompts = settings["prompts"].as_array().unwrap();
613 assert!(prompts.contains(&Value::String(".aether/SYSTEM.md".to_string())));
614 assert!(prompts.contains(&Value::String("AGENTS.md".to_string())));
615
616 assert_eq!(settings["mcpServers"].as_str().unwrap(), ".aether/mcp.json");
617
618 let agents = settings["agents"].as_array().unwrap();
619 assert_eq!(agents.len(), 1);
620 assert!(agents[0]["prompts"].as_array().unwrap().is_empty());
621 }
622
623 #[test]
624 fn scaffold_system_md_is_written() {
625 let dir = tempfile::tempdir().unwrap();
626 scaffold(dir.path(), &default_input()).unwrap();
627
628 let system_md_path = dir.path().join(".aether/SYSTEM.md");
629 assert!(system_md_path.exists());
630
631 let content = fs::read_to_string(&system_md_path).unwrap();
632 assert!(!content.is_empty());
633 }
634
635 #[test]
636 fn scaffold_system_md_matches_template() {
637 let dir = tempfile::tempdir().unwrap();
638 scaffold(dir.path(), &default_input()).unwrap();
639
640 let system_md_path = dir.path().join(".aether/SYSTEM.md");
641 let content = fs::read_to_string(&system_md_path).unwrap();
642
643 assert_eq!(content, SYSTEM_MD_TEMPLATE);
644 }
645
646 #[test]
647 fn default_agent_inherits_generated_mcp() {
648 let dir = tempfile::tempdir().unwrap();
649 scaffold(dir.path(), &default_input()).unwrap();
650
651 let catalog = load_agent_catalog(dir.path()).unwrap();
652 let model: llm::LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
653 let default_agent = catalog.resolve_default(&model, None, dir.path());
654
655 let expected_path = dir.path().join(".aether/mcp.json");
656 assert_eq!(default_agent.mcp_config_path, Some(expected_path));
657 }
658
659 #[test]
660 fn add_agent_appends_to_existing_settings() {
661 let dir = tempfile::tempdir().unwrap();
662 scaffold(dir.path(), &default_input()).unwrap();
663
664 let settings_path = dir.path().join(".aether/settings.json");
665 let new_agent = WizardInput {
666 name: "Researcher".to_string(),
667 description: "Research agent".to_string(),
668 model: "anthropic:claude-sonnet-4-5".to_string(),
669 reasoning_effort: None,
670 servers: vec![],
671 };
672 add_agent(&settings_path, &new_agent).unwrap();
673
674 let catalog = load_agent_catalog(dir.path()).unwrap();
675 assert_eq!(catalog.all().len(), 2);
676 assert_eq!(catalog.all()[0].name, "Default");
677 assert_eq!(catalog.all()[1].name, "Researcher");
678 }
679
680 #[test]
681 fn scaffold_includes_reasoning_effort() {
682 let dir = tempfile::tempdir().unwrap();
683 let input = WizardInput { reasoning_effort: Some(ReasoningEffort::High), ..default_input() };
684 scaffold(dir.path(), &input).unwrap();
685
686 let catalog = load_agent_catalog(dir.path()).unwrap();
687 assert_eq!(catalog.all()[0].reasoning_effort, Some(ReasoningEffort::High));
688 }
689
690 #[test]
691 fn scaffold_omits_reasoning_effort_when_none() {
692 let dir = tempfile::tempdir().unwrap();
693 scaffold(dir.path(), &default_input()).unwrap();
694
695 let content = std::fs::read_to_string(dir.path().join(".aether/settings.json")).unwrap();
696 assert!(!content.contains("reasoningEffort"));
697 }
698
699 #[test]
700 fn detect_providers_includes_catalog_providers() {
701 let statuses = detect_providers(&[]);
702 assert!(statuses.iter().any(|p| p.display_name == "Anthropic"));
703 }
704
705 #[test]
706 fn detect_providers_includes_discovered_local() {
707 let discovered = vec![LlmModel::Ollama("llama3".to_string()), LlmModel::Ollama("phi3".to_string())];
708 let statuses = detect_providers(&discovered);
709 let ollama = statuses.iter().find(|p| p.display_name == "Ollama").expect("expected Ollama entry");
710 assert!(ollama.detected);
711 assert!(ollama.config_hint.contains("2 models"));
712 }
713
714 #[test]
715 fn format_provider_lines_includes_all_providers() {
716 let theme = Theme::default();
717 let statuses = detect_providers(&[]);
718 let lines = format_provider_lines(&statuses, &theme);
719 let text: String = lines.iter().map(tui::Line::plain_text).collect::<Vec<_>>().join("\n");
720 assert!(text.contains("Not Configured"));
721 assert!(text.contains("Anthropic"));
722 }
723}