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(())
}
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(),
}
}