1pub mod manifest;
10pub mod session;
11pub mod task;
12
13use std::collections::HashMap;
14
15use clap::Args;
16
17pub use session::{
19 ProviderCreds, build_provider_registry, build_provider_registry_from_config,
20 provider_creds_from_config,
21};
22
23#[derive(Args, Debug, Clone, Default)]
25pub struct RunArgs {
26 #[arg(value_name = "PATH")]
28 pub path: Option<String>,
29
30 #[arg(short, long, value_name = "TEXT|FILE")]
33 pub task: Option<String>,
34
35 #[arg(short, long)]
37 pub model: Option<String>,
38
39 #[arg(long)]
49 pub yolo: bool,
50
51 #[arg(long)]
53 pub allow: Vec<String>,
54
55 #[arg(long)]
57 pub max_depth: Option<usize>,
58
59 #[arg(long)]
63 pub no_seed_commands: bool,
64
65 #[arg(long, value_name = "DIR")]
69 pub workdir: Option<std::path::PathBuf>,
70
71 #[arg(long)]
75 pub json: bool,
76
77 #[arg(long, value_name = "N", default_value_t = 1)]
83 pub count: usize,
84
85 #[arg(long, value_name = "LABEL")]
94 pub output_format: Option<String>,
95
96 #[arg(long, value_name = "TEXT")]
99 pub output_instructions: Option<String>,
100
101 #[arg(long, value_name = "JSON|@FILE")]
106 pub output_schema: Option<String>,
107
108 #[arg(skip)]
112 pub regions: HashMap<String, String>,
113}
114
115const KNOWN_RUN_FLAGS: &[&str] = &[
118 "task",
119 "model",
120 "yolo",
121 "allow",
122 "max-depth",
123 "no-seed-commands",
124 "workdir",
125 "output-format",
126 "output-instructions",
127 "output-schema",
128 "json",
132 "count",
133 "verbose",
134 "help",
135 "version",
136];
137
138pub fn output_request(
146 format: Option<String>,
147 instructions: Option<String>,
148 schema: Option<String>,
149) -> anyhow::Result<Option<leviath_core::output::OutputSpec>> {
150 if format.is_none() && instructions.is_none() && schema.is_none() {
151 return Ok(None);
152 }
153 let schema = match schema {
154 Some(raw) => {
155 let text = task::read_region_value(&raw)?;
156 Some(
157 serde_json::from_str(&text)
158 .map_err(|e| anyhow::anyhow!("--output-schema is not valid JSON: {e}"))?,
159 )
160 }
161 None => None,
162 };
163 Ok(Some(leviath_core::output::OutputSpec {
164 format,
165 instructions,
166 example: None,
167 schema,
168 validator: None,
169 }))
170}
171
172pub fn effective_workdir(
177 flag: Option<std::path::PathBuf>,
178 cwd: std::path::PathBuf,
179) -> anyhow::Result<String> {
180 let dir = match flag {
181 Some(dir) => {
182 let canonical = std::fs::canonicalize(&dir).map_err(|e| {
183 anyhow::anyhow!(
184 "--workdir '{}' is not a usable directory: {e}",
185 dir.display()
186 )
187 })?;
188 if !canonical.is_dir() {
189 anyhow::bail!("--workdir '{}' is not a directory", dir.display());
190 }
191 canonical
192 }
193 None => cwd,
194 };
195 Ok(dir.to_string_lossy().to_string())
196}
197
198pub fn extract_region_flags(argv: Vec<String>) -> (Vec<String>, HashMap<String, String>) {
208 let sub_pos = argv
211 .iter()
212 .enumerate()
213 .skip(1)
214 .find(|(_, t)| !t.starts_with('-'))
215 .map(|(i, _)| i);
216 let Some(sub_pos) = sub_pos else {
217 return (argv, HashMap::new());
218 };
219 if argv[sub_pos] != "run" {
220 return (argv, HashMap::new());
221 }
222
223 let mut out: Vec<String> = argv[..=sub_pos].to_vec();
224 let mut regions = HashMap::new();
225 let mut i = sub_pos + 1;
226 while i < argv.len() {
227 let token = &argv[i];
228 if let Some(name) = token.strip_prefix("--") {
229 let (name, inline) = match name.split_once('=') {
231 Some((n, v)) => (n, Some(v.to_string())),
232 None => (name, None),
233 };
234 if !name.is_empty() && !KNOWN_RUN_FLAGS.contains(&name) {
235 let value = match inline {
236 Some(v) => v,
237 None => {
238 i += 1;
240 argv.get(i).cloned().unwrap_or_default()
241 }
242 };
243 regions.insert(name.to_string(), value);
244 i += 1;
245 continue;
246 }
247 }
248 out.push(token.clone());
249 i += 1;
250 }
251 (out, regions)
252}
253
254#[cfg(test)]
255mod tests {
256
257 #[test]
261 fn an_output_request_carries_an_unrecognized_format_through() {
262 let spec = output_request(
263 Some("a2ui".to_string()),
264 Some("One card per finding.".to_string()),
265 None,
266 )
267 .expect("no schema to parse")
268 .expect("something was asked for");
269
270 assert_eq!(spec.format.as_deref(), Some("a2ui"));
271 assert_eq!(spec.instructions.as_deref(), Some("One card per finding."));
272 assert!(spec.schema.is_none());
273 }
274
275 #[test]
279 fn an_output_schema_can_be_read_from_a_file() {
280 let dir = tempfile::tempdir().expect("temp dir");
281 let path = dir.path().join("schema.json");
282 std::fs::write(&path, r#"{"type":"object","required":["summary"]}"#).expect("write");
283
284 let spec = output_request(None, None, Some(format!("@{}", path.display())))
285 .expect("the file parses")
286 .expect("something was asked for");
287 assert_eq!(
288 spec.schema,
289 Some(serde_json::json!({"type": "object", "required": ["summary"]}))
290 );
291
292 let err = output_request(
293 None,
294 None,
295 Some(format!("@{}", dir.path().join("gone.json").display())),
296 )
297 .expect_err("a file that is not there");
298 assert!(
299 err.to_string().contains("Failed to read region file"),
300 "{err}"
301 );
302 }
303
304 #[test]
307 fn no_output_flags_request_nothing() {
308 assert!(
309 output_request(None, None, None)
310 .expect("nothing to parse")
311 .is_none()
312 );
313 }
314
315 #[test]
319 fn an_output_schema_is_parsed_and_a_broken_one_is_refused() {
320 let spec = output_request(None, None, Some(r#"{"type":"object"}"#.to_string()))
321 .expect("valid JSON")
322 .expect("something was asked for");
323 assert_eq!(spec.schema, Some(serde_json::json!({"type": "object"})));
324
325 let err = output_request(None, None, Some("{not json".to_string()))
326 .expect_err("broken JSON is refused");
327 assert!(err.to_string().contains("not valid JSON"), "{err}");
328 }
329 use super::*;
330
331 fn argv(parts: &[&str]) -> Vec<String> {
332 parts.iter().map(|s| s.to_string()).collect()
333 }
334
335 #[test]
336 fn every_flag_run_declares_is_a_known_run_flag() {
337 let command = <RunArgs as clap::Args>::augment_args(clap::Command::new("run"));
342 for arg in command.get_arguments() {
343 if let Some(long) = arg.get_long() {
344 assert!(
345 KNOWN_RUN_FLAGS.contains(&long),
346 "`--{long}` is missing from KNOWN_RUN_FLAGS",
347 );
348 }
349 }
350 }
351
352 #[test]
353 fn extracts_dynamic_region_flags_and_preserves_known_ones() {
354 let (out, regions) = extract_region_flags(argv(&[
355 "lev",
356 "run",
357 "agents/reviewer",
358 "--task",
359 "review it",
360 "--files",
361 "@src/main.rs",
362 "--review-criteria",
363 "@policy.md",
364 "--yolo",
365 ]));
366 assert_eq!(
368 out,
369 argv(&[
370 "lev",
371 "run",
372 "agents/reviewer",
373 "--task",
374 "review it",
375 "--yolo",
376 ])
377 );
378 assert_eq!(
379 regions.get("files").map(String::as_str),
380 Some("@src/main.rs")
381 );
382 assert_eq!(
383 regions.get("review-criteria").map(String::as_str),
384 Some("@policy.md")
385 );
386 }
387
388 #[test]
389 fn extracts_equals_joined_region_flag() {
390 let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--criteria=be safe"]));
391 assert_eq!(out, argv(&["lev", "run", "a"]));
392 assert_eq!(regions.get("criteria").map(String::as_str), Some("be safe"));
393 }
394
395 #[test]
396 fn no_region_flags_leaves_argv_unchanged() {
397 let input = argv(&["lev", "run", "a", "--task", "t"]);
398 let (out, regions) = extract_region_flags(input.clone());
399 assert_eq!(out, input);
400 assert!(regions.is_empty());
401 }
402
403 #[test]
404 fn non_run_subcommand_is_untouched() {
405 let input = argv(&["lev", "ps", "--weird", "x"]);
407 let (out, regions) = extract_region_flags(input.clone());
408 assert_eq!(out, input);
409 assert!(regions.is_empty());
410 }
411
412 #[test]
413 fn no_subcommand_token_is_untouched() {
414 let input = argv(&["lev", "--verbose"]);
416 let (out, regions) = extract_region_flags(input.clone());
417 assert_eq!(out, input);
418 assert!(regions.is_empty());
419 }
420
421 #[test]
422 fn trailing_region_flag_without_value_maps_to_empty() {
423 let (out, regions) = extract_region_flags(argv(&["lev", "run", "a", "--spec"]));
425 assert_eq!(out, argv(&["lev", "run", "a"]));
426 assert_eq!(regions.get("spec").map(String::as_str), Some(""));
427 }
428
429 #[test]
430 fn global_verbose_before_run_still_activates() {
431 let (_out, regions) = extract_region_flags(argv(&["lev", "-v", "run", "a", "--spec", "x"]));
432 assert_eq!(regions.get("spec").map(String::as_str), Some("x"));
433 }
434
435 #[test]
438 fn workdir_flag_is_not_eaten_as_a_region() {
439 let input = argv(&["lev", "run", "a", "--workdir", "/elsewhere", "--task", "t"]);
440 let (out, regions) = extract_region_flags(input.clone());
441 assert_eq!(out, input);
442 assert!(regions.is_empty());
443 }
444
445 #[test]
446 fn effective_workdir_uses_the_flag_canonicalized() {
447 let dir = tempfile::tempdir().unwrap();
448 let got = effective_workdir(
449 Some(dir.path().to_path_buf()),
450 std::path::PathBuf::from("/unused"),
451 )
452 .unwrap();
453 assert_eq!(
454 got,
455 std::fs::canonicalize(dir.path())
456 .unwrap()
457 .to_string_lossy()
458 .to_string()
459 );
460 }
461
462 #[test]
463 fn effective_workdir_defaults_to_the_supplied_cwd() {
464 let cwd = tempfile::tempdir().unwrap();
465 let got = effective_workdir(None, cwd.path().to_path_buf()).unwrap();
466 assert_eq!(got, cwd.path().to_string_lossy().to_string());
467 }
468
469 #[test]
472 fn effective_workdir_refuses_a_missing_or_non_directory_path() {
473 let cwd = std::path::PathBuf::from("/unused");
474 let err = effective_workdir(
475 Some(std::path::PathBuf::from("/definitely/not/a/real/dir")),
476 cwd.clone(),
477 )
478 .unwrap_err();
479 assert!(err.to_string().contains("not a usable directory"), "{err}");
480
481 let dir = tempfile::tempdir().unwrap();
482 let file = dir.path().join("f.txt");
483 std::fs::write(&file, "x").unwrap();
484 let err = effective_workdir(Some(file), cwd).unwrap_err();
485 assert!(err.to_string().contains("not a directory"), "{err}");
486 }
487}