bctx 0.1.28

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use anyhow::Result;
use forge::budget::estimator::TokenEstimator;
use forge::substrate::local::LocalSubstrate;
use forge::substrate::{Substrate, SubstrateCommand};
use std::sync::OnceLock;
use weave::lenses::LensContext;
use weave::mesh::registry::FilterMesh;
use weave::output::savings::SavesReport;
use weave::ReadMode;

static MESH: OnceLock<FilterMesh> = OnceLock::new();
fn mesh() -> &'static FilterMesh {
    MESH.get_or_init(|| {
        let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        FilterMesh::default_mesh().with_project_recipes(&root)
    })
}

/// Persist a completed execution to the local store (`~/.bctx/executions.db`) so that
/// `bctx gain` and the dashboard reflect real all-time savings. Best-effort: any error
/// (no HOME, unwritable disk) is swallowed so it never breaks the wrapped command.
fn record_execution(
    program: &str,
    args: &[String],
    tokens_before: usize,
    tokens_after: usize,
    exit_code: i32,
    duration_ms: u64,
) {
    use forge::tracker::store::ExecutionStore;
    // home_dir() resolves HOME (Unix) or USERPROFILE (Windows) — raw HOME is unset
    // on Windows, which previously made recording silently no-op there.
    let db_path = std::path::PathBuf::from(crate::commands::home_dir())
        .join(".bctx")
        .join("executions.db");
    if let Ok(store) = ExecutionStore::open(&db_path) {
        let rec = ExecutionStore::new_record(
            program,
            args,
            tokens_before,
            tokens_after,
            exit_code,
            duration_ms,
        );
        let _ = store.record(&rec);
    }
}

/// Returns true if BCTX_BYPASS=1 is set or a .bctx-bypass file exists in CWD or any parent.
fn bypass_active() -> bool {
    if std::env::var("BCTX_BYPASS").as_deref() == Ok("1") {
        return true;
    }
    let mut dir = std::env::current_dir().ok();
    while let Some(d) = dir {
        if d.join(".bctx-bypass").exists() {
            return true;
        }
        dir = d.parent().map(|p| p.to_path_buf());
    }
    false
}

/// Run with an explicit read mode override (used by `bctx read --mode`).
/// If the sole argument is an existing file path, reads and compresses the file directly
/// instead of executing it as a command.
pub fn handle_with_mode(args: Vec<String>, mode: ReadMode) -> Result<()> {
    // trailing_var_arg may capture `--mode <val>` into args when it appears after the
    // file path (e.g. `bctx read src/main.rs --mode clarity`). Strip those out and
    // use the extracted mode if the caller didn't already set one explicitly.
    let (effective_mode, clean_args) = extract_trailing_mode(args, mode);

    if clean_args.len() == 1 && std::path::Path::new(&clean_args[0]).is_file() {
        let file_mode = match effective_mode {
            ReadMode::Auto => ReadMode::Signatures,
            other => other,
        };
        return handle_file(&clean_args[0], file_mode);
    }
    handle_inner(clean_args, Some(effective_mode))
}

/// Strip `--mode <value>` from trailing args if present, returning the resolved mode.
fn extract_trailing_mode(mut args: Vec<String>, existing: ReadMode) -> (ReadMode, Vec<String>) {
    let mut mode = existing;
    let _ = &mode; // suppress unused warning before first use
    let mut i = 0;
    while i < args.len() {
        if args[i] == "--mode" && i + 1 < args.len() {
            let val = args.remove(i + 1);
            args.remove(i);
            if matches!(mode, ReadMode::Auto) {
                mode = ReadMode::parse(&val).unwrap_or(ReadMode::Auto);
            }
        } else if let Some(val) = args[i].strip_prefix("--mode=") {
            let val = val.to_string();
            args.remove(i);
            if matches!(mode, ReadMode::Auto) {
                mode = ReadMode::parse(&val).unwrap_or(ReadMode::Auto);
            }
        } else {
            i += 1;
        }
    }
    (mode, args)
}

/// Read and compress a file, printing the compressed content to stdout.
fn handle_file(path: &str, mode: ReadMode) -> Result<()> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| anyhow::anyhow!("bctx read: cannot read '{path}': {e}"))?;
    let tokens_before = forge::budget::estimator::TokenEstimator::count_nonblocking(&content);
    let ctx = LensContext::new(4000);
    let output = mode.apply(&content, &ctx);
    // Never emit output larger than the source file (negative savings on tiny/dense files).
    let (out_content, tokens_after) = if output.tokens_after >= tokens_before {
        (content.as_str(), tokens_before)
    } else {
        (output.content.as_str(), output.tokens_after)
    };
    print!("{out_content}");
    if tokens_before > 0 {
        let savings_pct = 100.0 * (1.0 - tokens_after as f64 / tokens_before as f64);
        eprintln!("[bctx: {tokens_before}{tokens_after} tokens, {savings_pct:.0}% saved]");
    }
    Ok(())
}

pub fn handle(args: Vec<String>) -> Result<()> {
    // Honour BCTX_MODE env var if set.
    let mode = std::env::var("BCTX_MODE")
        .ok()
        .and_then(|v| ReadMode::parse(&v));
    // `bctx run <cmd>` is a documented alias for `bctx <cmd>` — strip the leading "run".
    let args = match args.first().map(String::as_str) {
        Some("run") => args[1..].to_vec(),
        _ => args,
    };
    handle_inner(args, mode)
}

