use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
pub fn hooks_installed(project_root: &Path) -> bool {
let settings_path = project_root.join(".claude").join("settings.local.json");
if !settings_path.exists() {
return false;
}
match fs::read_to_string(&settings_path) {
Ok(content) => {
if let Ok(settings) = serde_json::from_str::<Value>(&content) {
settings
.get("hooks")
.and_then(|h| h.get("Stop"))
.and_then(|s| s.as_array())
.map(|arr| {
arr.iter().any(|hook| {
hook.get("hooks")
.and_then(|h| h.as_array())
.map(|cmds| {
cmds.iter().any(|cmd| {
cmd.get("command")
.and_then(|c| c.as_str())
.map(|s| s.contains("scud") && s.contains("set-status"))
.unwrap_or(false)
})
})
.unwrap_or(false)
})
})
.unwrap_or(false)
} else {
false
}
}
Err(_) => false,
}
}
pub fn install_hooks(project_root: &Path) -> Result<()> {
let claude_dir = project_root.join(".claude");
let settings_path = claude_dir.join("settings.local.json");
fs::create_dir_all(&claude_dir).context("Failed to create .claude directory")?;
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
let stop_hook = json!([
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [ -n \"$SCUD_TASK_ID\" ]; then scud set-status \"$SCUD_TASK_ID\" done 2>/dev/null || true; fi'",
"timeout": 10
}
]
}
]);
let post_tool_hook = json!([
{
"matcher": "TaskUpdate|TaskCreate",
"hooks": [
{
"type": "command",
"command": "bash -c 'scud sync-from-claude 2>/dev/null || true'",
"timeout": 10
}
]
}
]);
let hooks = settings.get("hooks").cloned().unwrap_or_else(|| json!({}));
let mut hooks_obj = hooks.as_object().cloned().unwrap_or_default();
hooks_obj.insert("Stop".to_string(), stop_hook);
hooks_obj.insert("PostToolUse".to_string(), post_tool_hook);
settings["hooks"] = json!(hooks_obj);
let content = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, content)?;
Ok(())
}
pub fn uninstall_hooks(project_root: &Path) -> Result<()> {
let settings_path = project_root.join(".claude").join("settings.local.json");
if !settings_path.exists() {
return Ok(());
}
let content = fs::read_to_string(&settings_path)?;
let mut settings: Value = serde_json::from_str(&content)?;
if let Some(hooks) = settings.get_mut("hooks") {
if let Some(hooks_obj) = hooks.as_object_mut() {
if let Some(stop) = hooks_obj.get("Stop") {
let is_ours = stop
.as_array()
.map(|arr| {
arr.iter().any(|h| {
h.get("hooks")
.and_then(|cmds| cmds.as_array())
.map(|cmds| {
cmds.iter().any(|cmd| {
cmd.get("command")
.and_then(|c| c.as_str())
.map(|s| s.contains("SCUD_TASK_ID"))
.unwrap_or(false)
})
})
.unwrap_or(false)
})
})
.unwrap_or(false);
if is_ours {
hooks_obj.remove("Stop");
}
}
if let Some(post_tool) = hooks_obj.get("PostToolUse") {
let is_ours = post_tool
.as_array()
.map(|arr| {
arr.iter().any(|h| {
h.get("hooks")
.and_then(|cmds| cmds.as_array())
.map(|cmds| {
cmds.iter().any(|cmd| {
cmd.get("command")
.and_then(|c| c.as_str())
.map(|s| s.contains("scud sync-from-claude"))
.unwrap_or(false)
})
})
.unwrap_or(false)
})
})
.unwrap_or(false);
if is_ours {
hooks_obj.remove("PostToolUse");
}
}
}
}
let content = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, content)?;
Ok(())
}
pub fn agent_env_setup(task_id: &str) -> String {
format!("export SCUD_TASK_ID=\"{}\"", task_id)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_hooks_not_installed_missing_file() {
let tmp = TempDir::new().unwrap();
assert!(!hooks_installed(tmp.path()));
}
#[test]
fn test_install_hooks_creates_settings() {
let tmp = TempDir::new().unwrap();
install_hooks(tmp.path()).unwrap();
let settings_path = tmp.path().join(".claude").join("settings.local.json");
assert!(settings_path.exists());
let content = fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("SCUD_TASK_ID"));
assert!(content.contains("scud"));
}
#[test]
fn test_hooks_installed_detects_our_hook() {
let tmp = TempDir::new().unwrap();
install_hooks(tmp.path()).unwrap();
assert!(hooks_installed(tmp.path()));
}
#[test]
fn test_uninstall_hooks() {
let tmp = TempDir::new().unwrap();
install_hooks(tmp.path()).unwrap();
assert!(hooks_installed(tmp.path()));
uninstall_hooks(tmp.path()).unwrap();
assert!(!hooks_installed(tmp.path()));
}
#[test]
fn test_agent_env_setup() {
let env = agent_env_setup("auth:5");
assert_eq!(env, "export SCUD_TASK_ID=\"auth:5\"");
}
#[test]
fn test_install_hooks_includes_post_tool_use() {
let tmp = TempDir::new().unwrap();
install_hooks(tmp.path()).unwrap();
let settings_path = tmp.path().join(".claude").join("settings.local.json");
let content = fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("PostToolUse"));
assert!(content.contains("TaskUpdate|TaskCreate"));
assert!(content.contains("sync-from-claude"));
}
#[test]
fn test_uninstall_removes_post_tool_use_hook() {
let tmp = TempDir::new().unwrap();
install_hooks(tmp.path()).unwrap();
let settings_path = tmp.path().join(".claude").join("settings.local.json");
let content = fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("PostToolUse"));
uninstall_hooks(tmp.path()).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
assert!(!content.contains("PostToolUse"));
}
}