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    retarget_default_provider(config);
195}
196
197/// The providers this config holds a credential for, best first.
198///
199/// Ollama sits last on purpose. It needs no key, so a config that merely
200/// mentions it is not a statement of preference, and putting it first would
201/// make it the default on a machine that never installed it.
202fn configured_providers(config: &Config) -> Vec<&'static str> {
203    [
204        ("anthropic", config.providers.anthropic_api_key.is_some()),
205        ("openai", config.providers.openai_api_key.is_some()),
206        ("google", config.providers.google_api_key.is_some()),
207        ("openrouter", config.openrouter_api_key.is_some()),
208        ("claude-code", config.providers.claude_code_enabled),
209        ("ollama", config.ollama_base_url.is_some()),
210    ]
211    .into_iter()
212    .filter(|(_, configured)| *configured)
213    .map(|(id, _)| id)
214    .collect()
215}
216
217/// Point `default_provider` at a provider this config can actually reach.
218///
219/// It defaults to `anthropic` and nothing in non-interactive mode ever moved
220/// it, so `lev setup --non-interactive --openrouter-key ...` produced a config
221/// whose very next `lev doctor` said it "resolved to 'anthropic', which is not
222/// configured". The install was fine; the default was pointing at a provider
223/// the user had not asked for.
224///
225/// Only ever moves a default that is unreachable, so a deliberate choice
226/// already in the file survives.
227fn retarget_default_provider(config: &mut Config) {
228    let configured = configured_providers(config);
229    if configured.contains(&config.default_provider.as_str()) {
230        return;
231    }
232    if let Some(first) = configured.first() {
233        config.default_provider = (*first).to_string();
234    }
235}
236
237/// Build a wizard against `env`.
238///
239/// The base config comes from reading the *file*, deliberately not from
240/// `Config::load()`: `load` folds `$ANTHROPIC_API_KEY` and friends in, and the
241/// old wizard re-serialized the whole struct - quietly writing into
242/// `~/.leviath/config.toml` a key the user had chosen to keep in their
243/// environment. Those are tracked separately and shown as such.
244pub fn build_wizard(env: &SetupEnv) -> Wizard {
245    let base = Config::load_from_path_public(&env.config_path).unwrap_or_default();
246    let (candidates, errors) = state::candidates_from_scans(import::scan(&env.roots));
247    Wizard::new(
248        base,
249        &env.env_lookup,
250        candidates,
251        errors,
252        &env.agents_dir,
253        env.opener.clone(),
254    )
255}
256
257/// Answer verification requests until the wizard drops its sender.
258///
259/// Sequential rather than fanned out: the answers land on separate provider
260/// cards a user reads one at a time, and firing six requests at once buys
261/// nothing but a chance to trip a rate limiter with what is supposed to be a
262/// harmless check.
263pub async fn verification_loop<V: ProviderVerifier>(
264    verifier: V,
265    mut requests: mpsc::UnboundedReceiver<VerifyRequest>,
266    replies: mpsc::UnboundedSender<VerifyReply>,
267) {
268    while let Some(request) = requests.recv().await {
269        let outcome = verifier.verify(&request.creds).await;
270        // A closed receiver means the wizard exited; nothing left to report to.
271        if replies
272            .send(VerifyReply {
273                provider_id: request.provider_id,
274                outcome,
275            })
276            .is_err()
277        {
278            return;
279        }
280    }
281}
282
283/// The wizard's draw/input loop.
284///
285/// Generic over the backend and event source so it runs against a
286/// `TestBackend` and canned keys; the real crossterm bindings live in the
287/// binary. Returns the plan to apply, or `None` if the user quit.
288pub async fn run_wizard_loop<B: ratatui::backend::Backend>(
289    wizard: &mut Wizard,
290    terminal: &mut Terminal<B>,
291    events: &mut impl EventSource,
292    tick_rate: Duration,
293) -> anyhow::Result<Option<plan::SetupPlan>> {
294    loop {
295        wizard.ticks += 1;
296        wizard.drain_verifications();
297        terminal
298            .draw(|frame| render::draw(frame, wizard))
299            // ratatui 0.30 made the backend error an associated type with no
300            // Send/Sync guarantee, so convert by message rather than by `?`.
301            .map_err(|e| anyhow::anyhow!("terminal draw failed: {e}"))?;
302
303        if let Some(Event::Key(key)) = events.poll_event(tick_rate)?
304            && key.kind == KeyEventKind::Press
305            && wizard.handle_key(key) == input::Action::Save
306        {
307            wizard.finished = true;
308        }
309
310        if wizard.finished {
311            return Ok(Some(wizard.build_plan()));
312        }
313        if wizard.should_quit {
314            return Ok(None);
315        }
316    }
317}
318
319/// Set up the terminal, run the loop, tear the terminal down, then apply.
320///
321/// The teardown happens before anything is printed: writing a summary while the
322/// alternate screen is still up puts it somewhere the user will never see.
323pub async fn execute_core<S: TerminalSetup, E: EventSource>(
324    wizard: &mut Wizard,
325    env: &SetupEnv,
326    setup: &mut S,
327    events: &mut E,
328) -> anyhow::Result<()> {
329    setup.enable()?;
330    let mut terminal = setup.create_terminal()?;
331    let result = run_wizard_loop(wizard, &mut terminal, events, Duration::from_millis(120)).await;
332    setup.disable();
333
334    match result? {
335        Some(plan) => {
336            let applied = plan::apply(&plan, &env.config_path, &env.agents_dir)?;
337            report(&applied);
338            print_next_steps(&applied);
339        }
340        None => println!("Setup cancelled. Nothing was written."),
341    }
342    Ok(())
343}
344
345/// What to do now that setup is done.
346fn print_next_steps(applied: &plan::Applied) {
347    println!();
348    match applied.agents_installed.first() {
349        Some(agent) => println!("Try it:  lev run {agent} --task \"...\""),
350        None => println!("Install an agent with `lev setup`, then `lev run <agent>`."),
351    }
352}
353
354/// `lev setup`: the flags path, or the wizard.
355///
356/// `is_terminal` is injected because the answer is a property of the real
357/// process's stdout, and a wizard that starts on a pipe would take over a
358/// terminal that isn't there.
359pub async fn execute_with<S: TerminalSetup, E: EventSource>(
360    args: &SetupArgs,
361    env: &SetupEnv,
362    setup: &mut S,
363    events: &mut E,
364    is_terminal: bool,
365) -> anyhow::Result<()> {
366    if args.non_interactive {
367        return run_non_interactive(args, env);
368    }
369    if !is_terminal {
370        anyhow::bail!(
371            "lev setup needs a terminal. For scripted use:\n  \
372             lev setup --non-interactive --anthropic-key sk-ant-... --install-agents"
373        );
374    }
375    let mut wizard = build_wizard(env);
376    execute_core(&mut wizard, env, setup, events).await
377}
378
379/// Resolve `~/.leviath/agents` for the real environment.
380pub fn real_agents_dir(home: Option<&Path>) -> PathBuf {
381    home.unwrap_or(Path::new(""))
382        .join(".leviath")
383        .join("agents")
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::bundled::BUNDLED_AGENTS;
390    use crate::tui::{TestEventSource, TestSetup, key, key_with, test_terminal};
391    use crossterm::event::{KeyCode, KeyModifiers};
392
393    /// Args with everything off, so each test names only what it exercises.
394    fn args() -> SetupArgs {
395        SetupArgs {
396            non_interactive: false,
397            no_verify: false,
398            anthropic_key: None,
399            openai_key: None,
400            google_key: None,
401            openrouter_key: None,
402            ollama_url: None,
403            default_model: None,
404            claude_code: None,
405            claude_code_effort: None,
406            install_agents: false,
407        }
408    }
409
410    /// A `SetupEnv` rooted entirely in a tempdir, with a browser opener that
411    /// records instead of launching and an environment that is simply empty.
412    fn env_in(dir: &Path) -> SetupEnv {
413        SetupEnv {
414            config_path: dir.join("config.toml"),
415            agents_dir: dir.join("agents"),
416            roots: import::Roots {
417                home: dir.join("home"),
418                os_config: dir.join("os-config"),
419                xdg_config: dir.join("home").join(".config"),
420                cwd: dir.join("cwd"),
421            },
422            env_lookup: Box::new(|_| None),
423            opener: std::sync::Arc::new(|_| true),
424        }
425    }
426
427    // ─── default_provider retargeting ───────────────────────────────────────
428
429    #[test]
430    fn a_single_non_anthropic_key_becomes_the_default_provider() {
431        // The bug this exists for: setup succeeded, then `lev doctor` said the
432        // install resolved to a provider the user had never configured.
433        let mut config = Config::default();
434        assert_eq!(config.default_provider, "anthropic");
435        apply_flags(
436            &mut config,
437            &SetupArgs {
438                openrouter_key: Some("sk-or-test".to_string()),
439                ..args()
440            },
441        );
442        assert_eq!(config.default_provider, "openrouter");
443    }
444
445    #[test]
446    fn a_reachable_default_provider_is_left_alone() {
447        let mut config = Config::default();
448        apply_flags(
449            &mut config,
450            &SetupArgs {
451                anthropic_key: Some("sk-ant-test".to_string()),
452                openrouter_key: Some("sk-or-test".to_string()),
453                ..args()
454            },
455        );
456        assert_eq!(config.default_provider, "anthropic");
457    }
458
459    #[test]
460    fn a_deliberate_default_provider_survives() {
461        let mut config = Config {
462            default_provider: "google".to_string(),
463            ..Config::default()
464        };
465        apply_flags(
466            &mut config,
467            &SetupArgs {
468                google_key: Some("AIza-test".to_string()),
469                openrouter_key: Some("sk-or-test".to_string()),
470                ..args()
471            },
472        );
473        assert_eq!(config.default_provider, "google");
474    }
475
476    #[test]
477    fn configuring_nothing_leaves_the_default_provider_untouched() {
478        // Nothing to retarget to, so moving it would only make it wrong
479        // differently.
480        let mut config = Config::default();
481        apply_flags(&mut config, &args());
482        assert_eq!(config.default_provider, "anthropic");
483    }
484
485    #[test]
486    fn ollama_is_the_last_provider_considered() {
487        // It needs no key, so it is the one most likely to be present by
488        // accident. Anything the user actually holds a credential for wins.
489        let mut config = Config::default();
490        apply_flags(
491            &mut config,
492            &SetupArgs {
493                ollama_url: Some("http://localhost:11434".to_string()),
494                google_key: Some("AIza-test".to_string()),
495                ..args()
496            },
497        );
498        assert_eq!(config.default_provider, "google");
499
500        let mut ollama_only = Config::default();
501        apply_flags(
502            &mut ollama_only,
503            &SetupArgs {
504                ollama_url: Some("http://localhost:11434".to_string()),
505                ..args()
506            },
507        );
508        assert_eq!(ollama_only.default_provider, "ollama");
509    }
510
511    #[test]
512    fn the_claude_code_transport_counts_as_a_configured_provider() {
513        let mut config = Config::default();
514        apply_flags(
515            &mut config,
516            &SetupArgs {
517                claude_code: Some(true),
518                ..args()
519            },
520        );
521        assert_eq!(config.default_provider, "claude-code");
522    }
523
524    // ─── the non-interactive path ───────────────────────────────────────────
525
526    #[test]
527    fn flags_are_written_to_the_config() {
528        let dir = tempfile::tempdir().unwrap();
529        let env = env_in(dir.path());
530        let args = SetupArgs {
531            non_interactive: true,
532            anthropic_key: Some("sk-ant-x".to_string()),
533            openai_key: Some("sk-oai".to_string()),
534            google_key: Some("goog".to_string()),
535            openrouter_key: Some("sk-or".to_string()),
536            ollama_url: Some("http://box:11434".to_string()),
537            default_model: Some("m".to_string()),
538            claude_code: Some(true),
539            claude_code_effort: Some("xhigh".to_string()),
540            ..args()
541        };
542
543        run_non_interactive(&args, &env).unwrap();
544
545        let written = Config::load_from_path_public(&env.config_path).unwrap();
546        assert_eq!(
547            written.providers.anthropic_api_key.as_deref(),
548            Some("sk-ant-x")
549        );
550        assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
551        assert_eq!(written.providers.google_api_key.as_deref(), Some("goog"));
552        assert_eq!(written.openrouter_api_key.as_deref(), Some("sk-or"));
553        assert_eq!(written.ollama_base_url.as_deref(), Some("http://box:11434"));
554        assert_eq!(written.default_model.as_deref(), Some("m"));
555        assert!(written.providers.claude_code_enabled);
556        assert_eq!(
557            written.providers.claude_code_effort.as_deref(),
558            Some("xhigh")
559        );
560    }
561
562    #[test]
563    fn the_non_interactive_path_installs_agents_only_when_asked() {
564        let dir = tempfile::tempdir().unwrap();
565        let env = env_in(dir.path());
566
567        run_non_interactive(&args(), &env).unwrap();
568        assert!(!env.agents_dir.exists(), "nothing was asked for");
569
570        run_non_interactive(
571            &SetupArgs {
572                install_agents: true,
573                ..args()
574            },
575            &env,
576        )
577        .unwrap();
578        assert!(
579            env.agents_dir.join(BUNDLED_AGENTS[0].name).exists(),
580            "every bundled blueprint should land"
581        );
582
583        // Second time round there is nothing left to do.
584        run_non_interactive(
585            &SetupArgs {
586                install_agents: true,
587                ..args()
588            },
589            &env,
590        )
591        .unwrap();
592        assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
593    }
594
595    #[test]
596    fn the_non_interactive_path_keeps_settings_it_was_not_given() {
597        let dir = tempfile::tempdir().unwrap();
598        let env = env_in(dir.path());
599        run_non_interactive(
600            &SetupArgs {
601                anthropic_key: Some("sk-ant-first".to_string()),
602                ..args()
603            },
604            &env,
605        )
606        .unwrap();
607
608        run_non_interactive(
609            &SetupArgs {
610                openai_key: Some("sk-oai".to_string()),
611                ..args()
612            },
613            &env,
614        )
615        .unwrap();
616
617        let written = Config::load_from_path_public(&env.config_path).unwrap();
618        assert_eq!(
619            written.providers.anthropic_api_key.as_deref(),
620            Some("sk-ant-first")
621        );
622        assert_eq!(written.providers.openai_api_key.as_deref(), Some("sk-oai"));
623    }
624
625    #[test]
626    fn a_config_that_cannot_be_written_is_an_error() {
627        let dir = tempfile::tempdir().unwrap();
628        let blocked = dir.path().join("not-a-dir");
629        std::fs::write(&blocked, "").unwrap();
630        let mut env = env_in(dir.path());
631        env.config_path = blocked.join("config.toml");
632
633        assert!(run_non_interactive(&args(), &env).is_err());
634    }
635
636    // ─── building the wizard from the environment ───────────────────────────
637
638    #[test]
639    fn the_wizard_reads_the_config_file_and_scans_for_harnesses() {
640        let dir = tempfile::tempdir().unwrap();
641        let env = env_in(dir.path());
642        std::fs::create_dir_all(&env.roots.home).unwrap();
643        std::fs::write(
644            env.roots.home.join(".claude.json"),
645            r#"{"mcpServers":{"fs":{"command":"npx"}}}"#,
646        )
647        .unwrap();
648        run_non_interactive(
649            &SetupArgs {
650                anthropic_key: Some("sk-ant-stored".to_string()),
651                ..args()
652            },
653            &env,
654        )
655        .unwrap();
656
657        let wizard = build_wizard(&env);
658
659        assert_eq!(
660            wizard.base.providers.anthropic_api_key.as_deref(),
661            Some("sk-ant-stored")
662        );
663        assert_eq!(wizard.mcp.len(), 1);
664        assert_eq!(wizard.mcp[0].candidate.config.name, "fs");
665    }
666
667    #[test]
668    fn a_missing_config_file_starts_from_defaults() {
669        let dir = tempfile::tempdir().unwrap();
670
671        let wizard = build_wizard(&env_in(dir.path()));
672
673        assert_eq!(
674            wizard.base.default_provider,
675            Config::default().default_provider
676        );
677    }
678
679    // ─── the verification background loop ───────────────────────────────────
680
681    #[tokio::test]
682    async fn the_verification_loop_answers_every_request_then_stops() {
683        let dir = tempfile::tempdir().unwrap();
684        let mut wizard = build_wizard(&env_in(dir.path()));
685        let (requests, replies) = wizard.take_verify_ends().expect("first take");
686        wizard.providers[0].selected = true;
687        wizard.providers[0].value = "sk-ant".to_string();
688        wizard.request_verification(0);
689
690        let handle = tokio::spawn(verification_loop(verify::SkipVerifier, requests, replies));
691        // Dropping the wizard's sender ends the loop.
692        let sender = wizard.verify_tx.clone();
693        drop(sender);
694
695        // Give the loop a turn, then confirm the answer arrived.
696        for _ in 0..50 {
697            wizard.drain_verifications();
698            if !wizard.providers[0].checking {
699                break;
700            }
701            tokio::time::sleep(Duration::from_millis(2)).await;
702        }
703        assert!(!wizard.providers[0].checking);
704        assert_eq!(wizard.providers[0].outcome, verify::Outcome::Skipped);
705
706        drop(wizard);
707        handle.await.expect("the loop exits cleanly");
708    }
709
710    #[tokio::test]
711    async fn the_verification_loop_stops_when_nobody_is_listening() {
712        // The wizard exited mid-check; there is nothing left to report to.
713        let (request_tx, request_rx) = mpsc::unbounded_channel();
714        let (reply_tx, reply_rx) = mpsc::unbounded_channel::<VerifyReply>();
715        request_tx
716            .send(VerifyRequest {
717                provider_id: "anthropic".to_string(),
718                creds: leviath_runtime::provider_creds::ProviderCreds {
719                    name: "anthropic".to_string(),
720                    api_key: Some("sk-ant".to_string()),
721                    base_url: None,
722                    model_capabilities: std::collections::HashMap::new(),
723                    request_timeout_secs: Some(1),
724                    rate_limit: None,
725                    options: std::collections::HashMap::new(),
726                },
727            })
728            .unwrap();
729        drop(reply_rx);
730
731        verification_loop(verify::SkipVerifier, request_rx, reply_tx).await;
732    }
733
734    // ─── the wizard loop ────────────────────────────────────────────────────
735
736    #[tokio::test]
737    async fn quitting_returns_no_plan() {
738        let dir = tempfile::tempdir().unwrap();
739        let mut wizard = build_wizard(&env_in(dir.path()));
740        let mut terminal = test_terminal();
741        let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
742
743        let plan = run_wizard_loop(
744            &mut wizard,
745            &mut terminal,
746            &mut events,
747            Duration::from_millis(1),
748        )
749        .await
750        .unwrap();
751
752        assert!(plan.is_none());
753    }
754
755    #[tokio::test]
756    async fn saving_returns_the_plan_the_wizard_describes() {
757        let dir = tempfile::tempdir().unwrap();
758        let mut wizard = build_wizard(&env_in(dir.path()));
759        let mut terminal = test_terminal();
760        // A tick with no input, then save - covering the poll-timeout path.
761        let mut events = TestEventSource::new_with_nones(vec![
762            None,
763            Some(key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)),
764        ]);
765
766        let plan = run_wizard_loop(
767            &mut wizard,
768            &mut terminal,
769            &mut events,
770            Duration::from_millis(1),
771        )
772        .await
773        .unwrap()
774        .expect("a plan was produced");
775
776        assert_eq!(plan.agents.len(), BUNDLED_AGENTS.len());
777    }
778
779    #[tokio::test]
780    async fn non_press_and_non_key_events_are_ignored() {
781        let dir = tempfile::tempdir().unwrap();
782        let mut wizard = build_wizard(&env_in(dir.path()));
783        let mut terminal = test_terminal();
784        let release = crossterm::event::Event::Key(crossterm::event::KeyEvent::new_with_kind(
785            KeyCode::Char('q'),
786            KeyModifiers::empty(),
787            KeyEventKind::Release,
788        ));
789        let mut events = TestEventSource::new(vec![
790            release,
791            crossterm::event::Event::FocusGained,
792            crossterm::event::Event::Resize(80, 24),
793            key(KeyCode::Char('q')),
794        ]);
795
796        let plan = run_wizard_loop(
797            &mut wizard,
798            &mut terminal,
799            &mut events,
800            Duration::from_millis(1),
801        )
802        .await
803        .unwrap();
804
805        assert!(plan.is_none(), "only the real press quit");
806    }
807
808    #[tokio::test]
809    async fn a_draw_failure_propagates() {
810        let dir = tempfile::tempdir().unwrap();
811        let mut wizard = build_wizard(&env_in(dir.path()));
812        let mut terminal =
813            ratatui::Terminal::new(crate::tui::TestBackendHarness::failing(80, 24)).unwrap();
814        let mut events = TestEventSource::new(vec![]);
815
816        let result = run_wizard_loop(
817            &mut wizard,
818            &mut terminal,
819            &mut events,
820            Duration::from_millis(1),
821        )
822        .await;
823
824        assert!(result.is_err());
825    }
826
827    #[tokio::test]
828    async fn an_event_source_failure_propagates() {
829        let dir = tempfile::tempdir().unwrap();
830        let mut wizard = build_wizard(&env_in(dir.path()));
831        let mut terminal = test_terminal();
832        let mut events = TestEventSource::failing();
833
834        let result = run_wizard_loop(
835            &mut wizard,
836            &mut terminal,
837            &mut events,
838            Duration::from_millis(1),
839        )
840        .await;
841
842        assert!(result.is_err());
843    }
844
845    // ─── the composed command ───────────────────────────────────────────────
846
847    #[tokio::test]
848    async fn saving_writes_the_config_and_installs_the_agents() {
849        let dir = tempfile::tempdir().unwrap();
850        let env = env_in(dir.path());
851        let mut wizard = build_wizard(&env);
852        let mut setup = TestSetup::new();
853        let mut events =
854            TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
855
856        execute_core(&mut wizard, &env, &mut setup, &mut events)
857            .await
858            .unwrap();
859
860        assert!(env.config_path.exists());
861        assert!(env.agents_dir.join(BUNDLED_AGENTS[0].name).exists());
862    }
863
864    #[tokio::test]
865    async fn quitting_writes_nothing() {
866        let dir = tempfile::tempdir().unwrap();
867        let env = env_in(dir.path());
868        let mut wizard = build_wizard(&env);
869        let mut setup = TestSetup::new();
870        let mut events = TestEventSource::new(vec![key(KeyCode::Char('q'))]);
871
872        execute_core(&mut wizard, &env, &mut setup, &mut events)
873            .await
874            .unwrap();
875
876        assert!(
877            !env.config_path.exists(),
878            "nothing should have been written"
879        );
880        assert!(!env.agents_dir.exists());
881    }
882
883    #[tokio::test]
884    async fn a_terminal_that_will_not_start_is_an_error() {
885        let dir = tempfile::tempdir().unwrap();
886        let env = env_in(dir.path());
887        let mut wizard = build_wizard(&env);
888        let mut events = TestEventSource::new(vec![]);
889
890        let mut enable_fails = TestSetup {
891            enable_should_fail: true,
892            create_should_fail: false,
893        };
894        assert!(
895            execute_core(&mut wizard, &env, &mut enable_fails, &mut events)
896                .await
897                .is_err()
898        );
899
900        let mut create_fails = TestSetup {
901            enable_should_fail: false,
902            create_should_fail: true,
903        };
904        assert!(
905            execute_core(&mut wizard, &env, &mut create_fails, &mut events)
906                .await
907                .is_err()
908        );
909    }
910
911    #[tokio::test]
912    async fn a_loop_failure_is_surfaced_after_the_terminal_is_restored() {
913        let dir = tempfile::tempdir().unwrap();
914        let env = env_in(dir.path());
915        let mut wizard = build_wizard(&env);
916        let mut setup = TestSetup::new();
917        let mut events = TestEventSource::failing();
918
919        let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
920
921        assert!(result.is_err());
922        assert!(!env.config_path.exists());
923    }
924
925    #[tokio::test]
926    async fn a_write_failure_after_the_wizard_is_surfaced() {
927        // The terminal must already be restored, or the error would be printed
928        // onto an alternate screen the user never sees again.
929        let dir = tempfile::tempdir().unwrap();
930        let mut env = env_in(dir.path());
931        let blocked = dir.path().join("not-a-dir");
932        std::fs::write(&blocked, "").unwrap();
933        let mut wizard = build_wizard(&env);
934        env.config_path = blocked.join("config.toml");
935        let mut setup = TestSetup::new();
936        let mut events =
937            TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
938
939        let result = execute_core(&mut wizard, &env, &mut setup, &mut events).await;
940
941        assert!(result.is_err());
942    }
943
944    #[tokio::test]
945    async fn execute_with_routes_to_the_flags_path() {
946        let dir = tempfile::tempdir().unwrap();
947        let env = env_in(dir.path());
948        let mut setup = TestSetup::new();
949        let mut events = TestEventSource::new(vec![]);
950
951        execute_with(
952            &SetupArgs {
953                non_interactive: true,
954                anthropic_key: Some("sk-ant-x".to_string()),
955                ..args()
956            },
957            &env,
958            &mut setup,
959            &mut events,
960            false,
961        )
962        .await
963        .unwrap();
964
965        let written = Config::load_from_path_public(&env.config_path).unwrap();
966        assert_eq!(
967            written.providers.anthropic_api_key.as_deref(),
968            Some("sk-ant-x")
969        );
970    }
971
972    #[tokio::test]
973    async fn without_a_terminal_the_wizard_refuses_and_says_what_to_run_instead() {
974        // Starting ratatui on a pipe would take over a terminal that isn't
975        // there.
976        let dir = tempfile::tempdir().unwrap();
977        let env = env_in(dir.path());
978        let mut setup = TestSetup::new();
979        let mut events = TestEventSource::new(vec![]);
980
981        let error = execute_with(&args(), &env, &mut setup, &mut events, false)
982            .await
983            .expect_err("a pipe is not a terminal");
984
985        let message = error.to_string();
986        assert!(message.contains("needs a terminal"), "{message}");
987        assert!(message.contains("--non-interactive"), "{message}");
988        assert!(!env.config_path.exists());
989    }
990
991    #[tokio::test]
992    async fn with_a_terminal_execute_with_runs_the_wizard() {
993        let dir = tempfile::tempdir().unwrap();
994        let env = env_in(dir.path());
995        let mut setup = TestSetup::new();
996        let mut events =
997            TestEventSource::new(vec![key_with(KeyCode::Char('s'), KeyModifiers::CONTROL)]);
998
999        execute_with(&args(), &env, &mut setup, &mut events, true)
1000            .await
1001            .unwrap();
1002
1003        assert!(env.config_path.exists());
1004    }
1005
1006    // ─── reporting ──────────────────────────────────────────────────────────
1007
1008    #[test]
1009    fn the_summary_covers_agents_warnings_and_the_empty_case() {
1010        report(&plan::Applied {
1011            config_path: PathBuf::from("/tmp/config.toml"),
1012            agents_installed: vec!["coder".to_string()],
1013            warnings: vec!["could not install x".to_string()],
1014        });
1015        report(&plan::Applied {
1016            config_path: PathBuf::from("/tmp/config.toml"),
1017            agents_installed: Vec::new(),
1018            warnings: Vec::new(),
1019        });
1020    }
1021
1022    #[test]
1023    fn the_next_step_names_an_installed_agent_when_there_is_one() {
1024        print_next_steps(&plan::Applied {
1025            config_path: PathBuf::from("/tmp/config.toml"),
1026            agents_installed: vec!["coder".to_string()],
1027            warnings: Vec::new(),
1028        });
1029        print_next_steps(&plan::Applied {
1030            config_path: PathBuf::from("/tmp/config.toml"),
1031            agents_installed: Vec::new(),
1032            warnings: Vec::new(),
1033        });
1034    }
1035
1036    #[test]
1037    fn the_real_agents_directory_sits_under_the_leviath_home() {
1038        assert_eq!(
1039            real_agents_dir(Some(Path::new("/home/u"))),
1040            PathBuf::from("/home/u/.leviath/agents")
1041        );
1042        // No home directory resolvable: a relative path, not a panic.
1043        assert_eq!(real_agents_dir(None), PathBuf::from(".leviath/agents"));
1044    }
1045}