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;
11pub mod task;
12
13use std::collections::HashMap;
14
15use clap::Args;
16
17// Re-export the provider-registry builders used by the daemon setup.
18pub use session::{
19    ProviderCreds, build_provider_registry, build_provider_registry_from_config,
20    provider_creds_from_config,
21};
22
23/// Arguments for `lev run`.
24#[derive(Args, Debug, Clone, Default)]
25pub struct RunArgs {
26    /// Path to the agent (a manifest file, its directory, or an installed name).
27    #[arg(value_name = "PATH")]
28    pub path: Option<String>,
29
30    /// Task prompt, or the path of a file holding it. Left off, your editor
31    /// opens on a template for you to write it in.
32    #[arg(short, long, value_name = "TEXT|FILE")]
33    pub task: Option<String>,
34
35    /// Model override (`provider/model` or a bare model name).
36    #[arg(short, long)]
37    pub model: Option<String>,
38
39    /// Run unattended: approve every tool call, and answer the agent's own
40    /// prompts (ask_user_*, plan approvals) instead of waiting for a person.
41    #[arg(long)]
42    pub yolo: bool,
43
44    /// Allow a tool outright (repeatable).
45    #[arg(long)]
46    pub allow: Vec<String>,
47
48    /// Override the blueprint's max sub-agent tree depth.
49    #[arg(long)]
50    pub max_depth: Option<usize>,
51
52    /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
53    /// shell command at spawn - before the first inference, and so before any
54    /// approval prompt. See `lev validate <path>` to inspect them first.
55    #[arg(long)]
56    pub no_seed_commands: bool,
57
58    /// Working directory for the run (default: the directory `lev run` is
59    /// invoked from). The agent's file tools are confined to it, and relative
60    /// `[read_paths]` entries resolve against it.
61    #[arg(long, value_name = "DIR")]
62    pub workdir: Option<std::path::PathBuf>,
63
64    /// Dynamic per-region seed flags (`--<region> <text|@file>`), collected by an
65    /// argv pre-scan in the binary since region names are blueprint-defined.
66    /// clap skips this field; it is populated after parsing.
67    #[arg(skip)]
68    pub regions: HashMap<String, String>,
69}
70
71/// The `run` subcommand's own long flags - everything NOT in this set is treated
72/// as a dynamic `--<region>` seed flag by [`extract_region_flags`].
73const KNOWN_RUN_FLAGS: &[&str] = &[
74    "task",
75    "model",
76    "yolo",
77    "allow",
78    "max-depth",
79    "no-seed-commands",
80    "workdir",
81    "verbose",
82    "help",
83    "version",
84];
85
86/// The run's effective working directory: the `--workdir` flag when given
87/// (canonicalized, and refused early when it does not exist - a bad workdir
88/// would otherwise spawn an agent whose every tool call fails), else `cwd`
89/// (the directory the command was invoked from, resolved by the caller).
90pub fn effective_workdir(
91    flag: Option<std::path::PathBuf>,
92    cwd: std::path::PathBuf,
93) -> anyhow::Result<String> {
94    let dir = match flag {
95        Some(dir) => {
96            let canonical = std::fs::canonicalize(&dir).map_err(|e| {
97                anyhow::anyhow!(
98                    "--workdir '{}' is not a usable directory: {e}",
99                    dir.display()
100                )
101            })?;
102            if !canonical.is_dir() {
103                anyhow::bail!("--workdir '{}' is not a directory", dir.display());
104            }
105            canonical
106        }
107        None => cwd,
108    };
109    Ok(dir.to_string_lossy().to_string())
110}
111
112/// Pre-scan a full argv (program name first) for dynamic `--<region>` flags on
113/// the `run` subcommand, since region names are blueprint-defined and clap can't
114/// declare them. Returns `(argv_for_clap, region_flags)`: a `--<name>` (or
115/// `--<name>=<value>`) whose `<name>` is not a known `run` flag is pulled out
116/// (with its value) into the map; every other token passes through untouched.
117///
118/// A no-`=` region flag consumes the following token as its value. If argv has
119/// no `run` subcommand token, nothing is extracted (the returned argv equals the
120/// input). Pure - no environment or I/O - so it is unit-testable in isolation.
121pub fn extract_region_flags(argv: Vec<String>) -> (Vec<String>, HashMap<String, String>) {
122    // Locate the subcommand: the first bareword (non-`-`) token after the program
123    // name. Only activate when it is `run`.
124    let sub_pos = argv
125        .iter()
126        .enumerate()
127        .skip(1)
128        .find(|(_, t)| !t.starts_with('-'))
129        .map(|(i, _)| i);
130    let Some(sub_pos) = sub_pos else {
131        return (argv, HashMap::new());
132    };
133    if argv[sub_pos] != "run" {
134        return (argv, HashMap::new());
135    }
136
137    let mut out: Vec<String> = argv[..=sub_pos].to_vec();
138    let mut regions = HashMap::new();
139    let mut i = sub_pos + 1;
140    while i < argv.len() {
141        let token = &argv[i];
142        if let Some(name) = token.strip_prefix("--") {
143            // Split an `=`-joined value if present.
144            let (name, inline) = match name.split_once('=') {
145                Some((n, v)) => (n, Some(v.to_string())),
146                None => (name, None),
147            };
148            if !name.is_empty() && !KNOWN_RUN_FLAGS.contains(&name) {
149                let value = match inline {
150                    Some(v) => v,
151                    None => {
152                        // Consume the next token as the value, if any.
153                        i += 1;
154                        argv.get(i).cloned().unwrap_or_default()
155                    }
156                };
157                regions.insert(name.to_string(), value);
158                i += 1;
159                continue;
160            }
161        }
162        out.push(token.clone());
163        i += 1;
164    }
165    (out, regions)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn argv(parts: &[&str]) -> Vec<String> {
173        parts.iter().map(|s| s.to_string()).collect()
174    }
175
176    #[test]
177    fn extracts_dynamic_region_flags_and_preserves_known_ones() {
178        let (out, regions) = extract_region_flags(argv(&[
179            "lev",
180            "run",
181            "agents/reviewer",
182            "--task",
183            "review it",
184            "--files",
185            "@src/main.rs",
186            "--review-criteria",
187            "@policy.md",
188            "--yolo",
189        ]));
190        // Known flags + positional pass through to clap.
191        assert_eq!(
192            out,
193            argv(&[
194                "lev",
195                "run",
196                "agents/reviewer",
197                "--task",
198                "review it",
199                "--yolo",
200            ])
201        );
202        assert_eq!(
203            regions.get("files").map(String::as_str),
204            Some("@src/main.rs")
205        );
206        assert_eq!(
207            regions.get("review-criteria").map(String::as_str),
208            Some("@policy.md")
209        );
210    }
211
212    #[test]
213    fn extracts_equals_joined_region_flag() {
214        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--criteria=be safe"]));
215        assert_eq!(out, argv(&["lev", "run", "a"]));
216        assert_eq!(regions.get("criteria").map(String::as_str), Some("be safe"));
217    }
218
219    #[test]
220    fn no_region_flags_leaves_argv_unchanged() {
221        let input = argv(&["lev", "run", "a", "--task", "t"]);
222        let (out, regions) = extract_region_flags(input.clone());
223        assert_eq!(out, input);
224        assert!(regions.is_empty());
225    }
226
227    #[test]
228    fn non_run_subcommand_is_untouched() {
229        // A dynamic-looking flag on another subcommand is left for clap to reject.
230        let input = argv(&["lev", "ps", "--weird", "x"]);
231        let (out, regions) = extract_region_flags(input.clone());
232        assert_eq!(out, input);
233        assert!(regions.is_empty());
234    }
235
236    #[test]
237    fn no_subcommand_token_is_untouched() {
238        // Only flags, no bareword subcommand → nothing extracted.
239        let input = argv(&["lev", "--verbose"]);
240        let (out, regions) = extract_region_flags(input.clone());
241        assert_eq!(out, input);
242        assert!(regions.is_empty());
243    }
244
245    #[test]
246    fn trailing_region_flag_without_value_maps_to_empty() {
247        // A dynamic flag at the very end with no following value → empty string.
248        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--spec"]));
249        assert_eq!(out, argv(&["lev", "run", "a"]));
250        assert_eq!(regions.get("spec").map(String::as_str), Some(""));
251    }
252
253    #[test]
254    fn global_verbose_before_run_still_activates() {
255        let (_out, regions) = extract_region_flags(argv(&["lev", "-v", "run", "a", "--spec", "x"]));
256        assert_eq!(regions.get("spec").map(String::as_str), Some("x"));
257    }
258
259    /// `--workdir` is a real run flag: the pre-scan must pass it through to
260    /// clap, not swallow it as a region seed named "workdir".
261    #[test]
262    fn workdir_flag_is_not_eaten_as_a_region() {
263        let input = argv(&["lev", "run", "a", "--workdir", "/elsewhere", "--task", "t"]);
264        let (out, regions) = extract_region_flags(input.clone());
265        assert_eq!(out, input);
266        assert!(regions.is_empty());
267    }
268
269    #[test]
270    fn effective_workdir_uses_the_flag_canonicalized() {
271        let dir = tempfile::tempdir().unwrap();
272        let got = effective_workdir(
273            Some(dir.path().to_path_buf()),
274            std::path::PathBuf::from("/unused"),
275        )
276        .unwrap();
277        assert_eq!(
278            got,
279            std::fs::canonicalize(dir.path())
280                .unwrap()
281                .to_string_lossy()
282                .to_string()
283        );
284    }
285
286    #[test]
287    fn effective_workdir_defaults_to_the_supplied_cwd() {
288        let cwd = tempfile::tempdir().unwrap();
289        let got = effective_workdir(None, cwd.path().to_path_buf()).unwrap();
290        assert_eq!(got, cwd.path().to_string_lossy().to_string());
291    }
292
293    /// A bad `--workdir` fails before the daemon is contacted - otherwise it
294    /// spawns an agent whose every tool call fails.
295    #[test]
296    fn effective_workdir_refuses_a_missing_or_non_directory_path() {
297        let cwd = std::path::PathBuf::from("/unused");
298        let err = effective_workdir(
299            Some(std::path::PathBuf::from("/definitely/not/a/real/dir")),
300            cwd.clone(),
301        )
302        .unwrap_err();
303        assert!(err.to_string().contains("not a usable directory"), "{err}");
304
305        let dir = tempfile::tempdir().unwrap();
306        let file = dir.path().join("f.txt");
307        std::fs::write(&file, "x").unwrap();
308        let err = effective_workdir(Some(file), cwd).unwrap_err();
309        assert!(err.to_string().contains("not a directory"), "{err}");
310    }
311}