openlatch-client 0.1.8

The open-source security layer for AI agents — client forwarder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! 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 header;
pub mod output;

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

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

/// ASCII banner shown above `--help` and when the CLI is invoked with no
/// subcommand. Kept compact (3 lines) to avoid eating vertical space in small
/// terminals.
pub const BANNER: &str = "\
\x1b[36m▄▄▄ OpenLatch ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\x1b[0m
    hook events → envelope → cloud
    localhost:7443  ·  fail-open
";

/// Plain (no-ANSI) banner for non-TTY / --no-color contexts.
pub const BANNER_PLAIN: &str = "\
=== OpenLatch =============================
    hook events -> envelope -> cloud
    localhost:7443  ·  fail-open
";

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

    /// 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,
    },

    /// Authenticate with OpenLatch cloud
    Auth {
        #[command(subcommand)]
        cmd: AuthCommands,
    },

    /// Manage anonymous usage telemetry (opt-out)
    Telemetry {
        #[command(subcommand)]
        cmd: TelemetryCommands,
    },

    /// Manage OS-native supervision (auto-restart on login/boot)
    Supervision {
        #[command(subcommand)]
        cmd: SupervisionCommands,
    },
}

/// Subcommands under `openlatch telemetry`.
#[derive(Subcommand)]
pub enum TelemetryCommands {
    /// Show consent state, deciding rule, and build inclusion
    Status,
    /// Opt in to anonymous usage telemetry
    Enable,
    /// Opt out of anonymous usage telemetry
    Disable,
    /// Disable and wipe in-memory queue in any running daemon
    Purge,
    /// Show how to run any command with debug-mode event output
    Debug,
}

/// Subcommands under `openlatch supervision`.
#[derive(Subcommand)]
pub enum SupervisionCommands {
    /// Install the OS supervisor so the daemon auto-starts on login/boot
    Install,
    /// Remove the OS supervisor (stops auto-start; does not stop the daemon)
    Uninstall,
    /// Show supervision state (installed, running, backend)
    Status,
    /// Re-arm supervision after `disable` (re-installs the OS artifact)
    Enable,
    /// Remove the OS artifact and mark supervision as disabled
    Disable,
}

/// 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,
}

/// Subcommands under `openlatch auth`.
#[derive(Subcommand)]
pub enum AuthCommands {
    /// Log in to OpenLatch (opens browser for authentication)
    Login(AuthLoginArgs),
    /// Log out and remove stored credentials
    Logout,
    /// Show authentication status
    Status,
}

/// Arguments for the `auth login` subcommand.
#[derive(Args, Clone)]
pub struct AuthLoginArgs {
    /// Skip browser open; print URL only
    #[arg(long)]
    pub no_browser: bool,
}

/// 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,
    /// Accept anonymous usage telemetry without prompting
    #[arg(long, alias = "yes-telemetry", conflicts_with = "no_telemetry")]
    pub telemetry: bool,
    /// Decline anonymous usage telemetry without prompting
    #[arg(long)]
    pub no_telemetry: bool,
    /// Skip OS-native supervision install (launchd / systemd-user / Task Scheduler).
    /// Persistence is default-on — passing this flag keeps the daemon manually-managed only.
    #[arg(long)]
    pub no_persistence: 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 `doctor` subcommand.
#[derive(Args, Clone, Default)]
pub struct DoctorArgs {
    /// Trigger a controlled panic to validate the crash-report pipeline (hidden)
    #[arg(long, hide = true)]
    pub trigger_panic: bool,

    /// Auto-heal common issues (config, hooks, daemon, binaries). Creates .bak backups.
    #[arg(long, conflicts_with = "restore")]
    pub fix: bool,

    /// Restore files from the most recent .bak backups created by --fix.
    #[arg(long, conflicts_with = "fix")]
    pub restore: bool,

    /// Bundle diagnostics into a ZIP for sharing with OpenLatch support.
    #[arg(long)]
    pub rescue: bool,

    /// Override time window for --rescue log collection (e.g. 7d, 4h).
    #[arg(long, value_name = "DURATION", requires = "rescue")]
    pub since: Option<String>,

    /// Skip the inventory confirmation prompt on --rescue (for scripting).
    #[arg(long, short = 'y', requires = "rescue")]
    pub yes: bool,

    /// Override rescue output path.
    #[arg(long, value_name = "PATH", requires = "rescue")]
    pub output: Option<std::path::PathBuf>,
}

/// 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,

    /// Show tamper-evidence events from `~/.openlatch/tamper.jsonl` instead
    /// of hook events. Each line is either a tamper_detected or
    /// tamper_healed entry emitted by the daemon reconciler.
    #[arg(long)]
    pub tamper: bool,
}

/// 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
    "auth",
    "login",
    "logout",
    "telemetry",
    "supervision",
];

/// 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());
    }
}