openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
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
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
//! 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;
pub mod prompt;
pub mod report;

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` alone resolves to CARGO_PKG_VERSION, which cannot tell a build
    // from `main` apart from the release it post-dates — the exact question
    // `openlatch --version` is run to answer. Same source as `status` and the
    // wire `clientversion` (see build.rs).
    version = env!("OPENLATCH_VERSION"),
    about = "OpenLatch runtime enforcement node — capture and enforce inside every AI agent",
    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,
    },

    /// Check for or apply an auto-update of the openlatch binaries
    Update(commands::update::UpdateArgs),

    /// Inspect the configuration plane: list observed sources, tail config
    /// events, force a rescan, and report monitor health.
    Inventory {
        #[command(subcommand)]
        cmd: InventoryCommands,
    },

    /// (maintainers only) Phase 1 spike — verify, swap, rollback. Not for end users.
    #[command(name = "__spike-update", hide = true)]
    SpikeUpdate(commands::spike_update::SpikeUpdateArgs),

    /// Inspect or switch the model-boundary listener.
    Boundary {
        #[command(subcommand)]
        cmd: BoundaryCommands,
    },

    /// Inspect, discover, set or test the proxy every outbound request takes.
    Proxy {
        #[command(subcommand)]
        cmd: ProxyCommands,
    },

    /// Evaluate Autonomy Zone policy over NDJSON rows on stdin (batch).
    ///
    /// The ONLY way another process obtains a verdict, and stateless by
    /// contract: no enrolment, no credential, no daemon, no configuration file.
    /// `main()` answers it above every init that would resolve one.
    Evaluate(commands::evaluate::EvaluateArgs),

    /// (maintainers only) Boundary verification benches. Not for end users.
    #[command(hide = true)]
    Bench {
        #[command(subcommand)]
        cmd: BenchCommands,
    },
}

/// Subcommands under `openlatch boundary`.
///
/// `enable` / `disable` write `[boundary] enabled` in config.toml and nothing
/// else. They are **not** a second owner of the agent's `ANTHROPIC_BASE_URL` —
/// that stays written and removed by the daemon holding the pinned port, which
/// is what keeps the agent config from ever naming a listener that does not
/// exist. Turning the boundary on or off is a config change that takes effect
/// when the daemon next binds, so both commands offer the restart that applies
/// it rather than pretending the switch is live.
#[derive(Subcommand, Clone)]
pub enum BoundaryCommands {
    /// Report the boundary listener's up/down state and pinned port.
    Status,
    /// Turn the model boundary on in config, and offer to restart the daemon.
    Enable(BoundaryToggleArgs),
    /// Turn the model boundary off in config, and offer to restart the daemon.
    Disable(BoundaryToggleArgs),
    /// Print a prefix-churn block LOCALLY by its finding id (the content never
    /// leaves the host — it is read from the on-disk retention store).
    Explain {
        /// The `finding_id` from an `ai.openlatch.prefix.finding_id` field.
        finding_id: String,
    },
}

/// Arguments shared by `boundary enable` and `boundary disable`.
#[derive(Args, Clone, Default)]
pub struct BoundaryToggleArgs {
    /// Restart the daemon without asking.
    #[arg(long, short = 'y', conflicts_with = "no_restart")]
    pub yes: bool,
    /// Write the config and stop there. The change takes effect at the next
    /// daemon start; the command exits 7 to say config and runtime disagree.
    #[arg(long)]
    pub no_restart: bool,
}

/// Subcommands under `openlatch proxy`.
///
/// The five verbs split cleanly in two: `status` and `test` only read, `discover`, `set`
/// and `clear` write `[proxy]` in `config.toml`. Nothing here restarts the daemon — a
/// running daemon resolves its own route and re-reads it on its own schedule, and a
/// command that reached in to change a live process's egress would be a second owner of
/// the invariant the daemon holds.
#[derive(Subcommand, Clone)]
pub enum ProxyCommands {
    /// Show the resolved route: CLI-context always, daemon-context when it differs.
    Status,
    /// Re-run the OS discovery ladder, probe each candidate, and persist the winner.
    Discover(ProxyDiscoverArgs),
    /// Set the proxy by hand. Writes `mode = "manual"` AND `source = "manual"`, which is
    /// what stops discovery and self-heal from ever overwriting it.
    Set(ProxySetArgs),
    /// Go direct: `mode = "direct"`, url and source cleared, credentials deleted.
    Clear,
    /// Probe the chain hop by hop — proxy CONNECT, TLS, HTTP, and a streaming leg.
    Test(ProxyTestArgs),
}

/// Arguments for `openlatch proxy discover`.
#[derive(Args, Clone, Default)]
pub struct ProxyDiscoverArgs {
    /// Overwrite a route a human set (`source = "manual"`).
    ///
    /// Without it, discovery refuses. A human who typed a proxy URL knows something the
    /// ladder does not, and silently replacing it is how an estate's one working route
    /// gets lost to a rung that merely probes green.
    #[arg(long)]
    pub force: bool,
}

/// Arguments for `openlatch proxy set`.
#[derive(Args, Clone)]
pub struct ProxySetArgs {
    /// The proxy URL: `http://`, `https://`, `socks5://` or `socks5h://`.
    ///
    /// Userinfo is rejected — argv is world-readable. On a 407 this command prompts for
    /// the credential and stores it in the OS keychain.
    pub url: String,
    /// PEM bundle merged on top of the OS trust store.
    #[arg(long, value_name = "PATH")]
    pub ca_bundle: Option<String>,
    /// Hosts that bypass the proxy, comma-separated.
    #[arg(long, value_name = "LIST")]
    pub no_proxy: Option<String>,
    /// Kerberos SPN override.
    #[arg(long, value_name = "SPN")]
    pub spn: Option<String>,
}

