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 terminal
303 .draw(|frame| render::draw(frame, wizard))
304 .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
307
308 if let Some(Event::Key(key)) = events.poll_event(tick_rate)?
309 && key.kind == KeyEventKind::Press
310 && wizard.handle_key(key) == input::Action::Save
311 {
312 wizard.finished = true;
313 }
314
315 if wizard.finished {
316 return Ok(Some(wizard.build_plan()));
317 }
318 if wizard.should_quit {
319 return Ok(None);
320 }
321 }
322}
323
324pub async fn execute_core<S: TerminalSetup, E: EventSource>(
329 wizard: &mut Wizard,
330 env: &SetupEnv,
331 setup: &mut S,
332 events: &mut E,
333) -> anyhow::Result<()> {
334 setup.enable()?;
335 let mut terminal = setup.create_terminal()?;
336 let result = run_wizard_loop(wizard, &mut terminal, events, Duration::from_millis(120)).await;
337 setup.disable();
338
339 match result? {
340 Some(plan) => {
341 let applied = plan::apply(&plan, &env.config_path, &env.agents_dir)?;
342 report(&applied);
343 print_next_steps(&applied);
344 }
345 None => println!("Setup cancelled. Nothing was written."),
346 }
347 Ok(())
348}
349
350fn print_next_steps(applied: &plan::Applied) {
352 println!();
353 match applied.agents_installed.first() {
354 Some(agent) => println!("Try it: lev run {agent} --task \"...\""),
355 None => println!("Install an agent with `lev setup`, then `lev run <agent>`."),
356 }
357}
358
359pub async fn execute_with<S: TerminalSetup, E: EventSource>(
365 args: &SetupArgs,
366 env: &SetupEnv,
367 setup: &mut S,
368 events: &mut E,
369 is_terminal: bool,
370) -> anyhow::Result<()> {
371 if args.non_interactive {
372 return run_non_interactive(args, env);
373 }
374 if !is_terminal {
375 anyhow::bail!(
376 "lev setup needs a terminal. For scripted use:\n \
377 lev setup --non-interactive --anthropic-key sk-ant-... --install-agents"
378 );
379 }
380 let mut wizard = build_wizard(env);
381 execute_core(&mut wizard, env, setup, events).await
382}
383
384pub fn real_agents_dir(home: Option<&Path>) -> PathBuf {
386 home.unwrap_or(Path::new(""))
387 .join(".leviath")
388 .join("agents")
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::bundled::BUNDLED_AGENTS;
395 use crate::tui::{TestEventSource, TestSetup, key, key_with, test_terminal};
396 use crossterm::event::{KeyCode, KeyModifiers};
397
398 fn args() -> SetupArgs {
400 SetupArgs {
401 non_interactive: false,
402 no_verify: false,
403 anthropic_key: None,
404 openai_key: None,
405 google_key: None,
406 openrouter_key: None,
407 ollama_url: None,
408 default_model: None,
409 claude_code: None,
410 claude_code_effort: None,
411 install_agents: false,
412 }
413 }
414
415 fn env_in(dir: &Path) -> SetupEnv {
418 SetupEnv {
419 config_path: dir.join("config.toml"),
420 agents_dir: dir.join("agents"),
421 roots: import::Roots {
422 home: dir.join("home"),
423 os_config: dir.join("os-config"),
424 xdg_config: dir.join("home").join(".config"),
425 cwd: dir.join("cwd"),
426 },
427 env_lookup: Box::new(|_| None),
428 opener: std::sync::Arc::new(|_| true),
429 }
430 }
431
432 #[test]
435 fn a_single_non_anthropic_key_becomes_the_default_provider() {
436 let mut config = Config::default();
439 assert_eq!(config.default_provider, "anthropic");
440 apply_flags(
441 &mut config,
442 &SetupArgs {
443 openrouter_key: Some("sk-or-test".to_string()),
444 ..args()
445 },
446 );
447 assert_eq!(config.default_provider, "openrouter");
448 }
449
450 #[test]
451 fn a_reachable_default_provider_is_left_alone() {
452 let mut config = Config::default();
453 apply_flags(
454 &mut config,
455 &SetupArgs {
456 anthropic_key: Some("sk-ant-test".to_string()),
457 openrouter_key: Some("sk-or-test".to_string()),
458 ..args()
459 },
460 );
461 assert_eq!(config.default_provider, "anthropic");
462 }
463
464 #[test]
465 fn a_deliberate_default_provider_survives() {
466 let mut config = Config {
467 default_provider: "google".to_string(),
468 ..Config::default()
469 };
470 apply_flags(
471 &mut config,
472 &SetupArgs {
473 google_key: Some("AIza-test".to_string()),
474 openrouter_key: Some("sk-or-test".to_string()),
475 ..args()
476 },
477 );
478 assert_eq!(config.default_provider, "google");
479 }
480
481 #[test]
482 fn configuring_nothing_leaves_the_default_provider_untouched() {
483 let mut config = Config::default();
486 apply_flags(&mut config, &args());
487 assert_eq!(config.default_provider, "anthropic");
488 }
489
490 #[test]
491 fn ollama_is_the_last_provider_considered() {
492 let mut config = Config::default();
495 apply_flags(
496 &mut config,
497 &SetupArgs {
498 ollama_url: Some("http://localhost:11434".to_string()),
499 google_key: Some("AIza-test".to_string()),
500 ..args()
501 },
502 );
503 assert_eq!(config.default_provider, "google");
504
505 let mut ollama_only = Config::default();
506 apply_flags(
507 &mut ollama_only,
508 &SetupArgs {
509 ollama_url: Some("http://localhost:11434".to_string()),
510 ..args()
511 },
512 );
513 assert_eq!(ollama_only.default_provider, "ollama");
514 }
515
516 #[test]
517 fn the_claude_code_transport_counts_as_a_configured_provider() {
518 let mut config = Config::default();
519 apply_flags(
520 &mut config,
521 &SetupArgs {
522 claude_code: Some(true),
523 ..args()
524 },
525 );
526 assert_eq!(config.default_provider, "claude-code");
527 }
528
529 #[test]
532 fn flags_are_written_to_the_config() {
533 let dir = tempfile::tempdir().unwrap();
534 let env = env_in(dir.path());
535 let args = SetupArgs {
536 non_interactive: true,
537 anthropic_key: Some("sk-ant-x".to_string()),
538 openai_key: Some("sk-oai".to_string()),
539 google_key: Some("goog".to_string()),
540 openrouter_key: Some("sk-or".to_string()),
541 ollama_url: Some("http://box:11434".to_string()),
542 default_model: Some("m".to_string()),
543 claude_code: Some(true),
544 claude_code_effort: Some("xhigh".to_string()),
545 ..args()
546 };
547
548 run_non_interactive(&args, &env).unwrap();
549
550 let written = Config::load_from_path_public(&env.config_path).unwrap();
551 assert_eq!(
552 written.providers.anthropic_api_key.as_deref(),
553 Some("sk-ant-x")
554 );
555 assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
556 assert_eq!(written.providers.google_api_key.as_deref(), Some("goog"));
557 assert_eq!(written.openrouter_api_key.as_deref(), Some("sk-or"));
558 assert_eq!(written.ollama_base_url.as_deref(), Some("http://box:11434"));
559 assert_eq!(written.default_model.as_deref(), Some("m"));
560 assert!(written.providers.claude_code_enabled);
561 assert_eq!(
562 written.providers.claude_code_effort.as_deref(),
563 Some("xhigh")
564 );
565 }
566
567 #[test]
568 fn the_non_interactive_path_installs_agents_only_when_asked() {
569 let dir = tempfile::tempdir().unwrap();
570 let env = env_in(dir.path());
571
572 run_non_interactive(&args(), &env).unwrap();
573 assert!(!env.agents_dir.exists(), "nothing was asked for");
574
575 run_non_interactive(
576 &SetupArgs {
577 install_agents: true,
578 ..args()
579 },
580 &env,
581 )
582 .unwrap();
583 assert!(
584 env.agents_dir.join(BUNDLED_AGENTS[0].name).exists(),
585 "every bundled blueprint should land"
586 );
587
588 run_non_interactive(
590 &SetupArgs {
591 install_agents: true,
592 ..args()
593 },
594 &env,
595 )
596 .unwrap();
597 assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
598 }
599
600 #[test]
601 fn the_non_interactive_path_keeps_settings_it_was_not_given() {
602 let dir = tempfile::tempdir().unwrap();
603 let env = env_in(dir.path());
604 run_non_interactive(
605 &SetupArgs {
606 anthropic_key: Some("sk-ant-first".to_string()),
607 ..args()
608 },
609 &env,
610 )
611 .unwrap();
612
613 run_non_interactive(
614 &SetupArgs {
615 openai_key: Some("sk-oai".to_string()),
616 ..args()
617 },
618 &env,
619 )
620 .unwrap();
621
622 let written = Config::load_from_path_public(&env.config_path).unwrap();
623 assert_eq!(
624 written.providers.anthropic_api_key.as_deref(),
625 Some("sk-ant-first")
626 );
627 assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
628 }
629
630 #[test]
631 fn a_config_that_cannot_be_written_is_an_error() {
632 let dir = tempfile::tempdir().unwrap();
633 let blocked = dir.path().join("not-a-dir");
634 std::fs::write(&blocked, "").unwrap();
635 let mut env = env_in(dir.path());
636 env.config_path = blocked.join("config.toml");
637
638 assert!(run_non_interactive(&args(), &env).is_err());
639 }
640
641 #[test]
644 fn the_wizard_reads_the_config_file_and_scans_for_harnesses() {
645 let dir = tempfile::tempdir().unwrap();
646 let env = env_in(dir.path());
647 std::fs::create_dir_all(&env.roots.home).unwrap();
648 std::fs::write(
649 env.roots.home.join(".claude.json"),
650 r#"{"mcpServers":{"fs":{"command":"npx"}}}"#,
651 )
652 .unwrap();
653 run_non_interactive(
654 &SetupArgs {
655 anthropic_key: Some("sk-ant-stored".to_string()),
656 ..args()
657 },
658 &env,
659 )
660 .unwrap();
661
662 let wizard = build_wizard(&env);
663
664 assert_eq!(
665 wizard.base.providers.anthropic_api_key.as_deref(),
666 Some("sk-ant-stored")
667 );
668 assert_eq!(wizard.mcp.len(), 1);
669 assert_eq!(wizard.mcp[0].candidate.config.name, "fs");
670 }
671
672 #[test]
673 fn a_missing_config_file_starts_from_defaults() {
674 let dir = tempfile::tempdir().unwrap();
675
676 let wizard = build_wizard(&env_in(dir.path()));
677
678 assert_eq!(
679 wizard.base.default_provider,
680 Config::default().default_provider
681 );
682 }
683
684 #[tokio::test]
687 async fn the_verification_loop_answers_every_request_then_stops() {
688 let dir = tempfile::tempdir().unwrap();
689 let mut wizard = build_wizard(&env_in(dir.path()));
690 let (requests, replies) = wizard.take_verify_ends().expect("first take");
691 wizard.providers[0].selected = true;
692 wizard.providers[0].value = "sk-ant".to_string();
693 wizard.request_verification(0);
694
695 let handle = tokio::spawn(verification_loop(verify::SkipVerifier, requests, replies));
696 let sender = wizard.verify_tx.clone();
698 drop(sender);
699
700 for _ in 0..50 {
702 wizard.drain_verifications();
703 if !wizard.providers[0].checking {
704 break;
705 }
706 tokio::time::sleep(Duration::from_millis(2)).await;
707 }
708 assert!(!wizard.providers[0].checking);
709 assert_eq!(wizard.providers[0].outcome, verify::Outcome::Skipped);
710
711 drop(wizard);
712 handle.await.expect("the loop exits cleanly");
713 }
714
715 #[tokio::test]
716 async fn the_verification_loop_stops_when_nobody_is_listening() {
717 let (request_tx, request_rx) = mpsc::unbounded_channel();
719 let (reply_tx, reply_rx) = mpsc::unbounded_channel::<VerifyReply>();
720 request_tx
721 .send(VerifyRequest {
722 provider_id: "anthropic".to_string(),
723 creds: leviath_runtime::provider_creds::ProviderCreds {
724 name: "anthropic".to_string(),
725 api_key: Some("sk-ant".to_string()),
726 base_url: None,
727 model_capabilities: std::collections::HashMap::new(),
728 request_timeout_secs: Some(1),
729 rate_limit: None,
730 options: std::collections::HashMap::new(),
731 },
732 })
733 .unwrap();
734 drop(reply_rx);
735
736 verification_loop(verify::SkipVerifier, request_rx, reply_tx).await;
737 }
738
739 #[tokio::test]
742 async fn quitting_returns_no_plan() {
743 let dir = tempfile::tempdir().unwrap();
744 let mut wizard = build_wizard(&env_in(dir.path()));
745 let mut terminal = test_terminal();
746 let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
747
748 let plan = run_wizard_loop(
749 &mut wizard,
750 &mut terminal,
751 &mut events,
752 Duration::from_millis(1),
753 )
754 .await
755 .unwrap();
756
757 assert!(plan.is_none());
758 }
759
760 #[tokio::test]
761 async fn saving_returns_the_plan_the_wizard_describes() {
762 let dir = tempfile::tempdir().unwrap();
763 let mut wizard = build_wizard(&env_in(dir.path()));
764 let mut terminal = test_terminal();
765 let mut events = TestEventSource::new_with_nones(vec![
767 None,
768 Some(key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)),
769 ]);
770
771 let plan = run_wizard_loop(
772 &mut wizard,
773 &mut terminal,
774 &mut events,
775 Duration::from_millis(1),
776 )
777 .await
778 .unwrap()
779 .expect("a plan was produced");
780
781 assert_eq!(plan.agents.len(), BUNDLED_AGENTS.len());
782 }
783
784 #[tokio::test]
785 async fn non_press_and_non_key_events_are_ignored() {
786 let dir = tempfile::tempdir().unwrap();
787 let mut wizard = build_wizard(&env_in(dir.path()));
788 let mut terminal = test_terminal();
789 let release = crossterm::event::Event::Key(crossterm::event::KeyEvent::new_with_kind(
790 KeyCode::Char('q'),
791 KeyModifiers::empty(),
792 KeyEventKind::Release,
793 ));
794 let mut events = TestEventSource::new(vec![
795 release,
796 crossterm::event::Event::FocusGained,
797 crossterm::event::Event::Resize(80, 24),
798 key(KeyCode::Char('q')),
799 ]);
800
801 let plan = run_wizard_loop(
802 &mut wizard,
803 &mut terminal,
804 &mut events,
805 Duration::from_millis(1),
806 )
807 .await
808 .unwrap();
809
810 assert!(plan.is_none(), "only the real press quit");
811 }
812
813 #[tokio::test]
814 async fn a_draw_failure_propagates() {
815 let dir = tempfile::tempdir().unwrap();
816 let mut wizard = build_wizard(&env_in(dir.path()));
817 let mut terminal =
818 ratatui::Terminal::new(crate::tui::TestBackendHarness::failing(80, 24)).unwrap();
819 let mut events = TestEventSource::new(vec![]);
820
821 let result = run_wizard_loop(
822 &mut wizard,
823 &mut terminal,
824 &mut events,
825 Duration::from_millis(1),
826 )
827 .await;
828
829 assert!(result.is_err());
830 }
831
832 #[tokio::test]
833 async fn an_event_source_failure_propagates() {
834 let dir = tempfile::tempdir().unwrap();
835 let mut wizard = build_wizard(&env_in(dir.path()));
836 let mut terminal = test_terminal();
837 let mut events = TestEventSource::failing();
838
839 let result = run_wizard_loop(
840 &mut wizard,
841 &mut terminal,
842 &mut events,
843 Duration::from_millis(1),
844 )
845 .await;
846
847 assert!(result.is_err());
848 }
849
850 #[tokio::test]
853 async fn saving_writes_the_config_and_installs_the_agents() {
854 let dir = tempfile::tempdir().unwrap();
855 let env = env_in(dir.path());
856 let mut wizard = build_wizard(&env);
857 let mut setup = TestSetup::new();
858 let mut events =
859 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
860
861 execute_core(&mut wizard, &env, &mut setup, &mut events)
862 .await
863 .unwrap();
864
865 assert!(env.config_path.exists());
866 assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
867 }
868
869 #[tokio::test]
870 async fn quitting_writes_nothing() {
871 let dir = tempfile::tempdir().unwrap();
872 let env = env_in(dir.path());
873 let mut wizard = build_wizard(&env);
874 let mut setup = TestSetup::new();
875 let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
876
877 execute_core(&mut wizard, &env, &mut setup, &mut events)
878 .await
879 .unwrap();
880
881 assert!(
882 !env.config_path.exists(),
883 "nothing should have been written"
884 );
885 assert!(!env.agents_dir.exists());
886 }
887
888 #[tokio::test]
889 async fn a_terminal_that_will_not_start_is_an_error() {
890 let dir = tempfile::tempdir().unwrap();
891 let env = env_in(dir.path());
892 let mut wizard = build_wizard(&env);
893 let mut events = TestEventSource::new(vec![]);
894
895 let mut enable_fails = TestSetup {
896 enable_should_fail: true,
897 create_should_fail: false,
898 draw_should_fail: false,
899 };
900 assert!(
901 execute_core(&mut wizard, &env, &mut enable_fails, &mut events)
902 .await
903 .is_err()
904 );
905
906 let mut create_fails = TestSetup {
907 enable_should_fail: false,
908 create_should_fail: true,
909 draw_should_fail: false,
910 };
911 assert!(
912 execute_core(&mut wizard, &env, &mut create_fails, &mut events)
913 .await
914 .is_err()
915 );
916 }
917
918 #[tokio::test]
919 async fn a_loop_failure_is_surfaced_after_the_terminal_is_restored() {
920 let dir = tempfile::tempdir().unwrap();
921 let env = env_in(dir.path());
922 let mut wizard = build_wizard(&env);
923 let mut setup = TestSetup::new();
924 let mut events = TestEventSource::failing();
925
926 let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
927
928 assert!(result.is_err());
929 assert!(!env.config_path.exists());
930 }
931
932 #[tokio::test]
933 async fn a_write_failure_after_the_wizard_is_surfaced() {
934 let dir = tempfile::tempdir().unwrap();
937 let mut env = env_in(dir.path());
938 let blocked = dir.path().join("not-a-dir");
939 std::fs::write(&blocked, "").unwrap();
940 let mut wizard = build_wizard(&env);
941 env.config_path = blocked.join("config.toml");
942 let mut setup = TestSetup::new();
943 let mut events =
944 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
945
946 let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
947
948 assert!(result.is_err());
949 }
950
951 #[tokio::test]
952 async fn execute_with_routes_to_the_flags_path() {
953 let dir = tempfile::tempdir().unwrap();
954 let env = env_in(dir.path());
955 let mut setup = TestSetup::new();
956 let mut events = TestEventSource::new(vec![]);
957
958 execute_with(
959 &SetupArgs {
960 non_interactive: true,
961 anthropic_key: Some("sk-ant-x".to_string()),
962 ..args()
963 },
964 &env,
965 &mut setup,
966 &mut events,
967 false,
968 )
969 .await
970 .unwrap();
971
972 let written = Config::load_from_path_public(&env.config_path).unwrap();
973 assert_eq!(
974 written.providers.anthropic_api_key.as_deref(),
975 Some("sk-ant-x")
976 );
977 }
978
979 #[tokio::test]
980 async fn without_a_terminal_the_wizard_refuses_and_says_what_to_run_instead() {
981 let dir = tempfile::tempdir().unwrap();
984 let env = env_in(dir.path());
985 let mut setup = TestSetup::new();
986 let mut events = TestEventSource::new(vec![]);
987
988 let error = execute_with(&args(), &env, &mut setup, &mut events, false)
989 .await
990 .expect_err("a pipe is not a terminal");
991
992 let message = error.to_string();
993 assert!(message.contains("needs a terminal"), "{message}");
994 assert!(message.contains("--non-interactive"), "{message}");
995 assert!(!env.config_path.exists());
996 }
997
998 #[tokio::test]
999 async fn with_a_terminal_execute_with_runs_the_wizard() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let env = env_in(dir.path());
1002 let mut setup = TestSetup::new();
1003 let mut events =
1004 TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
1005
1006 execute_with(&args(), &env, &mut setup, &mut events, true)
1007 .await
1008 .unwrap();
1009
1010 assert!(env.config_path.exists());
1011 }
1012
1013 #[test]
1016 fn the_summary_covers_agents_warnings_and_the_empty_case() {
1017 report(&plan::Applied {
1018 config_path: PathBuf::from("/tmp/config.toml"),
1019 agents_installed: vec!["coder".to_string()],
1020 warnings: vec!["could not install x".to_string()],
1021 });
1022 report(&plan::Applied {
1023 config_path: PathBuf::from("/tmp/config.toml"),
1024 agents_installed: Vec::new(),
1025 warnings: Vec::new(),
1026 });
1027 }
1028
1029 #[test]
1030 fn the_next_step_names_an_installed_agent_when_there_is_one() {
1031 print_next_steps(&plan::Applied {
1032 config_path: PathBuf::from("/tmp/config.toml"),
1033 agents_installed: vec!["coder".to_string()],
1034 warnings: Vec::new(),
1035 });
1036 print_next_steps(&plan::Applied {
1037 config_path: PathBuf::from("/tmp/config.toml"),
1038 agents_installed: Vec::new(),
1039 warnings: Vec::new(),
1040 });
1041 }
1042
1043 #[test]
1044 fn the_real_agents_directory_sits_under_the_leviath_home() {
1045 assert_eq!(
1046 real_agents_dir(Some(Path::new("/home/u"))),
1047 PathBuf::from("/home/u/.leviath/agents")
1048 );
1049 assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
1051 }
1052}