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/// Arguments for `lev setup`.
55#[derive(Args)]
56pub struct SetupArgs {
57    /// Run non-interactively using only flag values (useful for scripting)
58    #[arg(long)]
59    pub non_interactive: bool,
60
61    /// Skip checking credentials against the provider APIs
62    #[arg(long)]
63    pub no_verify: bool,
64
65    /// Anthropic API key
66    #[arg(long)]
67    pub anthropic_key: Option<String>,
68
69    /// OpenAI API key
70    #[arg(long)]
71    pub openai_key: Option<String>,
72
73    /// Google AI (Gemini) API key
74    #[arg(long)]
75    pub google_key: Option<String>,
76
77    /// OpenRouter API key
78    #[arg(long)]
79    pub openrouter_key: Option<String>,
80
81    /// Ollama base URL (default: http://localhost:11434)
82    #[arg(long)]
83    pub ollama_url: Option<String>,
84
85    /// Default model override (e.g. claude-sonnet-4-6)
86    #[arg(long)]
87    pub default_model: Option<String>,
88
89    /// Enable the Claude Code CLI transport (runs on your Claude subscription
90    /// instead of an API key). Off unless set: the CLI adds its own context to
91    /// every call, including your account email address.
92    #[arg(long)]
93    pub claude_code: Option<bool>,
94
95    /// Reasoning effort for the Claude Code transport
96    /// (low, medium, high, xhigh, max)
97    #[arg(long)]
98    pub claude_code_effort: Option<String>,
99
100    /// Install the bundled agent blueprints without asking
101    #[arg(long)]
102    pub install_agents: bool,
103}
104
105/// Reads an environment variable. Injected so a test can hand the wizard a
106/// fixed environment instead of the developer's real one.
107pub type EnvLookup = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
108
109/// Everything the wizard needs from the outside world, injected so tests point
110/// it at tempdirs and a fake environment instead of the developer's real home.
111pub struct SetupEnv {
112    /// Where the config is read from and written back to.
113    pub config_path: PathBuf,
114    /// Where bundled blueprints are installed.
115    pub agents_dir: PathBuf,
116    /// Roots for the harness scan.
117    pub roots: import::Roots,
118    /// Reads an environment variable.
119    pub env_lookup: EnvLookup,
120    /// Opens a URL in a browser.
121    pub opener: leviath_mcp::BrowserOpener,
122}
123
124// The real `SetupEnv` - the user's actual home, a real `std::env` lookup, and
125// a real browser - is built in the binary, where those leaves belong. Nothing
126// in the library reaches the real environment, so no test can either.
127
128/// The non-interactive arm: apply flags to the config on disk and save.
129///
130/// Kept working byte-for-byte because it is the documented headless path and an
131/// integration test spawns the real binary through it.
132pub 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            // `preselect`, not `is_change`: the headless path must not overwrite
140            // a blueprint the user edited, any more than the wizard does.
141            .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
157/// Print what happened. Shared by both arms so the closing summary reads the
158/// same however setup was driven.
159fn 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
173/// Copy the flag values onto a config.
174fn 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
202/// The providers this config holds a credential for, best first.
203///
204/// Ollama sits last on purpose. It needs no key, so a config that merely
205/// mentions it is not a statement of preference, and putting it first would
206/// make it the default on a machine that never installed it.
207fn 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
222/// Point `default_provider` at a provider this config can actually reach.
223///
224/// It defaults to `anthropic` and nothing in non-interactive mode ever moved
225/// it, so `lev setup --non-interactive --openrouter-key ...` produced a config
226/// whose very next `lev doctor` said it "resolved to 'anthropic', which is not
227/// configured". The install was fine; the default was pointing at a provider
228/// the user had not asked for.
229///
230/// Only ever moves a default that is unreachable, so a deliberate choice
231/// already in the file survives.
232fn 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
242/// Build a wizard against `env`.
243///
244/// The base config comes from reading the *file*, deliberately not from
245/// `Config::load()`: `load` folds `$ANTHROPIC_API_KEY` and friends in, and the
246/// old wizard re-serialized the whole struct - quietly writing into
247/// `~/.leviath/config.toml` a key the user had chosen to keep in their
248/// environment. Those are tracked separately and shown as such.
249pub 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
262/// Answer verification requests until the wizard drops its sender.
263///
264/// Sequential rather than fanned out: the answers land on separate provider
265/// cards a user reads one at a time, and firing six requests at once buys
266/// nothing but a chance to trip a rate limiter with what is supposed to be a
267/// harmless check.
268pub 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        // A closed receiver means the wizard exited; nothing left to report to.
276        if replies
277            .send(VerifyReply {
278                provider_id: request.provider_id,
279                outcome,
280            })
281            .is_err()
282        {
283            return;
284        }
285    }
286}
287
288/// The wizard's draw/input loop.
289///
290/// Generic over the backend and event source so it runs against a
291/// `TestBackend` and canned keys; the real crossterm bindings live in the
292/// binary. Returns the plan to apply, or `None` if the user quit.
293pub 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        // The area is taken from the frame that was actually drawn, so a click
303        // resolves against the layout the user was looking at rather than
304        // against a size asked for separately afterwards.
305        let mut area = ratatui::layout::Rect::default();
306        terminal
307            .draw(|frame| {
308                area = frame.area();
309                render::draw(frame, wizard);
310            })
311            // ratatui 0.30 made the backend error an associated type with no
312            // Send/Sync guarantee, so convert by message rather than by `?`.
313            .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
339/// Set up the terminal, run the loop, tear the terminal down, then apply.
340///
341/// The teardown happens before anything is printed: writing a summary while the
342/// alternate screen is still up puts it somewhere the user will never see.
343pub 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
365/// What to do now that setup is done.
366fn 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
374/// `lev setup`: the flags path, or the wizard.
375///
376/// `is_terminal` is injected because the answer is a property of the real
377/// process's stdout, and a wizard that starts on a pipe would take over a
378/// terminal that isn't there.
379pub 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
399/// Resolve `~/.leviath/agents` for the real environment.
400pub 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    /// Args with everything off, so each test names only what it exercises.
414    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    /// A `SetupEnv` rooted entirely in a tempdir, with a browser opener that
431    /// records instead of launching and an environment that is simply empty.
432    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    // ─── default_provider retargeting ───────────────────────────────────────
448
449    #[test]
450    fn a_single_non_anthropic_key_becomes_the_default_provider() {
451        // The bug this exists for: setup succeeded, then `lev doctor` said the
452        // install resolved to a provider the user had never configured.
453        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        // Nothing to retarget to, so moving it would only make it wrong
499        // differently.
500        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        // It needs no key, so it is the one most likely to be present by
508        // accident. Anything the user actually holds a credential for wins.
509        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    // ─── the non-interactive path ───────────────────────────────────────────
545
546    #[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        // Second time round there is nothing left to do.
604        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    // ─── building the wizard from the environment ───────────────────────────
657
658    #[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    // ─── the verification background loop ───────────────────────────────────
700
701    #[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        // Dropping the wizard's sender ends the loop.
712        let sender = wizard.verify_tx.clone();
713        drop(sender);
714
715        // Give the loop a turn, then confirm the answer arrived.
716        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        // The wizard exited mid-check; there is nothing left to report to.
733        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    // ─── the wizard loop ────────────────────────────────────────────────────
755
756    #[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        // A tick with no input, then save - covering the poll-timeout path.
781        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    /// A click reaches the wizard through the loop, against the size the
829    /// terminal reports, and can finish the run the same way a key can.
830    #[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        // The row the click has to land on is asked for, not assumed, so the
839        // test does not encode a layout.
840        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    /// The last button finishes the run, whether it is pressed or clicked.
872    #[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    // ─── the composed command ───────────────────────────────────────────────
944
945    #[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        // The terminal must already be restored, or the error would be printed
1028        // onto an alternate screen the user never sees again.
1029        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        // Starting ratatui on a pipe would take over a terminal that isn't
1075        // there.
1076        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    // ─── reporting ──────────────────────────────────────────────────────────
1107
1108    #[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        // No home directory resolvable: a relative path, not a panic.
1143        assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
1144    }
1145}