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#[derive(clap::Subcommand)]
25pub enum Commands {
26    /// Create a new agent blueprint
27    Create(commands::create::CreateArgs),
28
29    /// Configure API keys and defaults
30    Setup(commands::setup::SetupArgs),
31
32    /// Run an agent
33    Run(commands::run::RunArgs),
34
35    /// List agents running in the shared-world daemon
36    #[command(long_about = commands::ps::PS_LONG_ABOUT)]
37    Ps(commands::ps::PsArgs),
38
39    /// Send a message to a running agent
40    Msg(commands::ctl::MsgArgs),
41
42    /// Cancel a running agent (alias: `kill`)
43    #[command(alias = "kill")]
44    Cancel(commands::ctl::CancelArgs),
45
46    /// Pause a running agent (it finishes its in-flight step, then holds)
47    Pause(commands::ctl::PauseArgs),
48
49    /// Resume a paused agent
50    Resume(commands::ctl::ResumeArgs),
51
52    /// Answer a pending interaction (or list open ones with no request id)
53    Respond(commands::ctl::RespondArgs),
54
55    /// Check that provider wiring works, end to end
56    #[command(long_about = commands::doctor::DOCTOR_LONG_ABOUT)]
57    Doctor(commands::doctor::DoctorArgs),
58
59    /// List available and installed blueprints
60    List(commands::list::ListArgs),
61
62    /// Install a blueprint
63    Add(commands::add::AddArgs),
64
65    /// Remove an installed blueprint
66    Remove(commands::remove::RemoveArgs),
67
68    /// Run blueprint tests
69    Test(commands::test::TestArgs),
70
71    /// Bundle a blueprint for distribution
72    Pack(commands::pack::PackArgs),
73
74    /// Interactive agent dashboard
75    #[command(name = "dash")]
76    Dashboard(commands::dashboard::DashboardArgs),
77
78    /// List and inspect available models
79    Models(commands::models::ModelsArgs),
80
81    /// Validate an agent blueprint
82    Validate(commands::validate::ValidateArgs),
83
84    /// List and validate the global Rhai script tools
85    Tools(commands::tools::ToolsArgs),
86
87    /// Manage taint tracking policy rules
88    Policy(commands::policy::PolicyArgs),
89
90    /// Start the REST + WebSocket API server
91    Serve(commands::serve::ServeArgs),
92
93    /// Serve this agent over the Agent Client Protocol (JSON-RPC over stdio)
94    #[command(name = "agent-client")]
95    AgentClient(commands::agent_client::AgentClientArgs),
96
97    /// Run the shared-world daemon in the foreground
98    Daemon(commands::daemon::DaemonArgs),
99
100    /// Show a run's context-window history (from its run.lvr archive)
101    Context(commands::context::ContextArgs),
102
103    /// Manage MCP tool servers and their authentication
104    Mcp(commands::mcp::McpArgs),
105
106    /// Inspect and move the secrets Leviath holds
107    Auth(commands::auth::AuthArgs),
108}
109
110/// The subset of commands whose real execution performs I/O that a unit test
111/// must never trigger. `dispatch()` routes these through this trait so its
112/// routing logic stays unit-testable with a mock; the real implementations are
113/// supplied by the binary (`main.rs`'s `RealExecutors`).
114///
115/// `async fn` in a trait is fine here: `dispatch` takes `&impl RiskyExecutors`
116/// (static dispatch, no `dyn`), so no boxing or `Send` bound is required.
117#[allow(async_fn_in_trait)]
118pub trait RiskyExecutors {
119    /// `lev run` - auto-starts the daemon (real process spawn) if needed and
120    /// spawns the agent into the shared world over the control socket.
121    async fn run(&self, args: commands::run::RunArgs) -> anyhow::Result<()>;
122    /// `lev ps` - resolves the control-socket path and queries the daemon.
123    async fn ps(&self, args: commands::ps::PsArgs) -> anyhow::Result<()>;
124    /// `lev msg` - resolves the control-socket path and sends a message.
125    async fn msg(&self, args: commands::ctl::MsgArgs) -> anyhow::Result<()>;
126    /// `lev cancel` - resolves the control-socket path and cancels a run.
127    async fn cancel(&self, args: commands::ctl::CancelArgs) -> anyhow::Result<()>;
128    /// `lev pause` - resolves the control-socket path and pauses a run.
129    async fn pause(&self, args: commands::ctl::PauseArgs) -> anyhow::Result<()>;
130    /// `lev resume` - resolves the control-socket path and resumes a run.
131    async fn resume(&self, args: commands::ctl::ResumeArgs) -> anyhow::Result<()>;
132    /// `lev respond` - resolves the control-socket path and answers/lists interactions.
133    async fn respond(&self, args: commands::ctl::RespondArgs) -> anyhow::Result<()>;
134    /// `lev doctor` - makes real billed inference calls, and (unless
135    /// `--no-daemon`) auto-starts the daemon and spawns a throwaway run.
136    async fn doctor(&self, args: commands::doctor::DoctorArgs) -> anyhow::Result<()>;
137    /// `lev setup` - interactive (blocking stdin) or `--non-interactive`.
138    async fn setup(&self, args: commands::setup::SetupArgs) -> anyhow::Result<()>;
139    /// `lev dash` - takes over the real terminal and blocks on real keyboard input.
140    async fn dashboard(&self, args: commands::dashboard::DashboardArgs) -> anyhow::Result<()>;
141    /// `lev serve` - binds a real port and serves indefinitely.
142    async fn serve(&self, args: commands::serve::ServeArgs) -> anyhow::Result<()>;
143    /// `lev agent-client` - takes over real stdin/stdout to speak the Agent
144    /// Client Protocol against the shared-world daemon.
145    async fn agent_client(
146        &self,
147        args: commands::agent_client::AgentClientArgs,
148    ) -> anyhow::Result<()>;
149    /// `lev daemon` - binds the control socket and serves the shared world.
150    async fn daemon(&self, args: commands::daemon::DaemonArgs) -> anyhow::Result<()>;
151    /// `lev mcp` - rewrites config, opens a browser for OAuth, touches the token store.
152    async fn mcp(&self, args: commands::mcp::McpArgs) -> anyhow::Result<()>;
153
154    /// `lev auth` - reads the config file and may write the OS credential store.
155    async fn auth(&self, args: commands::auth::AuthArgs) -> anyhow::Result<()>;
156}
157
158/// Inject argv-prescanned dynamic `--<region>` seed flags into a parsed
159/// `run` command. A no-op for every other subcommand. Kept here (a tested lib
160/// seam) so the bin entrypoint's post-parse wiring stays branch-free.
161pub fn apply_region_flags(
162    command: &mut Commands,
163    regions: std::collections::HashMap<String, String>,
164) {
165    if let Commands::Run(args) = command {
166        args.regions = regions;
167    }
168}
169
170/// Route a parsed subcommand to its executor. Safe commands are called
171/// directly (and are exercised through `dispatch()` by the tests below); the
172/// I/O-risky ones go through `ex` (see [`RiskyExecutors`]).
173pub async fn dispatch(command: Commands, ex: &impl RiskyExecutors) -> anyhow::Result<()> {
174    match command {
175        Commands::Create(args) => commands::create::execute(args).await,
176        Commands::Setup(args) => ex.setup(args).await,
177        Commands::Run(args) => ex.run(args).await,
178        Commands::Ps(args) => ex.ps(args).await,
179        Commands::Msg(args) => ex.msg(args).await,
180        Commands::Cancel(args) => ex.cancel(args).await,
181        Commands::Pause(args) => ex.pause(args).await,
182        Commands::Resume(args) => ex.resume(args).await,
183        Commands::Respond(args) => ex.respond(args).await,
184        Commands::Doctor(args) => ex.doctor(args).await,
185        Commands::List(args) => commands::list::execute(args).await,
186        Commands::Add(args) => commands::add::execute(args).await,
187        Commands::Remove(args) => commands::remove::execute(args).await,
188        Commands::Test(args) => commands::test::execute(args).await,
189        Commands::Pack(args) => commands::pack::execute(args).await,
190        Commands::Dashboard(args) => ex.dashboard(args).await,
191        Commands::Models(args) => commands::models::execute(args).await,
192        Commands::Validate(args) => commands::validate::execute(args).await,
193        Commands::Tools(args) => commands::tools::execute(args).await,
194        Commands::Policy(args) => commands::policy::execute(args).await,
195        Commands::Serve(args) => ex.serve(args).await,
196        Commands::AgentClient(args) => ex.agent_client(args).await,
197        Commands::Daemon(args) => ex.daemon(args).await,
198        Commands::Context(args) => commands::context::execute(args).await,
199        Commands::Mcp(args) => ex.mcp(args).await,
200        Commands::Auth(args) => ex.auth(args).await,
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// Test double for [`RiskyExecutors`]: every method is a no-op returning
209    /// `Ok(())`, so `dispatch()`'s risky routing arms are exercised without
210    /// touching a real terminal / stdin / port / subprocess.
211    struct MockRisky;
212
213    impl RiskyExecutors for MockRisky {
214        async fn run(&self, _args: commands::run::RunArgs) -> anyhow::Result<()> {
215            Ok(())
216        }
217        async fn ps(&self, _args: commands::ps::PsArgs) -> anyhow::Result<()> {
218            Ok(())
219        }
220        async fn msg(&self, _args: commands::ctl::MsgArgs) -> anyhow::Result<()> {
221            Ok(())
222        }
223        async fn respond(&self, _args: commands::ctl::RespondArgs) -> anyhow::Result<()> {
224            Ok(())
225        }
226        async fn doctor(&self, _args: commands::doctor::DoctorArgs) -> anyhow::Result<()> {
227            Ok(())
228        }
229        async fn cancel(&self, _args: commands::ctl::CancelArgs) -> anyhow::Result<()> {
230            Ok(())
231        }
232        async fn pause(&self, _args: commands::ctl::PauseArgs) -> anyhow::Result<()> {
233            Ok(())
234        }
235        async fn resume(&self, _args: commands::ctl::ResumeArgs) -> anyhow::Result<()> {
236            Ok(())
237        }
238        async fn setup(&self, _args: commands::setup::SetupArgs) -> anyhow::Result<()> {
239            Ok(())
240        }
241        async fn dashboard(&self, _args: commands::dashboard::DashboardArgs) -> anyhow::Result<()> {
242            Ok(())
243        }
244        async fn serve(&self, _args: commands::serve::ServeArgs) -> anyhow::Result<()> {
245            Ok(())
246        }
247        async fn agent_client(
248            &self,
249            _args: commands::agent_client::AgentClientArgs,
250        ) -> anyhow::Result<()> {
251            Ok(())
252        }
253        async fn daemon(&self, _args: commands::daemon::DaemonArgs) -> anyhow::Result<()> {
254            Ok(())
255        }
256        async fn auth(&self, _args: commands::auth::AuthArgs) -> anyhow::Result<()> {
257            Ok(())
258        }
259
260        async fn mcp(&self, _args: commands::mcp::McpArgs) -> anyhow::Result<()> {
261            Ok(())
262        }
263    }
264
265    fn create_args() -> commands::create::CreateArgs {
266        commands::create::CreateArgs {
267            name: "unused".to_string(),
268            template: "software-engineer".to_string(),
269        }
270    }
271
272    // ─── apply_region_flags ──────────────────────────────────────────────────
273
274    #[test]
275    fn apply_region_flags_populates_run_and_noops_other_commands() {
276        let mut run = Commands::Run(commands::run::RunArgs::default());
277        let flags = std::collections::HashMap::from([("criteria".to_string(), "safe".to_string())]);
278        apply_region_flags(&mut run, flags);
279        assert!(
280            matches!(&run, Commands::Run(a) if a.regions.get("criteria").map(String::as_str) == Some("safe")),
281            "region flag was injected into the Run args"
282        );
283        // A non-run command hits the no-op branch: it must not panic (and there
284        // is nothing to inject). Asserting the variant here would leave an
285        // always-false `matches!` arm uncovered, so the call itself is the check.
286        let mut other = Commands::Ps(commands::ps::PsArgs::default());
287        apply_region_flags(&mut other, std::collections::HashMap::new());
288    }
289
290    // ─── Risky variants: routed through the injected executor ────────────────
291
292    #[tokio::test]
293    async fn dispatch_run_variant_is_routed_through_the_executor() {
294        let result = dispatch(Commands::Run(commands::run::RunArgs::default()), &MockRisky).await;
295        assert!(result.is_ok());
296    }
297
298    #[tokio::test]
299    async fn dispatch_setup_variant_is_routed_through_the_executor() {
300        let args = commands::setup::SetupArgs {
301            non_interactive: true,
302            no_verify: false,
303            install_agents: false,
304            anthropic_key: None,
305            openai_key: None,
306            google_key: None,
307            openrouter_key: None,
308            ollama_url: None,
309            default_model: None,
310            claude_code: None,
311            claude_code_effort: None,
312        };
313        let result = dispatch(Commands::Setup(args), &MockRisky).await;
314        assert!(result.is_ok());
315    }
316
317    #[tokio::test]
318    async fn dispatch_dashboard_variant_is_routed_through_the_executor() {
319        let args = commands::dashboard::DashboardArgs {};
320        let result = dispatch(Commands::Dashboard(args), &MockRisky).await;
321        assert!(result.is_ok());
322    }
323
324    #[tokio::test]
325    async fn dispatch_msg_variant_is_routed_through_the_executor() {
326        let args = commands::ctl::MsgArgs {
327            agent_id: "a".to_string(),
328            content: "c".to_string(),
329        };
330        assert!(dispatch(Commands::Msg(args), &MockRisky).await.is_ok());
331    }
332
333    #[tokio::test]
334    async fn dispatch_respond_variant_is_routed_through_the_executor() {
335        let args = commands::ctl::RespondArgs {
336            request_id: None,
337            value: None,
338            choice: None,
339            approve: false,
340            deny: false,
341            session: false,
342            json: false,
343        };
344        assert!(dispatch(Commands::Respond(args), &MockRisky).await.is_ok());
345    }
346
347    #[tokio::test]
348    async fn dispatch_doctor_variant_is_routed_through_the_executor() {
349        // Routed, not called directly: `lev doctor` bills two inferences and
350        // auto-starts a daemon, so a unit test must never reach the real one.
351        let args = commands::doctor::DoctorArgs::default();
352        assert!(dispatch(Commands::Doctor(args), &MockRisky).await.is_ok());
353    }
354
355    #[tokio::test]
356    async fn dispatch_cancel_variant_is_routed_through_the_executor() {
357        let args = commands::ctl::CancelArgs {
358            run_id: "r".to_string(),
359            force: false,
360        };
361        assert!(dispatch(Commands::Cancel(args), &MockRisky).await.is_ok());
362    }
363
364    #[tokio::test]
365    async fn dispatch_pause_variant_is_routed_through_the_executor() {
366        let args = commands::ctl::PauseArgs {
367            run_id: "r".to_string(),
368        };
369        assert!(dispatch(Commands::Pause(args), &MockRisky).await.is_ok());
370    }
371
372    #[tokio::test]
373    async fn dispatch_resume_variant_is_routed_through_the_executor() {
374        let args = commands::ctl::ResumeArgs {
375            run_id: "r".to_string(),
376        };
377        assert!(dispatch(Commands::Resume(args), &MockRisky).await.is_ok());
378    }
379
380    #[tokio::test]
381    async fn dispatch_ps_variant_is_routed_through_the_executor() {
382        let result = dispatch(Commands::Ps(commands::ps::PsArgs::default()), &MockRisky).await;
383        assert!(result.is_ok());
384    }
385
386    #[tokio::test]
387    async fn dispatch_daemon_variant_is_routed_through_the_executor() {
388        let args = commands::daemon::DaemonArgs {
389            action: None,
390            socket: None,
391        };
392        let result = dispatch(Commands::Daemon(args), &MockRisky).await;
393        assert!(result.is_ok());
394    }
395
396    #[tokio::test]
397    async fn dispatch_auth_variant_is_routed_through_the_executor() {
398        let args = commands::auth::AuthArgs::status_for_test();
399        let result = dispatch(Commands::Auth(args), &MockRisky).await;
400        assert!(result.is_ok());
401    }
402
403    #[tokio::test]
404    async fn dispatch_mcp_variant_is_routed_through_the_executor() {
405        let args = commands::mcp::McpArgs::list_for_test();
406        let result = dispatch(Commands::Mcp(args), &MockRisky).await;
407        assert!(result.is_ok());
408    }
409
410    #[tokio::test]
411    async fn dispatch_serve_variant_is_routed_through_the_executor() {
412        let args = commands::serve::ServeArgs {
413            port: 0,
414            host: "127.0.0.1".to_string(),
415            cors: None,
416            token: Some("test-token".to_string()),
417            allow_admin: false,
418            workdir_root: None,
419            no_remote_yolo: false,
420        };
421        let result = dispatch(Commands::Serve(args), &MockRisky).await;
422        assert!(result.is_ok());
423    }
424
425    #[tokio::test]
426    async fn dispatch_agent_client_variant_is_routed_through_the_executor() {
427        let args = commands::agent_client::AgentClientArgs::default();
428        let result = dispatch(Commands::AgentClient(args), &MockRisky).await;
429        assert!(result.is_ok());
430    }
431
432    // ─── Safe variants: called directly, driven through dispatch() ───────────
433
434    #[tokio::test]
435    async fn dispatch_create_variant_is_routed() {
436        // An already-existing directory makes `create::execute` return a real,
437        // harmless `Err` without touching anything outside a tempdir.
438        let dir = tempfile::tempdir().unwrap();
439        let args = commands::create::CreateArgs {
440            name: dir.path().to_str().unwrap().to_string(),
441            ..create_args()
442        };
443        let result = dispatch(Commands::Create(args), &MockRisky).await;
444        assert!(result.is_err());
445    }
446
447    #[tokio::test]
448    async fn dispatch_list_variant_is_routed() {
449        // Isolated: this reaches `Config::load()`, which reads process-wide
450        // environment. Unisolated it races every `temp_env` test in the binary.
451        crate::config::with_isolated_config_path_async("dispatch-list", |_fake_dir| async move {
452            let args = commands::list::ListArgs {
453                filter: "all".to_string(),
454                json: false,
455            };
456            let result = dispatch(Commands::List(args), &MockRisky).await;
457            assert!(result.is_ok());
458        })
459        .await;
460    }
461
462    #[tokio::test]
463    async fn dispatch_add_variant_is_routed() {
464        let args = commands::add::AddArgs {
465            package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
466        };
467        // `add` loads the real config to report the `[read_paths]` grant status
468        // of what it installs, so it needs the same isolation every other
469        // config-touching test takes.
470        let result = crate::config::with_isolated_config_path_async("dispatch-add", |_| {
471            dispatch(Commands::Add(args), &MockRisky)
472        })
473        .await;
474        assert!(result.is_err());
475    }
476
477    #[tokio::test]
478    async fn dispatch_remove_variant_is_routed() {
479        let args = commands::remove::RemoveArgs {
480            name: "definitely-not-an-installed-agent-xyz".to_string(),
481        };
482        let result = dispatch(Commands::Remove(args), &MockRisky).await;
483        assert!(result.is_err());
484    }
485
486    #[tokio::test]
487    async fn dispatch_test_variant_is_routed() {
488        let dir = tempfile::tempdir().unwrap();
489        let args = commands::test::TestArgs {
490            path: Some(dir.path().to_str().unwrap().to_string()),
491            filter: None,
492            dry_run: true,
493        };
494        let result = dispatch(Commands::Test(args), &MockRisky).await;
495        assert!(result.is_err());
496    }
497
498    #[tokio::test]
499    async fn dispatch_pack_variant_is_routed() {
500        let dir = tempfile::tempdir().unwrap();
501        let args = commands::pack::PackArgs {
502            path: Some(dir.path().to_str().unwrap().to_string()),
503            output: None,
504        };
505        let result = dispatch(Commands::Pack(args), &MockRisky).await;
506        assert!(result.is_err());
507    }
508
509    #[tokio::test]
510    async fn dispatch_models_variant_is_routed() {
511        crate::config::with_isolated_config_path_async("dispatch-models", |_fake_dir| async move {
512            let args = commands::models::ModelsArgs {
513                command: commands::models::ModelsCommand::List(commands::models::ListArgs {
514                    provider: None,
515                    remote: false,
516                    all: false,
517                    json: false,
518                }),
519            };
520            let result = dispatch(Commands::Models(args), &MockRisky).await;
521            assert!(result.is_ok());
522        })
523        .await;
524    }
525
526    #[tokio::test]
527    async fn dispatch_validate_variant_is_routed() {
528        // `validate` loads the real config to answer "can this install reach
529        // the providers this blueprint names", so it needs the same isolation
530        // every other config-touching test takes.
531        crate::config::with_isolated_config_path_async("dispatch-validate", |_| async {
532            let dir = tempfile::tempdir().unwrap();
533            let args = commands::validate::ValidateArgs {
534                path: dir
535                    .path()
536                    .join("does-not-exist")
537                    .to_str()
538                    .unwrap()
539                    .to_string(),
540                deny_warnings: false,
541                json: false,
542            };
543            let result = dispatch(Commands::Validate(args), &MockRisky).await;
544            assert!(result.is_err());
545        })
546        .await;
547    }
548
549    #[tokio::test]
550    async fn dispatch_tools_variant_is_routed() {
551        // Point LEVIATH_HOME at a temp dir so the scan is hermetic; an empty
552        // tools dir just lists nothing and returns Ok (routing is exercised).
553        let home = tempfile::tempdir().unwrap();
554        let result = temp_env::async_with_vars(
555            [("LEVIATH_HOME", Some(home.path().to_str().unwrap()))],
556            async {
557                let args = commands::tools::ToolsArgs { json: false };
558                dispatch(Commands::Tools(args), &MockRisky).await
559            },
560        )
561        .await;
562        assert!(result.is_ok());
563    }
564
565    #[tokio::test]
566    async fn dispatch_context_variant_is_routed() {
567        // A run with no archive → the command errors (routing is exercised).
568        let args = commands::context::ContextArgs {
569            run_id: "no-such-run-xyzzy".to_string(),
570            json: false,
571            full: false,
572        };
573        let result = dispatch(Commands::Context(args), &MockRisky).await;
574        assert!(result.is_err());
575    }
576
577    #[tokio::test]
578    async fn dispatch_policy_list_variant_is_routed() {
579        let args = commands::policy::PolicyArgs {
580            command: commands::policy::PolicyCommand::List(commands::policy::PolicyListArgs {}),
581        };
582        let result = dispatch(Commands::Policy(args), &MockRisky).await;
583        assert!(result.is_ok());
584    }
585
586    #[tokio::test]
587    async fn dispatch_policy_test_variant_is_routed() {
588        let args = commands::policy::PolicyArgs {
589            command: commands::policy::PolicyCommand::Test(commands::policy::PolicyTestArgs {
590                tool: "shell".to_string(),
591                target: None,
592                taint: "public".to_string(),
593            }),
594        };
595        let result = dispatch(Commands::Policy(args), &MockRisky).await;
596        assert!(result.is_ok());
597    }
598}