use anyhow::{Context, Result, bail};
use serde_json::{Map, Value, json};
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
pub const SETTINGS: &str = ".claude/settings.local.json";
struct Hook {
event: &'static str,
matcher: Option<&'static str>,
command: &'static str,
}
const HOOKS: [Hook; 4] = [
Hook { event: "SessionStart", matcher: Some("startup|resume|clear|compact"), command: "kb context" },
Hook { event: "UserPromptSubmit", matcher: None, command: "kb hook prompt" },
Hook { event: "Stop", matcher: None, command: "kb hook stop" },
Hook { event: "PreToolUse", matcher: Some("Edit|Write|MultiEdit"), command: "kb hook edit" },
];
#[derive(Debug, PartialEq, Eq)]
pub enum Change {
Written,
Unchanged,
Removed,
}
pub fn add_hooks(root: &Path) -> Result<Change> {
let path = root.join(SETTINGS);
let mut settings = read(&path)?;
let hooks = settings
.entry("hooks")
.or_insert_with(|| json!({}))
.as_object_mut()
.with_context(|| format!("can't add the hooks to {}: its `hooks` isn't an object", path.display()))?;
let mut changed = false;
for hook in &HOOKS {
let groups =
hooks.entry(hook.event).or_insert_with(|| json!([])).as_array_mut().with_context(|| {
format!("can't add the hooks to {}: its `{}` isn't a list", path.display(), hook.event)
})?;
if groups.iter().any(|group| runs(group, hook.command)) {
continue;
}
changed = true;
groups.push(group(hook));
}
if !changed {
return Ok(Change::Unchanged);
}
write(&path, &settings)?;
Ok(Change::Written)
}
fn group(hook: &Hook) -> Value {
let handler = json!([{ "type": "command", "command": hook.command }]);
match hook.matcher {
Some(matcher) => json!({ "matcher": matcher, "hooks": handler }),
None => json!({ "hooks": handler }),
}
}
pub fn remove_hooks(root: &Path) -> Result<Change> {
let path = root.join(SETTINGS);
if !path.is_file() {
return Ok(Change::Unchanged);
}
let mut settings = read(&path)?;
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else { return Ok(Change::Unchanged) };
let mut changed = false;
for hook in &HOOKS {
let Some(groups) = hooks.get_mut(hook.event).and_then(Value::as_array_mut) else { continue };
let before = groups.len();
groups.retain(|group| !runs(group, hook.command));
changed |= groups.len() != before;
if groups.is_empty() {
hooks.remove(hook.event);
}
}
if !changed {
return Ok(Change::Unchanged);
}
if hooks.is_empty() {
settings.remove("hooks");
}
if settings.is_empty() {
fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
if let Some(dir) = path.parent()
&& fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
{
fs::remove_dir(dir).with_context(|| format!("removing {}", dir.display()))?;
}
return Ok(Change::Removed);
}
write(&path, &settings)?;
Ok(Change::Written)
}
fn runs(group: &Value, command: &str) -> bool {
group["hooks"].as_array().is_some_and(|hooks| hooks.iter().any(|h| h["command"] == command))
}
fn read(path: &Path) -> Result<Map<String, Value>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Map::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
match serde_json::from_str(&text) {
Ok(Value::Object(settings)) => Ok(settings),
Ok(_) => bail!("{} isn't a JSON object", path.display()),
Err(e) => bail!("{} isn't valid JSON ({e})", path.display()),
}
}
fn write(path: &Path, settings: &Map<String, Value>) -> Result<()> {
crate::store::write_file(path, &(serde_json::to_string_pretty(settings)? + "\n"))
}
#[cfg(test)]
#[path = "../../tests/cli/claude.rs"]
mod tests;