use colored::Colorize;
use std::path::PathBuf;
use std::process::ExitCode;
use super::agent::handle_agent_hook_install;
use super::config::create_hook_config;
use super::metadata::{
apply_yes_fallback, deduplicate_hook_events, deduplicate_hook_types, save_installed_hook,
};
use super::script::{
agent_fix_bin, build_thin_wrapper_script, merge_model_into_provider_args,
parse_provider_with_model, resolve_agent_fix_provider, shell_agent_availability_check,
shell_timer_functions,
};
use super::{find_git_root, global_hooks_dir, write_hook_script};
use crate::cli::commands::{AgentFixProvider, AgentProvider, HookEvent, HookTool};
type NamedConstructorList<T> = [(&'static str, fn() -> T)];
pub(crate) fn prompt_hook_types(show_all: bool) -> Option<Vec<HookTool>> {
use std::io::{self, Write};
const TYPES: &NamedConstructorList<HookTool> = &[
("git", || HookTool::Git),
("git-with-agent", || HookTool::GitWithAgent),
("prek", || HookTool::Prek),
("prek-with-agent", || HookTool::PrekWithAgent),
("agent", || HookTool::Agent),
];
let all_idx = TYPES.len() + 1;
let cancel_idx = if show_all {
TYPES.len() + 2
} else {
TYPES.len() + 1
};
println!("\nSelect hook type(s) [comma-separated, e.g. 1,2]:");
for (i, (name, _)) in TYPES.iter().enumerate() {
println!(" {}. {}", i + 1, name);
}
if show_all {
println!(" {}. all", all_idx);
}
println!(" {}. Cancel", cancel_idx);
print!("\n> ");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
let input = input.trim();
if input.is_empty() || input == cancel_idx.to_string() {
return None;
}
if show_all && (input == all_idx.to_string() || input.eq_ignore_ascii_case("all")) {
return Some(TYPES.iter().map(|(_, f)| f()).collect());
}
let selected: Vec<HookTool> = input
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n >= 1 && n <= TYPES.len())
.map(|n| (TYPES[n - 1].1)())
.collect();
if selected.is_empty() {
None
} else {
Some(selected)
}
}
pub(crate) fn prompt_hook_events(show_all: bool) -> Option<Vec<HookEvent>> {
use std::io::{self, Write};
const EVENTS: &NamedConstructorList<HookEvent> = &[
("pre-commit", || HookEvent::PreCommit),
("commit-msg", || HookEvent::CommitMsg),
("pre-push", || HookEvent::PrePush),
];
let all_idx = EVENTS.len() + 1;
let cancel_idx = if show_all {
EVENTS.len() + 2
} else {
EVENTS.len() + 1
};
println!("\nSelect event(s) [comma-separated, e.g. 1,2]:");
for (i, (name, _)) in EVENTS.iter().enumerate() {
println!(" {}. {}", i + 1, name);
}
if show_all {
println!(" {}. all", all_idx);
}
println!(" {}. Cancel", cancel_idx);
print!("\n> ");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
let input = input.trim();
if input.is_empty() || input == cancel_idx.to_string() {
return None;
}
if show_all && (input == all_idx.to_string() || input.eq_ignore_ascii_case("all")) {
return Some(EVENTS.iter().map(|(_, f)| f()).collect());
}
let selected: Vec<HookEvent> = input
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n >= 1 && n <= EVENTS.len())
.map(|n| (EVENTS[n - 1].1)())
.collect();
if selected.is_empty() {
None
} else {
Some(selected)
}
}
pub(crate) fn resolve_install_types_events(
hook_types: Vec<HookTool>,
hook_events: Vec<HookEvent>,
yes: bool,
) -> Result<(Vec<HookTool>, Vec<HookEvent>), ExitCode> {
if yes {
return Ok(apply_yes_fallback(
deduplicate_hook_types(hook_types),
deduplicate_hook_events(hook_events),
));
}
let types = resolve_or_prompt_types(hook_types, false, "Installation cancelled")?;
let events = resolve_or_prompt_events(hook_events, false, "Installation cancelled")?;
Ok((types, events))
}
fn resolve_or_prompt_types(
hook_types: Vec<HookTool>,
show_all: bool,
cancel_msg: &str,
) -> Result<Vec<HookTool>, ExitCode> {
let types = deduplicate_hook_types(hook_types);
if types.is_empty() {
match prompt_hook_types(show_all) {
Some(t) => Ok(t),
None => {
println!("{}", cancel_msg);
Err(ExitCode::SUCCESS)
}
}
} else {
Ok(types)
}
}
fn resolve_or_prompt_events(
hook_events: Vec<HookEvent>,
show_all: bool,
cancel_msg: &str,
) -> Result<Vec<HookEvent>, ExitCode> {
let events = deduplicate_hook_events(hook_events);
if events.is_empty() {
match prompt_hook_events(show_all) {
Some(e) => Ok(e),
None => {
println!("{}", cancel_msg);
Err(ExitCode::SUCCESS)
}
}
} else {
Ok(events)
}
}
pub(crate) fn resolve_uninstall_types_events(
hook_types: Vec<HookTool>,
hook_events: Vec<HookEvent>,
skip_prompt: bool,
) -> Result<(Vec<HookTool>, Vec<HookEvent>), ExitCode> {
if skip_prompt {
return Ok((
deduplicate_hook_types(hook_types),
deduplicate_hook_events(hook_events),
));
}
let types = resolve_or_prompt_types(hook_types, true, "Uninstall cancelled")?;
let events = resolve_or_prompt_events(hook_events, true, "Uninstall cancelled")?;
Ok((types, events))
}
fn parse_agent_provider(name: &str) -> Option<AgentProvider> {
match name.to_lowercase().as_str() {
"claude" => Some(AgentProvider::Claude),
"codex" => Some(AgentProvider::Codex),
"gemini" => Some(AgentProvider::Gemini),
"cursor" => Some(AgentProvider::Cursor),
"droid" => Some(AgentProvider::Droid),
"auggie" | "aug" | "augment" => Some(AgentProvider::Auggie),
"codebuddy" => Some(AgentProvider::Codebuddy),
"openclaw" => Some(AgentProvider::Openclaw),
_ => {
eprintln!(
"{}: Unknown agent provider '{}'. Valid options: claude, codex, gemini, cursor, droid, auggie, codebuddy, openclaw",
"Error".red(), name
);
None
}
}
}
pub(crate) struct HookInstallParams {
pub hook_types: Vec<HookTool>,
pub hook_events: Vec<HookEvent>,
pub force: bool,
pub yes: bool,
pub global: bool,
pub provider: Option<String>,
pub args: Option<String>,
pub provider_args: Option<String>,
}
pub(crate) fn handle_hook_install(params: HookInstallParams) -> ExitCode {
let mut overall = ExitCode::SUCCESS;
let HookInstallParams {
hook_types,
hook_events,
force,
yes,
global,
provider,
args,
provider_args,
} = params;
let (provider, provider_args) = if let Some(ref raw) = provider {
let (name, model) = parse_provider_with_model(raw);
let merged = merge_model_into_provider_args(model, provider_args.as_deref());
(Some(name.to_string()), merged)
} else {
(provider, provider_args)
};
let preresolved_fix_provider: Option<AgentFixProvider> =
if hook_types.iter().any(|t| t.has_agent_fix()) {
match resolve_agent_fix_provider(provider.as_deref(), yes) {
Ok(p) => Some(p),
Err(e) => return e,
}
} else {
None
};
for hook_type in &hook_types {
for hook_event in &hook_events {
let code = handle_hook_install_single(&HookInstallSingleParams {
hook_type: Some(hook_type.clone()),
hook_event: hook_event.clone(),
force,
yes,
global,
provider: provider.clone(),
preresolved_fix_provider: preresolved_fix_provider.clone(),
args: args.clone(),
provider_args: provider_args.clone(),
});
if code != ExitCode::SUCCESS {
overall = code;
}
}
}
overall
}
fn print_existing_hook_analysis(content: &str) {
let has_linthis = content.contains("linthis");
let has_prek =
content.contains("prek") || std::path::Path::new(".pre-commit-config.yaml").exists();
let has_precommit = content.contains("pre-commit");
let has_husky = content.contains("husky");
println!("\nDetected hook content:");
if has_linthis {
println!(" {} linthis", "✓".green());
}
if has_prek {
println!(" {} prek/pre-commit framework", "⚠".yellow());
}
if has_precommit && !has_prek {
println!(" {} pre-commit hooks", "⚠".yellow());
}
if has_husky {
println!(" {} husky", "⚠".yellow());
}
}
fn prompt_existing_hook_action(
hook_path: &std::path::Path,
hook_filename: &str,
hook_type: &Option<HookTool>,
hook_event: &HookEvent,
args: &Option<String>,
) -> ExitCode {
use std::io::{self, Write};
println!("\nOptions:");
println!(
" 1. {} - Replace existing hook with linthis",
"Replace".cyan()
);
println!(" 2. {} - Append linthis to existing hook", "Append".cyan());
println!(" 3. {} - Create backup and replace", "Backup".cyan());
println!(" 4. {} - Cancel", "Cancel".cyan());
print!("\nChoose an option [1-4]: ");
io::stdout().flush().unwrap();
let mut choice = String::new();
io::stdin().read_line(&mut choice).ok();
match choice.trim() {
"1" => handle_hook_install_impl(hook_type.clone(), hook_event, true, false, args.clone()),
"2" => handle_hook_install_impl(hook_type.clone(), hook_event, false, true, args.clone()),
"3" => {
let backup_path = hook_path.with_extension(format!("{}.backup", hook_filename));
if let Err(e) = std::fs::copy(hook_path, &backup_path) {
eprintln!("{}: Failed to create backup: {}", "Error".red(), e);
return ExitCode::from(2);
}
println!(
"{} Created backup at {}",
"✓".green(),
backup_path.display()
);
handle_hook_install_impl(hook_type.clone(), hook_event, true, false, args.clone())
}
_ => {
println!("Installation cancelled");
ExitCode::SUCCESS
}
}
}
struct HookInstallSingleParams {
hook_type: Option<HookTool>,
hook_event: HookEvent,
force: bool,
yes: bool,
global: bool,
provider: Option<String>,
preresolved_fix_provider: Option<AgentFixProvider>,
args: Option<String>,
provider_args: Option<String>,
}
fn install_with_agent_hook(params: &HookInstallSingleParams) -> ExitCode {
let hook_type = params.hook_type.as_ref().unwrap();
let fix_provider = if let Some(p) = params.preresolved_fix_provider.clone() {
p
} else {
match resolve_agent_fix_provider(params.provider.as_deref(), params.yes) {
Ok(p) => p,
Err(e) => return e,
}
};
let base = hook_type.base_tool().clone();
match &base {
HookTool::Git => handle_git_with_agent_install(
¶ms.hook_event,
params.force,
params.global,
params.yes,
&fix_provider,
¶ms.args,
params.provider_args.as_deref(),
),
HookTool::Prek => handle_precommit_with_agent_install(
&base,
¶ms.hook_event,
params.force,
&fix_provider,
¶ms.args,
),
_ => ExitCode::from(1),
}
}
fn handle_hook_install_single(params: &HookInstallSingleParams) -> ExitCode {
if params
.hook_type
.as_ref()
.map(|t| t.has_agent_fix())
.unwrap_or(false)
{
return install_with_agent_hook(params);
}
if matches!(params.hook_type, Some(HookTool::Agent)) {
let agent_provider = params.provider.as_deref().and_then(parse_agent_provider);
if params.provider.is_some() && agent_provider.is_none() {
return ExitCode::from(1);
}
return handle_agent_hook_install(
agent_provider,
std::slice::from_ref(¶ms.hook_event),
params.force,
params.yes,
params.global,
);
}
if params.global {
return handle_global_hook_install(
params.hook_type.clone(),
¶ms.hook_event,
params.force,
params.yes,
¶ms.args,
params.provider_args.as_deref(),
);
}
let git_root = match find_git_root() {
Some(root) => root,
None => {
eprintln!("{}: Not in a git repository", "Error".red());
eprintln!(" Run this command from within a git repository");
return ExitCode::from(1);
}
};
let hook_filename = params.hook_event.hook_filename();
let hook_path = git_root.join(".git/hooks").join(hook_filename);
let is_empty_hook = hook_path.exists()
&& std::fs::read_to_string(&hook_path)
.map(|s| s.trim().is_empty())
.unwrap_or(false);
if hook_path.exists() && !params.force && !is_empty_hook {
return handle_existing_hook_conflict(
&hook_path,
hook_filename,
¶ms.hook_type,
¶ms.hook_event,
params.yes,
¶ms.args,
);
}
handle_hook_install_impl(
params.hook_type.clone(),
¶ms.hook_event,
params.force,
false,
params.args.clone(),
)
}
fn handle_existing_hook_conflict(
hook_path: &std::path::Path,
hook_filename: &str,
hook_type: &Option<HookTool>,
hook_event: &HookEvent,
yes: bool,
args: &Option<String>,
) -> ExitCode {
println!(
"{}: {} already exists",
"Warning".yellow(),
hook_path.display()
);
if let Ok(existing_content) = std::fs::read_to_string(hook_path) {
print_existing_hook_analysis(&existing_content);
if !yes {
return prompt_existing_hook_action(
hook_path,
hook_filename,
hook_type,
hook_event,
args,
);
}
return handle_hook_install_impl(hook_type.clone(), hook_event, false, true, args.clone());
}
println!(
" Use {} to overwrite, or {} to append",
"--force".yellow(),
"choose option 2".cyan()
);
ExitCode::from(1)
}
fn handle_hook_install_impl(
hook_type: Option<HookTool>,
hook_event: &HookEvent,
force: bool,
append: bool,
args: Option<String>,
) -> ExitCode {
let tool = hook_type.unwrap_or(HookTool::Git);
if append {
if let Err(exit_code) = create_hook_config(&tool, hook_event, false, &args) {
return exit_code;
}
} else if let Err(exit_code) = create_hook_config(&tool, hook_event, force, &args) {
return exit_code;
}
ExitCode::SUCCESS
}
pub(crate) fn confirm_global_install(hook_filename: &str, hook_path: &std::path::Path) -> bool {
use std::io::{self, Write};
println!(
"This will install a global {} hook at {}",
hook_filename.cyan(),
hook_path.display()
);
println!(
"and set {} in your global git config.",
"core.hooksPath".cyan()
);
print!("Continue? [y/N]: ");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
pub(crate) fn check_existing_global_hook(
hook_path: &std::path::Path,
hook_filename: &str,
force: bool,
) -> Result<(), ExitCode> {
if !hook_path.exists() || force {
return Ok(());
}
if let Ok(existing) = std::fs::read_to_string(hook_path) {
if existing.trim().is_empty() {
return Ok(()); }
if existing.contains("# linthis-hook") || existing.contains("linthis hook run") {
println!(
"{}: Global {} hook already installed at {}",
"Info".cyan(),
hook_filename,
hook_path.display()
);
return Err(ExitCode::SUCCESS);
}
eprintln!(
"{}: {} already exists (not by linthis). Use --force to overwrite.",
"Warning".yellow(),
hook_path.display()
);
return Err(ExitCode::from(1));
}
Ok(())
}
fn set_global_hooks_path_config(
hook_filename: &str,
hook_path: &std::path::Path,
hooks_dir_str: &str,
) {
let git_config_result = std::process::Command::new("git")
.args(["config", "--global", "core.hooksPath", hooks_dir_str])
.status();
match git_config_result {
Ok(status) if status.success() => {
println!(
"{} Installed global {} hook → {}",
"✓".green(),
hook_filename,
hook_path.display()
);
println!(
"{} Set {} = {}",
"✓".green(),
"core.hooksPath".cyan(),
hooks_dir_str
);
println!(
" {} Thin wrapper: hook logic auto-updates with linthis",
"→".dimmed()
);
println!();
println!(
" {}",
"How it works (local takes priority):".dimmed()
);
println!(
" {} If local hook has linthis → global delegates entirely",
"·".dimmed()
);
println!(
" {} If local hook has no linthis → global runs linthis first, then delegates",
"·".dimmed()
);
println!(
" {} No local hook → global runs linthis directly",
"·".dimmed()
);
}
Ok(_) | Err(_) => {
println!(
"{} Installed global {} hook → {}",
"✓".green(),
hook_filename,
hook_path.display()
);
eprintln!(
"{}: Failed to set core.hooksPath automatically. Run manually:\n git config --global core.hooksPath {}",
"Warning".yellow(),
hooks_dir_str
);
}
}
}
pub(crate) fn handle_global_hook_install(
hook_type: Option<HookTool>,
hook_event: &HookEvent,
force: bool,
yes: bool,
args: &Option<String>,
provider_args: Option<&str>,
) -> ExitCode {
use std::fs;
let fix_provider: Option<AgentFixProvider> = if hook_type
.as_ref()
.map(|t| t.has_agent_fix())
.unwrap_or(false)
{
match resolve_agent_fix_provider(None, yes) {
Ok(p) => Some(p),
Err(e) => return e,
}
} else {
None
};
let hooks_dir = match global_hooks_dir() {
Some(d) => d,
None => {
eprintln!("{}: Could not determine home directory", "Error".red());
return ExitCode::from(1);
}
};
let hook_filename = hook_event.hook_filename();
let hook_path = hooks_dir.join(hook_filename);
if !yes && !confirm_global_install(hook_filename, &hook_path) {
println!("Installation cancelled");
return ExitCode::SUCCESS;
}
if let Err(code) = check_existing_global_hook(&hook_path, hook_filename, force) {
return code;
}
if let Err(e) = fs::create_dir_all(&hooks_dir) {
eprintln!(
"{}: Failed to create {}: {}",
"Error".red(),
hooks_dir.display(),
e
);
return ExitCode::from(2);
}
let effective_hook_type = hook_type.clone().unwrap_or(HookTool::Git);
let provider_str = fix_provider.as_ref().map(|p| p.as_str());
let content = build_thin_wrapper_script(
hook_event,
&effective_hook_type,
provider_str,
true,
provider_args,
);
let _ = args;
if let Err(code) = write_hook_script(&hook_path, &content) {
return code;
}
let hooks_dir_str = hooks_dir.to_string_lossy().to_string();
set_global_hooks_path_config(hook_filename, &hook_path, &hooks_dir_str);
save_installed_hook(
"global",
"",
hook_event,
&effective_hook_type,
provider_str,
provider_args,
);
ExitCode::SUCCESS
}
fn resolve_hook_install_target(
hook_filename: &str,
global: bool,
) -> Result<(PathBuf, &'static str, String), ExitCode> {
if global {
let hooks_dir = match global_hooks_dir() {
Some(d) => d,
None => {
eprintln!(
"{}: Could not determine global hooks directory",
"Error".red()
);
return Err(ExitCode::from(1));
}
};
Ok((hooks_dir.join(hook_filename), "global", String::new()))
} else {
let git_root = match find_git_root() {
Some(root) => root,
None => {
eprintln!("{}: Not in a git repository", "Error".red());
return Err(ExitCode::from(1));
}
};
let project_str = git_root.to_str().unwrap_or("").to_string();
Ok((
git_root.join(".git/hooks").join(hook_filename),
"local",
project_str,
))
}
}
fn handle_git_with_agent_install(
hook_event: &HookEvent,
force: bool,
global: bool,
yes: bool,
fix_provider: &AgentFixProvider,
args: &Option<String>,
provider_args: Option<&str>,
) -> ExitCode {
use std::fs;
let hook_filename = hook_event.hook_filename();
let _ = args;
let (hook_path, scope, project) = match resolve_hook_install_target(hook_filename, global) {
Ok(t) => t,
Err(code) => return code,
};
if global && !yes && !confirm_global_install(hook_filename, &hook_path) {
println!("Installation cancelled");
return ExitCode::SUCCESS;
}
let content = build_thin_wrapper_script(
hook_event,
&HookTool::GitWithAgent,
Some(fix_provider.as_str()),
global,
provider_args,
);
if let Err(code) = check_existing_global_hook(&hook_path, hook_filename, force) {
return code;
}
if let Some(parent) = hook_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!("{}: Failed to create hooks directory: {}", "Error".red(), e);
return ExitCode::from(2);
}
}
if let Err(code) = write_hook_script(&hook_path, &content) {
return code;
}
println!(
"{} Created {} (git-with-agent, {})",
"✓".green(),
hook_path.display(),
fix_provider
);
println!(
" {} On lint failure: {}",
"→".dimmed(),
agent_fix_bin(fix_provider).cyan()
);
println!(
" {} Thin wrapper: hook logic auto-updates with linthis",
"→".dimmed()
);
if global {
if let Some(hooks_dir) = global_hooks_dir() {
let hooks_dir_str = hooks_dir.to_string_lossy().to_string();
let _ = std::process::Command::new("git")
.args(["config", "--global", "core.hooksPath", &hooks_dir_str])
.status();
println!(
"{} Set {} = {}",
"✓".green(),
"core.hooksPath".cyan(),
hooks_dir_str
);
}
}
save_installed_hook(
scope,
&project,
hook_event,
&HookTool::GitWithAgent,
Some(fix_provider.as_str()),
provider_args,
);
ExitCode::SUCCESS
}
fn handle_precommit_with_agent_install(
base_tool: &HookTool,
hook_event: &HookEvent,
force: bool,
fix_provider: &AgentFixProvider,
args: &Option<String>,
) -> ExitCode {
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
if let Err(exit) = create_hook_config(base_tool, hook_event, force, args) {
return exit;
}
let git_root = match find_git_root() {
Some(root) => root,
None => return ExitCode::from(1),
};
let tool_cmd = match base_tool {
HookTool::Prek => "prek run",
_ => return ExitCode::from(1),
};
let prompt = format!(
"The {tool} pre-commit check failed with lint errors. \
Run '{tool_cmd}' to see them. Fix all issues by editing the files directly. \
Verify by running '{tool_cmd}' again until it passes.",
tool = fix_provider,
tool_cmd = tool_cmd,
);
let agent_cmd = super::script::agent_fix_headless_cmd(fix_provider, &prompt, None);
let timer_fns = shell_timer_functions();
let agent_check = shell_agent_availability_check(fix_provider);
let wrapper = format!(
"#!/bin/sh\n\
{timer}\
{tool_cmd}\n\
EXIT=$?\n\
\n\
if [ $EXIT -ne 0 ]; then\n\
\x20 # Check if agent provider is available before attempting fix\n\
\x20 {agent_check}\
\x20 if [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
\x20\x20\x20 echo \"[linthis] Errors detected. Invoking {provider} to fix...\" >&2\n\
\x20\x20\x20 start_timer \"Fixing with {provider}\"\n\
\x20\x20\x20 {agent}\n\
\x20\x20\x20 stop_timer\n\
\x20\x20\x20 echo \"[linthis] Re-verifying...\" >&2\n\
\x20\x20\x20 {tool_cmd}\n\
\x20\x20\x20 EXIT=$?\n\
\x20 fi\n\
fi\n\
\n\
exit $EXIT\n",
timer = timer_fns,
tool_cmd = tool_cmd,
provider = fix_provider,
agent = agent_cmd,
agent_check = agent_check,
);
let hook_filename = hook_event.hook_filename();
let hook_path = git_root.join(".git/hooks").join(hook_filename);
match fs::write(&hook_path, &wrapper) {
Ok(_) => {
#[cfg(unix)]
{
if let Ok(meta) = fs::metadata(&hook_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&hook_path, perms);
}
}
println!(
"{} Created wrapper {} ({}-with-agent, {})",
"✓".green(),
hook_path.display(),
match base_tool {
HookTool::Prek => "prek",
_ => "pre-commit",
},
fix_provider
);
println!(
" {} On failure: {}",
"→".dimmed(),
agent_fix_bin(fix_provider).cyan()
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{}: Failed to create wrapper hook: {}", "Error".red(), e);
ExitCode::from(2)
}
}
}