use anyhow::{Context, Result};
use clap::{Arg, Command};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
#[derive(Debug, Deserialize, Serialize)]
struct Config {
commands: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct HookInput {
#[allow(dead_code)]
session_id: String,
#[allow(dead_code)]
transcript_path: String,
#[allow(dead_code)]
cwd: String,
#[allow(dead_code)]
hook_event_name: String,
tool_name: String,
tool_input: ToolInput,
}
#[derive(Debug, Deserialize)]
struct ToolInput {
command: String,
#[allow(dead_code)]
description: Option<String>,
}
#[derive(Debug, Serialize)]
struct HookOutput {
decision: String,
reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
replacement_command: Option<String>,
}
fn main() -> Result<()> {
let matches = Command::new("claude-hook-advisor")
.version(env!("CARGO_PKG_VERSION"))
.about("Advises Claude Code on better command alternatives based on project preferences")
.arg(
Arg::new("config")
.short('c')
.long("config")
.value_name("FILE")
.help("Path to configuration file")
.default_value(".claude-hook-advisor.toml"),
)
.arg(
Arg::new("hook")
.long("hook")
.help("Run as a Claude Code hook (reads JSON from stdin)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("replace")
.long("replace")
.help("Replace commands instead of blocking (experimental, not yet supported by Claude Code)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("install")
.long("install")
.help("Install and configure Claude Hook Advisor for this project")
.action(clap::ArgAction::SetTrue),
)
.get_matches();
let config_path = matches.get_one::<String>("config").unwrap();
let replace_mode = matches.get_flag("replace");
if matches.get_flag("hook") {
run_as_hook(config_path, replace_mode)
} else if matches.get_flag("install") {
run_installer(config_path)
} else {
println!("Claude Hook Advisor");
println!("Use --hook flag to run as a Claude Code hook");
println!("Use --install flag to set up configuration for this project");
Ok(())
}
}
fn run_as_hook(config_path: &str, replace_mode: bool) -> Result<()> {
let config = load_config(config_path)?;
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
let hook_input: HookInput =
serde_json::from_str(&buffer).context("Failed to parse hook input JSON")?;
if hook_input.tool_name != "Bash" {
return Ok(());
}
let command = &hook_input.tool_input.command;
if let Some((suggestion, replacement_cmd)) = check_command_mappings(&config, command)? {
let output = if replace_mode {
HookOutput {
decision: "replace".to_string(),
reason: format!("Command mapped: using '{replacement_cmd}' instead"),
replacement_command: Some(replacement_cmd),
}
} else {
HookOutput {
decision: "block".to_string(),
reason: suggestion,
replacement_command: None,
}
};
println!("{}", serde_json::to_string(&output)?);
std::process::exit(0);
}
Ok(())
}
fn load_config(config_path: &str) -> Result<Config> {
if !Path::new(config_path).exists() {
eprintln!("Warning: Config file '{config_path}' not found. No command mappings will be applied.");
return Ok(Config {
commands: HashMap::new(),
});
}
let content = fs::read_to_string(config_path)
.with_context(|| format!("Failed to read config file: {config_path}"))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {config_path}"))?;
Ok(config)
}
fn check_command_mappings(config: &Config, command: &str) -> Result<Option<(String, String)>> {
for (pattern, replacement) in &config.commands {
let regex_pattern = format!(r"\b{}\b", regex::escape(pattern));
let regex = Regex::new(®ex_pattern)?;
if regex.is_match(command) {
let suggested_command = regex.replace_all(command, replacement);
let suggestion = format!(
"Command '{pattern}' is mapped to use '{replacement}' instead. Try: {suggested_command}"
);
return Ok(Some((suggestion, suggested_command.to_string())));
}
}
Ok(None)
}
fn run_installer(config_path: &str) -> Result<()> {
println!("🚀 Claude Hook Advisor Installer");
println!("==================================");
if Path::new(config_path).exists() {
println!("⚠️ Configuration file '{config_path}' already exists.");
print!("Do you want to overwrite it? (y/N): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
println!("Installation cancelled.");
return Ok(());
}
}
let project_type = detect_project_type()?;
let config_content = generate_config_for_project(&project_type);
fs::write(config_path, &config_content)
.with_context(|| format!("Failed to write config file: {config_path}"))?;
println!("✅ Created configuration file: {config_path}");
println!("📋 Configuration type: {project_type}");
println!();
println!("📝 Command mappings configured:");
let config: Config = toml::from_str(&config_content)?;
for (from, to) in &config.commands {
println!(" {from} → {to}");
}
println!();
print_claude_integration_instructions()?;
println!("🎉 Installation complete! Claude Hook Advisor is ready to use.");
Ok(())
}
fn detect_project_type() -> Result<String> {
let current_dir = std::env::current_dir()?;
if current_dir.join("package.json").exists() {
return Ok("Node.js".to_string());
}
if current_dir.join("requirements.txt").exists()
|| current_dir.join("pyproject.toml").exists()
|| current_dir.join("setup.py").exists()
{
return Ok("Python".to_string());
}
if current_dir.join("Cargo.toml").exists() {
return Ok("Rust".to_string());
}
if current_dir.join("go.mod").exists() {
return Ok("Go".to_string());
}
if current_dir.join("pom.xml").exists() || current_dir.join("build.gradle").exists() {
return Ok("Java".to_string());
}
if current_dir.join("Dockerfile").exists() {
return Ok("Docker".to_string());
}
Ok("General".to_string())
}
fn generate_config_for_project(project_type: &str) -> String {
let commands = get_commands_for_project_type(project_type);
let config = Config { commands };
let header = format!(
"# Claude Hook Advisor Configuration\n# Auto-generated for {project_type} project\n\n"
);
match toml::to_string_pretty(&config) {
Ok(toml_content) => format!("{header}{toml_content}"),
Err(_) => {
format!("{header}[commands]\n# Basic configuration\n")
}
}
}
fn get_commands_for_project_type(project_type: &str) -> HashMap<String, String> {
let mut commands = HashMap::new();
match project_type {
"Node.js" => {
commands.insert("npm".to_string(), "bun".to_string());
commands.insert("yarn".to_string(), "bun".to_string());
commands.insert("pnpm".to_string(), "bun".to_string());
commands.insert("npx".to_string(), "bunx".to_string());
commands.insert("npm start".to_string(), "bun dev".to_string());
commands.insert("npm test".to_string(), "bun test".to_string());
commands.insert("npm run build".to_string(), "bun run build".to_string());
}
"Python" => {
commands.insert("pip".to_string(), "uv pip".to_string());
commands.insert("pip install".to_string(), "uv add".to_string());
commands.insert("pip uninstall".to_string(), "uv remove".to_string());
commands.insert("python".to_string(), "uv run python".to_string());
commands.insert("python -m".to_string(), "uv run python -m".to_string());
}
"Rust" => {
commands.insert("cargo check".to_string(), "cargo clippy".to_string());
commands.insert("cargo test".to_string(), "cargo test -- --nocapture".to_string());
}
"Go" => {
commands.insert("go run".to_string(), "go run -race".to_string());
commands.insert("go test".to_string(), "go test -v".to_string());
}
"Java" => {
commands.insert("mvn".to_string(), "./mvnw".to_string());
commands.insert("gradle".to_string(), "./gradlew".to_string());
}
"Docker" => {
commands.insert("docker".to_string(), "podman".to_string());
commands.insert("docker-compose".to_string(), "podman-compose".to_string());
}
_ => {
commands.insert("cat".to_string(), "bat".to_string());
commands.insert("ls".to_string(), "eza".to_string());
commands.insert("grep".to_string(), "rg".to_string());
commands.insert("find".to_string(), "fd".to_string());
}
}
commands.insert("curl".to_string(), "curl -L".to_string());
commands.insert("rm".to_string(), "trash".to_string());
commands.insert("rm -rf".to_string(), "echo 'Use trash command for safety'".to_string());
commands
}
fn print_claude_integration_instructions() -> Result<()> {
let binary_path = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "claude-hook-advisor".to_string());
const HEADER: &str = r#"🔧 Claude Code Integration Setup:
==================================
To integrate with Claude Code, you have several options:
Option 1: Using the /hooks command in Claude Code
1. Run `/hooks` in Claude Code
2. Select `PreToolUse`
3. Add matcher: `Bash`"#;
const JSON_TEMPLATE: &str = r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{} --hook"
}}
]
}}
]
}}
}}"#;
print!(
r#"{HEADER}
4. Add hook command: `{binary_path} --hook`
5. Save to project settings
Option 2: Manual .claude/settings.json configuration
Add this to your .claude/settings.json:
{json_config}
"#,
binary_path = binary_path,
json_config = JSON_TEMPLATE.replace("{}", &binary_path)
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_mapping() {
let mut commands = HashMap::new();
commands.insert("npm".to_string(), "bun".to_string());
commands.insert("yarn".to_string(), "bun".to_string());
commands.insert("npx".to_string(), "bunx".to_string());
let config = Config { commands };
let result = check_command_mappings(&config, "npm install").unwrap();
assert!(result.is_some());
let (suggestion, replacement) = result.unwrap();
assert!(suggestion.contains("bun install"));
assert_eq!(replacement, "bun install");
let result = check_command_mappings(&config, "yarn start").unwrap();
assert!(result.is_some());
let (suggestion, replacement) = result.unwrap();
assert!(suggestion.contains("bun start"));
assert_eq!(replacement, "bun start");
let result = check_command_mappings(&config, "ls -la").unwrap();
assert!(result.is_none());
}
}