mod binary;
mod hook_file;
use std::path::PathBuf;
use anyhow::{Result, bail};
use openmoat_hosts::Host;
pub use binary::{hook_binary, stale_hint};
pub use hook_file::{HookState, Outcome, read_or_empty};
use crate::home::user_home;
#[derive(Debug, Clone, Copy)]
pub struct HookSpec {
pub event: &'static str,
pub matcher: &'static str,
pub timeout: u64,
}
const TOOL_HOOK_TIMEOUT_S: u64 = 600;
const CONFIG_HOOK_TIMEOUT_S: u64 = 60;
const TOOL_MATCHER: &str = "Bash|Monitor|PowerShell|Edit|Write|MultiEdit|NotebookEdit|Read|Glob|Grep|LSP|SendFile|WebFetch|mcp__.*";
const CLAUDE_CODE_HOOKS: &[HookSpec] = &[
HookSpec {
event: "PreToolUse",
matcher: TOOL_MATCHER,
timeout: TOOL_HOOK_TIMEOUT_S,
},
HookSpec {
event: "ConfigChange",
matcher: "user_settings|project_settings|local_settings",
timeout: CONFIG_HOOK_TIMEOUT_S,
},
];
const CODEX_HOOKS: &[HookSpec] = &[HookSpec {
event: "PreToolUse",
matcher: "Bash|apply_patch|mcp__.*",
timeout: TOOL_HOOK_TIMEOUT_S,
}];
const CURSOR_HOOKS: &[HookSpec] = &[
HookSpec {
event: "beforeShellExecution",
matcher: "",
timeout: TOOL_HOOK_TIMEOUT_S,
},
HookSpec {
event: "beforeMCPExecution",
matcher: "",
timeout: TOOL_HOOK_TIMEOUT_S,
},
HookSpec {
event: "beforeReadFile",
matcher: "",
timeout: TOOL_HOOK_TIMEOUT_S,
},
HookSpec {
event: "preToolUse",
matcher: "",
timeout: TOOL_HOOK_TIMEOUT_S,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookFormat {
Nested,
Cursor,
}
#[derive(Debug, Clone)]
pub struct HostConfig {
pub host: Host,
pub settings_path: PathBuf,
pub hooks: &'static [HookSpec],
pub format: HookFormat,
}
impl HostConfig {
pub fn for_host(host: Host) -> Result<Self> {
let home = user_home()?;
let config = match host {
Host::ClaudeCode => Self {
host,
settings_path: env_dir("CLAUDE_CONFIG_DIR")
.unwrap_or_else(|| home.join(".claude"))
.join("settings.json"),
hooks: CLAUDE_CODE_HOOKS,
format: HookFormat::Nested,
},
Host::Codex => Self {
host,
settings_path: env_dir("CODEX_HOME")
.unwrap_or_else(|| home.join(".codex"))
.join("hooks.json"),
hooks: CODEX_HOOKS,
format: HookFormat::Nested,
},
Host::Cursor => Self {
host,
settings_path: env_dir("CURSOR_CONFIG_DIR")
.unwrap_or_else(|| home.join(".cursor"))
.join("hooks.json"),
hooks: CURSOR_HOOKS,
format: HookFormat::Cursor,
},
Host::Continue => {
bail!("the Continue CLI runs the Claude Code hook; it has none of its own")
}
};
Ok(config)
}
pub fn host_present(&self) -> bool {
self.settings_path
.parent()
.is_some_and(std::path::Path::is_dir)
}
pub fn install(&self, binary: &std::path::Path, dry_run: bool) -> Result<Outcome> {
hook_file::install(self, binary, dry_run)
}
pub fn state(&self, binary: &std::path::Path) -> HookState {
hook_file::state(self, binary)
}
}
pub const CONTINUE_CLI_WARNING: &str = "the released Continue CLI does not run hooks, so \
OpenMoat cannot check its tool calls; run it under `moat run`";
pub fn continue_cli() -> Result<Option<PathBuf>> {
let dir = match env_dir("CONTINUE_GLOBAL_DIR") {
Some(dir) => dir,
None => user_home()?.join(".continue"),
};
if dir.is_dir() {
return Ok(Some(dir));
}
let path: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
Ok(crate::environment::find_in(&path, &[], "cn"))
}
fn env_dir(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}