openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
use clap::Parser;

use openlatch_client::cli;
use openlatch_client::cli::Commands;
use openlatch_client::telemetry::{self, Event};

fn main() {
    // CLI-09: Set SIGINT handler to ensure exit code 130. Flush pending crash
    // reports before the hard exit — `process::exit` skips Drop impls, so the
    // `ClientInitGuard`'s flush-on-drop never runs.
    let _ = ctrlc::set_handler(|| {
        #[cfg(feature = "crash-report")]
        openlatch_client::crash_report::flush(std::time::Duration::from_secs(2));
        std::process::exit(130);
    });

    // Crash reporting first — captures panics in everything below, including
    // runtime construction and the detached daemon child process which
    // re-enters this main() via `daemon start --foreground`. The guard is
    // bound to `main`'s stack frame so its Drop (2s flush deadline) runs on
    // normal exit. No-op when the `crash-report` feature is disabled or
    // consent resolves to Disabled.
    #[cfg(feature = "crash-report")]
    let _crash_guard = init_crash_report();

    // Telemetry: initialise the process-global handle and install a panic hook.
    // Both are no-ops when consent is disabled or no key is baked. Phase A's
    // init never spawns a network task; Task 3 will activate the POST path.
    // Note: sentry's PanicIntegration already installed its own hook during
    // init above; our hook chains through `prev(info)` so both fire.
    init_telemetry();
    install_panic_hook();

    let cli_args = cli::Cli::parse();
    let output = cli::build_output_config(&cli_args);

    // No subcommand → print banner + help and exit 0. Matches `--help` UX but
    // routes through our color-aware output path so `--no-color`/non-TTY gets
    // the plain banner while TTY gets the ANSI version.
    let Some(command) = cli_args.command.as_ref() else {
        use clap::CommandFactory;
        cli::header::print_full_banner(&output);
        let _ = cli::Cli::command().print_help();
        println!();
        return;
    };

    let started_at = std::time::Instant::now();
    let (command_label, subcommand_label) = command_labels(command);
    let skip_command_event = should_skip_command_event(command);

    // Tag the current process as a CLI invocation. `run_daemon_foreground`
    // overwrites `process_type` to "daemon" before any daemon work runs,
    // when this main() is reached via `daemon start --foreground`.
    #[cfg(feature = "crash-report")]
    openlatch_client::crash_report::enrich_cli_scope(command_label);

    let result = dispatch(command, &output);

    let exit_code = if result.is_err() { 1 } else { 0 };
    let duration_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
    if !skip_command_event {
        telemetry::capture_global(Event::command_invoked(
            command_label,
            subcommand_label,
            exit_code,
            duration_ms,
        ));
    }

    if let Err(e) = result {
        output.print_error(&e);
        std::process::exit(1);
    }
}

/// Initialise Sentry crash reporting. Returns `None` when disabled by any
/// consent rule; subsequent `enrich_*` / `flush` calls are no-ops in that
/// state. The guard must be held for the lifetime of the program — its Drop
/// flushes pending events with a 2s deadline.
#[cfg(feature = "crash-report")]
fn init_crash_report() -> Option<sentry::ClientInitGuard> {
    let dir = openlatch_client::config::openlatch_dir();
    openlatch_client::crash_report::init(&dir)
}

/// Initialise the process-global telemetry handle. Reads the openlatch dir
/// for the consent file and agent id. Best-effort — errors are swallowed
/// because telemetry must never fail user commands (invariant I10-adjacent).
fn init_telemetry() {
    let dir = openlatch_client::config::openlatch_dir();
    // The agent install id may not exist yet on a brand-new install (init has
    // not run). When absent, fall back to a placeholder — the very first
    // command before `init` produces no useful identity, and that is
    // acceptable.
    let agent_id = std::fs::read_to_string(dir.join("config.toml"))
        .ok()
        .and_then(|raw| {
            // Cheap parse: look for an `agent_id = "..."` line. Avoids
            // pulling in toml parsing here just to learn the id.
            raw.lines()
                .find_map(|l| {
                    let l = l.trim();
                    l.strip_prefix("agent_id")
                        .and_then(|rest| rest.split('=').nth(1))
                        .map(|v| v.trim().trim_matches('"').to_string())
                })
                .filter(|s| s.starts_with("agt_"))
        })
        .unwrap_or_else(|| "agt_unknown".to_string());

    // Task 3: probe for a non-empty PostHog key (build-time baked or runtime
    // override). Empty key → no-op handle, preserves I1.
    let baked_key_present = openlatch_client::telemetry::network::key_is_present();
    let handle = telemetry::init(&dir, agent_id, false, baked_key_present);
    let _ = telemetry::install_global(handle);
}

/// Convert a panic hook into a `daemon_crashed` event. Captures only the
/// `file:line` of the panic location — never the message or interpolated
/// values (§5.3 of the brainstorm).
fn install_panic_hook() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let location = info
            .location()
            .map(|l| format!("{}:{}", l.file(), l.line()))
            .unwrap_or_else(|| "unknown".to_string());
        telemetry::capture_global(Event::daemon_crashed(&location, 0));
        prev(info);
    }));
}

