bctx 0.1.40

bctx CLI — intercept CLI commands and compress output for LLM coding agents
//! Whether a command an agent is about to run should go through bctx.

use std::path::Path;

#[derive(Debug, PartialEq, Eq)]
pub enum Route {
    /// Run through the lens pipeline as `bctx <argv…>`.
    Compress(Vec<String>),
    /// Leave the command exactly as the agent wrote it.
    Unchanged,
}

pub fn route(command: &str, cwd: &Path) -> Route {
    if bypass_active(cwd) || has_shell_syntax(command) {
        return Route::Unchanged;
    }
    let Some(words) = shlex::split(command) else {
        return Route::Unchanged;
    };
    let Some((program, args)) = words.split_first() else {
        return Route::Unchanged;
    };
    if program.contains('/')
        || program.contains('=')
        || program == "bctx"
        || !reads_builds_or_tests(program, args)
        || crate::commands::run::is_streaming(program, args)
        || !crate::commands::run::mesh().compresses(program, args)
    {
        return Route::Unchanged;
    }
    Route::Compress(words)
}

/// Commands whose job is to read, build or test: never to push, delete, publish, install,
/// run a program or change remote state. An agent checks its own rules ("ask before
/// git push") against the command bctx hands it, so only these may be rewritten (founder
/// decision, 2026-10-20).
fn reads_builds_or_tests(program: &str, args: &[String]) -> bool {
    let words: Vec<&str> = args.iter().map(String::as_str).collect();
    let first = words.first().copied();
    let second = words.get(1).copied();
    let has = |flag: &str| words.contains(&flag);
    match program {
        "git" => matches!(
            git_subcommand(&words),
            Some(
                "log"
                    | "status"
                    | "diff"
                    | "show"
                    | "blame"
                    | "shortlog"
                    | "ls-files"
                    | "grep"
                    | "describe"
                    | "rev-parse"
            )
        ),
        "cargo" => matches!(first, Some("build" | "check" | "test" | "clippy" | "tree")),
        "npm" | "pnpm" | "yarn" | "bun" => match first {
            Some("test") => true,
            Some("run") => matches!(
                second,
                Some("test" | "lint" | "build" | "typecheck" | "check")
            ),
            _ => false,
        },
        "go" => matches!(first, Some("test" | "vet" | "build" | "list")),
        "pytest" | "jest" | "vitest" | "mocha" | "tsc" | "mypy" | "flake8" | "pylint" => true,
        "eslint" => !has("--fix"),
        "ruff" => first == Some("check") && !has("--fix"),
        "python" | "python3" => {
            first == Some("-m") && matches!(second, Some("pytest" | "unittest" | "mypy"))
        }
        "kubectl" => match first {
            Some("get") => !has("-w") && !has("--watch"),
            Some("logs") => !has("-f") && !has("--follow"),
            Some("describe" | "top" | "explain" | "version" | "api-resources") => true,
            _ => false,
        },
        "docker" => match first {
            Some("logs") => !has("-f") && !has("--follow"),
            Some("ps" | "images" | "inspect" | "version" | "info") => true,
            _ => false,
        },
        "terraform" => matches!(first, Some("plan" | "show" | "validate" | "output")),
        "gh" => matches!(second, Some("list" | "view" | "checks" | "diff" | "status")),
        "aws" => second.is_some_and(|op| {
            op.starts_with("describe-") || op.starts_with("list-") || op.starts_with("get-")
        }),
        "ls" | "tree" | "grep" | "rg" | "wc" | "du" | "df" => true,
        "find" => !words.iter().any(|w| {
            matches!(
                *w,
                "-delete"
                    | "-exec"
                    | "-execdir"
                    | "-ok"
                    | "-okdir"
                    | "-fprint"
                    | "-fprintf"
                    | "-fls"
            )
        }),
        _ => false,
    }
}

/// The subcommand of a `git` invocation, past global options such as `-C <dir>`.
fn git_subcommand<'a>(words: &[&'a str]) -> Option<&'a str> {
    let mut rest = words.iter();
    while let Some(word) = rest.next() {
        match *word {
            "-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace" => {
                rest.next();
            }
            w if w.starts_with('-') => {}
            w => return Some(w),
        }
    }
    None
}

