bctx 0.1.38

bctx CLI — intercept CLI commands and compress output for LLM coding agents
//! `bctx dashboard`: a local web view of ~/.bctx/executions.db, served to this machine only.

use anyhow::Result;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;

const PAGE: &str = include_str!("local_dashboard.html");

#[derive(Clone)]
struct Ctx {
    token: Arc<String>,
    port: u16,
    db_path: Arc<PathBuf>,
}

/// Only our own origin may talk to us (blocks DNS-rebinding pages in the user's browser).
fn allowed(ctx: &Ctx, token: &str, headers: &HeaderMap) -> Result<(), StatusCode> {
    let host = headers
        .get("host")
        .and_then(|h| h.to_str().ok())
        .unwrap_or("");
    if host != format!("127.0.0.1:{}", ctx.port) && host != format!("localhost:{}", ctx.port) {
        return Err(StatusCode::FORBIDDEN);
    }
    if token != ctx.token.as_str() {
        return Err(StatusCode::NOT_FOUND);
    }
    Ok(())
}

async fn page(State(ctx): State<Ctx>, Path(token): Path<String>, headers: HeaderMap) -> Response {
    match allowed(&ctx, &token, &headers) {
        Ok(()) => Html(PAGE).into_response(),
        Err(code) => code.into_response(),
    }
}

async fn summary(
    State(ctx): State<Ctx>,
    Path(token): Path<String>,
    headers: HeaderMap,
) -> Response {
    if let Err(code) = allowed(&ctx, &token, &headers) {
        return code.into_response();
    }
    match summary_json(&ctx.db_path) {
        Ok(v) => Json(v).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
    }
}

fn summary_json(db_path: &PathBuf) -> Result<Value> {
    let store = forge::tracker::store::ExecutionStore::open(db_path)?;
    let (before, after, count) = store.totals()?;
    let saved = before.saturating_sub(after);
    let pct = if before == 0 {
        0.0
    } else {
        saved as f64 * 100.0 / before as f64
    };
    let by_day: Vec<Value> = store
        .by_day(30)?
        .into_iter()
        .map(|(day, b, a)| json!({ "day": day, "tokens_before": b, "tokens_after": a }))
        .collect();
    let by_program: Vec<Value> = store
        .summary()?
        .into_iter()
        .map(|(p, b, a, avg)| json!({ "program": p, "tokens_before": b, "tokens_after": a, "avg_pct": avg }))
        .collect();
    let recent: Vec<Value> = store
        .recent(50)?
        .into_iter()
        .map(|r| {
            json!({
                "program": r.program, "args": r.args,
                "tokens_before": r.tokens_before, "tokens_after": r.tokens_after,
                "exit_code": r.exit_code, "duration_ms": r.duration_ms, "timestamp": r.timestamp,
            })
        })
        .collect();
    Ok(json!({
        "totals": { "commands": count, "tokens_before": before, "tokens_after": after, "saved": saved, "saved_pct": pct },
        "by_day": by_day,
        "by_program": by_program,
        "recent": recent,
    }))
}

pub fn serve(port: u16, open_browser: bool) -> Result<()> {
    // A server outlives whoever reads its output (an agent, `| head -1`): with the CLI's
    // default SIGPIPE, the first print after that reader goes away would kill it.
    // SAFETY: signal() with SIG_IGN only sets this process's disposition for SIGPIPE.
    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_IGN);
    }
    let db_path = PathBuf::from(crate::commands::home_dir())
        .join(".bctx")
        .join("executions.db");
    let token = format!(
        "{}{}",
        uuid::Uuid::new_v4().simple(),
        uuid::Uuid::new_v4().simple()
    );
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()?;
    rt.block_on(async move {
        let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
        let port = listener.local_addr()?.port();
        let ctx = Ctx {
            token: Arc::new(token.clone()),
            port,
            db_path: Arc::new(db_path),
        };
        let app = Router::new()
            .route("/:token/", get(page))
            .route("/:token/api/summary", get(summary))
            .with_state(ctx);
        let url = format!("http://127.0.0.1:{port}/{token}/");
        let mut out = std::io::stdout();
        let _ = writeln!(out, "bctx dashboard: {url}");
        let _ = writeln!(out, "Served to this machine only. Ctrl-C to stop.");
        let _ = out.flush();
        if open_browser {
            let _ = open::that(&url);
        }
        axum::serve(listener, app).await?;
        Ok::<(), anyhow::Error>(())
    })
}