Skip to main content

aether_cli/init/
run.rs

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    // Scope the TUI session and renderer so they drop before we print to
21    // stdout — raw-mode causes println! to emit bare \n without \r,
22    // producing a staircase effect in the output.
23    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
79/// Values extracted from the wizard.
80struct 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
253/// Write the project scaffold files, skipping any that already exist.
254fn 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}