use crate::routing::{route, Route};
use serde_json::{json, Value};
use std::path::PathBuf;
struct Contract {
name: &'static str,
command: fn(&Value) -> Option<&str>,
rewritten: fn(&Value, &str) -> Option<Value>,
}
const CONTRACTS: &[Contract] = &[Contract {
name: "cursor",
command: cursor_command,
rewritten: cursor_rewritten,
}];
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()
}
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)? }))
}
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
pub(crate) fn bctx_path() -> String {
std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "bctx".into())
}
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,
}
}
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
);
}
}