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 (tools that support command rewriting via hooks):
9/// - Claude Code: .claude/settings.local.json (nested PreToolUse, matcher: "Bash")
10/// - Cursor: .cursor/hooks.json (flat preToolUse, matcher: "Shell")
11/// - Gemini CLI: .gemini/settings.json (nested BeforeTool, matcher: "run_shell_command")
12/// - OpenCode: ~/.config/opencode/plugins/sqz.ts (TypeScript plugin, tool.execute.before)
13///
14/// Tools that do NOT support command rewriting via hooks (use MCP server instead):
15/// - Codex: only supports deny in PreToolUse; updatedInput is parsed but ignored
16/// - Windsurf: no documented hook API; uses .windsurfrules prompt-level guidance
17/// - Cline: PreToolUse cannot rewrite commands; uses .clinerules prompt-level guidance
18
19use std::path::{Path, PathBuf};
20
21use crate::error::Result;
22
23/// A tool hook configuration for a specific AI coding tool.
24#[derive(Debug, Clone)]
25pub struct ToolHookConfig {
26    /// Name of the AI tool.
27    pub tool_name: String,
28    /// Path to the hook config file (relative to project root or home).
29    pub config_path: PathBuf,
30    /// The JSON/TOML content to write.
31    pub config_content: String,
32    /// Whether this is a project-level or user-level config.
33    pub scope: HookScope,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum HookScope {
38    /// Installed per-project (e.g., .claude/hooks/)
39    Project,
40    /// Installed globally for the user (e.g., ~/.claude/hooks/)
41    User,
42}
43
44/// Which AI tool platform is invoking the hook.
45/// Each platform has a different JSON output format.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum HookPlatform {
48    /// Claude Code: hookSpecificOutput with updatedInput (camelCase)
49    ClaudeCode,
50    /// Cursor: flat { permission, updated_input } (snake_case)
51    Cursor,
52    /// Gemini CLI: decision + hookSpecificOutput.tool_input
53    GeminiCli,
54    /// Windsurf: exit-code based (no JSON rewriting support confirmed)
55    Windsurf,
56}
57
58/// Process a PreToolUse hook invocation from an AI tool.
59///
60/// Reads a JSON payload from `input` describing the tool call, rewrites
61/// bash commands to pipe through sqz, and returns the modified payload.
62///
63/// Input format (Claude Code):
64/// ```json
65/// {
66///   "tool_name": "Bash",
67///   "tool_input": {
68///     "command": "git status"
69///   }
70/// }
71/// ```
72///
73/// Output: same structure with command rewritten to pipe through sqz.
74/// Exit code 0 = proceed with modified command.
75/// Exit code 1 = block the tool call (not used here).
76pub fn process_hook(input: &str) -> Result<String> {
77    process_hook_for_platform(input, HookPlatform::ClaudeCode)
78}
79
80/// Process a hook invocation for Cursor (different output format).
81///
82/// Cursor uses flat JSON: `{ "permission": "allow", "updated_input": { "command": "..." } }`
83/// Returns `{}` when no rewrite (Cursor requires JSON on all code paths).
84pub fn process_hook_cursor(input: &str) -> Result<String> {
85    process_hook_for_platform(input, HookPlatform::Cursor)
86}
87
88/// Process a hook invocation for Gemini CLI.
89///
90/// Gemini uses: `{ "decision": "allow", "hookSpecificOutput": { "tool_input": { "command": "..." } } }`
91pub fn process_hook_gemini(input: &str) -> Result<String> {
92    process_hook_for_platform(input, HookPlatform::GeminiCli)
93}
94
95/// Process a hook invocation for Windsurf.
96///
97/// Windsurf hook support is limited. We attempt the same rewrite as Claude Code
98/// but the output format may not be honored. Falls back to exit-code semantics.
99pub fn process_hook_windsurf(input: &str) -> Result<String> {
100    process_hook_for_platform(input, HookPlatform::Windsurf)
101}
102
103/// Platform-aware hook processing. Extracts the command from the tool-specific
104/// input format, rewrites it, and returns the response in the correct format
105/// for the target platform.
106fn 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    // Claude Code uses "tool_name" + "tool_input" (official docs).
111    // Cursor uses "hook_event_name": "beforeShellExecution" with "command" at top level.
112    // Some older references show "toolName" + "toolCall" — accept all.
113    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    // Only intercept Bash/shell tool calls.
126    //
127    // Claude Code's built-in tools (Read, Grep, Glob, Write) bypass shell
128    // hooks entirely. PostToolUse hooks can view but NOT modify their output
129    // (confirmed: github.com/anthropics/claude-code/issues/4544). The tool
130    // output enters the context unchanged. We can only compress Bash command
131    // output by rewriting the command via PreToolUse. The MCP server
132    // (sqz-mcp) provides compressed alternatives to these built-in tools.
133    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        // Pass through non-bash tools unchanged.
139        // Cursor requires valid JSON on all code paths (empty object = passthrough).
140        return Ok(match platform {
141            HookPlatform::Cursor => "{}".to_string(),
142            _ => input.to_string(),
143        });
144    }
145
146    // Claude Code puts command in tool_input.command (official docs).
147    // Cursor puts command at top level: { "command": "git status" }.
148    // Windsurf puts command in tool_info.command_line.
149    // Some older references show toolCall.command — accept all.
150    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    // Don't intercept commands that are already piped through sqz.
177    // Check the base command name specifically, not substring — so
178    // "grep sqz logfile" or "cargo search sqz" aren't skipped.
179    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    // Don't intercept interactive or long-running commands
188    if is_interactive_command(command) {
189        return Ok(match platform {
190            HookPlatform::Cursor => "{}".to_string(),
191            _ => input.to_string(),
192        });
193    }
194
195    // Don't intercept commands with shell operators that would break piping.
196    // Compound commands (&&, ||, ;), redirects (>, <, >>), background (&),
197    // heredocs (<<), and process substitution would misbehave when we append
198    // `2>&1 | sqz compress` — the pipe only captures the last command.
199    if has_shell_operators(command) {
200        return Ok(match platform {
201            HookPlatform::Cursor => "{}".to_string(),
202            _ => input.to_string(),
203        });
204    }
205
206    // Rewrite: pipe the command's output through sqz compress.
207    // The command is a simple command (no operators), so direct piping is safe.
208    let rewritten = format!(
209        "SQZ_CMD={} {} 2>&1 | sqz compress",
210        shell_escape(extract_base_command(command)),
211        command
212    );
213
214    // Build platform-specific output.
215    //
216    // Each AI tool expects a different JSON response format. Using the wrong
217    // format causes silent failures (the tool ignores the rewrite).
218    //
219    // Verified against official docs + RTK codebase (github.com/rtk-ai/rtk):
220    //
221    // Claude Code (docs.anthropic.com/en/docs/claude-code/hooks):
222    //   hookSpecificOutput.hookEventName = "PreToolUse"
223    //   hookSpecificOutput.permissionDecision = "allow"
224    //   hookSpecificOutput.updatedInput = { "command": "..." }  (camelCase, replaces entire input)
225    //
226    // Cursor (confirmed by RTK hooks/cursor/rtk-rewrite.sh):
227    //   permission = "allow"
228    //   updated_input = { "command": "..." }  (snake_case, flat — NOT nested in hookSpecificOutput)
229    //   Returns {} when no rewrite (Cursor requires JSON on all paths)
230    //
231    // Gemini CLI (geminicli.com/docs/hooks/reference):
232    //   decision = "allow" | "deny"  (top-level)
233    //   hookSpecificOutput.tool_input = { "command": "..." }  (merged with model args)
234    //
235    // Codex (developers.openai.com/codex/hooks):
236    //   Only "deny" works in PreToolUse. "allow", updatedInput, additionalContext
237    //   are parsed but NOT supported — they fail open. RTK uses AGENTS.md instead.
238    //   We do NOT generate hooks for Codex.
239    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            // Windsurf hook support is unconfirmed for command rewriting.
266            // Use Claude Code format as best-effort; the hook may only work
267            // via exit codes (0 = allow, 2 = block).
268            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
285/// Generate hook configuration files for all supported AI tools.
286pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
287    // On Windows, `sqz_path` contains backslashes (C:\Users\...\sqz.exe).
288    // Embedding the raw path into JSON string values produces invalid JSON
289    // because `\` must be escaped as `\\` per RFC 8259. Same rule applies
290    // to JS/TS string literals. See issue #2.
291    //
292    // We keep TWO versions of the path:
293    //   - `sqz_path_raw` — the original, shown in markdown files the user
294    //     reads (.windsurfrules, .clinerules) where backslashes should
295    //     render as-is so the user can copy-paste the command.
296    //   - `sqz_path` — JSON/JS-escaped, used in every .json / .ts config.
297    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        // Claude Code — goes in .claude/settings.local.json (nested format)
303        // Includes PreToolUse for Bash compression AND SessionStart compact
304        // for re-injecting context after compaction.
305        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        // Cursor — uses its own hook format (different from Claude Code).
339        // Config: .cursor/hooks.json with "version": 1
340        // Event key: "preToolUse" (camelCase, NOT "PreToolUse" or "beforeShellExecution")
341        // Entry format: flat { "command": "...", "matcher": "Shell" } (NOT nested hooks array)
342        // Input: { "tool_name": "Shell", "tool_input": { "command": "..." } }
343        // Output: { "permission": "allow", "updated_input": { "command": "..." } }
344        // Returns {} when no rewrite (Cursor requires JSON on all paths).
345        // Verified against RTK: github.com/rtk-ai/rtk/blob/master/hooks/cursor/
346        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        // Windsurf — no confirmed hook API for command rewriting.
365        // RTK uses .windsurfrules (prompt-level guidance) instead of hooks.
366        // We generate a rules file that instructs Windsurf to use sqz.
367        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        // Cline / Roo Code — PreToolUse cannot rewrite commands (only cancel/allow).
390        // RTK uses .clinerules (prompt-level guidance) instead of hooks.
391        // We generate a rules file that instructs Cline to use sqz.
392        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        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
415        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        // OpenCode — TypeScript plugin at ~/.config/opencode/plugins/sqz.ts
438        // plus opencode.json config in project root. Unlike other tools,
439        // OpenCode uses a TS plugin (not JSON hooks), so we generate a
440        // placeholder config here and the actual plugin is installed
441        // separately via install_opencode_plugin().
442        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
462/// Install hook configs for detected AI tools in the given project directory.
463///
464/// Returns the list of tools that were configured.
465pub 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        // Don't overwrite existing hook configs
473        if full_path.exists() {
474            continue;
475        }
476
477        // Create parent directories
478        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    // Also install the OpenCode TypeScript plugin (user-level)
490    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
499// ── Helpers ───────────────────────────────────────────────────────────────
500
501/// Extract the base command name from a full command string.
502fn 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
511/// Escape a string for embedding as the contents of a double-quoted JSON
512/// string value (per RFC 8259). Also valid for embedding in a double-quoted
513/// JavaScript/TypeScript string literal — JS string-escape rules for the
514/// characters that appear in filesystem paths (`\`, `"`, control chars) are
515/// a strict subset of JSON's.
516///
517/// Needed because hook configs embed the sqz executable path into JSON/TS
518/// files via `format!`. On Windows, `current_exe()` returns
519/// `C:\Users\...\sqz.exe` — the raw backslashes produce invalid JSON that
520/// Claude/Cursor/Gemini fail to parse. See issue #2.
521pub(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                // Other control chars: use \u00XX escape
534                out.push_str(&format!("\\u{:04x}", c as u32));
535            }
536            c => out.push(c),
537        }
538    }
539    out
540}
541
542/// Shell-escape a string for use in an environment variable assignment.
543fn 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
551/// Check if a command contains shell operators that would break piping.
552/// Commands with these operators are passed through uncompressed rather
553/// than risk incorrect behavior.
554fn has_shell_operators(cmd: &str) -> bool {
555    // Check for operators that would cause the pipe to only capture
556    // the last command in a chain
557    cmd.contains("&&")
558        || cmd.contains("||")
559        || cmd.contains(';')
560        || cmd.contains('>')
561        || cmd.contains('<')
562        || cmd.contains('|') // already has a pipe
563        || cmd.contains('&') && !cmd.contains("&&") // background &
564        || cmd.contains("<<")  // heredoc
565        || cmd.contains("$(")  // command substitution
566        || cmd.contains('`')   // backtick substitution
567}
568
569/// Check if a command is interactive or long-running (should not be intercepted).
570fn 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// ── Tests ─────────────────────────────────────────────────────────────────
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_process_hook_rewrites_bash_command() {
593        // Use the official Claude Code input format: tool_name + tool_input
594        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        // Claude Code format: hookSpecificOutput with updatedInput
598        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        // updatedInput for Claude Code (camelCase)
602        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        // Claude Code format should NOT have top-level decision/permission/continue
607        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        // Gemini CLI uses tool_name + tool_input (same field names as Claude Code)
650        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        // Gemini uses top-level decision (not hookSpecificOutput.permissionDecision)
654        assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
655        // Gemini format: hookSpecificOutput.tool_input.command (NOT updatedInput)
656        let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
657        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
658        // Should NOT have Claude Code fields
659        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        // Test backward compatibility with older toolName/toolCall format
668        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        // Cursor uses tool_name "Shell" + tool_input.command (same as Claude Code input)
678        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        // Cursor expects flat permission + updated_input (snake_case)
682        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        // Should NOT have Claude Code hookSpecificOutput
687        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        // Cursor requires {} on all code paths, even when no rewrite happens
694        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        // sqz commands should not be double-wrapped; Cursor still needs {}
702        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        // Windsurf uses agent_action_name + tool_info.command_line
710        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        // Windsurf uses Claude Code format as best-effort
714        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        // Windsurf and Cline should generate rules files, not hook configs
750        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        // Cursor should use correct format
757        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        // Cursor should NOT use nested hooks array (that's Claude Code format)
765        assert!(!cursor.config_content.contains("\"type\": \"command\""),
766            "Cursor config should use flat format, not nested hooks array");
767    }
768
769    // ── Issue #2: Windows path escaping in hook configs ───────────────
770
771    #[test]
772    fn test_json_escape_string_value() {
773        // Plain ASCII: unchanged
774        assert_eq!(json_escape_string_value("sqz"), "sqz");
775        assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
776        // Backslash: escaped
777        assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
778                   r"C:\\Users\\Alice\\sqz.exe");
779        // Double quote: escaped
780        assert_eq!(json_escape_string_value(r#"path with "quotes""#),
781                   r#"path with \"quotes\""#);
782        // Control chars
783        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        // Issue #2 repro: on Windows, current_exe() returns a path with
789        // backslashes. Without escaping, the generated JSON is invalid.
790        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        // Verify the command was written with the original path (not lossy-transformed).
799        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        // The .windsurfrules / .clinerules files are markdown for humans.
833        // Backslashes should NOT be doubled there — the user needs to
834        // copy-paste the command into their shell.
835        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        // Regression: make sure the escape path doesn't mangle Unix paths
852        // (which have no backslashes to escape).
853        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        // Should install at least some hooks
879        assert!(!installed.is_empty(), "should install at least one hook config");
880        // Verify files were created
881        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        // First install
893        install_tool_hooks(dir.path(), "sqz");
894        // Write a custom file to one of the paths
895        let custom_path = dir.path().join(".claude/settings.local.json");
896        std::fs::write(&custom_path, "custom content").unwrap();
897        // Second install should not overwrite
898        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}