use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum HookAgentArg {
Claude,
Codex,
}
pub struct SetupHooksOptions<'a> {
pub root: &'a Path,
pub agent: Option<HookAgentArg>,
pub dry_run: bool,
pub force: bool,
pub user: bool,
pub gitignore_claude: bool,
pub uninstall: bool,
}
pub const FALLOW_GATE_SCRIPT: &str = include_str!("setup_hooks/fallow-gate.sh");
pub const CLAUDE_SETTINGS_DEFAULT: &str = include_str!("setup_hooks/settings.json");
pub const AGENTS_BLOCK_START: &str = "<!-- fallow:setup-hooks:start -->";
pub const AGENTS_BLOCK_END: &str = "<!-- fallow:setup-hooks:end -->";
pub const AGENTS_BLOCK_BODY: &str = "## Fallow local gate
Before any `git commit` or `git push`, run `fallow audit --format json --quiet --explain`. If the verdict is `fail`, fix the reported findings before retrying. Treat JSON runtime errors like `{ \"error\": true, ... }` as non-blocking.
";
const HOOK_SCRIPT_MARKER: &str = "# Generated by fallow setup-hooks.";
const PROJECT_FALLOW_HANDLER_COMMAND: &str = "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fallow-gate.sh";
const USER_FALLOW_HANDLER_COMMAND: &str = "\"$HOME\"/.claude/hooks/fallow-gate.sh";
const FALLOW_GATE_POSIX_SUFFIX: &str = "/.claude/hooks/fallow-gate.sh";
const FALLOW_GATE_WINDOWS_SUFFIX: &str = "\\.claude\\hooks\\fallow-gate.sh";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
Install,
Uninstall,
}
pub fn run_setup_hooks(opts: &SetupHooksOptions<'_>) -> ExitCode {
let mode = if opts.uninstall {
Mode::Uninstall
} else {
Mode::Install
};
let plan = match Plan::resolve(opts, mode) {
Ok(plan) => plan,
Err(msg) => {
eprintln!("{msg}");
return ExitCode::from(2);
}
};
if plan.is_empty() {
eprintln!(
"No .claude/, AGENTS.md, or .codex/ found; pass --agent claude or --agent codex to force a target."
);
return ExitCode::from(2);
}
let report = match plan.execute(opts, mode) {
Ok(report) => report,
Err(msg) => {
eprintln!("{msg}");
return ExitCode::from(2);
}
};
print_summary(&report, opts, mode);
ExitCode::SUCCESS
}
#[derive(Debug, Default)]
struct Plan {
claude: Option<ClaudeTargets>,
codex: Option<CodexTargets>,
}
#[derive(Debug)]
struct ClaudeTargets {
settings_path: PathBuf,
script_path: PathBuf,
}
#[derive(Debug)]
struct CodexTargets {
agents_path: PathBuf,
}
impl Plan {
fn resolve(opts: &SetupHooksOptions<'_>, mode: Mode) -> Result<Self, String> {
let (want_claude, want_codex) = match opts.agent {
Some(HookAgentArg::Claude) => (true, false),
Some(HookAgentArg::Codex) => (false, true),
None => auto_detect(opts.root, mode),
};
let mut plan = Self::default();
if want_claude {
plan.claude = Some(ClaudeTargets::resolve(opts)?);
}
if want_codex {
plan.codex = Some(CodexTargets::resolve(opts));
}
Ok(plan)
}
fn is_empty(&self) -> bool {
self.claude.is_none() && self.codex.is_none()
}
fn execute(&self, opts: &SetupHooksOptions<'_>, mode: Mode) -> Result<Report, String> {
let mut report = Report::default();
if let Some(claude) = &self.claude {
report.claude = Some(claude.execute(opts, mode)?);
}
if let Some(codex) = &self.codex {
report.codex = Some(codex.execute(opts, mode)?);
}
if mode == Mode::Install && self.claude.is_some() && opts.gitignore_claude && !opts.dry_run
{
ensure_gitignore_entry(opts.root, ".claude/")
.map_err(|e| format!("Failed to update .gitignore for .claude/: {e}"))?;
}
Ok(report)
}
}
fn auto_detect(root: &Path, mode: Mode) -> (bool, bool) {
let has_claude = root.join(".claude").is_dir();
let has_codex = root.join("AGENTS.md").is_file() || root.join(".codex").is_dir();
match mode {
Mode::Install if !has_claude && !has_codex => (true, false),
_ => (has_claude, has_codex),
}
}
impl ClaudeTargets {
fn resolve(opts: &SetupHooksOptions<'_>) -> Result<Self, String> {
let base = if opts.user {
home_dir().ok_or_else(|| {
"Cannot resolve user home directory; unset --user or set $HOME.".to_string()
})?
} else {
opts.root.to_path_buf()
};
Ok(Self {
settings_path: base.join(".claude").join("settings.json"),
script_path: base.join(".claude").join("hooks").join("fallow-gate.sh"),
})
}
fn execute(&self, opts: &SetupHooksOptions<'_>, mode: Mode) -> Result<ClaudeReport, String> {
match mode {
Mode::Install => self.execute_install(opts),
Mode::Uninstall => self.execute_uninstall(opts),
}
}
fn execute_install(&self, opts: &SetupHooksOptions<'_>) -> Result<ClaudeReport, String> {
if !opts.dry_run {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
if let Some(parent) = self.script_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
}
let settings_outcome =
merge_claude_settings(&self.settings_path, opts.user, opts.force, opts.dry_run)?;
let script_outcome = write_executable_script(
&self.script_path,
FALLOW_GATE_SCRIPT,
opts.force,
opts.dry_run,
)?;
Ok(ClaudeReport {
settings_path: self.settings_path.clone(),
settings_outcome,
script_path: self.script_path.clone(),
script_outcome,
})
}
fn execute_uninstall(&self, opts: &SetupHooksOptions<'_>) -> Result<ClaudeReport, String> {
let settings_outcome = uninstall_claude_settings(&self.settings_path, opts.dry_run)?;
let script_outcome = remove_claude_script(&self.script_path, opts.force, opts.dry_run)?;
Ok(ClaudeReport {
settings_path: self.settings_path.clone(),
settings_outcome,
script_path: self.script_path.clone(),
script_outcome,
})
}
}
impl CodexTargets {
fn resolve(opts: &SetupHooksOptions<'_>) -> Self {
Self {
agents_path: opts.root.join("AGENTS.md"),
}
}
fn execute(&self, opts: &SetupHooksOptions<'_>, mode: Mode) -> Result<CodexReport, String> {
let outcome = match mode {
Mode::Install => upsert_managed_block(&self.agents_path, opts.dry_run)
.map_err(|e| format!("Failed to update {}: {e}", self.agents_path.display()))?,
Mode::Uninstall => remove_managed_block(&self.agents_path, opts.dry_run)
.map_err(|e| format!("Failed to update {}: {e}", self.agents_path.display()))?,
};
Ok(CodexReport {
agents_path: self.agents_path.clone(),
outcome,
})
}
}
#[derive(Debug, Default)]
struct Report {
claude: Option<ClaudeReport>,
codex: Option<CodexReport>,
}
#[derive(Debug)]
struct ClaudeReport {
settings_path: PathBuf,
settings_outcome: SettingsOutcome,
script_path: PathBuf,
script_outcome: ScriptOutcome,
}
#[derive(Debug)]
struct CodexReport {
agents_path: PathBuf,
outcome: AgentsOutcome,
}
#[derive(Debug)]
enum SettingsOutcome {
Created,
Updated {
handlers_added: usize,
handlers_removed: usize,
handlers_preserved: usize,
},
Unchanged {
handlers_preserved: usize,
},
NotPresent,
}
#[derive(Debug)]
enum ScriptOutcome {
Created,
Updated,
Unchanged,
Removed,
UserEditedPreserved,
NotPresent,
}
#[derive(Debug)]
enum AgentsOutcome {
Inserted,
Replaced,
Unchanged,
Removed,
NotPresent,
}
fn merge_claude_settings(
path: &Path,
user: bool,
force: bool,
dry_run: bool,
) -> Result<SettingsOutcome, String> {
let existing_raw = match read_optional_text(path) {
Ok(contents) => contents,
Err(_) if force => None,
Err(err) => {
return Err(format!(
"Failed to read {}: {err}; re-run with --force to overwrite.",
path.display()
));
}
};
let desired = desired_claude_settings(user)?;
let (serialized, outcome) = match existing_raw.as_deref() {
None | Some("") => (serialize_settings(&desired)?, SettingsOutcome::Created),
Some(raw) if raw.trim().is_empty() => {
(serialize_settings(&desired)?, SettingsOutcome::Created)
}
Some(raw) => {
let current: Option<serde_json::Value> = match serde_json::from_str(raw) {
Ok(v) => Some(v),
Err(e) => {
if !force {
return Err(format!(
"{} is not valid JSON ({e}); re-run with --force to overwrite.",
path.display()
));
}
None
}
};
match current {
None => (serialize_settings(&desired)?, SettingsOutcome::Created),
Some(current) => {
let (value, added, removed, preserved) =
merge_settings_value(¤t, &desired)?;
let serialized = serialize_settings(&value)?;
let outcome = if raw == serialized {
SettingsOutcome::Unchanged {
handlers_preserved: preserved,
}
} else {
SettingsOutcome::Updated {
handlers_added: added,
handlers_removed: removed,
handlers_preserved: preserved,
}
};
(serialized, outcome)
}
}
}
};
if dry_run {
return Ok(outcome);
}
if matches!(outcome, SettingsOutcome::Unchanged { .. }) {
return Ok(outcome);
}
std::fs::write(path, serialized)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(outcome)
}
fn serialize_settings(value: &serde_json::Value) -> Result<String, String> {
let mut serialized = serde_json::to_string_pretty(value)
.map_err(|e| format!("Failed to serialize settings: {e}"))?;
serialized.push('\n');
Ok(serialized)
}
fn desired_claude_settings(user: bool) -> Result<serde_json::Value, String> {
let mut desired: serde_json::Value = serde_json::from_str(CLAUDE_SETTINGS_DEFAULT)
.map_err(|e| format!("internal default settings.json is invalid: {e}"))?;
let Some(command) = desired
.get_mut("hooks")
.and_then(|hooks| hooks.get_mut("PreToolUse"))
.and_then(serde_json::Value::as_array_mut)
.and_then(|groups| groups.first_mut())
.and_then(serde_json::Value::as_object_mut)
.and_then(|group| group.get_mut("hooks"))
.and_then(serde_json::Value::as_array_mut)
.and_then(|handlers| handlers.first_mut())
.and_then(serde_json::Value::as_object_mut)
.and_then(|handler| handler.get_mut("command"))
else {
return Err(
"internal default settings.json does not contain a Claude hook command".to_string(),
);
};
*command = serde_json::Value::String(fallow_handler_command(user).to_string());
Ok(desired)
}
const fn fallow_handler_command(user: bool) -> &'static str {
if user {
USER_FALLOW_HANDLER_COMMAND
} else {
PROJECT_FALLOW_HANDLER_COMMAND
}
}
fn uninstall_claude_settings(path: &Path, dry_run: bool) -> Result<SettingsOutcome, String> {
let Some(raw) =
read_optional_text(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
else {
return Ok(SettingsOutcome::NotPresent);
};
if raw.trim().is_empty() {
return Ok(SettingsOutcome::NotPresent);
}
let current: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => {
return Ok(SettingsOutcome::Unchanged {
handlers_preserved: 0,
});
}
};
let (next, removed, preserved) = strip_fallow_handlers(¤t)?;
if removed == 0 {
return Ok(SettingsOutcome::Unchanged {
handlers_preserved: preserved,
});
}
if dry_run {
return Ok(SettingsOutcome::Updated {
handlers_added: 0,
handlers_removed: removed,
handlers_preserved: preserved,
});
}
let serialized = serde_json::to_string_pretty(&next)
.map_err(|e| format!("Failed to serialize settings: {e}"))?;
let mut content = serialized;
content.push('\n');
std::fs::write(path, content)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(SettingsOutcome::Updated {
handlers_added: 0,
handlers_removed: removed,
handlers_preserved: preserved,
})
}
fn merge_settings_value(
current: &serde_json::Value,
desired: &serde_json::Value,
) -> Result<(serde_json::Value, usize, usize, usize), String> {
let current_obj = current
.as_object()
.ok_or_else(|| "settings.json must be a JSON object".to_string())?
.clone();
let mut rebuilt = serde_json::Map::with_capacity(current_obj.len() + 1);
if let Some(schema) = current_obj
.get("$schema")
.cloned()
.or_else(|| desired.get("$schema").cloned())
{
rebuilt.insert("$schema".to_string(), schema);
}
for (key, value) in current_obj {
if key == "$schema" {
continue;
}
rebuilt.insert(key, value);
}
let mut out = serde_json::Value::Object(rebuilt);
let out_obj = out
.as_object_mut()
.expect("rebuilt value must remain an object");
let hooks_entry = out_obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::json!({}));
let hooks_obj = hooks_entry
.as_object_mut()
.ok_or_else(|| "settings.json `hooks` must be a JSON object".to_string())?;
let pretool_entry = hooks_obj
.entry("PreToolUse".to_string())
.or_insert_with(|| serde_json::json!([]));
let pretool_arr = pretool_entry
.as_array_mut()
.ok_or_else(|| "settings.json `hooks.PreToolUse` must be an array".to_string())?;
let desired_handlers: Vec<serde_json::Value> = desired
.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(|p| p.as_array())
.and_then(|groups| groups.first())
.and_then(|group| group.get("hooks"))
.and_then(|h| h.as_array())
.cloned()
.unwrap_or_default();
let mut removed_existing = 0usize;
let mut preserved = 0usize;
let mut first_bash_idx = None;
for (idx, group) in pretool_arr.iter_mut().enumerate() {
let Some(group_obj) = group.as_object_mut() else {
continue;
};
if group_obj.get("matcher").and_then(serde_json::Value::as_str) != Some("Bash") {
continue;
}
if first_bash_idx.is_none() {
first_bash_idx = Some(idx);
}
let group_hooks = group_obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| "PreToolUse group `hooks` must be an array".to_string())?;
let before = group_hooks.len();
group_hooks.retain(|handler| !is_fallow_handler(handler));
removed_existing += before - group_hooks.len();
preserved += group_hooks.len();
}
let added_now = desired_handlers.len();
if let Some(idx) = first_bash_idx {
let group = pretool_arr[idx]
.as_object_mut()
.ok_or_else(|| "PreToolUse group must be a JSON object".to_string())?;
let group_hooks = group
.entry("hooks".to_string())
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| "PreToolUse group `hooks` must be an array".to_string())?;
group_hooks.extend(desired_handlers);
} else {
pretool_arr.push(serde_json::json!({
"matcher": "Bash",
"hooks": desired_handlers,
}));
}
Ok((out, added_now, removed_existing, preserved))
}
fn strip_fallow_handlers(
current: &serde_json::Value,
) -> Result<(serde_json::Value, usize, usize), String> {
let mut out = current.clone();
let Some(out_obj) = out.as_object_mut() else {
return Err("settings.json must be a JSON object".to_string());
};
let Some(hooks_val) = out_obj.get_mut("hooks") else {
return Ok((out, 0, 0));
};
let Some(hooks_obj) = hooks_val.as_object_mut() else {
return Ok((out, 0, 0));
};
let Some(pretool_val) = hooks_obj.get_mut("PreToolUse") else {
return Ok((out, 0, 0));
};
let Some(pretool_arr) = pretool_val.as_array_mut() else {
return Ok((out, 0, 0));
};
let mut removed = 0usize;
let mut preserved = 0usize;
for group in pretool_arr.iter_mut() {
let Some(group_obj) = group.as_object_mut() else {
continue;
};
let is_bash = group_obj.get("matcher").and_then(serde_json::Value::as_str) == Some("Bash");
if !is_bash {
continue;
}
let Some(group_hooks) = group_obj
.get_mut("hooks")
.and_then(serde_json::Value::as_array_mut)
else {
continue;
};
let before = group_hooks.len();
group_hooks.retain(|handler| !is_fallow_handler(handler));
removed += before - group_hooks.len();
preserved += group_hooks.len();
}
pretool_arr.retain(|group| {
let Some(group_obj) = group.as_object() else {
return true;
};
match group_obj.get("hooks").and_then(serde_json::Value::as_array) {
Some(hooks) => !hooks.is_empty(),
None => true,
}
});
let pretool_empty = pretool_arr.is_empty();
if pretool_empty {
hooks_obj.remove("PreToolUse");
}
if hooks_obj.is_empty() {
out_obj.remove("hooks");
}
Ok((out, removed, preserved))
}
fn is_fallow_handler(handler: &serde_json::Value) -> bool {
handler
.get("command")
.and_then(serde_json::Value::as_str)
.is_some_and(|cmd| is_owned_fallow_command(cmd.trim()))
}
fn is_owned_fallow_command(command: &str) -> bool {
is_canonical_fallow_command(command)
|| is_canonical_fallow_command(trim_outer_quotes(command))
|| is_legacy_fallow_path(trim_outer_quotes(command))
}
fn is_canonical_fallow_command(command: &str) -> bool {
[
"\"$CLAUDE_PROJECT_DIR\"",
"$CLAUDE_PROJECT_DIR",
"\"$HOME\"",
"$HOME",
"\"${HOME}\"",
"${HOME}",
]
.into_iter()
.filter_map(|prefix| command.strip_prefix(prefix))
.any(has_fallow_gate_suffix)
}
fn is_legacy_fallow_path(command: &str) -> bool {
has_fallow_gate_suffix(command)
&& (command.starts_with("~/")
|| command.starts_with("~\\")
|| command.starts_with('/')
|| command.starts_with("\\\\")
|| command.starts_with('\\')
|| is_windows_drive_path(command))
}
fn has_fallow_gate_suffix(command: &str) -> bool {
command == FALLOW_GATE_POSIX_SUFFIX
|| command == FALLOW_GATE_WINDOWS_SUFFIX
|| command.ends_with(FALLOW_GATE_POSIX_SUFFIX)
|| command.ends_with(FALLOW_GATE_WINDOWS_SUFFIX)
}
fn is_windows_drive_path(command: &str) -> bool {
let bytes = command.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
}
fn trim_outer_quotes(command: &str) -> &str {
command
.strip_prefix('"')
.and_then(|trimmed| trimmed.strip_suffix('"'))
.unwrap_or(command)
}
fn write_executable_script(
path: &Path,
content: &str,
force: bool,
dry_run: bool,
) -> Result<ScriptOutcome, String> {
let existing = if path.exists() {
Some(
std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?,
)
} else {
None
};
let outcome = match existing.as_deref() {
None => ScriptOutcome::Created,
Some(prev) if prev == content => ScriptOutcome::Unchanged,
Some(prev) => {
let looks_generated = prev.contains(HOOK_SCRIPT_MARKER);
if !looks_generated && !force {
return Err(format!(
"{} already exists and does not look like a fallow-generated script; re-run with --force to overwrite.",
path.display()
));
}
ScriptOutcome::Updated
}
};
if dry_run {
return Ok(outcome);
}
if matches!(outcome, ScriptOutcome::Unchanged) {
set_executable_bit(path);
return Ok(outcome);
}
let mut file = std::fs::File::create(path)
.map_err(|e| format!("Failed to create {}: {e}", path.display()))?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
drop(file);
set_executable_bit(path);
Ok(outcome)
}
fn remove_claude_script(path: &Path, force: bool, dry_run: bool) -> Result<ScriptOutcome, String> {
if !path.exists() {
return Ok(ScriptOutcome::NotPresent);
}
let existing = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let looks_generated = existing.contains(HOOK_SCRIPT_MARKER);
if !looks_generated && !force {
return Ok(ScriptOutcome::UserEditedPreserved);
}
if dry_run {
return Ok(ScriptOutcome::Removed);
}
std::fs::remove_file(path).map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
Ok(ScriptOutcome::Removed)
}
#[cfg(unix)]
fn set_executable_bit(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = std::fs::metadata(path).map(|m| m.permissions()) {
perms.set_mode(0o755);
let _ = std::fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn set_executable_bit(_path: &Path) {
}
fn upsert_managed_block(path: &Path, dry_run: bool) -> std::io::Result<AgentsOutcome> {
let existing = read_optional_text(path)?.unwrap_or_default();
let new_block = format!("{AGENTS_BLOCK_START}\n{AGENTS_BLOCK_BODY}{AGENTS_BLOCK_END}\n");
let (next, outcome) =
if existing.contains(AGENTS_BLOCK_START) && existing.contains(AGENTS_BLOCK_END) {
let start = existing.find(AGENTS_BLOCK_START).unwrap();
let end = existing.find(AGENTS_BLOCK_END).unwrap();
let end_line_end = existing[end..]
.find('\n')
.map_or(existing.len(), |offset| end + offset + 1);
let mut buf = String::with_capacity(existing.len() + new_block.len());
buf.push_str(&existing[..start]);
buf.push_str(&new_block);
buf.push_str(&existing[end_line_end..]);
let outcome = if buf == existing {
AgentsOutcome::Unchanged
} else {
AgentsOutcome::Replaced
};
(buf, outcome)
} else if existing.is_empty() {
(new_block, AgentsOutcome::Inserted)
} else if let Some(insert_at) = find_tooling_insertion_point(&existing) {
let mut buf = String::with_capacity(existing.len() + new_block.len() + 2);
buf.push_str(&existing[..insert_at]);
if !buf.ends_with("\n\n") {
buf.push('\n');
}
buf.push_str(&new_block);
if !existing[insert_at..].starts_with('\n') {
buf.push('\n');
}
buf.push_str(&existing[insert_at..]);
(buf, AgentsOutcome::Inserted)
} else {
let mut buf = existing;
if !buf.ends_with('\n') {
buf.push('\n');
}
buf.push_str("\n---\n\n");
buf.push_str(&new_block);
(buf, AgentsOutcome::Inserted)
};
if dry_run {
return Ok(outcome);
}
if matches!(outcome, AgentsOutcome::Unchanged) {
return Ok(outcome);
}
std::fs::write(path, next)?;
Ok(outcome)
}
fn remove_managed_block(path: &Path, dry_run: bool) -> std::io::Result<AgentsOutcome> {
let Some(existing) = read_optional_text(path)? else {
return Ok(AgentsOutcome::NotPresent);
};
let Some(start) = existing.find(AGENTS_BLOCK_START) else {
return Ok(AgentsOutcome::Unchanged);
};
let Some(end) = existing.find(AGENTS_BLOCK_END) else {
return Ok(AgentsOutcome::Unchanged);
};
let end_line_end = existing[end..]
.find('\n')
.map_or(existing.len(), |offset| end + offset + 1);
let mut prefix_end = start;
let prefix = &existing[..start];
if prefix.ends_with("\n---\n\n") {
prefix_end -= "\n---\n\n".len() - 1;
} else if prefix.ends_with("---\n\n") {
prefix_end -= "---\n\n".len() - 1;
}
let mut buf = String::with_capacity(existing.len());
buf.push_str(&existing[..prefix_end]);
let tail = &existing[end_line_end..];
let tail = tail.strip_prefix('\n').unwrap_or(tail);
buf.push_str(tail);
if dry_run {
return Ok(AgentsOutcome::Removed);
}
std::fs::write(path, buf)?;
Ok(AgentsOutcome::Removed)
}
fn find_tooling_insertion_point(text: &str) -> Option<usize> {
const CANDIDATES: &[&str] = &[
"## Tooling",
"## Local development",
"## Local Development",
"## Development",
"## Pre-commit",
];
for marker in CANDIDATES {
if let Some(idx) = text.find(marker) {
let after_heading = idx + marker.len();
if let Some(nl) = text[after_heading..].find('\n') {
return Some(after_heading + nl + 1);
}
return Some(text.len());
}
}
None
}
fn ensure_gitignore_entry(root: &Path, entry: &str) -> std::io::Result<()> {
let gitignore_path = root.join(".gitignore");
let existing = read_optional_text(&gitignore_path)?.unwrap_or_default();
let target = entry.trim_end_matches('/');
let already_ignored = existing.lines().any(|line| {
let trimmed = line.trim();
trimmed == target || trimmed == entry
});
if already_ignored {
return Ok(());
}
let is_new = existing.is_empty();
let mut contents = existing;
if !is_new && !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(entry);
if !entry.ends_with('\n') {
contents.push('\n');
}
std::fs::write(&gitignore_path, contents)
}
fn read_optional_text(path: &Path) -> std::io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn print_summary(report: &Report, opts: &SetupHooksOptions<'_>, mode: Mode) {
let verb = match mode {
Mode::Install => "install",
Mode::Uninstall => "uninstall",
};
let suffix = if opts.dry_run { " (dry run)" } else { "" };
eprintln!("fallow setup-hooks ({verb}){suffix}:");
if let Some(claude) = &report.claude {
let settings_rel = display_rel(opts.root, &claude.settings_path);
let script_rel = display_rel(opts.root, &claude.script_path);
eprintln!(
" {:<42} {}",
settings_rel,
describe_settings(&claude.settings_outcome)
);
eprintln!(
" {:<42} {}",
script_rel,
describe_script(&claude.script_outcome, opts.dry_run, mode)
);
}
if let Some(codex) = &report.codex {
let agents_rel = display_rel(opts.root, &codex.agents_path);
eprintln!(
" {:<42} {}",
agents_rel,
describe_agents(&codex.outcome, opts.dry_run, mode)
);
}
if mode == Mode::Install && report.claude.is_some() && opts.gitignore_claude && !opts.dry_run {
eprintln!(" {:<42} .claude/ added", ".gitignore");
}
}
fn describe_settings(outcome: &SettingsOutcome) -> String {
match outcome {
SettingsOutcome::Created => "created".to_string(),
SettingsOutcome::Updated {
handlers_added,
handlers_removed,
handlers_preserved,
} => {
let mut parts: Vec<String> = Vec::new();
if *handlers_added > 0 {
parts.push(format!(
"{handlers_added} handler{} added",
plural(*handlers_added)
));
}
if *handlers_removed > 0 {
parts.push(format!(
"{handlers_removed} handler{} removed",
plural(*handlers_removed)
));
}
if *handlers_preserved > 0 {
parts.push(format!("{handlers_preserved} preserved"));
}
if parts.is_empty() {
"updated".to_string()
} else {
format!("updated ({})", parts.join(", "))
}
}
SettingsOutcome::Unchanged { handlers_preserved } => {
if *handlers_preserved == 0 {
"unchanged".to_string()
} else {
format!(
"unchanged ({handlers_preserved} non-fallow handler{} preserved)",
plural(*handlers_preserved)
)
}
}
SettingsOutcome::NotPresent => "not present".to_string(),
}
}
fn describe_script(outcome: &ScriptOutcome, dry_run: bool, mode: Mode) -> String {
match (outcome, mode, dry_run) {
(ScriptOutcome::Created, _, false) => "created".to_string(),
(ScriptOutcome::Created, _, true) => "would create".to_string(),
(ScriptOutcome::Updated, _, false) => "updated".to_string(),
(ScriptOutcome::Updated, _, true) => "would update".to_string(),
(ScriptOutcome::Unchanged, _, _) => "unchanged".to_string(),
(ScriptOutcome::Removed, _, false) => "removed".to_string(),
(ScriptOutcome::Removed, _, true) => "would remove".to_string(),
(ScriptOutcome::UserEditedPreserved, _, _) => {
"preserved (no fallow marker; re-run with --force to remove)".to_string()
}
(ScriptOutcome::NotPresent, _, _) => "not present".to_string(),
}
}
fn describe_agents(outcome: &AgentsOutcome, dry_run: bool, _mode: Mode) -> String {
match (outcome, dry_run) {
(AgentsOutcome::Inserted, false) => "managed block inserted".to_string(),
(AgentsOutcome::Inserted, true) => "would insert managed block".to_string(),
(AgentsOutcome::Replaced, false) => "managed block replaced in place".to_string(),
(AgentsOutcome::Replaced, true) => "would replace managed block".to_string(),
(AgentsOutcome::Removed, false) => "managed block removed".to_string(),
(AgentsOutcome::Removed, true) => "would remove managed block".to_string(),
(AgentsOutcome::Unchanged, _) => "unchanged (no managed block)".to_string(),
(AgentsOutcome::NotPresent, _) => "not present".to_string(),
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn display_rel(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map_or_else(|_| path.display().to_string(), |p| p.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn opts(root: &Path) -> SetupHooksOptions<'_> {
SetupHooksOptions {
root,
agent: None,
dry_run: false,
force: false,
user: false,
gitignore_claude: false,
uninstall: false,
}
}
#[test]
fn auto_defaults_to_claude_when_no_surface_exists() {
let tmp = tempdir().unwrap();
let (claude, codex) = auto_detect(tmp.path(), Mode::Install);
assert!(claude);
assert!(!codex);
}
#[test]
fn auto_uninstall_does_not_fabricate_missing_surfaces() {
let tmp = tempdir().unwrap();
let (claude, codex) = auto_detect(tmp.path(), Mode::Uninstall);
assert!(!claude);
assert!(!codex);
}
#[test]
fn auto_picks_both_when_claude_and_agents_present() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
std::fs::write(tmp.path().join("AGENTS.md"), "# agents\n").unwrap();
let (claude, codex) = auto_detect(tmp.path(), Mode::Install);
assert!(claude);
assert!(codex);
}
#[test]
fn dry_run_does_not_touch_files() {
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.dry_run = true;
o.agent = Some(HookAgentArg::Claude);
let code = run_setup_hooks(&o);
assert_eq!(code, ExitCode::SUCCESS);
assert!(!tmp.path().join(".claude").exists());
}
#[test]
fn claude_flow_writes_both_files() {
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
let code = run_setup_hooks(&o);
assert_eq!(code, ExitCode::SUCCESS);
assert!(tmp.path().join(".claude/settings.json").is_file());
assert!(tmp.path().join(".claude/hooks/fallow-gate.sh").is_file());
}
#[test]
fn settings_merge_preserves_unrelated_keys() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{"env":{"FOO":"bar"},"hooks":{"PostToolUse":[]}}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let result = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["env"]["FOO"], "bar");
assert!(parsed["hooks"]["PostToolUse"].is_array());
let pretool = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pretool.len(), 1);
assert_eq!(pretool[0]["matcher"], "Bash");
}
#[test]
fn settings_merge_is_idempotent() {
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let first = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let second = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
assert_eq!(first, second);
}
#[test]
fn schema_reorder_then_second_run_is_byte_identical() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let seeded = serde_json::json!({
"env": { "FOO": "bar" },
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": PROJECT_FALLOW_HANDLER_COMMAND,
}
],
}
],
},
});
let mut seeded = serde_json::to_string_pretty(&seeded).unwrap();
seeded.push('\n');
std::fs::write(tmp.path().join(".claude/settings.json"), seeded).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let first = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let first_key_line = first
.lines()
.find(|line| line.trim_start().starts_with('"'))
.unwrap();
assert!(
first_key_line.trim_start().starts_with("\"$schema\""),
"expected $schema at position 0 after rewrite, got: {first_key_line}"
);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let second = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
assert_eq!(first, second);
}
#[test]
fn settings_merge_errors_on_invalid_utf8_without_force() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let path = tmp.path().join(".claude/settings.json");
std::fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
let err = merge_claude_settings(&path, false, false, false)
.expect_err("invalid UTF-8 should not be silently replaced");
assert!(err.contains("re-run with --force"));
}
#[test]
fn settings_merge_force_overwrites_invalid_utf8() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let path = tmp.path().join(".claude/settings.json");
std::fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
let outcome = merge_claude_settings(&path, false, true, false).unwrap();
assert!(matches!(outcome, SettingsOutcome::Created));
let raw = std::fs::read_to_string(path).unwrap();
assert!(raw.contains("\"$schema\""));
}
#[test]
fn settings_merge_force_overwrites_invalid_json() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let path = tmp.path().join(".claude/settings.json");
std::fs::write(&path, "{ invalid json\n").unwrap();
let outcome = merge_claude_settings(&path, false, true, false).unwrap();
assert!(matches!(outcome, SettingsOutcome::Created));
let raw = std::fs::read_to_string(path).unwrap();
assert!(raw.contains("\"$schema\""));
}
#[test]
fn script_refuses_to_clobber_user_edited_without_force() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude/hooks")).unwrap();
let script_path = tmp.path().join(".claude/hooks/fallow-gate.sh");
std::fs::write(&script_path, "#!/bin/sh\necho user-owned\n").unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
let code = run_setup_hooks(&o);
assert_eq!(code, ExitCode::from(2));
let kept = std::fs::read_to_string(&script_path).unwrap();
assert_eq!(kept, "#!/bin/sh\necho user-owned\n");
}
#[test]
fn script_upgrades_previous_fallow_generated_file() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude/hooks")).unwrap();
let script_path = tmp.path().join(".claude/hooks/fallow-gate.sh");
let prior = "#!/usr/bin/env bash\n# Generated by fallow setup-hooks.\nexit 0\n";
std::fs::write(&script_path, prior).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let replaced = std::fs::read_to_string(&script_path).unwrap();
assert_eq!(replaced, FALLOW_GATE_SCRIPT);
}
#[test]
fn agents_block_appends_once() {
let tmp = tempdir().unwrap();
let agents_path = tmp.path().join("AGENTS.md");
std::fs::write(&agents_path, "# Project agents\n").unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Codex);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let after_first = std::fs::read_to_string(&agents_path).unwrap();
assert_eq!(after_first.matches(AGENTS_BLOCK_START).count(), 1);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let after_second = std::fs::read_to_string(&agents_path).unwrap();
assert_eq!(after_second, after_first);
}
#[test]
fn agents_block_replaces_managed_section_in_place() {
let tmp = tempdir().unwrap();
let agents_path = tmp.path().join("AGENTS.md");
let seeded =
format!("# agents\n\n{AGENTS_BLOCK_START}\nstale body\n{AGENTS_BLOCK_END}\n\nbelow\n");
std::fs::write(&agents_path, seeded).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Codex);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let contents = std::fs::read_to_string(&agents_path).unwrap();
assert!(contents.contains("Fallow local gate"));
assert!(!contents.contains("stale body"));
assert!(contents.contains("below"));
}
#[test]
fn gitignore_unchanged_by_default() {
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
assert!(!tmp.path().join(".gitignore").exists());
}
#[test]
fn gitignore_updates_only_with_flag() {
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
o.gitignore_claude = true;
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let ignored = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(ignored.contains(".claude/"));
}
#[cfg(unix)]
#[test]
fn hook_script_is_executable_on_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let mode = std::fs::metadata(tmp.path().join(".claude/hooks/fallow-gate.sh"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "expected executable bits set");
}
#[test]
fn schema_is_placed_at_position_zero() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{"hooks":{"PreToolUse":[]},"env":{"FOO":"bar"}}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let first_key_line = raw
.lines()
.find(|line| line.trim_start().starts_with('"'))
.unwrap();
assert!(
first_key_line.trim_start().starts_with("\"$schema\""),
"expected $schema at position 0, got: {first_key_line}"
);
}
#[test]
fn stale_fallow_handler_is_replaced_on_upgrade() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bun run lint" },
{ "type": "command", "if": "Bash(git commit *)", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-gate.sh" },
{ "type": "command", "if": "Bash(git push *)", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-gate.sh" }
]
}
]
}
}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
let bash_group = &parsed["hooks"]["PreToolUse"][0]["hooks"];
let entries = bash_group.as_array().unwrap();
let fallow_count = entries.iter().filter(|e| is_fallow_handler(e)).count();
assert_eq!(
fallow_count, 1,
"stale fallow handlers should collapse to one"
);
let lint_count = entries
.iter()
.filter(|e| e.get("command").and_then(|c| c.as_str()) == Some("bun run lint"))
.count();
assert_eq!(lint_count, 1, "unrelated handler must be preserved");
}
#[test]
fn stale_fallow_handlers_are_removed_from_all_bash_groups() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bun run lint" }
]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-gate.sh" }
]
}
]
}
}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
let groups = parsed["hooks"]["PreToolUse"].as_array().unwrap();
let fallow_count = groups
.iter()
.flat_map(|group| group["hooks"].as_array().into_iter().flatten())
.filter(|handler| is_fallow_handler(handler))
.count();
assert_eq!(
fallow_count, 1,
"expected a single canonical fallow handler"
);
}
#[test]
fn canonical_handler_stays_in_first_bash_group_after_strip() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-gate.sh" }
]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bun run lint" }
]
}
]
}
}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
let groups = parsed["hooks"]["PreToolUse"].as_array().unwrap();
let first_group_hooks = groups[0]["hooks"].as_array().unwrap();
let second_group_hooks = groups[1]["hooks"].as_array().unwrap();
assert_eq!(
first_group_hooks
.iter()
.filter(|handler| is_fallow_handler(handler))
.count(),
1
);
assert_eq!(
second_group_hooks
.iter()
.filter(|handler| {
handler.get("command").and_then(serde_json::Value::as_str)
== Some("bun run lint")
})
.count(),
1
);
assert_eq!(
second_group_hooks
.iter()
.filter(|handler| is_fallow_handler(handler))
.count(),
0
);
}
#[test]
fn agents_block_inserts_under_tooling_heading() {
let tmp = tempdir().unwrap();
let agents_path = tmp.path().join("AGENTS.md");
let seeded = "# agents\n\n## Tooling\n\nUse bun.\n\n## Other\n\nmore.\n";
std::fs::write(&agents_path, seeded).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Codex);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let contents = std::fs::read_to_string(&agents_path).unwrap();
let tooling_idx = contents.find("## Tooling").unwrap();
let block_idx = contents.find(AGENTS_BLOCK_START).unwrap();
let other_idx = contents.find("## Other").unwrap();
assert!(
tooling_idx < block_idx && block_idx < other_idx,
"managed block should land between `## Tooling` and `## Other`"
);
}
#[test]
fn agents_block_appended_uses_hr_separator_when_no_heading() {
let tmp = tempdir().unwrap();
let agents_path = tmp.path().join("AGENTS.md");
std::fs::write(&agents_path, "# agents\n\nsome prose.\n").unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Codex);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let contents = std::fs::read_to_string(&agents_path).unwrap();
let hr_idx = contents.find("\n---\n").unwrap();
let block_idx = contents.find(AGENTS_BLOCK_START).unwrap();
assert!(
hr_idx < block_idx,
"expected `---` separator before managed block"
);
}
#[test]
fn is_fallow_handler_matches_canonical_and_legacy_paths() {
let unix = serde_json::json!({ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-gate.sh" });
let windows = serde_json::json!({ "type": "command", "command": "$CLAUDE_PROJECT_DIR\\.claude\\hooks\\fallow-gate.sh" });
let quoted = serde_json::json!({ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fallow-gate.sh" });
let user_home = serde_json::json!({ "type": "command", "command": "\"$HOME\"/.claude/hooks/fallow-gate.sh" });
let legacy_home =
serde_json::json!({ "type": "command", "command": "~/.claude/hooks/fallow-gate.sh" });
let legacy_absolute = serde_json::json!({ "type": "command", "command": "\"/Users/bartwaardenburg/project/.claude/hooks/fallow-gate.sh\"" });
let legacy_windows_absolute = serde_json::json!({ "type": "command", "command": "\"C:/Users/bart/project/.claude/hooks/fallow-gate.sh\"" });
let unrelated = serde_json::json!({ "type": "command", "command": "bun run lint" });
let mentions_path = serde_json::json!({ "type": "command", "command": "bash -lc 'cp /tmp/fallow-gate.sh /tmp/fallow-gate.sh.bak && bun run lint'" });
assert!(is_fallow_handler(&unix));
assert!(is_fallow_handler(&windows));
assert!(is_fallow_handler("ed));
assert!(is_fallow_handler(&user_home));
assert!(is_fallow_handler(&legacy_home));
assert!(is_fallow_handler(&legacy_absolute));
assert!(is_fallow_handler(&legacy_windows_absolute));
assert!(!is_fallow_handler(&unrelated));
assert!(!is_fallow_handler(&mentions_path));
}
#[test]
fn legacy_absolute_handler_is_replaced_with_canonical_command() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/Users/bartwaardenburg/project/.claude/hooks/fallow-gate.sh" }
]
}
]
}
}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
let hooks = parsed["hooks"]["PreToolUse"][0]["hooks"]
.as_array()
.unwrap();
let fallow_commands: Vec<_> = hooks
.iter()
.filter(|handler| is_fallow_handler(handler))
.filter_map(|handler| handler.get("command").and_then(serde_json::Value::as_str))
.collect();
assert_eq!(fallow_commands, vec![PROJECT_FALLOW_HANDLER_COMMAND]);
}
#[test]
fn user_install_uses_home_based_handler_command() {
let desired = desired_claude_settings(true).unwrap();
let command = desired["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert_eq!(command, USER_FALLOW_HANDLER_COMMAND);
}
#[test]
fn agents_block_errors_on_invalid_utf8() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("AGENTS.md");
std::fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
let err = upsert_managed_block(&path, false).expect_err("invalid UTF-8 should error");
assert_eq!(err.kind(), ErrorKind::InvalidData);
}
#[test]
fn gitignore_errors_on_invalid_utf8() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".gitignore"), [0xff, 0xfe, 0xfd]).unwrap();
let err =
ensure_gitignore_entry(tmp.path(), ".claude/").expect_err("invalid UTF-8 should error");
assert_eq!(err.kind(), ErrorKind::InvalidData);
}
#[test]
fn uninstall_round_trips_a_fresh_install() {
let tmp = tempdir().unwrap();
let mut install = opts(tmp.path());
install.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&install), ExitCode::SUCCESS);
assert!(tmp.path().join(".claude/settings.json").is_file());
assert!(tmp.path().join(".claude/hooks/fallow-gate.sh").is_file());
let mut uninstall = opts(tmp.path());
uninstall.agent = Some(HookAgentArg::Claude);
uninstall.uninstall = true;
assert_eq!(run_setup_hooks(&uninstall), ExitCode::SUCCESS);
assert!(!tmp.path().join(".claude/hooks/fallow-gate.sh").exists());
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(
parsed.get("hooks").is_none(),
"expected empty hooks block to collapse"
);
}
#[test]
fn uninstall_preserves_non_fallow_handlers() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let existing = r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bun run lint" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-gate.sh" }
]
}
]
},
"env": { "FOO": "bar" }
}"#;
std::fs::write(tmp.path().join(".claude/settings.json"), existing).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
o.uninstall = true;
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
let group_hooks = parsed["hooks"]["PreToolUse"][0]["hooks"]
.as_array()
.unwrap();
assert_eq!(group_hooks.len(), 1);
assert_eq!(group_hooks[0]["command"], "bun run lint");
assert_eq!(parsed["env"]["FOO"], "bar");
}
#[test]
fn uninstall_is_idempotent_when_nothing_to_remove() {
let tmp = tempdir().unwrap();
let mut o = opts(tmp.path());
o.uninstall = true;
assert_eq!(run_setup_hooks(&o), ExitCode::from(2));
}
#[test]
fn uninstall_idempotent_when_surfaces_present_but_clean() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
std::fs::write(
tmp.path().join(".claude/settings.json"),
r#"{"env":{"FOO":"bar"}}"#,
)
.unwrap();
std::fs::write(tmp.path().join("AGENTS.md"), "# agents\n").unwrap();
let mut o = opts(tmp.path());
o.uninstall = true;
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let raw = std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
assert_eq!(raw.trim(), r#"{"env":{"FOO":"bar"}}"#);
let agents = std::fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap();
assert_eq!(agents, "# agents\n");
}
#[test]
fn uninstall_preserves_user_edited_script() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude/hooks")).unwrap();
let script_path = tmp.path().join(".claude/hooks/fallow-gate.sh");
std::fs::write(&script_path, "#!/bin/sh\necho user-owned\n").unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
o.uninstall = true;
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
assert!(script_path.is_file());
let kept = std::fs::read_to_string(&script_path).unwrap();
assert_eq!(kept, "#!/bin/sh\necho user-owned\n");
}
#[test]
fn uninstall_force_removes_user_edited_script() {
let tmp = tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".claude/hooks")).unwrap();
let script_path = tmp.path().join(".claude/hooks/fallow-gate.sh");
std::fs::write(&script_path, "#!/bin/sh\necho user-owned\n").unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Claude);
o.uninstall = true;
o.force = true;
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
assert!(!script_path.exists());
}
#[test]
fn uninstall_removes_managed_block_from_agents() {
let tmp = tempdir().unwrap();
let agents_path = tmp.path().join("AGENTS.md");
let seeded = format!(
"# agents\n\nprose before.\n\n---\n\n{AGENTS_BLOCK_START}\n{AGENTS_BLOCK_BODY}{AGENTS_BLOCK_END}\n"
);
std::fs::write(&agents_path, seeded).unwrap();
let mut o = opts(tmp.path());
o.agent = Some(HookAgentArg::Codex);
o.uninstall = true;
assert_eq!(run_setup_hooks(&o), ExitCode::SUCCESS);
let contents = std::fs::read_to_string(&agents_path).unwrap();
assert!(!contents.contains(AGENTS_BLOCK_START));
assert!(!contents.contains("Fallow local gate"));
assert!(contents.contains("prose before."));
}
#[test]
fn uninstall_dry_run_does_not_touch_files() {
let tmp = tempdir().unwrap();
let mut install = opts(tmp.path());
install.agent = Some(HookAgentArg::Claude);
assert_eq!(run_setup_hooks(&install), ExitCode::SUCCESS);
let mut dry = opts(tmp.path());
dry.agent = Some(HookAgentArg::Claude);
dry.uninstall = true;
dry.dry_run = true;
assert_eq!(run_setup_hooks(&dry), ExitCode::SUCCESS);
assert!(tmp.path().join(".claude/settings.json").is_file());
assert!(tmp.path().join(".claude/hooks/fallow-gate.sh").is_file());
}
}