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_*, interaction points) instead of waiting for a person.
41    ///
42    /// One exception, and it is the one that looks like a hang: an interaction
43    /// point declaring `unattended = "ask"` still holds for a person. The
44    /// bundled software-engineer's plan approval does, deliberately, because
45    /// everything after it writes code. Such a run parks in `Waiting` until
46    /// `[limits] interaction_timeout_secs` (default 3600) releases it. Lower
47    /// that to bound the wait.
48    #[arg(long)]
49    pub yolo: bool,
50
51    /// Allow a tool outright (repeatable).
52    #[arg(long)]
53    pub allow: Vec<String>,
54
55    /// Override the blueprint's max sub-agent tree depth.
56    #[arg(long)]
57    pub max_depth: Option<usize>,
58
59    /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
60    /// shell command at spawn - before the first inference, and so before any
61    /// approval prompt. See `lev validate <path>` to inspect them first.
62    #[arg(long)]
63    pub no_seed_commands: bool,
64
65    /// Working directory for the run (default: the directory `lev run` is
66    /// invoked from). The agent's file tools are confined to it, and relative
67    /// `[read_paths]` entries resolve against it.
68    #[arg(long, value_name = "DIR")]
69    pub workdir: Option<std::path::PathBuf>,
70
71    /// Print the spawned run as JSON instead of a sentence, for a caller that
72    /// has to parse the run id back out and poll `lev ps --json`.
73    #[arg(long)]
74    pub json: bool,
75
76    /// Dynamic per-region seed flags (`--<region> <text|@file>`), collected by an
77    /// argv pre-scan in the binary since region names are blueprint-defined.
78    /// clap skips this field; it is populated after parsing.
79    #[arg(skip)]
80    pub regions: HashMap<String, String>,
81}
82
83/// The `run` subcommand's own long flags - everything NOT in this set is treated
84/// as a dynamic `--<region>` seed flag by [`extract_region_flags`].
85const KNOWN_RUN_FLAGS: &[&str] = &[
86    "task",
87    "model",
88    "yolo",
89    "allow",
90    "max-depth",
91    "no-seed-commands",
92    "workdir",
93    // Every flag `run` owns must be listed here. One that is missing is not a
94    // parse error: the pre-scan silently reads it as a `--<region>` seed and
95    // swallows the token after it.
96    "json",
97    "verbose",
98    "help",
99    "version",
100];
101
102/// The run's effective working directory: the `--workdir` flag when given
103/// (canonicalized, and refused early when it does not exist - a bad workdir
104/// would otherwise spawn an agent whose every tool call fails), else `cwd`
105/// (the directory the command was invoked from, resolved by the caller).
106pub fn effective_workdir(
107    flag: Option<std::path::PathBuf>,
108    cwd: std::path::PathBuf,
109) -> anyhow::Result<String> {
110    let dir = match flag {
111        Some(dir) => {
112            let canonical = std::fs::canonicalize(&dir).map_err(|e| {
113                anyhow::anyhow!(
114                    "--workdir '{}' is not a usable directory: {e}",
115                    dir.display()
116                )
117            })?;
118            if !canonical.is_dir() {
119                anyhow::bail!("--workdir '{}' is not a directory", dir.display());
120            }
121            canonical
122        }
123        None => cwd,
124    };
125    Ok(dir.to_string_lossy().to_string())
126}
127
128/// Pre-scan a full argv (program name first) for dynamic `--<region>` flags on
129/// the `run` subcommand, since region names are blueprint-defined and clap can't
130/// declare them. Returns `(argv_for_clap, region_flags)`: a `--<name>` (or
131/// `--<name>=<value>`) whose `<name>` is not a known `run` flag is pulled out
132/// (with its value) into the map; every other token passes through untouched.
133///
134/// A no-`=` region flag consumes the following token as its value. If argv has
135/// no `run` subcommand token, nothing is extracted (the returned argv equals the
136/// input). Pure - no environment or I/O - so it is unit-testable in isolation.
137pub fn extract_region_flags(argv: Vec<String>) -> (Vec<String>, HashMap<String, String>) {
138    // Locate the subcommand: the first bareword (non-`-`) token after the program
139    // name. Only activate when it is `run`.
140    let sub_pos = argv
141        .iter()
142        .enumerate()
143        .skip(1)
144        .find(|(_, t)| !t.starts_with('-'))
145        .map(|(i, _)| i);
146    let Some(sub_pos) = sub_pos else {
147        return (argv, HashMap::new());
148    };
149    if argv[sub_pos] != "run" {
150        return (argv, HashMap::new());
151    }
152
153    let mut out: Vec<String> = argv[..=sub_pos].to_vec();
154    let mut regions = HashMap::new();
155    let mut i = sub_pos + 1;
156    while i < argv.len() {
157        let token = &argv[i];
158        if let Some(name) = token.strip_prefix("--") {
159            // Split an `=`-joined value if present.
160            let (name, inline) = match name.split_once('=') {
161                Some((n, v)) => (n, Some(v.to_string())),
162                None => (name, None),
163            };
164            if !name.is_empty() && !KNOWN_RUN_FLAGS.contains(&name) {
165                let value = match inline {
166                    Some(v) => v,
167                    None => {
168                        // Consume the next token as the value, if any.
169                        i += 1;
170                        argv.get(i).cloned().unwrap_or_default()
171                    }
172                };
173                regions.insert(name.to_string(), value);
174                i += 1;
175                continue;
176            }
177        }
178        out.push(token.clone());
179        i += 1;
180    }
181    (out, regions)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn argv(parts: &[&str]) -> Vec<String> {
189        parts.iter().map(|s| s.to_string()).collect()
190    }
191
192    #[test]
193    fn every_flag_run_declares_is_a_known_run_flag() {
194        // A flag clap owns but this list omits is not a parse error. The
195        // pre-scan reads it as a `--<region>` seed, eats the token after it, and
196        // the run starts with a region nobody asked for. Ask clap for the list
197        // rather than repeating it, so a new flag is covered when it is added.
198        let command = <RunArgs as clap::Args>::augment_args(clap::Command::new("run"));
199        for arg in command.get_arguments() {
200            if let Some(long) = arg.get_long() {
201                assert!(
202                    KNOWN_RUN_FLAGS.contains(&long),
203                    "`--{long}` is missing from KNOWN_RUN_FLAGS",
204                );
205            }
206        }
207    }
208
209    #[test]
210    fn extracts_dynamic_region_flags_and_preserves_known_ones() {
211        let (out, regions) = extract_region_flags(argv(&[
212            "lev",
213            "run",
214            "agents/reviewer",
215            "--task",
216            "review it",
217            "--files",
218            "@src/main.rs",
219            "--review-criteria",
220            "@policy.md",
221            "--yolo",
222        ]));
223        // Known flags + positional pass through to clap.
224        assert_eq!(
225            out,
226            argv(&[
227                "lev",
228                "run",
229                "agents/reviewer",
230                "--task",
231                "review it",
232                "--yolo",
233            ])
234        );
235        assert_eq!(
236            regions.get("files").map(String::as_str),
237            Some("@src/main.rs")
238        );
239        assert_eq!(
240            regions.get("review-criteria").map(String::as_str),
241            Some("@policy.md")
242        );
243    }
244
245    #[test]
246    fn extracts_equals_joined_region_flag() {
247        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--criteria=be safe"]));
248        assert_eq!(out, argv(&["lev", "run", "a"]));
249        assert_eq!(regions.get("criteria").map(String::as_str), Some("be safe"));
250    }
251
252    #[test]
253    fn no_region_flags_leaves_argv_unchanged() {
254        let input = argv(&["lev", "run", "a", "--task", "t"]);
255        let (out, regions) = extract_region_flags(input.clone());
256        assert_eq!(out, input);
257        assert!(regions.is_empty());
258    }
259
260    #[test]
261    fn non_run_subcommand_is_untouched() {
262        // A dynamic-looking flag on another subcommand is left for clap to reject.
263        let input = argv(&["lev", "ps", "--weird", "x"]);
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 no_subcommand_token_is_untouched() {
271        // Only flags, no bareword subcommand → nothing extracted.
272        let input = argv(&["lev", "--verbose"]);
273        let (out, regions) = extract_region_flags(input.clone());
274        assert_eq!(out, input);
275        assert!(regions.is_empty());
276    }
277
278    #[test]
279    fn trailing_region_flag_without_value_maps_to_empty() {
280        // A dynamic flag at the very end with no following value → empty string.
281        let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--spec"]));
282        assert_eq!(out, argv(&["lev", "run", "a"]));
283        assert_eq!(regions.get("spec").map(String::as_str), Some(""));
284    }
285
286    #[test]
287    fn global_verbose_before_run_still_activates() {
288        let (_out, regions) = extract_region_flags(argv(&["lev", "-v", "run", "a", "--spec", "x"]));
289        assert_eq!(regions.get("spec").map(String::as_str), Some("x"));
290    }
291
292    /// `--workdir` is a real run flag: the pre-scan must pass it through to
293    /// clap, not swallow it as a region seed named "workdir".
294    #[test]
295    fn workdir_flag_is_not_eaten_as_a_region() {
296        let input = argv(&["lev", "run", "a", "--workdir", "/elsewhere", "--task", "t"]);
297        let (out, regions) = extract_region_flags(input.clone());
298        assert_eq!(out, input);
299        assert!(regions.is_empty());
300    }
301
302    #[test]
303    fn effective_workdir_uses_the_flag_canonicalized() {
304        let dir = tempfile::tempdir().unwrap();
305        let got = effective_workdir(
306            Some(dir.path().to_path_buf()),
307            std::path::PathBuf::from("/unused"),
308        )
309        .unwrap();
310        assert_eq!(
311            got,
312            std::fs::canonicalize(dir.path())
313                .unwrap()
314                .to_string_lossy()
315                .to_string()
316        );
317    }
318
319    #[test]
320    fn effective_workdir_defaults_to_the_supplied_cwd() {
321        let cwd = tempfile::tempdir().unwrap();
322        let got = effective_workdir(None, cwd.path().to_path_buf()).unwrap();
323        assert_eq!(got, cwd.path().to_string_lossy().to_string());
324    }
325
326    /// A bad `--workdir` fails before the daemon is contacted - otherwise it
327    /// spawns an agent whose every tool call fails.
328    #[test]
329    fn effective_workdir_refuses_a_missing_or_non_directory_path() {
330        let cwd = std::path::PathBuf::from("/unused");
331        let err = effective_workdir(
332            Some(std::path::PathBuf::from("/definitely/not/a/real/dir")),
333            cwd.clone(),
334        )
335        .unwrap_err();
336        assert!(err.to_string().contains("not a usable directory"), "{err}");
337
338        let dir = tempfile::tempdir().unwrap();
339        let file = dir.path().join("f.txt");
340        std::fs::write(&file, "x").unwrap();
341        let err = effective_workdir(Some(file), cwd).unwrap_err();
342        assert!(err.to_string().contains("not a directory"), "{err}");
343    }
344}