use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::AppError;
const CODEX_STATUS_MESSAGE: &str = "omamori: checking command safety";
pub const SHIM_COMMANDS: &[&str] = &["rm", "git", "chmod", "find", "rsync"];
#[derive(Debug, Clone)]
pub struct InstallOptions {
pub base_dir: PathBuf,
pub source_exe: PathBuf,
pub generate_hooks: bool,
}
#[derive(Debug, Clone)]
pub struct InstallResult {
pub shim_dir: PathBuf,
pub linked_commands: Vec<String>,
pub hook_script: Option<PathBuf>,
pub settings_snippet: Option<PathBuf>,
pub cursor_hook_snippet: Option<PathBuf>,
pub codex_wrapper: Option<PathBuf>,
pub codex_hooks_outcome: Option<CodexHooksOutcome>,
pub codex_config_outcome: Option<CodexConfigOutcome>,
pub claude_settings_outcome: Option<ClaudeSettingsOutcome>,
}
#[derive(Debug, Clone)]
pub enum CodexHooksOutcome {
Merged,
Created,
AlreadyPresent,
Skipped(String),
}
#[derive(Debug, Clone)]
pub enum CodexConfigOutcome {
Added,
AlreadyEnabled,
ExplicitlyDisabled,
Skipped(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaudeSettingsOutcome {
Created,
Merged,
AlreadyPresent,
MatcherMigrated,
Skipped(String),
}
#[derive(Debug, Clone)]
pub struct UninstallResult {
pub shim_dir: PathBuf,
pub removed_entries: Vec<PathBuf>,
}
pub fn install(options: &InstallOptions) -> Result<InstallResult, AppError> {
let shim_dir = options.base_dir.join("shim");
fs::create_dir_all(&shim_dir)?;
let source_exe = options.source_exe.clone();
let mut linked_commands = Vec::new();
for command in SHIM_COMMANDS {
let link_path = shim_dir.join(command);
recreate_symlink(&source_exe, &link_path)?;
linked_commands.push((*command).to_string());
}
let (hook_script, settings_snippet) = if options.generate_hooks {
let hooks_dir = options.base_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let script_path = hooks_dir.join("claude-pretooluse.sh");
atomic_write(&script_path, &render_hook_script())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms)?;
}
let snippet_path = hooks_dir.join("claude-settings.snippet.json");
atomic_write(&snippet_path, &render_settings_snippet(&script_path))?;
(Some(script_path), Some(snippet_path))
} else {
(None, None)
};
let cursor_hook_snippet = if options.generate_hooks {
let hooks_dir = options.base_dir.join("hooks");
let cursor_snippet_path = hooks_dir.join("cursor-hooks.snippet.json");
let omamori_exe = options.source_exe.clone();
atomic_write(
&cursor_snippet_path,
&render_cursor_hooks_snippet(&omamori_exe),
)?;
Some(cursor_snippet_path)
} else {
None
};
let claude_settings_outcome = match (options.generate_hooks, hook_script.as_ref()) {
(true, Some(script_path)) => {
let claude_dir = claude_home_dir();
if is_real_directory(&claude_dir) {
Some(
merge_claude_settings(&claude_dir, script_path)
.unwrap_or_else(|e| ClaudeSettingsOutcome::Skipped(format!("I/O: {e}"))),
)
} else {
Some(ClaudeSettingsOutcome::Skipped(
"Claude Code not detected (~/.claude not a directory)".into(),
))
}
}
_ => None,
};
let (codex_wrapper, codex_hooks_outcome, codex_config_outcome) = if options.generate_hooks {
setup_codex_hooks(&options.base_dir, &options.source_exe)
} else {
(None, None, None)
};
if let Err(e) = generate_install_baseline(&options.base_dir) {
eprintln!("omamori: warning — failed to generate integrity baseline: {e}");
}
Ok(InstallResult {
shim_dir,
linked_commands,
hook_script,
settings_snippet,
cursor_hook_snippet,
codex_wrapper,
codex_hooks_outcome,
codex_config_outcome,
claude_settings_outcome,
})
}
pub fn uninstall(base_dir: &Path) -> Result<UninstallResult, AppError> {
let shim_dir = base_dir.join("shim");
let hooks_dir = base_dir.join("hooks");
let mut removed_entries = Vec::new();
for command in SHIM_COMMANDS {
let link_path = shim_dir.join(command);
if link_path.exists() || link_path.is_symlink() {
fs::remove_file(&link_path)?;
removed_entries.push(link_path);
}
}
for path in [
hooks_dir.join("claude-pretooluse.sh"),
hooks_dir.join("claude-settings.snippet.json"),
hooks_dir.join("cursor-hooks.snippet.json"),
hooks_dir.join("codex-pretooluse.sh"),
hooks_dir.join("codex-hooks.snippet.json"),
] {
if path.exists() {
fs::remove_file(&path)?;
removed_entries.push(path);
}
}
if let Err(e) = remove_codex_hooks_entry() {
eprintln!("omamori: warning — failed to clean Codex hooks.json: {e}");
}
if let Err(e) = remove_claude_settings_entry(base_dir) {
eprintln!("omamori: warning — failed to clean Claude settings: {e}");
}
let integrity_path = base_dir.join(".integrity.json");
if integrity_path.exists() {
fs::remove_file(&integrity_path)?;
removed_entries.push(integrity_path);
}
remove_dir_if_empty(&hooks_dir)?;
remove_dir_if_empty(&shim_dir)?;
remove_dir_if_empty(base_dir)?;
Ok(UninstallResult {
shim_dir,
removed_entries,
})
}
pub fn default_base_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".omamori")
}
fn recreate_symlink(source: &Path, link_path: &Path) -> Result<(), AppError> {
if link_path.exists() || link_path.is_symlink() {
fs::remove_file(link_path)?;
}
#[cfg(unix)]
std::os::unix::fs::symlink(source, link_path)?;
#[cfg(not(unix))]
std::os::windows::fs::symlink_file(source, link_path)?;
Ok(())
}
fn remove_dir_if_empty(path: &Path) -> Result<(), AppError> {
if path.is_dir() && fs::read_dir(path)?.next().is_none() {
fs::remove_dir(path)?;
}
Ok(())
}
fn atomic_write(target: &Path, content: &str) -> Result<(), std::io::Error> {
let dir = target.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile_in(dir)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.into_path();
fs::rename(&tmp_path, target)?;
Ok(())
}
fn atomic_write_with_mode(target: &Path, content: &str, mode: u32) -> Result<(), std::io::Error> {
let dir = target.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile_in_with_mode(dir, mode)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.into_path();
fs::rename(&tmp_path, target)?;
Ok(())
}
fn tempfile_in(dir: &Path) -> Result<AtomicTempFile, std::io::Error> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!(".omamori-tmp-{}-{}", std::process::id(), seq));
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)?
};
#[cfg(not(unix))]
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(AtomicTempFile { file, path })
}
fn tempfile_in_with_mode(dir: &Path, mode: u32) -> Result<AtomicTempFile, std::io::Error> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!(".omamori-tmp-{}-{}", std::process::id(), seq));
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(mode)
.open(&path)?
};
#[cfg(not(unix))]
let _ = mode;
#[cfg(not(unix))]
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(AtomicTempFile { file, path })
}
struct AtomicTempFile {
file: fs::File,
path: PathBuf,
}
impl AtomicTempFile {
fn into_path(self) -> PathBuf {
self.path
}
}
impl Write for AtomicTempFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.file.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.file.flush()
}
}
pub fn hook_content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn parse_hook_version(content: &str) -> Option<&str> {
content
.lines()
.find(|line| line.starts_with("# omamori hook v"))
.and_then(|line| line.strip_prefix("# omamori hook v"))
}
pub fn regenerate_hooks(base_dir: &Path) -> Result<(), std::io::Error> {
let hooks_dir = base_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let script_path = hooks_dir.join("claude-pretooluse.sh");
atomic_write(&script_path, &render_hook_script())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms)?;
}
let snippet_path = hooks_dir.join("claude-settings.snippet.json");
atomic_write(&snippet_path, &render_settings_snippet(&script_path))?;
if let Ok(exe) = std::env::current_exe() {
let stable_exe = resolve_stable_exe_path(&exe);
let cursor_path = hooks_dir.join("cursor-hooks.snippet.json");
atomic_write(&cursor_path, &render_cursor_hooks_snippet(&stable_exe))?;
let codex_wrapper = hooks_dir.join("codex-pretooluse.sh");
if codex_wrapper.exists() {
atomic_write(&codex_wrapper, &render_codex_pretooluse_script(&stable_exe))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&codex_wrapper)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&codex_wrapper, perms)?;
}
let codex_dir = codex_home_dir();
if is_real_directory(&codex_dir) {
let _ = merge_codex_hooks(&codex_dir, &codex_wrapper);
}
}
} else {
eprintln!(
"omamori warning: failed to resolve current exe; cursor/codex hooks not regenerated"
);
}
Ok(())
}
pub fn render_hook_script() -> String {
format!(
r#"#!/bin/sh
# omamori hook v{version}
# Thin wrapper: delegates all detection to `omamori hook-check`
set -eu
cat | omamori hook-check --provider claude-code
exit $?
"#,
version = env!("CARGO_PKG_VERSION")
)
}
pub(crate) const PROTECTED_ENV_VARS: &[&str] = &[
"CLAUDECODE",
"CODEX_CI",
"CURSOR_AGENT",
"GEMINI_CLI",
"CLINE_ACTIVE",
"AI_GUARD",
];
pub fn blocked_string_patterns() -> Vec<(&'static str, &'static str)> {
vec![
("/bin/rm ", "blocked direct rm path that bypasses PATH shim"),
(
"/bin/rm\"",
"blocked direct rm path that bypasses PATH shim",
),
(
"/bin/rm\t",
"blocked direct rm path that bypasses PATH shim",
),
("/bin/rm'", "blocked direct rm path that bypasses PATH shim"),
(
"/usr/bin/rm ",
"blocked direct rm path that bypasses PATH shim",
),
(
"/usr/bin/rm\"",
"blocked direct rm path that bypasses PATH shim",
),
(
"/usr/bin/rm\t",
"blocked direct rm path that bypasses PATH shim",
),
(
"/usr/bin/rm'",
"blocked direct rm path that bypasses PATH shim",
),
(
".claude/settings.json",
"blocked attempt to edit Claude Code settings (contains hook config)",
),
("config disable", "blocked attempt to modify omamori rules"),
("config enable", "blocked attempt to modify omamori rules"),
("omamori uninstall", "blocked attempt to uninstall omamori"),
(
"omamori init --force",
"blocked attempt to overwrite omamori config",
),
(
"omamori override",
"blocked attempt to override omamori core rules",
),
(
"omamori doctor --fix",
"blocked attempt to run doctor --fix via AI",
),
(
"omamori explain",
"blocked attempt to run explain via AI (oracle attack prevention)",
),
(
".integrity.json",
"blocked attempt to edit integrity baseline",
),
(
".codex/hooks.json",
"blocked attempt to edit Codex hooks config",
),
(".codex/config.toml", "blocked attempt to edit Codex config"),
(
"config.toml.bak",
"blocked attempt to use Codex config backup",
),
(
"codex_hooks",
"blocked attempt to modify Codex hooks feature flag",
),
("audit.jsonl", "blocked attempt to modify audit log"),
("audit-secret", "blocked attempt to access audit secret"),
(
"omamori/config.toml",
"blocked attempt to edit omamori config",
),
(
".local/share/omamori",
"blocked attempt to modify omamori data directory",
),
]
}
fn cellar_to_stable_path(exe: &Path) -> Option<PathBuf> {
let s = exe.to_string_lossy();
let cellar_idx = s.find("/Cellar/")?;
let prefix = &s[..cellar_idx];
let bin_idx = s.rfind("/bin/")?;
let binary = &s[bin_idx + 5..];
(!binary.is_empty()).then(|| PathBuf::from(format!("{prefix}/bin/{binary}")))
}
pub(crate) fn resolve_stable_exe_path(exe: &Path) -> PathBuf {
if let Some(stable) = cellar_to_stable_path(exe) {
if stable.exists() {
return stable;
}
eprintln!(
"omamori warning: Cellar path detected but stable path {} does not exist; \
using versioned path (may break after brew upgrade)",
stable.display()
);
}
exe.to_path_buf()
}
pub(crate) fn render_cursor_hooks_snippet(omamori_exe: &Path) -> String {
let exe_str = omamori_exe.display().to_string();
let command = format!("{} cursor-hook", shell_words::quote(&exe_str));
let snippet = serde_json::json!({
"_comment": format!("Generated by omamori v{}. Merge into .cursor/hooks.json", env!("CARGO_PKG_VERSION")),
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": command }
]
}
});
serde_json::to_string_pretty(&snippet).unwrap() + "\n"
}
fn generate_install_baseline(base_dir: &Path) -> Result<(), crate::AppError> {
let baseline = crate::integrity::generate_baseline(base_dir)?;
crate::integrity::write_baseline(base_dir, &baseline)?;
Ok(())
}
pub(crate) fn claude_settings_entry(script_path: &Path) -> serde_json::Value {
let command = shell_words::quote(&script_path.display().to_string()).into_owned();
serde_json::json!({
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": command,
}],
"x-omamori-version": env!("CARGO_PKG_VERSION"),
})
}
fn render_settings_snippet(script_path: &Path) -> String {
let entry = claude_settings_entry(script_path);
let snippet = serde_json::json!({
"_comment": format!(
"Generated by omamori v{}. Auto-merged into ~/.claude/settings.json by `omamori install --hooks`.",
env!("CARGO_PKG_VERSION")
),
"hooks": {
"PreToolUse": [entry]
}
});
serde_json::to_string_pretty(&snippet).unwrap() + "\n"
}
pub(crate) fn claude_home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
}
pub(crate) fn merge_claude_settings(
claude_dir: &Path,
script_path: &Path,
) -> Result<ClaudeSettingsOutcome, std::io::Error> {
let settings_path = claude_dir.join("settings.json");
let entry = claude_settings_entry(script_path);
if !settings_path.exists() && !settings_path.is_symlink() {
let doc = serde_json::json!({
"hooks": { "PreToolUse": [entry] }
});
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
return Ok(ClaudeSettingsOutcome::Created);
}
if settings_path.is_symlink() || !is_real_file(&settings_path) {
return Ok(ClaudeSettingsOutcome::Skipped(
"settings.json is a symlink or not a regular file".into(),
));
}
let raw = fs::read_to_string(&settings_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
return Ok(ClaudeSettingsOutcome::Skipped(format!(
"JSON parse error: {e}"
)));
}
};
let arr = doc
.as_object_mut()
.and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
})
.and_then(|h| {
let pre = h
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
pre.as_array_mut()
});
let arr = match arr {
Some(a) => a,
None => {
return Ok(ClaudeSettingsOutcome::Skipped(
"hooks.PreToolUse is not an array".into(),
));
}
};
let install_root = script_path
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("/"));
let existing_idx = arr
.iter()
.position(|e| is_omamori_owned_entry(e, &install_root));
if let Some(idx) = existing_idx {
if arr[idx] == entry {
return Ok(ClaudeSettingsOutcome::AlreadyPresent);
}
let was_legacy_matcher = arr[idx]
.get("matcher")
.and_then(|m| m.as_str())
.map(is_legacy_matcher)
.unwrap_or(false);
arr[idx] = entry;
let outcome = if was_legacy_matcher {
ClaudeSettingsOutcome::MatcherMigrated
} else {
ClaudeSettingsOutcome::Merged
};
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
return Ok(outcome);
}
arr.push(entry);
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
Ok(ClaudeSettingsOutcome::Merged)
}
pub(crate) fn entry_is_omamori_managed(entry: &serde_json::Value, base_dir: &Path) -> bool {
let mut commands: Vec<&str> = Vec::new();
if let Some(arr) = entry.get("hooks").and_then(|v| v.as_array()) {
for h in arr {
if let Some(c) = h.get("command").and_then(|v| v.as_str()) {
commands.push(c);
}
}
}
if let Some(c) = entry.get("command").and_then(|v| v.as_str()) {
commands.push(c);
}
commands.iter().any(|c| {
let unquoted = c.trim_matches('\'').trim_matches('"');
Path::new(unquoted).starts_with(base_dir)
})
}
pub(crate) fn is_omamori_owned_entry(entry: &serde_json::Value, base_dir: &Path) -> bool {
if !entry_is_omamori_managed(entry, base_dir) {
return false;
}
let hooks_size = entry
.get("hooks")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let has_flat = entry.get("command").is_some();
if hooks_size > 1 {
return false; }
if has_flat && hooks_size > 0 {
return false; }
true
}
fn is_legacy_matcher(matcher: &str) -> bool {
matcher == "*" || matcher.contains("==") || matcher.contains("&&") || matcher.contains("||")
}
fn codex_home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".codex")
}
pub(crate) fn is_real_directory(path: &Path) -> bool {
path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false)
}
fn is_real_file(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.is_file())
.unwrap_or(false)
}
pub fn render_codex_pretooluse_script(omamori_exe: &Path) -> String {
let exe_str = omamori_exe.display().to_string();
let quoted = shell_words::quote(&exe_str);
format!(
r#"#!/bin/sh
# omamori hook v{version} — Codex CLI fail-close wrapper
# Codex: exit 0 = allow, exit 2 = block, exit 1 = allow (fail-open!)
# This wrapper maps all non-zero exits to exit 2 for fail-close safety.
set -u
cat | {exe} hook-check --provider codex
STATUS=$?
if [ "$STATUS" -eq 0 ]; then exit 0; else exit 2; fi
"#,
version = env!("CARGO_PKG_VERSION"),
exe = quoted,
)
}
fn codex_hooks_entry(wrapper_path: &Path) -> serde_json::Value {
let command = shell_words::quote(&wrapper_path.display().to_string()).into_owned();
serde_json::json!({
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": command,
"timeout": 30,
"statusMessage": CODEX_STATUS_MESSAGE
}]
})
}
pub(crate) fn merge_codex_hooks(
codex_dir: &Path,
wrapper_path: &Path,
) -> Result<CodexHooksOutcome, std::io::Error> {
let hooks_path = codex_dir.join("hooks.json");
let entry = codex_hooks_entry(wrapper_path);
if !hooks_path.exists() && !hooks_path.is_symlink() {
let doc = serde_json::json!({
"hooks": { "PreToolUse": [entry] }
});
atomic_write(&hooks_path, &serde_json::to_string_pretty(&doc).unwrap())?;
return Ok(CodexHooksOutcome::Created);
}
if hooks_path.is_symlink() || !is_real_file(&hooks_path) {
return Ok(CodexHooksOutcome::Skipped(
"hooks.json is a symlink or not a regular file".into(),
));
}
let raw = fs::read_to_string(&hooks_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => return Ok(CodexHooksOutcome::Skipped(format!("JSON parse error: {e}"))),
};
let arr = doc
.as_object_mut()
.and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
})
.and_then(|h| {
let pre = h
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
pre.as_array_mut()
});
let arr = match arr {
Some(a) => a,
None => {
return Ok(CodexHooksOutcome::Skipped(
"hooks.PreToolUse is not an array".into(),
));
}
};
let existing_idx = arr.iter().position(|e| {
e.pointer("/hooks/0/statusMessage").and_then(|v| v.as_str()) == Some(CODEX_STATUS_MESSAGE)
});
if let Some(idx) = existing_idx {
if arr[idx] == entry {
return Ok(CodexHooksOutcome::AlreadyPresent);
}
arr[idx] = entry;
} else {
arr.push(entry);
}
atomic_write(&hooks_path, &serde_json::to_string_pretty(&doc).unwrap())?;
Ok(CodexHooksOutcome::Merged)
}
fn remove_claude_settings_entry(base_dir: &Path) -> Result<(), std::io::Error> {
let settings_path = claude_home_dir().join("settings.json");
if !settings_path.exists() {
return Ok(());
}
if settings_path.is_symlink() || !is_real_file(&settings_path) {
return Ok(());
}
let raw = fs::read_to_string(&settings_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return Ok(()),
};
let mut modified = false;
if let Some(arr) = doc
.pointer_mut("/hooks/PreToolUse")
.and_then(|v| v.as_array_mut())
{
let before = arr.len();
arr.retain(|e| !is_omamori_owned_entry(e, base_dir));
if arr.len() != before {
modified = true;
}
let omamori_script_path = base_dir.join("hooks").join("claude-pretooluse.sh");
for entry in arr.iter_mut() {
if !entry_is_omamori_managed(entry, base_dir) {
continue;
}
if let Some(hooks_arr) = entry.get_mut("hooks").and_then(|v| v.as_array_mut()) {
let h_before = hooks_arr.len();
hooks_arr.retain(|h| {
let cmd = h.get("command").and_then(|v| v.as_str());
let is_canonical_omamori = cmd
.map(|c| {
let unquoted = c.trim_matches('\'').trim_matches('"');
Path::new(unquoted) == omamori_script_path
})
.unwrap_or(false);
!is_canonical_omamori
});
if hooks_arr.len() != h_before {
modified = true;
}
}
}
}
if modified {
atomic_write_with_mode(
&settings_path,
&serde_json::to_string_pretty(&doc).unwrap(),
0o600,
)?;
}
Ok(())
}
fn remove_codex_hooks_entry() -> Result<(), std::io::Error> {
let hooks_path = codex_home_dir().join("hooks.json");
if !hooks_path.exists() {
return Ok(());
}
let raw = fs::read_to_string(&hooks_path)?;
let mut doc: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return Ok(()), };
let modified = doc
.pointer_mut("/hooks/PreToolUse")
.and_then(|v| v.as_array_mut())
.map(|arr| {
let before = arr.len();
arr.retain(|e| {
e.pointer("/hooks/0/statusMessage").and_then(|v| v.as_str())
!= Some(CODEX_STATUS_MESSAGE)
});
arr.len() != before
})
.unwrap_or(false);
if modified {
atomic_write(&hooks_path, &serde_json::to_string_pretty(&doc).unwrap())?;
}
Ok(())
}
pub(crate) fn update_codex_config(codex_dir: &Path) -> Result<CodexConfigOutcome, std::io::Error> {
let config_path = codex_dir.join("config.toml");
if !config_path.exists() {
return Ok(CodexConfigOutcome::Skipped("config.toml not found".into()));
}
if config_path.is_symlink() || !is_real_file(&config_path) {
return Ok(CodexConfigOutcome::Skipped(
"config.toml is a symlink or not a regular file".into(),
));
}
let raw = fs::read_to_string(&config_path)?;
let mut doc: toml_edit::DocumentMut = raw.parse().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("TOML parse: {e}"))
})?;
if let Some(features) = doc.get("features") {
if !features.is_table() && !features.is_table_like() {
return Ok(CodexConfigOutcome::Skipped(
"features is not a table".into(),
));
}
if let Some(item) = features.get("codex_hooks") {
if item.as_bool() == Some(true) {
return Ok(CodexConfigOutcome::AlreadyEnabled);
}
if item.as_bool() == Some(false) {
return Ok(CodexConfigOutcome::ExplicitlyDisabled);
}
}
}
let backup_path = codex_dir.join("config.toml.bak");
fs::copy(&config_path, &backup_path)?;
doc["features"]["codex_hooks"] = toml_edit::value(true);
atomic_write(&config_path, &doc.to_string())?;
Ok(CodexConfigOutcome::Added)
}
fn setup_codex_hooks(
base_dir: &Path,
source_exe: &Path,
) -> (
Option<PathBuf>,
Option<CodexHooksOutcome>,
Option<CodexConfigOutcome>,
) {
let codex_dir = codex_home_dir();
if !is_real_directory(&codex_dir) {
return (None, None, None); }
let hooks_dir = base_dir.join("hooks");
let wrapper_path = hooks_dir.join("codex-pretooluse.sh");
if let Err(e) = atomic_write(&wrapper_path, &render_codex_pretooluse_script(source_exe)) {
eprintln!("omamori: warning — Codex wrapper: {e}");
return (None, None, None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(&wrapper_path).map(|m| m.permissions()) {
perms.set_mode(0o755);
let _ = fs::set_permissions(&wrapper_path, perms);
}
}
let hooks_outcome = merge_codex_hooks(&codex_dir, &wrapper_path)
.unwrap_or_else(|e| CodexHooksOutcome::Skipped(format!("I/O: {e}")));
if matches!(hooks_outcome, CodexHooksOutcome::Skipped(_)) {
let snippet_path = hooks_dir.join("codex-hooks.snippet.json");
let _ = atomic_write(&snippet_path, &render_codex_hooks_snippet(&wrapper_path));
}
let config_outcome = match update_codex_config(&codex_dir) {
Ok(outcome) => outcome,
Err(e) => CodexConfigOutcome::Skipped(format!("I/O: {e}")),
};
(
Some(wrapper_path),
Some(hooks_outcome),
Some(config_outcome),
)
}
pub fn auto_setup_codex_if_needed(base_dir: &Path) -> bool {
if std::env::var_os("CODEX_CI").is_none() {
return false;
}
let wrapper_path = base_dir.join("hooks/codex-pretooluse.sh");
if wrapper_path.exists() {
return false; }
let source_exe = match std::env::current_exe() {
Ok(exe) => resolve_stable_exe_path(&exe),
Err(_) => return false,
};
let codex_dir = codex_home_dir();
if !is_real_directory(&codex_dir) {
return false;
}
eprintln!("omamori: Codex CLI detected — auto-configuring hooks");
let hooks_dir = base_dir.join("hooks");
if fs::create_dir_all(&hooks_dir).is_err() {
eprintln!("omamori: warning — could not create hooks directory");
return false;
}
let (wrapper, hooks_out, config_out) = setup_codex_hooks(base_dir, &source_exe);
if let Some(ref path) = wrapper {
eprintln!("omamori: [done] {} (created)", path.display());
}
match hooks_out {
Some(CodexHooksOutcome::Created | CodexHooksOutcome::Merged) => {
eprintln!("omamori: [done] ~/.codex/hooks.json (merged)");
}
Some(CodexHooksOutcome::Skipped(ref reason)) => {
eprintln!("omamori: [warn] ~/.codex/hooks.json — {reason}");
}
_ => {}
}
match config_out {
Some(CodexConfigOutcome::Added) => {
eprintln!("omamori: [done] ~/.codex/config.toml (codex_hooks = true)");
}
Some(CodexConfigOutcome::ExplicitlyDisabled) => {
eprintln!(
"omamori: [warn] ~/.codex/config.toml: codex_hooks = false (set by user, not changed)"
);
eprintln!("omamori: hooks will NOT activate until you set codex_hooks = true");
}
Some(CodexConfigOutcome::Skipped(ref reason)) => {
eprintln!("omamori: [warn] ~/.codex/config.toml — {reason}");
}
_ => {}
}
wrapper.is_some()
}
pub(crate) fn render_codex_hooks_snippet(wrapper_path: &Path) -> String {
let doc = serde_json::json!({
"_comment": format!(
"Generated by omamori v{}. Merge PreToolUse entry into ~/.codex/hooks.json",
env!("CARGO_PKG_VERSION")
),
"hooks": { "PreToolUse": [codex_hooks_entry(wrapper_path)] }
});
serde_json::to_string_pretty(&doc).unwrap() + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_creates_shims_and_hook_templates() {
let root = std::env::temp_dir().join(format!("omamori-install-{}", std::process::id()));
let source = root.join("omamori");
fs::create_dir_all(&root).unwrap();
fs::write(&source, "binary").unwrap();
let result = install(&InstallOptions {
base_dir: root.clone(),
source_exe: source.clone(),
generate_hooks: true,
})
.unwrap();
assert!(result.shim_dir.join("rm").exists());
assert!(result.hook_script.unwrap().exists());
assert!(result.settings_snippet.unwrap().exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn hook_script_is_thin_wrapper() {
let script = render_hook_script();
assert!(
script.contains("omamori hook-check"),
"hook script should delegate to omamori hook-check"
);
assert!(
!script.contains("case \"$INPUT\""),
"hook script should not contain case statements (now a thin wrapper)"
);
}
#[test]
fn cursor_snippet_quotes_path_with_spaces() {
let snippet = render_cursor_hooks_snippet(Path::new("/Users/my user/bin/omamori"));
let v: serde_json::Value = serde_json::from_str(snippet.trim()).unwrap();
let cmd = v["hooks"]["beforeShellExecution"][0]["command"]
.as_str()
.unwrap();
let words = shell_words::split(cmd).unwrap();
assert_eq!(words[0], "/Users/my user/bin/omamori");
assert_eq!(words[1], "cursor-hook");
}
#[test]
fn codex_pretooluse_script_quotes_path_with_spaces() {
let script = render_codex_pretooluse_script(Path::new("/Users/my user/bin/omamori"));
assert!(script.contains("hook-check --provider codex"));
assert!(!script.contains("\"\""));
}
#[test]
fn settings_snippet_escapes_path() {
let path = std::path::Path::new(r#"/tmp/test "path"/hook.sh"#);
let snippet = render_settings_snippet(path);
assert!(snippet.contains(r#"\"path\""#));
assert!(!snippet.contains(r#"" "path""#));
}
#[test]
fn protected_env_vars_constant_covers_all_detectors() {
for var in &[
"CLAUDECODE",
"CODEX_CI",
"CURSOR_AGENT",
"GEMINI_CLI",
"CLINE_ACTIVE",
"AI_GUARD",
] {
assert!(
PROTECTED_ENV_VARS.contains(var),
"PROTECTED_ENV_VARS should include {var}"
);
}
}
#[test]
fn hook_script_contains_version_comment() {
let script = render_hook_script();
let version = env!("CARGO_PKG_VERSION");
assert!(
script.contains(&format!("# omamori hook v{version}")),
"hook script should contain version comment"
);
}
#[test]
fn parse_hook_version_extracts_version() {
let script = render_hook_script();
let version = parse_hook_version(&script);
assert_eq!(version, Some(env!("CARGO_PKG_VERSION")));
}
#[test]
fn parse_hook_version_returns_none_for_old_hooks() {
let old_script = "#!/bin/sh\nset -eu\nINPUT=\"$(cat)\"\n";
assert_eq!(parse_hook_version(old_script), None);
}
#[test]
fn parse_hook_version_returns_none_for_empty() {
assert_eq!(parse_hook_version(""), None);
}
#[test]
fn regenerate_hooks_creates_files() {
let root = std::env::temp_dir().join(format!("omamori-regen-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
regenerate_hooks(&root).unwrap();
let hook_path = root.join("hooks/claude-pretooluse.sh");
assert!(hook_path.exists(), "hook script should be created");
let content = fs::read_to_string(&hook_path).unwrap();
assert_eq!(
parse_hook_version(&content),
Some(env!("CARGO_PKG_VERSION"))
);
let snippet_path = root.join("hooks/claude-settings.snippet.json");
assert!(snippet_path.exists(), "settings snippet should be created");
let _ = fs::remove_dir_all(root);
}
#[test]
fn atomic_write_creates_file() {
let dir = std::env::temp_dir().join(format!("omamori-atomic-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let target = dir.join("test.txt");
atomic_write(&target, "hello world").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello world");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn tempfile_in_generates_unique_paths() {
let dir = std::env::temp_dir().join(format!("omamori-tmpuniq-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let tmp1 = tempfile_in(&dir).unwrap();
let tmp2 = tempfile_in(&dir).unwrap();
assert_ne!(
tmp1.path, tmp2.path,
"sequential tempfile_in must produce different paths"
);
let _ = fs::remove_file(&tmp1.path);
let _ = fs::remove_file(&tmp2.path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn tempfile_in_uses_exclusive_creation() {
let dir = std::env::temp_dir().join(format!("omamori-tmpexcl-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let tmp = tempfile_in(&dir).unwrap();
let path = tmp.path.clone();
drop(tmp);
let tmp2 = tempfile_in(&dir).unwrap();
assert_ne!(path, tmp2.path);
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&tmp2.path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn hook_content_hash_is_deterministic() {
let content = "#!/bin/sh\necho hello\n";
let hash1 = hook_content_hash(content);
let hash2 = hook_content_hash(content);
assert_eq!(hash1, hash2, "same content should produce same hash");
}
#[test]
fn hook_content_hash_differs_for_different_content() {
let hash1 = hook_content_hash("exit 2");
let hash2 = hook_content_hash("exit 0");
assert_ne!(
hash1, hash2,
"different content should produce different hash"
);
}
#[test]
fn render_hook_script_produces_stable_hash() {
let script1 = render_hook_script();
let script2 = render_hook_script();
let hash1 = hook_content_hash(&script1);
let hash2 = hook_content_hash(&script2);
assert_eq!(hash1, hash2, "render_hook_script() should be deterministic");
}
#[test]
fn t2_attack_version_preserved_content_changed_hash_differs() {
let original = render_hook_script();
let original_hash = hook_content_hash(&original);
let tampered = original.replace("omamori hook-check", "true");
let tampered_hash = hook_content_hash(&tampered);
assert_ne!(
original_hash, tampered_hash,
"T2 attack (hook-check → true) should be detected by hash mismatch"
);
assert_eq!(
parse_hook_version(&tampered),
parse_hook_version(&original),
"T2 attack preserves version comment"
);
}
#[test]
fn hook_content_hash_returns_hex_string() {
let hash = hook_content_hash("test");
assert_eq!(hash.len(), 64, "SHA-256 hex string should be 64 chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash should contain only hex characters"
);
}
#[test]
fn cellar_to_stable_apple_silicon() {
let p = Path::new("/opt/homebrew/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/opt/homebrew/bin/omamori")
);
}
#[test]
fn cellar_to_stable_intel() {
let p = Path::new("/usr/local/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/usr/local/bin/omamori")
);
}
#[test]
fn cellar_to_stable_linuxbrew() {
let p = Path::new("/home/linuxbrew/.linuxbrew/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/omamori")
);
}
#[test]
fn cellar_to_stable_cargo_install_returns_none() {
assert!(cellar_to_stable_path(Path::new("/Users/dev/.cargo/bin/omamori")).is_none());
}
#[test]
fn cellar_to_stable_manual_copy_returns_none() {
assert!(cellar_to_stable_path(Path::new("/usr/local/bin/omamori")).is_none());
}
#[test]
fn cellar_to_stable_formula_name_irrelevant() {
let p = Path::new("/opt/homebrew/Cellar/some-other-formula/1.0/bin/omamori");
assert_eq!(
cellar_to_stable_path(p).unwrap(),
PathBuf::from("/opt/homebrew/bin/omamori")
);
}
#[test]
fn cellar_to_stable_relative_path_returns_none() {
assert!(cellar_to_stable_path(Path::new("Cellar/omamori/0.6.0/bin/omamori")).is_none());
}
#[test]
fn cellar_to_stable_empty_path_returns_none() {
assert!(cellar_to_stable_path(Path::new("")).is_none());
}
#[test]
fn cellar_to_stable_incomplete_cellar_returns_none() {
assert!(cellar_to_stable_path(Path::new("/opt/homebrew/Cellar/omamori/0.6.0/")).is_none());
}
#[test]
fn resolve_stable_uses_cellar_path_when_stable_missing() {
let cellar = PathBuf::from("/nonexistent/Cellar/omamori/0.6.0/bin/omamori");
assert_eq!(resolve_stable_exe_path(&cellar), cellar);
}
#[test]
fn resolve_stable_passes_through_non_cellar() {
let cargo = PathBuf::from("/Users/dev/.cargo/bin/omamori");
assert_eq!(resolve_stable_exe_path(&cargo), cargo);
}
#[test]
fn resolve_stable_returns_stable_when_exists() {
let dir = std::env::temp_dir().join(format!("omamori-stable-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let bin_dir = dir.join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("omamori"), "binary").unwrap();
let cellar = dir.join("Cellar/omamori/0.6.0/bin/omamori");
let expected = bin_dir.join("omamori");
assert_eq!(resolve_stable_exe_path(&cellar), expected);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn meta_patterns_block_omamori_override() {
let patterns = blocked_string_patterns();
assert!(
patterns.iter().any(|(p, _)| p.contains("omamori override")),
"meta-patterns should block 'omamori override'"
);
}
#[test]
fn codex_pretooluse_script_contains_fail_close_logic() {
let script = render_codex_pretooluse_script(Path::new("/usr/local/bin/omamori"));
assert!(script.contains("exit 2"), "must map non-zero to exit 2");
assert!(script.contains("hook-check --provider codex"));
assert!(script.contains("set -u"));
assert!(script.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))));
}
#[test]
fn codex_hooks_entry_has_status_message() {
let entry = codex_hooks_entry(Path::new("/path/to/wrapper.sh"));
let msg = entry
.pointer("/hooks/0/statusMessage")
.and_then(|v| v.as_str());
assert_eq!(msg, Some(CODEX_STATUS_MESSAGE));
}
#[test]
fn merge_codex_hooks_creates_new_file() {
let dir = std::env::temp_dir().join(format!("omamori-codex-new-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let result = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(result, CodexHooksOutcome::Created));
let content = fs::read_to_string(dir.join("hooks.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
doc.pointer("/hooks/PreToolUse/0/hooks/0/statusMessage")
.is_some()
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_codex_hooks_preserves_existing_entries() {
let dir = std::env::temp_dir().join(format!("omamori-codex-merge-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let existing = serde_json::json!({
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "/tmp/test.sh"}]}],
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "/other/tool"}]}]
}
});
fs::write(
dir.join("hooks.json"),
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let result = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(result, CodexHooksOutcome::Merged));
let content = fs::read_to_string(dir.join("hooks.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(doc.pointer("/hooks/UserPromptSubmit/0").is_some());
let arr = doc
.pointer("/hooks/PreToolUse")
.unwrap()
.as_array()
.unwrap();
assert_eq!(arr.len(), 2, "should have original + omamori entry");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_codex_hooks_is_idempotent() {
let dir = std::env::temp_dir().join(format!("omamori-codex-idem-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let r1 = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(r1, CodexHooksOutcome::Created));
let r2 = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(r2, CodexHooksOutcome::AlreadyPresent));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_codex_hooks_skips_invalid_json() {
let dir = std::env::temp_dir().join(format!("omamori-codex-bad-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("hooks.json"), "{ not valid json }}}").unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
let result = merge_codex_hooks(&dir, &wrapper).unwrap();
assert!(matches!(result, CodexHooksOutcome::Skipped(_)));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn remove_codex_hooks_entry_cleans_up() {
let dir = std::env::temp_dir().join(format!("omamori-codex-rm-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("wrapper.sh");
fs::write(&wrapper, "#!/bin/sh").unwrap();
merge_codex_hooks(&dir, &wrapper).unwrap();
let content = fs::read_to_string(dir.join("hooks.json")).unwrap();
assert!(content.contains(CODEX_STATUS_MESSAGE));
let raw = fs::read_to_string(dir.join("hooks.json")).unwrap();
let mut doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
if let Some(arr) = doc
.pointer_mut("/hooks/PreToolUse")
.and_then(|v| v.as_array_mut())
{
arr.retain(|e| {
e.pointer("/hooks/0/statusMessage").and_then(|v| v.as_str())
!= Some(CODEX_STATUS_MESSAGE)
});
}
fs::write(
dir.join("hooks.json"),
serde_json::to_string_pretty(&doc).unwrap(),
)
.unwrap();
let cleaned = fs::read_to_string(dir.join("hooks.json")).unwrap();
assert!(!cleaned.contains(CODEX_STATUS_MESSAGE));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn is_real_directory_rejects_symlinks() {
let dir = std::env::temp_dir().join(format!("omamori-symdir-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let real = dir.join("real");
let link = dir.join("link");
fs::create_dir_all(&real).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(is_real_directory(&real));
#[cfg(unix)]
assert!(!is_real_directory(&link));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_skips_non_table_features() {
let dir = std::env::temp_dir().join(format!("omamori-toml-bad-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "features = \"oops\"\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(
matches!(result, CodexConfigOutcome::Skipped(ref s) if s.contains("not a table")),
"should skip when features is not a table, got: {result:?}"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_adds_feature_flag() {
let dir = std::env::temp_dir().join(format!("omamori-toml-add-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "model = \"gpt-5.3-codex\"\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(matches!(result, CodexConfigOutcome::Added));
let content = fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(content.contains("codex_hooks = true"));
assert!(dir.join("config.toml.bak").exists());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_idempotent() {
let dir = std::env::temp_dir().join(format!("omamori-toml-idem-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "[features]\ncodex_hooks = true\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(matches!(result, CodexConfigOutcome::AlreadyEnabled));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn update_codex_config_respects_explicit_false() {
let dir = std::env::temp_dir().join(format!("omamori-toml-false-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("config.toml"), "[features]\ncodex_hooks = false\n").unwrap();
let result = update_codex_config(&dir).unwrap();
assert!(matches!(result, CodexConfigOutcome::ExplicitlyDisabled));
let content = fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(content.contains("codex_hooks = false"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn blocked_string_patterns_include_omamori_override() {
let patterns = blocked_string_patterns();
assert!(
patterns.iter().any(|(p, _)| *p == "omamori override"),
"blocked_string_patterns should include 'omamori override'"
);
}
#[test]
#[serial_test::serial]
fn auto_setup_codex_skips_without_env() {
let saved = std::env::var_os("CODEX_CI");
unsafe { std::env::remove_var("CODEX_CI") };
let dir = std::env::temp_dir().join(format!("omamori-codex-g12-1-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let result = auto_setup_codex_if_needed(&dir);
assert!(!result, "should skip when CODEX_CI is not set");
let _ = fs::remove_dir_all(&dir);
if let Some(v) = saved {
unsafe { std::env::set_var("CODEX_CI", v) };
}
}
#[test]
#[serial_test::serial]
fn auto_setup_codex_skips_when_wrapper_exists() {
let saved = std::env::var_os("CODEX_CI");
unsafe { std::env::remove_var("CODEX_CI") };
let dir = std::env::temp_dir().join(format!("omamori-codex-g12-2-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let hooks_dir = dir.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
fs::write(hooks_dir.join("codex-pretooluse.sh"), "#!/bin/sh\n").unwrap();
let result = auto_setup_codex_if_needed(&dir);
assert!(!result);
let _ = fs::remove_dir_all(&dir);
if let Some(v) = saved {
unsafe { std::env::set_var("CODEX_CI", v) };
}
}
fn fresh_test_dir(tag: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("omamori-claude-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn fake_script(dir: &Path) -> std::path::PathBuf {
let omamori_root = dir.join(".omamori");
let hooks_dir = omamori_root.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let script = hooks_dir.join("claude-pretooluse.sh");
fs::write(&script, "#!/bin/sh\n# omamori hook v\nexit 0\n").unwrap();
script
}
fn with_test_home<R>(home: &Path, f: impl FnOnce() -> R) -> R {
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", home) };
let result = f();
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
result
}
#[test]
#[serial_test::serial]
fn merge_claude_creates_when_missing() {
let dir = fresh_test_dir("v001");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Created));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse/0/matcher")
.and_then(|v| v.as_str()),
Some("Bash")
);
assert_eq!(
doc.pointer("/hooks/PreToolUse/0/x-omamori-version")
.and_then(|v| v.as_str()),
Some(env!("CARGO_PKG_VERSION"))
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_preserves_user_hooks() {
let dir = fresh_test_dir("v002");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let user_doc = serde_json::json!({
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "/usr/local/bin/userhook"}]}],
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{"type": "command", "command": "/usr/local/bin/another"}]
}]
},
"theme": "dark"
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&user_doc).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Merged));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
.and_then(|v| v.as_str()),
Some("/usr/local/bin/userhook")
);
let pre = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(pre.len(), 2, "user entry + omamori entry");
assert_eq!(doc.get("theme").and_then(|v| v.as_str()), Some("dark"));
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_is_idempotent() {
let dir = fresh_test_dir("v003");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let r1 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(r1, ClaudeSettingsOutcome::Created));
let r2 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(r2, ClaudeSettingsOutcome::AlreadyPresent));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(1)
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_migrates_legacy_boolean_matcher() {
let dir = fresh_test_dir("v004");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let stale = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "tool == \"Bash\"",
"hooks": [{"type": "command", "command": omamori_cmd.clone()}]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&stale).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::MatcherMigrated));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse/0/matcher")
.and_then(|v| v.as_str()),
Some("Bash"),
"legacy boolean matcher must migrate to simple Bash"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_migrates_wildcard_matcher() {
let dir = fresh_test_dir("v005");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let stale = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "*",
"command": script.display().to_string()
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&stale).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::MatcherMigrated));
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
#[cfg(unix)]
fn merge_claude_writes_with_mode_0o600() {
use std::os::unix::fs::PermissionsExt;
let dir = fresh_test_dir("v006");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let _ = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
let mode = fs::metadata(claude_dir.join("settings.json"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"SEC-3: settings.json must be written with mode 0o600"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_skips_corrupted_json() {
let dir = fresh_test_dir("v007");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("settings.json"), "{ not valid }}}").unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Skipped(_)));
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
assert_eq!(raw, "{ not valid }}}", "must not overwrite on parse error");
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_handles_large_settings_file() {
let dir = fresh_test_dir("v008");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let mut user_entries = Vec::new();
for i in 0..500 {
user_entries.push(serde_json::json!({
"matcher": format!("Tool{i}"),
"hooks": [{"type": "command", "command": format!("/path/{i}")}]
}));
}
let user_doc = serde_json::json!({
"hooks": { "PreToolUse": user_entries }
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&user_doc).unwrap(),
)
.unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Merged));
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(501)
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_handles_empty_file() {
let dir = fresh_test_dir("v009");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("settings.json"), "").unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Skipped(_)));
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
#[cfg(unix)]
fn merge_claude_skips_symlink() {
let dir = fresh_test_dir("v011");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let real = dir.join("real-settings.json");
fs::write(&real, "{}").unwrap();
std::os::unix::fs::symlink(&real, claude_dir.join("settings.json")).unwrap();
let result = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(result, ClaudeSettingsOutcome::Skipped(_)));
assert_eq!(fs::read_to_string(&real).unwrap(), "{}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn is_legacy_matcher_classifies_correctly() {
assert!(is_legacy_matcher("*"));
assert!(is_legacy_matcher("tool == \"Bash\""));
assert!(is_legacy_matcher("tool == \"Bash\" && tool == \"Edit\""));
assert!(is_legacy_matcher("tool == \"Bash\" || tool == \"Edit\""));
assert!(!is_legacy_matcher("Bash"));
assert!(!is_legacy_matcher("Edit"));
assert!(!is_legacy_matcher("Read"));
}
#[test]
fn claude_settings_entry_uses_current_spec() {
let entry = claude_settings_entry(Path::new("/usr/local/.omamori/hooks/x.sh"));
assert_eq!(
entry.get("matcher").and_then(|v| v.as_str()),
Some("Bash"),
"matcher must be simple string"
);
assert!(
entry.pointer("/hooks/0/type").is_some(),
"must use nested hooks array with type field"
);
assert_eq!(
entry.get("x-omamori-version").and_then(|v| v.as_str()),
Some(env!("CARGO_PKG_VERSION")),
"must embed omamori version"
);
}
#[test]
fn entry_is_omamori_managed_rejects_lookalike_dirs() {
let base = Path::new("/home/u/.omamori");
let entry_bak = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/home/u/.omamori-bak/hooks/x.sh" }]
});
let entry_real = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/home/u/.omamori/hooks/x.sh" }]
});
assert!(!entry_is_omamori_managed(&entry_bak, base));
assert!(entry_is_omamori_managed(&entry_real, base));
}
#[test]
fn entry_is_omamori_managed_walks_full_hooks_array() {
let base = Path::new("/opt/omamori");
let entry_at_idx1 = serde_json::json!({
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/usr/local/bin/userhook" },
{ "type": "command", "command": "/opt/omamori/hooks/script.sh" }
]
});
assert!(entry_is_omamori_managed(&entry_at_idx1, base));
}
#[test]
#[serial_test::serial]
fn merge_claude_handles_custom_base_dir() {
let dir = fresh_test_dir("p2-base");
let custom_base = dir.join("custom-omamori");
let hooks_dir = custom_base.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let script = hooks_dir.join("claude-pretooluse.sh");
fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let r1 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(matches!(r1, ClaudeSettingsOutcome::Created));
let r2 = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
assert!(
matches!(r2, ClaudeSettingsOutcome::AlreadyPresent),
"custom-base-dir managed entry must be recognised"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_does_not_touch_user_hook_inside_omamori_dir() {
let dir = fresh_test_dir("r4-user-in-omamori");
let script = fake_script(&dir);
let user_inside = dir.join(".omamori").join("hooks").join("user-hook.sh");
fs::write(&user_inside, "#!/bin/sh\nexit 0\n").unwrap();
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let user_cmd = shell_words::quote(&user_inside.display().to_string()).into_owned();
let hybrid = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": user_cmd.clone()},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid).unwrap(),
)
.unwrap();
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1);
let inner = arr[0].pointer("/hooks").and_then(|v| v.as_array()).unwrap();
assert_eq!(
inner.len(),
1,
"user hook stored inside omamori base dir must survive"
);
let surviving = inner[0].get("command").and_then(|c| c.as_str()).unwrap();
assert!(
surviving.contains("user-hook.sh"),
"the surviving hook must be the user's, not the omamori canonical: {surviving}"
);
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_surgically_removes_omamori_from_hybrid() {
let dir = fresh_test_dir("r3-hybrid-surgical");
let script = fake_script(&dir);
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let hybrid = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "/usr/local/bin/userhook"},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid).unwrap(),
)
.unwrap();
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1, "hybrid entry must survive uninstall");
let inner = arr[0].pointer("/hooks").and_then(|v| v.as_array()).unwrap();
assert_eq!(inner.len(), 1, "only user hook should remain");
assert_eq!(
inner[0].get("command").and_then(|c| c.as_str()),
Some("/usr/local/bin/userhook"),
"user hook preserved"
);
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn merge_claude_does_not_replace_hybrid_entry() {
let dir = fresh_test_dir("r2-hybrid-merge");
let script = fake_script(&dir);
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let hybrid = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "/usr/local/bin/userhook"},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid).unwrap(),
)
.unwrap();
let _ = with_test_home(&dir, || {
merge_claude_settings(&claude_dir, &script).unwrap()
});
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(
arr.len(),
2,
"hybrid entry must be preserved + canonical pushed"
);
let hybrid_entry = arr
.iter()
.find(|e| {
e.pointer("/hooks")
.and_then(|v| v.as_array())
.map(|a| a.len() == 2)
.unwrap_or(false)
})
.expect("hybrid entry must still have 2 inner hooks");
let inner = hybrid_entry
.pointer("/hooks")
.and_then(|v| v.as_array())
.unwrap();
assert!(
inner
.iter()
.any(|h| h.get("command").and_then(|c| c.as_str())
== Some("/usr/local/bin/userhook")),
"user-managed sibling hook must survive"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_settings_entry_preserves_hybrid_entry() {
let dir = fresh_test_dir("r2-hybrid-uninstall");
let script = fake_script(&dir);
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let omamori_cmd = shell_words::quote(&script.display().to_string()).into_owned();
let hybrid_only = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "/usr/local/bin/userhook"},
{"type": "command", "command": omamori_cmd}
]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&hybrid_only).unwrap(),
)
.unwrap();
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1, "hybrid entry must not be deleted");
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn remove_claude_settings_entry_preserves_user_hooks() {
let dir = fresh_test_dir("p1-uninstall");
let script = fake_script(&dir);
let saved = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
let claude_dir = dir.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let user_doc = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "/usr/local/bin/userhook" }]
}]
}
});
fs::write(
claude_dir.join("settings.json"),
serde_json::to_string_pretty(&user_doc).unwrap(),
)
.unwrap();
merge_claude_settings(&claude_dir, &script).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
doc.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(2)
);
let omamori_root = dir.join(".omamori");
remove_claude_settings_entry(&omamori_root).unwrap();
let raw = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let arr = doc
.pointer("/hooks/PreToolUse")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(arr.len(), 1, "user entry preserved, omamori removed");
assert_eq!(
arr[0].pointer("/hooks/0/command").and_then(|v| v.as_str()),
Some("/usr/local/bin/userhook")
);
match saved {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(dir);
}
}