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, Eq)]
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    /// Print the version.
39    Version,
40}
41
42/// Arguments of `talon setup`.
43#[derive(Debug, Args, PartialEq, Eq)]
44pub struct SetupArgs {
45    /// Step name.
46    pub step: String,
47    /// Step arguments, passed through verbatim (e.g. `--tz Europe/Berlin`).
48    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
49    pub args: Vec<String>,
50}
51
52/// `talon audit` subcommands.
53#[derive(Debug, Subcommand, PartialEq, Eq)]
54pub enum AuditCommand {
55    /// Recompute the hash chain; exit code 1 if it was tampered with.
56    Verify,
57    /// Print recent audit events (oldest first) as JSON lines.
58    Tail {
59        /// Number of events.
60        #[arg(short = 'n', long = "lines", default_value_t = 50)]
61        lines: usize,
62        /// Only events whose kind starts with this prefix (e.g. `ipc.`).
63        #[arg(long)]
64        kind: Option<String>,
65    },
66}
67
68impl Cli {
69    /// The subcommand to run (`run` when none was given).
70    pub fn command(&self) -> &Command {
71        self.command.as_ref().unwrap_or(&Command::Run)
72    }
73
74    /// Project root: `--project-root`, `TALON_PROJECT_ROOT`, else the
75    /// current directory.
76    pub fn resolve_project_root(&self) -> std::io::Result<PathBuf> {
77        match &self.project_root {
78            Some(p) if p.is_absolute() => Ok(p.clone()),
79            Some(p) => Ok(std::env::current_dir()?.join(p)),
80            None => std::env::current_dir(),
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn parse(args: &[&str]) -> Cli {
90        Cli::try_parse_from(args).unwrap()
91    }
92
93    #[test]
94    fn defaults_to_run() {
95        let cli = parse(&["talon"]);
96        assert_eq!(cli.command(), &Command::Run);
97        assert_eq!(parse(&["talon", "run"]).command(), &Command::Run);
98    }
99
100    #[test]
101    fn setup_passes_hyphenated_args_through() {
102        let cli = parse(&[
103            "talon",
104            "setup",
105            "timezone",
106            "--tz",
107            "Europe/Berlin",
108            "--force",
109        ]);
110        assert_eq!(
111            cli.command(),
112            &Command::Setup(SetupArgs {
113                step: "timezone".into(),
114                args: vec!["--tz".into(), "Europe/Berlin".into(), "--force".into()],
115            })
116        );
117    }
118
119    #[test]
120    fn audit_subcommands() {
121        assert_eq!(
122            parse(&["talon", "audit", "verify"]).command(),
123            &Command::Audit {
124                command: AuditCommand::Verify
125            }
126        );
127        assert_eq!(
128            parse(&["talon", "audit", "tail", "-n", "5", "--kind", "ipc."]).command(),
129            &Command::Audit {
130                command: AuditCommand::Tail {
131                    lines: 5,
132                    kind: Some("ipc.".into())
133                }
134            }
135        );
136        assert_eq!(
137            parse(&["talon", "audit", "tail"]).command(),
138            &Command::Audit {
139                command: AuditCommand::Tail {
140                    lines: 50,
141                    kind: None
142                }
143            }
144        );
145    }
146
147    #[test]
148    fn project_root_flag_is_global() {
149        let cli = parse(&["talon", "doctor", "--project-root", "/srv/talon"]);
150        assert_eq!(cli.command(), &Command::Doctor);
151        assert_eq!(
152            cli.resolve_project_root().unwrap(),
153            PathBuf::from("/srv/talon")
154        );
155    }
156
157    #[test]
158    fn unknown_command_is_rejected() {
159        assert!(Cli::try_parse_from(["talon", "frobnicate"]).is_err());
160    }
161}