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, Copy, PartialEq, Eq)]
41pub enum OverwriteMode {
42 PreserveExisting,
44 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}