Skip to main content

leviath_cli/
dispatch.rs

1//! Command dispatch: the `Commands` enum and the `dispatch()` function that
2//! routes a parsed subcommand to its executor.
3//!
4//! This lives in the library crate (not `main.rs`) so its routing logic can be
5//! unit-tested under `cargo llvm-cov`'s `--lib` scope. The subcommands whose
6//! real execution performs I/O a unit test must never trigger - a real
7//! terminal takeover (`dash`), blocking stdin (`setup` interactive,
8//! foreground `run`), binding a real port (`serve`), spawning a detached
9//! worker or running a real inference loop (`run` background / `__run-worker`) -
10//! are routed through the [`RiskyExecutors`] trait rather than called
11//! directly. That way:
12//!
13//! * unit tests drive `dispatch()`'s full routing match against a
14//!   `#[cfg(test)]` mock (`MockRisky`) that touches nothing real, and
15//! * the real implementations live in the (coverage-unmeasured) `lev` binary
16//!   as `main.rs`'s `RealExecutors`, which simply wires real I/O into the
17//!   library's already-tested command cores.
18//!
19//! Injection gives a "routing is tested, real I/O is never touched by a test"
20//! guarantee without any coverage escape hatch in library code.
21
22use crate::commands;
23
24/// Every `lev` subcommand. Each variant's doc comment is what `--help` prints.
25#[derive(clap::Subcommand)]
26pub enum Commands {
27    /// Create a new agent blueprint
28    Create(commands::create::CreateArgs),
29
30    /// Configure API keys and defaults
31    Setup(commands::setup::SetupArgs),
32
33    /// Run an agent
34    Run(commands::run::RunArgs),
35
36    /// List agents running in the shared-world daemon
37    #[command(long_about = commands::ps::PS_LONG_ABOUT)]
38    Ps(commands::ps::PsArgs),
39
40    /// Send a message to a running agent
41    Msg(commands::ctl::MsgArgs),
42
43    /// Cancel a running agent (alias: `kill`)
44    #[command(alias = "kill")]
45    Cancel(commands::ctl::CancelArgs),
46
47    /// Pause a running agent (it finishes its in-flight step, then holds)
48    Pause(commands::ctl::PauseArgs),
49
50    /// Resume a paused agent
51    Resume(commands::ctl::ResumeArgs),
52
53    /// Answer a pending interaction (or list open ones with no request id)
54    Respond(commands::ctl::RespondArgs),
55
56    /// Check that provider wiring works, end to end
57    #[command(long_about = commands::doctor::DOCTOR_LONG_ABOUT)]
58    Doctor(commands::doctor::DoctorArgs),
59
60    /// List available and installed blueprints
61    List(commands::list::ListArgs),
62
63    /// Install a blueprint
64    Add(commands::add::AddArgs),
65
66    /// Remove an installed blueprint
67    Remove(commands::remove::RemoveArgs),
68
69    /// Run blueprint tests
70    Test(commands::test::TestArgs),
71
72    /// Bundle a blueprint for distribution
73    Pack(commands::pack::PackArgs),
74
75    /// Interactive agent dashboard
76    #[command(name = "dash")]
77    Dashboard(commands::dashboard::DashboardArgs),
78
79    /// List and inspect available models
80    Models(commands::models::ModelsArgs),
81
82    /// Validate an agent blueprint
83    Validate(commands::validate::ValidateArgs),
84
85    /// List and validate the global Rhai script tools
86    Tools(commands::tools::ToolsArgs),
87
88    /// Show what runs without an approval prompt, and why
89    Approvals(commands::approvals::ApprovalsArgs),
90
91    /// Manage taint tracking policy rules
92    Policy(commands::policy::PolicyArgs),
93
94    /// Start the REST + WebSocket API server
95    Serve(commands::serve::ServeArgs),
96
97    /// Serve this agent over the Agent Client Protocol (JSON-RPC over stdio)
98    #[command(name = "agent-client")]
99    AgentClient(commands::agent_client::AgentClientArgs),
100
101    /// Run the shared-world daemon in the foreground
102    Daemon(commands::daemon::DaemonArgs),
103
104    /// Show a run's context-window history (from its run.lvr archive)
105    Context(commands::context::ContextArgs),
106
107    /// Show a run's per-stage token ledger, where a staged agent's cost lives
108    Stages(commands::stages::StagesArgs),
109
110    /// Print what an agent handed back when a run finished
111    Result(commands::result::ResultArgs),
112
113    /// Manage MCP tool servers and their authentication
114    Mcp(commands::mcp::McpArgs),
115
116    /// Inspect and move the secrets Leviath holds
117    Auth(commands::auth::AuthArgs),
118}
119
120/// The subset of commands whose real execution performs I/O that a unit test
121/// must never trigger. `dispatch()` routes these through this trait so its
122/// routing logic stays unit-testable with a mock; the real implementations are
123/// supplied by the binary (`main.rs`'s `RealExecutors`).
124///
125/// `async fn` in a trait is fine here: `dispatch` takes `&impl RiskyExecutors`
126/// (static dispatch, no `dyn`), so no boxing or `Send` bound is required.
127pub trait RiskyExecutors {
128    // Each method returns `impl Future` rather than being an `async fn`, so what
129    // the future promises is stated rather than inferred.
130    //
131    // Deliberately **not** `+ Send`. These run on the CLI's single-threaded
132    // entry path and hold non-`Send` state across awaits - the daemon-readiness
133    // poll takes a `&mut dyn FnMut() -> bool`, and the TUI paths hold terminal
134    // handles. Adding the bound does not compile, which is the useful answer:
135    // an `async fn` here left that unsaid, and this says it.
136    /// `lev run` - auto-starts the daemon (real process spawn) if needed and
137    /// spawns the agent into the shared world over the control socket.
138    fn run(
139        &self,
140        args: commands::run::RunArgs,
141    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
142    /// `lev ps` - resolves the control-socket path and queries the daemon.
143    fn ps(
144        &self,
145        args: commands::ps::PsArgs,
146    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
147    /// `lev msg` - resolves the control-socket path and sends a message.
148    fn msg(
149        &self,
150        args: commands::ctl::MsgArgs,
151    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
152    /// `lev cancel` - resolves the control-socket path and cancels a run.
153    fn cancel(
154        &self,
155        args: commands::ctl::CancelArgs,
156    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
157    /// `lev pause` - resolves the control-socket path and pauses a run.
158    fn pause(
159        &self,
160        args: commands::ctl::PauseArgs,
161    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
162    /// `lev resume` - resolves the control-socket path and resumes a run.
163    fn resume(
164        &self,
165        args: commands::ctl::ResumeArgs,
166    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
167    /// `lev respond` - resolves the control-socket path and answers/lists interactions.
168    fn respond(
169        &self,
170        args: commands::ctl::RespondArgs,
171    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
172    /// `lev doctor` - makes real billed inference calls, and (unless
173    /// `--no-daemon`) auto-starts the daemon and spawns a throwaway run.
174    fn doctor(
175        &self,
176        args: commands::doctor::DoctorArgs,
177    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
178    /// `lev setup` - interactive (blocking stdin) or `--non-interactive`.
179    fn setup(
180        &self,
181        args: commands::setup::SetupArgs,
182    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
183    /// `lev dash` - takes over the real terminal and blocks on real keyboard input.
184    fn dashboard(
185        &self,
186        args: commands::dashboard::DashboardArgs,
187    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
188    /// `lev serve` - binds a real port and serves indefinitely.
189    fn serve(
190        &self,
191        args: commands::serve::ServeArgs,
192    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
193    /// `lev agent-client` - takes over real stdin/stdout to speak the Agent
194    /// Client Protocol against the shared-world daemon.
195    fn agent_client(
196        &self,
197        args: commands::agent_client::AgentClientArgs,
198    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
199    /// `lev daemon` - binds the control socket and serves the shared world.
200    fn daemon(
201        &self,
202        args: commands::daemon::DaemonArgs,
203    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
204    /// `lev mcp` - rewrites config, opens a browser for OAuth, touches the token store.
205    fn mcp(
206        &self,
207        args: commands::mcp::McpArgs,
208    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
209
210    /// `lev auth` - reads the config file and may write the OS credential store.
211    fn auth(
212        &self,
213        args: commands::auth::AuthArgs,
214    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
215}
216
217/// Inject argv-prescanned dynamic `--<region>` seed flags into a parsed
218/// `run` command. A no-op for every other subcommand. Kept here (a tested lib
219/// seam) so the bin entrypoint's post-parse wiring stays branch-free.
220pub fn apply_region_flags(
221    command: &mut Commands,
222    regions: std::collections::HashMap<String, String>,
223) {
224    if let Commands::Run(args) = command {
225        args.regions = regions;
226    }
227}
228
229/// Route a parsed subcommand to its executor. Safe commands are called
230/// directly (and are exercised through `dispatch()` by the tests below); the
231/// I/O-risky ones go through `ex` (see [`RiskyExecutors`]).
232pub async fn dispatch(command: Commands, ex: &impl RiskyExecutors) -> anyhow::Result<()> {
233    match command {
234        Commands::Create(args) => commands::create::execute(args).await,
235        Commands::Setup(args) => ex.setup(args).await,
236        Commands::Run(args) => ex.run(args).await,
237        Commands::Ps(args) => ex.ps(args).await,
238        Commands::Msg(args) => ex.msg(args).await,
239        Commands::Cancel(args) => ex.cancel(args).await,
240        Commands::Pause(args) => ex.pause(args).await,
241        Commands::Resume(args) => ex.resume(args).await,
242        Commands::Respond(args) => ex.respond(args).await,
243        Commands::Doctor(args) => ex.doctor(args).await,
244        Commands::List(args) => commands::list::execute(args).await,
245        Commands::Add(args) => commands::add::execute(args).await,
246        Commands::Remove(args) => commands::remove::execute(args).await,
247        Commands::Test(args) => commands::test::execute(args).await,
248        Commands::Pack(args) => commands::pack::execute(args).await,
249        Commands::Dashboard(args) => ex.dashboard(args).await,
250        Commands::Models(args) => commands::models::execute(args).await,
251        Commands::Validate(args) => commands::validate::execute(args).await,
252        Commands::Tools(args) => commands::tools::execute(args).await,
253        Commands::Approvals(args) => commands::approvals::execute(args).await,
254        Commands::Policy(args) => commands::policy::execute(args).await,
255        Commands::Serve(args) => ex.serve(args).await,
256        Commands::AgentClient(args) => ex.agent_client(args).await,
257        Commands::Daemon(args) => ex.daemon(args).await,
258        Commands::Context(args) => commands::context::execute(args).await,
259        Commands::Stages(args) => commands::stages::execute(args).await,
260        Commands::Result(args) => commands::result::execute(args).await,
261        Commands::Mcp(args) => ex.mcp(args).await,
262        Commands::Auth(args) => ex.auth(args).await,
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    /// Test double for [`RiskyExecutors`]: every method is a no-op returning
271    /// `Ok(())`, so `dispatch()`'s risky routing arms are exercised without
272    /// touching a real terminal / stdin / port / subprocess.
273    struct MockRisky;
274
275    impl RiskyExecutors for MockRisky {
276        async fn run(&self, _args: commands::run::RunArgs) -> anyhow::Result<()> {
277            Ok(())
278        }
279        async fn ps(&self, _args: commands::ps::PsArgs) -> anyhow::Result<()> {
280            Ok(())
281        }
282        async fn msg(&self, _args: commands::ctl::MsgArgs) -> anyhow::Result<()> {
283            Ok(())
284        }
285        async fn respond(&self, _args: commands::ctl::RespondArgs) -> anyhow::Result<()> {
286            Ok(())
287        }
288        async fn doctor(&self, _args: commands::doctor::DoctorArgs) -> anyhow::Result<()> {
289            Ok(())
290        }
291        async fn cancel(&self, _args: commands::ctl::CancelArgs) -> anyhow::Result<()> {
292            Ok(())
293        }
294        async fn pause(&self, _args: commands::ctl::PauseArgs) -> anyhow::Result<()> {
295            Ok(())
296        }
297        async fn resume(&self, _args: commands::ctl::ResumeArgs) -> anyhow::Result<()> {
298            Ok(())
299        }
300        async fn setup(&self, _args: commands::setup::SetupArgs) -> anyhow::Result<()> {
301            Ok(())
302        }
303        async fn dashboard(&self, _args: commands::dashboard::DashboardArgs) -> anyhow::Result<()> {
304            Ok(())
305        }
306        async fn serve(&self, _args: commands::serve::ServeArgs) -> anyhow::Result<()> {
307            Ok(())
308        }
309        async fn agent_client(
310            &self,
311            _args: commands::agent_client::AgentClientArgs,
312        ) -> anyhow::Result<()> {
313            Ok(())
314        }
315        async fn daemon(&self, _args: commands::daemon::DaemonArgs) -> anyhow::Result<()> {
316            Ok(())
317        }
318        async fn auth(&self, _args: commands::auth::AuthArgs) -> anyhow::Result<()> {
319            Ok(())
320        }
321
322        async fn mcp(&self, _args: commands::mcp::McpArgs) -> anyhow::Result<()> {
323            Ok(())
324        }
325    }
326
327    fn create_args() -> commands::create::CreateArgs {
328        commands::create::CreateArgs {
329            name: "unused".to_string(),
330            template: "software-engineer".to_string(),
331        }
332    }
333
334    // ─── apply_region_flags ──────────────────────────────────────────────────
335
336    #[test]
337    fn apply_region_flags_populates_run_and_noops_other_commands() {
338        let mut run = Commands::Run(commands::run::RunArgs::default());
339        let flags = std::collections::HashMap::from([("criteria".to_string(), "safe".to_string())]);
340        apply_region_flags(&mut run, flags);
341        assert!(
342            matches!(&run, Commands::Run(a) if a.regions.get("criteria").map(String::as_str) == Some("safe")),
343            "region flag was injected into the Run args"
344        );
345        // A non-run command hits the no-op branch: it must not panic (and there
346        // is nothing to inject). Asserting the variant here would leave an
347        // always-false `matches!` arm uncovered, so the call itself is the check.
348        let mut other = Commands::Ps(commands::ps::PsArgs::default());
349        apply_region_flags(&mut other, std::collections::HashMap::new());
350    }
351
352    // ─── Risky variants: routed through the injected executor ────────────────
353
354    #[tokio::test]
355    async fn dispatch_run_variant_is_routed_through_the_executor() {
356        let result = dispatch(Commands::Run(commands::run::RunArgs::default()), &MockRisky).await;
357        assert!(result.is_ok());
358    }
359
360    #[tokio::test]
361    async fn dispatch_setup_variant_is_routed_through_the_executor() {
362        let args = commands::setup::SetupArgs {
363            non_interactive: true,
364            no_verify: false,
365            install_agents: false,
366            anthropic_key: None,
367            openai_key: None,
368            google_key: None,
369            openrouter_key: None,
370            ollama_url: None,
371            default_model: None,
372            claude_code: None,
373            claude_code_effort: None,
374        };
375        let result = dispatch(Commands::Setup(args), &MockRisky).await;
376        assert!(result.is_ok());
377    }
378
379    #[tokio::test]
380    async fn dispatch_dashboard_variant_is_routed_through_the_executor() {
381        let args = commands::dashboard::DashboardArgs {};
382        let result = dispatch(Commands::Dashboard(args), &MockRisky).await;
383        assert!(result.is_ok());
384    }
385
386    #[tokio::test]
387    async fn dispatch_msg_variant_is_routed_through_the_executor() {
388        let args = commands::ctl::MsgArgs {
389            agent_id: "a".to_string(),
390            content: "c".to_string(),
391        };
392        assert!(dispatch(Commands::Msg(args), &MockRisky).await.is_ok());
393    }
394
395    #[tokio::test]
396    async fn dispatch_respond_variant_is_routed_through_the_executor() {
397        let args = commands::ctl::RespondArgs {
398            request_id: None,
399            value: None,
400            choice: None,
401            approve: false,
402            deny: false,
403            session: false,
404            stage: false,
405            json: false,
406        };
407        assert!(dispatch(Commands::Respond(args), &MockRisky).await.is_ok());
408    }
409
410    #[tokio::test]
411    async fn dispatch_doctor_variant_is_routed_through_the_executor() {
412        // Routed, not called directly: `lev doctor` bills two inferences and
413        // auto-starts a daemon, so a unit test must never reach the real one.
414        let args = commands::doctor::DoctorArgs::default();
415        assert!(dispatch(Commands::Doctor(args), &MockRisky).await.is_ok());
416    }
417
418    #[tokio::test]
419    async fn dispatch_cancel_variant_is_routed_through_the_executor() {
420        let args = commands::ctl::CancelArgs {
421            run_id: "r".to_string(),
422            force: false,
423        };
424        assert!(dispatch(Commands::Cancel(args), &MockRisky).await.is_ok());
425    }
426
427    #[tokio::test]
428    async fn dispatch_pause_variant_is_routed_through_the_executor() {
429        let args = commands::ctl::PauseArgs {
430            run_id: "r".to_string(),
431        };
432        assert!(dispatch(Commands::Pause(args), &MockRisky).await.is_ok());
433    }
434
435    #[tokio::test]
436    async fn dispatch_resume_variant_is_routed_through_the_executor() {
437        let args = commands::ctl::ResumeArgs {
438            run_id: "r".to_string(),
439        };
440        assert!(dispatch(Commands::Resume(args), &MockRisky).await.is_ok());
441    }
442
443    #[tokio::test]
444    async fn dispatch_ps_variant_is_routed_through_the_executor() {
445        let result = dispatch(Commands::Ps(commands::ps::PsArgs::default()), &MockRisky).await;
446        assert!(result.is_ok());
447    }
448
449    #[tokio::test]
450    async fn dispatch_daemon_variant_is_routed_through_the_executor() {
451        let args = commands::daemon::DaemonArgs {
452            action: None,
453            socket: None,
454        };
455        let result = dispatch(Commands::Daemon(args), &MockRisky).await;
456        assert!(result.is_ok());
457    }
458
459    #[tokio::test]
460    async fn dispatch_auth_variant_is_routed_through_the_executor() {
461        let args = commands::auth::AuthArgs::status_for_test();
462        let result = dispatch(Commands::Auth(args), &MockRisky).await;
463        assert!(result.is_ok());
464    }
465
466    #[tokio::test]
467    async fn dispatch_mcp_variant_is_routed_through_the_executor() {
468        let args = commands::mcp::McpArgs::list_for_test();
469        let result = dispatch(Commands::Mcp(args), &MockRisky).await;
470        assert!(result.is_ok());
471    }
472
473    #[tokio::test]
474    async fn dispatch_serve_variant_is_routed_through_the_executor() {
475        let args = commands::serve::ServeArgs {
476            port: 0,
477            host: "127.0.0.1".to_string(),
478            cors: None,
479            token: Some("test-token".to_string()),
480            allow_admin: false,
481            workdir_root: None,
482            no_remote_yolo: false,
483            tls_cert: None,
484            tls_key: None,
485        };
486        let result = dispatch(Commands::Serve(args), &MockRisky).await;
487        assert!(result.is_ok());
488    }
489
490    #[tokio::test]
491    async fn dispatch_agent_client_variant_is_routed_through_the_executor() {
492        let args = commands::agent_client::AgentClientArgs::default();
493        let result = dispatch(Commands::AgentClient(args), &MockRisky).await;
494        assert!(result.is_ok());
495    }
496
497    // ─── Safe variants: called directly, driven through dispatch() ───────────
498
499    #[tokio::test]
500    async fn dispatch_create_variant_is_routed() {
501        // An already-existing directory makes `create::execute` return a real,
502        // harmless `Err` without touching anything outside a tempdir.
503        let dir = tempfile::tempdir().unwrap();
504        let args = commands::create::CreateArgs {
505            name: dir.path().to_str().unwrap().to_string(),
506            ..create_args()
507        };
508        let result = dispatch(Commands::Create(args), &MockRisky).await;
509        assert!(result.is_err());
510    }
511
512    #[tokio::test]
513    async fn dispatch_list_variant_is_routed() {
514        // Isolated: this reaches `Config::load()`, which reads process-wide
515        // environment. Unisolated it races every `temp_env` test in the binary.
516        crate::config::with_isolated_config_path_async("dispatch-list", |_fake_dir| async move {
517            let args = commands::list::ListArgs {
518                filter: commands::list::ListFilter::All,
519                json: false,
520            };
521            let result = dispatch(Commands::List(args), &MockRisky).await;
522            assert!(result.is_ok());
523        })
524        .await;
525    }
526
527    #[tokio::test]
528    async fn dispatch_add_variant_is_routed() {
529        let args = commands::add::AddArgs {
530            package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
531        };
532        // `add` loads the real config to report the `[read_paths]` grant status
533        // of what it installs, so it needs the same isolation every other
534        // config-touching test takes.
535        let result = crate::config::with_isolated_config_path_async("dispatch-add", |_| {
536            dispatch(Commands::Add(args), &MockRisky)
537        })
538        .await;
539        assert!(result.is_err());
540    }
541
542    #[tokio::test]
543    async fn dispatch_remove_variant_is_routed() {
544        let args = commands::remove::RemoveArgs {
545            name: "definitely-not-an-installed-agent-xyz".to_string(),
546        };
547        let result = dispatch(Commands::Remove(args), &MockRisky).await;
548        assert!(result.is_err());
549    }
550
551    #[tokio::test]
552    async fn dispatch_test_variant_is_routed() {
553        let dir = tempfile::tempdir().unwrap();
554        let args = commands::test::TestArgs {
555            path: Some(dir.path().to_str().unwrap().to_string()),
556            filter: None,
557            dry_run: true,
558        };
559        let result = dispatch(Commands::Test(args), &MockRisky).await;
560        assert!(result.is_err());
561    }
562
563    #[tokio::test]
564    async fn dispatch_pack_variant_is_routed() {
565        let dir = tempfile::tempdir().unwrap();
566        let args = commands::pack::PackArgs {
567            path: Some(dir.path().to_str().unwrap().to_string()),
568            output: None,
569        };
570        let result = dispatch(Commands::Pack(args), &MockRisky).await;
571        assert!(result.is_err());
572    }
573
574    #[tokio::test]
575    async fn dispatch_models_variant_is_routed() {
576        crate::config::with_isolated_config_path_async("dispatch-models", |_fake_dir| async move {
577            let args = commands::models::ModelsArgs {
578                command: commands::models::ModelsCommand::List(commands::models::ListArgs {
579                    provider: None,
580                    remote: false,
581                    all: false,
582                    json: false,
583                }),
584            };
585            let result = dispatch(Commands::Models(args), &MockRisky).await;
586            assert!(result.is_ok());
587        })
588        .await;
589    }
590
591    #[tokio::test]
592    async fn dispatch_validate_variant_is_routed() {
593        // `validate` loads the real config to answer "can this install reach
594        // the providers this blueprint names", so it needs the same isolation
595        // every other config-touching test takes.
596        crate::config::with_isolated_config_path_async("dispatch-validate", |_| async {
597            let dir = tempfile::tempdir().unwrap();
598            let args = commands::validate::ValidateArgs {
599                path: dir
600                    .path()
601                    .join("does-not-exist")
602                    .to_str()
603                    .unwrap()
604                    .to_string(),
605                deny_warnings: false,
606                json: false,
607            };
608            let result = dispatch(Commands::Validate(args), &MockRisky).await;
609            assert!(result.is_err());
610        })
611        .await;
612    }
613
614    #[tokio::test]
615    async fn dispatch_tools_variant_is_routed() {
616        // Point LEVIATH_HOME at a temp dir so the scan is hermetic; an empty
617        // tools dir just lists nothing and returns Ok (routing is exercised).
618        let home = tempfile::tempdir().unwrap();
619        let result = temp_env::async_with_vars(
620            [("LEVIATH_HOME", Some(home.path().to_str().unwrap()))],
621            async {
622                let args = commands::tools::ToolsArgs { json: false };
623                dispatch(Commands::Tools(args), &MockRisky).await
624            },
625        )
626        .await;
627        assert!(result.is_ok());
628    }
629
630    #[tokio::test]
631    async fn dispatch_routes_stages() {
632        // A run id that does not exist: the point is that the arm is wired to
633        // the command, not what the command finds.
634        let result = dispatch(
635            Commands::Stages(commands::stages::StagesArgs {
636                run_id: "no-such-run".to_string(),
637                json: false,
638                regions: false,
639            }),
640            &MockRisky,
641        )
642        .await;
643        assert!(result.is_err(), "no ledger for a run that never ran");
644    }
645
646    #[tokio::test]
647    async fn dispatch_context_variant_is_routed() {
648        // A run with no archive → the command errors (routing is exercised).
649        let args = commands::context::ContextArgs {
650            run_id: "no-such-run-xyzzy".to_string(),
651            json: false,
652            full: false,
653        };
654        let result = dispatch(Commands::Context(args), &MockRisky).await;
655        assert!(result.is_err());
656    }
657
658    #[tokio::test]
659    async fn dispatch_result_variant_is_routed() {
660        // A run that is not there → the command errors, which is what shows the
661        // routing reached it.
662        let args = commands::result::ResultArgs {
663            run_id: "no-such-run-xyzzy".to_string(),
664            json: false,
665            raw: false,
666        };
667        let result = dispatch(Commands::Result(args), &MockRisky).await;
668        assert!(result.is_err());
669    }
670
671    #[tokio::test]
672    async fn dispatch_approvals_variant_is_routed() {
673        // A temp home means an empty config, so the report is the shipped
674        // defaults and nothing touches the user's own file.
675        let home = tempfile::tempdir().unwrap();
676        let config = home.path().join("config.toml");
677        let result = temp_env::async_with_vars(
678            [
679                ("LEVIATH_HOME", Some(home.path().to_str().unwrap())),
680                ("LEVIATH_CONFIG_PATH", Some(config.to_str().unwrap())),
681            ],
682            async {
683                // Both spellings: with an agent named, and without, which is
684                // the form that reports only what every agent gets.
685                let args = commands::approvals::ApprovalsArgs {
686                    command: commands::approvals::ApprovalsCommand::Safe(
687                        commands::approvals::SafeArgs {
688                            agent: Some("coder".to_string()),
689                            json: true,
690                        },
691                    ),
692                };
693                dispatch(Commands::Approvals(args), &MockRisky).await
694            },
695        )
696        .await;
697        assert!(result.is_ok());
698    }
699
700    /// A config that will not parse has to surface, not be reported as "these
701    /// are your defaults" - the whole point of the command is telling the user
702    /// what is actually in effect.
703    #[tokio::test]
704    async fn dispatch_approvals_surfaces_a_broken_config() {
705        let home = tempfile::tempdir().unwrap();
706        let config = home.path().join("config.toml");
707        std::fs::write(&config, "this is not = = toml").unwrap();
708        let result = temp_env::async_with_vars(
709            [
710                ("LEVIATH_HOME", Some(home.path().to_str().unwrap())),
711                ("LEVIATH_CONFIG_PATH", Some(config.to_str().unwrap())),
712            ],
713            async {
714                let args = commands::approvals::ApprovalsArgs {
715                    command: commands::approvals::ApprovalsCommand::Safe(
716                        commands::approvals::SafeArgs {
717                            agent: None,
718                            json: false,
719                        },
720                    ),
721                };
722                dispatch(Commands::Approvals(args), &MockRisky).await
723            },
724        )
725        .await;
726        assert!(result.is_err());
727    }
728
729    #[tokio::test]
730    async fn dispatch_policy_list_variant_is_routed() {
731        let args = commands::policy::PolicyArgs {
732            command: commands::policy::PolicyCommand::List(commands::policy::PolicyListArgs {}),
733        };
734        let result = dispatch(Commands::Policy(args), &MockRisky).await;
735        assert!(result.is_ok());
736    }
737
738    #[tokio::test]
739    async fn dispatch_policy_test_variant_is_routed() {
740        let args = commands::policy::PolicyArgs {
741            command: commands::policy::PolicyCommand::Test(commands::policy::PolicyTestArgs {
742                tool: "shell".to_string(),
743                target: None,
744                taint: "public".to_string(),
745            }),
746        };
747        let result = dispatch(Commands::Policy(args), &MockRisky).await;
748        assert!(result.is_ok());
749    }
750}