use super::agent_config::{self, Style};
use anyhow::Result;
use serde_json::{json, Value};
use std::path::Path;
pub(crate) struct AgentHook {
pub agent: &'static str,
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))
}
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()) {
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)))
}