use crate::error::{ExoMonadError, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
const EXOMONAD_MARKER: &str = "_exomonad_generated";
const HOOK_EVENTS: &[(&str, &str, bool)] = &[
("PreToolUse", "pre-tool-use", true),
("PostToolUse", "post-tool-use", true),
("PermissionRequest", "permission-request", true),
("Notification", "notification", false),
("Stop", "stop", false),
("SubagentStart", "subagent-start", false),
("SubagentStop", "subagent-stop", false),
("PreCompact", "pre-compact", false),
("SessionStart", "session-start", false),
("SessionEnd", "session-end", false),
("UserPromptSubmit", "user-prompt-submit", false),
];
pub struct HookConfig {
settings_path: PathBuf,
original_content: Option<String>,
created_file: bool,
}
impl HookConfig {
pub fn generate(cwd: &Path, binary_path: &Path) -> Result<Self> {
Self::generate_with_binary(cwd, binary_path)
}
pub fn generate_with_binary(cwd: &Path, binary_path: &Path) -> Result<Self> {
let claude_dir = cwd.join(".claude");
let settings_path = claude_dir.join("settings.local.json");
if !claude_dir.exists() {
fs::create_dir_all(&claude_dir).map_err(ExoMonadError::Io)?;
debug!(path = %claude_dir.display(), "Created .claude directory");
}
let (original_content, mut settings) = if settings_path.exists() {
let content = fs::read_to_string(&settings_path).map_err(ExoMonadError::Io)?;
let settings: Value = serde_json::from_str(&content)
.map_err(|e| ExoMonadError::JsonParse { source: e })?;
(Some(content), settings)
} else {
(None, json!({}))
};
let created_file = original_content.is_none();
let hooks = generate_hook_config(binary_path);
if let Some(existing_hooks) = settings.get("hooks") {
let mut merged = existing_hooks.clone();
if let (Some(merged_obj), Some(new_hooks)) = (merged.as_object_mut(), hooks.as_object())
{
for (key, value) in new_hooks {
merged_obj.insert(key.clone(), value.clone());
}
}
settings["hooks"] = merged;
} else {
settings["hooks"] = hooks;
}
settings[EXOMONAD_MARKER] = json!(true);
let content =
serde_json::to_string_pretty(&settings).map_err(ExoMonadError::JsonSerialize)?;
fs::write(&settings_path, &content).map_err(ExoMonadError::Io)?;
debug!(
path = %settings_path.display(),
"Generated hook configuration"
);
Ok(Self {
settings_path,
original_content,
created_file,
})
}
pub fn settings_path(&self) -> &Path {
&self.settings_path
}
pub fn cleanup(&mut self) -> Result<()> {
if !self.settings_path.exists() {
return Ok(());
}
if self.created_file {
fs::remove_file(&self.settings_path).map_err(ExoMonadError::Io)?;
debug!(
path = %self.settings_path.display(),
"Removed generated settings.local.json"
);
} else if let Some(ref original) = self.original_content {
fs::write(&self.settings_path, original).map_err(ExoMonadError::Io)?;
debug!(
path = %self.settings_path.display(),
"Restored original settings.local.json"
);
} else {
if let Ok(content) = fs::read_to_string(&self.settings_path) {
if let Ok(mut settings) = serde_json::from_str::<Value>(&content) {
if let Some(obj) = settings.as_object_mut() {
obj.remove(EXOMONAD_MARKER);
if let Some(hooks) = obj.get_mut("hooks") {
if let Some(hooks_obj) = hooks.as_object_mut() {
for (event_name, _, _) in HOOK_EVENTS {
if let Some(entries) = hooks_obj.get(*event_name) {
if is_exomonad_hook(entries) {
hooks_obj.remove(*event_name);
}
}
}
}
}
if let Ok(cleaned) = serde_json::to_string_pretty(&settings) {
fs::write(&self.settings_path, cleaned).ok();
}
}
}
}
}
Ok(())
}
}
impl Drop for HookConfig {
fn drop(&mut self) {
if let Err(e) = self.cleanup() {
warn!(error = %e, "Failed to clean up hook configuration");
}
}
}
fn generate_hook_config(exomonad_path: &Path) -> Value {
let mut hooks = serde_json::Map::new();
for (event_name, subcommand, needs_matcher) in HOOK_EVENTS {
let command = format!("{} hook {}", exomonad_path.display(), subcommand);
let hook_entry = json!({
"type": "command",
"command": command
});
let config = if *needs_matcher {
json!([{
"matcher": "*",
"hooks": [hook_entry]
}])
} else {
json!([{
"hooks": [hook_entry]
}])
};
hooks.insert(event_name.to_string(), config);
}
Value::Object(hooks)
}
fn is_exomonad_hook(entries: &Value) -> bool {
if let Some(arr) = entries.as_array() {
for entry in arr {
if let Some(hooks_arr) = entry.get("hooks").and_then(|h| h.as_array()) {
for hook in hooks_arr {
if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) {
if cmd.contains("exomonad hook") {
return true;
}
}
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generate_hook_config() {
let path = PathBuf::from("/usr/bin/exomonad");
let config = generate_hook_config(&path);
let pre_tool = &config["PreToolUse"];
assert!(pre_tool.is_array());
let first = &pre_tool[0];
assert_eq!(first["matcher"], "*");
assert!(first["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("hook pre-tool-use"));
let stop = &config["Stop"];
assert!(stop.is_array());
let first = &stop[0];
assert!(first.get("matcher").is_none());
assert!(first["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("hook stop"));
}
#[test]
fn test_hook_config_lifecycle() {
let temp_dir = TempDir::new().unwrap();
let cwd = temp_dir.path();
let exomonad = PathBuf::from("/test/exomonad");
let config = HookConfig::generate(cwd, &exomonad).unwrap();
assert!(config.settings_path().exists());
let content = fs::read_to_string(config.settings_path()).unwrap();
assert!(content.contains("PreToolUse"));
assert!(content.contains("exomonad hook pre-tool-use"));
assert!(content.contains(EXOMONAD_MARKER));
drop(config);
let settings_path = cwd.join(".claude/settings.local.json");
assert!(!settings_path.exists());
}
#[test]
fn test_hook_config_with_existing() {
let temp_dir = TempDir::new().unwrap();
let cwd = temp_dir.path();
let claude_dir = cwd.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let existing = json!({
"some_setting": true,
"hooks": {
"CustomHook": [{"hooks": [{"type": "command", "command": "echo custom"}]}]
}
});
let settings_path = claude_dir.join("settings.local.json");
fs::write(
&settings_path,
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
let exomonad = PathBuf::from("/test/exomonad");
let config = HookConfig::generate(cwd, &exomonad).unwrap();
let content = fs::read_to_string(config.settings_path()).unwrap();
let settings: Value = serde_json::from_str(&content).unwrap();
assert!(settings["hooks"]["PreToolUse"].is_array());
assert!(settings["hooks"]["CustomHook"].is_array());
assert_eq!(settings["some_setting"], true);
drop(config);
let restored = fs::read_to_string(&settings_path).unwrap();
let restored: Value = serde_json::from_str(&restored).unwrap();
assert_eq!(restored["some_setting"], true);
assert!(
restored["hooks"]["PreToolUse"].is_null()
|| !restored["hooks"]["PreToolUse"].is_array()
);
}
#[test]
fn test_is_exomonad_hook() {
let our_hook = json!([{
"matcher": "*",
"hooks": [{"type": "command", "command": "/bin/exomonad hook pre-tool-use"}]
}]);
assert!(is_exomonad_hook(&our_hook));
let other_hook = json!([{
"matcher": "*",
"hooks": [{"type": "command", "command": "echo hello"}]
}]);
assert!(!is_exomonad_hook(&other_hook));
}
}