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)]
55pub struct SetupArgs {
56 #[arg(long)]
58 pub non_interactive: bool,
59
60 #[arg(long)]
62 pub no_verify: bool,
63
64 #[arg(long)]
66 pub anthropic_key: Option<String>,
67
68 #[arg(long)]
70 pub openai_key: Option<String>,
71
72 #[arg(long)]
74 pub google_key: Option<String>,
75
76 #[arg(long)]
78 pub openrouter_key: Option<String>,
79
80 #[arg(long)]
82 pub ollama_url: Option<String>,
83
84 #[arg(long)]
86 pub default_model: Option<String>,
87
88 #[arg(long)]
92 pub claude_code: Option<bool>,
93
94 #[arg(long)]
97 pub claude_code_effort: Option<String>,
98
99 #[arg(long)]
101 pub install_agents: bool,
102}
103
104pub type EnvLookup = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
107
108pub struct SetupEnv {
111 pub config_path: PathBuf,
112 pub agents_dir: PathBuf,
113 pub roots: import::Roots,
115 pub env_lookup: EnvLookup,
117 pub opener: leviath_mcp::BrowserOpener,
119}
120
121pub fn run_non_interactive(args: &SetupArgs, env: &SetupEnv) -> anyhow::Result<()> {
130 let mut config = Config::load_from_path_public(&env.config_path).unwrap_or_default();
131 apply_flags(&mut config, args);
132
133 let agents = if args.install_agents {
134 crate::bundled::plan_agent_actions(&env.agents_dir)
135 .into_iter()
136 .filter(|(_, action)| action.is_change())
137 .map(|(agent, _)| agent)
138 .collect()
139 } else {
140 Vec::new()
141 };
142
143 let applied = plan::apply(
144 &plan::SetupPlan { config, agents },
145 &env.config_path,
146 &env.agents_dir,
147 )?;
148 report(&applied);
149 Ok(())
150}
151
152fn report(applied: &plan::Applied) {
155 println!("Config saved to {}", applied.config_path.display());
156 if !applied.agents_installed.is_empty() {
157 println!(
158 "Installed {} agent(s): {}",
159 applied.agents_installed.len(),
160 applied.agents_installed.join(", ")
161 );
162 }
163 for warning in &applied.warnings {
164 println!(" Warning: {warning}");
165 }
166}
167
168fn apply_flags(config: &mut Config, args: &SetupArgs) {
170 if let Some(ref k) = args.anthropic_key {
171 config.providers.anthropic_api_key = Some(k.clone());
172 }
173 if let Some(ref k) = args.openai_key {
174 config.providers.openai_api_key = Some(k.clone());
175 }
176 if let Some(ref k) = args.google_key {
177 config.providers.google_api_key = Some(k.clone());
178 }
179 if let Some(ref k) = args.openrouter_key {
180 config.openrouter_api_key = Some(k.clone());
181 }
182 if let Some(ref u) = args.ollama_url {
183 config.ollama_base_url = Some(u.clone());
184 }
185 if let Some(ref m) = args.default_model {
186 config.default_model = Some(m.clone());
187 }
188 if let Some(enabled) = args.claude_code {
189 config.providers.claude_code_enabled = enabled;
190 }
191 if let Some(ref e) = args.claude_code_effort {
192 config.providers.claude_code_effort = Some(e.clone());
193 }
194 retarget_default_provider(config);
195}
196
197fn configured_providers(config: &Config) -> Vec<&'static str> {
203 [
204 ("anthropic", config.providers.anthropic_api_key.is_some()),
205 ("openai", config.providers.openai_api_key.is_some()),
206 ("google", config.providers.google_api_key.is_some()),
207 ("openrouter", config.openrouter_api_key.is_some()),
208 ("claude-code", config.providers.claude_code_enabled),
209 ("ollama", config.ollama_base_url.is_some()),
210 ]
211 .into_iter()
212 .filter(|(_, configured)| *configured)
213 .map(|(id, _)| id)
214 .collect()
215}
216
217fn retarget_default_provider(config: &mut Config) {
228 let configured = configured_providers(config);
229 if configured.contains(&config.default_provider.as_str()) {
230 return;
231 }
232 if let Some(first) = configured.first() {
233 config.default_provider = (*first).to_string();
234 }
235}
236
237pub fn build_wizard(env: &SetupEnv) -> Wizard {
245 let base = Config::load_from_path_public(&env.config_path).unwrap_or_default();
246 let (candidates, errors) = state::candidates_from_scans(import::scan(&env.roots));
247 Wizard::new(
248 base,
249 &env.env_lookup,
250 candidates,
251 errors,
252 &env.agents_dir,
253 env.opener.clone(),
254 )
255}
256
257pub async fn verification_loop<V: ProviderVerifier>(
264 verifier: V,
265 mut requests: mpsc::UnboundedReceiver<VerifyRequest>,
266 replies: mpsc::UnboundedSender<VerifyReply>,
267) {
268 while let Some(request) = requests.recv().await {
269 let outcome = verifier.verify(&request.creds).await;
270 if replies
272 .send(VerifyReply {
273 provider_id: request.provider_id,
274 outcome,
275 })
276 .is_err()
277 {
278 return;
279 }
280 }
281}
282
283pub async fn run_wizard_loop<B: ratatui::backend::Backend>(
289 wizard: &mut Wizard,
290 terminal: &mut Terminal<B>,
291 events: &mut impl EventSource,
292 tick_rate: Duration,
293) -> anyhow::Result<Option<plan::SetupPlan>> {
294 loop {
295 wizard.ticks += 1;
296 wizard.drain_verifications();
297 terminal
298 .draw(|frame| render::draw(frame, wizard))
299 .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
302
303 if let Some(Event::Key(key)) = events.poll_event(tick_rate)?
304 && key.kind == KeyEventKind::Press
305 && wizard.handle_key(key) == input::Action::Save
306 {
307 wizard.finished = true;
308 }
309
310 if wizard.finished {
311 return Ok(Some(wizard.build_plan()));
312 }
313 if wizard.should_quit {
314 return Ok(None);
315 }
316 }
317}
318
319pub async fn execute_core<S: TerminalSetup, E: EventSource>(
324 wizard: &mut Wizard,
325 env: &SetupEnv,
326 setup: &mut S,
327 events: &mut E,
328) -> anyhow::Result<()> {
329 setup.enable()?;
330 let mut terminal = setup.create_terminal()?;
331 let result = run_wizard_loop(wizard, &mut terminal, events, Duration::from_millis(120)).await;
332 setup.disable();
333
334 match result? {
335 Some(plan) => {
336 let applied = plan::apply(&plan, &env.config_path, &env.agents_dir)?;
337 report(&applied);
338 print_next_steps(&applied);
339 }
340 None => println!("Setup cancelled. Nothing was written."),
341 }
342 Ok(())
343}
344
345fn print_next_steps(applied: &plan::Applied) {
347 println!();
348 match applied.agents_installed.first() {
349 Some(agent) => println!("Try it: lev run {agent} --task \"...\""),
350 None => println!("Install an agent with `lev setup`, then `lev run <agent>`."),
351 }
352}
353
354pub async fn execute_with<S: TerminalSetup, E: EventSource>(
360 args: &SetupArgs,
361 env: &SetupEnv,
362 setup: &mut S,
363 events: &mut E,
364 is_terminal: bool,
365) -> anyhow::Result<()> {
366 if args.non_interactive {
367 return run_non_interactive(args, env);
368 }
369 if !is_terminal {
370 anyhow::bail!(
371 "lev setup needs a terminal. For scripted use:\n \
372 lev setup --non-interactive --anthropic-key sk-ant-... --install-agents"
373 );
374 }
375 let mut wizard = build_wizard(env);
376 execute_core(&mut wizard, env, setup, events).await
377}
378
379pub fn real_agents_dir(home: Option<&Path>) -> PathBuf {
381 home.unwrap_or(Path::new(""))
382 .join(".leviath")
383 .join("agents")
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::bundled::BUNDLED_AGENTS;
390 use crate::tui::{TestEventSource, TestSetup, key, key_with, test_terminal};
391 use crossterm::event::{KeyCode, KeyModifiers};
392
393 fn args() -> SetupArgs {
395 SetupArgs {
396 non_interactive: false,
397 no_verify: false,
398 anthropic_key: None,
399 openai_key: None,
400 google_key: None,
401 openrouter_key: None,
402 ollama_url: None,
403 default_model: None,
404 claude_code: None,
405 claude_code_effort: None,
406 install_agents: false,
407 }
408 }
409
410 fn env_in(dir: &Path) -> SetupEnv {
413 SetupEnv {
414 config_path: dir.join("config.toml"),
415 agents_dir: dir.join("agents"),
416 roots: import::Roots {
417 home: dir.join("home"),
418 os_config: dir.join("os-config"),
419 xdg_config: dir.join("home").join(".config"),
420 cwd: dir.join("cwd"),
421 },
422 env_lookup: Box::new(|_| None),
423 opener: std::sync::Arc::new(|_| true),
424 }
425 }
426
427 #[test]
430 fn a_single_non_anthropic_key_becomes_the_default_provider() {
431 let mut config = Config::default();
434 assert_eq!(config.default_provider, "anthropic");
435 apply_flags(
436 &mut config,
437 &SetupArgs {
438 openrouter_key: Some("sk-or-test".to_string()),
439 ..args()
440 },
441 );
442 assert_eq!(config.default_provider, "openrouter");
443 }
444
445 #[test]
446 fn a_reachable_default_provider_is_left_alone() {
447 let mut config = Config::default();
448 apply_flags(
449 &mut config,
450 &SetupArgs {
451 anthropic_key: Some("sk-ant-test".to_string()),
452 openrouter_key: Some("sk-or-test".to_string()),
453 ..args()
454 },
455 );
456 assert_eq!(config.default_provider, "anthropic");
457 }
458
459 #[test]
460 fn a_deliberate_default_provider_survives() {
461 let mut config = Config {
462 default_provider: "google".to_string(),
463 ..Config::default()
464 };
465 apply_flags(
466 &mut config,
467 &SetupArgs {
468 google_key: Some("AIza-test".to_string()),
469 openrouter_key: Some("sk-or-test".to_string()),
470 ..args()
471 },
472 );
473 assert_eq!(config.default_provider, "google");
474 }
475
476 #[test]
477 fn configuring_nothing_leaves_the_default_provider_untouched() {
478 let mut config = Config::default();
481 apply_flags(&mut config, &args());
482 assert_eq!(config.default_provider, "anthropic");
483 }
484
485 #[test]
486 fn ollama_is_the_last_provider_considered() {
487 let mut config = Config::default();
490 apply_flags(
491 &mut config,
492 &SetupArgs {
493 ollama_url: Some("http://localhost:11434".to_string()),
494 google_key: Some("AIza-test".to_string()),
495 ..args()
496 },
497 );
498 assert_eq!(config.default_provider, "google");
499
500 let mut ollama_only = Config::default();
501 apply_flags(
502 &mut ollama_only,
503 &SetupArgs {
504 ollama_url: Some("http://localhost:11434".to_string()),
505 ..args()
506 },
507 );
508 assert_eq!(ollama_only.default_provider, "ollama");
509 }
510
511 #[test]
512 fn the_claude_code_transport_counts_as_a_configured_provider() {
513 let mut config = Config::default();
514 apply_flags(
515 &mut config,
516 &SetupArgs {
517 claude_code: Some(true),
518 ..args()
519 },
520 );
521 assert_eq!(config.default_provider, "claude-code");
522 }
523
524 #[test]
527 fn flags_are_written_to_the_config() {
528 let dir = tempfile::tempdir().unwrap();
529 let env = env_in(dir.path());
530 let args = SetupArgs {
531 non_interactive: true,
532 anthropic_key: Some("sk-ant-x".to_string()),
533 openai_key: Some("sk-oai".to_string()),
534 google_key: Some("goog".to_string()),
535 openrouter_key: Some("sk-or".to_string()),
536 ollama_url: Some("http://box:11434".to_string()),
537 default_model: Some("m".to_string()),
538 claude_code: Some(true),
539 claude_code_effort: Some("xhigh".to_string()),
540 ..args()
541 };
542
543 run_non_interactive(&args, &env).unwrap();
544
545 let written = Config::load_from_path_public(&env.config_path).unwrap();
546 assert_eq!(
547 written.providers.anthropic_api_key.as_deref(),
548 Some("sk-ant-x")
549 );
550 assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
551 assert_eq!(written.providers.google_api_key.as_deref(), Some("goog"));
552 assert_eq!(written.openrouter_api_key.as_deref(), Some("sk-or"));
553 assert_eq!(written.ollama_base_url.as_deref(), Some("http://box:11434"));
554 assert_eq!(written.default_model.as_deref(), Some("m"));
555 assert!(written.providers.claude_code_enabled);
556 assert_eq!(
557 written.providers.claude_code_effort.as_deref(),
558 Some("xhigh")
559 );
560 }
561
562 #[test]
563 fn the_non_interactive_path_installs_agents_only_when_asked() {
564 let dir = tempfile::tempdir().unwrap();
565 let env = env_in(dir.path());
566
567 run_non_interactive(&args(), &env).unwrap();
568 assert!(!env.agents_dir.exists(), "nothing was asked for");
569
570 run_non_interactive(
571 &SetupArgs {
572 install_agents: true,
573 ..args()
574 },
575 &env,
576 )
577 .unwrap();
578 assert!(
579 env.agents_dir.join(BUNDLED_AGENTS[0].name).exists(),
580 "every bundled blueprint should land"
581 );
582
583 run_non_interactive(
585 &SetupArgs {
586 install_agents: true,
587 ..args()
588 },
589 &env,
590 )
591 .unwrap();
592 assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
593 }
594
595 #[test]
596 fn the_non_interactive_path_keeps_settings_it_was_not_given() {
597 let dir = tempfile::tempdir().unwrap();
598 let env = env_in(dir.path());
599 run_non_interactive(
600 &SetupArgs {
601 anthropic_key: Some("sk-ant-first".to_string()),
602 ..args()
603 },
604 &env,
605 )
606 .unwrap();
607
608 run_non_interactive(
609 &SetupArgs {
610 openai_key: Some("sk-oai".to_string()),
611 ..args()
612 },
613 &env,
614 )
615 .unwrap();
616
617 let written = Config::load_from_path_public(&env.config_path).unwrap();
618 assert_eq!(
619 written.providers.anthropic_api_key.as_deref(),
620 Some("sk-ant-first")
621 );
622 assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
623 }
624
625 #[test]
626 fn a_config_that_cannot_be_written_is_an_error() {
627 let dir = tempfile::tempdir().unwrap();
628 let blocked = dir.path().join("not-a-dir");
629 std::fs::write(&blocked, "").unwrap();
630 let mut env = env_in(dir.path());
631 env.config_path = blocked.join("config.toml");
632
633 assert!(run_non_interactive(&args(), &env).is_err());
634 }
635
636 #[test]
639 fn the_wizard_reads_the_config_file_and_scans_for_harnesses() {
640 let dir = tempfile::tempdir().unwrap();
641 let env = env_in(dir.path());
642 std::fs::create_dir_all(&env.roots.home).unwrap();
643 std::fs::write(
644 env.roots.home.join(".claude.json"),
645 r#"{"mcpServers":{"fs":{"command":"npx"}}}"#,
646 )
647 .unwrap();
648 run_non_interactive(
649 &SetupArgs {
650 anthropic_key: Some("sk-ant-stored".to_string()),
651 ..args()
652 },
653 &env,
654 )
655 .unwrap();
656
657 let wizard = build_wizard(&env);
658
659 assert_eq!(
660 wizard.base.providers.anthropic_api_key.as_deref(),
661 Some("sk-ant-stored")
662 );
663 assert_eq!(wizard.mcp.len(), 1);
664 assert_eq!(wizard.mcp[0].candidate.config.name, "fs");
665 }
666
667 #[test]
668 fn a_missing_config_file_starts_from_defaults() {
669 let dir = tempfile::tempdir().unwrap();
670
671 let wizard = build_wizard(&env_in(dir.path()));
672
673 assert_eq!(
674 wizard.base.default_provider,
675 Config::default().default_provider
676 );
677 }
678
679 #[tokio::test]
682 async fn the_verification_loop_answers_every_request_then_stops() {
683 let dir = tempfile::tempdir().unwrap();
684 let mut wizard = build_wizard(&env_in(dir.path()));
685 let (requests, replies) = wizard.take_verify_ends().expect("first take");
686 wizard.providers[0].selected = true;
687 wizard.providers[0].value = "sk-ant".to_string();
688 wizard.request_verification(0);
689
690 let handle = tokio::spawn(verification_loop(verify::SkipVerifier, requests, replies));
691 let sender = wizard.verify_tx.clone();
693 drop(sender);
694
695 for _ in 0..50 {
697 wizard.drain_verifications();
698 if !wizard.providers[0].checking {
699 break;
700 }
701 tokio::time::sleep(Duration::from_millis(2)).await;
702 }
703 assert!(!wizard.providers[0].checking);
704 assert_eq!(wizard.providers[0].outcome, verify::Outcome::Skipped);
705
706 drop(wizard);
707 handle.await.expect("the loop exits cleanly");
708 }
709
710 #[tokio::test]
711 async fn the_verification_loop_stops_when_nobody_is_listening() {
712 let (request_tx, request_rx) = mpsc::unbounded_channel();
714 let (reply_tx, reply_rx) = mpsc::unbounded_channel::<VerifyReply>();
715 request_tx
716 .send(VerifyRequest {
717 provider_id: "anthropic".to_string(),
718 creds: leviath_runtime::provider_creds::ProviderCreds {
719 name: "anthropic".to_string(),
720 api_key: Some("sk-ant".to_string()),
721 base_url: None,
722 model_capabilities: std::collections::HashMap::new(),
723 request_timeout_secs: Some(1),
724 rate_limit: None,
725 options: std::collections::HashMap::new(),
726 },
727 })
728 .unwrap();
729 drop(reply_rx);
730
731 verification_loop(verify::SkipVerifier, request_rx, reply_tx).await;
732 }
733
734 #[tokio::test]
737 async fn quitting_returns_no_plan() {
738 let dir = tempfile::tempdir().unwrap();
739 let mut wizard = build_wizard(&env_in(dir.path()));
740 let mut terminal = test_terminal();
741 let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
742
743 let plan = run_wizard_loop(
744 &mut wizard,
745 &mut terminal,
746 &mut events,
747 Duration::from_millis(1),
748 )
749 .await
750 .unwrap();
751
752 assert!(plan.is_none());
753 }
754
755 #[tokio::test]
756 async fn saving_returns_the_plan_the_wizard_describes() {
757 let dir = tempfile::tempdir().unwrap();
758 let mut wizard = build_wizard(&env_in(dir.path()));
759 let mut terminal = test_terminal();
760 let mut events = TestEventSource::new_with_nones(vec![
762 None,
763 Some(key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)),
764 ]);
765
766 let plan = run_wizard_loop(
767 &mut wizard,
768 &mut terminal,
769 &mut events,
770 Duration::from_millis(1),
771 )
772 .await
773 .unwrap()
774 .expect("a plan was produced");
775
776 assert_eq!(plan.agents.len(), BUNDLED_AGENTS.len());
777 }
778
779 #[tokio::test]
780 async fn non_press_and_non_key_events_are_ignored() {
781 let dir = tempfile::tempdir().unwrap();
782 let mut wizard = build_wizard(&env_in(dir.path()));
783 let mut terminal = test_terminal();
784 let release = crossterm::event::Event::Key(crossterm::event::KeyEvent::new_with_kind(
785 KeyCode::Char('q'),
786 KeyModifiers::empty(),
787 KeyEventKind::Release,
788 ));
789 let mut events = TestEventSource::new(vec![
790 release,
791 crossterm::event::Event::FocusGained,
792 crossterm::event::Event::Resize(80, 24),
793 key(KeyCode::Char('q')),
794 ]);
795
796 let plan = run_wizard_loop(
797 &mut wizard,
798 &mut terminal,
799 &mut events,
800 Duration::from_millis(1),
801 )
802 .await
803 .unwrap();
804
805 assert!(plan.is_none(), "only the real press quit");
806 }
807
808 #[tokio::test]
809 async fn a_draw_failure_propagates() {
810 let dir = tempfile::tempdir().unwrap();
811 let mut wizard = build_wizard(&env_in(dir.path()));
812 let mut terminal =
813 ratatui::Terminal::new(crate::tui::TestBackendHarness::failing(80, 24)).unwrap();
814 let mut events = TestEventSource::new(vec![]);
815
816 let result = run_wizard_loop(
817 &mut wizard,
818 &mut terminal,
819 &mut events,
820 Duration::from_millis(1),
821 )
822 .await;
823
824 assert!(result.is_err());
825 }
826
827 #[tokio::test]
828 async fn an_event_source_failure_propagates() {
829 let dir = tempfile::tempdir().unwrap();
830 let mut wizard = build_wizard(&env_in(dir.path()));
831 let mut terminal = test_terminal();
832 let mut events = TestEventSource::failing();
833
834 let result = run_wizard_loop(
835 &mut wizard,
836 &mut terminal,
837 &mut events,
838 Duration::from_millis(1),
839 )
840 .await;
841
842 assert!(result.is_err());
843 }
844
845 #[tokio::test]
848 async fn saving_writes_the_config_and_installs_the_agents() {
849 let dir = tempfile::tempdir().unwrap();
850 let env = env_in(dir.path());
851 let mut wizard = build_wizard(&env);
852 let mut setup = TestSetup::new();
853 let mut events =
854 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
855
856 execute_core(&mut wizard, &env, &mut setup, &mut events)
857 .await
858 .unwrap();
859
860 assert!(env.config_path.exists());
861 assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
862 }
863
864 #[tokio::test]
865 async fn quitting_writes_nothing() {
866 let dir = tempfile::tempdir().unwrap();
867 let env = env_in(dir.path());
868 let mut wizard = build_wizard(&env);
869 let mut setup = TestSetup::new();
870 let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
871
872 execute_core(&mut wizard, &env, &mut setup, &mut events)
873 .await
874 .unwrap();
875
876 assert!(
877 !env.config_path.exists(),
878 "nothing should have been written"
879 );
880 assert!(!env.agents_dir.exists());
881 }
882
883 #[tokio::test]
884 async fn a_terminal_that_will_not_start_is_an_error() {
885 let dir = tempfile::tempdir().unwrap();
886 let env = env_in(dir.path());
887 let mut wizard = build_wizard(&env);
888 let mut events = TestEventSource::new(vec![]);
889
890 let mut enable_fails = TestSetup {
891 enable_should_fail: true,
892 create_should_fail: false,
893 };
894 assert!(
895 execute_core(&mut wizard, &env, &mut enable_fails, &mut events)
896 .await
897 .is_err()
898 );
899
900 let mut create_fails = TestSetup {
901 enable_should_fail: false,
902 create_should_fail: true,
903 };
904 assert!(
905 execute_core(&mut wizard, &env, &mut create_fails, &mut events)
906 .await
907 .is_err()
908 );
909 }
910
911 #[tokio::test]
912 async fn a_loop_failure_is_surfaced_after_the_terminal_is_restored() {
913 let dir = tempfile::tempdir().unwrap();
914 let env = env_in(dir.path());
915 let mut wizard = build_wizard(&env);
916 let mut setup = TestSetup::new();
917 let mut events = TestEventSource::failing();
918
919 let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
920
921 assert!(result.is_err());
922 assert!(!env.config_path.exists());
923 }
924
925 #[tokio::test]
926 async fn a_write_failure_after_the_wizard_is_surfaced() {
927 let dir = tempfile::tempdir().unwrap();
930 let mut env = env_in(dir.path());
931 let blocked = dir.path().join("not-a-dir");
932 std::fs::write(&blocked, "").unwrap();
933 let mut wizard = build_wizard(&env);
934 env.config_path = blocked.join("config.toml");
935 let mut setup = TestSetup::new();
936 let mut events =
937 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
938
939 let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
940
941 assert!(result.is_err());
942 }
943
944 #[tokio::test]
945 async fn execute_with_routes_to_the_flags_path() {
946 let dir = tempfile::tempdir().unwrap();
947 let env = env_in(dir.path());
948 let mut setup = TestSetup::new();
949 let mut events = TestEventSource::new(vec![]);
950
951 execute_with(
952 &SetupArgs {
953 non_interactive: true,
954 anthropic_key: Some("sk-ant-x".to_string()),
955 ..args()
956 },
957 &env,
958 &mut setup,
959 &mut events,
960 false,
961 )
962 .await
963 .unwrap();
964
965 let written = Config::load_from_path_public(&env.config_path).unwrap();
966 assert_eq!(
967 written.providers.anthropic_api_key.as_deref(),
968 Some("sk-ant-x")
969 );
970 }
971
972 #[tokio::test]
973 async fn without_a_terminal_the_wizard_refuses_and_says_what_to_run_instead() {
974 let dir = tempfile::tempdir().unwrap();
977 let env = env_in(dir.path());
978 let mut setup = TestSetup::new();
979 let mut events = TestEventSource::new(vec![]);
980
981 let error = execute_with(&args(), &env, &mut setup, &mut events, false)
982 .await
983 .expect_err("a pipe is not a terminal");
984
985 let message = error.to_string();
986 assert!(message.contains("needs a terminal"), "{message}");
987 assert!(message.contains("--non-interactive"), "{message}");
988 assert!(!env.config_path.exists());
989 }
990
991 #[tokio::test]
992 async fn with_a_terminal_execute_with_runs_the_wizard() {
993 let dir = tempfile::tempdir().unwrap();
994 let env = env_in(dir.path());
995 let mut setup = TestSetup::new();
996 let mut events =
997 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
998
999 execute_with(&args(), &env, &mut setup, &mut events, true)
1000 .await
1001 .unwrap();
1002
1003 assert!(env.config_path.exists());
1004 }
1005
1006 #[test]
1009 fn the_summary_covers_agents_warnings_and_the_empty_case() {
1010 report(&plan::Applied {
1011 config_path: PathBuf::from("/tmp/config.toml"),
1012 agents_installed: vec!["coder".to_string()],
1013 warnings: vec!["could not install x".to_string()],
1014 });
1015 report(&plan::Applied {
1016 config_path: PathBuf::from("/tmp/config.toml"),
1017 agents_installed: Vec::new(),
1018 warnings: Vec::new(),
1019 });
1020 }
1021
1022 #[test]
1023 fn the_next_step_names_an_installed_agent_when_there_is_one() {
1024 print_next_steps(&plan::Applied {
1025 config_path: PathBuf::from("/tmp/config.toml"),
1026 agents_installed: vec!["coder".to_string()],
1027 warnings: Vec::new(),
1028 });
1029 print_next_steps(&plan::Applied {
1030 config_path: PathBuf::from("/tmp/config.toml"),
1031 agents_installed: Vec::new(),
1032 warnings: Vec::new(),
1033 });
1034 }
1035
1036 #[test]
1037 fn the_real_agents_directory_sits_under_the_leviath_home() {
1038 assert_eq!(
1039 real_agents_dir(Some(Path::new("/home/u"))),
1040 PathBuf::from("/home/u/.leviath/agents")
1041 );
1042 assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
1044 }
1045}