use crate::auth::{retrieve_credential, FileCredentialStore, KeyringCredentialStore};
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::AuthLoginArgs;
use crate::cli::InitArgs;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG, ERR_PORT_IN_USE};
use crate::hooks;
use crate::hooks::DetectedAgent;
use crate::telemetry::{self, config as telemetry_config, consent_file_path, Event};
use secrecy::ExposeSecret;
use std::io::{BufRead, IsTerminal, Write};
pub fn run_init(args: &InitArgs, output: &OutputConfig) -> Result<(), OlError> {
crate::cli::header::print(output, &["init"]);
let ol_dir = config::openlatch_dir();
std::fs::create_dir_all(&ol_dir).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!(
"Cannot create openlatch directory '{}': {e}",
ol_dir.display()
),
)
.with_suggestion("Check that you have write permission to your home directory.")
})?;
std::fs::create_dir_all(ol_dir.join("logs")).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create logs directory: {e}"),
)
})?;
let agent = match hooks::detect_agent() {
Ok(a) => {
let label = agent_label(&a);
output.print_step(&format!("Detected agent: {label}"));
a
}
Err(e) => {
output.print_error(&e);
return Err(e);
}
};
let token_path = ol_dir.join("daemon.token");
let token_existed = token_path.exists();
let new_token = config::generate_token();
std::fs::write(&token_path, &new_token).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot write token file '{}': {e}", token_path.display()),
)
.with_suggestion("Check that you have write permission to the openlatch directory.")
})?;
crate::fs_secure::restrict_to_owner(&token_path).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot set permissions on token file: {e}"),
)
})?;
let token_action = if token_existed {
"(regenerated existing)"
} else {
"(new)"
};
output.print_step(&format!("Generated auth token {token_action}"));
let settings_path = match &agent {
DetectedAgent::ClaudeCode { settings_path, .. } => settings_path.clone(),
};
let config_path = config::openlatch_dir().join("config.toml");
let needs_port_probe = !config_path.exists() || args.reconfig;
let port = if needs_port_probe {
if args.reconfig {
let _ = lifecycle::run_stop(output);
let _ = std::fs::remove_file(config::openlatch_dir().join("daemon.port"));
let _ = std::fs::remove_file(&config_path);
}
let requested = std::env::var("OPENLATCH_PORT")
.ok()
.filter(|v| !v.trim().is_empty())
.map(|v| config::parse_port_env(&v))
.transpose()?;
let selected = match requested {
Some(p) => {
if std::net::TcpListener::bind(("127.0.0.1", p)).is_err() {
return Err(OlError::new(
ERR_PORT_IN_USE,
format!("OPENLATCH_PORT={p} is already in use"),
)
.with_suggestion(format!(
"Free port {p}, choose another via OPENLATCH_PORT, or unset it to probe {}-{} automatically.",
config::PORT_RANGE_START,
config::PORT_RANGE_END
))
.with_docs("https://docs.openlatch.ai/errors/OL-1500"));
}
output.print_substep(&format!("Selected port {p} (from OPENLATCH_PORT)"));
p
}
None => {
let probed =
config::probe_free_port(config::PORT_RANGE_START, config::PORT_RANGE_END)?;
output.print_substep(&format!("Selected port {probed} (first available)"));
probed
}
};
config::ensure_config(selected)?;
config::write_port_file(selected)?;
selected
} else {
config::Config::load(None, None, false)?.port
};
config::ensure_agent_id(&config_path)?;
if let Some(api_url) = &args.api_url {
config::persist_api_url(&config_path, api_url)?;
output.print_substep(&format!("Cloud API URL set to {api_url}"));
}
if args.no_boundary {
config::persist_boundary_enabled(&config_path, false)?;
output.print_substep(
"Model boundary disabled in config — agents connect to the provider directly",
);
}
let cfg = config::Config::load(Some(port), None, false)?;
let (auth_success, org_name) = run_auth_for_init(output)?;
handle_telemetry_consent(args, output, &ol_dir)?;
match hooks::staging::stage_hook_binary(&ol_dir) {
Ok(outcome) => {
output.print_step(&format!("Hook binary at {}", outcome.target().display()));
}
Err(e) => {
output.print_error(&e);
return Err(e);
}
}
let hook_result = match hooks::install_hooks(&agent, cfg.port, &new_token) {
Ok(r) => r,
Err(e) => {
output.print_error(&e);
return Err(e);
}
};
output.print_step(&format!("Hooks written to {}", settings_path.display()));
for entry in &hook_result.entries {
let action_label = match entry.action {
hooks::HookAction::Added => "added",
hooks::HookAction::Replaced => "replaced",
};
output.print_substep(&format!("{} ({})", entry.event_type, action_label));
}
let (supervision_backend_label, supervision_mode_label, supervision_deferred_reason) =
run_supervision_install_for_init(args, &config_path, output);
let start_plan = plan_daemon_start(
args.no_start,
args.foreground,
supervision_mode_label == "active",
);
let (port, pid) = if start_plan == DaemonStartPlan::Skip {
output.print_step("Skipped daemon start (--no-start)");
(cfg.port, 0u32)
} else if start_plan == DaemonStartPlan::SupervisorOwned {
if wait_for_health(cfg.port, 10) {
let pid = lifecycle::read_pid_file().unwrap_or(0);
output.print_step(&format!(
"Daemon started on port {} (PID {pid}, supervised)",
cfg.port
));
(cfg.port, pid)
} else if let Some(pid) =
lifecycle::read_pid_file().filter(|p| lifecycle::is_process_alive(*p))
{
output.print_step(&format!(
"Daemon starting under supervision (PID {pid}) — not yet answering /health"
));
if output.format == OutputFormat::Human && !output.quiet {
eprintln!(" Check `openlatch status` shortly, or the newest ~/.openlatch/logs/daemon.log.<date>.");
}
(cfg.port, pid)
} else {
tracing::warn!(
"supervision reported active but no daemon came up; starting one directly"
);
match lifecycle::spawn_daemon_background(cfg.port, &new_token) {
Ok(pid) => {
let _ = wait_for_health(cfg.port, 5);
output.print_step(&format!("Daemon started on port {} (PID {pid})", cfg.port));
(cfg.port, pid)
}
Err(e) => {
output.print_error(&e);
return Err(e);
}
}
}
} else if args.foreground {
output.print_step(&format!(
"Starting daemon on port {} (foreground)",
cfg.port
));
#[cfg(feature = "boundary")]
let spawn_boundary = cfg.boundary.enabled;
#[cfg(not(feature = "boundary"))]
let spawn_boundary = false;
run_daemon_foreground(cfg.port, &new_token, spawn_boundary)?;
(cfg.port, std::process::id())
} else {
match lifecycle::spawn_daemon_background(cfg.port, &new_token) {
Ok(pid) => {
let _ = wait_for_health(cfg.port, 5);
output.print_step(&format!("Daemon started on port {} (PID {pid})", cfg.port));
(cfg.port, pid)
}
Err(e) => {
output.print_error(&e);
return Err(e);
}
}
};
#[cfg(feature = "boundary")]
verify_boundary_preflight(args, &cfg, start_plan, output)?;
if auth_success {
let cloud_msg = if org_name.is_empty() {
"Cloud sync: enabled".to_string()
} else {
format!("Cloud sync: connected (org: {org_name})")
};
output.print_step(&cloud_msg);
if output.format == OutputFormat::Human && !output.quiet {
eprintln!(" Events will be forwarded automatically");
}
}
let today = chrono::Local::now().format("%Y-%m-%d");
let log_path = config::openlatch_dir()
.join("logs")
.join(format!("events-{today}.jsonl"));
let fully_live = !args.no_start && auth_success;
if output.format == OutputFormat::Human && !output.quiet {
eprintln!();
if args.no_start {
eprintln!("Setup complete. Run `openlatch start` to launch the daemon.");
} else {
eprintln!("Ready. Events will appear in: {}", log_path.display());
}
if fully_live {
print_init_success_banner(output.color);
}
}
let agent_label_str = match &agent {
DetectedAgent::ClaudeCode { .. } => "claude-code",
};
telemetry::capture_global(Event::cli_initialized(
agent_label_str,
hook_result.entries.len(),
!token_existed,
));
telemetry::capture_global(Event::supervision_installed(
supervision_backend_label,
supervision_mode_label,
supervision_deferred_reason.as_deref(),
));
if output.format == OutputFormat::Json {
let hooks_list: Vec<&str> = hook_result
.entries
.iter()
.map(|e| e.event_type.as_str())
.collect();
let agent_str = match &agent {
DetectedAgent::ClaudeCode { .. } => "claude-code",
};
let cloud_status = if auth_success {
"connected"
} else {
"not_configured"
};
let json = serde_json::json!({
"status": "ok",
"agent": agent_str,
"port": port,
"pid": pid,
"hooks": hooks_list,
"log_path": log_path.to_string_lossy(),
"token_action": token_action,
"daemon_started": !args.no_start,
"cloud_status": cloud_status,
"org_name": org_name,
"supervision": {
"mode": supervision_mode_label,
"backend": supervision_backend_label,
"disabled_reason": supervision_deferred_reason,
},
});
output.print_json(&json);
}
Ok(())
}
fn run_auth_for_init(output: &OutputConfig) -> Result<(bool, String), OlError> {
let keyring = KeyringCredentialStore::new();
let cfg = config::Config::load(None, None, false).ok();
let agent_id = cfg
.as_ref()
.and_then(|c| c.agent_id.clone())
.unwrap_or_default();
let file_store =
FileCredentialStore::new(config::openlatch_dir().join("credentials.enc"), agent_id);
let api_url = cfg
.map(|c| c.cloud.api_url)
.unwrap_or_else(|| "https://app.openlatch.ai".to_string());
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Failed to create async runtime: {e}"),
)
})?;
if let Ok(val) = std::env::var("OPENLATCH_API_KEY") {
if !val.is_empty() {
let (online, org_name, _org_id) =
rt.block_on(crate::cli::commands::auth::validate_online(&val, &api_url));
if online {
let msg = if org_name.is_empty() {
"Authenticated via env var".to_string()
} else {
format!("Authenticated via env var (org: {org_name})")
};
output.print_step(&msg);
return Ok((true, org_name));
}
output.print_step("Authenticated via env var (cloud offline - validation skipped)");
return Ok((true, String::new()));
}
}
if let Ok(existing_key) = retrieve_credential(&keyring, &file_store) {
let key_str = existing_key.expose_secret().to_string();
let v = rt.block_on(crate::cli::commands::auth::validate_online_full(
&key_str, &api_url,
));
if v.online {
let msg = if v.org_name.is_empty() {
"Authenticated".to_string()
} else {
format!("Authenticated (org: {})", v.org_name)
};
output.print_step(&msg);
return Ok((true, v.org_name));
}
if !v.rejected {
output.print_step("Authenticated (cloud offline - using stored credentials)");
return Ok((true, String::new()));
}
output.print_substep("Existing credentials invalid, re-authenticating...");
}
let login_args = AuthLoginArgs { no_browser: false };
crate::cli::commands::auth::run_login(&login_args, output)?;
if let Ok(key) = retrieve_credential(&keyring, &file_store) {
let key_str = key.expose_secret().to_string();
let (_, org_name, _) = rt.block_on(crate::cli::commands::auth::validate_online(
&key_str, &api_url,
));
return Ok((true, org_name));
}
Ok((true, String::new()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DaemonStartPlan {
Skip,
Foreground,
SupervisorOwned,
SpawnBackground,
}
pub(crate) fn plan_daemon_start(
no_start: bool,
foreground: bool,
supervision_active: bool,
) -> DaemonStartPlan {
if no_start {
DaemonStartPlan::Skip
} else if foreground {
DaemonStartPlan::Foreground
} else if supervision_active {
DaemonStartPlan::SupervisorOwned
} else {
DaemonStartPlan::SpawnBackground
}
}
fn run_supervision_install_for_init(
args: &InitArgs,
config_path: &std::path::Path,
output: &OutputConfig,
) -> (&'static str, &'static str, Option<String>) {
use crate::supervision::{select_supervisor, SupervisionMode, SupervisorKind};
let skip_reason: Option<&'static str> = if args.foreground {
Some("foreground_session")
} else if args.no_start {
Some("no_start")
} else if args.no_persistence {
Some("user_opt_out")
} else {
None
};
if let Some(reason) = skip_reason {
let _ = config::persist_supervision_state(
config_path,
&SupervisionMode::Disabled,
&SupervisorKind::None,
Some(reason),
);
let msg = match reason {
"user_opt_out" => "Supervision: skipped (--no-persistence)",
"foreground_session" => "Supervision: skipped (foreground session)",
"no_start" => "Supervision: skipped (--no-start)",
_ => "Supervision: skipped",
};
output.print_step(msg);
return ("none", "disabled", Some(reason.to_string()));
}
let Some(supervisor) = select_supervisor() else {
let reason = "unsupported_os";
let _ = config::persist_supervision_state(
config_path,
&SupervisionMode::Deferred,
&SupervisorKind::None,
Some(reason),
);
output
.print_step("Supervision: deferred (no supported supervisor detected on this system)");
return ("none", "deferred", Some(reason.to_string()));
};
let exe_path =
std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("openlatch"));
let backend = supervisor.kind();
let backend_label: &'static str = match backend {
SupervisorKind::Launchd => "launchd",
SupervisorKind::Systemd => "systemd",
SupervisorKind::TaskScheduler => "task_scheduler",
SupervisorKind::None => "none",
};
match supervisor.install(&exe_path) {
Ok(()) => {
let _ = config::persist_supervision_state(
config_path,
&SupervisionMode::Active,
&backend,
None,
);
output.print_step(&format!(
"Supervision installed ({backend_label}) — daemon will auto-start on login"
));
if output.format == OutputFormat::Human && !output.quiet {
eprintln!(
" Disable with `openlatch supervision disable` or run `openlatch init --no-persistence`."
);
}
(backend_label, "active", None)
}
Err(e) => {
let reason_text = format!("{} ({})", e.message, e.code);
let _ = config::persist_supervision_state(
config_path,
&SupervisionMode::Deferred,
&backend,
Some(&reason_text),
);
output.print_step(&format!(
"Supervision: deferred — {backend_label} install failed ({})",
e.code
));
if output.format == OutputFormat::Human && !output.quiet {
eprintln!(" {}", e.message);
eprintln!(
" Init will continue; run `openlatch supervision install` to retry after the issue is resolved."
);
}
(backend_label, "deferred", Some(reason_text))
}
}
}
fn agent_label(agent: &DetectedAgent) -> String {
match agent {
DetectedAgent::ClaudeCode { claude_dir, .. } => {
format!("Claude Code ({})", claude_dir.display())
}
}
}
fn run_daemon_foreground(port: u16, token: &str, spawn_boundary: bool) -> Result<(), OlError> {
let mut cfg = config::Config::load(Some(port), None, true)?;
cfg.foreground = true;
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Failed to create async runtime: {e}"),
)
})?;
let token_owned = token.to_string();
let pid = std::process::id();
#[cfg(feature = "crash-report")]
crate::crash_report::enrich_daemon_scope(cfg.port, pid);
rt.block_on(async move {
use crate::envelope;
use crate::logging;
use crate::privacy;
let _guard = logging::daemon_log::init_daemon_logging(&cfg.log_dir, true);
if let Ok(deleted) = logging::cleanup_old_logs(&cfg.log_dir, cfg.retention_days) {
if deleted > 0 {
tracing::info!(deleted = deleted, "cleaned up old log files");
}
}
privacy::init_filter(&cfg.extra_patterns);
let pid_path = config::openlatch_dir().join("daemon.pid");
if let Err(e) = std::fs::write(&pid_path, pid.to_string()) {
tracing::warn!(error = %e, "failed to write PID file");
}
logging::daemon_log::log_startup(
env!("CARGO_PKG_VERSION"),
cfg.port,
pid,
envelope::os_string(),
envelope::arch_string(),
);
crate::cli::commands::lifecycle::log_observability_status_from_env();
let credential_store = crate::cli::commands::lifecycle::build_credential_store();
match crate::daemon::start_server(
cfg.clone(),
token_owned,
Some(credential_store),
spawn_boundary,
)
.await
{
Ok((uptime_secs, events)) => {
eprintln!(
"openlatch daemon stopped \u{2022} uptime {} \u{2022} {} events processed",
crate::daemon::format_uptime(uptime_secs),
events
);
}
Err(e) => {
tracing::error!(error = %e, "daemon exited with error");
eprintln!("Error: daemon exited unexpectedly: {e}");
}
}
let _ = std::fs::remove_file(&pid_path);
});
#[cfg(feature = "crash-report")]
crate::crash_report::flush(std::time::Duration::from_secs(2));
Ok(())
}
fn handle_telemetry_consent(
args: &InitArgs,
output: &OutputConfig,
ol_dir: &std::path::Path,
) -> Result<(), OlError> {
let consent_path = consent_file_path(ol_dir);
if args.no_telemetry {
telemetry_config::write_consent(&consent_path, false)?;
output.print_step("Telemetry: disabled (--no-telemetry)");
return Ok(());
}
if args.telemetry {
telemetry_config::write_consent(&consent_path, true)?;
output.print_step("Telemetry: enabled (--telemetry)");
return Ok(());
}
if consent_path.exists() {
return Ok(());
}
let stdin_is_tty = std::io::stdin().is_terminal();
let stdout_is_tty = std::io::stdout().is_terminal();
let interactive =
stdin_is_tty && stdout_is_tty && output.format == OutputFormat::Human && !output.quiet;
if !interactive {
telemetry_config::write_consent(&consent_path, false)?;
if output.format == OutputFormat::Human && !output.quiet {
eprintln!(
"ℹ Telemetry is off in non-interactive mode. Enable with `openlatch telemetry enable`."
);
}
return Ok(());
}
print_telemetry_notice();
let enabled = read_consent_answer();
telemetry_config::write_consent(&consent_path, enabled)?;
if enabled {
output.print_step("Telemetry: enabled — thanks for helping shape OpenLatch.");
} else {
output.print_step("Telemetry: disabled — enable later with `openlatch telemetry enable`.");
}
Ok(())
}
fn print_init_success_banner(color: bool) {
use crate::cli::color as c;
let check = c::checkmark(color);
let headline = c::bold(
"OpenLatch is now capturing activity from your AI agents",
color,
);
let url = c::bold("https://app.openlatch.ai", color);
let rule = c::dim(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
color,
);
let lines = [
String::new(),
rule.clone(),
format!(" {check} {headline}"),
String::new(),
" Nothing else to do — OpenLatch runs in the background,".to_string(),
" capturing what your agents do so you can see and".to_string(),
" control it from the console.".to_string(),
String::new(),
" Explore agent activity, inventory, and the audit trail:".to_string(),
format!(" {url}"),
rule,
];
for line in lines {
eprintln!("{line}");
}
let _ = std::io::stderr().flush();
}
fn print_telemetry_notice() {
let lines = [
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
" Help shape OpenLatch",
"",
" OpenLatch is early, and anonymous usage data is how we",
" learn which agents, detections, and workflows matter",
" most to the people we protect.",
"",
" What we collect:",
" ✓ Command names (e.g. `init`, `status`) and durations",
" ✓ Which AI agents you use",
" ✓ Error codes and daemon health signals",
" ✓ Aggregated hook volume (counts only, every 5 min)",
"",
" What we NEVER collect:",
" ✗ File contents, source code, or prompts",
" ✗ Environment variables, tokens, or secrets",
" ✗ Command arguments or flag values",
" ✗ IP addresses, hostnames, usernames",
"",
" Turn it off anytime: openlatch telemetry disable",
"",
" Share anonymous usage data to help improve OpenLatch? [Y/n]",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
];
for line in lines {
eprintln!("{line}");
}
let _ = std::io::stderr().flush();
}
fn read_consent_answer() -> bool {
let mut buf = String::new();
let stdin = std::io::stdin();
let _ = stdin.lock().read_line(&mut buf);
let answer = buf.trim().to_ascii_lowercase();
!matches!(answer.as_str(), "n" | "no")
}
#[cfg(feature = "boundary")]
const PREFLIGHT_VERDICT_WAIT: std::time::Duration = std::time::Duration::from_secs(15);
#[cfg(feature = "boundary")]
fn verify_boundary_preflight(
args: &InitArgs,
cfg: &config::Config,
start_plan: DaemonStartPlan,
output: &OutputConfig,
) -> Result<(), OlError> {
if start_plan == DaemonStartPlan::Skip
|| args.foreground
|| !cfg.boundary.enabled
|| args.no_boundary
|| !cfg.boundary.owns_agent_wiring()
{
return Ok(());
}
let port = cfg.boundary.port;
let deadline = std::time::Instant::now() + PREFLIGHT_VERDICT_WAIT;
let url = format!("http://127.0.0.1:{port}/admin/boundary/status");
loop {
let status = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()
.and_then(|c| c.get(&url).send().ok())
.and_then(|r| r.json::<serde_json::Value>().ok());
if let Some(body) = status {
match body.get("preflight").and_then(|v| v.as_str()) {
Some("ok") => {
output.print_step(&format!(
"Model boundary verified — agent routed via http://127.0.0.1:{port}"
));
return Ok(());
}
Some("failed") => {
let reason = body
.get("preflight_error")
.and_then(|v| v.as_str())
.unwrap_or("the boundary could not complete a request to the provider");
let err = OlError::new(
crate::error::ERR_BOUNDARY_PREFLIGHT_FAILED,
format!("Model boundary check failed: {reason}"),
)
.with_suggestion(
"Your agent settings were left untouched, so sessions connect straight to \
the provider and keep working — but nothing is captured. Check network \
reachability to https://api.anthropic.com (proxy, VPN, TLS interception), \
then run `openlatch restart`. Run `openlatch doctor` for the full picture."
.to_string(),
)
.with_docs("https://docs.openlatch.ai/errors/OL-BND-PREFLIGHT");
output.print_error(&err);
return Err(err);
}
_ => {}
}
}
if std::time::Instant::now() >= deadline {
output.print_substep(
"Model boundary: no verdict yet — the daemon is still checking it. \
Run `openlatch doctor` in a moment to confirm.",
);
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
fn wait_for_health(port: u16, timeout_secs: u64) -> bool {
let url = format!("http://127.0.0.1:{port}/health");
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(timeout_secs);
while start.elapsed() < timeout {
if let Ok(resp) = reqwest::blocking::get(&url) {
if resp.status().is_success() {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[cfg(test)]
mod start_plan_tests {
use super::*;
#[test]
fn active_supervision_means_init_does_not_spawn() {
assert_eq!(
plan_daemon_start(false, false, true),
DaemonStartPlan::SupervisorOwned
);
}
#[test]
fn without_supervision_init_still_spawns() {
assert_eq!(
plan_daemon_start(false, false, false),
DaemonStartPlan::SpawnBackground
);
}
#[test]
fn explicit_flags_win_over_supervision() {
assert_eq!(plan_daemon_start(true, false, true), DaemonStartPlan::Skip);
assert_eq!(
plan_daemon_start(false, true, true),
DaemonStartPlan::Foreground
);
assert_eq!(plan_daemon_start(true, true, true), DaemonStartPlan::Skip);
}
}