openlatch-client 0.0.0

The open-source security layer for AI agents — client forwarder
Documentation
//! CLI command tree for the `openlatch` binary.
//!
//! This module defines the full clap command structure, global flags, noun-verb
//! aliasing, and the helper for resolving output configuration from parsed CLI args.
//!
//! ## Command grammar
//!
//! Primary verbs: `init`, `status`, `start`, `stop`, `restart`, `logs`, `doctor`,
//! `uninstall`, `docs`
//!
//! Noun-verb aliases: `hooks install` → `init`, `hooks uninstall` → `uninstall`,
//! `daemon start` → `start`, `daemon stop` → `stop`, `daemon restart` → `restart`

pub mod color;
pub mod commands;
pub mod output;

use clap::{Args, Parser, Subcommand, ValueEnum};

use crate::cli::output::OutputConfig;

/// The top-level CLI struct parsed by clap.
#[derive(Parser)]
#[command(
    name = "openlatch",
    version,
    about = "The security layer for AI agents",
    after_help = "Run 'openlatch <command> --help' for more information on a command."
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,

    /// Output format: human (default), json
    #[arg(long, global = true, default_value = "human")]
    pub format: OutputFormat,

    /// Alias for --format json
    #[arg(long, global = true)]
    pub json: bool,

    /// Show verbose output
    #[arg(long, short = 'v', global = true)]
    pub verbose: bool,

    /// Show debug output (implies --verbose)
    #[arg(long, global = true)]
    pub debug: bool,

    /// Suppress all output except errors
    #[arg(long, short = 'q', global = true)]
    pub quiet: bool,

    /// Disable colored output
    #[arg(long, global = true)]
    pub no_color: bool,
}

/// Output format selection.
#[derive(Clone, ValueEnum)]
pub enum OutputFormat {
    Human,
    Json,
}

/// Top-level subcommands.
#[derive(Subcommand)]
pub enum Commands {
    /// Initialize OpenLatch — detect agent, install hooks, start daemon
    #[command(visible_alias = "setup")]
    Init(InitArgs),

    /// Show daemon status, uptime, event counts
    Status,

    /// Start the daemon
    Start(StartArgs),

    /// Stop the daemon
    Stop,

    /// Restart the daemon
    Restart,

    /// View event logs
    Logs(LogsArgs),

    /// Diagnose configuration and connectivity issues
    Doctor,

    /// Remove hooks and stop daemon
    Uninstall(UninstallArgs),

    /// Open documentation in browser
    Docs,

    /// Hook management subcommands (noun-verb alias: 'hooks install' = 'init')
    Hooks {
        #[command(subcommand)]
        cmd: HooksCommands,
    },

    /// Daemon management subcommands (noun-verb alias: 'daemon start' = 'start')
    #[command(hide = true)]
    Daemon {
        #[command(subcommand)]
        cmd: DaemonCommands,
    },
}

/// Subcommands under `openlatch hooks`.
#[derive(Subcommand)]
pub enum HooksCommands {
    /// Install hooks (same as 'openlatch init')
    Install(InitArgs),
    /// Remove hooks (same as 'openlatch uninstall')
    Uninstall(UninstallArgs),
    /// Show hook status
    Status,
}

/// Subcommands under `openlatch daemon`.
#[derive(Subcommand)]
pub enum DaemonCommands {
    /// Start the daemon (same as 'openlatch start')
    Start(StartArgs),
    /// Stop the daemon (same as 'openlatch stop')
    Stop,
    /// Restart the daemon (same as 'openlatch restart')
    Restart,
}

/// Arguments for the `init` subcommand.
#[derive(Args, Clone)]
pub struct InitArgs {
    /// Run in foreground (no background daemon)
    #[arg(long)]
    pub foreground: bool,
    /// Re-probe port and update configuration (use when port conflicts arise)
    #[arg(long)]
    pub reconfig: bool,
    /// Install hooks and generate token without starting the daemon
    #[arg(long)]
    pub no_start: bool,
}

