aether-agent-cli 0.7.9

CLI and ACP server for the Aether AI coding agent
Documentation
mod build_settings;
mod recommendations;
mod tui_runner;
use aether_project::user_settings_path;
pub use build_settings::Preset;
use llm::catalog::Provider;
use recommendations::recommended_for_provider;
use std::fs;
use std::io;
use std::path::PathBuf;
use thiserror::Error;

use crate::init::build_settings::supported_providers;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitScope {
    User,
    Project,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitTarget {
    pub scope: InitScope,
    pub settings_path: PathBuf,
    pub asset_root: PathBuf,
}

#[derive(Debug, Clone)]
pub struct InitRequest {
    pub target: InitTargetRequest,
    pub provider: Option<Provider>,
    pub preset: Option<Preset>,
    pub force: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InitTargetRequest {
    User,
    Project { path: PathBuf },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InitOutcome {
    Applied { settings_path: PathBuf, missing_env_var: Option<&'static str> },
    Cancelled,
    AlreadyInitialized { settings_path: PathBuf },
}

#[derive(Debug, Error)]
pub enum InitError {
    #[error("could not determine user home directory; set $AETHER_HOME or $HOME")]
    NoHomeDir,
    #[error("io error at {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    #[error("provider `{provider}` does not have a curated preset; supported: {supported}")]
    UnsupportedProvider { provider: Provider, supported: String },
    #[error("terminal error: {0}")]
    Terminal(#[source] io::Error),
}

impl InitTarget {
    pub fn user(aether_home: impl Into<PathBuf>) -> Self {
        let asset_root = aether_home.into();
        Self { scope: InitScope::User, settings_path: asset_root.join("settings.json"), asset_root }
    }

    pub fn project(project_root: impl Into<PathBuf>) -> Self {
        let project_root = project_root.into();
        let asset_root = project_root.join(".aether");
        Self { scope: InitScope::Project, settings_path: asset_root.join("settings.json"), asset_root }
    }
}

impl InitRequest {
    pub fn user_onboarding() -> Self {
        Self { target: InitTargetRequest::User, provider: None, preset: None, force: false }
    }
}

pub fn apply_init(
    target: InitTarget,
    provider: Provider,
    preset: Preset,
    force: bool,
) -> Result<InitOutcome, InitError> {
    if target.settings_path.is_file() && !force {
        return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
    }

    let recs = recommended_for_provider(provider).ok_or_else(|| InitError::UnsupportedProvider {
        provider,
        supported: supported_providers().map(Provider::parser_name).collect::<Vec<_>>().join(", "),
    })?;

    let built = build_settings::build_preset(preset, provider, &recs, target.scope);

    fs::create_dir_all(&target.asset_root).map_err(|e| InitError::Io { path: target.asset_root.clone(), source: e })?;

    for file in built.files {
        let dest = target.asset_root.join(file.path);
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent).map_err(|e| InitError::Io { path: parent.to_path_buf(), source: e })?;
        }
        fs::write(&dest, file.body).map_err(|e| InitError::Io { path: dest, source: e })?;
    }

    let serialized = serde_json::to_string_pretty(&built.settings).expect("AetherSettings always serializes");
    fs::write(&target.settings_path, format!("{serialized}\n"))
        .map_err(|e| InitError::Io { path: target.settings_path.clone(), source: e })?;

    let missing_env_var = provider.required_env_var().filter(|var| std::env::var(var).is_err());
    Ok(InitOutcome::Applied { settings_path: target.settings_path, missing_env_var })
}

pub async fn run_init(request: InitRequest) -> Result<InitOutcome, InitError> {
    let target = resolve_target(&request)?;

    if target.settings_path.is_file() && !request.force {
        return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
    }

    let Some((provider, preset)) = tui_runner::run_wizard(request.provider, request.preset).await? else {
        return Ok(InitOutcome::Cancelled);
    };

    apply_init(target, provider, preset, request.force)
}

pub fn next_steps_message(outcome: &InitOutcome) -> Option<String> {
    match outcome {
        InitOutcome::Applied { settings_path, missing_env_var: Some(var) } => Some(format!(
            "Wrote {}. Set ${var} in your shell, then run `wisp` or `aether` to start chatting.",
            settings_path.display()
        )),
        InitOutcome::Applied { settings_path, missing_env_var: None } => {
            Some(format!("Wrote {}. Run `wisp` or `aether` to start chatting.", settings_path.display()))
        }
        InitOutcome::AlreadyInitialized { settings_path } => {
            Some(format!("Already initialized at {}; pass --force to overwrite.", settings_path.display()))
        }
        InitOutcome::Cancelled => None,
    }
}

fn resolve_target(request: &InitRequest) -> Result<InitTarget, InitError> {
    match &request.target {
        InitTargetRequest::User => {
            let settings_path = user_settings_path().ok_or(InitError::NoHomeDir)?;
            let home = settings_path.parent().ok_or(InitError::NoHomeDir)?.to_path_buf();
            Ok(InitTarget::user(home))
        }
        InitTargetRequest::Project { path } => {
            let root = path.canonicalize().unwrap_or_else(|_| path.clone());
            Ok(InitTarget::project(root))
        }
    }
}