1use std::path::{Path, PathBuf};
16
17use crate::error::Result;
18
19#[derive(Debug, Clone)]
21pub struct ToolHookConfig {
22 pub tool_name: String,
24 pub config_path: PathBuf,
26 pub config_content: String,
28 pub scope: HookScope,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum HookScope {
34 Project,
36 User,
38}
39
40pub fn process_hook(input: &str) -> Result<String> {
59 let parsed: serde_json::Value = serde_json::from_str(input)
60 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
61
62 let tool_name = parsed
65 .get("toolName")
66 .or_else(|| parsed.get("tool_name"))
67 .and_then(|v| v.as_str())
68 .unwrap_or("");
69
70 if !matches!(tool_name, "Bash" | "bash" | "shell" | "terminal"
72 | "run_terminal_command" | "run_shell_command") {
73 return Ok(input.to_string());
75 }
76
77 let command = parsed
79 .get("toolCall")
80 .or_else(|| parsed.get("tool_input"))
81 .and_then(|v| v.get("command"))
82 .and_then(|v| v.as_str())
83 .unwrap_or("");
84
85 if command.is_empty() {
86 return Ok(input.to_string());
87 }
88
89 if command.contains("sqz") || command.contains("SQZ_CMD") {
91 return Ok(input.to_string());
92 }
93
94 if is_interactive_command(command) {
96 return Ok(input.to_string());
97 }
98
99 let rewritten = format!(
101 "SQZ_CMD={} {} 2>&1 | sqz compress",
102 shell_escape(extract_base_command(command)),
103 command
104 );
105
106 let output = serde_json::json!({
111 "decision": "approve",
112 "reason": "sqz: command output will be compressed for token savings",
113 "updatedInput": {
114 "command": rewritten
115 },
116 "hookSpecificOutput": {
117 "tool_input": {
118 "command": rewritten
119 }
120 }
121 });
122
123 serde_json::to_string(&output)
124 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
125}
126
127pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
129 vec![
130 ToolHookConfig {
132 tool_name: "Claude Code".to_string(),
133 config_path: PathBuf::from(".claude/settings.local.json"),
134 config_content: format!(
135 r#"{{
136 "hooks": {{
137 "PreToolUse": [
138 {{
139 "matcher": "Bash",
140 "hooks": [
141 {{
142 "type": "command",
143 "command": "{sqz_path} hook claude"
144 }}
145 ]
146 }}
147 ]
148 }}
149}}"#
150 ),
151 scope: HookScope::Project,
152 },
153 ToolHookConfig {
155 tool_name: "Cursor".to_string(),
156 config_path: PathBuf::from(".cursor/hooks.json"),
157 config_content: format!(
158 r#"{{
159 "hooks": {{
160 "PreToolUse": [
161 {{
162 "matcher": "Bash",
163 "hooks": [
164 {{
165 "type": "command",
166 "command": "{sqz_path} hook cursor"
167 }}
168 ]
169 }}
170 ]
171 }}
172}}"#
173 ),
174 scope: HookScope::Project,
175 },
176 ToolHookConfig {
178 tool_name: "Windsurf".to_string(),
179 config_path: PathBuf::from(".windsurf/hooks.json"),
180 config_content: format!(
181 r#"{{
182 "hooks": {{
183 "PreToolUse": [
184 {{
185 "matcher": "Bash",
186 "hooks": [
187 {{
188 "type": "command",
189 "command": "{sqz_path} hook windsurf"
190 }}
191 ]
192 }}
193 ]
194 }}
195}}"#
196 ),
197 scope: HookScope::Project,
198 },
199 ToolHookConfig {
201 tool_name: "Cline".to_string(),
202 config_path: PathBuf::from(".cline/hooks.json"),
203 config_content: format!(
204 r#"{{
205 "hooks": {{
206 "PreToolUse": [
207 {{
208 "matcher": "Bash",
209 "hooks": [
210 {{
211 "type": "command",
212 "command": "{sqz_path} hook cline"
213 }}
214 ]
215 }}
216 ]
217 }}
218}}"#
219 ),
220 scope: HookScope::Project,
221 },
222 ToolHookConfig {
224 tool_name: "Gemini CLI".to_string(),
225 config_path: PathBuf::from(".gemini/settings.json"),
226 config_content: format!(
227 r#"{{
228 "hooks": {{
229 "BeforeTool": [
230 {{
231 "matcher": "run_shell_command",
232 "hooks": [
233 {{
234 "type": "command",
235 "command": "{sqz_path} hook gemini"
236 }}
237 ]
238 }}
239 ]
240 }}
241}}"#
242 ),
243 scope: HookScope::Project,
244 },
245 ]
246}
247
248pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
252 let configs = generate_hook_configs(sqz_path);
253 let mut installed = Vec::new();
254
255 for config in &configs {
256 let full_path = project_dir.join(&config.config_path);
257
258 if full_path.exists() {
260 continue;
261 }
262
263 if let Some(parent) = full_path.parent() {
265 if std::fs::create_dir_all(parent).is_err() {
266 continue;
267 }
268 }
269
270 if std::fs::write(&full_path, &config.config_content).is_ok() {
271 installed.push(config.tool_name.clone());
272 }
273 }
274
275 installed
276}
277
278fn extract_base_command(cmd: &str) -> &str {
282 cmd.split_whitespace()
283 .next()
284 .unwrap_or("unknown")
285 .rsplit('/')
286 .next()
287 .unwrap_or("unknown")
288}
289
290fn shell_escape(s: &str) -> String {
292 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
293 s.to_string()
294 } else {
295 format!("'{}'", s.replace('\'', "'\\''"))
296 }
297}
298
299fn is_interactive_command(cmd: &str) -> bool {
301 let base = extract_base_command(cmd);
302 matches!(
303 base,
304 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
305 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
306 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
307 ) || cmd.contains("--watch")
308 || cmd.contains("-w ")
309 || cmd.ends_with(" -w")
310 || cmd.contains("run dev")
311 || cmd.contains("run start")
312 || cmd.contains("run serve")
313}
314
315#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_process_hook_rewrites_bash_command() {
323 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
324 let result = process_hook(input).unwrap();
325 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
326 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
327 let cmd = parsed["updatedInput"]["command"].as_str().unwrap();
328 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
329 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
330 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
331 }
332
333 #[test]
334 fn test_process_hook_passes_through_non_bash() {
335 let input = r#"{"toolName":"Read","toolCall":{"path":"file.txt"}}"#;
336 let result = process_hook(input).unwrap();
337 assert_eq!(result, input, "non-bash tools should pass through unchanged");
338 }
339
340 #[test]
341 fn test_process_hook_skips_sqz_commands() {
342 let input = r#"{"toolName":"Bash","toolCall":{"command":"sqz stats"}}"#;
343 let result = process_hook(input).unwrap();
344 assert_eq!(result, input, "sqz commands should not be double-wrapped");
345 }
346
347 #[test]
348 fn test_process_hook_skips_interactive() {
349 let input = r#"{"toolName":"Bash","toolCall":{"command":"vim file.txt"}}"#;
350 let result = process_hook(input).unwrap();
351 assert_eq!(result, input, "interactive commands should pass through");
352 }
353
354 #[test]
355 fn test_process_hook_skips_watch_mode() {
356 let input = r#"{"toolName":"Bash","toolCall":{"command":"npm run dev --watch"}}"#;
357 let result = process_hook(input).unwrap();
358 assert_eq!(result, input, "watch mode should pass through");
359 }
360
361 #[test]
362 fn test_process_hook_empty_command() {
363 let input = r#"{"toolName":"Bash","toolCall":{"command":""}}"#;
364 let result = process_hook(input).unwrap();
365 assert_eq!(result, input);
366 }
367
368 #[test]
369 fn test_process_hook_gemini_format() {
370 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
372 let result = process_hook(input).unwrap();
373 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
374 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
375 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
376 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
377 }
378
379 #[test]
380 fn test_process_hook_invalid_json() {
381 let result = process_hook("not json");
382 assert!(result.is_err());
383 }
384
385 #[test]
386 fn test_extract_base_command() {
387 assert_eq!(extract_base_command("git status"), "git");
388 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
389 assert_eq!(extract_base_command("cargo test --release"), "cargo");
390 }
391
392 #[test]
393 fn test_is_interactive_command() {
394 assert!(is_interactive_command("vim file.txt"));
395 assert!(is_interactive_command("npm run dev --watch"));
396 assert!(is_interactive_command("python3"));
397 assert!(!is_interactive_command("git status"));
398 assert!(!is_interactive_command("cargo test"));
399 }
400
401 #[test]
402 fn test_generate_hook_configs() {
403 let configs = generate_hook_configs("sqz");
404 assert!(configs.len() >= 4, "should generate configs for multiple tools");
405 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
406 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
407 }
408
409 #[test]
410 fn test_shell_escape_simple() {
411 assert_eq!(shell_escape("git"), "git");
412 assert_eq!(shell_escape("cargo-test"), "cargo-test");
413 }
414
415 #[test]
416 fn test_shell_escape_special_chars() {
417 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
418 }
419
420 #[test]
421 fn test_install_tool_hooks_creates_files() {
422 let dir = tempfile::tempdir().unwrap();
423 let installed = install_tool_hooks(dir.path(), "sqz");
424 assert!(!installed.is_empty(), "should install at least one hook config");
426 for name in &installed {
428 let configs = generate_hook_configs("sqz");
429 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
430 let path = dir.path().join(&config.config_path);
431 assert!(path.exists(), "hook config should exist: {}", path.display());
432 }
433 }
434
435 #[test]
436 fn test_install_tool_hooks_does_not_overwrite() {
437 let dir = tempfile::tempdir().unwrap();
438 install_tool_hooks(dir.path(), "sqz");
440 let custom_path = dir.path().join(".claude/settings.local.json");
442 std::fs::write(&custom_path, "custom content").unwrap();
443 install_tool_hooks(dir.path(), "sqz");
445 let content = std::fs::read_to_string(&custom_path).unwrap();
446 assert_eq!(content, "custom content", "should not overwrite existing config");
447 }
448}