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};
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.")
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&token_path, perms).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 probed = config::probe_free_port(config::PORT_RANGE_START, config::PORT_RANGE_END)?;
output.print_substep(&format!("Selected port {probed} (first available)"));
config::ensure_config(probed)?;
config::write_port_file(probed)?;
probed
} else {
config::Config::load(None, None, false)?.port
};
config::ensure_agent_id(&config_path)?;
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)?;
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 (port, pid) = if args.no_start {
output.print_step("Skipped daemon start (--no-start)");
(cfg.port, 0u32)
} else if args.foreground {
output.print_step(&format!(
"Starting daemon on port {} (foreground)",
cfg.port
));
run_daemon_foreground(cfg.port, &new_token)?;
(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);
}
}
};
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()))
}
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) -> 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)).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("Your AI agents are now protected by OpenLatch", 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(),
" and will notify you the moment a security incident is".to_string(),
" detected on one of your agents.".to_string(),
String::new(),
" Explore dashboards, policies, and the full 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")
}
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
}