Skip to main content

aether_cli/init/
mod.rs

1mod build_settings;
2mod harness;
3mod recommendations;
4mod tui_runner;
5use aether_project::user_settings_path;
6pub use build_settings::{Preset, build_batteries_included_settings};
7pub use harness::HarnessIntegration;
8use llm::catalog::Provider;
9use recommendations::recommended_for_provider;
10use std::fs;
11use std::io;
12use std::path::PathBuf;
13use thiserror::Error;
14
15use crate::init::build_settings::supported_providers;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum InitScope {
19    User,
20    Project,
21}
22
23impl InitScope {
24    pub fn asset_path(self, asset_rel_path: &str) -> String {
25        match self {
26            Self::User => asset_rel_path.to_string(),
27            Self::Project => format!(".aether/{asset_rel_path}"),
28        }
29    }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct InitTarget {
34    pub scope: InitScope,
35    pub settings_path: PathBuf,
36    pub asset_root: PathBuf,
37}
38
39#[derive(Debug, Clone)]
40pub struct InitRequest {
41    pub target: InitTargetRequest,
42    pub provider: Option<Provider>,
43    pub preset: Option<Preset>,
44    pub harnesses: Vec<HarnessIntegration>,
45    pub force: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum InitTargetRequest {
50    User,
51    Project { path: PathBuf },
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum InitOutcome {
56    Applied { settings_path: PathBuf, missing_env_var: Option<&'static str> },
57    Cancelled,
58    AlreadyInitialized { settings_path: PathBuf },
59}
60
61#[derive(Debug, Error)]
62pub enum InitError {
63    #[error("could not determine user home directory; set $AETHER_HOME or $HOME")]
64    NoHomeDir,
65    #[error("io error at {path}: {source}")]
66    Io {
67        path: PathBuf,
68        #[source]
69        source: io::Error,
70    },
71    #[error("provider `{provider}` does not have a curated preset; supported: {supported}")]
72    UnsupportedProvider { provider: Provider, supported: String },
73    #[error("terminal error: {0}")]
74    Terminal(#[source] io::Error),
75}
76
77impl InitTarget {
78    pub fn user(aether_home: impl Into<PathBuf>) -> Self {
79        let asset_root = aether_home.into();
80        Self { scope: InitScope::User, settings_path: asset_root.join("settings.json"), asset_root }
81    }
82
83    pub fn project(project_root: impl Into<PathBuf>) -> Self {
84        let project_root = project_root.into();
85        let asset_root = project_root.join(".aether");
86        Self { scope: InitScope::Project, settings_path: asset_root.join("settings.json"), asset_root }
87    }
88}
89
90impl InitRequest {
91    pub fn user_onboarding() -> Self {
92        Self { target: InitTargetRequest::User, provider: None, preset: None, harnesses: vec![], force: false }
93    }
94}
95
96pub fn apply_init(
97    target: InitTarget,
98    provider: Provider,
99    preset: Preset,
100    harnesses: &[HarnessIntegration],
101    force: bool,
102) -> Result<InitOutcome, InitError> {
103    if target.settings_path.is_file() && !force {
104        return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
105    }
106
107    let recs = recommended_for_provider(provider).ok_or_else(|| InitError::UnsupportedProvider {
108        provider,
109        supported: supported_providers().map(Provider::parser_name).collect::<Vec<_>>().join(", "),
110    })?;
111
112    let built = build_settings::build_preset(preset, provider, &recs, target.scope, harnesses);
113
114    fs::create_dir_all(&target.asset_root).map_err(|e| InitError::Io { path: target.asset_root.clone(), source: e })?;
115
116    for file in built.files {
117        let dest = target.asset_root.join(file.path);
118        if let Some(parent) = dest.parent() {
119            fs::create_dir_all(parent).map_err(|e| InitError::Io { path: parent.to_path_buf(), source: e })?;
120        }
121        fs::write(&dest, file.body).map_err(|e| InitError::Io { path: dest, source: e })?;
122    }
123
124    let serialized = serde_json::to_string_pretty(&built.settings).expect("AetherSettings always serializes");
125    fs::write(&target.settings_path, format!("{serialized}\n"))
126        .map_err(|e| InitError::Io { path: target.settings_path.clone(), source: e })?;
127
128    let missing_env_var = provider.required_env_var().filter(|var| std::env::var(var).is_err());
129    Ok(InitOutcome::Applied { settings_path: target.settings_path, missing_env_var })
130}
131
132pub async fn run_init(request: InitRequest) -> Result<InitOutcome, InitError> {
133    let target = resolve_target(&request)?;
134
135    if target.settings_path.is_file() && !request.force {
136        return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
137    }
138
139    let Some((provider, preset, harnesses)) =
140        tui_runner::run_wizard(request.provider, request.preset, request.harnesses).await?
141    else {
142        return Ok(InitOutcome::Cancelled);
143    };
144
145    apply_init(target, provider, preset, &harnesses, request.force)
146}
147
148pub fn next_steps_message(outcome: &InitOutcome) -> Option<String> {
149    match outcome {
150        InitOutcome::Applied { settings_path, missing_env_var: Some(var) } => {
151            Some(format!("Wrote {}. Set ${var} in your shell.", settings_path.display()))
152        }
153        InitOutcome::Applied { settings_path, missing_env_var: None } => {
154            Some(format!("Wrote {}", settings_path.display()))
155        }
156        InitOutcome::AlreadyInitialized { settings_path } => {
157            Some(format!("Already initialized at {}; pass --force to overwrite.", settings_path.display()))
158        }
159        InitOutcome::Cancelled => None,
160    }
161}
162
163fn resolve_target(request: &InitRequest) -> Result<InitTarget, InitError> {
164    match &request.target {
165        InitTargetRequest::User => {
166            let settings_path = user_settings_path().ok_or(InitError::NoHomeDir)?;
167            let home = settings_path.parent().ok_or(InitError::NoHomeDir)?.to_path_buf();
168            Ok(InitTarget::user(home))
169        }
170        InitTargetRequest::Project { path } => {
171            let root = path.canonicalize().unwrap_or_else(|_| path.clone());
172            Ok(InitTarget::project(root))
173        }
174    }
175}