lobe-cli 0.1.1

Lobe — local-first HTTP performance profiling CLI for developers. Spins up a capture proxy, records DNS/TCP/TLS/TTFB/download phases per request, and flags anomalies against grounded network baselines.
mod capture_tui;
mod cli;
mod config;
mod db;
mod explain_cmd;
mod install_skill;
mod login_cmd;
mod output;
mod tui;
mod upload;

use clap::Parser;

use cli::{Cli, Command};
use capture_tui::run_capture_tui;
use db::open_store;
use explain_cmd::run_explain;
use install_skill::run_install_skill;
use login_cmd::run_login;
use output::{
    print_comparison, print_comparison_unavailable, print_history, print_probe_result,
    print_watch_result, print_zero_baseline_notice,
};
use lobe_core::engine::history::compare_ttfb;
use lobe_core::engine::proxy::SimpleProbe;
use lobe_core::error::{Result, TloxError};
use tokio::time::{sleep, Duration};
use tui::run_watch_tui;

#[tokio::main]
async fn main() {
    if let Err(error) = run().await {
        eprintln!("{}", friendly_error(&error));
        std::process::exit(1);
    }
}

async fn run() -> Result<()> {
    let cli = Cli::parse();
    let store = open_store(cli.db.as_deref())?;

    match cli.command {
        Command::Probe { url } => {
            let probe = SimpleProbe::new();
            let result = probe.probe_and_store(&store, &url).await?;
            print_probe_result(&result);
        }
        Command::History { target, limit } => {
            let results = match target.as_deref() {
                Some(target) => store.recent_results_for_target(target, limit)?,
                None => store.recent_results(limit)?,
            };

            print_history(&results);
        }
        Command::Compare { url, threshold } => {
            let results = store.recent_results_for_target(&url, 2)?;

            if results.len() < 2 {
                print_comparison_unavailable(&url);
            } else {
                let current = &results[0];
                let previous = &results[1];

                match compare_ttfb(previous, current, threshold) {
                    Some(summary) => print_comparison(current, previous, &summary),
                    None => print_zero_baseline_notice(&url),
                }
            }
        }
        Command::Watch {
            url,
            interval,
            threshold,
            count,
            tui,
        } => {
            if tui {
                run_watch_tui(store, url, interval, threshold, count).await?;
                return Ok(());
            }

            let probe = SimpleProbe::new();
            let mut remaining = count;

            loop {
                let result = probe.probe_and_store(&store, &url).await?;
                let results = store.recent_results_for_target(&url, 2)?;

                let (comparison, comparison_note) = if results.len() < 2 {
                    (None, Some("first-run"))
                } else {
                    match compare_ttfb(&results[1], &results[0], threshold) {
                        Some(summary) => (Some(summary), None),
                        None => (None, Some("previous-ttfb-zero")),
                    }
                };

                print_watch_result(&result, comparison.as_ref(), comparison_note);

                if let Some(remaining_count) = remaining.as_mut() {
                    *remaining_count -= 1;
                    if *remaining_count == 0 {
                        break;
                    }
                }

                sleep(Duration::from_secs(interval)).await;
            }
        }
        Command::Capture {
            upstream,
            listen,
            upload,
        } => {
            run_capture_tui(listen, upstream, upload).await?;
        }
        Command::Login {
            token,
            web_url,
            api_url,
        } => {
            run_login(token, web_url, api_url).await?;
        }
        Command::Explain {
            session,
            model,
            output,
        } => {
            run_explain(session, model, output).await?;
        }
        Command::InstallSkill {
            target,
            global,
            force,
        } => {
            run_install_skill(target, global, force)?;
        }
    }

    Ok(())
}

/// Convert a MissingApiKey error into a friendly onboarding message. Every other
/// error falls through to the default `Display` implementation.
pub fn friendly_error(error: &TloxError) -> String {
    match error {
        TloxError::MissingApiKey => concat!(
            "ANTHROPIC_API_KEY is not set.\n\n",
            "`lobe explain` calls Anthropic's Claude API with your key.\n",
            "1. Get a key: https://console.anthropic.com/settings/keys\n",
            "2. Export it in your shell:\n\n",
            "     export ANTHROPIC_API_KEY=sk-ant-...\n\n",
            "   Or add that line to ~/.zshrc or ~/.bashrc to persist it.\n",
        )
        .to_string(),
        other => other.to_string(),
    }
}