Skip to main content

talon/
cli.rs

1//! Command-line interface (`clap` derive).
2
3use std::path::PathBuf;
4
5use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand};
6
7/// Make every argument that reads a `TALAHON_*` variable read its
8/// pre-rename `TALON_*` name instead when `legacy_in_use` says only that one
9/// is set (`clap` knows no alias for `env`). Applied to subcommands too.
10pub fn with_legacy_env(
11    cmd: clap::Command,
12    legacy_in_use: &dyn Fn(&str) -> Option<String>,
13) -> clap::Command {
14    cmd.mut_args(|arg| {
15        match arg
16            .get_env()
17            .and_then(|key| key.to_str())
18            .and_then(legacy_in_use)
19        {
20            // `clap` wants a `'static` name; one short string per aliased
21            // variable for the life of the process.
22            Some(legacy) => arg.env(&*Box::leak(legacy.into_boxed_str())),
23            None => arg,
24        }
25    })
26    .mut_subcommands(|sub| with_legacy_env(sub, legacy_in_use))
27}
28
29/// `talahon` — automated AI SOC analyst for CoPilot.
30#[derive(Debug, Parser)]
31#[command(name = "talahon", version, about, long_about = None)]
32pub struct Cli {
33    /// Talahon project directory (holds `.env`, `groups/`, `store/`, `data/`).
34    /// Defaults to the current directory.
35    #[arg(long, global = true, env = "TALAHON_PROJECT_ROOT", value_name = "DIR")]
36    pub project_root: Option<PathBuf>,
37
38    /// Subcommand; `run` when omitted.
39    #[command(subcommand)]
40    pub command: Option<Command>,
41}
42
43impl Cli {
44    /// [`Parser::parse`] that also honours the `TALON_*` spelling of the
45    /// environment variables behind the arguments (see [`with_legacy_env`]).
46    pub fn parse_with_legacy_env() -> Self {
47        let cmd = with_legacy_env(
48            <Self as CommandFactory>::command(),
49            &talon_core::env::legacy_process_key_in_use,
50        );
51        let matches = cmd.get_matches();
52        Self::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
53    }
54}
55
56/// Top-level subcommands.
57#[derive(Debug, Subcommand, PartialEq)]
58pub enum Command {
59    /// Run the orchestrator (default).
60    Run,
61    /// Run a setup step (`timezone`, `environment`, `vllm`, `container`,
62    /// `groups`, `register`, `mounts`, `service`, `verify`).
63    Setup(SetupArgs),
64    /// Diagnose the environment: container runtime, image, vLLM, LLM
65    /// gateway, directories, audit chain.
66    Doctor,
67    /// Inspect the hash-chained audit log.
68    Audit {
69        /// Audit subcommand.
70        #[command(subcommand)]
71        command: AuditCommand,
72    },
73    /// Score investigation quality against the golden set
74    /// (`evals/golden/`): offline from saved reports, or online through a
75    /// running Talahon.
76    Eval {
77        /// Eval subcommand.
78        #[command(subcommand)]
79        command: EvalCommand,
80    },
81    /// Print the version.
82    Version,
83}
84
85/// `talahon eval` subcommands.
86#[derive(Debug, Subcommand, PartialEq)]
87pub enum EvalCommand {
88    /// Offline: score already-produced reports (`<case id>.json`) against
89    /// the golden cases. Exit code 1 when the pass rate is below
90    /// `--min-pass-rate`, 2 on unusable input.
91    Score(EvalScoreArgs),
92    /// Online: submit every golden case to a running Talahon
93    /// (`POST /investigate`), wait for each verdict, then score. The
94    /// case's alert must already exist in the CoPilot stack Talahon is
95    /// connected to.
96    Run(EvalRunArgs),
97}
98
99/// Options shared by the `talahon eval` modes.
100#[derive(Debug, Args, PartialEq)]
101pub struct EvalCommonArgs {
102    /// Directory of golden case files (default: `<project root>/evals/golden`).
103    #[arg(long, value_name = "DIR")]
104    pub cases: Option<PathBuf>,
105    /// Print the summary as JSON instead of a table.
106    #[arg(long)]
107    pub json: bool,
108    /// Lowest acceptable fraction of passing cases, `0.0`–`1.0`.
109    #[arg(long, value_name = "FRACTION", default_value_t = 1.0, value_parser = parse_pass_rate)]
110    pub min_pass_rate: f64,
111}
112
113/// Arguments of `talahon eval score`.
114#[derive(Debug, Args, PartialEq)]
115pub struct EvalScoreArgs {
116    /// Directory holding one `<case id>.json` per case: a triage report, or
117    /// an investigation record as returned by `GET /investigations/{id}`.
118    #[arg(long, value_name = "DIR")]
119    pub reports: PathBuf,
120    /// Shared options.
121    #[command(flatten)]
122    pub common: EvalCommonArgs,
123}
124
125/// Arguments of `talahon eval run`.
126#[derive(Debug, Args, PartialEq)]
127pub struct EvalRunArgs {
128    /// Base URL of the Talahon HTTP API (default:
129    /// `http://<COPILOT_HTTP_BIND>:<COPILOT_HTTP_PORT>` from the project's
130    /// `.env`). The API key is always `HTTP_API_KEY` from `.env`.
131    #[arg(long, value_name = "URL")]
132    pub url: Option<String>,
133    /// Seconds to wait for each investigation's verdict.
134    #[arg(long, value_name = "SECONDS", default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..))]
135    pub timeout_secs: u64,
136    /// Seconds between status polls.
137    #[arg(long, value_name = "SECONDS", default_value_t = 5, value_parser = clap::value_parser!(u64).range(1..))]
138    pub poll_secs: u64,
139    /// JSON file mapping case ids to the alert ids they are seeded under
140    /// in the target stack (`{"<case id>": 4711, ...}`); overrides the
141    /// case's `request.alert_id`.
142    #[arg(long, value_name = "FILE")]
143    pub alert_map: Option<PathBuf>,
144    /// Customer code to submit instead of each case's
145    /// `request.customer_code`.
146    #[arg(long, value_name = "CODE")]
147    pub customer_code: Option<String>,
148    /// Write each investigation record to `<DIR>/<case id>.json`, in the
149    /// form `talahon eval score --reports <DIR>` reads.
150    #[arg(long, value_name = "DIR")]
151    pub save_dir: Option<PathBuf>,
152    /// Score the existing job when Talahon de-duplicates a trigger instead of
153    /// failing the case (the verdict may predate the change under test).
154    #[arg(long)]
155    pub allow_deduplicated: bool,
156    /// Shared options.
157    #[command(flatten)]
158    pub common: EvalCommonArgs,
159}
160
161fn parse_pass_rate(raw: &str) -> Result<f64, String> {
162    match raw.trim().parse::<f64>() {
163        Ok(v) if (0.0..=1.0).contains(&v) => Ok(v),
164        _ => Err("expected a fraction between 0.0 and 1.0".to_string()),
165    }
166}
167
168/// Arguments of `talahon setup`.
169#[derive(Debug, Args, PartialEq, Eq)]
170pub struct SetupArgs {
171    /// Step name.
172    pub step: String,
173    /// Step arguments, passed through verbatim (e.g. `--tz Europe/Berlin`).
174    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
175    pub args: Vec<String>,
176}
177
178/// `talahon audit` subcommands.
179#[derive(Debug, Subcommand, PartialEq, Eq)]
180pub enum AuditCommand {
181    /// Verify the chain (and, with `TALAHON_AUDIT_KEY`, its MACs and the
182    /// truncation checkpoint); exit code 1 if anything does not verify.
183    Verify,
184    /// Print the newest row's id, hash and sequence number, for anchoring
185    /// the chain on a system this host cannot write.
186    Head,
187    /// Print recent audit events (oldest first) as JSON lines.
188    Tail {
189        /// Number of events.
190        #[arg(short = 'n', long = "lines", default_value_t = 50)]
191        lines: usize,
192        /// Only events whose kind starts with this prefix (e.g. `ipc.`).
193        #[arg(long)]
194        kind: Option<String>,
195    },
196}
197
198impl Cli {
199    /// The subcommand to run (`run` when none was given).
200    pub fn command(&self) -> &Command {
201        self.command.as_ref().unwrap_or(&Command::Run)
202    }
203
204    /// Project root: `--project-root`, `TALAHON_PROJECT_ROOT`, else the
205    /// current directory.
206    pub fn resolve_project_root(&self) -> std::io::Result<PathBuf> {
207        match &self.project_root {
208            Some(p) if p.is_absolute() => Ok(p.clone()),
209            Some(p) => Ok(std::env::current_dir()?.join(p)),
210            None => std::env::current_dir(),
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn parse(args: &[&str]) -> Cli {
220        Cli::try_parse_from(args).unwrap()
221    }
222
223    #[test]
224    fn env_backed_arguments_read_the_old_name_when_only_that_is_set() {
225        let env_of = |cmd: &clap::Command| {
226            cmd.get_arguments()
227                .find(|a| a.get_id() == "project_root")
228                .and_then(|a| a.get_env())
229                .and_then(|e| e.to_str())
230                .map(str::to_string)
231        };
232        // Nothing set under the old name: the argument keeps its variable.
233        let cmd = with_legacy_env(<Cli as CommandFactory>::command(), &|_| None);
234        assert_eq!(env_of(&cmd).as_deref(), Some("TALAHON_PROJECT_ROOT"));
235        // Only TALON_PROJECT_ROOT is set: that is what the argument reads.
236        let asked = std::sync::Mutex::new(Vec::new());
237        let cmd = with_legacy_env(<Cli as CommandFactory>::command(), &|key| {
238            asked.lock().unwrap().push(key.to_string());
239            talon_core::env::legacy_key(key)
240        });
241        assert_eq!(env_of(&cmd).as_deref(), Some("TALON_PROJECT_ROOT"));
242        assert!(
243            asked
244                .lock()
245                .unwrap()
246                .contains(&"TALAHON_PROJECT_ROOT".to_string())
247        );
248        // Subcommands are still intact.
249        assert!(cmd.get_subcommands().any(|c| c.get_name() == "doctor"));
250    }
251
252    #[test]
253    fn defaults_to_run() {
254        let cli = parse(&["talahon"]);
255        assert_eq!(cli.command(), &Command::Run);
256        assert_eq!(parse(&["talahon", "run"]).command(), &Command::Run);
257    }
258
259    #[test]
260    fn setup_passes_hyphenated_args_through() {
261        let cli = parse(&[
262            "talahon",
263            "setup",
264            "timezone",
265            "--tz",
266            "Europe/Berlin",
267            "--force",
268        ]);
269        assert_eq!(
270            cli.command(),
271            &Command::Setup(SetupArgs {
272                step: "timezone".into(),
273                args: vec!["--tz".into(), "Europe/Berlin".into(), "--force".into()],
274            })
275        );
276    }
277
278    #[test]
279    fn audit_subcommands() {
280        assert_eq!(
281            parse(&["talahon", "audit", "verify"]).command(),
282            &Command::Audit {
283                command: AuditCommand::Verify
284            }
285        );
286        assert_eq!(
287            parse(&["talahon", "audit", "head"]).command(),
288            &Command::Audit {
289                command: AuditCommand::Head
290            }
291        );
292        assert_eq!(
293            parse(&["talahon", "audit", "tail", "-n", "5", "--kind", "ipc."]).command(),
294            &Command::Audit {
295                command: AuditCommand::Tail {
296                    lines: 5,
297                    kind: Some("ipc.".into())
298                }
299            }
300        );
301        assert_eq!(
302            parse(&["talahon", "audit", "tail"]).command(),
303            &Command::Audit {
304                command: AuditCommand::Tail {
305                    lines: 50,
306                    kind: None
307                }
308            }
309        );
310    }
311
312    #[test]
313    fn eval_subcommands() {
314        let Some(Command::Eval {
315            command: EvalCommand::Score(score),
316        }) = parse(&["talahon", "eval", "score", "--reports", "out"]).command
317        else {
318            panic!("expected eval score");
319        };
320        assert_eq!(score.reports, PathBuf::from("out"));
321        assert_eq!(score.common.cases, None);
322        assert!(!score.common.json);
323        assert!((score.common.min_pass_rate - 1.0).abs() < f64::EPSILON);
324
325        let Some(Command::Eval {
326            command: EvalCommand::Run(run),
327        }) = parse(&[
328            "talahon",
329            "eval",
330            "run",
331            "--cases",
332            "c",
333            "--json",
334            "--min-pass-rate",
335            "0.8",
336            "--timeout-secs",
337            "60",
338            "--alert-map",
339            "map.json",
340            "--allow-deduplicated",
341        ])
342        .command
343        else {
344            panic!("expected eval run");
345        };
346        assert_eq!(run.common.cases, Some(PathBuf::from("c")));
347        assert!(run.common.json && run.allow_deduplicated);
348        assert!((run.common.min_pass_rate - 0.8).abs() < f64::EPSILON);
349        assert_eq!((run.timeout_secs, run.poll_secs), (60, 5));
350        assert_eq!(run.alert_map, Some(PathBuf::from("map.json")));
351        assert_eq!(
352            (run.url, run.save_dir, run.customer_code),
353            (None, None, None)
354        );
355
356        let bad: [&[&str]; 5] = [
357            &["talahon", "eval"],
358            &["talahon", "eval", "score"],
359            &[
360                "talahon",
361                "eval",
362                "score",
363                "--reports",
364                "o",
365                "--min-pass-rate",
366                "1.5",
367            ],
368            &[
369                "talahon",
370                "eval",
371                "score",
372                "--reports",
373                "o",
374                "--min-pass-rate",
375                "NaN",
376            ],
377            &["talahon", "eval", "run", "--timeout-secs", "0"],
378        ];
379        for args in bad {
380            assert!(Cli::try_parse_from(args).is_err(), "{args:?}");
381        }
382    }
383
384    #[test]
385    fn project_root_flag_is_global() {
386        let cli = parse(&["talahon", "doctor", "--project-root", "/srv/talahon"]);
387        assert_eq!(cli.command(), &Command::Doctor);
388        assert_eq!(
389            cli.resolve_project_root().unwrap(),
390            PathBuf::from("/srv/talahon")
391        );
392    }
393
394    #[test]
395    fn unknown_command_is_rejected() {
396        assert!(Cli::try_parse_from(["talahon", "frobnicate"]).is_err());
397    }
398}