Skip to main content

sqz_engine/
tool_hooks.rs

1/// PreToolUse hook integration for AI coding tools.
2///
3/// Provides transparent command interception: when an AI tool (Claude Code,
4/// Cursor, Copilot, etc.) executes a bash command, the hook rewrites it to
5/// pipe output through sqz for compression. The AI tool never knows it
6/// happened — it just sees smaller output.
7///
8/// Supported hook formats:
9/// - Claude Code: .claude/settings.local.json (nested PreToolUse, matcher: "Bash")
10/// - Cursor: .cursor/hooks.json (nested PreToolUse, matcher: "Bash")
11/// - Windsurf: .windsurf/hooks.json (nested PreToolUse, matcher: "Bash")
12/// - Gemini CLI: .gemini/settings.json (nested BeforeTool, matcher: "run_shell_command")
13/// - Cline: .cline/hooks.json (nested PreToolUse, matcher: "Bash")
14
15use std::path::{Path, PathBuf};
16
17use crate::error::Result;
18
19/// A tool hook configuration for a specific AI coding tool.
20#[derive(Debug, Clone)]
21pub struct ToolHookConfig {
22    /// Name of the AI tool.
23    pub tool_name: String,
24    /// Path to the hook config file (relative to project root or home).
25    pub config_path: PathBuf,
26    /// The JSON/TOML content to write.
27    pub config_content: String,
28    /// Whether this is a project-level or user-level config.
29    pub scope: HookScope,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum HookScope {
34    /// Installed per-project (e.g., .claude/hooks/)
35    Project,
36    /// Installed globally for the user (e.g., ~/.claude/hooks/)
37    User,
38}
39
40/// Process a PreToolUse hook invocation from an AI tool.
41///
42/// Reads a JSON payload from `input` describing the tool call, rewrites
43/// bash commands to pipe through sqz, and returns the modified payload.
44///
45/// Input format (Claude Code):
46/// ```json
47/// {
48///   "tool_name": "Bash",
49///   "tool_input": {
50///     "command": "git status"
51///   }
52/// }
53/// ```
54///
55/// Output: same structure with command rewritten to pipe through sqz.
56/// Exit code 0 = proceed with modified command.
57/// Exit code 1 = block the tool call (not used here).
58pub 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    // Claude Code uses "toolName" + "toolCall", Gemini uses "tool_name" + "tool_input",
63    // Cursor uses "toolName" or "tool_name" depending on version.
64    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    // Only intercept Bash/shell tool calls
71    if !matches!(tool_name, "Bash" | "bash" | "shell" | "terminal"
72        | "run_terminal_command" | "run_shell_command") {
73        // Pass through non-bash tools unchanged
74        return Ok(input.to_string());
75    }
76
77    // Claude Code puts command in toolCall.command, Gemini in tool_input.command
78    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    // Don't intercept commands that are already piped through sqz
90    if command.contains("sqz") || command.contains("SQZ_CMD") {
91        return Ok(input.to_string());
92    }
93
94    // Don't intercept interactive or long-running commands
95    if is_interactive_command(command) {
96        return Ok(input.to_string());
97    }
98
99    // Rewrite: pipe the command's output through sqz compress
100    let rewritten = format!(
101        "SQZ_CMD={} {} 2>&1 | sqz compress",
102        shell_escape(extract_base_command(command)),
103        command
104    );
105
106    // Build the output in the format the tool expects.
107    // Claude Code expects: { "decision": "approve", "updatedInput": { "command": "..." } }
108    // Gemini expects: { "hookSpecificOutput": { "tool_input": { "command": "..." } } }
109    // For maximum compatibility, output both formats.
110    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
127/// Generate hook configuration files for all supported AI tools.
128pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
129    vec![
130        // Claude Code — goes in .claude/settings.local.json (nested format)
131        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        // Cursor
154        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        // Windsurf
177        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        // Cline
200        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        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
223        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
248/// Install hook configs for detected AI tools in the given project directory.
249///
250/// Returns the list of tools that were configured.
251pub 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        // Don't overwrite existing hook configs
259        if full_path.exists() {
260            continue;
261        }
262
263        // Create parent directories
264        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
278// ── Helpers ───────────────────────────────────────────────────────────────
279
280/// Extract the base command name from a full command string.
281fn 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
290/// Shell-escape a string for use in an environment variable assignment.
291fn 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
299/// Check if a command is interactive or long-running (should not be intercepted).
300fn 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// ── Tests ─────────────────────────────────────────────────────────────────
316
317#[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        // Gemini CLI uses tool_name + tool_input
371        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        // Should install at least some hooks
425        assert!(!installed.is_empty(), "should install at least one hook config");
426        // Verify files were created
427        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        // First install
439        install_tool_hooks(dir.path(), "sqz");
440        // Write a custom file to one of the paths
441        let custom_path = dir.path().join(".claude/settings.local.json");
442        std::fs::write(&custom_path, "custom content").unwrap();
443        // Second install should not overwrite
444        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}