1pub mod catalog;
34pub mod import;
35pub mod input;
36pub mod plan;
37pub mod render;
38pub mod state;
39pub mod verify;
40
41use std::path::{Path, PathBuf};
42use std::time::Duration;
43
44use clap::Args;
45use ratatui::Terminal;
46use tokio::sync::mpsc;
47
48use crate::config::Config;
49use crate::tui::{EventSource, TerminalSetup};
50use crossterm::event::{Event, KeyEventKind};
51use state::{VerifyReply, VerifyRequest, Wizard};
52use verify::ProviderVerifier;
53
54#[derive(Args)]
56pub struct SetupArgs {
57 #[arg(long)]
59 pub non_interactive: bool,
60
61 #[arg(long)]
63 pub no_verify: bool,
64
65 #[arg(long)]
67 pub anthropic_key: Option<String>,
68
69 #[arg(long)]
71 pub openai_key: Option<String>,
72
73 #[arg(long)]
75 pub google_key: Option<String>,
76
77 #[arg(long)]
79 pub openrouter_key: Option<String>,
80
81 #[arg(long)]
83 pub ollama_url: Option<String>,
84
85 #[arg(long)]
87 pub default_model: Option<String>,
88
89 #[arg(long)]
93 pub claude_code: Option<bool>,
94
95 #[arg(long)]
98 pub claude_code_effort: Option<String>,
99
100 #[arg(long)]
102 pub install_agents: bool,
103}
104
105pub type EnvLookup = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
108
109pub struct SetupEnv {
112 pub config_path: PathBuf,
114 pub agents_dir: PathBuf,
116 pub roots: import::Roots,
118 pub env_lookup: EnvLookup,
120 pub opener: leviath_mcp::BrowserOpener,
122}
123
124pub fn run_non_interactive(args: &SetupArgs, env: &SetupEnv) -> anyhow::Result<()> {
133 let mut config = Config::load_from_path_public(&env.config_path).unwrap_or_default();
134 apply_flags(&mut config, args);
135
136 let agents = if args.install_agents {
137 crate::bundled::plan_agent_actions(&env.agents_dir)
138 .into_iter()
139 .filter(|(_, action)| action.preselect())
142 .map(|(agent, _)| agent)
143 .collect()
144 } else {
145 Vec::new()
146 };
147
148 let applied = plan::apply(
149 &plan::SetupPlan { config, agents },
150 &env.config_path,
151 &env.agents_dir,
152 )?;
153 report(&applied);
154 Ok(())
155}
156
157fn report(applied: &plan::Applied) {
160 println!("Config saved to {}", applied.config_path.display());
161 if !applied.agents_installed.is_empty() {
162 println!(
163 "Installed {} agent(s): {}",
164 applied.agents_installed.len(),
165 applied.agents_installed.join(", ")
166 );
167 }
168 for warning in &applied.warnings {
169 println!(" Warning: {warning}");
170 }
171}
172
173fn apply_flags(config: &mut Config, args: &SetupArgs) {
175 if let Some(ref k) = args.anthropic_key {
176 config.providers.anthropic_api_key = Some(k.clone());
177 }
178 if let Some(ref k) = args.openai_key {
179 config.providers.openai_api_key = Some(k.clone());
180 }
181 if let Some(ref k) = args.google_key {
182 config.providers.google_api_key = Some(k.clone());
183 }
184 if let Some(ref k) = args.openrouter_key {
185 config.openrouter_api_key = Some(k.clone());
186 }
187 if let Some(ref u) = args.ollama_url {
188 config.ollama_base_url = Some(u.clone());
189 }
190 if let Some(ref m) = args.default_model {
191 config.default_model = Some(m.clone());
192 }
193 if let Some(enabled) = args.claude_code {
194 config.providers.claude_code_enabled = enabled;
195 }
196 if let Some(ref e) = args.claude_code_effort {
197 config.providers.claude_code_effort = Some(e.clone());
198 }
199 retarget_default_provider(config);
200}
201
202fn configured_providers(config: &Config) -> Vec<&'static str> {
208 [
209 ("anthropic", config.providers.anthropic_api_key.is_some()),
210 ("openai", config.providers.openai_api_key.is_some()),
211 ("google", config.providers.google_api_key.is_some()),
212 ("openrouter", config.openrouter_api_key.is_some()),
213 ("claude-code", config.providers.claude_code_enabled),
214 ("ollama", config.ollama_base_url.is_some()),
215 ]
216 .into_iter()
217 .filter(|(_, configured)| *configured)
218 .map(|(id, _)| id)
219 .collect()
220}
221
222fn retarget_default_provider(config: &mut Config) {
233 let configured = configured_providers(config);
234 if configured.contains(&config.default_provider.as_str()) {
235 return;
236 }
237 if let Some(first) = configured.first() {
238 config.default_provider = (*first).to_string();
239 }
240}
241
242pub fn build_wizard(env: &SetupEnv) -> Wizard {
250 let base = Config::load_from_path_public(&env.config_path).unwrap_or_default();
251 let (candidates, errors) = state::candidates_from_scans(import::scan(&env.roots));
252 Wizard::new(
253 base,
254 &env.env_lookup,
255 candidates,
256 errors,
257 &env.agents_dir,
258 env.opener.clone(),
259 )
260}
261
262pub async fn verification_loop<V: ProviderVerifier>(
269 verifier: V,
270 mut requests: mpsc::UnboundedReceiver<VerifyRequest>,
271 replies: mpsc::UnboundedSender<VerifyReply>,
272) {
273 while let Some(request) = requests.recv().await {
274 let outcome = verifier.verify(&request.creds).await;
275 if replies
277 .send(VerifyReply {
278 provider_id: request.provider_id,
279 outcome,
280 })
281 .is_err()
282 {
283 return;
284 }
285 }
286}
287
288pub async fn run_wizard_loop<B: ratatui::backend::Backend>(
294 wizard: &mut Wizard,
295 terminal: &mut Terminal<B>,
296 events: &mut impl EventSource,
297 tick_rate: Duration,
298) -> anyhow::Result<Option<plan::SetupPlan>> {
299 loop {
300 wizard.ticks += 1;
301 wizard.drain_verifications();
302 let mut area = ratatui::layout::Rect::default();
306 terminal
307 .draw(|frame| {
308 area = frame.area();
309 render::draw(frame, wizard);
310 })
311 .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
314
315 match events.poll_event(tick_rate)? {
316 Some(Event::Key(key))
317 if key.kind == KeyEventKind::Press
318 && wizard.handle_key(key) == input::Action::Save =>
319 {
320 wizard.finished = true;
321 }
322 Some(Event::Mouse(mouse))
323 if wizard.handle_mouse(mouse, area) == input::Action::Save =>
324 {
325 wizard.finished = true;
326 }
327 _ => {}
328 }
329
330 if wizard.finished {
331 return Ok(Some(wizard.build_plan()));
332 }
333 if wizard.should_quit {
334 return Ok(None);
335 }
336 }
337}
338
339pub async fn execute_core<S: TerminalSetup, E: EventSource>(
344 wizard: &mut Wizard,
345 env: &SetupEnv,
346 setup: &mut S,
347 events: &mut E,
348) -> anyhow::Result<()> {
349 setup.enable()?;
350 let mut terminal = setup.create_terminal()?;
351 let result = run_wizard_loop(wizard, &mut terminal, events, Duration::from_millis(120)).await;
352 setup.disable();
353
354 match result? {
355 Some(plan) => {
356 let applied = plan::apply(&plan, &env.config_path, &env.agents_dir)?;
357 report(&applied);
358 print_next_steps(&applied);
359 }
360 None => println!("Setup cancelled. Nothing was written."),
361 }
362 Ok(())
363}
364
365fn print_next_steps(applied: &plan::Applied) {
367 println!();
368 match applied.agents_installed.first() {
369 Some(agent) => println!("Try it: lev run {agent} --task \"...\""),
370 None => println!("Install an agent with `lev setup`, then `lev run <agent>`."),
371 }
372}
373
374pub async fn execute_with<S: TerminalSetup, E: EventSource>(
380 args: &SetupArgs,
381 env: &SetupEnv,
382 setup: &mut S,
383 events: &mut E,
384 is_terminal: bool,
385) -> anyhow::Result<()> {
386 if args.non_interactive {
387 return run_non_interactive(args, env);
388 }
389 if !is_terminal {
390 anyhow::bail!(
391 "lev setup needs a terminal. For scripted use:\n \
392 lev setup --non-interactive --anthropic-key sk-ant-... --install-agents"
393 );
394 }
395 let mut wizard = build_wizard(env);
396 execute_core(&mut wizard, env, setup, events).await
397}
398
399pub fn real_agents_dir(home: Option<&Path>) -> PathBuf {
401 home.unwrap_or(Path::new(""))
402 .join(".leviath")
403 .join("agents")
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::bundled::BUNDLED_AGENTS;
410 use crate::tui::{TestEventSource, TestSetup, key, key_with, test_terminal};
411 use crossterm::event::{KeyCode, KeyModifiers};
412
413 fn args() -> SetupArgs {
415 SetupArgs {
416 non_interactive: false,
417 no_verify: false,
418 anthropic_key: None,
419 openai_key: None,
420 google_key: None,
421 openrouter_key: None,
422 ollama_url: None,
423 default_model: None,
424 claude_code: None,
425 claude_code_effort: None,
426 install_agents: false,
427 }
428 }
429
430 fn env_in(dir: &Path) -> SetupEnv {
433 SetupEnv {
434 config_path: dir.join("config.toml"),
435 agents_dir: dir.join("agents"),
436 roots: import::Roots {
437 home: dir.join("home"),
438 os_config: dir.join("os-config"),
439 xdg_config: dir.join("home").join(".config"),
440 cwd: dir.join("cwd"),
441 },
442 env_lookup: Box::new(|_| None),
443 opener: std::sync::Arc::new(|_| true),
444 }
445 }
446
447 #[test]
450 fn a_single_non_anthropic_key_becomes_the_default_provider() {
451 let mut config = Config::default();
454 assert_eq!(config.default_provider, "anthropic");
455 apply_flags(
456 &mut config,
457 &SetupArgs {
458 openrouter_key: Some("sk-or-test".to_string()),
459 ..args()
460 },
461 );
462 assert_eq!(config.default_provider, "openrouter");
463 }
464
465 #[test]
466 fn a_reachable_default_provider_is_left_alone() {
467 let mut config = Config::default();
468 apply_flags(
469 &mut config,
470 &SetupArgs {
471 anthropic_key: Some("sk-ant-test".to_string()),
472 openrouter_key: Some("sk-or-test".to_string()),
473 ..args()
474 },
475 );
476 assert_eq!(config.default_provider, "anthropic");
477 }
478
479 #[test]
480 fn a_deliberate_default_provider_survives() {
481 let mut config = Config {
482 default_provider: "google".to_string(),
483 ..Config::default()
484 };
485 apply_flags(
486 &mut config,
487 &SetupArgs {
488 google_key: Some("AIza-test".to_string()),
489 openrouter_key: Some("sk-or-test".to_string()),
490 ..args()
491 },
492 );
493 assert_eq!(config.default_provider, "google");
494 }
495
496 #[test]
497 fn configuring_nothing_leaves_the_default_provider_untouched() {
498 let mut config = Config::default();
501 apply_flags(&mut config, &args());
502 assert_eq!(config.default_provider, "anthropic");
503 }
504
505 #[test]
506 fn ollama_is_the_last_provider_considered() {
507 let mut config = Config::default();
510 apply_flags(
511 &mut config,
512 &SetupArgs {
513 ollama_url: Some("http://localhost:11434".to_string()),
514 google_key: Some("AIza-test".to_string()),
515 ..args()
516 },
517 );
518 assert_eq!(config.default_provider, "google");
519
520 let mut ollama_only = Config::default();
521 apply_flags(
522 &mut ollama_only,
523 &SetupArgs {
524 ollama_url: Some("http://localhost:11434".to_string()),
525 ..args()
526 },
527 );
528 assert_eq!(ollama_only.default_provider, "ollama");
529 }
530
531 #[test]
532 fn the_claude_code_transport_counts_as_a_configured_provider() {
533 let mut config = Config::default();
534 apply_flags(
535 &mut config,
536 &SetupArgs {
537 claude_code: Some(true),
538 ..args()
539 },
540 );
541 assert_eq!(config.default_provider, "claude-code");
542 }
543
544 #[test]
547 fn flags_are_written_to_the_config() {
548 let dir = tempfile::tempdir().unwrap();
549 let env = env_in(dir.path());
550 let args = SetupArgs {
551 non_interactive: true,
552 anthropic_key: Some("sk-ant-x".to_string()),
553 openai_key: Some("sk-oai".to_string()),
554 google_key: Some("goog".to_string()),
555 openrouter_key: Some("sk-or".to_string()),
556 ollama_url: Some("http://box:11434".to_string()),
557 default_model: Some("m".to_string()),
558 claude_code: Some(true),
559 claude_code_effort: Some("xhigh".to_string()),
560 ..args()
561 };
562
563 run_non_interactive(&args, &env).unwrap();
564
565 let written = Config::load_from_path_public(&env.config_path).unwrap();
566 assert_eq!(
567 written.providers.anthropic_api_key.as_deref(),
568 Some("sk-ant-x")
569 );
570 assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
571 assert_eq!(written.providers.google_api_key.as_deref(), Some("goog"));
572 assert_eq!(written.openrouter_api_key.as_deref(), Some("sk-or"));
573 assert_eq!(written.ollama_base_url.as_deref(), Some("http://box:11434"));
574 assert_eq!(written.default_model.as_deref(), Some("m"));
575 assert!(written.providers.claude_code_enabled);
576 assert_eq!(
577 written.providers.claude_code_effort.as_deref(),
578 Some("xhigh")
579 );
580 }
581
582 #[test]
583 fn the_non_interactive_path_installs_agents_only_when_asked() {
584 let dir = tempfile::tempdir().unwrap();
585 let env = env_in(dir.path());
586
587 run_non_interactive(&args(), &env).unwrap();
588 assert!(!env.agents_dir.exists(), "nothing was asked for");
589
590 run_non_interactive(
591 &SetupArgs {
592 install_agents: true,
593 ..args()
594 },
595 &env,
596 )
597 .unwrap();
598 assert!(
599 env.agents_dir.join(BUNDLED_AGENTS[0].name).exists(),
600 "every bundled blueprint should land"
601 );
602
603 run_non_interactive(
605 &SetupArgs {
606 install_agents: true,
607 ..args()
608 },
609 &env,
610 )
611 .unwrap();
612 assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
613 }
614
615 #[test]
616 fn the_non_interactive_path_keeps_settings_it_was_not_given() {
617 let dir = tempfile::tempdir().unwrap();
618 let env = env_in(dir.path());
619 run_non_interactive(
620 &SetupArgs {
621 anthropic_key: Some("sk-ant-first".to_string()),
622 ..args()
623 },
624 &env,
625 )
626 .unwrap();
627
628 run_non_interactive(
629 &SetupArgs {
630 openai_key: Some("sk-oai".to_string()),
631 ..args()
632 },
633 &env,
634 )
635 .unwrap();
636
637 let written = Config::load_from_path_public(&env.config_path).unwrap();
638 assert_eq!(
639 written.providers.anthropic_api_key.as_deref(),
640 Some("sk-ant-first")
641 );
642 assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
643 }
644
645 #[test]
646 fn a_config_that_cannot_be_written_is_an_error() {
647 let dir = tempfile::tempdir().unwrap();
648 let blocked = dir.path().join("not-a-dir");
649 std::fs::write(&blocked, "").unwrap();
650 let mut env = env_in(dir.path());
651 env.config_path = blocked.join("config.toml");
652
653 assert!(run_non_interactive(&args(), &env).is_err());
654 }
655
656 #[test]
659 fn the_wizard_reads_the_config_file_and_scans_for_harnesses() {
660 let dir = tempfile::tempdir().unwrap();
661 let env = env_in(dir.path());
662 std::fs::create_dir_all(&env.roots.home).unwrap();
663 std::fs::write(
664 env.roots.home.join(".claude.json"),
665 r#"{"mcpServers":{"fs":{"command":"npx"}}}"#,
666 )
667 .unwrap();
668 run_non_interactive(
669 &SetupArgs {
670 anthropic_key: Some("sk-ant-stored".to_string()),
671 ..args()
672 },
673 &env,
674 )
675 .unwrap();
676
677 let wizard = build_wizard(&env);
678
679 assert_eq!(
680 wizard.base.providers.anthropic_api_key.as_deref(),
681 Some("sk-ant-stored")
682 );
683 assert_eq!(wizard.mcp.len(), 1);
684 assert_eq!(wizard.mcp[0].candidate.config.name, "fs");
685 }
686
687 #[test]
688 fn a_missing_config_file_starts_from_defaults() {
689 let dir = tempfile::tempdir().unwrap();
690
691 let wizard = build_wizard(&env_in(dir.path()));
692
693 assert_eq!(
694 wizard.base.default_provider,
695 Config::default().default_provider
696 );
697 }
698
699 #[tokio::test]
702 async fn the_verification_loop_answers_every_request_then_stops() {
703 let dir = tempfile::tempdir().unwrap();
704 let mut wizard = build_wizard(&env_in(dir.path()));
705 let (requests, replies) = wizard.take_verify_ends().expect("first take");
706 wizard.providers[0].selected = true;
707 wizard.providers[0].value = "sk-ant".to_string();
708 wizard.request_verification(0);
709
710 let handle = tokio::spawn(verification_loop(verify::SkipVerifier, requests, replies));
711 let sender = wizard.verify_tx.clone();
713 drop(sender);
714
715 for _ in 0..50 {
717 wizard.drain_verifications();
718 if !wizard.providers[0].checking {
719 break;
720 }
721 tokio::time::sleep(Duration::from_millis(2)).await;
722 }
723 assert!(!wizard.providers[0].checking);
724 assert_eq!(wizard.providers[0].outcome, verify::Outcome::Skipped);
725
726 drop(wizard);
727 handle.await.expect("the loop exits cleanly");
728 }
729
730 #[tokio::test]
731 async fn the_verification_loop_stops_when_nobody_is_listening() {
732 let (request_tx, request_rx) = mpsc::unbounded_channel();
734 let (reply_tx, reply_rx) = mpsc::unbounded_channel::<VerifyReply>();
735 request_tx
736 .send(VerifyRequest {
737 provider_id: "anthropic".to_string(),
738 creds: leviath_runtime::provider_creds::ProviderCreds {
739 name: "anthropic".to_string(),
740 api_key: Some("sk-ant".to_string()),
741 base_url: None,
742 model_capabilities: std::collections::HashMap::new(),
743 request_timeout_secs: Some(1),
744 rate_limit: None,
745 options: std::collections::HashMap::new(),
746 },
747 })
748 .unwrap();
749 drop(reply_rx);
750
751 verification_loop(verify::SkipVerifier, request_rx, reply_tx).await;
752 }
753
754 #[tokio::test]
757 async fn quitting_returns_no_plan() {
758 let dir = tempfile::tempdir().unwrap();
759 let mut wizard = build_wizard(&env_in(dir.path()));
760 let mut terminal = test_terminal();
761 let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
762
763 let plan = run_wizard_loop(
764 &mut wizard,
765 &mut terminal,
766 &mut events,
767 Duration::from_millis(1),
768 )
769 .await
770 .unwrap();
771
772 assert!(plan.is_none());
773 }
774
775 #[tokio::test]
776 async fn saving_returns_the_plan_the_wizard_describes() {
777 let dir = tempfile::tempdir().unwrap();
778 let mut wizard = build_wizard(&env_in(dir.path()));
779 let mut terminal = test_terminal();
780 let mut events = TestEventSource::new_with_nones(vec![
782 None,
783 Some(key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)),
784 ]);
785
786 let plan = run_wizard_loop(
787 &mut wizard,
788 &mut terminal,
789 &mut events,
790 Duration::from_millis(1),
791 )
792 .await
793 .unwrap()
794 .expect("a plan was produced");
795
796 assert_eq!(plan.agents.len(), BUNDLED_AGENTS.len());
797 }
798
799 #[tokio::test]
800 async fn non_press_and_non_key_events_are_ignored() {
801 let dir = tempfile::tempdir().unwrap();
802 let mut wizard = build_wizard(&env_in(dir.path()));
803 let mut terminal = test_terminal();
804 let release = crossterm::event::Event::Key(crossterm::event::KeyEvent::new_with_kind(
805 KeyCode::Char('q'),
806 KeyModifiers::empty(),
807 KeyEventKind::Release,
808 ));
809 let mut events = TestEventSource::new(vec![
810 release,
811 crossterm::event::Event::FocusGained,
812 crossterm::event::Event::Resize(80, 24),
813 key(KeyCode::Char('q')),
814 ]);
815
816 let plan = run_wizard_loop(
817 &mut wizard,
818 &mut terminal,
819 &mut events,
820 Duration::from_millis(1),
821 )
822 .await
823 .unwrap();
824
825 assert!(plan.is_none(), "only the real press quit");
826 }
827
828 #[tokio::test]
831 async fn a_click_is_routed_with_the_window_it_was_made_in() {
832 let dir = tempfile::tempdir().unwrap();
833 let mut wizard = build_wizard(&env_in(dir.path()));
834 wizard.enter(state::Step::Providers);
835 let mut terminal = test_terminal();
836 let size = terminal.size().expect("the test backend has a size");
837 let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
838 let row = (0..area.height)
841 .find(|y| render::row_at(area, &wizard, 4, *y) == Some(1))
842 .expect("the second provider is on screen");
843
844 let mut events = TestEventSource::new(vec![
845 crossterm::event::Event::Mouse(crossterm::event::MouseEvent {
846 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
847 column: 4,
848 row,
849 modifiers: KeyModifiers::empty(),
850 }),
851 key_with(KeyCode::Char('s'), KeyModifiers::CONTROL),
852 ]);
853
854 let plan = run_wizard_loop(
855 &mut wizard,
856 &mut terminal,
857 &mut events,
858 Duration::from_millis(1),
859 )
860 .await
861 .unwrap()
862 .expect("ctrl-s finished it");
863
864 assert!(
865 wizard.providers[1].selected,
866 "the click selected what it landed on"
867 );
868 assert!(!plan.agents.is_empty());
869 }
870
871 #[tokio::test]
873 async fn clicking_apply_and_finish_ends_the_wizard() {
874 let dir = tempfile::tempdir().unwrap();
875 let mut wizard = build_wizard(&env_in(dir.path()));
876 wizard.enter(state::Step::Review);
877 let mut terminal = test_terminal();
878 let size = terminal.size().expect("the test backend has a size");
879 let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
880 let button = wizard.nav_rows() - 1;
881 let row = (0..area.height)
882 .find(|y| render::row_at(area, &wizard, 4, *y) == Some(button))
883 .expect("the button is on screen");
884
885 let mut events = TestEventSource::new(vec![crossterm::event::Event::Mouse(
886 crossterm::event::MouseEvent {
887 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
888 column: 4,
889 row,
890 modifiers: KeyModifiers::empty(),
891 },
892 )]);
893
894 let plan = run_wizard_loop(
895 &mut wizard,
896 &mut terminal,
897 &mut events,
898 Duration::from_millis(1),
899 )
900 .await
901 .unwrap();
902
903 assert!(plan.is_some(), "the click applied the plan");
904 }
905
906 #[tokio::test]
907 async fn a_draw_failure_propagates() {
908 let dir = tempfile::tempdir().unwrap();
909 let mut wizard = build_wizard(&env_in(dir.path()));
910 let mut terminal =
911 ratatui::Terminal::new(crate::tui::TestBackendHarness::failing(80, 24)).unwrap();
912 let mut events = TestEventSource::new(vec![]);
913
914 let result = run_wizard_loop(
915 &mut wizard,
916 &mut terminal,
917 &mut events,
918 Duration::from_millis(1),
919 )
920 .await;
921
922 assert!(result.is_err());
923 }
924
925 #[tokio::test]
926 async fn an_event_source_failure_propagates() {
927 let dir = tempfile::tempdir().unwrap();
928 let mut wizard = build_wizard(&env_in(dir.path()));
929 let mut terminal = test_terminal();
930 let mut events = TestEventSource::failing();
931
932 let result = run_wizard_loop(
933 &mut wizard,
934 &mut terminal,
935 &mut events,
936 Duration::from_millis(1),
937 )
938 .await;
939
940 assert!(result.is_err());
941 }
942
943 #[tokio::test]
946 async fn saving_writes_the_config_and_installs_the_agents() {
947 let dir = tempfile::tempdir().unwrap();
948 let env = env_in(dir.path());
949 let mut wizard = build_wizard(&env);
950 let mut setup = TestSetup::new();
951 let mut events =
952 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
953
954 execute_core(&mut wizard, &env, &mut setup, &mut events)
955 .await
956 .unwrap();
957
958 assert!(env.config_path.exists());
959 assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
960 }
961
962 #[tokio::test]
963 async fn quitting_writes_nothing() {
964 let dir = tempfile::tempdir().unwrap();
965 let env = env_in(dir.path());
966 let mut wizard = build_wizard(&env);
967 let mut setup = TestSetup::new();
968 let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
969
970 execute_core(&mut wizard, &env, &mut setup, &mut events)
971 .await
972 .unwrap();
973
974 assert!(
975 !env.config_path.exists(),
976 "nothing should have been written"
977 );
978 assert!(!env.agents_dir.exists());
979 }
980
981 #[tokio::test]
982 async fn a_terminal_that_will_not_start_is_an_error() {
983 let dir = tempfile::tempdir().unwrap();
984 let env = env_in(dir.path());
985 let mut wizard = build_wizard(&env);
986 let mut events = TestEventSource::new(vec![]);
987
988 let mut enable_fails = TestSetup {
989 enable_should_fail: true,
990 create_should_fail: false,
991 draw_should_fail: false,
992 };
993 assert!(
994 execute_core(&mut wizard, &env, &mut enable_fails, &mut events)
995 .await
996 .is_err()
997 );
998
999 let mut create_fails = TestSetup {
1000 enable_should_fail: false,
1001 create_should_fail: true,
1002 draw_should_fail: false,
1003 };
1004 assert!(
1005 execute_core(&mut wizard, &env, &mut create_fails, &mut events)
1006 .await
1007 .is_err()
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn a_loop_failure_is_surfaced_after_the_terminal_is_restored() {
1013 let dir = tempfile::tempdir().unwrap();
1014 let env = env_in(dir.path());
1015 let mut wizard = build_wizard(&env);
1016 let mut setup = TestSetup::new();
1017 let mut events = TestEventSource::failing();
1018
1019 let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
1020
1021 assert!(result.is_err());
1022 assert!(!env.config_path.exists());
1023 }
1024
1025 #[tokio::test]
1026 async fn a_write_failure_after_the_wizard_is_surfaced() {
1027 let dir = tempfile::tempdir().unwrap();
1030 let mut env = env_in(dir.path());
1031 let blocked = dir.path().join("not-a-dir");
1032 std::fs::write(&blocked, "").unwrap();
1033 let mut wizard = build_wizard(&env);
1034 env.config_path = blocked.join("config.toml");
1035 let mut setup = TestSetup::new();
1036 let mut events =
1037 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
1038
1039 let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
1040
1041 assert!(result.is_err());
1042 }
1043
1044 #[tokio::test]
1045 async fn execute_with_routes_to_the_flags_path() {
1046 let dir = tempfile::tempdir().unwrap();
1047 let env = env_in(dir.path());
1048 let mut setup = TestSetup::new();
1049 let mut events = TestEventSource::new(vec![]);
1050
1051 execute_with(
1052 &SetupArgs {
1053 non_interactive: true,
1054 anthropic_key: Some("sk-ant-x".to_string()),
1055 ..args()
1056 },
1057 &env,
1058 &mut setup,
1059 &mut events,
1060 false,
1061 )
1062 .await
1063 .unwrap();
1064
1065 let written = Config::load_from_path_public(&env.config_path).unwrap();
1066 assert_eq!(
1067 written.providers.anthropic_api_key.as_deref(),
1068 Some("sk-ant-x")
1069 );
1070 }
1071
1072 #[tokio::test]
1073 async fn without_a_terminal_the_wizard_refuses_and_says_what_to_run_instead() {
1074 let dir = tempfile::tempdir().unwrap();
1077 let env = env_in(dir.path());
1078 let mut setup = TestSetup::new();
1079 let mut events = TestEventSource::new(vec![]);
1080
1081 let error = execute_with(&args(), &env, &mut setup, &mut events, false)
1082 .await
1083 .expect_err("a pipe is not a terminal");
1084
1085 let message = error.to_string();
1086 assert!(message.contains("needs a terminal"), "{message}");
1087 assert!(message.contains("--non-interactive"), "{message}");
1088 assert!(!env.config_path.exists());
1089 }
1090
1091 #[tokio::test]
1092 async fn with_a_terminal_execute_with_runs_the_wizard() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let env = env_in(dir.path());
1095 let mut setup = TestSetup::new();
1096 let mut events =
1097 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
1098
1099 execute_with(&args(), &env, &mut setup, &mut events, true)
1100 .await
1101 .unwrap();
1102
1103 assert!(env.config_path.exists());
1104 }
1105
1106 #[test]
1109 fn the_summary_covers_agents_warnings_and_the_empty_case() {
1110 report(&plan::Applied {
1111 config_path: PathBuf::from("/tmp/config.toml"),
1112 agents_installed: vec!["coder".to_string()],
1113 warnings: vec!["could not install x".to_string()],
1114 });
1115 report(&plan::Applied {
1116 config_path: PathBuf::from("/tmp/config.toml"),
1117 agents_installed: Vec::new(),
1118 warnings: Vec::new(),
1119 });
1120 }
1121
1122 #[test]
1123 fn the_next_step_names_an_installed_agent_when_there_is_one() {
1124 print_next_steps(&plan::Applied {
1125 config_path: PathBuf::from("/tmp/config.toml"),
1126 agents_installed: vec!["coder".to_string()],
1127 warnings: Vec::new(),
1128 });
1129 print_next_steps(&plan::Applied {
1130 config_path: PathBuf::from("/tmp/config.toml"),
1131 agents_installed: Vec::new(),
1132 warnings: Vec::new(),
1133 });
1134 }
1135
1136 #[test]
1137 fn the_real_agents_directory_sits_under_the_leviath_home() {
1138 assert_eq!(
1139 real_agents_dir(Some(Path::new("/home/u"))),
1140 PathBuf::from("/home/u/.leviath/agents")
1141 );
1142 assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
1144 }
1145}