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/// Whether [`apply_init`] may overwrite an existing settings file.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum OverwriteMode {
42    /// Leave an existing settings file untouched, returning [`InitOutcome::AlreadyInitialized`].
43    PreserveExisting,
44    /// Overwrite an existing settings file.
45    OverwriteExisting,
46}
47
48#[derive(Debug, Clone)]
49pub struct InitRequest {
50    pub target: InitTargetRequest,
51    pub provider: Option<Provider>,
52    pub preset: Option<Preset>,
53    pub harnesses: Vec<HarnessIntegration>,
54    pub overwrite: OverwriteMode,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum InitTargetRequest {
59    User,
60    Project { path: PathBuf },
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum InitOutcome {
65    Applied { settings_path: PathBuf, missing_env_var: Option<&'static str> },
66    Cancelled,
67    AlreadyInitialized { settings_path: PathBuf },
68}
69
70#[derive(Debug, Error)]
71pub enum InitError {
72    #[error("could not determine user home directory; set $AETHER_HOME or $HOME")]
73    NoHomeDir,
74    #[error("io error at {path}: {source}")]
75    Io {
76        path: PathBuf,
77        #[source]
78        source: io::Error,
79    },
80    #[error("provider `{provider}` does not have a curated preset; supported: {supported}")]
81    UnsupportedProvider { provider: Provider, supported: String },
82    #[error("terminal error: {0}")]
83    Terminal(#[source] io::Error),
84}
85
86impl InitTarget {
87    pub fn user(aether_home: impl Into<PathBuf>) -> Self {
88        let asset_root = aether_home.into();
89        Self { scope: InitScope::User, settings_path: asset_root.join("settings.json"), asset_root }
90    }
91
92    pub fn project(project_root: impl Into<PathBuf>) -> Self {
93        let project_root = project_root.into();
94        let asset_root = project_root.join(".aether");
95        Self { scope: InitScope::Project, settings_path: asset_root.join("settings.json"), asset_root }
96    }
97}
98
99impl InitRequest {
100    pub fn user_onboarding() -> Self {
101        Self {
102            target: InitTargetRequest::User,
103            provider: None,
104            preset: None,
105            harnesses: vec![],
106            overwrite: OverwriteMode::PreserveExisting,
107        }
108    }
109}
110
111pub fn apply_init(
112    target: InitTarget,
113    provider: Provider,
114    preset: Preset,
115    harnesses: &[HarnessIntegration],
116    overwrite: OverwriteMode,
117) -> Result<InitOutcome, InitError> {
118    if target.settings_path.is_file() && matches!(overwrite, OverwriteMode::PreserveExisting) {
119        return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
120    }
121
122    let recs = recommended_for_provider(provider).ok_or_else(|| InitError::UnsupportedProvider {
123        provider,
124        supported: supported_providers().map(Provider::parser_name).collect::<Vec<_>>().join(", "),
125    })?;
126
127    let built = build_settings::build_preset(preset, provider, &recs, target.scope, harnesses);
128
129    fs::create_dir_all(&target.asset_root).map_err(|e| InitError::Io { path: target.asset_root.clone(), source: e })?;
130
131    for file in built.files {
132        let dest = target.asset_root.join(file.path);
133        if let Some(parent) = dest.parent() {
134            fs::create_dir_all(parent).map_err(|e| InitError::Io { path: parent.to_path_buf(), source: e })?;
135        }
136        fs::write(&dest, file.body).map_err(|e| InitError::Io { path: dest, source: e })?;
137    }
138
139    let serialized = serde_json::to_string_pretty(&built.settings).expect("AetherSettings always serializes");
140    fs::write(&target.settings_path, format!("{serialized}\n"))
141        .map_err(|e| InitError::Io { path: target.settings_path.clone(), source: e })?;
142
143    let missing_env_var = provider.required_env_var().filter(|var| std::env::var(var).is_err());
144    Ok(InitOutcome::Applied { settings_path: target.settings_path, missing_env_var })
145}
146
147pub async fn run_init(request: InitRequest) -> Result<InitOutcome, InitError> {
148    let target = resolve_target(&request)?;
149
150    if target.settings_path.is_file() && matches!(request.overwrite, OverwriteMode::PreserveExisting) {
151        return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
152    }
153
154    let Some((provider, preset, harnesses)) =
155        tui_runner::run_wizard(request.provider, request.preset, request.harnesses).await?
156    else {
157        return Ok(InitOutcome::Cancelled);
158    };
159
160    apply_init(target, provider, preset, &harnesses, request.overwrite)
161}
162
163pub fn next_steps_message(outcome: &InitOutcome) -> Option<String> {
164    match outcome {
165        InitOutcome::Applied { settings_path, missing_env_var: Some(var) } => {
166            Some(format!("Wrote {}. Set ${var} in your shell.", settings_path.display()))
167        }
168        InitOutcome::Applied { settings_path, missing_env_var: None } => {
169            Some(format!("Wrote {}", settings_path.display()))
170        }
171        InitOutcome::AlreadyInitialized { settings_path } => {
172            Some(format!("Already initialized at {}; pass --force to overwrite.", settings_path.display()))
173        }
174        InitOutcome::Cancelled => None,
175    }
176}
177
178fn resolve_target(request: &InitRequest) -> Result<InitTarget, InitError> {
179    match &request.target {
180        InitTargetRequest::User => {
181            let settings_path = user_settings_path().ok_or(InitError::NoHomeDir)?;
182            let home = settings_path.parent().ok_or(InitError::NoHomeDir)?.to_path_buf();
183            Ok(InitTarget::user(home))
184        }
185        InitTargetRequest::Project { path } => {
186            let root = path.canonicalize().unwrap_or_else(|_| path.clone());
187            Ok(InitTarget::project(root))
188        }
189    }
190}