/// Arguments for `openlatch proxy test`.
#[derive(Args, Clone, Default)]
pub struct ProxyTestArgs {
    /// Probe this destination instead of the configured platform origin.
    #[arg(long, value_name = "URL")]
    pub url: Option<String>,
}

/// Subcommands under `openlatch bench` (maintainer/CI verification only).
#[derive(Subcommand, Clone)]
pub enum BenchCommands {
    /// D-24: inject a panic into the observe path; prove the forward is
    /// unmodified, the failure is recorded, and the PID is unchanged.
    PanicIsolation(BenchPanicArgs),
    /// D-05/D-25: prove the pinned port rebinds the same port within 2s and
    /// that an occupied port fails loudly rather than re-probing.
    PortStability,
    /// I-1 §7 DoD: write a canonical stream of `ai.openlatch.economics.*` events
    /// (the fixture corpus I-2 builds against) to stdout as JSONL.
    ExportFixtures,
    /// D-08: prove the capture path (observe/tokenize/session/churn) makes ZERO
    /// outbound connections — it holds no network client by construction.
    TransformEgress,
    /// D-05: re-evaluate a fixed matching request against a baseline transform rule
    /// N times and prove every would-have output is byte-identical, with zero
    /// outbound connections during evaluation.
    Replay(BenchReplayArgs),
    /// D-14/D-19: run a FIXED scripted conversation against Anthropic and write
    /// per-turn provider usage + TTFT as JSON. `--direct` is the control (straight
    /// to the provider); default routes through the boundary listener. Dev/CI-only:
    /// it makes REAL network calls and needs `ANTHROPIC_API_KEY`.
    CacheBaseline(BenchCacheBaselineArgs),
    /// D-14/D-19: compare two cache-baseline JSON reports — token-weighted
    /// cache-hit-rate delta, p95 TTFT delta, and the two-part G-2 gate (the no-MCP
    /// case is the D-19 release gate; an MCP case is disclosed, not gated).
    Compare(BenchCompareArgs),
    /// Run N concurrent sessions interleaved so most-recently-active is usually
    /// wrong, and report the mis-attribution rate without the request's own
    /// signals vs with them, per selector. Network-free.
    Attribution(BenchAttributionArgs),
}

/// Arguments for `bench attribution`.
#[derive(Args, Clone)]
pub struct BenchAttributionArgs {
    /// How many sessions are live in the install at once. Must be >= 2 — with
    /// one live session both paths attest the same answer.
    #[arg(long, default_value_t = 3)]
    pub sessions: usize,
    /// Turns each session takes.
    #[arg(long, default_value_t = 20)]
    pub turns: usize,
}

/// Arguments for `bench panic-isolation`.
#[derive(Args, Clone)]
pub struct BenchPanicArgs {
    /// Which fault to inject. `observe` panics inside the measurement step
    /// (D-24); `mutate` panics inside the L-0 mutation step (D-28). Both must
    /// forward the ORIGINAL body byte-identically and record the failure.
    #[arg(long, default_value = "observe")]
    pub inject: String,
}

