Skip to main content

leviath_cli/commands/run/
mod.rs

1//! `lev run` - run an agent in the shared-world daemon.
2//!
3//! `run` resolves the blueprint + task locally and asks the running daemon (auto-
4//! started if needed) to create the agent in the one shared ECS world. The
5//! request-building + daemon exchange live in [`crate::daemon::client`]; this
6//! module keeps the manifest/session/tool-source helpers still shared across the
7//! CLI, and the `RunArgs` the binary wires into that path.
8
9pub mod manifest;
10pub mod session;
11
12use std::collections::HashMap;
13
14use clap::Args;
15
16// Re-export the provider-registry builders used by the daemon setup.
17pub use session::{
18    ProviderCreds, build_provider_registry, build_provider_registry_from_config,
19    provider_creds_from_config,
20};
21
22/// Arguments for `lev run`.
23#[derive(Args, Debug, Clone, Default)]
24pub struct RunArgs {
25    /// Path to the agent (a manifest file, its directory, or an installed name).
26    #[arg(value_name = "PATH")]
27    pub path: Option<String>,
28
29    /// Task prompt for the agent.
30    #[arg(short, long)]
31    pub task: Option<String>,
32
33    /// Model override (`provider/model` or a bare model name).
34    #[arg(short, long)]
35    pub model: Option<String>,
36
37    /// Run unattended: approve every tool call, and answer the agent's own
38    /// prompts (ask_user_*, plan approvals) instead of waiting for a person.
39    #[arg(long)]
40    pub yolo: bool,
41
42    /// Allow a tool outright (repeatable).
43    #[arg(long)]
44    pub allow: Vec<String>,
45
46    /// Override the blueprint's max sub-agent tree depth.
47    #[arg(long)]
48    pub max_depth: Option<usize>,
49
50    /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
51    /// shell command at spawn - before the first inference, and so before any
52    /// approval prompt. See `lev validate <path>` to inspect them first.
53    #[arg(long)]
54    pub no_seed_commands: bool,
55
56    /// Dynamic per-region seed flags (`--<region> <text|@file>`), collected by an
57    /// argv pre-scan in the binary since region names are blueprint-defined.
58    /// clap skips this field; it is populated after parsing.
59    #[arg(skip)]
60    pub regions: HashMap<String, String>,
61}
62
63/// The `run` subcommand's own long flags - everything NOT in this set is treated
64/// as a dynamic `--<region>` seed flag by [`extract_region_flags`].
65const KNOWN_RUN_FLAGS: &[&str] = &[
66    "task",
67    "model",
68    "yolo",
69    "allow",
70    "max-depth",
71    "no-seed-commands",
72    "verbose",
73    "help",
74    "version",
75];
76
77/// Pre-scan a full argv (program name first) for dynamic `--<region>` flags on
78/// the `run` subcommand, since region names are blueprint-defined and clap can't
79/// declare them. Returns `(argv_for_clap, region_flags)`: a `--<name>` (or
80/// `--<name>=<value>`) whose `<name>` is not a known `run` flag is pulled out
81/// (with its value) into the map; every other token passes through untouched.
82///
83/// A no-`=` region flag consumes the following token as its value. If argv has
84/// no `run` subcommand token, nothing is extracted (the returned argv equals the
85/// input). Pure - no environment or I/O - so it is unit-testable in isolation.
86pub fn extract_region_flags(argv: Vec<String>) -> (Vec<String>, HashMap<String, String>) {
87    // Locate the subcommand: the first bareword (non-`-`) token after the program
88    // name. Only activate when it is `run`.
89    let sub_pos = argv
90        .iter()
91        .enumerate()
92        .skip(1)
93        .find(|(_, t)| !t.starts_with('-'))
94        .map(|(i, _)| i);
95    let Some(sub_pos) = sub_pos else {
96        return (argv, HashMap::new());
97    };
98    if argv[sub_pos] != "run" {
99        return (argv, HashMap::new());
100    }
101
102    let mut out: Vec<String> = argv[..=sub_pos].to_vec();
103    let mut regions = HashMap::new();
104    let mut i = sub_pos + 1;
105    while i < argv.len() {
106        let token = &argv[i];
107        if let Some(name) = token.strip_prefix("--") {
108            // Split an `=`-joined value if present.
109            let (name, inline) = match name.split_once('=') {
110                Some((n, v)) => (n, Some(v.to_string())),
111                None => (name, None),
112            };
113            if !name.is_empty() && !KNOWN_RUN_FLAGS.contains(&name) {
114                let value = match inline {
115                    Some(v) => v,
116                    None => {
117                        // Consume the next token as the value, if any.
118                        i += 1;
119                        argv.get(i).cloned().unwrap_or_default()
120                    }
121                };
122                regions.insert(name.to_string(), value);
123                i += 1;
124                continue;
125            }
126        }
127        out.push(token.clone());
128        i += 1;
129    }
130    (out, regions)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn argv(parts: &[&str]) -> Vec<String> {
138        parts.iter().map(|s| s.to_string()).collect()
139    }
140
141    #[test]
142    fn extracts_dynamic_region_flags_and_preserves_known_ones() {
143        let (out, regions) = extract_region_flags(argv(&[
144            "lev",
145            "run",
146            "agents/reviewer",
147            "--task",
148            "review it",
149            "--files",
150            "@src/main.rs",
151            "--review-criteria",
152            "@policy.md",
153            "--yolo",
154        ]));
155        // Known flags + positional pass through to clap.
156        assert_eq!(
157            out,
158            argv(&[
159                "lev",
160                "run",
161                "agents/reviewer",
162                "--task",
163                "review it",
164                "--yolo",
165            ])
166        );
167        assert_eq!(
168            regions.get("files").map(String::as_str),
169            Some("@src/main.rs")
170        );
171        assert_eq!(
172            regions.get("review-criteria").map(String::as_str),
173            Some("@policy.md")
174        );
175    }
176
177    #[test]
178    fn extracts_equals_joined_region_flag() {
179        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--criteria=be safe"]));
180        assert_eq!(out, argv(&["lev", "run", "a"]));
181        assert_eq!(regions.get("criteria").map(String::as_str), Some("be safe"));
182    }
183
184    #[test]
185    fn no_region_flags_leaves_argv_unchanged() {
186        let input = argv(&["lev", "run", "a", "--task", "t"]);
187        let (out, regions) = extract_region_flags(input.clone());
188        assert_eq!(out, input);
189        assert!(regions.is_empty());
190    }
191
192    #[test]
193    fn non_run_subcommand_is_untouched() {
194        // A dynamic-looking flag on another subcommand is left for clap to reject.
195        let input = argv(&["lev", "ps", "--weird", "x"]);
196        let (out, regions) = extract_region_flags(input.clone());
197        assert_eq!(out, input);
198        assert!(regions.is_empty());
199    }
200
201    #[test]
202    fn no_subcommand_token_is_untouched() {
203        // Only flags, no bareword subcommand → nothing extracted.
204        let input = argv(&["lev", "--verbose"]);
205        let (out, regions) = extract_region_flags(input.clone());
206        assert_eq!(out, input);
207        assert!(regions.is_empty());
208    }
209
210    #[test]
211    fn trailing_region_flag_without_value_maps_to_empty() {
212        // A dynamic flag at the very end with no following value → empty string.
213        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--spec"]));
214        assert_eq!(out, argv(&["lev", "run", "a"]));
215        assert_eq!(regions.get("spec").map(String::as_str), Some(""));
216    }
217
218    #[test]
219    fn global_verbose_before_run_still_activates() {
220        let (_out, regions) = extract_region_flags(argv(&["lev", "-v", "run", "a", "--spec", "x"]));
221        assert_eq!(regions.get("spec").map(String::as_str), Some("x"));
222    }
223}