Skip to main content

aether_cli/agent/
new.rs

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        // Should only include models where the env var is set (or has no requirement)
593        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}