bctx 0.1.40

bctx CLI — intercept CLI commands and compress output for LLM coding agents
//! `bctx hook …`: how AI agents hand a shell command to bctx before running it.

use crate::routing::{route, Route};
use serde_json::{json, Value};
use std::path::PathBuf;

/// One agent's tool-call hook contract, as recorded by the plan 10 probe in
/// `bins/bctx/tests/fixtures/agent_hooks/<name>/` (spec, "Probe results").
struct Contract {
    name: &'static str,
    /// The shell command in this hook input, if it is a call of the agent's shell tool.
    command: fn(&Value) -> Option<&str>,
    /// The response that makes the agent run `new` instead, every other input field kept.
    rewritten: fn(&Value, &str) -> Option<Value>,
}

// Codex has no contract: it checks its rules and dangerous-command heuristics against the
// rewritten command (plan 10 final review), so it gets a hook only after a live check.
const CONTRACTS: &[Contract] = &[Contract {
    name: "cursor",
    command: cursor_command,
    rewritten: cursor_rewritten,
}];

/// Cursor names its shell tool differently across versions and surfaces.
const CURSOR_SHELL_TOOLS: &[&str] = &["Shell", "shell", "run_terminal_cmd", "terminal"];

fn tool_name(input: &Value) -> Option<&str> {
    input.get("tool_name")?.as_str()
}

fn tool_input_command(input: &Value) -> Option<&str> {
    input.get("tool_input")?.get("command")?.as_str()
}

/// `tool_input` with only its `command` replaced.
fn with_command(input: &Value, new: &str) -> Option<Value> {
    let mut tool_input = input.get("tool_input")?.clone();
    *tool_input.get_mut("command")? = Value::String(new.to_string());
    Some(tool_input)
}

fn cursor_command(input: &Value) -> Option<&str> {
    CURSOR_SHELL_TOOLS
        .contains(&tool_name(input)?)
        .then_some(())?;
    tool_input_command(input)
}

fn cursor_rewritten(input: &Value, new: &str) -> Option<Value> {
    Some(json!({ "updated_input": with_command(input, new)? }))
}

/// Single-quoted for any POSIX shell.
pub(crate) fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}

/// The running bctx; agents' shells may not have it on PATH.
pub(crate) fn bctx_path() -> String {
    std::env::current_exe()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| "bctx".into())
}

/// The hook's answer for `input`, or `None` for "no change" (print nothing).
pub(crate) fn respond(agent: &str, input: &str) -> Option<String> {
    let contract = CONTRACTS.iter().find(|c| c.name == agent)?;
    let input: Value = serde_json::from_str(input).ok()?;
    let command = (contract.command)(&input)?;
    let cwd = input
        .get("cwd")
        .and_then(Value::as_str)
        .map(PathBuf::from)
        .or_else(|| std::env::current_dir().ok())?;
    match route(command, &cwd) {
        Route::Compress(_) => {
            let new = format!("{} {command}", shell_quote(&bctx_path()));
            (contract.rewritten)(&input, &new).map(|v| v.to_string())
        }
        Route::Unchanged => None,
    }
}

/// `bctx hook rewrite --agent <name>`: never fails, never blocks the agent's command.
pub fn rewrite(agent: &str) -> anyhow::Result<()> {
    let mut input = String::new();
    let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut input);
    if let Some(out) = respond(agent, &input) {
        println!("{out}");
    }
    Ok(())
}

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

    fn permission_keys(v: &Value, out: &mut Vec<String>) {
        match v {
            Value::Object(map) => {
                for (k, x) in map {
                    let k = k.to_lowercase();
                    if k.contains("permission") || k.contains("decision") {
                        out.push(k);
                    }
                    permission_keys(x, out);
                }
            }
            Value::Array(items) => items.iter().for_each(|x| permission_keys(x, out)),
            _ => {}
        }
    }

    fn fixture(agent: &str, name: &str, cwd: &str) -> Value {
        let path = format!(
            "{}/tests/fixtures/agent_hooks/{agent}/{name}",
            env!("CARGO_MANIFEST_DIR")
        );
        let raw = std::fs::read_to_string(path).unwrap();
        let raw = raw
            .replace("__CWD__", cwd)
            .replace("__BCTX__", &shell_quote(&bctx_path()));
        serde_json::from_str(&raw).unwrap()
    }

    #[test]
    fn every_supported_agent_gets_its_recorded_rewrite_and_no_permission() {
        let cwd = std::env::temp_dir().to_string_lossy().replace('\\', "/");
        for contract in CONTRACTS {
            let input = fixture(contract.name, "input.json", &cwd);
            let out = respond(contract.name, &input.to_string()).expect("a rewrite");
            let out: Value = serde_json::from_str(&out).unwrap();
            assert_eq!(
                out,
                fixture(contract.name, "expected.json", &cwd),
                "{}",
                contract.name
            );
            let mut keys = Vec::new();
            permission_keys(&out, &mut keys);
            assert!(keys.is_empty(), "{}: {keys:?}", contract.name);
        }
    }

    #[test]
    fn anything_unexpected_means_no_change() {
        for contract in CONTRACTS {
            for input in [
                "not json",
                "{}",
                r#"{"tool_input":{}}"#,
                r#"{"tool_name":"Bash","tool_input":{"command":42}}"#,
                r#"{"tool_name":"Shell","tool_input":{"command":["bash","git log"]}}"#,
                r#"{"tool_name":"Bash","tool_input":{"command":"git log | head"}}"#,
                r#"{"tool_name":"Read","tool_input":{"command":"git log"}}"#,
                r#"{"tool_input":{"command":"git log"}}"#,
            ] {
                assert_eq!(
                    respond(contract.name, input),
                    None,
                    "{}: {input}",
                    contract.name
                );
            }
        }
        assert_eq!(
            respond(
                "no-such-agent",
                r#"{"tool_name":"Bash","tool_input":{"command":"git log"}}"#
            ),
            None
        );
    }
}