/// Map a parsed CLI command to a stable `(command, subcommand)` pair for the
/// `command_invoked` event. Names are taken from the clap subcommand grammar
/// — never from user-supplied flag values.
fn command_labels(cmd: &Commands) -> (&'static str, Option<&'static str>) {
    match cmd {
        Commands::Init(_) => ("init", None),
        Commands::Status => ("status", None),
        Commands::Start(_) => ("start", None),
        Commands::Stop => ("stop", None),
        Commands::Restart => ("restart", None),
        Commands::Logs(_) => ("logs", None),
        Commands::Doctor(_) => ("doctor", None),
        Commands::Uninstall(_) => ("uninstall", None),
        Commands::Docs => ("docs", None),
        Commands::Hooks { cmd } => (
            "hooks",
            Some(match cmd {
                cli::HooksCommands::Install(_) => "install",
                cli::HooksCommands::Uninstall(_) => "uninstall",
                cli::HooksCommands::Status => "status",
            }),
        ),
        Commands::Daemon { cmd } => (
            "daemon",
            Some(match cmd {
                cli::DaemonCommands::Start(_) => "start",
                cli::DaemonCommands::Stop => "stop",
                cli::DaemonCommands::Restart => "restart",
            }),
        ),
        Commands::Auth { cmd } => (
            "auth",
            Some(match cmd {
                cli::AuthCommands::Login(_) => "login",
                cli::AuthCommands::Logout => "logout",
                cli::AuthCommands::Status => "status",
            }),
        ),
        Commands::Telemetry { cmd } => (
            "telemetry",
            Some(match cmd {
                cli::TelemetryCommands::Status => "status",
                cli::TelemetryCommands::Enable => "enable",
                cli::TelemetryCommands::Disable => "disable",
                cli::TelemetryCommands::Purge => "purge",
                cli::TelemetryCommands::Debug => "debug",
            }),
        ),
        Commands::Supervision { cmd } => (
            "supervision",
            Some(match cmd {
                cli::SupervisionCommands::Install => "install",
                cli::SupervisionCommands::Uninstall => "uninstall",
                cli::SupervisionCommands::Status => "status",
                cli::SupervisionCommands::Enable => "enable",
                cli::SupervisionCommands::Disable => "disable",
            }),
        ),
    }
}

/// `--help` and `--version` exit clap-side without ever reaching dispatch.
/// Other commands always emit `command_invoked`. The brainstorm singles out
/// help/version as noise to skip — clap handles them before we get here, so
/// this is currently unreachable, but we keep the predicate in case clap
/// behaviour changes or a Help subcommand is ever added.
fn should_skip_command_event(_cmd: &Commands) -> bool {
    false
}

/// Dispatch CLI commands to their handlers.
///
/// Returns `Err(OlError)` on failure; main() prints the error and exits 1.
fn dispatch(
    command: &Commands,
    output: &openlatch_client::cli::output::OutputConfig,
) -> Result<(), openlatch_client::error::OlError> {
    match command {
        Commands::Init(args) => openlatch_client::cli::commands::init::run_init(args, output),
        Commands::Status => openlatch_client::cli::commands::status::run_status(output),
        Commands::Start(args) => {
            openlatch_client::cli::commands::lifecycle::run_start(args, output)
        }
        Commands::Stop => openlatch_client::cli::commands::lifecycle::run_stop(output),
        Commands::Restart => openlatch_client::cli::commands::lifecycle::run_restart(output),
        Commands::Logs(args) => openlatch_client::cli::commands::logs::run_logs(args, output),
        Commands::Doctor(args) => openlatch_client::cli::commands::doctor::run_doctor(args, output),
        Commands::Uninstall(args) => {
            openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
        }
        Commands::Docs => openlatch_client::cli::commands::docs::run_docs(output),
        Commands::Hooks { cmd } => match cmd {
            cli::HooksCommands::Install(args) => {
                openlatch_client::cli::commands::init::run_init(args, output)
            }
            cli::HooksCommands::Uninstall(args) => {
                openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
            }
            cli::HooksCommands::Status => {
                openlatch_client::cli::commands::status::run_status(output)
            }
        },
        Commands::Daemon { cmd } => match cmd {
            cli::DaemonCommands::Start(args) => {
                openlatch_client::cli::commands::lifecycle::run_start(args, output)
            }
            cli::DaemonCommands::Stop => {
                openlatch_client::cli::commands::lifecycle::run_stop(output)
            }
            cli::DaemonCommands::Restart => {
                openlatch_client::cli::commands::lifecycle::run_restart(output)
            }
        },
        Commands::Auth { cmd } => match cmd {
            cli::AuthCommands::Login(args) => {
                openlatch_client::cli::commands::auth::run_login(args, output)
            }
            cli::AuthCommands::Logout => openlatch_client::cli::commands::auth::run_logout(output),
            cli::AuthCommands::Status => openlatch_client::cli::commands::auth::run_status(output),
        },
        Commands::Telemetry { cmd } => openlatch_client::cli::commands::telemetry::run(cmd, output),
        Commands::Supervision { cmd } => {
            openlatch_client::cli::commands::supervision::run(cmd, output)
        }
    }
}