/// Arguments for `bench replay`.
#[derive(Args, Clone)]
pub struct BenchReplayArgs {
    /// Which rule to replay. `OL-ECO-001` / `OL-ECO-002` replay a would-have
    /// tuple from the baseline set; `OL-ECO-L0` replays the ACTING L-0 lever
    /// over a fixed session and compares the rewritten BODIES as well (D-28).
    #[arg(long, default_value = "OL-ECO-001")]
    pub rule: String,
    /// How many times to re-evaluate the would-have. All outputs must match.
    #[arg(long, default_value = "100")]
    pub runs: u64,
}

/// Arguments for `bench cache-baseline`.
#[derive(Args, Clone)]
pub struct BenchCacheBaselineArgs {
    /// Number of scripted turns (requests). The D-19 gate needs >= 100.
    #[arg(long, default_value = "100")]
    pub turns: usize,
    /// Inject MCP tool definitions into the request. This workload is MEASURED &
    /// DISCLOSED, never gated (MCP forces tool defs into the invalidatable prefix).
    #[arg(long)]
    pub with_mcp: bool,
    /// Send straight to https://api.anthropic.com (the control). Default sends
    /// through the boundary listener on the pinned loopback port.
    #[arg(long)]
    pub direct: bool,
    /// Build the prefix WITHOUT a `cache_control` breakpoint — the workload
    /// L-0's `insert_breakpoints` exists for. Run once with
    /// `[boundary] transforms_act` off and once on, then `bench compare` the two
    /// reports to A/B the lever (D-28). Off, the cache-hit rate is ~0 by
    /// construction; on, it should climb from the second turn.
    #[arg(long)]
    pub uncached_prefix: bool,
    /// Output path for the per-turn usage JSON. Defaults to stdout when omitted.
    #[arg(long, value_name = "FILE")]
    pub out: Option<std::path::PathBuf>,
    /// Positional alternative to `--out`. Defaults to stdout when omitted.
    #[arg(value_name = "OUT")]
    pub out_positional: Option<std::path::PathBuf>,
}

/// Arguments for `bench compare`.
#[derive(Args, Clone)]
pub struct BenchCompareArgs {
    /// Baseline report A (typically the `--direct` control).
    #[arg(value_name = "A")]
    pub a: std::path::PathBuf,
    /// Comparison report B (typically the through-layer run).
    #[arg(value_name = "B")]
    pub b: std::path::PathBuf,
}

/// Subcommands under `openlatch inventory`.
#[derive(Subcommand, Clone)]
pub enum InventoryCommands {
    /// List observed config sources from the local audit log.
    List(commands::inventory::ListArgs),
    /// Tail the local audit log filtered to `ai.openlatch.config.*` events.
    Log(commands::inventory::LogArgs),
    /// Force a manifest rescan now (POSTs `/admin/inventory/rescan`).
    Rescan(commands::inventory::RescanArgs),
    /// Report config-monitor health: cache size, manifest loaded, enabled flag.
    Status(commands::inventory::StatusArgs),
    /// Detail view for one source — current content_hash + pending alerts.
    Inspect(commands::inventory::InspectArgs),
    /// Show currently-known project roots inferred from cached config paths.
    Projects(commands::inventory::ProjectsArgs),
    /// Acknowledge a pending alert so it stops being injected at next interaction.
    Ack(commands::inventory::AckArgs),
}

