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; 5] = [
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" },
Hook { event: "PreToolUse", matcher: None, command: "kb hook tool" },
];
#[derive(Debug, PartialEq, Eq)]
pub enum Change {
Written,
Unchanged,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edited {
pub event: &'static str,
pub command: String,
pub hook: &'static str,
}
pub fn add_hooks(root: &Path) -> Result<(Change, Vec<Edited>)> {
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;
let mut edited = Vec::new();
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;
}
let before = edited.len();
edited.extend(look_alikes(groups, hook));
if edited.len() > before {
continue;
}
changed = true;
groups.push(group(hook));
}
if !changed {
return Ok((Change::Unchanged, edited));
}
write(&path, &settings)?;
Ok((Change::Written, edited))
}
pub fn update_hooks(root: &Path) -> Result<Change> {
let path = root.join(SETTINGS);
if !path.is_file() {
return Ok(Change::Unchanged);
}
let hooks = read(&path)?.remove("hooks").unwrap_or(Value::Null);
let installed = HOOKS.iter().any(|hook| {
hooks[hook.event].as_array().is_some_and(|groups| groups.iter().any(|group| runs(group, hook.command)))
});
if !installed {
return Ok(Change::Unchanged);
}
Ok(add_hooks(root)?.0)
}
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, Vec<Edited>)> {
let path = root.join(SETTINGS);
if !path.is_file() {
return Ok((Change::Unchanged, Vec::new()));
}
let mut settings = read(&path)?;
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
return Ok((Change::Unchanged, Vec::new()));
};
let mut changed = false;
let mut edited = Vec::new();
for hook in &HOOKS {
let Some(groups) = hooks.get_mut(hook.event).and_then(Value::as_array_mut) else { continue };
groups.retain_mut(|group| {
let Some(handlers) = group.get_mut("hooks").and_then(Value::as_array_mut) else { return true };
let before = handlers.len();
handlers.retain(|handler| !kanbrains(handler, hook.command));
changed |= handlers.len() != before;
handlers.len() == before || !handlers.is_empty()
});
edited.extend(look_alikes(groups, hook));
if groups.is_empty() {
hooks.remove(hook.event);
}
}
if !changed {
return Ok((Change::Unchanged, edited));
}
if hooks.is_empty() {
settings.remove("hooks");
}
if settings.is_empty() {
crate::store::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, edited));
}
write(&path, &settings)?;
Ok((Change::Written, edited))
}
fn runs(group: &Value, command: &str) -> bool {
group["hooks"].as_array().is_some_and(|hooks| hooks.iter().any(|handler| kanbrains(handler, command)))
}
fn kanbrains(handler: &Value, command: &str) -> bool {
if handler["type"] != "command" {
return false;
}
let Some(written) = handler["command"].as_str() else { return false };
if let Some(given) = handler.get("args") {
let args = command.split(' ').skip(1).map(Some);
return written.chars().all(|c| in_quotes(c) || c == '\\')
&& is_kb(written)
&& given.as_array().is_some_and(|given| given.iter().map(Value::as_str).eq(args));
}
let powershell = match handler.get("shell").map(Value::as_str) {
None | Some(Some("bash")) => false,
Some(Some("powershell")) => true,
_ => return false,
};
let (call, line) = match written.strip_prefix("& ") {
Some(line) if powershell => (true, line),
_ => (false, written),
};
let (program, rest) = if let Some(quote @ ('"' | '\'')) = line.chars().next() {
if powershell && !call {
return false;
}
let Some((path, rest)) = line[1..].split_once(quote) else { return false };
let backslash = powershell || quote == '\'';
if !path.chars().all(|c| in_quotes(c) || (backslash && c == '\\')) {
return false;
}
(path, rest)
} else {
let (path, rest) = line.split_at(line.find(' ').unwrap_or(line.len()));
let plain = if powershell { path } else { path.strip_prefix("~/").unwrap_or(path) };
if !plain.chars().all(|c| bare(c) || (powershell && c == '\\')) {
return false;
}
(path, rest)
};
is_kb(program) && rest.trim_end_matches([' ', '\t']) == &command["kb".len()..]
}
fn bare(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '.' | '/' | ':' | '-')
}
fn in_quotes(c: char) -> bool {
bare(c) || matches!(c, ' ' | '(' | ')')
}
fn is_kb(program: &str) -> bool {
let drive = program.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) && program.get(1..3) == Some(":\\");
let windows = (drive || program.starts_with("\\\\")) && !program.contains('/');
if !windows && program.contains('\\') {
return false;
}
let name = program.rsplit(if windows { '\\' } else { '/' }).next().unwrap_or_default();
name == "kb" || name.eq_ignore_ascii_case("kb.exe")
}
fn look_alikes<'a>(groups: &'a [Value], hook: &'a Hook) -> impl Iterator<Item = Edited> + 'a {
let args: Vec<&str> = hook.command.split(' ').skip(1).collect();
let handlers = groups.iter().filter_map(|group| group["hooks"].as_array()).flatten();
handlers.filter(|handler| handler["type"] == "command").filter_map(move |handler| {
let mut command = handler["command"].as_str()?.to_string();
for arg in handler["args"].as_array().into_iter().flatten() {
command.push(' ');
command.push_str(&arg.as_str().map_or_else(|| arg.to_string(), str::to_string));
}
let words: Vec<&str> = command
.split_whitespace()
.map(|word| {
word.trim_matches(|c: char| !(c.is_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '\\' | ':')))
})
.collect();
let found = words.windows(args.len() + 1).any(|run| {
let name = run[0].rsplit(['/', '\\']).next().unwrap_or_default();
(name.eq_ignore_ascii_case("kb") || name.eq_ignore_ascii_case("kb.exe")) && run[1..] == args[..]
});
found.then_some(Edited { event: hook.event, command, hook: hook.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;