Skip to main content

leviath_cli/commands/setup/
mod.rs

1//! `lev setup` - the guided path from "just installed Leviath" to "ready to run
2//! an agent".
3//!
4//! The previous version was nine `print!`/`read_line` prompts in a fixed order:
5//! it asked every user for four API keys whether they had them or not, echoed
6//! them in plaintext, touched about eight of `Config`'s twenty-odd fields, and
7//! knew nothing about MCP servers or agent blueprints. It also ended by
8//! claiming "All API keys look valid" on the strength of a `starts_with`
9//! check. A fresh install came out the other side with a config file and no
10//! agents.
11//!
12//! This is a ratatui wizard instead: pick the providers you actually use,
13//! configure and verify each, set defaults and limits, install the bundled
14//! blueprints, and import MCP servers already configured in other harnesses.
15//!
16//! ## Shape
17//!
18//! * [`state`] - what step we're on and what's been chosen. Pure data.
19//! * [`input`] - key handling.
20//! * [`render`] - drawing.
21//! * [`plan`] - the decisions as plain data, and the only code that writes.
22//! * [`catalog`] - which providers exist and how each is configured.
23//! * [`import`] - MCP servers found in other tools.
24//! * [`verify`] - proving a credential works.
25//!
26//! The terminal is a *front-end*, not the feature: everything it collects lands
27//! in a [`plan::SetupPlan`], and `--non-interactive` builds the same struct
28//! from flags. A future mobile or web host would be a third builder with
29//! nothing downstream changing - which is why none of the platform-shaped parts
30//! (scanning a home directory, taking over a TTY) are prescribed anywhere but
31//! here.
32
33pub 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    /// Run non-interactively using only flag values (useful for scripting)
57    #[arg(long)]
58    pub non_interactive: bool,
59
60    /// Skip checking credentials against the provider APIs
61    #[arg(long)]
62    pub no_verify: bool,
63
64    /// Anthropic API key
65    #[arg(long)]
66    pub anthropic_key: Option<String>,
67
68    /// OpenAI API key
69    #[arg(long)]
70    pub openai_key: Option<String>,
71
72    /// Google AI (Gemini) API key
73    #[arg(long)]
74    pub google_key: Option<String>,
75
76    /// OpenRouter API key
77    #[arg(long)]
78    pub openrouter_key: Option<String>,
79
80    /// Ollama base URL (default: http://localhost:11434)
81    #[arg(long)]
82    pub ollama_url: Option<String>,
83
84    /// Default model override (e.g. claude-sonnet-4-6)
85    #[arg(long)]
86    pub default_model: Option<String>,
87
88    /// Enable the Claude Code CLI transport (runs on your Claude subscription
89    /// instead of an API key). Off unless set: the CLI adds its own context to
90    /// every call, including your account email address.
91    #[arg(long)]
92    pub claude_code: Option<bool>,
93
94    /// Reasoning effort for the Claude Code transport
95    /// (low, medium, high, xhigh, max)
96    #[arg(long)]
97    pub claude_code_effort: Option<String>,
98
99    /// Install the bundled agent blueprints without asking
100    #[arg(long)]
101    pub install_agents: bool,
102}
103
104/// Reads an environment variable. Injected so a test can hand the wizard a
105/// fixed environment instead of the developer's real one.
106pub type EnvLookup = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
107
108/// Everything the wizard needs from the outside world, injected so tests point
109/// it at tempdirs and a fake environment instead of the developer's real home.
110pub struct SetupEnv {
111    pub config_path: PathBuf,
112    pub agents_dir: PathBuf,
113    /// Roots for the harness scan.
114    pub roots: import::Roots,
115    /// Reads an environment variable.
116    pub env_lookup: EnvLookup,
117    /// Opens a URL in a browser.
118    pub opener: leviath_mcp::BrowserOpener,
119}
120
121// The real `SetupEnv` - the user's actual home, a real `std::env` lookup, and
122// a real browser - is built in the binary, where those leaves belong. Nothing
123// in the library reaches the real environment, so no test can either.
124
125/// The non-interactive arm: apply flags to the config on disk and save.
126///
127/// Kept working byte-for-byte because it is the documented headless path and an
128/// integration test spawns the real binary through it.
129pub 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
152/// Print what happened. Shared by both arms so the closing summary reads the
153/// same however setup was driven.
154fn 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
168/// Copy the flag values onto a config.
169fn 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}
195
196/// Build a wizard against `env`.
197///
198/// The base config comes from reading the *file*, deliberately not from
199/// `Config::load()`: `load` folds `$ANTHROPIC_API_KEY` and friends in, and the
200/// old wizard re-serialized the whole struct - quietly writing into
201/// `~/.leviath/config.toml` a key the user had chosen to keep in their
202/// environment. Those are tracked separately and shown as such.
203pub fn build_wizard(env: &SetupEnv) -> Wizard {
204    let base = Config::load_from_path_public(&env.config_path).unwrap_or_default();
205    let (candidates, errors) = state::candidates_from_scans(import::scan(&env.roots));
206    Wizard::new(
207        base,
208        &env.env_lookup,
209        candidates,
210        errors,
211        &env.agents_dir,
212        env.opener.clone(),
213    )
214}
215
216/// Answer verification requests until the wizard drops its sender.
217///
218/// Sequential rather than fanned out: the answers land on separate provider
219/// cards a user reads one at a time, and firing six requests at once buys
220/// nothing but a chance to trip a rate limiter with what is supposed to be a
221/// harmless check.
222pub async fn verification_loop<V: ProviderVerifier>(
223    verifier: V,
224    mut requests: mpsc::UnboundedReceiver<VerifyRequest>,
225    replies: mpsc::UnboundedSender<VerifyReply>,
226) {
227    while let Some(request) = requests.recv().await {
228        let outcome = verifier.verify(&request.creds).await;
229        // A closed receiver means the wizard exited; nothing left to report to.
230        if replies
231            .send(VerifyReply {
232                provider_id: request.provider_id,
233                outcome,
234            })
235            .is_err()
236        {
237            return;
238        }
239    }
240}
241
242/// The wizard's draw/input loop.
243///
244/// Generic over the backend and event source so it runs against a
245/// `TestBackend` and canned keys; the real crossterm bindings live in the
246/// binary. Returns the plan to apply, or `None` if the user quit.
247pub async fn run_wizard_loop<B: ratatui::backend::Backend>(
248    wizard: &mut Wizard,
249    terminal: &mut Terminal<B>,
250    events: &mut impl EventSource,
251    tick_rate: Duration,
252) -> anyhow::Result<Option<plan::SetupPlan>> {
253    loop {
254        wizard.ticks += 1;
255        wizard.drain_verifications();
256        terminal
257            .draw(|frame| render::draw(frame, wizard))
258            // ratatui 0.30 made the backend error an associated type with no
259            // Send/Sync guarantee, so convert by message rather than by `?`.
260            .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
261
262        if let Some(Event::Key(key)) = events.poll_event(tick_rate)?
263            && key.kind == KeyEventKind::Press
264            && wizard.handle_key(key) == input::Action::Save
265        {
266            wizard.finished = true;
267        }
268
269        if wizard.finished {
270            return Ok(Some(wizard.build_plan()));
271        }
272        if wizard.should_quit {
273            return Ok(None);
274        }
275    }
276}
277
278/// Set up the terminal, run the loop, tear the terminal down, then apply.
279///
280/// The teardown happens before anything is printed: writing a summary while the
281/// alternate screen is still up puts it somewhere the user will never see.
282pub async fn execute_core<S: TerminalSetup, E: EventSource>(
283    wizard: &mut Wizard,
284    env: &SetupEnv,
285    setup: &mut S,
286    events: &mut E,
287) -> anyhow::Result<()> {
288    setup.enable()?;
289    let mut terminal = setup.create_terminal()?;
290    let result = run_wizard_loop(wizard, &mut terminal, events, Duration::from_millis(120)).await;
291    setup.disable();
292
293    match result? {
294        Some(plan) => {
295            let applied = plan::apply(&plan, &env.config_path, &env.agents_dir)?;
296            report(&applied);
297            print_next_steps(&applied);
298        }
299        None => println!("Setup cancelled. Nothing was written."),
300    }
301    Ok(())
302}
303
304/// What to do now that setup is done.
305fn print_next_steps(applied: &plan::Applied) {
306    println!();
307    match applied.agents_installed.first() {
308        Some(agent) => println!("Try it:  lev run {agent} --task \"...\""),
309        None => println!("Install an agent with `lev setup`, then `lev run <agent>`."),
310    }
311}
312
313/// `lev setup`: the flags path, or the wizard.
314///
315/// `is_terminal` is injected because the answer is a property of the real
316/// process's stdout, and a wizard that starts on a pipe would take over a
317/// terminal that isn't there.
318pub async fn execute_with<S: TerminalSetup, E: EventSource>(
319    args: &SetupArgs,
320    env: &SetupEnv,
321    setup: &mut S,
322    events: &mut E,
323    is_terminal: bool,
324) -> anyhow::Result<()> {
325    if args.non_interactive {
326        return run_non_interactive(args, env);
327    }
328    if !is_terminal {
329        anyhow::bail!(
330            "lev setup needs a terminal. For scripted use:\n  \
331             lev setup --non-interactive --anthropic-key sk-ant-... --install-agents"
332        );
333    }
334    let mut wizard = build_wizard(env);
335    execute_core(&mut wizard, env, setup, events).await
336}
337
338/// Resolve `~/.leviath/agents` for the real environment.
339pub fn real_agents_dir(home: Option<&Path>) -> PathBuf {
340    home.unwrap_or(Path::new(""))
341        .join(".leviath")
342        .join("agents")
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::bundled::BUNDLED_AGENTS;
349    use crate::tui::{TestEventSource, TestSetup, key, key_with, test_terminal};
350    use crossterm::event::{KeyCode, KeyModifiers};
351
352    /// Args with everything off, so each test names only what it exercises.
353    fn args() -> SetupArgs {
354        SetupArgs {
355            non_interactive: false,
356            no_verify: false,
357            anthropic_key: None,
358            openai_key: None,
359            google_key: None,
360            openrouter_key: None,
361            ollama_url: None,
362            default_model: None,
363            claude_code: None,
364            claude_code_effort: None,
365            install_agents: false,
366        }
367    }
368
369    /// A `SetupEnv` rooted entirely in a tempdir, with a browser opener that
370    /// records instead of launching and an environment that is simply empty.
371    fn env_in(dir: &Path) -> SetupEnv {
372        SetupEnv {
373            config_path: dir.join("config.toml"),
374            agents_dir: dir.join("agents"),
375            roots: import::Roots {
376                home: dir.join("home"),
377                os_config: dir.join("os-config"),
378                xdg_config: dir.join("home").join(".config"),
379                cwd: dir.join("cwd"),
380            },
381            env_lookup: Box::new(|_| None),
382            opener: std::sync::Arc::new(|_| true),
383        }
384    }
385
386    // ─── the non-interactive path ───────────────────────────────────────────
387
388    #[test]
389    fn flags_are_written_to_the_config() {
390        let dir = tempfile::tempdir().unwrap();
391        let env = env_in(dir.path());
392        let args = SetupArgs {
393            non_interactive: true,
394            anthropic_key: Some("sk-ant-x".to_string()),
395            openai_key: Some("sk-oai".to_string()),
396            google_key: Some("goog".to_string()),
397            openrouter_key: Some("sk-or".to_string()),
398            ollama_url: Some("http://box:11434".to_string()),
399            default_model: Some("m".to_string()),
400            claude_code: Some(true),
401            claude_code_effort: Some("xhigh".to_string()),
402            ..args()
403        };
404
405        run_non_interactive(&args, &env).unwrap();
406
407        let written = Config::load_from_path_public(&env.config_path).unwrap();
408        assert_eq!(
409            written.providers.anthropic_api_key.as_deref(),
410            Some("sk-ant-x")
411        );
412        assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
413        assert_eq!(written.providers.google_api_key.as_deref(), Some("goog"));
414        assert_eq!(written.openrouter_api_key.as_deref(), Some("sk-or"));
415        assert_eq!(written.ollama_base_url.as_deref(), Some("http://box:11434"));
416        assert_eq!(written.default_model.as_deref(), Some("m"));
417        assert!(written.providers.claude_code_enabled);
418        assert_eq!(
419            written.providers.claude_code_effort.as_deref(),
420            Some("xhigh")
421        );
422    }
423
424    #[test]
425    fn the_non_interactive_path_installs_agents_only_when_asked() {
426        let dir = tempfile::tempdir().unwrap();
427        let env = env_in(dir.path());
428
429        run_non_interactive(&args(), &env).unwrap();
430        assert!(!env.agents_dir.exists(), "nothing was asked for");
431
432        run_non_interactive(
433            &SetupArgs {
434                install_agents: true,
435                ..args()
436            },
437            &env,
438        )
439        .unwrap();
440        assert!(
441            env.agents_dir.join(BUNDLED_AGENTS[0].name).exists(),
442            "every bundled blueprint should land"
443        );
444
445        // Second time round there is nothing left to do.
446        run_non_interactive(
447            &SetupArgs {
448                install_agents: true,
449                ..args()
450            },
451            &env,
452        )
453        .unwrap();
454        assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
455    }
456
457    #[test]
458    fn the_non_interactive_path_keeps_settings_it_was_not_given() {
459        let dir = tempfile::tempdir().unwrap();
460        let env = env_in(dir.path());
461        run_non_interactive(
462            &SetupArgs {
463                anthropic_key: Some("sk-ant-first".to_string()),
464                ..args()
465            },
466            &env,
467        )
468        .unwrap();
469
470        run_non_interactive(
471            &SetupArgs {
472                openai_key: Some("sk-oai".to_string()),
473                ..args()
474            },
475            &env,
476        )
477        .unwrap();
478
479        let written = Config::load_from_path_public(&env.config_path).unwrap();
480        assert_eq!(
481            written.providers.anthropic_api_key.as_deref(),
482            Some("sk-ant-first")
483        );
484        assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
485    }
486
487    #[test]
488    fn a_config_that_cannot_be_written_is_an_error() {
489        let dir = tempfile::tempdir().unwrap();
490        let blocked = dir.path().join("not-a-dir");
491        std::fs::write(&blocked, "").unwrap();
492        let mut env = env_in(dir.path());
493        env.config_path = blocked.join("config.toml");
494
495        assert!(run_non_interactive(&args(), &env).is_err());
496    }
497
498    // ─── building the wizard from the environment ───────────────────────────
499
500    #[test]
501    fn the_wizard_reads_the_config_file_and_scans_for_harnesses() {
502        let dir = tempfile::tempdir().unwrap();
503        let env = env_in(dir.path());
504        std::fs::create_dir_all(&env.roots.home).unwrap();
505        std::fs::write(
506            env.roots.home.join(".claude.json"),
507            r#"{"mcpServers":{"fs":{"command":"npx"}}}"#,
508        )
509        .unwrap();
510        run_non_interactive(
511            &SetupArgs {
512                anthropic_key: Some("sk-ant-stored".to_string()),
513                ..args()
514            },
515            &env,
516        )
517        .unwrap();
518
519        let wizard = build_wizard(&env);
520
521        assert_eq!(
522            wizard.base.providers.anthropic_api_key.as_deref(),
523            Some("sk-ant-stored")
524        );
525        assert_eq!(wizard.mcp.len(), 1);
526        assert_eq!(wizard.mcp[0].candidate.config.name, "fs");
527    }
528
529    #[test]
530    fn a_missing_config_file_starts_from_defaults() {
531        let dir = tempfile::tempdir().unwrap();
532
533        let wizard = build_wizard(&env_in(dir.path()));
534
535        assert_eq!(
536            wizard.base.default_provider,
537            Config::default().default_provider
538        );
539    }
540
541    // ─── the verification background loop ───────────────────────────────────
542
543    #[tokio::test]
544    async fn the_verification_loop_answers_every_request_then_stops() {
545        let dir = tempfile::tempdir().unwrap();
546        let mut wizard = build_wizard(&env_in(dir.path()));
547        let (requests, replies) = wizard.take_verify_ends().expect("first take");
548        wizard.providers[0].selected = true;
549        wizard.providers[0].value = "sk-ant".to_string();
550        wizard.request_verification(0);
551
552        let handle = tokio::spawn(verification_loop(verify::SkipVerifier, requests, replies));
553        // Dropping the wizard's sender ends the loop.
554        let sender = wizard.verify_tx.clone();
555        drop(sender);
556
557        // Give the loop a turn, then confirm the answer arrived.
558        for _ in 0..50 {
559            wizard.drain_verifications();
560            if !wizard.providers[0].checking {
561                break;
562            }
563            tokio::time::sleep(Duration::from_millis(2)).await;
564        }
565        assert!(!wizard.providers[0].checking);
566        assert_eq!(wizard.providers[0].outcome, verify::Outcome::Skipped);
567
568        drop(wizard);
569        handle.await.expect("the loop exits cleanly");
570    }
571
572    #[tokio::test]
573    async fn the_verification_loop_stops_when_nobody_is_listening() {
574        // The wizard exited mid-check; there is nothing left to report to.
575        let (request_tx, request_rx) = mpsc::unbounded_channel();
576        let (reply_tx, reply_rx) = mpsc::unbounded_channel::<VerifyReply>();
577        request_tx
578            .send(VerifyRequest {
579                provider_id: "anthropic".to_string(),
580                creds: leviath_runtime::provider_creds::ProviderCreds {
581                    name: "anthropic".to_string(),
582                    api_key: Some("sk-ant".to_string()),
583                    base_url: None,
584                    model_capabilities: std::collections::HashMap::new(),
585                    request_timeout_secs: Some(1),
586                    rate_limit: None,
587                    options: std::collections::HashMap::new(),
588                },
589            })
590            .unwrap();
591        drop(reply_rx);
592
593        verification_loop(verify::SkipVerifier, request_rx, reply_tx).await;
594    }
595
596    // ─── the wizard loop ────────────────────────────────────────────────────
597
598    #[tokio::test]
599    async fn quitting_returns_no_plan() {
600        let dir = tempfile::tempdir().unwrap();
601        let mut wizard = build_wizard(&env_in(dir.path()));
602        let mut terminal = test_terminal();
603        let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
604
605        let plan = run_wizard_loop(
606            &mut wizard,
607            &mut terminal,
608            &mut events,
609            Duration::from_millis(1),
610        )
611        .await
612        .unwrap();
613
614        assert!(plan.is_none());
615    }
616
617    #[tokio::test]
618    async fn saving_returns_the_plan_the_wizard_describes() {
619        let dir = tempfile::tempdir().unwrap();
620        let mut wizard = build_wizard(&env_in(dir.path()));
621        let mut terminal = test_terminal();
622        // A tick with no input, then save - covering the poll-timeout path.
623        let mut events = TestEventSource::new_with_nones(vec![
624            None,
625            Some(key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)),
626        ]);
627
628        let plan = run_wizard_loop(
629            &mut wizard,
630            &mut terminal,
631            &mut events,
632            Duration::from_millis(1),
633        )
634        .await
635        .unwrap()
636        .expect("a plan was produced");
637
638        assert_eq!(plan.agents.len(), BUNDLED_AGENTS.len());
639    }
640
641    #[tokio::test]
642    async fn non_press_and_non_key_events_are_ignored() {
643        let dir = tempfile::tempdir().unwrap();
644        let mut wizard = build_wizard(&env_in(dir.path()));
645        let mut terminal = test_terminal();
646        let release = crossterm::event::Event::Key(crossterm::event::KeyEvent::new_with_kind(
647            KeyCode::Char('q'),
648            KeyModifiers::empty(),
649            KeyEventKind::Release,
650        ));
651        let mut events = TestEventSource::new(vec![
652            release,
653            crossterm::event::Event::FocusGained,
654            crossterm::event::Event::Resize(80, 24),
655            key(KeyCode::Char('q')),
656        ]);
657
658        let plan = run_wizard_loop(
659            &mut wizard,
660            &mut terminal,
661            &mut events,
662            Duration::from_millis(1),
663        )
664        .await
665        .unwrap();
666
667        assert!(plan.is_none(), "only the real press quit");
668    }
669
670    #[tokio::test]
671    async fn a_draw_failure_propagates() {
672        let dir = tempfile::tempdir().unwrap();
673        let mut wizard = build_wizard(&env_in(dir.path()));
674        let mut terminal =
675            ratatui::Terminal::new(crate::tui::TestBackendHarness::failing(80, 24)).unwrap();
676        let mut events = TestEventSource::new(vec![]);
677
678        let result = run_wizard_loop(
679            &mut wizard,
680            &mut terminal,
681            &mut events,
682            Duration::from_millis(1),
683        )
684        .await;
685
686        assert!(result.is_err());
687    }
688
689    #[tokio::test]
690    async fn an_event_source_failure_propagates() {
691        let dir = tempfile::tempdir().unwrap();
692        let mut wizard = build_wizard(&env_in(dir.path()));
693        let mut terminal = test_terminal();
694        let mut events = TestEventSource::failing();
695
696        let result = run_wizard_loop(
697            &mut wizard,
698            &mut terminal,
699            &mut events,
700            Duration::from_millis(1),
701        )
702        .await;
703
704        assert!(result.is_err());
705    }
706
707    // ─── the composed command ───────────────────────────────────────────────
708
709    #[tokio::test]
710    async fn saving_writes_the_config_and_installs_the_agents() {
711        let dir = tempfile::tempdir().unwrap();
712        let env = env_in(dir.path());
713        let mut wizard = build_wizard(&env);
714        let mut setup = TestSetup::new();
715        let mut events =
716            TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
717
718        execute_core(&mut wizard, &env, &mut setup, &mut events)
719            .await
720            .unwrap();
721
722        assert!(env.config_path.exists());
723        assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
724    }
725
726    #[tokio::test]
727    async fn quitting_writes_nothing() {
728        let dir = tempfile::tempdir().unwrap();
729        let env = env_in(dir.path());
730        let mut wizard = build_wizard(&env);
731        let mut setup = TestSetup::new();
732        let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
733
734        execute_core(&mut wizard, &env, &mut setup, &mut events)
735            .await
736            .unwrap();
737
738        assert!(
739            !env.config_path.exists(),
740            "nothing should have been written"
741        );
742        assert!(!env.agents_dir.exists());
743    }
744
745    #[tokio::test]
746    async fn a_terminal_that_will_not_start_is_an_error() {
747        let dir = tempfile::tempdir().unwrap();
748        let env = env_in(dir.path());
749        let mut wizard = build_wizard(&env);
750        let mut events = TestEventSource::new(vec![]);
751
752        let mut enable_fails = TestSetup {
753            enable_should_fail: true,
754            create_should_fail: false,
755        };
756        assert!(
757            execute_core(&mut wizard, &env, &mut enable_fails, &mut events)
758                .await
759                .is_err()
760        );
761
762        let mut create_fails = TestSetup {
763            enable_should_fail: false,
764            create_should_fail: true,
765        };
766        assert!(
767            execute_core(&mut wizard, &env, &mut create_fails, &mut events)
768                .await
769                .is_err()
770        );
771    }
772
773    #[tokio::test]
774    async fn a_loop_failure_is_surfaced_after_the_terminal_is_restored() {
775        let dir = tempfile::tempdir().unwrap();
776        let env = env_in(dir.path());
777        let mut wizard = build_wizard(&env);
778        let mut setup = TestSetup::new();
779        let mut events = TestEventSource::failing();
780
781        let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
782
783        assert!(result.is_err());
784        assert!(!env.config_path.exists());
785    }
786
787    #[tokio::test]
788    async fn a_write_failure_after_the_wizard_is_surfaced() {
789        // The terminal must already be restored, or the error would be printed
790        // onto an alternate screen the user never sees again.
791        let dir = tempfile::tempdir().unwrap();
792        let mut env = env_in(dir.path());
793        let blocked = dir.path().join("not-a-dir");
794        std::fs::write(&blocked, "").unwrap();
795        let mut wizard = build_wizard(&env);
796        env.config_path = blocked.join("config.toml");
797        let mut setup = TestSetup::new();
798        let mut events =
799            TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
800
801        let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
802
803        assert!(result.is_err());
804    }
805
806    #[tokio::test]
807    async fn execute_with_routes_to_the_flags_path() {
808        let dir = tempfile::tempdir().unwrap();
809        let env = env_in(dir.path());
810        let mut setup = TestSetup::new();
811        let mut events = TestEventSource::new(vec![]);
812
813        execute_with(
814            &SetupArgs {
815                non_interactive: true,
816                anthropic_key: Some("sk-ant-x".to_string()),
817                ..args()
818            },
819            &env,
820            &mut setup,
821            &mut events,
822            false,
823        )
824        .await
825        .unwrap();
826
827        let written = Config::load_from_path_public(&env.config_path).unwrap();
828        assert_eq!(
829            written.providers.anthropic_api_key.as_deref(),
830            Some("sk-ant-x")
831        );
832    }
833
834    #[tokio::test]
835    async fn without_a_terminal_the_wizard_refuses_and_says_what_to_run_instead() {
836        // Starting ratatui on a pipe would take over a terminal that isn't
837        // there.
838        let dir = tempfile::tempdir().unwrap();
839        let env = env_in(dir.path());
840        let mut setup = TestSetup::new();
841        let mut events = TestEventSource::new(vec![]);
842
843        let error = execute_with(&args(), &env, &mut setup, &mut events, false)
844            .await
845            .expect_err("a pipe is not a terminal");
846
847        let message = error.to_string();
848        assert!(message.contains("needs a terminal"), "{message}");
849        assert!(message.contains("--non-interactive"), "{message}");
850        assert!(!env.config_path.exists());
851    }
852
853    #[tokio::test]
854    async fn with_a_terminal_execute_with_runs_the_wizard() {
855        let dir = tempfile::tempdir().unwrap();
856        let env = env_in(dir.path());
857        let mut setup = TestSetup::new();
858        let mut events =
859            TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
860
861        execute_with(&args(), &env, &mut setup, &mut events, true)
862            .await
863            .unwrap();
864
865        assert!(env.config_path.exists());
866    }
867
868    // ─── reporting ──────────────────────────────────────────────────────────
869
870    #[test]
871    fn the_summary_covers_agents_warnings_and_the_empty_case() {
872        report(&plan::Applied {
873            config_path: PathBuf::from("/tmp/config.toml"),
874            agents_installed: vec!["coder".to_string()],
875            warnings: vec!["could not install x".to_string()],
876        });
877        report(&plan::Applied {
878            config_path: PathBuf::from("/tmp/config.toml"),
879            agents_installed: Vec::new(),
880            warnings: Vec::new(),
881        });
882    }
883
884    #[test]
885    fn the_next_step_names_an_installed_agent_when_there_is_one() {
886        print_next_steps(&plan::Applied {
887            config_path: PathBuf::from("/tmp/config.toml"),
888            agents_installed: vec!["coder".to_string()],
889            warnings: Vec::new(),
890        });
891        print_next_steps(&plan::Applied {
892            config_path: PathBuf::from("/tmp/config.toml"),
893            agents_installed: Vec::new(),
894            warnings: Vec::new(),
895        });
896    }
897
898    #[test]
899    fn the_real_agents_directory_sits_under_the_leviath_home() {
900        assert_eq!(
901            real_agents_dir(Some(Path::new("/home/u"))),
902            PathBuf::from("/home/u/.leviath/agents")
903        );
904        // No home directory resolvable: a relative path, not a panic.
905        assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
906    }
907}