/// Arguments for the `start` subcommand.
#[derive(Args, Clone)]
pub struct StartArgs {
    /// Run in foreground mode
    #[arg(long)]
    pub foreground: bool,
    /// Port to listen on (overrides config)
    #[arg(long)]
    pub port: Option<u16>,
}

/// Arguments for the `logs` subcommand.
#[derive(Args, Clone)]
pub struct LogsArgs {
    /// Follow log output (live tail)
    #[arg(long, short = 'f')]
    pub follow: bool,

    /// Show events since this time (e.g., "1h", "30m", "2024-01-01")
    #[arg(long)]
    pub since: Option<String>,

    /// Number of recent events to show
    #[arg(long, short = 'n', default_value = "20")]
    pub lines: usize,
}

/// Arguments for the `uninstall` subcommand.
#[derive(Args, Clone)]
pub struct UninstallArgs {
    /// Also remove ~/.openlatch/ directory and all data
    #[arg(long)]
    pub purge: bool,

    /// Skip confirmation prompt
    #[arg(long, short = 'y')]
    pub yes: bool,
}

/// Known subcommand names used for typo suggestion (CLI-12).
const KNOWN_SUBCOMMANDS: &[&str] = &[
    "init",
    "status",
    "start",
    "stop",
    "restart",
    "logs",
    "doctor",
    "uninstall",
    "docs",
    "hooks",
    "daemon",
    "setup", // visible alias for init
];

/// Suggest the closest known subcommand for an unknown input string.
///
/// Uses Jaro-Winkler similarity. Returns `Some(suggestion)` if any known
/// subcommand is more than 70% similar, or `None` if no close match exists.
///
/// # Examples
///
/// ```
/// use openlatch_client::cli::suggest_subcommand;
/// assert_eq!(suggest_subcommand("stats"), Some("status".to_string()));
/// assert_eq!(suggest_subcommand("xyz"), None);
/// ```
pub fn suggest_subcommand(input: &str) -> Option<String> {
    let mut best_name = "";
    let mut best_score = 0.0_f64;

    for &name in KNOWN_SUBCOMMANDS {
        let score = strsim::jaro_winkler(input, name);
        if score > best_score {
            best_score = score;
            best_name = name;
        }
    }

    if best_score > 0.7 && !best_name.is_empty() {
        Some(best_name.to_string())
    } else {
        None
    }
}

/// Resolve the parsed CLI flags into a single [`OutputConfig`].
///
/// `--json` flag takes precedence over `--format`. `--no-color` flag,
/// `NO_COLOR` env var, and TTY detection are all applied via [`color::is_color_enabled`].
pub fn build_output_config(cli: &Cli) -> OutputConfig {
    let format = if cli.json {
        output::OutputFormat::Json
    } else {
        match cli.format {
            OutputFormat::Json => output::OutputFormat::Json,
            OutputFormat::Human => output::OutputFormat::Human,
        }
    };

    let color_enabled = color::is_color_enabled(cli.no_color);

    OutputConfig {
        format,
        verbose: cli.verbose || cli.debug,
        debug: cli.debug,
        quiet: cli.quiet,
        color: color_enabled,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_suggest_subcommand_close_match() {
        // "stats" is close to "status"
        let suggestion = suggest_subcommand("stats");
        assert_eq!(suggestion, Some("status".to_string()));
    }

    #[test]
    fn test_suggest_subcommand_exact_match() {
        let suggestion = suggest_subcommand("init");
        assert_eq!(suggestion, Some("init".to_string()));
    }

    #[test]
    fn test_suggest_subcommand_no_match() {
        // "xyz" has no close match
        let suggestion = suggest_subcommand("xyz");
        assert!(suggestion.is_none());
    }

    #[test]
    fn test_suggest_subcommand_typo() {
        // "unitstall" → "uninstall"
        let suggestion = suggest_subcommand("unitstall");
        // May match "uninstall" or another; just verify it returns something sensible
        assert!(suggestion.is_some());
    }
}