Skip to main content

openlatch_client/cli/
mod.rs

1//! CLI command tree for the `openlatch` binary.
2//!
3//! This module defines the full clap command structure, global flags, noun-verb
4//! aliasing, and the helper for resolving output configuration from parsed CLI args.
5//!
6//! ## Command grammar
7//!
8//! Primary verbs: `init`, `status`, `start`, `stop`, `restart`, `logs`, `doctor`,
9//! `uninstall`, `docs`
10//!
11//! Noun-verb aliases: `hooks install` → `init`, `hooks uninstall` → `uninstall`,
12//! `daemon start` → `start`, `daemon stop` → `stop`, `daemon restart` → `restart`
13
14pub mod color;
15pub mod commands;
16pub mod output;
17
18use clap::{Args, Parser, Subcommand, ValueEnum};
19
20use crate::cli::output::OutputConfig;
21
22/// The top-level CLI struct parsed by clap.
23#[derive(Parser)]
24#[command(
25    name = "openlatch",
26    version,
27    about = "The security layer for AI agents",
28    after_help = "Run 'openlatch <command> --help' for more information on a command."
29)]
30pub struct Cli {
31    #[command(subcommand)]
32    pub command: Commands,
33
34    /// Output format: human (default), json
35    #[arg(long, global = true, default_value = "human")]
36    pub format: OutputFormat,
37
38    /// Alias for --format json
39    #[arg(long, global = true)]
40    pub json: bool,
41
42    /// Show verbose output
43    #[arg(long, short = 'v', global = true)]
44    pub verbose: bool,
45
46    /// Show debug output (implies --verbose)
47    #[arg(long, global = true)]
48    pub debug: bool,
49
50    /// Suppress all output except errors
51    #[arg(long, short = 'q', global = true)]
52    pub quiet: bool,
53
54    /// Disable colored output
55    #[arg(long, global = true)]
56    pub no_color: bool,
57}
58
59/// Output format selection.
60#[derive(Clone, ValueEnum)]
61pub enum OutputFormat {
62    Human,
63    Json,
64}
65
66/// Top-level subcommands.
67#[derive(Subcommand)]
68pub enum Commands {
69    /// Initialize OpenLatch — detect agent, install hooks, start daemon
70    #[command(visible_alias = "setup")]
71    Init(InitArgs),
72
73    /// Show daemon status, uptime, event counts
74    Status,
75
76    /// Start the daemon
77    Start(StartArgs),
78
79    /// Stop the daemon
80    Stop,
81
82    /// Restart the daemon
83    Restart,
84
85    /// View event logs
86    Logs(LogsArgs),
87
88    /// Diagnose configuration and connectivity issues
89    Doctor,
90
91    /// Remove hooks and stop daemon
92    Uninstall(UninstallArgs),
93
94    /// Open documentation in browser
95    Docs,
96
97    /// Hook management subcommands (noun-verb alias: 'hooks install' = 'init')
98    Hooks {
99        #[command(subcommand)]
100        cmd: HooksCommands,
101    },
102
103    /// Daemon management subcommands (noun-verb alias: 'daemon start' = 'start')
104    #[command(hide = true)]
105    Daemon {
106        #[command(subcommand)]
107        cmd: DaemonCommands,
108    },
109}
110
111/// Subcommands under `openlatch hooks`.
112#[derive(Subcommand)]
113pub enum HooksCommands {
114    /// Install hooks (same as 'openlatch init')
115    Install(InitArgs),
116    /// Remove hooks (same as 'openlatch uninstall')
117    Uninstall(UninstallArgs),
118    /// Show hook status
119    Status,
120}
121
122/// Subcommands under `openlatch daemon`.
123#[derive(Subcommand)]
124pub enum DaemonCommands {
125    /// Start the daemon (same as 'openlatch start')
126    Start(StartArgs),
127    /// Stop the daemon (same as 'openlatch stop')
128    Stop,
129    /// Restart the daemon (same as 'openlatch restart')
130    Restart,
131}
132
133/// Arguments for the `init` subcommand.
134#[derive(Args, Clone)]
135pub struct InitArgs {
136    /// Run in foreground (no background daemon)
137    #[arg(long)]
138    pub foreground: bool,
139    /// Re-probe port and update configuration (use when port conflicts arise)
140    #[arg(long)]
141    pub reconfig: bool,
142    /// Install hooks and generate token without starting the daemon
143    #[arg(long)]
144    pub no_start: bool,
145}
146
147/// Arguments for the `start` subcommand.
148#[derive(Args, Clone)]
149pub struct StartArgs {
150    /// Run in foreground mode
151    #[arg(long)]
152    pub foreground: bool,
153    /// Port to listen on (overrides config)
154    #[arg(long)]
155    pub port: Option<u16>,
156}
157
158/// Arguments for the `logs` subcommand.
159#[derive(Args, Clone)]
160pub struct LogsArgs {
161    /// Follow log output (live tail)
162    #[arg(long, short = 'f')]
163    pub follow: bool,
164
165    /// Show events since this time (e.g., "1h", "30m", "2024-01-01")
166    #[arg(long)]
167    pub since: Option<String>,
168
169    /// Number of recent events to show
170    #[arg(long, short = 'n', default_value = "20")]
171    pub lines: usize,
172}
173
174/// Arguments for the `uninstall` subcommand.
175#[derive(Args, Clone)]
176pub struct UninstallArgs {
177    /// Also remove ~/.openlatch/ directory and all data
178    #[arg(long)]
179    pub purge: bool,
180
181    /// Skip confirmation prompt
182    #[arg(long, short = 'y')]
183    pub yes: bool,
184}
185
186/// Known subcommand names used for typo suggestion (CLI-12).
187const KNOWN_SUBCOMMANDS: &[&str] = &[
188    "init",
189    "status",
190    "start",
191    "stop",
192    "restart",
193    "logs",
194    "doctor",
195    "uninstall",
196    "docs",
197    "hooks",
198    "daemon",
199    "setup", // visible alias for init
200];
201
202/// Suggest the closest known subcommand for an unknown input string.
203///
204/// Uses Jaro-Winkler similarity. Returns `Some(suggestion)` if any known
205/// subcommand is more than 70% similar, or `None` if no close match exists.
206///
207/// # Examples
208///
209/// ```
210/// use openlatch_client::cli::suggest_subcommand;
211/// assert_eq!(suggest_subcommand("stats"), Some("status".to_string()));
212/// assert_eq!(suggest_subcommand("xyz"), None);
213/// ```
214pub fn suggest_subcommand(input: &str) -> Option<String> {
215    let mut best_name = "";
216    let mut best_score = 0.0_f64;
217
218    for &name in KNOWN_SUBCOMMANDS {
219        let score = strsim::jaro_winkler(input, name);
220        if score > best_score {
221            best_score = score;
222            best_name = name;
223        }
224    }
225
226    if best_score > 0.7 && !best_name.is_empty() {
227        Some(best_name.to_string())
228    } else {
229        None
230    }
231}
232
233/// Resolve the parsed CLI flags into a single [`OutputConfig`].
234///
235/// `--json` flag takes precedence over `--format`. `--no-color` flag,
236/// `NO_COLOR` env var, and TTY detection are all applied via [`color::is_color_enabled`].
237pub fn build_output_config(cli: &Cli) -> OutputConfig {
238    let format = if cli.json {
239        output::OutputFormat::Json
240    } else {
241        match cli.format {
242            OutputFormat::Json => output::OutputFormat::Json,
243            OutputFormat::Human => output::OutputFormat::Human,
244        }
245    };
246
247    let color_enabled = color::is_color_enabled(cli.no_color);
248
249    OutputConfig {
250        format,
251        verbose: cli.verbose || cli.debug,
252        debug: cli.debug,
253        quiet: cli.quiet,
254        color: color_enabled,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_suggest_subcommand_close_match() {
264        // "stats" is close to "status"
265        let suggestion = suggest_subcommand("stats");
266        assert_eq!(suggestion, Some("status".to_string()));
267    }
268
269    #[test]
270    fn test_suggest_subcommand_exact_match() {
271        let suggestion = suggest_subcommand("init");
272        assert_eq!(suggestion, Some("init".to_string()));
273    }
274
275    #[test]
276    fn test_suggest_subcommand_no_match() {
277        // "xyz" has no close match
278        let suggestion = suggest_subcommand("xyz");
279        assert!(suggestion.is_none());
280    }
281
282    #[test]
283    fn test_suggest_subcommand_typo() {
284        // "unitstall" → "uninstall"
285        let suggestion = suggest_subcommand("unitstall");
286        // May match "uninstall" or another; just verify it returns something sensible
287        assert!(suggestion.is_some());
288    }
289}