1use std::path::{Path, PathBuf};
20
21use crate::error::Result;
22
23#[derive(Debug, Clone)]
25pub struct ToolHookConfig {
26 pub tool_name: String,
28 pub config_path: PathBuf,
30 pub config_content: String,
32 pub scope: HookScope,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum HookScope {
38 Project,
40 User,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum HookPlatform {
48 ClaudeCode,
50 Cursor,
52 GeminiCli,
54 Windsurf,
56}
57
58pub fn process_hook(input: &str) -> Result<String> {
77 process_hook_for_platform(input, HookPlatform::ClaudeCode)
78}
79
80pub fn process_hook_cursor(input: &str) -> Result<String> {
85 process_hook_for_platform(input, HookPlatform::Cursor)
86}
87
88pub fn process_hook_gemini(input: &str) -> Result<String> {
92 process_hook_for_platform(input, HookPlatform::GeminiCli)
93}
94
95pub fn process_hook_windsurf(input: &str) -> Result<String> {
100 process_hook_for_platform(input, HookPlatform::Windsurf)
101}
102
103fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
107 let parsed: serde_json::Value = serde_json::from_str(input)
108 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
109
110 let tool_name = parsed
114 .get("tool_name")
115 .or_else(|| parsed.get("toolName"))
116 .and_then(|v| v.as_str())
117 .unwrap_or("");
118
119 let hook_event = parsed
120 .get("hook_event_name")
121 .or_else(|| parsed.get("agent_action_name"))
122 .and_then(|v| v.as_str())
123 .unwrap_or("");
124
125 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
134 | "run_terminal_command" | "run_shell_command")
135 || matches!(hook_event, "beforeShellExecution" | "pre_run_command");
136
137 if !is_shell {
138 return Ok(match platform {
141 HookPlatform::Cursor => "{}".to_string(),
142 _ => input.to_string(),
143 });
144 }
145
146 let command = parsed
151 .get("tool_input")
152 .and_then(|v| v.get("command"))
153 .and_then(|v| v.as_str())
154 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
155 .or_else(|| {
156 parsed
157 .get("tool_info")
158 .and_then(|v| v.get("command_line"))
159 .and_then(|v| v.as_str())
160 })
161 .or_else(|| {
162 parsed
163 .get("toolCall")
164 .and_then(|v| v.get("command"))
165 .and_then(|v| v.as_str())
166 })
167 .unwrap_or("");
168
169 if command.is_empty() {
170 return Ok(match platform {
171 HookPlatform::Cursor => "{}".to_string(),
172 _ => input.to_string(),
173 });
174 }
175
176 let base_cmd = extract_base_command(command);
180 if base_cmd == "sqz" || command.starts_with("SQZ_CMD=") {
181 return Ok(match platform {
182 HookPlatform::Cursor => "{}".to_string(),
183 _ => input.to_string(),
184 });
185 }
186
187 if is_interactive_command(command) {
189 return Ok(match platform {
190 HookPlatform::Cursor => "{}".to_string(),
191 _ => input.to_string(),
192 });
193 }
194
195 if has_shell_operators(command) {
200 return Ok(match platform {
201 HookPlatform::Cursor => "{}".to_string(),
202 _ => input.to_string(),
203 });
204 }
205
206 let rewritten = format!(
209 "SQZ_CMD={} {} 2>&1 | sqz compress",
210 shell_escape(extract_base_command(command)),
211 command
212 );
213
214 let output = match platform {
240 HookPlatform::ClaudeCode => serde_json::json!({
241 "hookSpecificOutput": {
242 "hookEventName": "PreToolUse",
243 "permissionDecision": "allow",
244 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
245 "updatedInput": {
246 "command": rewritten
247 }
248 }
249 }),
250 HookPlatform::Cursor => serde_json::json!({
251 "permission": "allow",
252 "updated_input": {
253 "command": rewritten
254 }
255 }),
256 HookPlatform::GeminiCli => serde_json::json!({
257 "decision": "allow",
258 "hookSpecificOutput": {
259 "tool_input": {
260 "command": rewritten
261 }
262 }
263 }),
264 HookPlatform::Windsurf => {
265 serde_json::json!({
269 "hookSpecificOutput": {
270 "hookEventName": "PreToolUse",
271 "permissionDecision": "allow",
272 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
273 "updatedInput": {
274 "command": rewritten
275 }
276 }
277 })
278 }
279 };
280
281 serde_json::to_string(&output)
282 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
283}
284
285pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
287 let sqz_path_raw = sqz_path;
298 let sqz_path_json = json_escape_string_value(sqz_path);
299 let sqz_path = &sqz_path_json;
300
301 vec![
302 ToolHookConfig {
306 tool_name: "Claude Code".to_string(),
307 config_path: PathBuf::from(".claude/settings.local.json"),
308 config_content: format!(
309 r#"{{
310 "hooks": {{
311 "PreToolUse": [
312 {{
313 "matcher": "Bash",
314 "hooks": [
315 {{
316 "type": "command",
317 "command": "{sqz_path} hook claude"
318 }}
319 ]
320 }}
321 ],
322 "SessionStart": [
323 {{
324 "matcher": "compact",
325 "hooks": [
326 {{
327 "type": "command",
328 "command": "{sqz_path} resume"
329 }}
330 ]
331 }}
332 ]
333 }}
334}}"#
335 ),
336 scope: HookScope::Project,
337 },
338 ToolHookConfig {
347 tool_name: "Cursor".to_string(),
348 config_path: PathBuf::from(".cursor/hooks.json"),
349 config_content: format!(
350 r#"{{
351 "version": 1,
352 "hooks": {{
353 "preToolUse": [
354 {{
355 "command": "{sqz_path} hook cursor",
356 "matcher": "Shell"
357 }}
358 ]
359 }}
360}}"#
361 ),
362 scope: HookScope::Project,
363 },
364 ToolHookConfig {
368 tool_name: "Windsurf".to_string(),
369 config_path: PathBuf::from(".windsurfrules"),
370 config_content: format!(
371 r#"# sqz — Token-Optimized CLI Output
372
373Always prefix shell commands with `sqz` to compress output for token savings:
374
375```bash
376# Instead of: Use:
377git status {sqz_path_raw} git status
378cargo test {sqz_path_raw} cargo test
379git log -10 {sqz_path_raw} git log -10
380docker ps {sqz_path_raw} docker ps
381```
382
383sqz is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
384If sqz is not available, run commands normally.
385"#
386 ),
387 scope: HookScope::Project,
388 },
389 ToolHookConfig {
393 tool_name: "Cline".to_string(),
394 config_path: PathBuf::from(".clinerules"),
395 config_content: format!(
396 r#"# sqz — Token-Optimized CLI Output
397
398Always prefix shell commands with `sqz` to compress output for token savings:
399
400```bash
401# Instead of: Use:
402git status {sqz_path_raw} git status
403cargo test {sqz_path_raw} cargo test
404git log -10 {sqz_path_raw} git log -10
405docker ps {sqz_path_raw} docker ps
406```
407
408sqz is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
409If sqz is not available, run commands normally.
410"#
411 ),
412 scope: HookScope::Project,
413 },
414 ToolHookConfig {
416 tool_name: "Gemini CLI".to_string(),
417 config_path: PathBuf::from(".gemini/settings.json"),
418 config_content: format!(
419 r#"{{
420 "hooks": {{
421 "BeforeTool": [
422 {{
423 "matcher": "run_shell_command",
424 "hooks": [
425 {{
426 "type": "command",
427 "command": "{sqz_path} hook gemini"
428 }}
429 ]
430 }}
431 ]
432 }}
433}}"#
434 ),
435 scope: HookScope::Project,
436 },
437 ToolHookConfig {
443 tool_name: "OpenCode".to_string(),
444 config_path: PathBuf::from("opencode.json"),
445 config_content: format!(
446 r#"{{
447 "$schema": "https://opencode.ai/config.json",
448 "mcp": {{
449 "sqz": {{
450 "type": "local",
451 "command": ["sqz-mcp", "--transport", "stdio"]
452 }}
453 }},
454 "plugin": ["sqz"]
455}}"#
456 ),
457 scope: HookScope::Project,
458 },
459 ]
460}
461
462pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
466 let configs = generate_hook_configs(sqz_path);
467 let mut installed = Vec::new();
468
469 for config in &configs {
470 let full_path = project_dir.join(&config.config_path);
471
472 if full_path.exists() {
474 continue;
475 }
476
477 if let Some(parent) = full_path.parent() {
479 if std::fs::create_dir_all(parent).is_err() {
480 continue;
481 }
482 }
483
484 if std::fs::write(&full_path, &config.config_content).is_ok() {
485 installed.push(config.tool_name.clone());
486 }
487 }
488
489 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
491 if !installed.iter().any(|n| n == "OpenCode") {
492 installed.push("OpenCode".to_string());
493 }
494 }
495
496 installed
497}
498
499fn extract_base_command(cmd: &str) -> &str {
503 cmd.split_whitespace()
504 .next()
505 .unwrap_or("unknown")
506 .rsplit('/')
507 .next()
508 .unwrap_or("unknown")
509}
510
511pub(crate) fn json_escape_string_value(s: &str) -> String {
522 let mut out = String::with_capacity(s.len() + 2);
523 for ch in s.chars() {
524 match ch {
525 '\\' => out.push_str("\\\\"),
526 '"' => out.push_str("\\\""),
527 '\n' => out.push_str("\\n"),
528 '\r' => out.push_str("\\r"),
529 '\t' => out.push_str("\\t"),
530 '\x08' => out.push_str("\\b"),
531 '\x0c' => out.push_str("\\f"),
532 c if (c as u32) < 0x20 => {
533 out.push_str(&format!("\\u{:04x}", c as u32));
535 }
536 c => out.push(c),
537 }
538 }
539 out
540}
541
542fn shell_escape(s: &str) -> String {
544 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
545 s.to_string()
546 } else {
547 format!("'{}'", s.replace('\'', "'\\''"))
548 }
549}
550
551fn has_shell_operators(cmd: &str) -> bool {
555 cmd.contains("&&")
558 || cmd.contains("||")
559 || cmd.contains(';')
560 || cmd.contains('>')
561 || cmd.contains('<')
562 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
568
569fn is_interactive_command(cmd: &str) -> bool {
571 let base = extract_base_command(cmd);
572 matches!(
573 base,
574 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
575 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
576 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
577 ) || cmd.contains("--watch")
578 || cmd.contains("-w ")
579 || cmd.ends_with(" -w")
580 || cmd.contains("run dev")
581 || cmd.contains("run start")
582 || cmd.contains("run serve")
583}
584
585#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_process_hook_rewrites_bash_command() {
593 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
595 let result = process_hook(input).unwrap();
596 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
597 let hook_output = &parsed["hookSpecificOutput"];
599 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
600 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
601 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
603 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
604 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
605 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
606 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
608 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
609 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
610 }
611
612 #[test]
613 fn test_process_hook_passes_through_non_bash() {
614 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
615 let result = process_hook(input).unwrap();
616 assert_eq!(result, input, "non-bash tools should pass through unchanged");
617 }
618
619 #[test]
620 fn test_process_hook_skips_sqz_commands() {
621 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
622 let result = process_hook(input).unwrap();
623 assert_eq!(result, input, "sqz commands should not be double-wrapped");
624 }
625
626 #[test]
627 fn test_process_hook_skips_interactive() {
628 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
629 let result = process_hook(input).unwrap();
630 assert_eq!(result, input, "interactive commands should pass through");
631 }
632
633 #[test]
634 fn test_process_hook_skips_watch_mode() {
635 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
636 let result = process_hook(input).unwrap();
637 assert_eq!(result, input, "watch mode should pass through");
638 }
639
640 #[test]
641 fn test_process_hook_empty_command() {
642 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
643 let result = process_hook(input).unwrap();
644 assert_eq!(result, input);
645 }
646
647 #[test]
648 fn test_process_hook_gemini_format() {
649 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
651 let result = process_hook_gemini(input).unwrap();
652 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
653 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
655 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
657 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
658 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
660 "Gemini format should not have updatedInput");
661 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
662 "Gemini format should not have permissionDecision");
663 }
664
665 #[test]
666 fn test_process_hook_legacy_format() {
667 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
669 let result = process_hook(input).unwrap();
670 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
671 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
672 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
673 }
674
675 #[test]
676 fn test_process_hook_cursor_format() {
677 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
679 let result = process_hook_cursor(input).unwrap();
680 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
681 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
683 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
684 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
685 assert!(cmd.contains("git status"));
686 assert!(parsed.get("hookSpecificOutput").is_none(),
688 "Cursor format should not have hookSpecificOutput");
689 }
690
691 #[test]
692 fn test_process_hook_cursor_passthrough_returns_empty_json() {
693 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
695 let result = process_hook_cursor(input).unwrap();
696 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
697 }
698
699 #[test]
700 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
701 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
703 let result = process_hook_cursor(input).unwrap();
704 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
705 }
706
707 #[test]
708 fn test_process_hook_windsurf_format() {
709 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
711 let result = process_hook_windsurf(input).unwrap();
712 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
713 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
715 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
716 assert!(cmd.contains("cargo test"));
717 assert!(cmd.contains("SQZ_CMD=cargo"));
718 }
719
720 #[test]
721 fn test_process_hook_invalid_json() {
722 let result = process_hook("not json");
723 assert!(result.is_err());
724 }
725
726 #[test]
727 fn test_extract_base_command() {
728 assert_eq!(extract_base_command("git status"), "git");
729 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
730 assert_eq!(extract_base_command("cargo test --release"), "cargo");
731 }
732
733 #[test]
734 fn test_is_interactive_command() {
735 assert!(is_interactive_command("vim file.txt"));
736 assert!(is_interactive_command("npm run dev --watch"));
737 assert!(is_interactive_command("python3"));
738 assert!(!is_interactive_command("git status"));
739 assert!(!is_interactive_command("cargo test"));
740 }
741
742 #[test]
743 fn test_generate_hook_configs() {
744 let configs = generate_hook_configs("sqz");
745 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
746 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
747 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
748 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
749 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
751 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
752 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
753 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
754 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
755 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
756 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
758 assert!(cursor.config_content.contains("\"preToolUse\""),
759 "Cursor config should use preToolUse (camelCase), not PreToolUse or beforeShellExecution");
760 assert!(cursor.config_content.contains("\"version\": 1"),
761 "Cursor config should include version: 1");
762 assert!(cursor.config_content.contains("\"matcher\": \"Shell\""),
763 "Cursor config should use matcher: Shell");
764 assert!(!cursor.config_content.contains("\"type\": \"command\""),
766 "Cursor config should use flat format, not nested hooks array");
767 }
768
769 #[test]
772 fn test_json_escape_string_value() {
773 assert_eq!(json_escape_string_value("sqz"), "sqz");
775 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
776 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
778 r"C:\\Users\\Alice\\sqz.exe");
779 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
781 r#"path with \"quotes\""#);
782 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
784 }
785
786 #[test]
787 fn test_windows_path_produces_valid_json_for_claude() {
788 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
791 let configs = generate_hook_configs(windows_path);
792
793 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
794 .expect("Claude config should be generated");
795 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
796 .expect("Claude hook config must be valid JSON on Windows paths");
797
798 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
800 .as_str()
801 .expect("command field must be a string");
802 assert!(cmd.contains(windows_path),
803 "command '{cmd}' must contain the original Windows path '{windows_path}'");
804 }
805
806 #[test]
807 fn test_windows_path_produces_valid_json_for_cursor() {
808 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
809 let configs = generate_hook_configs(windows_path);
810
811 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
812 let parsed: serde_json::Value = serde_json::from_str(&cursor.config_content)
813 .expect("Cursor hook config must be valid JSON on Windows paths");
814 let cmd = parsed["hooks"]["preToolUse"][0]["command"].as_str().unwrap();
815 assert!(cmd.contains(windows_path));
816 }
817
818 #[test]
819 fn test_windows_path_produces_valid_json_for_gemini() {
820 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
821 let configs = generate_hook_configs(windows_path);
822
823 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
824 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
825 .expect("Gemini hook config must be valid JSON on Windows paths");
826 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
827 assert!(cmd.contains(windows_path));
828 }
829
830 #[test]
831 fn test_rules_files_use_raw_path_for_readability() {
832 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
836 let configs = generate_hook_configs(windows_path);
837
838 for tool in &["Windsurf", "Cline"] {
839 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
840 assert!(cfg.config_content.contains(windows_path),
841 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
842 cfg.config_content);
843 assert!(!cfg.config_content.contains(r"C:\\Users"),
844 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
845 cfg.config_content);
846 }
847 }
848
849 #[test]
850 fn test_unix_path_still_works() {
851 let unix_path = "/usr/local/bin/sqz";
854 let configs = generate_hook_configs(unix_path);
855
856 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
857 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
858 .expect("Unix path should produce valid JSON");
859 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
860 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
861 }
862
863 #[test]
864 fn test_shell_escape_simple() {
865 assert_eq!(shell_escape("git"), "git");
866 assert_eq!(shell_escape("cargo-test"), "cargo-test");
867 }
868
869 #[test]
870 fn test_shell_escape_special_chars() {
871 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
872 }
873
874 #[test]
875 fn test_install_tool_hooks_creates_files() {
876 let dir = tempfile::tempdir().unwrap();
877 let installed = install_tool_hooks(dir.path(), "sqz");
878 assert!(!installed.is_empty(), "should install at least one hook config");
880 for name in &installed {
882 let configs = generate_hook_configs("sqz");
883 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
884 let path = dir.path().join(&config.config_path);
885 assert!(path.exists(), "hook config should exist: {}", path.display());
886 }
887 }
888
889 #[test]
890 fn test_install_tool_hooks_does_not_overwrite() {
891 let dir = tempfile::tempdir().unwrap();
892 install_tool_hooks(dir.path(), "sqz");
894 let custom_path = dir.path().join(".claude/settings.local.json");
896 std::fs::write(&custom_path, "custom content").unwrap();
897 install_tool_hooks(dir.path(), "sqz");
899 let content = std::fs::read_to_string(&custom_path).unwrap();
900 assert_eq!(content, "custom content", "should not overwrite existing config");
901 }
902}