bctx 0.1.40

bctx CLI — intercept CLI commands and compress output for LLM coding agents
//! Tool-call hook entries bctx adds to agents' JSON configs (plan 10, layer 1). Each entry
//! runs `bctx hook rewrite --agent <name>`, which only ever changes the command.

use super::agent_config::{self, Style};
use anyhow::Result;
use serde_json::{json, Value};
use std::path::Path;

/// An agent whose own hook lets bctx rewrite a shell command (spec, "Probe results").
pub(crate) struct AgentHook {
    pub agent: &'static str,
    /// Hooks file, relative to the home directory.
    pub file: &'static str,
    pub event: &'static str,
}

pub(crate) const CURSOR: AgentHook = AgentHook {
    agent: "cursor",
    file: ".cursor/hooks.json",
    event: "preToolUse",
};

fn marker(agent: &str) -> String {
    format!("hook rewrite --agent {agent}")
}

fn is_ours(entry: &Value, agent: &str) -> bool {
    entry.to_string().contains(&marker(agent))
}

/// The entry in the agent's own shape: Cursor lists commands directly, and bctx checks the
/// tool name itself.
fn entry(hook: &AgentHook, bctx_bin: &str) -> Value {
    let command = format!(
        "{} {}",
        super::hook::shell_quote(bctx_bin),
        marker(hook.agent)
    );
    json!({ "command": command })
}

pub(crate) fn install(home: &Path, hook: &AgentHook, bctx_bin: &str) -> Result<bool> {
    let path = home.join(hook.file);
    let existed = path.exists();
    let entry = entry(hook, bctx_bin);
    let add = json!({ "hooks": { hook.event: [entry.clone()] } });
    let (event, agent) = (hook.event, hook.agent);
    let written = agent_config::merge(
        &path,
        &add,
        |cfg| {
            cfg["hooks"][event]
                .as_array()
                .is_some_and(|l| l.iter().any(|e| is_ours(e, agent)))
        },
        |cfg| {
            if agent == "cursor" && cfg.as_object().is_some_and(|c| c.is_empty()) {
                cfg["version"] = json!(1);
            }
            let Some(hooks) = agent_config::object_at(cfg, "hooks") else {
                return false;
            };
            let Some(list) = agent_config::array_at(hooks, event) else {
                return false;
            };
            list.retain(|e| !is_ours(e, agent));
            list.push(entry);
            true
        },
        Style::Pretty,
    )?;
    if written && !existed {
        super::created_files::note(&path)?;
    }
    if written {
        println!("  ✓ shell commands → bctx ({})", path.display());
    }
    Ok(written)
}

pub(crate) fn remove(home: &Path, hook: &AgentHook) -> Result<bool> {
    let path = home.join(hook.file);
    let Ok(raw) = std::fs::read_to_string(&path) else {
        return Ok(false);
    };
    let Ok(mut cfg) = serde_json::from_str::<Value>(&raw) else {
        println!(
            "  ! {} is not plain JSON: remove the `{}` hook entry by hand",
            path.display(),
            marker(hook.agent)
        );
        return Ok(false);
    };
    let Some(list) = cfg
        .get_mut("hooks")
        .and_then(|h| h.get_mut(hook.event))
        .and_then(Value::as_array_mut)
    else {
        return Ok(false);
    };
    let before = list.len();
    list.retain(|e| !is_ours(e, hook.agent));
    if list.len() == before {
        return Ok(false);
    }
    if list.is_empty() {
        if let Some(h) = cfg["hooks"].as_object_mut() {
            h.remove(hook.event);
        }
    }
    let created = super::created_files::was_created(&path);
    if created && cfg["hooks"].as_object().is_some_and(|h| h.is_empty()) {
        // Only what bctx put there is left (an empty `hooks`, Cursor's `version`).
        std::fs::remove_file(&path)?;
        super::created_files::forget(&path)?;
    } else {
        std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;
    }
    println!("  ✓ removed the bctx hook from {}", path.display());
    Ok(true)
}

pub(crate) fn present(home: &Path, hook: &AgentHook) -> bool {
    std::fs::read_to_string(home.join(hook.file))
        .ok()
        .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
        .and_then(|cfg| cfg["hooks"][hook.event].as_array().cloned())
        .is_some_and(|l| l.iter().any(|e| is_ours(e, hook.agent)))
}