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/// - OpenCode: ~/.config/opencode/plugins/sqz.ts (TypeScript plugin, tool.execute.before)
15
16use std::path::{Path, PathBuf};
17
18use crate::error::Result;
19
20/// A tool hook configuration for a specific AI coding tool.
21#[derive(Debug, Clone)]
22pub struct ToolHookConfig {
23    /// Name of the AI tool.
24    pub tool_name: String,
25    /// Path to the hook config file (relative to project root or home).
26    pub config_path: PathBuf,
27    /// The JSON/TOML content to write.
28    pub config_content: String,
29    /// Whether this is a project-level or user-level config.
30    pub scope: HookScope,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HookScope {
35    /// Installed per-project (e.g., .claude/hooks/)
36    Project,
37    /// Installed globally for the user (e.g., ~/.claude/hooks/)
38    User,
39}
40
41/// Process a PreToolUse hook invocation from an AI tool.
42///
43/// Reads a JSON payload from `input` describing the tool call, rewrites
44/// bash commands to pipe through sqz, and returns the modified payload.
45///
46/// Input format (Claude Code):
47/// ```json
48/// {
49///   "tool_name": "Bash",
50///   "tool_input": {
51///     "command": "git status"
52///   }
53/// }
54/// ```
55///
56/// Output: same structure with command rewritten to pipe through sqz.
57/// Exit code 0 = proceed with modified command.
58/// Exit code 1 = block the tool call (not used here).
59pub 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    // Claude Code uses "toolName" + "toolCall", Gemini uses "tool_name" + "tool_input",
64    // Cursor uses "toolName" or "tool_name" depending on version.
65    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    // Only intercept Bash/shell tool calls.
72    //
73    // Claude Code's built-in tools (Read, Grep, Glob, Write) bypass shell
74    // hooks entirely. PostToolUse hooks can view but NOT modify their output
75    // (confirmed: github.com/anthropics/claude-code/issues/4544). The tool
76    // output enters the context unchanged. We can only compress Bash command
77    // output by rewriting the command via PreToolUse. The MCP server
78    // (sqz-mcp) provides compressed alternatives to these built-in tools.
79    if !matches!(tool_name, "Bash" | "bash" | "shell" | "terminal"
80        | "run_terminal_command" | "run_shell_command") {
81        // Pass through non-bash tools unchanged
82        return Ok(input.to_string());
83    }
84
85    // Claude Code puts command in toolCall.command, Gemini in tool_input.command
86    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    // Don't intercept commands that are already piped through sqz
98    if command.contains("sqz") || command.contains("SQZ_CMD") {
99        return Ok(input.to_string());
100    }
101
102    // Don't intercept interactive or long-running commands
103    if is_interactive_command(command) {
104        return Ok(input.to_string());
105    }
106
107    // Rewrite: pipe the command's output through sqz compress
108    let rewritten = format!(
109        "SQZ_CMD={} {} 2>&1 | sqz compress",
110        shell_escape(extract_base_command(command)),
111        command
112    );
113
114    // Build the output in the format the tool expects.
115    // Claude Code expects: { "decision": "approve", "updatedInput": { "command": "..." } }
116    // Gemini expects: { "hookSpecificOutput": { "tool_input": { "command": "..." } } }
117    // For maximum compatibility, output both formats.
118    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
135/// Generate hook configuration files for all supported AI tools.
136pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
137    vec![
138        // Claude Code — goes in .claude/settings.local.json (nested format)
139        // Includes PreToolUse for Bash compression AND SessionStart compact
140        // for re-injecting context after compaction.
141        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        // Cursor
175        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        // Windsurf
198        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        // Cline
221        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        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
244        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        // OpenCode — TypeScript plugin at ~/.config/opencode/plugins/sqz.ts
267        // plus opencode.json config in project root. Unlike other tools,
268        // OpenCode uses a TS plugin (not JSON hooks), so we generate a
269        // placeholder config here and the actual plugin is installed
270        // separately via install_opencode_plugin().
271        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
291/// Install hook configs for detected AI tools in the given project directory.
292///
293/// Returns the list of tools that were configured.
294pub 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        // Don't overwrite existing hook configs
302        if full_path.exists() {
303            continue;
304        }
305
306        // Create parent directories
307        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    // Also install the OpenCode TypeScript plugin (user-level)
319    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
328// ── Helpers ───────────────────────────────────────────────────────────────
329
330/// Extract the base command name from a full command string.
331fn 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
340/// Shell-escape a string for use in an environment variable assignment.
341fn 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
349/// Check if a command is interactive or long-running (should not be intercepted).
350fn 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// ── Tests ─────────────────────────────────────────────────────────────────
366
367#[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        // Gemini CLI uses tool_name + tool_input
421        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        // Should install at least some hooks
476        assert!(!installed.is_empty(), "should install at least one hook config");
477        // Verify files were created
478        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        // First install
490        install_tool_hooks(dir.path(), "sqz");
491        // Write a custom file to one of the paths
492        let custom_path = dir.path().join(".claude/settings.local.json");
493        std::fs::write(&custom_path, "custom content").unwrap();
494        // Second install should not overwrite
495        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}