aether-agent-cli 0.1.7

CLI and ACP server for the Aether AI coding agent
Documentation
use crate::agent::NewArgs;
use crate::agent::new_agent_wizard::{
    NewAgentMode, NewAgentOutcome, NewAgentWizard, add_agent, available_prompt_files, detect_mcp_configs,
    run_wizard_loop, scaffold,
};
use crate::error::CliError;
use llm::LlmModel;
use llm::catalog::available_models;
use llm::providers::local::discovery::discover_local_models;
use std::collections::BTreeMap;
use std::io;
use tui::{MouseCapture, TerminalConfig, TerminalRuntime, Theme, terminal_size};
use wisp::components::model_selector::ModelEntry;

pub async fn run_new(args: NewArgs) -> Result<NewAgentOutcome, CliError> {
    let project_root = args.path.canonicalize().unwrap_or(args.path);
    let settings_path = project_root.join(".aether/settings.json");
    let is_existing = settings_path.is_file();
    let mode = if is_existing { NewAgentMode::AddAgentToExistingProject } else { NewAgentMode::ScaffoldProject };

    let discovery_handle = tokio::spawn(discover_local_models());

    let size = terminal_size().unwrap_or((80, 24));
    let mut terminal = TerminalRuntime::new(
        io::stdout(),
        Theme::default(),
        size,
        TerminalConfig { bracketed_paste: false, mouse_capture: MouseCapture::Enabled },
    )
    .map_err(CliError::IoError)?;

    let discovered = discovery_handle.await.unwrap_or_default();
    let model_entries = build_model_entries(&discovered);

    if !model_entries.iter().any(|e| !e.is_disabled()) {
        terminal.clear_screen().map_err(CliError::IoError)?;
        println!("No providers detected. Set an API key environment variable and try again.");
        return Ok(NewAgentOutcome::Cancelled);
    }

    let prompt_options = available_prompt_files(&mode, &project_root);
    let mcp_configs = detect_mcp_configs(&project_root);
    let mut wizard = NewAgentWizard::new(mode, model_entries, &prompt_options, &mcp_configs);

    let outcome = run_wizard_loop(&mut wizard, &mut terminal).await?;
    terminal.clear_screen().map_err(CliError::IoError)?;

    if matches!(outcome, NewAgentOutcome::Cancelled) {
        println!("Cancelled.");
        return Ok(NewAgentOutcome::Cancelled);
    }

    let draft = wizard.into_draft();

    if is_existing {
        add_agent(&settings_path, &draft)?;
    } else {
        scaffold(&project_root, &draft)?;
    }

    Ok(NewAgentOutcome::Applied)
}

fn build_model_entries(discovered: &[LlmModel]) -> Vec<ModelEntry> {
    let available: std::collections::HashSet<String> = available_models().iter().map(ToString::to_string).collect();

    let mut entries: Vec<ModelEntry> = available_models()
        .into_iter()
        .chain(discovered.iter().cloned())
        .map(|m| ModelEntry {
            value: m.to_string(),
            name: format!("{} / {}", m.provider_display_name(), m.display_name()),
            reasoning_levels: m.reasoning_levels().to_vec(),
            supports_image: m.supports_image(),
            supports_audio: m.supports_audio(),
            disabled_reason: None,
        })
        .collect();

    let mut unavailable_providers: BTreeMap<&str, (usize, &str, Option<&str>)> = BTreeMap::new();
    for m in LlmModel::all() {
        if available.contains(&m.to_string()) {
            continue;
        }
        let entry =
            unavailable_providers.entry(m.provider()).or_insert((0, m.provider_display_name(), m.required_env_var()));
        entry.0 += 1;
    }
    for (provider_key, (count, display, env_var)) in &unavailable_providers {
        let noun = if *count == 1 { "model" } else { "models" };
        let reason = env_var.map_or("provider is not configured".to_string(), |var| format!("set {var}"));
        entries.push(ModelEntry {
            value: format!("__unavailable:{provider_key}"),
            name: format!("{display} / {display} ({count} {noun})"),
            reasoning_levels: vec![],
            supports_image: false,
            supports_audio: false,
            disabled_reason: Some(reason),
        });
    }

    entries
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_model_entries_includes_available() {
        let items = build_model_entries(&[]);
        for item in items.iter().filter(|e| !e.is_disabled()) {
            let model: LlmModel = item.value.parse().unwrap();
            assert!(
                model.required_env_var().is_none_or(|var| std::env::var(var).is_ok()),
                "model {} should be available",
                item.value
            );
        }
    }

    #[test]
    fn build_model_entries_includes_unavailable_providers() {
        let items = build_model_entries(&[]);
        let disabled: Vec<_> = items.iter().filter(|e| e.is_disabled()).collect();
        let available_providers: std::collections::HashSet<&str> =
            items.iter().filter(|e| !e.is_disabled()).filter_map(|e| e.value.split_once(':').map(|(p, _)| p)).collect();

        for entry in &disabled {
            assert!(entry.value.starts_with("__unavailable:"), "disabled entry should use __unavailable: prefix");
            assert!(entry.disabled_reason.is_some(), "disabled entry should have a reason");
            let provider = entry.value.strip_prefix("__unavailable:").unwrap();
            assert!(
                !available_providers.contains(provider),
                "disabled provider {provider} should not also be in available set"
            );
        }
    }
}