1use std::path::{Path, PathBuf};
17
18use crate::error::Result;
19
20#[derive(Debug, Clone)]
22pub struct ToolHookConfig {
23 pub tool_name: String,
25 pub config_path: PathBuf,
27 pub config_content: String,
29 pub scope: HookScope,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HookScope {
35 Project,
37 User,
39}
40
41pub fn process_hook(input: &str) -> Result<String> {
60 let parsed: serde_json::Value = serde_json::from_str(input)
61 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
62
63 let tool_name = parsed
66 .get("toolName")
67 .or_else(|| parsed.get("tool_name"))
68 .and_then(|v| v.as_str())
69 .unwrap_or("");
70
71 if !matches!(tool_name, "Bash" | "bash" | "shell" | "terminal"
80 | "run_terminal_command" | "run_shell_command") {
81 return Ok(input.to_string());
83 }
84
85 let command = parsed
87 .get("toolCall")
88 .or_else(|| parsed.get("tool_input"))
89 .and_then(|v| v.get("command"))
90 .and_then(|v| v.as_str())
91 .unwrap_or("");
92
93 if command.is_empty() {
94 return Ok(input.to_string());
95 }
96
97 if command.contains("sqz") || command.contains("SQZ_CMD") {
99 return Ok(input.to_string());
100 }
101
102 if is_interactive_command(command) {
104 return Ok(input.to_string());
105 }
106
107 let rewritten = format!(
109 "SQZ_CMD={} {} 2>&1 | sqz compress",
110 shell_escape(extract_base_command(command)),
111 command
112 );
113
114 let output = serde_json::json!({
119 "decision": "approve",
120 "reason": "sqz: command output will be compressed for token savings",
121 "updatedInput": {
122 "command": rewritten
123 },
124 "hookSpecificOutput": {
125 "tool_input": {
126 "command": rewritten
127 }
128 }
129 });
130
131 serde_json::to_string(&output)
132 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
133}
134
135pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
137 vec![
138 ToolHookConfig {
142 tool_name: "Claude Code".to_string(),
143 config_path: PathBuf::from(".claude/settings.local.json"),
144 config_content: format!(
145 r#"{{
146 "hooks": {{
147 "PreToolUse": [
148 {{
149 "matcher": "Bash",
150 "hooks": [
151 {{
152 "type": "command",
153 "command": "{sqz_path} hook claude"
154 }}
155 ]
156 }}
157 ],
158 "SessionStart": [
159 {{
160 "matcher": "compact",
161 "hooks": [
162 {{
163 "type": "command",
164 "command": "{sqz_path} resume"
165 }}
166 ]
167 }}
168 ]
169 }}
170}}"#
171 ),
172 scope: HookScope::Project,
173 },
174 ToolHookConfig {
176 tool_name: "Cursor".to_string(),
177 config_path: PathBuf::from(".cursor/hooks.json"),
178 config_content: format!(
179 r#"{{
180 "hooks": {{
181 "PreToolUse": [
182 {{
183 "matcher": "Bash",
184 "hooks": [
185 {{
186 "type": "command",
187 "command": "{sqz_path} hook cursor"
188 }}
189 ]
190 }}
191 ]
192 }}
193}}"#
194 ),
195 scope: HookScope::Project,
196 },
197 ToolHookConfig {
199 tool_name: "Windsurf".to_string(),
200 config_path: PathBuf::from(".windsurf/hooks.json"),
201 config_content: format!(
202 r#"{{
203 "hooks": {{
204 "PreToolUse": [
205 {{
206 "matcher": "Bash",
207 "hooks": [
208 {{
209 "type": "command",
210 "command": "{sqz_path} hook windsurf"
211 }}
212 ]
213 }}
214 ]
215 }}
216}}"#
217 ),
218 scope: HookScope::Project,
219 },
220 ToolHookConfig {
222 tool_name: "Cline".to_string(),
223 config_path: PathBuf::from(".cline/hooks.json"),
224 config_content: format!(
225 r#"{{
226 "hooks": {{
227 "PreToolUse": [
228 {{
229 "matcher": "Bash",
230 "hooks": [
231 {{
232 "type": "command",
233 "command": "{sqz_path} hook cline"
234 }}
235 ]
236 }}
237 ]
238 }}
239}}"#
240 ),
241 scope: HookScope::Project,
242 },
243 ToolHookConfig {
245 tool_name: "Gemini CLI".to_string(),
246 config_path: PathBuf::from(".gemini/settings.json"),
247 config_content: format!(
248 r#"{{
249 "hooks": {{
250 "BeforeTool": [
251 {{
252 "matcher": "run_shell_command",
253 "hooks": [
254 {{
255 "type": "command",
256 "command": "{sqz_path} hook gemini"
257 }}
258 ]
259 }}
260 ]
261 }}
262}}"#
263 ),
264 scope: HookScope::Project,
265 },
266 ToolHookConfig {
272 tool_name: "OpenCode".to_string(),
273 config_path: PathBuf::from("opencode.json"),
274 config_content: format!(
275 r#"{{
276 "$schema": "https://opencode.ai/config.json",
277 "mcp": {{
278 "sqz": {{
279 "type": "local",
280 "command": ["sqz-mcp", "--transport", "stdio"]
281 }}
282 }},
283 "plugin": ["sqz"]
284}}"#
285 ),
286 scope: HookScope::Project,
287 },
288 ]
289}
290
291pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
295 let configs = generate_hook_configs(sqz_path);
296 let mut installed = Vec::new();
297
298 for config in &configs {
299 let full_path = project_dir.join(&config.config_path);
300
301 if full_path.exists() {
303 continue;
304 }
305
306 if let Some(parent) = full_path.parent() {
308 if std::fs::create_dir_all(parent).is_err() {
309 continue;
310 }
311 }
312
313 if std::fs::write(&full_path, &config.config_content).is_ok() {
314 installed.push(config.tool_name.clone());
315 }
316 }
317
318 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
320 if !installed.iter().any(|n| n == "OpenCode") {
321 installed.push("OpenCode".to_string());
322 }
323 }
324
325 installed
326}
327
328fn extract_base_command(cmd: &str) -> &str {
332 cmd.split_whitespace()
333 .next()
334 .unwrap_or("unknown")
335 .rsplit('/')
336 .next()
337 .unwrap_or("unknown")
338}
339
340fn shell_escape(s: &str) -> String {
342 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
343 s.to_string()
344 } else {
345 format!("'{}'", s.replace('\'', "'\\''"))
346 }
347}
348
349fn is_interactive_command(cmd: &str) -> bool {
351 let base = extract_base_command(cmd);
352 matches!(
353 base,
354 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
355 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
356 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
357 ) || cmd.contains("--watch")
358 || cmd.contains("-w ")
359 || cmd.ends_with(" -w")
360 || cmd.contains("run dev")
361 || cmd.contains("run start")
362 || cmd.contains("run serve")
363}
364
365#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn test_process_hook_rewrites_bash_command() {
373 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
374 let result = process_hook(input).unwrap();
375 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
376 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
377 let cmd = parsed["updatedInput"]["command"].as_str().unwrap();
378 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
379 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
380 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
381 }
382
383 #[test]
384 fn test_process_hook_passes_through_non_bash() {
385 let input = r#"{"toolName":"Read","toolCall":{"path":"file.txt"}}"#;
386 let result = process_hook(input).unwrap();
387 assert_eq!(result, input, "non-bash tools should pass through unchanged");
388 }
389
390 #[test]
391 fn test_process_hook_skips_sqz_commands() {
392 let input = r#"{"toolName":"Bash","toolCall":{"command":"sqz stats"}}"#;
393 let result = process_hook(input).unwrap();
394 assert_eq!(result, input, "sqz commands should not be double-wrapped");
395 }
396
397 #[test]
398 fn test_process_hook_skips_interactive() {
399 let input = r#"{"toolName":"Bash","toolCall":{"command":"vim file.txt"}}"#;
400 let result = process_hook(input).unwrap();
401 assert_eq!(result, input, "interactive commands should pass through");
402 }
403
404 #[test]
405 fn test_process_hook_skips_watch_mode() {
406 let input = r#"{"toolName":"Bash","toolCall":{"command":"npm run dev --watch"}}"#;
407 let result = process_hook(input).unwrap();
408 assert_eq!(result, input, "watch mode should pass through");
409 }
410
411 #[test]
412 fn test_process_hook_empty_command() {
413 let input = r#"{"toolName":"Bash","toolCall":{"command":""}}"#;
414 let result = process_hook(input).unwrap();
415 assert_eq!(result, input);
416 }
417
418 #[test]
419 fn test_process_hook_gemini_format() {
420 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
422 let result = process_hook(input).unwrap();
423 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
424 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
425 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
426 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
427 }
428
429 #[test]
430 fn test_process_hook_invalid_json() {
431 let result = process_hook("not json");
432 assert!(result.is_err());
433 }
434
435 #[test]
436 fn test_extract_base_command() {
437 assert_eq!(extract_base_command("git status"), "git");
438 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
439 assert_eq!(extract_base_command("cargo test --release"), "cargo");
440 }
441
442 #[test]
443 fn test_is_interactive_command() {
444 assert!(is_interactive_command("vim file.txt"));
445 assert!(is_interactive_command("npm run dev --watch"));
446 assert!(is_interactive_command("python3"));
447 assert!(!is_interactive_command("git status"));
448 assert!(!is_interactive_command("cargo test"));
449 }
450
451 #[test]
452 fn test_generate_hook_configs() {
453 let configs = generate_hook_configs("sqz");
454 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
455 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
456 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
457 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
458 }
459
460 #[test]
461 fn test_shell_escape_simple() {
462 assert_eq!(shell_escape("git"), "git");
463 assert_eq!(shell_escape("cargo-test"), "cargo-test");
464 }
465
466 #[test]
467 fn test_shell_escape_special_chars() {
468 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
469 }
470
471 #[test]
472 fn test_install_tool_hooks_creates_files() {
473 let dir = tempfile::tempdir().unwrap();
474 let installed = install_tool_hooks(dir.path(), "sqz");
475 assert!(!installed.is_empty(), "should install at least one hook config");
477 for name in &installed {
479 let configs = generate_hook_configs("sqz");
480 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
481 let path = dir.path().join(&config.config_path);
482 assert!(path.exists(), "hook config should exist: {}", path.display());
483 }
484 }
485
486 #[test]
487 fn test_install_tool_hooks_does_not_overwrite() {
488 let dir = tempfile::tempdir().unwrap();
489 install_tool_hooks(dir.path(), "sqz");
491 let custom_path = dir.path().join(".claude/settings.local.json");
493 std::fs::write(&custom_path, "custom content").unwrap();
494 install_tool_hooks(dir.path(), "sqz");
496 let content = std::fs::read_to_string(&custom_path).unwrap();
497 assert_eq!(content, "custom content", "should not overwrite existing config");
498 }
499}