/// 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,
    /// Do NOT point the agent at the model-boundary listener. The boundary is
    /// on by default (secure-by-default) — it writes `ANTHROPIC_BASE_URL` + the
    /// install-id header, which disables Claude Code Remote Control. Pass this to
    /// keep the agent connected directly to the provider.
    ///
    /// Persists `[boundary] enabled = false` in config.toml, so the opt-out
    /// survives the OS supervisor restarting the daemon. Re-enable by setting it
    /// back to `true` there, or by re-running `init` without this flag.
    #[arg(long)]
    pub no_boundary: bool,
    /// Platform origin to forward events to, written to `[cloud] api_url` in
    /// config.toml. Use for local development against a self-hosted platform,
    /// e.g. `--api-url http://127.0.0.1:5183`. Omit to keep the current value.
    #[arg(long, value_name = "URL")]
    pub api_url: Option<String>,

    /// Wire only the named agents. Repeatable, e.g.
    /// `--agent claude-code --agent codex-cli`.
    ///
    /// Omitted, every detected agent is wired — coverage is the default, and
    /// opting one out is the explicit act. A value that is not an agent type,
    /// or one that names an agent this machine does not have, exits non-zero
    /// naming it: that is a typo, and silently doing nothing is how somebody
    /// comes to believe they are covered when they are not.
    #[arg(long, value_name = "TYPE")]
    pub agent: Vec<String>,

    // ---- Egress (Proxy Support I-2) ----
    //
    // Precedence tier 1 — these beat `OPENLATCH_*`, `[proxy]` in config.toml, the
    // ambient `https_proxy` family, and OS discovery, per key.
    /// Proxy URL to use, e.g. `http://proxy.corp:8080`. Highest precedence.
    ///
    /// **Never put a username or password in it.** argv is readable by every process on
    /// the host (`/proc/<pid>/cmdline`, Win32 `CommandLine`), so a URL carrying userinfo
    /// is rejected outright. Credentials belong in `OPENLATCH_PROXY` or the prompt this
    /// command shows on a 407 — both of which land in the OS credential store.
    #[arg(long, value_name = "URL")]
    pub proxy: Option<String>,
    /// Hosts that bypass the proxy, comma-separated. Loopback always bypasses and needs
    /// no entry.
    #[arg(long, value_name = "LIST")]
    pub no_proxy: Option<String>,
    /// PEM bundle merged on top of the OS trust store — the intercepting proxy's root.
    #[arg(long, value_name = "PATH")]
    pub ca_bundle: Option<String>,
    /// `auto` walks the ladder, `manual` uses exactly what is configured and is never
    /// re-discovered, `direct` never uses a proxy.
    #[arg(long, value_name = "MODE")]
    pub proxy_mode: Option<String>,
    /// Force an authentication scheme instead of answering whatever the proxy offers.
    /// NTLM is deliberately absent.
    #[arg(long, value_name = "SCHEME")]
    pub proxy_auth: Option<String>,
    /// Kerberos SPN override. Defaults to `HTTP/<proxy-host>`.
    #[arg(long, value_name = "SPN")]
    pub proxy_spn: Option<String>,

    /// Never prompt, even on a terminal. An install that would have to ask a question
    /// fails instead, naming what it needed.
    ///
    /// This is the only way to script an install from an interactive shell: without it,
    /// a TTY is taken as an invitation to ask, and the script blocks on a prompt nobody
    /// is there to answer.
    #[arg(long)]
    pub yes: bool,
    /// Report what `init` WOULD do about the egress route and exit, touching nothing.
    ///
    /// Writes no file and creates no directory — not even `~/.openlatch`. Pair with
    /// `--json` for `proxy: { action, source? }`.
    #[arg(long)]
    pub dry_run: 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>,
    /// Loopback port for the model boundary (overrides config; default 7600).
    ///
    /// A non-default port makes this instance ISOLATED: it binds the port but
    /// does not write `~/.claude/settings.json`, and prints the
    /// `ANTHROPIC_BASE_URL` to export instead. Use it to run a second daemon
    /// alongside the machine's own without stealing its agent wiring.
    ///
    /// Applies to this invocation only — like `--port`, it does not persist, so
    /// a later `openlatch status` / `doctor` / `stop` in a different shell reads
    /// the config default and reports on the wrong instance. For a whole
    /// isolated shell, export the equivalent variables instead:
    /// `OPENLATCH_DIR`, `OPENLATCH_PORT`, `OPENLATCH_BOUNDARY_PORT`.
    #[arg(long, value_name = "PORT")]
    pub boundary_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, all data, and the API key stored
    /// in the OS keychain (this machine only — the key is not revoked
    /// server-side; use `openlatch auth logout` for that)
    #[arg(long)]
    pub purge: bool,

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

    /// Unwire only the named agents. Repeatable; omitted means every detected
    /// agent.
    ///
    /// **It scopes the hook removal and nothing else.** Supervision teardown,
    /// stopping the daemon and `--purge` are machine-wide and run regardless,
    /// so `uninstall --agent codex-cli` on a host with two agents is a partial
    /// unwire *plus* a full stop: the other agent's entries survive, pointed at
    /// a daemon that is no longer running. `uninstall` is not `stop --agent`.
    ///
    /// A value that is not an agent type, or one this machine does not have,
    /// exits non-zero naming it — before anything is touched.
    #[arg(long, value_name = "TYPE")]
    pub agent: Vec<String>,
}

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