/// True when `BCTX_BYPASS=1` is set or a `.bctx-bypass` file exists in `cwd` or any parent.
pub fn bypass_active(cwd: &Path) -> bool {
    std::env::var("BCTX_BYPASS").as_deref() == Ok("1")
        || cwd.ancestors().any(|dir| dir.join(".bctx-bypass").exists())
}

/// Anything that makes this more than one simple command, or that runs other commands:
/// pipes, lists, background jobs, redirects, subshells and command substitution.
fn has_shell_syntax(command: &str) -> bool {
    if command.contains('`') || command.contains("$(") {
        return true;
    }
    let (mut single, mut double, mut escaped) = (false, false, false);
    for c in command.chars() {
        if escaped {
            escaped = false;
            continue;
        }
        match c {
            '\\' if !single => escaped = true,
            '\'' if !double => single = !single,
            '"' if !single => double = !double,
            '|' | '&' | ';' | '<' | '>' | '(' | ')' | '\n' | '\r' if !single && !double => {
                return true
            }
            _ => {}
        }
    }
    single || double
}

#[cfg(test)]
mod tests {
    use super::*;

    // These tests assume BCTX_BYPASS is not set in the environment running them.
    fn compresses(command: &str) -> bool {
        matches!(route(command, &std::env::temp_dir()), Route::Compress(_))
    }

    #[test]
    fn a_simple_command_bctx_compresses_goes_through_bctx() {
        assert!(compresses("git log --oneline"));
        assert!(compresses("git log --grep 'a | b; c > d'"));
        assert!(compresses(r#"git log --grep "x;y""#));
    }

    #[test]
    fn only_commands_that_read_build_or_test_are_rewritten() {
        // An agent's own rules ("ask before git push") see the rewritten command, so a
        // command that changes anything must reach them exactly as written.
        for c in [
            "git push --force",
            "git reset --hard HEAD~1",
            "git commit -m x",
            "git checkout main",
            "git clean -fd",
            "git branch -D old",
            "npm install left-pad",
            "npm publish",
            "cargo run",
            "cargo install ripgrep",
            "cargo publish",
            "kubectl delete pod web",
            "kubectl apply -f deploy.yaml",
            "kubectl get pods -w",
            "terraform apply",
            "terraform destroy",
            "docker run alpine",
            "docker rm web",
            "rm -rf build",
            "env TARGET=build rm -rf build",
            "export FOO=bar",
            "find . -name x -delete",
            "eslint --fix .",
        ] {
            assert!(!compresses(c), "{c:?}");
        }
        for c in [
            "git status",
            "git diff",
            "git show HEAD",
            "git -C repo log --oneline",
            "cargo test",
            "cargo build",
            "npm test",
            "kubectl get pods",
            "docker ps",
        ] {
            assert!(compresses(c), "{c:?}");
        }
    }

    #[test]
    fn pipes_lists_redirects_and_substitutions_stay_as_written() {
        for c in [
            "git log | head",
            "git diff | patch -p1",
            "git add . && git commit -m x",
            "git status; ls",
            "git log > out.txt",
            "git apply < fix.patch",
            "git log &",
            "(git log)",
            "git log $(git rev-parse HEAD)",
            "git log `git rev-parse HEAD`",
            "git log \"$(date)\"",
            "git log\ngit status",
        ] {
            assert!(!compresses(c), "{c:?}");
        }
    }

    #[test]
    fn env_prefixes_paths_and_bctx_itself_stay_as_written() {
        for c in [
            "GIT_PAGER=cat git log",
            "/usr/bin/git log",
            "./git log",
            "bctx git log",
        ] {
            assert!(!compresses(c), "{c:?}");
        }
    }

    #[test]
    fn programs_without_a_compressor_and_streaming_commands_stay_as_written() {
        for c in [
            "vim notes.md",
            "npm run dev",
            "cargo watch -x test",
            "kubectl logs -f web",
        ] {
            assert!(!compresses(c), "{c:?}");
        }
    }

    #[test]
    fn empty_and_unbalanced_commands_stay_as_written() {
        for c in ["", "   ", "git log 'oops", "git log \"oops"] {
            assert!(!compresses(c), "{c:?}");
        }
    }

    #[test]
    fn a_bypass_file_in_any_parent_keeps_commands_as_written() {
        let root = tempfile::tempdir().unwrap();
        let nested = root.path().join("a/b");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(root.path().join(".bctx-bypass"), "").unwrap();
        assert!(bypass_active(&nested));
        assert_eq!(route("git log", &nested), Route::Unchanged);
    }
}