use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand, ValueEnum};
use serde_json::{json, Map, Value};
use super::OutputConfig;
#[derive(Args)]
pub struct HooksArgs {
#[command(subcommand)]
command: HooksCommand,
}
#[derive(Subcommand)]
enum HooksCommand {
Bundle,
Install(Target),
Uninstall(Target),
Status(Target),
}
#[derive(Args)]
struct Target {
#[arg(long, value_enum)]
harness: Vec<Harness>,
#[arg(long)]
project: bool,
#[arg(long)]
no_st: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Harness {
Claude,
Codex,
}
impl Harness {
fn name(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
}
}
}
pub fn bundle() -> Value {
let text = include_str!("../../hooks/bobbin.bundle.json")
.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"));
serde_json::from_str(&text).expect("hooks/bobbin.bundle.json is valid JSON")
}
pub fn hooks_for(bundle: &Value, harness: Harness) -> Vec<(String, Option<String>, String)> {
bundle["hooks"]
.as_array()
.into_iter()
.flatten()
.filter(|h| {
h["harnesses"]
.as_array()
.is_none_or(|hs| hs.iter().any(|x| x == harness.name()))
})
.map(|h| {
(
h["event"].as_str().unwrap_or_default().to_string(),
h["matcher"].as_str().map(str::to_string),
h["command"].as_str().unwrap_or_default().to_string(),
)
})
.collect()
}
pub fn merge(config: &mut Value, bundle: &Value, harness: Harness) -> usize {
if !config.is_object() {
*config = json!({});
}
let map = config.as_object_mut().expect("object");
if !map.get("hooks").is_some_and(Value::is_object) {
map.insert("hooks".into(), json!({}));
}
let hooks = map
.get_mut("hooks")
.and_then(Value::as_object_mut)
.expect("object");
let mut added = 0;
for (event, matcher, command) in hooks_for(bundle, harness) {
let groups = hooks
.entry(event)
.or_insert_with(|| json!([]))
.as_array_mut()
.expect("hooks.<event> is a list");
let index = match groups
.iter()
.position(|g| g["matcher"].as_str() == matcher.as_deref())
{
Some(i) => i,
None => {
let mut g = Map::new();
if let Some(m) = &matcher {
g.insert("matcher".into(), json!(m));
}
g.insert("hooks".into(), json!([]));
groups.push(Value::Object(g));
groups.len() - 1
}
};
let list = groups[index]["hooks"].as_array_mut().expect("hooks list");
if !list.iter().any(|h| h["command"] == command.as_str()) {
list.push(json!({"type": "command", "command": command}));
added += 1;
}
}
added
}
pub fn remove(config: &mut Value, bundle: &Value, harness: Harness) -> usize {
let ours: Vec<String> = hooks_for(bundle, harness)
.into_iter()
.map(|h| h.2)
.collect();
let Some(hooks) = config.get_mut("hooks").and_then(Value::as_object_mut) else {
return 0;
};
let mut removed = 0;
for groups in hooks.values_mut() {
let Some(groups) = groups.as_array_mut() else {
continue;
};
for group in groups.iter_mut() {
if let Some(list) = group.get_mut("hooks").and_then(Value::as_array_mut) {
let before = list.len();
list.retain(|h| !ours.iter().any(|c| h["command"] == c.as_str()));
removed += before - list.len();
}
}
groups.retain(|g| g["hooks"].as_array().is_none_or(|l| !l.is_empty()));
}
hooks.retain(|_, v| v.as_array().is_none_or(|g| !g.is_empty()));
removed
}
pub fn present(config: &Value, bundle: &Value, harness: Harness) -> (usize, usize) {
let want = hooks_for(bundle, harness);
let found = want
.iter()
.filter(|(event, matcher, command)| {
config["hooks"][event].as_array().is_some_and(|groups| {
groups.iter().any(|g| {
g["matcher"].as_str() == matcher.as_deref()
&& g["hooks"]
.as_array()
.is_some_and(|l| l.iter().any(|h| h["command"] == command.as_str()))
})
})
})
.count();
(found, want.len())
}
pub fn config_path(harness: Harness, project: bool) -> PathBuf {
let home = || PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
match (harness, project) {
(Harness::Claude, false) => home().join(".claude/settings.json"),
(Harness::Claude, true) => PathBuf::from(".claude/settings.json"),
(Harness::Codex, false) => std::env::var_os("CODEX_HOME")
.map_or_else(|| home().join(".codex"), PathBuf::from)
.join("config.toml"),
(Harness::Codex, true) => PathBuf::from(".codex/config.toml"),
}
}
pub fn read_config(path: &Path, harness: Harness) -> Result<Value> {
let Ok(text) = std::fs::read_to_string(path) else {
return Ok(json!({}));
};
Ok(match harness {
Harness::Claude => serde_json::from_str(&text)
.with_context(|| format!("{} is not valid JSON", path.display()))?,
Harness::Codex => serde_json::to_value(
toml::from_str::<toml::Value>(&text)
.with_context(|| format!("{} is not valid TOML", path.display()))?,
)?,
})
}
pub fn write_config(path: &Path, harness: Harness, config: &Value) -> Result<()> {
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir)?;
}
if path.exists() {
let mut backup = path.as_os_str().to_owned();
backup.push(".bak-bobbin");
std::fs::copy(path, PathBuf::from(backup))?;
}
let text = match harness {
Harness::Claude => serde_json::to_string_pretty(config)? + "\n",
Harness::Codex => toml::to_string(&serde_json::from_value::<toml::Value>(config.clone())?)?,
};
std::fs::write(path, text)?;
Ok(())
}
fn st_available() -> bool {
Command::new("st")
.args(["ops", "hooks", "list"])
.output()
.is_ok_and(|o| o.status.success())
}
fn st_register(register: bool) -> Result<String> {
let out = if register {
let file = std::env::temp_dir().join(format!("bobbin-bundle-{}.json", std::process::id()));
std::fs::write(&file, serde_json::to_string_pretty(&bundle())?)?;
let out = Command::new("st")
.args(["ops", "hooks", "register"])
.arg(&file)
.output();
let _ = std::fs::remove_file(&file);
out?
} else {
Command::new("st")
.args(["ops", "hooks", "unregister", "bobbin"])
.output()?
};
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if !out.status.success() {
bail!("st refused: {}", text.trim());
}
Ok(text.trim().to_string())
}
pub async fn run(args: HooksArgs, _output: OutputConfig) -> Result<()> {
let bundle = bundle();
let target = match &args.command {
HooksCommand::Bundle => {
println!("{}", serde_json::to_string_pretty(&bundle)?);
return Ok(());
}
HooksCommand::Install(t) | HooksCommand::Uninstall(t) | HooksCommand::Status(t) => t,
};
let harnesses = if target.harness.is_empty() {
vec![Harness::Claude, Harness::Codex]
} else {
target.harness.clone()
};
let via_st = !target.no_st && st_available();
let mut ok = true;
match &args.command {
HooksCommand::Install(_) | HooksCommand::Uninstall(_) if via_st => {
let register = matches!(args.command, HooksCommand::Install(_));
println!("{}", st_register(register)?);
println!(
"bobbin hooks {} through shantytown: st renders them into every role's \
claude and codex settings. Check with `st ops hooks check`.",
if register {
"registered"
} else {
"unregistered"
}
);
}
HooksCommand::Status(_) if via_st => {
let out = Command::new("st")
.args(["ops", "hooks", "check", "--json"])
.output()?;
let report: Value = serde_json::from_slice(&out.stdout)?;
for h in harnesses {
let items: Vec<&Value> = report["items"]
.as_array()
.into_iter()
.flatten()
.filter(|i| i["bundle"] == "bobbin" && i["harness"] == h.name())
.collect();
let count = |k: &str, v: &str| items.iter().filter(|i| i[k] == v).count();
println!(
"{}: {} hook item(s) via st; configured ok {}, live ok {}, firing ok {}, silent {}",
h.name(),
items.len(),
count("configured", "ok"),
count("live", "ok"),
count("firing", "ok"),
count("firing", "silent"),
);
ok &= !items.is_empty() && count("configured", "ok") == items.len();
}
}
_ => {
for h in harnesses {
let path = config_path(h, target.project);
let mut cfg = read_config(&path, h)?;
match &args.command {
HooksCommand::Install(_) => {
let n = merge(&mut cfg, &bundle, h);
if n > 0 {
write_config(&path, h, &cfg)?;
}
println!("{}: added {n} hook(s) to {}", h.name(), path.display());
}
HooksCommand::Uninstall(_) => {
let n = remove(&mut cfg, &bundle, h);
if n > 0 {
write_config(&path, h, &cfg)?;
}
println!("{}: removed {n} hook(s) from {}", h.name(), path.display());
}
_ => {
let (have, want) = present(&cfg, &bundle, h);
println!(
"{}: {have}/{want} bobbin hook(s) in {}",
h.name(),
path.display()
);
ok &= have == want;
}
}
}
}
}
if !ok {
std::process::exit(1);
}
Ok(())
}
#[cfg(test)]
#[path = "hooks_tests.rs"]
mod tests;