1use crate::hooks::run_as_hook;
4use crate::Config;
5use anyhow::{Context, Result};
6use clap::{Arg, Command};
7use std::fs;
8use std::path::Path;
9
10pub fn run_cli() -> Result<()> {
17 let matches = Command::new("claude-hook-advisor")
18 .version(env!("CARGO_PKG_VERSION"))
19 .about("Advises Claude Code on better command alternatives based on project preferences")
20 .arg(
21 Arg::new("config")
22 .short('c')
23 .long("config")
24 .value_name("FILE")
25 .help("Path to configuration file")
26 .default_value(".claude-hook-advisor.toml"),
27 )
28 .arg(
29 Arg::new("hook")
30 .long("hook")
31 .help("Run as a Claude Code hook (reads JSON from stdin)")
32 .action(clap::ArgAction::SetTrue),
33 )
34 .arg(
35 Arg::new("replace")
36 .long("replace")
37 .help("Replace commands instead of blocking (experimental, not yet supported by Claude Code)")
38 .action(clap::ArgAction::SetTrue),
39 )
40 .arg(
41 Arg::new("install")
42 .long("install")
43 .help("Install Claude Hook Advisor: configure hooks and create/update config file")
44 .action(clap::ArgAction::SetTrue),
45 )
46 .arg(
47 Arg::new("uninstall")
48 .long("uninstall")
49 .help("Remove Claude Hook Advisor hooks from Claude Code settings")
50 .action(clap::ArgAction::SetTrue),
51 )
52 .arg(
53 Arg::new("history")
54 .long("history")
55 .help("View command history")
56 .action(clap::ArgAction::SetTrue),
57 )
58 .arg(
59 Arg::new("limit")
60 .long("limit")
61 .value_name("N")
62 .help("Limit number of history results (default: 20)")
63 .value_parser(clap::value_parser!(usize)),
64 )
65 .arg(
66 Arg::new("session")
67 .long("session")
68 .value_name("ID")
69 .help("Filter history by session ID"),
70 )
71 .arg(
72 Arg::new("failures")
73 .long("failures")
74 .help("Show only failed commands (non-zero exit codes)")
75 .action(clap::ArgAction::SetTrue),
76 )
77 .arg(
78 Arg::new("pattern")
79 .long("pattern")
80 .value_name("PATTERN")
81 .help("Filter commands by pattern (e.g., 'git', 'npm')"),
82 )
83 .get_matches();
84
85 let config_path = matches.get_one::<String>("config")
86 .expect("config argument has default value");
87 let replace_mode = matches.get_flag("replace");
88
89 if matches.get_flag("hook") {
90 run_as_hook(config_path, replace_mode)
91 } else if matches.get_flag("install") {
92 run_smart_installation(config_path)
93 } else if matches.get_flag("uninstall") {
94 crate::installer::uninstall_claude_hooks()
95 } else if matches.get_flag("history") {
96 let limit = matches.get_one::<usize>("limit").copied();
97 let session_id = matches.get_one::<String>("session").map(|s| s.to_string());
98 let failures_only = matches.get_flag("failures");
99 let pattern = matches.get_one::<String>("pattern").map(|s| s.to_string());
100
101 show_command_history(config_path, limit, session_id, failures_only, pattern)
102 } else {
103 println!("Claude Hook Advisor v{}", env!("CARGO_PKG_VERSION"));
104 println!();
105 println!("Installation:");
106 println!(" --install Install Claude Hook Advisor: configure hooks and create/update config file");
107 println!();
108 println!("Command Mapping:");
109 println!(" --hook Run as a Claude Code hook");
110 println!();
111 println!("Command History:");
112 println!(" --history View command history");
113 println!(" --limit <N> Limit number of results (default: 20)");
114 println!(" --session <ID> Filter by session ID");
115 println!(" --failures Show only failed commands");
116 println!(" --pattern <PATTERN> Filter by command pattern");
117 println!();
118 println!("Configuration:");
119 println!(" -c, --config <FILE> Path to config file [default: .claude-hook-advisor.toml]");
120 println!();
121 println!("To configure directory aliases and command mappings, edit .claude-hook-advisor.toml directly.");
122 Ok(())
123 }
124}
125
126
127fn show_command_history(
140 config_path: &str,
141 limit: Option<usize>,
142 session_id: Option<String>,
143 failures_only: bool,
144 pattern: Option<String>,
145) -> Result<()> {
146 use crate::history;
147
148 let config = crate::config::load_config(config_path)
150 .context("Failed to load configuration")?;
151
152 let history_config = match config.command_history {
154 Some(ref cfg) if cfg.enabled => cfg,
155 Some(_) => {
156 println!("Command history is disabled in configuration.");
157 return Ok(());
158 }
159 None => {
160 println!("Command history is not configured.");
161 println!("Add a [command_history] section to your .claude-hook-advisor.toml:");
162 println!();
163 println!("[command_history]");
164 println!("enabled = true");
165 println!("log_file = \"~/.claude-hook-advisor/bash-history.db\"");
166 return Ok(());
167 }
168 };
169
170 let log_path = expand_tilde_path(&history_config.log_file)?;
172
173 if !log_path.exists() {
175 println!("No command history found at: {}", log_path.display());
176 println!("Commands will be logged once you start using Claude Code with hooks enabled.");
177 return Ok(());
178 }
179
180 let conn = history::init_database(&log_path)
182 .context("Failed to open command history database")?;
183
184 let query = history::HistoryQuery {
186 limit: Some(limit.unwrap_or(20)),
187 session_id,
188 failures_only,
189 command_pattern: pattern,
190 };
191
192 let records = history::query_history(&conn, &query)
194 .context("Failed to query command history")?;
195
196 if records.is_empty() {
197 println!("No commands found matching the specified criteria.");
198 return Ok(());
199 }
200
201 println!("Command History ({} records)", records.len());
203 println!("{}", "=".repeat(80));
204 println!();
205
206 for record in records {
207 let timestamp = record.timestamp;
208 let exit_code_str = match record.exit_code {
209 Some(0) => "ā".to_string(),
210 Some(code) => format!("ā (exit: {})", code),
211 None => "?".to_string(),
212 };
213
214 println!("{} {}", timestamp, exit_code_str);
215 println!(" Command: {}", record.command);
216
217 if let Some(cwd) = record.cwd {
218 println!(" CWD: {}", cwd);
219 }
220
221 if record.was_replaced {
222 if let Some(original) = record.original_command {
223 println!(" Original: {}", original);
224 }
225 }
226
227 println!(" Session: {}", record.session_id);
228 println!();
229 }
230
231 Ok(())
232}
233
234fn expand_tilde_path(path: &str) -> Result<std::path::PathBuf> {
236 if path.starts_with("~/") {
237 let home = std::env::var("HOME")
238 .context("HOME environment variable not set")?;
239 Ok(std::path::PathBuf::from(path.replacen("~", &home, 1)))
240 } else {
241 Ok(std::path::PathBuf::from(path))
242 }
243}
244
245fn run_smart_installation(config_path: &str) -> Result<()> {
259 println!("š Claude Hook Advisor Installation");
260 println!("===================================\n");
261
262 if hooks_already_exist()? {
264 println!("ā
Hooks already installed in Claude Code settings");
265 } else {
266 println!("š Installing hooks into Claude Code settings...");
267 crate::installer::install_claude_hooks()?;
268 println!("ā
Hooks installed successfully");
269 }
270
271 println!("\nš Checking configuration file...");
273 if Path::new(config_path).exists() {
274 println!("ā
Config file exists: {config_path}");
275 ensure_config_sections(config_path)?;
276 } else {
277 println!("š Creating new config file: {config_path}");
278 create_smart_config(config_path)?;
279 }
280
281 println!("\nš Installation complete! Claude Hook Advisor is ready to use.");
282 println!("š” You can now use semantic directory references in Claude Code conversations.");
283
284 Ok(())
285}
286
287fn hooks_already_exist() -> Result<bool> {
294 let local_settings = Path::new(".claude/settings.local.json");
296 let shared_settings = Path::new(".claude/settings.json");
297
298 let settings_path = if local_settings.exists() {
299 local_settings
300 } else if shared_settings.exists() {
301 shared_settings
302 } else {
303 return Ok(false); };
305
306 let settings_content = fs::read_to_string(settings_path)
308 .with_context(|| format!("Failed to read {}", settings_path.display()))?;
309
310 let settings: serde_json::Value = serde_json::from_str(&settings_content)
311 .with_context(|| "Failed to parse Claude settings JSON")?;
312
313 if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
315 for event_name in &["PreToolUse", "UserPromptSubmit"] {
317 if let Some(event_hooks) = hooks.get(*event_name).and_then(|h| h.as_array()) {
318 for hook_group in event_hooks {
319 if let Some(hooks_array) = hook_group.get("hooks").and_then(|h| h.as_array()) {
320 for hook in hooks_array {
321 if let Some(command) = hook.get("command").and_then(|c| c.as_str()) {
322 if command.contains("claude-hook-advisor") {
323 return Ok(true);
324 }
325 }
326 }
327 }
328 }
329 }
330 }
331 }
332
333 Ok(false)
334}
335
336fn create_smart_config(config_path: &str) -> Result<()> {
347 let project_type = detect_project_type()?;
349 println!("š Detected project type: {project_type}");
350
351 let commands = get_commands_for_project_type(&project_type);
353
354 let config = Config {
356 commands,
357 semantic_directories: std::collections::HashMap::new(), command_history: None, };
360
361 let toml_content = toml::to_string_pretty(&config)
363 .with_context(|| "Failed to serialize configuration to TOML")?;
364
365 let _project_name = get_project_name();
367 let final_content = format!(r#"# Claude Hook Advisor Configuration
368# Auto-generated for {project_type} project
369# This file configures command mappings and semantic directory aliases
370# for use with Claude Code integration.
371
372{toml_content}
373# Semantic directory aliases - natural language directory references
374# Uncomment and customize these examples:
375# docs = "~/Documents/Documentation"
376# central_docs = "~/Documents/Documentation"
377# project_docs = "~/Documents/Documentation/my-project"
378# claude_docs = "~/Documents/Documentation/claude"
379
380# Command history tracking - logs all Bash commands Claude runs
381# Uncomment to enable:
382# [command_history]
383# enabled = true
384# log_file = "~/.claude-hook-advisor/bash-history.db"
385"#);
386
387 fs::write(config_path, final_content)
388 .with_context(|| format!("Failed to write config file: {config_path}"))?;
389
390 println!("ā
Created smart configuration for {project_type} project");
391
392 if !config.commands.is_empty() {
394 println!("š Command mappings configured:");
395 for (from, to) in &config.commands {
396 println!(" {from} ā {to}");
397 }
398 } else {
399 println!("š No specific command mappings for {project_type} - using general alternatives");
400 }
401
402 Ok(())
403}
404
405fn detect_project_type() -> Result<String> {
411 let current_dir = std::env::current_dir()?;
412
413 if current_dir.join("package.json").exists() {
415 return Ok("Node.js".to_string());
416 }
417
418 if current_dir.join("requirements.txt").exists()
419 || current_dir.join("pyproject.toml").exists()
420 || current_dir.join("setup.py").exists()
421 {
422 return Ok("Python".to_string());
423 }
424
425 if current_dir.join("Cargo.toml").exists() {
426 return Ok("Rust".to_string());
427 }
428
429 if current_dir.join("go.mod").exists() {
430 return Ok("Go".to_string());
431 }
432
433 if current_dir.join("pom.xml").exists() || current_dir.join("build.gradle").exists() {
434 return Ok("Java".to_string());
435 }
436
437 if current_dir.join("Dockerfile").exists() {
438 return Ok("Docker".to_string());
439 }
440
441 Ok("General".to_string())
442}
443
444fn get_commands_for_project_type(project_type: &str) -> std::collections::HashMap<String, String> {
452 let mut commands = std::collections::HashMap::new();
453
454 match project_type {
455 "Node.js" => {
456 commands.insert("npm".to_string(), "bun".to_string());
457 commands.insert("yarn".to_string(), "bun".to_string());
458 commands.insert("pnpm".to_string(), "bun".to_string());
459 commands.insert("npx".to_string(), "bunx".to_string());
460 commands.insert("npm start".to_string(), "bun dev".to_string());
461 commands.insert("npm test".to_string(), "bun test".to_string());
462 commands.insert("npm run build".to_string(), "bun run build".to_string());
463 }
464 "Python" => {
465 commands.insert("pip".to_string(), "uv pip".to_string());
466 commands.insert("pip install".to_string(), "uv add".to_string());
467 commands.insert("pip uninstall".to_string(), "uv remove".to_string());
468 commands.insert("python".to_string(), "uv run python".to_string());
469 commands.insert("python -m".to_string(), "uv run python -m".to_string());
470 }
471 "Rust" => {
472 commands.insert("cargo check".to_string(), "cargo clippy".to_string());
473 commands.insert("cargo test".to_string(), "cargo test -- --nocapture".to_string());
474 }
475 "Go" => {
476 commands.insert("go run".to_string(), "go run -race".to_string());
477 commands.insert("go test".to_string(), "go test -v".to_string());
478 }
479 "Java" => {
480 commands.insert("mvn".to_string(), "./mvnw".to_string());
481 commands.insert("gradle".to_string(), "./gradlew".to_string());
482 }
483 "Docker" => {
484 commands.insert("docker".to_string(), "podman".to_string());
485 commands.insert("docker-compose".to_string(), "podman-compose".to_string());
486 }
487 _ => {
488 commands.insert("cat".to_string(), "bat".to_string());
490 commands.insert("ls".to_string(), "eza".to_string());
491 commands.insert("grep".to_string(), "rg".to_string());
492 commands.insert("find".to_string(), "fd".to_string());
493 }
494 }
495
496 commands.insert("curl".to_string(), "curl -L".to_string());
498 commands.insert("rm".to_string(), "trash".to_string());
499 commands.insert("rm -rf".to_string(), "echo 'Use trash command for safety'".to_string());
500
501 commands
502}
503
504fn get_project_name() -> String {
506 std::env::current_dir()
507 .ok()
508 .and_then(|dir| dir.file_name().map(|name| name.to_string_lossy().to_string()))
509 .unwrap_or_else(|| "project".to_string())
510}
511
512
513fn ensure_config_sections(config_path: &str) -> Result<()> {
522 let mut config_content = fs::read_to_string(config_path)
523 .with_context(|| format!("Failed to read config file: {config_path}"))?;
524
525 let mut needs_update = false;
526
527 if !config_content.contains("[commands]") {
529 config_content.push_str("\n# Command mappings - suggest alternatives when Claude Code runs these commands\n");
530 config_content.push_str("[commands]\n");
531 config_content.push_str("# npm = \"bun\" # Suggest 'bun' instead of 'npm'\n");
532 config_content.push_str("# yarn = \"bun\" # Suggest 'bun' instead of 'yarn'\n");
533 config_content.push_str("# npx = \"bunx\" # Suggest 'bunx' instead of 'npx'\n");
534 config_content.push_str("# grep = \"rg\" # Suggest 'rg' (ripgrep) instead of 'grep'\n\n");
535 needs_update = true;
536 println!("ā
Added [commands] section with examples");
537 }
538
539 if !config_content.contains("[semantic_directories]") {
540 config_content.push_str("# Semantic directory aliases - natural language directory references\n");
541 config_content.push_str("[semantic_directories]\n");
542 config_content.push_str("docs = \"~/Documents/Documentation\"\n");
543 config_content.push_str("central_docs = \"~/Documents/Documentation\"\n");
544 config_content.push_str("project_docs = \"~/Documents/Documentation/my-project\"\n");
545 config_content.push_str("claude_docs = \"~/Documents/Documentation/claude\"\n\n");
546 needs_update = true;
547 println!("ā
Added [semantic_directories] section with default aliases");
548 }
549
550
551 if needs_update {
552 fs::write(config_path, config_content)
553 .with_context(|| format!("Failed to update config file: {config_path}"))?;
554 println!("š¾ Configuration file updated");
555 } else {
556 println!("ā
All required sections already present");
557 }
558
559 Ok(())
560}
561
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use tempfile::tempdir;
567 use serde_json::json;
568
569 fn with_temp_dir<F>(test: F)
571 where
572 F: FnOnce(),
573 {
574 let temp_dir = tempdir().unwrap();
575 let original_dir = std::env::current_dir().unwrap();
576
577 std::env::set_current_dir(temp_dir.path()).unwrap();
579
580 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
582 test();
583 }));
584
585 std::env::set_current_dir(&original_dir).unwrap();
587
588 if let Err(err) = result {
590 std::panic::resume_unwind(err);
591 }
592 }
593
594 #[test]
595 fn test_hooks_already_exist_no_settings_file() {
596 with_temp_dir(|| {
597 let result = hooks_already_exist().unwrap();
598 assert!(!result, "Should return false when no settings files exist");
599 });
600 }
601
602 #[test]
603 fn test_hooks_already_exist_empty_settings() {
604 with_temp_dir(|| {
605 fs::create_dir_all(".claude").unwrap();
607 let settings_content = json!({});
608 fs::write(".claude/settings.local.json", serde_json::to_string_pretty(&settings_content).unwrap()).unwrap();
609
610 let result = hooks_already_exist().unwrap();
611 assert!(!result, "Should return false when settings file has no hooks");
612 });
613 }
614
615 #[test]
616 fn test_hooks_already_exist_with_our_hooks() {
617 with_temp_dir(|| {
618 fs::create_dir_all(".claude").unwrap();
620 let settings_content = json!({
621 "hooks": {
622 "PreToolUse": [
623 {
624 "matcher": "Bash",
625 "hooks": [
626 {
627 "type": "command",
628 "command": "claude-hook-advisor --hook"
629 }
630 ]
631 }
632 ]
633 }
634 });
635 fs::write(".claude/settings.local.json", serde_json::to_string_pretty(&settings_content).unwrap()).unwrap();
636
637 let result = hooks_already_exist().unwrap();
638 assert!(result, "Should return true when our hooks are present");
639 });
640 }
641
642 #[test]
643 fn test_hooks_already_exist_with_other_hooks() {
644 with_temp_dir(|| {
645 fs::create_dir_all(".claude").unwrap();
647 let settings_content = json!({
648 "hooks": {
649 "PreToolUse": [
650 {
651 "matcher": "Bash",
652 "hooks": [
653 {
654 "type": "command",
655 "command": "some-other-tool --hook"
656 }
657 ]
658 }
659 ]
660 }
661 });
662 fs::write(".claude/settings.local.json", serde_json::to_string_pretty(&settings_content).unwrap()).unwrap();
663
664 let result = hooks_already_exist().unwrap();
665 assert!(!result, "Should return false when only other hooks are present");
666 });
667 }
668
669 #[test]
670 fn test_hooks_already_exist_userprompsubmit_hooks() {
671 with_temp_dir(|| {
672 fs::create_dir_all(".claude").unwrap();
674 let settings_content = json!({
675 "hooks": {
676 "UserPromptSubmit": [
677 {
678 "hooks": [
679 {
680 "type": "command",
681 "command": "/path/to/claude-hook-advisor --hook"
682 }
683 ]
684 }
685 ]
686 }
687 });
688 fs::write(".claude/settings.local.json", serde_json::to_string_pretty(&settings_content).unwrap()).unwrap();
689
690 let result = hooks_already_exist().unwrap();
691 assert!(result, "Should return true when UserPromptSubmit hooks are present");
692 });
693 }
694
695 #[test]
696 fn test_hooks_already_exist_prefers_local_settings() {
697 with_temp_dir(|| {
698 fs::create_dir_all(".claude").unwrap();
700
701 let shared_settings = json!({
703 "hooks": {
704 "PreToolUse": [
705 {
706 "matcher": "Bash",
707 "hooks": [
708 {
709 "type": "command",
710 "command": "claude-hook-advisor --hook"
711 }
712 ]
713 }
714 ]
715 }
716 });
717 fs::write(".claude/settings.json", serde_json::to_string_pretty(&shared_settings).unwrap()).unwrap();
718
719 let local_settings = json!({});
721 fs::write(".claude/settings.local.json", serde_json::to_string_pretty(&local_settings).unwrap()).unwrap();
722
723 let result = hooks_already_exist().unwrap();
724 assert!(!result, "Should check local settings first and return false when they don't have our hooks");
725 });
726 }
727
728 #[test]
729 fn test_create_example_config() {
730 let temp_dir = tempdir().unwrap();
731 let config_path = temp_dir.path().join("test-config.toml");
732
733 create_smart_config(config_path.to_str().unwrap()).unwrap();
734
735 let content = fs::read_to_string(&config_path).unwrap();
736
737 assert!(content.contains("[commands]"));
739 assert!(content.contains("[semantic_directories]"));
740
741 assert!(content.contains("docs = \"~/Documents/Documentation\""));
743 assert!(content.contains("docs = \"~/Documents/Documentation\""));
744
745 assert!(content.contains("# Claude Hook Advisor Configuration"));
747 assert!(content.contains("# Uncomment and customize these examples:"));
748 }
749
750 #[test]
751 fn test_ensure_config_sections_missing_sections() {
752 let temp_dir = tempdir().unwrap();
753 let config_path = temp_dir.path().join("test-config.toml");
754
755 fs::write(&config_path, "# Minimal config\n").unwrap();
757
758 ensure_config_sections(config_path.to_str().unwrap()).unwrap();
759
760 let content = fs::read_to_string(&config_path).unwrap();
761
762 assert!(content.contains("[commands]"));
764 assert!(content.contains("[semantic_directories]"));
765
766 assert!(content.contains("docs = \"~/Documents/Documentation\""));
768 assert!(content.contains("# npm = \"bun\""));
769 }
770
771 #[test]
772 fn test_ensure_config_sections_all_sections_present() {
773 let temp_dir = tempdir().unwrap();
774 let config_path = temp_dir.path().join("test-config.toml");
775
776 let existing_config = r#"# Existing config
777[commands]
778npm = "bun"
779
780[semantic_directories]
781docs = "~/Documents"
782"#;
783 fs::write(&config_path, existing_config).unwrap();
784
785 ensure_config_sections(config_path.to_str().unwrap()).unwrap();
786
787 let content = fs::read_to_string(&config_path).unwrap();
788
789 assert_eq!(content, existing_config);
791 }
792}