/// Returns true for commands that stream continuously and must not be buffered.
/// These bypass LocalSubstrate entirely and inherit the terminal's stdin/stdout/stderr.
fn is_streaming(program: &str, args: &[String]) -> bool {
    let sub = args.first().map(String::as_str).unwrap_or("");
    let sub2 = args.get(1).map(String::as_str).unwrap_or("");
    match program {
        "npm" | "pnpm" | "bun" => match sub {
            "start" => true,
            "run" => {
                matches!(sub2, "dev" | "serve" | "start" | "preview" | "storybook")
                    || sub2.starts_with("dev:")
                    || sub2.ends_with(":dev")
                    || sub2.starts_with("watch")
                    || sub2.ends_with(":watch")
                    || sub2.ends_with("-watch")
            }
            _ => false,
        },
        "cargo" => sub == "watch",
        "docker" => {
            matches!(sub, "attach" | "exec") || (sub == "logs" && args.contains(&"-f".to_string()))
        }
        "kubectl" => {
            matches!(sub, "exec" | "attach" | "port-forward" | "proxy")
                || (sub == "logs" && args.contains(&"-f".to_string()))
        }
        _ => false,
    }
}

fn handle_inner(args: Vec<String>, mode_override: Option<ReadMode>) -> Result<()> {
    if args.is_empty() {
        anyhow::bail!("usage: bctx <command> [args...]");
    }

    let program = args[0].clone();
    let cmd_args: Vec<String> = args[1..].to_vec();

    // Streaming commands inherit the terminal directly — buffering would hide all output.
    if is_streaming(&program, &cmd_args) {
        let status = std::process::Command::new(&program)
            .args(&cmd_args)
            .status()
            .map_err(|e| anyhow::anyhow!("{program}: {e}"))?;
        std::process::exit(status.code().unwrap_or(-1));
    }

    let substrate = LocalSubstrate;
    let cmd = SubstrateCommand {
        program: program.clone(),
        args: cmd_args.clone(),
        working_dir: None,
        timeout_secs: 60,
        capture_stderr: true,
        env: Vec::new(),
    };

    let start = std::time::Instant::now();
    let result = substrate.execute(cmd)?;
    let duration_ms = start.elapsed().as_millis() as u64;
    let raw_stdout = String::from_utf8_lossy(&result.stdout).to_string();
    let raw_stderr = String::from_utf8_lossy(&result.stderr).to_string();

    // Bypass: passthrough raw output, no compression, no savings report, no cloud sync.
    if bypass_active() {
        print!("{raw_stdout}");
        if !raw_stderr.is_empty() {
            eprint!("{raw_stderr}");
        }
        std::process::exit(result.exit_code);
    }

    let exit_code = result.exit_code;
    let tokens_before = TokenEstimator::count_nonblocking(&raw_stdout);

    let ctx = LensContext::new(2000).with_exit_code(exit_code);

    let compressed_content = match &mode_override {
        // Full mode: bypass all lens processing.
        Some(ReadMode::Full) => raw_stdout.clone(),
        // Explicit mode override: apply its lens stack directly.
        Some(mode) => mode.apply(&raw_stdout, &ctx).content,
        // Auto (default): route through FilterMesh domain nodes.
        None => mesh().apply(&program, &cmd_args, &raw_stdout, &ctx).content,
    };

    // Never print "compressed" output larger than the raw command output — on tiny or
    // incompressible output the lens overhead can exceed the payload (negative savings).
    let (compressed_content, tokens_after) = {
        let after = TokenEstimator::count_nonblocking(&compressed_content);
        if after >= tokens_before {
            (raw_stdout.clone(), tokens_before)
        } else {
            (compressed_content, after)
        }
    };
    print!("{compressed_content}");
    // Flush stdout so buffered output appears before the stderr annotation.
    let _ = std::io::Write::flush(&mut std::io::stdout());
    if !raw_stderr.is_empty() {
        eprint!("{raw_stderr}");
    }

    let mode_label = mode_override
        .as_ref()
        .map(|m| format!(" [{}]", m.name()))
        .unwrap_or_default();

    if tokens_before > 0 {
        let label = format!("{program}{mode_label}");
        let report = SavesReport::new(label.as_str(), tokens_before, tokens_after);
        eprintln!("{}", report.render_inline());

        // Persist locally so `bctx gain` / dashboard reflect real all-time savings.
        // Best-effort: never fail the command if the store can't be written.
        record_execution(
            &program,
            &cmd_args,
            tokens_before,
            tokens_after,
            exit_code,
            duration_ms,
        );

        // Send savings to cloud if logged in — join before exit so the thread completes
        if let Some(token) = cloud::client::auth::load_token() {
            let handle = cloud::client::sync::push_signals_bg(
                token.endpoint,
                token.access_token,
                tokens_after as i64,
                (tokens_before as i64 - tokens_after as i64).max(0),
                program.clone(),
            );
            let _ = handle.join();
        }
    }

    std::process::exit(result.exit_code);
}