use crate::rewrite::{self, PATTERNS};
use crate::{error::Error, init::find_root};
use std::io::Read;
use std::path::PathBuf;
const MAX_HOOK_INPUT_BYTES: u64 = 1024 * 1024;
pub const HOOK_COMMAND: &str = "oo hook claude";
pub fn claude_settings_path(cwd: &std::path::Path, global: bool) -> Result<PathBuf, Error> {
if global {
if let Some(dir) = std::env::var_os("OO_CLAUDE_DIR") {
if dir.is_empty() {
return Err(Error::Init(
"OO_CLAUDE_DIR is set but empty; expected the Claude Code config directory"
.into(),
));
}
return Ok(PathBuf::from(dir).join("settings.json"));
}
match std::env::var_os("HOME") {
Some(home) if !home.as_os_str().is_empty() => {
Ok(PathBuf::from(home).join(".claude").join("settings.json"))
}
_ => Err(Error::Init(
"cannot determine the global Claude Code config directory: \
$OO_CLAUDE_DIR is not set and $HOME is unset or empty"
.into(),
)),
}
} else {
Ok(find_root(cwd).join(".claude").join("settings.json"))
}
}
pub fn cmd_hook_claude() -> i32 {
let Some(text) = read_hook_input_bounded(std::io::stdin()) else {
return 0; };
if let Some(out) = handle(&text) {
println!("{out}");
}
0
}
fn read_hook_input_bounded<R: Read>(reader: R) -> Option<String> {
let mut buf = Vec::new();
let read = reader.take(MAX_HOOK_INPUT_BYTES + 1).read_to_end(&mut buf);
if read.is_err() || buf.len() > MAX_HOOK_INPUT_BYTES as usize {
return None;
}
Some(String::from_utf8_lossy(&buf).to_string())
}
fn handle(text: &str) -> Option<String> {
if std::env::var("OO_DISABLE").is_ok_and(|v| v == "1") {
return None;
}
let Ok(root) = serde_json::from_str::<serde_json::Value>(text.trim()) else {
return None; };
if root.get("tool_name").and_then(|v| v.as_str()) != Some("Bash") {
return None;
}
let tool_input = root.get("tool_input")?.as_object()?;
let command = tool_input.get("command")?.as_str()?;
if command.trim().is_empty() {
return None;
}
let rewritten = rewrite::rewrite(command, &PATTERNS)?;
let mut updated = serde_json::Map::from_iter(tool_input.clone());
updated.insert("command".to_string(), serde_json::Value::String(rewritten));
let output = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecisionReason": "oo auto-rewrite",
"updatedInput": serde_json::Value::Object(updated),
}
});
Some(output.to_string())
}
pub fn merge_oo_hook(settings_path: &std::path::Path) -> Result<(), Error> {
let mut doc = load_or_create(settings_path)?;
let pretooluse = pretooluse_array(settings_path, &mut doc)?;
let command_entry = serde_json::json!({"type": "command", "command": HOOK_COMMAND});
let new_group = serde_json::json!({"matcher": "Bash", "hooks": [command_entry.clone()]});
let already_present = pretooluse.iter().any(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|cmds| {
cmds.iter()
.any(|c| c.get("command").and_then(|v| v.as_str()) == Some(HOOK_COMMAND))
})
});
if !already_present {
let bash_group_valid = pretooluse.iter().any(|g| {
g.get("matcher").and_then(|m| m.as_str()) == Some("Bash")
&& g.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|cmds| cmds.iter().all(|c| c.is_object()))
});
match pretooluse
.iter_mut()
.find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Bash"))
{
Some(group) if bash_group_valid => {
if let Some(cmds) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) {
cmds.push(command_entry);
}
}
Some(_) => {
return Err(Error::Init(format!(
"existing {} has a malformed `matcher: \"Bash\"` group \
(its `hooks` is not an array of objects) — refusing to modify it",
settings_path.display()
)));
}
None => pretooluse.push(new_group),
}
}
let serialized = serde_json::to_string_pretty(&doc)
.map_err(|e| Error::Init(format!("JSON serialize failed: {e}")))?;
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| Error::Init(format!("cannot create {}: {e}", parent.display())))?;
}
let tmp_path = settings_path.with_file_name(format!(
"{}.tmp",
settings_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("settings.json")
));
std::fs::write(&tmp_path, &serialized)
.map_err(|e| Error::Init(format!("cannot write {}: {e}", tmp_path.display())))?;
std::fs::rename(&tmp_path, settings_path).map_err(|rename_err| {
let mut msg = format!(
"cannot rename {} to {}: {rename_err}",
tmp_path.display(),
settings_path.display()
);
if let Err(cleanup_err) = std::fs::remove_file(&tmp_path) {
msg.push_str(&format!(
"; cleanup of the temp file also failed: {cleanup_err}"
));
}
Error::Init(msg)
})?;
Ok(())
}
fn load_or_create(settings_path: &std::path::Path) -> Result<serde_json::Value, Error> {
let existing = match std::fs::read_to_string(settings_path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(Error::Init(format!(
"cannot read {}: {e}",
settings_path.display()
)));
}
};
let doc = match &existing {
None => serde_json::Value::Object(serde_json::Map::new()),
Some(s) => serde_json::from_str::<serde_json::Value>(s).map_err(|e| {
Error::Init(format!(
"existing {} is not valid JSON — refusing to overwrite it: {e}",
settings_path.display()
))
})?,
};
if !doc.is_object() {
return Err(Error::Init(format!(
"existing {} is not a JSON object — refusing to overwrite it",
settings_path.display()
)));
}
Ok(doc)
}
fn pretooluse_array<'a>(
settings_path: &std::path::Path,
doc: &'a mut serde_json::Value,
) -> Result<&'a mut Vec<serde_json::Value>, Error> {
let doc = match doc {
serde_json::Value::Object(obj) => obj,
_ => {
return Err(Error::Init(format!(
"existing {} is not a JSON object — refusing to modify it",
settings_path.display()
)));
}
};
let present = doc.get_mut("hooks").is_some();
if !present {
doc.insert(
"hooks".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
let hooks = match doc.get_mut("hooks") {
Some(serde_json::Value::Object(obj)) => obj,
Some(_) => {
return Err(Error::Init(format!(
"existing {} has a non-object hooks key — refusing to modify it",
settings_path.display()
)));
}
None => {
return Err(Error::Init(format!(
"existing {} has a non-object hooks key — refusing to modify it",
settings_path.display()
)));
}
};
let pretooluse = match hooks
.entry("PreToolUse".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()))
{
serde_json::Value::Array(arr) => arr,
_ => {
return Err(Error::Init(format!(
"existing {} has a non-array hooks.PreToolUse — refusing to modify it",
settings_path.display()
)));
}
};
Ok(pretooluse)
}
#[cfg(test)]
#[path = "hook_tests.rs"]
mod tests;