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        terminal
303            .draw(|frame| render::draw(frame, wizard))
304            // ratatui 0.30 made the backend error an associated type with no
305            // Send/Sync guarantee, so convert by message rather than by `?`.
306            .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
324/// Set up the terminal, run the loop, tear the terminal down, then apply.
325///
326/// The teardown happens before anything is printed: writing a summary while the
327/// alternate screen is still up puts it somewhere the user will never see.
328pub 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
350/// What to do now that setup is done.
351fn 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
359/// `lev setup`: the flags path, or the wizard.
360///
361/// `is_terminal` is injected because the answer is a property of the real
362/// process's stdout, and a wizard that starts on a pipe would take over a
363/// terminal that isn't there.
364pub 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
384/// Resolve `~/.leviath/agents` for the real environment.
385pub 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    /// Args with everything off, so each test names only what it exercises.
399    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    /// A `SetupEnv` rooted entirely in a tempdir, with a browser opener that
416    /// records instead of launching and an environment that is simply empty.
417    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    // ─── default_provider retargeting ───────────────────────────────────────
433
434    #[test]
435    fn a_single_non_anthropic_key_becomes_the_default_provider() {
436        // The bug this exists for: setup succeeded, then `lev doctor` said the
437        // install resolved to a provider the user had never configured.
438        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        // Nothing to retarget to, so moving it would only make it wrong
484        // differently.
485        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        // It needs no key, so it is the one most likely to be present by
493        // accident. Anything the user actually holds a credential for wins.
494        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    // ─── the non-interactive path ───────────────────────────────────────────
530
531    #[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        // Second time round there is nothing left to do.
589        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    // ─── building the wizard from the environment ───────────────────────────
642
643    #[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    // ─── the verification background loop ───────────────────────────────────
685
686    #[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        // Dropping the wizard's sender ends the loop.
697        let sender = wizard.verify_tx.clone();
698        drop(sender);
699
700        // Give the loop a turn, then confirm the answer arrived.
701        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        // The wizard exited mid-check; there is nothing left to report to.
718        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    // ─── the wizard loop ────────────────────────────────────────────────────
740
741    #[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        // A tick with no input, then save - covering the poll-timeout path.
766        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    // ─── the composed command ───────────────────────────────────────────────
851
852    #[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        // The terminal must already be restored, or the error would be printed
935        // onto an alternate screen the user never sees again.
936        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        // Starting ratatui on a pipe would take over a terminal that isn't
982        // there.
983        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    // ─── reporting ──────────────────────────────────────────────────────────
1014
1015    #[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        // No home directory resolvable: a relative path, not a panic.
1050        assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
1051    }
1052}