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        };
343        assert!(dispatch(Commands::Respond(args), &MockRisky).await.is_ok());
344    }
345
346    #[tokio::test]
347    async fn dispatch_doctor_variant_is_routed_through_the_executor() {
348        // Routed, not called directly: `lev doctor` bills two inferences and
349        // auto-starts a daemon, so a unit test must never reach the real one.
350        let args = commands::doctor::DoctorArgs::default();
351        assert!(dispatch(Commands::Doctor(args), &MockRisky).await.is_ok());
352    }
353
354    #[tokio::test]
355    async fn dispatch_cancel_variant_is_routed_through_the_executor() {
356        let args = commands::ctl::CancelArgs {
357            run_id: "r".to_string(),
358            force: false,
359        };
360        assert!(dispatch(Commands::Cancel(args), &MockRisky).await.is_ok());
361    }
362
363    #[tokio::test]
364    async fn dispatch_pause_variant_is_routed_through_the_executor() {
365        let args = commands::ctl::PauseArgs {
366            run_id: "r".to_string(),
367        };
368        assert!(dispatch(Commands::Pause(args), &MockRisky).await.is_ok());
369    }
370
371    #[tokio::test]
372    async fn dispatch_resume_variant_is_routed_through_the_executor() {
373        let args = commands::ctl::ResumeArgs {
374            run_id: "r".to_string(),
375        };
376        assert!(dispatch(Commands::Resume(args), &MockRisky).await.is_ok());
377    }
378
379    #[tokio::test]
380    async fn dispatch_ps_variant_is_routed_through_the_executor() {
381        let result = dispatch(Commands::Ps(commands::ps::PsArgs::default()), &MockRisky).await;
382        assert!(result.is_ok());
383    }
384
385    #[tokio::test]
386    async fn dispatch_daemon_variant_is_routed_through_the_executor() {
387        let args = commands::daemon::DaemonArgs {
388            action: None,
389            socket: None,
390        };
391        let result = dispatch(Commands::Daemon(args), &MockRisky).await;
392        assert!(result.is_ok());
393    }
394
395    #[tokio::test]
396    async fn dispatch_auth_variant_is_routed_through_the_executor() {
397        let args = commands::auth::AuthArgs::status_for_test();
398        let result = dispatch(Commands::Auth(args), &MockRisky).await;
399        assert!(result.is_ok());
400    }
401
402    #[tokio::test]
403    async fn dispatch_mcp_variant_is_routed_through_the_executor() {
404        let args = commands::mcp::McpArgs::list_for_test();
405        let result = dispatch(Commands::Mcp(args), &MockRisky).await;
406        assert!(result.is_ok());
407    }
408
409    #[tokio::test]
410    async fn dispatch_serve_variant_is_routed_through_the_executor() {
411        let args = commands::serve::ServeArgs {
412            port: 0,
413            host: "127.0.0.1".to_string(),
414            cors: None,
415            token: Some("test-token".to_string()),
416            allow_admin: false,
417            workdir_root: None,
418            no_remote_yolo: false,
419        };
420        let result = dispatch(Commands::Serve(args), &MockRisky).await;
421        assert!(result.is_ok());
422    }
423
424    #[tokio::test]
425    async fn dispatch_agent_client_variant_is_routed_through_the_executor() {
426        let args = commands::agent_client::AgentClientArgs::default();
427        let result = dispatch(Commands::AgentClient(args), &MockRisky).await;
428        assert!(result.is_ok());
429    }
430
431    // ─── Safe variants: called directly, driven through dispatch() ───────────
432
433    #[tokio::test]
434    async fn dispatch_create_variant_is_routed() {
435        // An already-existing directory makes `create::execute` return a real,
436        // harmless `Err` without touching anything outside a tempdir.
437        let dir = tempfile::tempdir().unwrap();
438        let args = commands::create::CreateArgs {
439            name: dir.path().to_str().unwrap().to_string(),
440            ..create_args()
441        };
442        let result = dispatch(Commands::Create(args), &MockRisky).await;
443        assert!(result.is_err());
444    }
445
446    #[tokio::test]
447    async fn dispatch_list_variant_is_routed() {
448        // Isolated: this reaches `Config::load()`, which reads process-wide
449        // environment. Unisolated it races every `temp_env` test in the binary.
450        crate::config::with_isolated_config_path_async("dispatch-list", |_fake_dir| async move {
451            let args = commands::list::ListArgs {
452                filter: "all".to_string(),
453            };
454            let result = dispatch(Commands::List(args), &MockRisky).await;
455            assert!(result.is_ok());
456        })
457        .await;
458    }
459
460    #[tokio::test]
461    async fn dispatch_add_variant_is_routed() {
462        let args = commands::add::AddArgs {
463            package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
464        };
465        // `add` loads the real config to report the `[read_paths]` grant status
466        // of what it installs, so it needs the same isolation every other
467        // config-touching test takes.
468        let result = crate::config::with_isolated_config_path_async("dispatch-add", |_| {
469            dispatch(Commands::Add(args), &MockRisky)
470        })
471        .await;
472        assert!(result.is_err());
473    }
474
475    #[tokio::test]
476    async fn dispatch_remove_variant_is_routed() {
477        let args = commands::remove::RemoveArgs {
478            name: "definitely-not-an-installed-agent-xyz".to_string(),
479        };
480        let result = dispatch(Commands::Remove(args), &MockRisky).await;
481        assert!(result.is_err());
482    }
483
484    #[tokio::test]
485    async fn dispatch_test_variant_is_routed() {
486        let dir = tempfile::tempdir().unwrap();
487        let args = commands::test::TestArgs {
488            path: Some(dir.path().to_str().unwrap().to_string()),
489            filter: None,
490            dry_run: true,
491        };
492        let result = dispatch(Commands::Test(args), &MockRisky).await;
493        assert!(result.is_err());
494    }
495
496    #[tokio::test]
497    async fn dispatch_pack_variant_is_routed() {
498        let dir = tempfile::tempdir().unwrap();
499        let args = commands::pack::PackArgs {
500            path: Some(dir.path().to_str().unwrap().to_string()),
501            output: None,
502        };
503        let result = dispatch(Commands::Pack(args), &MockRisky).await;
504        assert!(result.is_err());
505    }
506
507    #[tokio::test]
508    async fn dispatch_models_variant_is_routed() {
509        crate::config::with_isolated_config_path_async("dispatch-models", |_fake_dir| async move {
510            let args = commands::models::ModelsArgs {
511                command: commands::models::ModelsCommand::List(commands::models::ListArgs {
512                    provider: None,
513                    remote: false,
514                    all: false,
515                }),
516            };
517            let result = dispatch(Commands::Models(args), &MockRisky).await;
518            assert!(result.is_ok());
519        })
520        .await;
521    }
522
523    #[tokio::test]
524    async fn dispatch_validate_variant_is_routed() {
525        // `validate` loads the real config to answer "can this install reach
526        // the providers this blueprint names", so it needs the same isolation
527        // every other config-touching test takes.
528        crate::config::with_isolated_config_path_async("dispatch-validate", |_| async {
529            let dir = tempfile::tempdir().unwrap();
530            let args = commands::validate::ValidateArgs {
531                path: dir
532                    .path()
533                    .join("does-not-exist")
534                    .to_str()
535                    .unwrap()
536                    .to_string(),
537                deny_warnings: false,
538            };
539            let result = dispatch(Commands::Validate(args), &MockRisky).await;
540            assert!(result.is_err());
541        })
542        .await;
543    }
544
545    #[tokio::test]
546    async fn dispatch_tools_variant_is_routed() {
547        // Point LEVIATH_HOME at a temp dir so the scan is hermetic; an empty
548        // tools dir just lists nothing and returns Ok (routing is exercised).
549        let home = tempfile::tempdir().unwrap();
550        let result = temp_env::async_with_vars(
551            [("LEVIATH_HOME", Some(home.path().to_str().unwrap()))],
552            async {
553                let args = commands::tools::ToolsArgs { json: false };
554                dispatch(Commands::Tools(args), &MockRisky).await
555            },
556        )
557        .await;
558        assert!(result.is_ok());
559    }
560
561    #[tokio::test]
562    async fn dispatch_context_variant_is_routed() {
563        // A run with no archive → the command errors (routing is exercised).
564        let args = commands::context::ContextArgs {
565            run_id: "no-such-run-xyzzy".to_string(),
566            json: false,
567            full: false,
568        };
569        let result = dispatch(Commands::Context(args), &MockRisky).await;
570        assert!(result.is_err());
571    }
572
573    #[tokio::test]
574    async fn dispatch_policy_list_variant_is_routed() {
575        let args = commands::policy::PolicyArgs {
576            command: commands::policy::PolicyCommand::List(commands::policy::PolicyListArgs {}),
577        };
578        let result = dispatch(Commands::Policy(args), &MockRisky).await;
579        assert!(result.is_ok());
580    }
581
582    #[tokio::test]
583    async fn dispatch_policy_test_variant_is_routed() {
584        let args = commands::policy::PolicyArgs {
585            command: commands::policy::PolicyCommand::Test(commands::policy::PolicyTestArgs {
586                tool: "shell".to_string(),
587                target: None,
588                taint: "public".to_string(),
589            }),
590        };
591        let result = dispatch(Commands::Policy(args), &MockRisky).await;
592        assert!(result.is_ok());
593    }
594}