lobe-cli 0.1.4

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_burst_summary, print_comparison, print_comparison_unavailable, print_history,
    print_probe_result, print_sweep_table, print_watch_result, print_zero_baseline_notice,
};
use lobe_core::engine::burst::run_burst;
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,
            concurrency,
            requests,
            method,
            allow_unsafe,
        } => {
            let probe = SimpleProbe::new();

            if concurrency.is_empty() {
                if !method.eq_ignore_ascii_case("GET") {
                    eprintln!(
                        "--method {method} requires --concurrency; the single-shot probe always sends GET"
                    );
                    std::process::exit(1);
                }
                let result = probe.probe_and_store(&store, &url).await?;
                print_probe_result(&result);
                return Ok(());
            }

            let method = method.to_ascii_uppercase();
            if !matches!(method.as_str(), "GET" | "HEAD") && !allow_unsafe {
                eprintln!(
                    "refusing to flood a mutating endpoint: a burst of concurrent {method} \
                     requests executes real mutations. Pass --allow-unsafe if you mean it."
                );
                std::process::exit(1);
            }

            let mut summaries = Vec::with_capacity(concurrency.len());
            for level in concurrency {
                let total = requests.max(level);
                println!(
                    "probing {url} — concurrency {level}, {total} requests ({method})..."
                );
                let summary = run_burst(&probe, &url, &method, level, total).await?;
                print_burst_summary(&summary);
                summaries.push(summary);
            }

            if summaries.len() > 1 {
                print_sweep_table(&summaries);
            }
        }
        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,
            pr,
            repo,
            branch,
            commit,
            ci_run_id,
        } => {
            use lobe_core::engine::capture::GitContext;
            let git_context = if pr.is_some()
                || repo.is_some()
                || branch.is_some()
                || commit.is_some()
                || ci_run_id.is_some()
            {
                Some(GitContext {
                    repo,
                    pr_number: pr,
                    branch,
                    commit,
                    ci_run_id,
                })
            } else {
                None
            };
            run_capture_tui(listen, upstream, upload, git_context).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(),
    }
}