bctx 0.1.5

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use anyhow::{Context, Result};
use std::path::PathBuf;

pub fn handle(agent: String) -> Result<()> {
    match agent.as_str() {
        "claude" => install_claude()?,
        "cursor" => install_cursor()?,
        "zsh" => install_shell("zsh")?,
        "bash" => install_shell("bash")?,
        other => {
            eprintln!("bctx init: unknown agent '{other}'");
            eprintln!("Supported: claude, cursor, zsh, bash");
            std::process::exit(1);
        }
    }
    Ok(())
}

// ── Claude Code ──────────────────────────────────────────────────────────────

fn install_claude() -> Result<()> {
    let home = std::env::var("HOME").context("HOME not set")?;
    let claude_dir = PathBuf::from(&home).join(".claude");
    std::fs::create_dir_all(&claude_dir)?;

    // 1. MCP server registration (mcp.json)
    let mcp_path = claude_dir.join("mcp.json");
    let existing = if mcp_path.exists() {
        std::fs::read_to_string(&mcp_path)?
    } else {
        "{}".to_string()
    };
    let mut mcp_config: serde_json::Value =
        serde_json::from_str(&existing).unwrap_or(serde_json::json!({}));
    let bctx_bin = which_bctx();
    mcp_config["mcpServers"]["bctx"] = serde_json::json!({
        "command": bctx_bin,
        "args": ["mcp"]
    });
    std::fs::write(&mcp_path, serde_json::to_string_pretty(&mcp_config)?)?;
    println!("✓ bctx MCP server registered at {}", mcp_path.display());

    // 2. Pre-tool-use hook script
    let hook_path = claude_dir.join("bctx-hook.sh");
    std::fs::write(&hook_path, HOOK_SCRIPT)?;
    // Make executable
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
    }
    println!("✓ bctx hook script written to {}", hook_path.display());

    // 3. Register hook in settings.json (preserve existing hooks)
    let settings_path = claude_dir.join("settings.json");
    let existing_settings = if settings_path.exists() {
        std::fs::read_to_string(&settings_path)?
    } else {
        "{}".to_string()
    };
    let mut settings: serde_json::Value =
        serde_json::from_str(&existing_settings).unwrap_or(serde_json::json!({}));

    let hook_entry = serde_json::json!({
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": hook_path.to_string_lossy() }]
    });

    // Append only if not already present
    let already_present = settings["hooks"]["PreToolUse"]
        .as_array()
        .is_some_and(|arr| {
            arr.iter().any(|h| {
                h["hooks"]
                    .as_array()
                    .and_then(|hs| hs.first())
                    .and_then(|h| h["command"].as_str())
                    .is_some_and(|c| c.contains("bctx-hook"))
            })
        });

    if already_present {
        println!("  (bctx hook already present in settings.json — skipped)");
    } else if let Some(arr) = settings["hooks"]["PreToolUse"].as_array_mut() {
        arr.push(hook_entry);
    } else {
        settings["hooks"]["PreToolUse"] = serde_json::json!([hook_entry]);
    }

    std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
    println!(
        "✓ bctx pre-tool-use hook registered in {}",
        settings_path.display()
    );
    println!();
    println!("  Restart Claude Code to activate.");
    println!("  To bypass bctx in a project: touch .bctx-bypass");
    println!("  To bypass globally:          BCTX_BYPASS=1 <command>");
    Ok(())
}

/// Shell script installed at ~/.claude/bctx-hook.sh.
/// Rewrites Bash tool calls: git/cargo/npm/etc. → bctx git/cargo/npm/etc.
/// Skips rewrite when: BCTX_BYPASS=1, .bctx-bypass file found,
/// or command already starts with bctx.
const HOOK_SCRIPT: &str = r#"#!/usr/bin/env bash
# bctx Claude Code pre-tool-use hook
# Rewrites interceptable commands to go through the bctx lens pipeline.
# Bypass signals (any one is sufficient):
#   - BCTX_BYPASS=1 environment variable
#   - .bctx-bypass file in current directory or any parent
#   - command already starts with "bctx "

set -euo pipefail
INPUT=$(cat)

# Global env bypass
[ "${BCTX_BYPASS:-0}" = "1" ] && { printf '%s' "$INPUT"; exit 0; }

