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}