1use crate::error::CliError;
2use crate::init::InitArgs;
3use llm::LlmModel;
4use llm::providers::local::discovery::discover_local_models;
5use serde_json::Value;
6use std::io;
7use std::path::Path;
8use tokio::sync::mpsc::UnboundedReceiver;
9use tui::{
10 Component, CrosstermEvent, Event, Form, FormField, FormFieldKind, FormMessage, MouseCapture,
11 MultiSelect, Renderer, SelectOption, TerminalSession, TextField, Theme,
12 spawn_terminal_event_task, terminal_size,
13};
14use wisp::components::model_selector::{ModelEntry, ModelSelector, ModelSelectorMessage};
15
16const SYSTEM_MD_TEMPLATE: &str = include_str!("../../templates/SYSTEM.md");
17
18pub async fn run_init(args: InitArgs) -> Result<(), CliError> {
19 let project_root = args.path.canonicalize().unwrap_or(args.path);
20 let (form_values, model) = {
24 let size = terminal_size().unwrap_or((80, 24));
25 let mut renderer = Renderer::new(io::stdout(), Theme::default(), size);
26 let _session =
27 TerminalSession::new(false, MouseCapture::Disabled).map_err(CliError::IoError)?;
28
29 let mut terminal_rx = spawn_terminal_event_task();
30 let mut form = build_form();
31 let form_result = run_form(&mut form, &mut renderer, &mut terminal_rx).await?;
32 let Some(form_values) = form_result else {
33 renderer.clear_screen().map_err(CliError::IoError)?;
34 println!("Cancelled.");
35 return Ok(());
36 };
37
38 renderer.clear_screen().map_err(CliError::IoError)?;
39 let mut selector = ModelSelector::new(
40 build_model_entries().await,
41 "model".to_string(),
42 Some("anthropic:claude-sonnet-4-5"),
43 None,
44 );
45
46 let Some(model) =
47 run_model_selector(&mut selector, &mut renderer, &mut terminal_rx).await?
48 else {
49 renderer.clear_screen().map_err(CliError::IoError)?;
50 println!("Cancelled.");
51 return Ok(());
52 };
53
54 renderer.clear_screen().map_err(CliError::IoError)?;
55 (form_values, model)
56 };
57
58 let input = WizardInput::from_form_and_model(&form_values, model);
59 scaffold(&project_root, &input)?;
60 Ok(())
61}
62
63async fn build_model_entries() -> Vec<ModelEntry> {
64 let discovered = discover_local_models().await;
65 LlmModel::all()
66 .iter()
67 .cloned()
68 .chain(discovered)
69 .map(|m| ModelEntry {
70 value: m.to_string(),
71 name: format!("{} / {}", m.provider_display_name(), m.display_name()),
72 reasoning_levels: m.reasoning_levels().to_vec(),
73 supports_image: m.supports_image(),
74 supports_audio: m.supports_audio(),
75 })
76 .collect()
77}
78
79struct WizardInput {
81 name: String,
82 description: String,
83 model: String,
84 servers: Vec<String>,
85}
86
87impl WizardInput {
88 fn from_form_and_model(json: &Value, model: String) -> Self {
89 let name = json["name"].as_str().unwrap_or("").to_string();
90 let description = json["description"].as_str().unwrap_or("").to_string();
91 let servers = json["servers"]
92 .as_array()
93 .map(|arr| {
94 arr.iter()
95 .filter_map(|v| v.as_str().map(String::from))
96 .collect()
97 })
98 .unwrap_or_default();
99
100 Self {
101 name,
102 description,
103 model,
104 servers,
105 }
106 }
107}
108
109fn build_form() -> Form {
110 let server_options = vec![
111 SelectOption {
112 value: "coding".to_string(),
113 title: "Coding".to_string(),
114 description: Some("Filesystem, search, and bash tools".to_string()),
115 },
116 SelectOption {
117 value: "lsp".to_string(),
118 title: "Lsp".to_string(),
119 description: Some("Language Server Protocol integration".to_string()),
120 },
121 SelectOption {
122 value: "skills".to_string(),
123 title: "Skills".to_string(),
124 description: Some("Skills and slash-commands".to_string()),
125 },
126 SelectOption {
127 value: "subagents".to_string(),
128 title: "Subagents".to_string(),
129 description: Some("Spawn sub-agents in parallel".to_string()),
130 },
131 SelectOption {
132 value: "tasks".to_string(),
133 title: "Tasks".to_string(),
134 description: Some("Task management tools, backed by JSONL files".to_string()),
135 },
136 SelectOption {
137 value: "survey".to_string(),
138 title: "Survey".to_string(),
139 description: Some("Allow your agent to ask you structured questions".to_string()),
140 },
141 ];
142
143 Form::new(
144 "Initialize a new Aether project".to_string(),
145 vec![
146 FormField {
147 name: "name".to_string(),
148 label: "Agent Name".to_string(),
149 description: None,
150 required: true,
151 kind: FormFieldKind::Text(TextField::new("".to_string())),
152 },
153 FormField {
154 name: "description".to_string(),
155 label: "Description".to_string(),
156 description: None,
157 required: true,
158 kind: FormFieldKind::Text(TextField::new("".to_string())),
159 },
160 FormField {
161 name: "servers".to_string(),
162 label: "MCP Servers".to_string(),
163 description: None,
164 required: true,
165 kind: FormFieldKind::MultiSelect(MultiSelect::new(
166 server_options,
167 vec![true, true, true, true, true, true],
168 )),
169 },
170 ],
171 )
172}
173
174async fn run_form<W: io::Write>(
175 form: &mut Form,
176 renderer: &mut Renderer<W>,
177 terminal_rx: &mut UnboundedReceiver<CrosstermEvent>,
178) -> Result<Option<Value>, CliError> {
179 renderer
180 .render_frame(|ctx| form.render(ctx))
181 .map_err(CliError::IoError)?;
182
183 loop {
184 let Some(event) = terminal_rx.recv().await else {
185 return Ok(None);
186 };
187 if let CrosstermEvent::Resize(c, r) = &event {
188 renderer.on_resize((*c, *r));
189 }
190 if let Ok(tui_event) = Event::try_from(event) {
191 if let Some(msg) = form
192 .on_event(&tui_event)
193 .await
194 .and_then(|msgs| msgs.into_iter().next())
195 {
196 match msg {
197 FormMessage::Submit => return Ok(Some(form.to_json())),
198 FormMessage::Close => return Ok(None),
199 }
200 }
201 renderer
202 .render_frame(|ctx| form.render(ctx))
203 .map_err(CliError::IoError)?;
204 }
205 }
206}
207
208async fn run_model_selector<W: io::Write>(
209 selector: &mut ModelSelector,
210 renderer: &mut Renderer<W>,
211 terminal_rx: &mut UnboundedReceiver<CrosstermEvent>,
212) -> Result<Option<String>, CliError> {
213 renderer
214 .render_frame(|ctx| {
215 selector.update_viewport(ctx.size.height as usize);
216 selector.render(ctx)
217 })
218 .map_err(CliError::IoError)?;
219
220 loop {
221 let Some(event) = terminal_rx.recv().await else {
222 return Ok(None);
223 };
224 if let CrosstermEvent::Resize(c, r) = &event {
225 renderer.on_resize((*c, *r));
226 }
227 if let Ok(tui_event) = Event::try_from(event) {
228 if let Some(msg) = selector
229 .on_event(&tui_event)
230 .await
231 .and_then(|msgs| msgs.into_iter().next())
232 {
233 match msg {
234 ModelSelectorMessage::Done(changes) => {
235 let model = changes
236 .into_iter()
237 .find(|c| c.config_id == "model")
238 .map(|c| c.new_value);
239 return Ok(model);
240 }
241 }
242 }
243 renderer
244 .render_frame(|ctx| {
245 selector.update_viewport(ctx.size.height as usize);
246 selector.render(ctx)
247 })
248 .map_err(CliError::IoError)?;
249 }
250 }
251}
252
253fn scaffold(project_root: &Path, input: &WizardInput) -> Result<(), CliError> {
255 std::fs::create_dir_all(project_root).map_err(CliError::IoError)?;
256
257 write_if_absent(&project_root.join(".aether/SYSTEM.md"), SYSTEM_MD_TEMPLATE)?;
258 write_if_absent(
259 &project_root.join(".aether/mcp.json"),
260 &build_mcp_json(input),
261 )?;
262 write_if_absent(&project_root.join("AGENTS.md"), &build_agents_md(input))?;
263 write_if_absent(
264 &project_root.join(".aether/settings.json"),
265 &build_settings_json(input),
266 )?;
267
268 Ok(())
269}
270
271fn write_if_absent(path: &Path, content: &str) -> Result<(), CliError> {
272 if path.exists() {
273 println!("Skipping: {}", path.display());
274 return Ok(());
275 }
276 if let Some(parent) = path.parent() {
277 std::fs::create_dir_all(parent).map_err(CliError::IoError)?;
278 }
279 std::fs::write(path, content).map_err(CliError::IoError)?;
280 println!("Created: {}", path.display());
281 Ok(())
282}
283
284fn build_settings_json(input: &WizardInput) -> String {
285 let value = serde_json::json!({
286 "prompts": [".aether/SYSTEM.md", "AGENTS.md"],
287 "mcpServers": ".aether/mcp.json",
288 "agents": [{
289 "name": input.name,
290 "description": input.description,
291 "model": input.model,
292 "userInvocable": true,
293 "agentInvocable": true,
294 "prompts": []
295 }]
296 });
297 serde_json::to_string_pretty(&value).expect("settings serialization cannot fail")
298}
299
300fn build_mcp_json(input: &WizardInput) -> String {
301 let mut servers = serde_json::Map::new();
302 for server in &input.servers {
303 let mut entry = serde_json::Map::new();
304 entry.insert("type".to_string(), serde_json::json!("in-memory"));
305 if server == "skills" {
306 entry.insert(
307 "args".to_string(),
308 serde_json::json!(["--dir", "$HOME/.aether"]),
309 );
310 }
311 servers.insert(server.clone(), Value::Object(entry));
312 }
313 let value = serde_json::json!({ "servers": servers });
314 serde_json::to_string_pretty(&value).expect("mcp serialization cannot fail")
315}
316
317fn build_agents_md(input: &WizardInput) -> String {
318 format!(
319 "# {}\n\n{}\n\nYou are an expert coding assistant.\n",
320 input.name, input.description
321 )
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use aether_project::load_agent_catalog;
328 use mcp_utils::client::config::RawMcpConfig;
329 use std::fs;
330
331 fn default_input() -> WizardInput {
332 WizardInput {
333 name: "Default".to_string(),
334 description: "Default coding agent".to_string(),
335 model: "anthropic:claude-sonnet-4-5".to_string(),
336 servers: vec![
337 "coding".to_string(),
338 "skills".to_string(),
339 "tasks".to_string(),
340 ],
341 }
342 }
343
344 #[test]
345 fn scaffold_writes_all_files() {
346 let dir = tempfile::tempdir().unwrap();
347 scaffold(dir.path(), &default_input()).unwrap();
348
349 assert!(dir.path().join(".aether/settings.json").exists());
350 assert!(dir.path().join(".aether/mcp.json").exists());
351 assert!(dir.path().join(".aether/SYSTEM.md").exists());
352 assert!(dir.path().join("AGENTS.md").exists());
353 }
354
355 #[test]
356 fn scaffold_skips_existing_files() {
357 let dir = tempfile::tempdir().unwrap();
358 let agents_path = dir.path().join("AGENTS.md");
359 fs::write(&agents_path, "My custom prompt").unwrap();
360
361 scaffold(dir.path(), &default_input()).unwrap();
362
363 let content = fs::read_to_string(&agents_path).unwrap();
364 assert_eq!(content, "My custom prompt");
365 }
366
367 #[test]
368 fn scaffold_rejects_invalid_model() {
369 let input = WizardInput {
370 model: "invalid:nope".to_string(),
371 ..default_input()
372 };
373 let dir = tempfile::tempdir().unwrap();
374 scaffold(dir.path(), &input).unwrap();
375
376 let result = load_agent_catalog(dir.path());
377 assert!(result.is_err());
378 }
379
380 #[test]
381 fn scaffold_settings_json_is_valid() {
382 let dir = tempfile::tempdir().unwrap();
383 scaffold(dir.path(), &default_input()).unwrap();
384
385 let catalog = load_agent_catalog(dir.path()).unwrap();
386 assert_eq!(catalog.all().len(), 1);
387 assert_eq!(catalog.all()[0].name, "Default");
388 }
389
390 #[test]
391 fn scaffold_mcp_json_is_valid() {
392 let dir = tempfile::tempdir().unwrap();
393 scaffold(dir.path(), &default_input()).unwrap();
394
395 let mcp_path = dir.path().join(".aether/mcp.json");
396 let raw = RawMcpConfig::from_json_file(&mcp_path).unwrap();
397 assert_eq!(raw.servers.len(), 3);
398 assert!(raw.servers.contains_key("coding"));
399 assert!(raw.servers.contains_key("skills"));
400 assert!(raw.servers.contains_key("tasks"));
401 }
402
403 #[test]
404 fn scaffold_is_idempotent() {
405 let dir = tempfile::tempdir().unwrap();
406 let input = default_input();
407 scaffold(dir.path(), &input).unwrap();
408 scaffold(dir.path(), &input).unwrap();
409
410 assert!(dir.path().join(".aether/settings.json").exists());
411 }
412
413 #[test]
414 fn scaffold_creates_parent_dirs() {
415 let dir = tempfile::tempdir().unwrap();
416 let nested = dir.path().join("deep/nested/project");
417 scaffold(&nested, &default_input()).unwrap();
418
419 assert!(nested.join(".aether/settings.json").exists());
420 assert!(nested.join(".aether/mcp.json").exists());
421 assert!(nested.join(".aether/SYSTEM.md").exists());
422 assert!(nested.join("AGENTS.md").exists());
423 }
424
425 #[test]
426 fn scaffold_custom_servers() {
427 let dir = tempfile::tempdir().unwrap();
428 let input = WizardInput {
429 servers: vec!["coding".to_string(), "lsp".to_string()],
430 ..default_input()
431 };
432 scaffold(dir.path(), &input).unwrap();
433
434 let raw = RawMcpConfig::from_json_file(&dir.path().join(".aether/mcp.json")).unwrap();
435 assert_eq!(raw.servers.len(), 2);
436 assert!(raw.servers.contains_key("coding"));
437 assert!(raw.servers.contains_key("lsp"));
438 assert!(!raw.servers.contains_key("tasks"));
439 }
440
441 #[tokio::test]
442 async fn build_model_entries_has_items() {
443 let items = build_model_entries().await;
444 assert!(!items.is_empty());
445 }
446
447 #[tokio::test]
448 async fn build_model_entries_includes_default() {
449 let items = build_model_entries().await;
450 assert!(
451 items
452 .iter()
453 .any(|e| e.value == "anthropic:claude-sonnet-4-5")
454 );
455 }
456
457 #[test]
458 fn generated_settings_reference_aether_paths() {
459 let dir = tempfile::tempdir().unwrap();
460 scaffold(dir.path(), &default_input()).unwrap();
461
462 let settings_path = dir.path().join(".aether/settings.json");
463 let content = fs::read_to_string(&settings_path).unwrap();
464 let settings: Value = serde_json::from_str(&content).unwrap();
465
466 let prompts = settings["prompts"].as_array().unwrap();
467 assert!(prompts.contains(&Value::String(".aether/SYSTEM.md".to_string())));
468 assert!(prompts.contains(&Value::String("AGENTS.md".to_string())));
469
470 assert_eq!(settings["mcpServers"].as_str().unwrap(), ".aether/mcp.json");
471
472 let agents = settings["agents"].as_array().unwrap();
473 assert_eq!(agents.len(), 1);
474 assert!(agents[0]["prompts"].as_array().unwrap().is_empty());
475 }
476
477 #[test]
478 fn scaffold_system_md_is_written() {
479 let dir = tempfile::tempdir().unwrap();
480 scaffold(dir.path(), &default_input()).unwrap();
481
482 let system_md_path = dir.path().join(".aether/SYSTEM.md");
483 assert!(system_md_path.exists());
484
485 let content = fs::read_to_string(&system_md_path).unwrap();
486 assert!(!content.is_empty());
487 }
488
489 #[test]
490 fn scaffold_system_md_matches_template() {
491 let dir = tempfile::tempdir().unwrap();
492 scaffold(dir.path(), &default_input()).unwrap();
493
494 let system_md_path = dir.path().join(".aether/SYSTEM.md");
495 let content = fs::read_to_string(&system_md_path).unwrap();
496
497 assert_eq!(content, SYSTEM_MD_TEMPLATE);
498 }
499
500 #[test]
501 fn default_agent_inherits_generated_mcp() {
502 let dir = tempfile::tempdir().unwrap();
503 scaffold(dir.path(), &default_input()).unwrap();
504
505 let catalog = load_agent_catalog(dir.path()).unwrap();
506 let model: llm::LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
507 let default_agent = catalog.resolve_default(&model, None, dir.path());
508
509 let expected_path = dir.path().join(".aether/mcp.json");
510 assert_eq!(default_agent.mcp_config_path, Some(expected_path));
511 }
512}