Skip to main content

talon/
cli.rs

1//! Command-line interface (`clap` derive).
2
3use std::path::PathBuf;
4
5use clap::{Args, Parser, Subcommand};
6
7/// `talon` — automated AI SOC analyst for CoPilot.
8#[derive(Debug, Parser)]
9#[command(name = "talon", version, about, long_about = None)]
10pub struct Cli {
11    /// Talon project directory (holds `.env`, `groups/`, `store/`, `data/`).
12    /// Defaults to the current directory.
13    #[arg(long, global = true, env = "TALON_PROJECT_ROOT", value_name = "DIR")]
14    pub project_root: Option<PathBuf>,
15
16    /// Subcommand; `run` when omitted.
17    #[command(subcommand)]
18    pub command: Option<Command>,
19}
20
21/// Top-level subcommands.
22#[derive(Debug, Subcommand, PartialEq)]
23pub enum Command {
24    /// Run the orchestrator (default).
25    Run,
26    /// Run a setup step (`timezone`, `environment`, `vllm`, `container`,
27    /// `groups`, `register`, `mounts`, `service`, `verify`).
28    Setup(SetupArgs),
29    /// Diagnose the environment: container runtime, image, vLLM, LLM
30    /// gateway, directories, audit chain.
31    Doctor,
32    /// Inspect the hash-chained audit log.
33    Audit {
34        /// Audit subcommand.
35        #[command(subcommand)]
36        command: AuditCommand,
37    },
38    /// Score investigation quality against the golden set
39    /// (`evals/golden/`): offline from saved reports, or online through a
40    /// running Talon.
41    Eval {
42        /// Eval subcommand.
43        #[command(subcommand)]
44        command: EvalCommand,
45    },
46    /// Print the version.
47    Version,
48}
49
50/// `talon eval` subcommands.
51#[derive(Debug, Subcommand, PartialEq)]
52pub enum EvalCommand {
53    /// Offline: score already-produced reports (`<case id>.json`) against
54    /// the golden cases. Exit code 1 when the pass rate is below
55    /// `--min-pass-rate`, 2 on unusable input.
56    Score(EvalScoreArgs),
57    /// Online: submit every golden case to a running Talon
58    /// (`POST /investigate`), wait for each verdict, then score. The
59    /// case's alert must already exist in the CoPilot stack Talon is
60    /// connected to.
61    Run(EvalRunArgs),
62}
63
64/// Options shared by the `talon eval` modes.
65#[derive(Debug, Args, PartialEq)]
66pub struct EvalCommonArgs {
67    /// Directory of golden case files (default: `<project root>/evals/golden`).
68    #[arg(long, value_name = "DIR")]
69    pub cases: Option<PathBuf>,
70    /// Print the summary as JSON instead of a table.
71    #[arg(long)]
72    pub json: bool,
73    /// Lowest acceptable fraction of passing cases, `0.0`–`1.0`.
74    #[arg(long, value_name = "FRACTION", default_value_t = 1.0, value_parser = parse_pass_rate)]
75    pub min_pass_rate: f64,
76}
77
78/// Arguments of `talon eval score`.
79#[derive(Debug, Args, PartialEq)]
80pub struct EvalScoreArgs {
81    /// Directory holding one `<case id>.json` per case: a triage report, or
82    /// an investigation record as returned by `GET /investigations/{id}`.
83    #[arg(long, value_name = "DIR")]
84    pub reports: PathBuf,
85    /// Shared options.
86    #[command(flatten)]
87    pub common: EvalCommonArgs,
88}
89
90/// Arguments of `talon eval run`.
91#[derive(Debug, Args, PartialEq)]
92pub struct EvalRunArgs {
93    /// Base URL of the Talon HTTP API (default:
94    /// `http://<COPILOT_HTTP_BIND>:<COPILOT_HTTP_PORT>` from the project's
95    /// `.env`). The API key is always `HTTP_API_KEY` from `.env`.
96    #[arg(long, value_name = "URL")]
97    pub url: Option<String>,
98    /// Seconds to wait for each investigation's verdict.
99    #[arg(long, value_name = "SECONDS", default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..))]
100    pub timeout_secs: u64,
101    /// Seconds between status polls.
102    #[arg(long, value_name = "SECONDS", default_value_t = 5, value_parser = clap::value_parser!(u64).range(1..))]
103    pub poll_secs: u64,
104    /// JSON file mapping case ids to the alert ids they are seeded under
105    /// in the target stack (`{"<case id>": 4711, ...}`); overrides the
106    /// case's `request.alert_id`.
107    #[arg(long, value_name = "FILE")]
108    pub alert_map: Option<PathBuf>,
109    /// Customer code to submit instead of each case's
110    /// `request.customer_code`.
111    #[arg(long, value_name = "CODE")]
112    pub customer_code: Option<String>,
113    /// Write each investigation record to `<DIR>/<case id>.json`, in the
114    /// form `talon eval score --reports <DIR>` reads.
115    #[arg(long, value_name = "DIR")]
116    pub save_dir: Option<PathBuf>,
117    /// Score the existing job when Talon de-duplicates a trigger instead of
118    /// failing the case (the verdict may predate the change under test).
119    #[arg(long)]
120    pub allow_deduplicated: bool,
121    /// Shared options.
122    #[command(flatten)]
123    pub common: EvalCommonArgs,
124}
125
126fn parse_pass_rate(raw: &str) -> Result<f64, String> {
127    match raw.trim().parse::<f64>() {
128        Ok(v) if (0.0..=1.0).contains(&v) => Ok(v),
129        _ => Err("expected a fraction between 0.0 and 1.0".to_string()),
130    }
131}
132
133/// Arguments of `talon setup`.
134#[derive(Debug, Args, PartialEq, Eq)]
135pub struct SetupArgs {
136    /// Step name.
137    pub step: String,
138    /// Step arguments, passed through verbatim (e.g. `--tz Europe/Berlin`).
139    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
140    pub args: Vec<String>,
141}
142
143/// `talon audit` subcommands.
144#[derive(Debug, Subcommand, PartialEq, Eq)]
145pub enum AuditCommand {
146    /// Recompute the hash chain; exit code 1 if it was tampered with.
147    Verify,
148    /// Print recent audit events (oldest first) as JSON lines.
149    Tail {
150        /// Number of events.
151        #[arg(short = 'n', long = "lines", default_value_t = 50)]
152        lines: usize,
153        /// Only events whose kind starts with this prefix (e.g. `ipc.`).
154        #[arg(long)]
155        kind: Option<String>,
156    },
157}
158
159impl Cli {
160    /// The subcommand to run (`run` when none was given).
161    pub fn command(&self) -> &Command {
162        self.command.as_ref().unwrap_or(&Command::Run)
163    }
164
165    /// Project root: `--project-root`, `TALON_PROJECT_ROOT`, else the
166    /// current directory.
167    pub fn resolve_project_root(&self) -> std::io::Result<PathBuf> {
168        match &self.project_root {
169            Some(p) if p.is_absolute() => Ok(p.clone()),
170            Some(p) => Ok(std::env::current_dir()?.join(p)),
171            None => std::env::current_dir(),
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn parse(args: &[&str]) -> Cli {
181        Cli::try_parse_from(args).unwrap()
182    }
183
184    #[test]
185    fn defaults_to_run() {
186        let cli = parse(&["talon"]);
187        assert_eq!(cli.command(), &Command::Run);
188        assert_eq!(parse(&["talon", "run"]).command(), &Command::Run);
189    }
190
191    #[test]
192    fn setup_passes_hyphenated_args_through() {
193        let cli = parse(&[
194            "talon",
195            "setup",
196            "timezone",
197            "--tz",
198            "Europe/Berlin",
199            "--force",
200        ]);
201        assert_eq!(
202            cli.command(),
203            &Command::Setup(SetupArgs {
204                step: "timezone".into(),
205                args: vec!["--tz".into(), "Europe/Berlin".into(), "--force".into()],
206            })
207        );
208    }
209
210    #[test]
211    fn audit_subcommands() {
212        assert_eq!(
213            parse(&["talon", "audit", "verify"]).command(),
214            &Command::Audit {
215                command: AuditCommand::Verify
216            }
217        );
218        assert_eq!(
219            parse(&["talon", "audit", "tail", "-n", "5", "--kind", "ipc."]).command(),
220            &Command::Audit {
221                command: AuditCommand::Tail {
222                    lines: 5,
223                    kind: Some("ipc.".into())
224                }
225            }
226        );
227        assert_eq!(
228            parse(&["talon", "audit", "tail"]).command(),
229            &Command::Audit {
230                command: AuditCommand::Tail {
231                    lines: 50,
232                    kind: None
233                }
234            }
235        );
236    }
237
238    #[test]
239    fn eval_subcommands() {
240        let Some(Command::Eval {
241            command: EvalCommand::Score(score),
242        }) = parse(&["talon", "eval", "score", "--reports", "out"]).command
243        else {
244            panic!("expected eval score");
245        };
246        assert_eq!(score.reports, PathBuf::from("out"));
247        assert_eq!(score.common.cases, None);
248        assert!(!score.common.json);
249        assert!((score.common.min_pass_rate - 1.0).abs() < f64::EPSILON);
250
251        let Some(Command::Eval {
252            command: EvalCommand::Run(run),
253        }) = parse(&[
254            "talon",
255            "eval",
256            "run",
257            "--cases",
258            "c",
259            "--json",
260            "--min-pass-rate",
261            "0.8",
262            "--timeout-secs",
263            "60",
264            "--alert-map",
265            "map.json",
266            "--allow-deduplicated",
267        ])
268        .command
269        else {
270            panic!("expected eval run");
271        };
272        assert_eq!(run.common.cases, Some(PathBuf::from("c")));
273        assert!(run.common.json && run.allow_deduplicated);
274        assert!((run.common.min_pass_rate - 0.8).abs() < f64::EPSILON);
275        assert_eq!((run.timeout_secs, run.poll_secs), (60, 5));
276        assert_eq!(run.alert_map, Some(PathBuf::from("map.json")));
277        assert_eq!(
278            (run.url, run.save_dir, run.customer_code),
279            (None, None, None)
280        );
281
282        let bad: [&[&str]; 5] = [
283            &["talon", "eval"],
284            &["talon", "eval", "score"],
285            &[
286                "talon",
287                "eval",
288                "score",
289                "--reports",
290                "o",
291                "--min-pass-rate",
292                "1.5",
293            ],
294            &[
295                "talon",
296                "eval",
297                "score",
298                "--reports",
299                "o",
300                "--min-pass-rate",
301                "NaN",
302            ],
303            &["talon", "eval", "run", "--timeout-secs", "0"],
304        ];
305        for args in bad {
306            assert!(Cli::try_parse_from(args).is_err(), "{args:?}");
307        }
308    }
309
310    #[test]
311    fn project_root_flag_is_global() {
312        let cli = parse(&["talon", "doctor", "--project-root", "/srv/talon"]);
313        assert_eq!(cli.command(), &Command::Doctor);
314        assert_eq!(
315            cli.resolve_project_root().unwrap(),
316            PathBuf::from("/srv/talon")
317        );
318    }
319
320    #[test]
321    fn unknown_command_is_rejected() {
322        assert!(Cli::try_parse_from(["talon", "frobnicate"]).is_err());
323    }
324}