Skip to main content

aether_cli/agent/
new.rs

1use crate::agent::NewArgs;
2use crate::agent::new_agent_wizard::{
3    NewAgentMode, NewAgentOutcome, NewAgentWizard, add_agent, available_prompt_files, detect_mcp_configs,
4    run_wizard_loop, scaffold,
5};
6use crate::error::CliError;
7use llm::LlmModel;
8use llm::catalog::available_models;
9use llm::providers::local::discovery::discover_local_models;
10use std::collections::BTreeMap;
11use std::io;
12use tui::{MouseCapture, TerminalConfig, TerminalRuntime, Theme, terminal_size};
13use wisp::components::model_selector::ModelEntry;
14
15pub async fn run_new(args: NewArgs) -> Result<NewAgentOutcome, CliError> {
16    let project_root = args.path.canonicalize().unwrap_or(args.path);
17    let settings_path = project_root.join(".aether/settings.json");
18    let is_existing = settings_path.is_file();
19    let mode = if is_existing { NewAgentMode::AddAgentToExistingProject } else { NewAgentMode::ScaffoldProject };
20
21    let discovery_handle = tokio::spawn(discover_local_models());
22
23    let size = terminal_size().unwrap_or((80, 24));
24    let mut terminal = TerminalRuntime::new(
25        io::stdout(),
26        Theme::default(),
27        size,
28        TerminalConfig { bracketed_paste: false, mouse_capture: MouseCapture::Enabled },
29    )
30    .map_err(CliError::IoError)?;
31
32    let discovered = discovery_handle.await.unwrap_or_default();
33    let model_entries = build_model_entries(&discovered);
34
35    if !model_entries.iter().any(|e| !e.is_disabled()) {
36        terminal.clear_screen().map_err(CliError::IoError)?;
37        println!("No providers detected. Set an API key environment variable and try again.");
38        return Ok(NewAgentOutcome::Cancelled);
39    }
40
41    let prompt_options = available_prompt_files(&mode, &project_root);
42    let mcp_configs = detect_mcp_configs(&project_root);
43    let mut wizard = NewAgentWizard::new(mode, model_entries, &prompt_options, &mcp_configs);
44
45    let outcome = run_wizard_loop(&mut wizard, &mut terminal).await?;
46    terminal.clear_screen().map_err(CliError::IoError)?;
47
48    if matches!(outcome, NewAgentOutcome::Cancelled) {
49        println!("Cancelled.");
50        return Ok(NewAgentOutcome::Cancelled);
51    }
52
53    let draft = wizard.into_draft();
54
55    if is_existing {
56        add_agent(&settings_path, &draft)?;
57    } else {
58        scaffold(&project_root, &draft)?;
59    }
60
61    Ok(NewAgentOutcome::Applied)
62}
63
64fn build_model_entries(discovered: &[LlmModel]) -> Vec<ModelEntry> {
65    let available: std::collections::HashSet<String> = available_models().iter().map(ToString::to_string).collect();
66
67    let mut entries: Vec<ModelEntry> = available_models()
68        .into_iter()
69        .chain(discovered.iter().cloned())
70        .map(|m| ModelEntry {
71            value: m.to_string(),
72            name: format!("{} / {}", m.provider_display_name(), m.display_name()),
73            reasoning_levels: m.reasoning_levels().to_vec(),
74            supports_image: m.supports_image(),
75            supports_audio: m.supports_audio(),
76            disabled_reason: None,
77        })
78        .collect();
79
80    let mut unavailable_providers: BTreeMap<&str, (usize, &str, Option<&str>)> = BTreeMap::new();
81    for m in LlmModel::all() {
82        if available.contains(&m.to_string()) {
83            continue;
84        }
85        let entry =
86            unavailable_providers.entry(m.provider()).or_insert((0, m.provider_display_name(), m.required_env_var()));
87        entry.0 += 1;
88    }
89    for (provider_key, (count, display, env_var)) in &unavailable_providers {
90        let noun = if *count == 1 { "model" } else { "models" };
91        let reason = env_var.map_or("provider is not configured".to_string(), |var| format!("set {var}"));
92        entries.push(ModelEntry {
93            value: format!("__unavailable:{provider_key}"),
94            name: format!("{display} / {display} ({count} {noun})"),
95            reasoning_levels: vec![],
96            supports_image: false,
97            supports_audio: false,
98            disabled_reason: Some(reason),
99        });
100    }
101
102    entries
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn build_model_entries_includes_available() {
111        let items = build_model_entries(&[]);
112        for item in items.iter().filter(|e| !e.is_disabled()) {
113            let model: LlmModel = item.value.parse().unwrap();
114            assert!(
115                model.required_env_var().is_none_or(|var| std::env::var(var).is_ok()),
116                "model {} should be available",
117                item.value
118            );
119        }
120    }
121
122    #[test]
123    fn build_model_entries_includes_unavailable_providers() {
124        let items = build_model_entries(&[]);
125        let disabled: Vec<_> = items.iter().filter(|e| e.is_disabled()).collect();
126        let available_providers: std::collections::HashSet<&str> =
127            items.iter().filter(|e| !e.is_disabled()).filter_map(|e| e.value.split_once(':').map(|(p, _)| p)).collect();
128
129        for entry in &disabled {
130            assert!(entry.value.starts_with("__unavailable:"), "disabled entry should use __unavailable: prefix");
131            assert!(entry.disabled_reason.is_some(), "disabled entry should have a reason");
132            let provider = entry.value.strip_prefix("__unavailable:").unwrap();
133            assert!(
134                !available_providers.contains(provider),
135                "disabled provider {provider} should not also be in available set"
136            );
137        }
138    }
139}