# Project-level bypass: walk up from CWD
dir="$PWD"
while [ "$dir" != "/" ]; do
  [ -f "$dir/.bctx-bypass" ] && { printf '%s' "$INPUT"; exit 0; }
  dir=$(dirname "$dir")
done

# Extract the bash command string from tool input JSON
COMMAND=$(python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('command', ''))
" <<< "$INPUT" 2>/dev/null || true)

[ -z "$COMMAND" ] && { printf '%s' "$INPUT"; exit 0; }

# Already going through bctx
case "$COMMAND" in
  bctx\ *) printf '%s' "$INPUT"; exit 0 ;;
esac

# Programs bctx knows how to compress
PROGRAMS="git cargo npm pnpm npx python3 python pytest jest vitest kubectl docker terraform aws"

for prog in $PROGRAMS; do
  case "$COMMAND" in
    "$prog "*|"$prog")
      python3 -c "
import sys, json
d = json.load(sys.stdin)
d['tool_input']['command'] = 'bctx ' + d['tool_input']['command']
print(json.dumps(d))
" <<< "$INPUT" 2>/dev/null || printf '%s' "$INPUT"
      exit 0
      ;;
  esac
done

printf '%s' "$INPUT"
exit 0
"#;

// ── Cursor ────────────────────────────────────────────────────────────────────

fn install_cursor() -> Result<()> {
    // Cursor uses .cursor/mcp.json in the project root, or global ~/.cursor/mcp.json
    let home = std::env::var("HOME").context("HOME not set")?;
    let cursor_dir = PathBuf::from(&home).join(".cursor");
    let mcp_path = cursor_dir.join("mcp.json");

    let existing = if mcp_path.exists() {
        std::fs::read_to_string(&mcp_path)?
    } else {
        "{}".to_string()
    };

    let mut config: serde_json::Value =
        serde_json::from_str(&existing).unwrap_or(serde_json::json!({}));

    let bctx_bin = which_bctx();
    config["mcpServers"]["bctx"] = serde_json::json!({
        "command": bctx_bin,
        "args": ["mcp"]
    });

    std::fs::create_dir_all(&cursor_dir)?;
    std::fs::write(&mcp_path, serde_json::to_string_pretty(&config)?)?;
    println!("✓ bctx MCP server registered at {}", mcp_path.display());
    println!("  Restart Cursor to activate the 15 bctx tools.");
    Ok(())
}

// ── Shell (bash/zsh) ──────────────────────────────────────────────────────────

fn install_shell(shell: &str) -> Result<()> {
    let home = std::env::var("HOME").context("HOME not set")?;
    let rc_file = match shell {
        "zsh" => PathBuf::from(&home).join(".zshrc"),
        "bash" => PathBuf::from(&home).join(".bashrc"),
        _ => unreachable!(),
    };

    let snippet = format!(
        "\n# bctx — intercept git/cargo/etc. through the context-aware lens pipeline\n\
         for _bctx_cmd in git cargo npm pnpm python3 kubectl docker; do\n\
         {INDENT}eval \"$_bctx_cmd() {{ bctx \\\"$_bctx_cmd\\\" \\\"$@\\\"; }}\"\n\
         done\nunset _bctx_cmd\n",
        INDENT = "  "
    );

    // Check if already installed
    let current = std::fs::read_to_string(&rc_file).unwrap_or_default();
    if current.contains("# bctx —") {
        println!("bctx shell hook already present in {}", rc_file.display());
        return Ok(());
    }

    // Append to rc file
    let mut content = current;
    content.push_str(&snippet);
    std::fs::write(&rc_file, content)?;
    println!("✓ bctx shell hook appended to {}", rc_file.display());
    println!("  Run: source {}", rc_file.display());
    Ok(())
}

/// Find the installed bctx binary path.
fn which_bctx() -> String {
    // Check common install locations
    let candidates = [
        std::env::current_exe()
            .ok()
            .map(|p| p.to_string_lossy().to_string()),
        Some("/Users/azeno/.cargo/bin/bctx".to_string()),
        Some("/usr/local/bin/bctx".to_string()),
    ];
    for c in candidates.into_iter().flatten() {
        if std::path::Path::new(&c).exists() {
            return c;
        }
    }
    "bctx".to_string()
}