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