openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
/// `openlatch supervision` subcommand handlers.
///
/// Manages the OS-native supervisor (launchd / systemd-user / Task Scheduler)
/// that keeps the daemon alive across reboots. All handlers are idempotent per
/// `.claude/rules/cli-output.md`.
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::SupervisionCommands;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG};
use crate::supervision::{select_supervisor, SupervisionMode, SupervisorKind};

/// Dispatch for the `openlatch supervision <cmd>` family.
pub fn run(cmd: &SupervisionCommands, output: &OutputConfig) -> Result<(), OlError> {
    match cmd {
        SupervisionCommands::Install => run_install(output),
        SupervisionCommands::Uninstall => run_uninstall(output),
        SupervisionCommands::Status => run_status(output),
        SupervisionCommands::Enable => run_install(output),
        SupervisionCommands::Disable => run_disable(output),
    }
}

fn backend_label(kind: &SupervisorKind) -> &'static str {
    match kind {
        SupervisorKind::Launchd => "launchd",
        SupervisorKind::Systemd => "systemd",
        SupervisorKind::TaskScheduler => "task_scheduler",
        SupervisorKind::None => "none",
    }
}

fn mode_label(mode: &SupervisionMode) -> &'static str {
    match mode {
        SupervisionMode::Active => "active",
        SupervisionMode::Deferred => "deferred",
        SupervisionMode::Disabled => "disabled",
    }
}

fn ensure_config_path() -> Result<std::path::PathBuf, OlError> {
    let path = config::openlatch_dir().join("config.toml");
    if !path.exists() {
        // Initialise with the default template so subsequent sections have
        // something to insert into.
        config::ensure_config(config::Config::defaults().port)?;
    }
    Ok(path)
}

fn run_install(output: &OutputConfig) -> Result<(), OlError> {
    let config_path = ensure_config_path()?;

    let Some(supervisor) = select_supervisor() else {
        let reason = "unsupported_os";
        config::persist_supervision_state(
            &config_path,
            &SupervisionMode::Deferred,
            &SupervisorKind::None,
            Some(reason),
        )?;
        emit_outcome(output, "deferred", "none", Some(reason));
        return Ok(());
    };

    let exe_path =
        std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("openlatch"));
    let kind = supervisor.kind();

    let (mode, reason): (SupervisionMode, Option<String>) = match supervisor.install(&exe_path) {
        Ok(()) => (SupervisionMode::Active, None),
        Err(e) => {
            let reason = format!("{} ({})", e.message, e.code);
            (SupervisionMode::Deferred, Some(reason))
        }
    };

    config::persist_supervision_state(&config_path, &mode, &kind, reason.as_deref())?;

    crate::telemetry::capture_global(crate::telemetry::Event::supervision_installed(
        backend_label(&kind),
        mode_label(&mode),
        reason.as_deref(),
    ));

    emit_outcome(
        output,
        mode_label(&mode),
        backend_label(&kind),
        reason.as_deref(),
    );
    Ok(())
}

fn run_uninstall(output: &OutputConfig) -> Result<(), OlError> {
    let config_path = ensure_config_path()?;
    if let Some(supervisor) = select_supervisor() {
        let _ = supervisor.uninstall();
    }
    config::persist_supervision_state(
        &config_path,
        &SupervisionMode::Disabled,
        &SupervisorKind::None,
        Some("user_uninstalled"),
    )?;
    emit_outcome(output, "disabled", "none", Some("user_uninstalled"));
    Ok(())
}

fn run_disable(output: &OutputConfig) -> Result<(), OlError> {
    let config_path = ensure_config_path()?;
    if let Some(supervisor) = select_supervisor() {
        let _ = supervisor.uninstall();
    }
    config::persist_supervision_state(
        &config_path,
        &SupervisionMode::Disabled,
        &SupervisorKind::None,
        Some("user_opt_out"),
    )?;
    emit_outcome(output, "disabled", "none", Some("user_opt_out"));
    Ok(())
}

fn run_status(output: &OutputConfig) -> Result<(), OlError> {
    let cfg = config::Config::load(None, None, false)?;
    let (installed, running, description) = match select_supervisor() {
        Some(sup) => match sup.status() {
            Ok(s) => (s.installed, s.running, s.description),
            Err(e) => {
                return Err(OlError::new(
                    ERR_INVALID_CONFIG,
                    format!("Cannot query supervisor: {}", e.message),
                ))
            }
        },
        None => (false, false, "unsupported OS".to_string()),
    };

    let configured_mode = mode_label(&cfg.supervision.mode);
    let configured_backend = backend_label(&cfg.supervision.backend);

    if output.format == OutputFormat::Json {
        let json = serde_json::json!({
            "mode": configured_mode,
            "backend": configured_backend,
            "installed": installed,
            "running": running,
            "description": description,
            "disabled_reason": cfg.supervision.disabled_reason,
        });
        output.print_json(&json);
    } else if !output.quiet {
        crate::cli::header::print(output, &["supervision"]);
        eprintln!("  Mode:        {configured_mode}");
        eprintln!("  Backend:     {configured_backend}");
        eprintln!("  Installed:   {installed}");
        eprintln!("  Running:     {running}");
        eprintln!("  Description: {description}");
        if let Some(reason) = &cfg.supervision.disabled_reason {
            eprintln!("  Reason:      {reason}");
        }
    }
    Ok(())
}

fn emit_outcome(output: &OutputConfig, mode: &str, backend: &str, reason: Option<&str>) {
    if output.format == OutputFormat::Json {
        let json = serde_json::json!({
            "mode": mode,
            "backend": backend,
            "disabled_reason": reason,
        });
        output.print_json(&json);
    } else if !output.quiet {
        match mode {
            "active" => output.print_step(&format!(
                "Supervision installed ({backend}) — daemon will auto-start"
            )),
            "deferred" => {
                let tail = reason.unwrap_or("install failed");
                output.print_step(&format!("Supervision deferred — {tail}"));
            }
            "disabled" => {
                let tail = reason.unwrap_or("user_opt_out");
                output.print_step(&format!("Supervision disabled ({tail})"));
            }
            _ => output.print_step(&format!("Supervision: {mode}")),
        }
    }
}