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/// - Gemini CLI: .gemini/settings.json (nested BeforeTool, matcher: "run_shell_command")
11/// - OpenCode: ~/.config/opencode/plugins/sqz.ts (TypeScript plugin, tool.execute.before)
12///
13/// Tools that do NOT support command rewriting via hooks (use prompt-level
14/// guidance via rules files 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/// - Cursor: beforeShellExecution hook can allow/deny/ask only; the response
19///   has no documented field for rewriting the command. Uses .cursor/rules/sqz.mdc
20///   prompt-level guidance instead. The `sqz hook cursor` subcommand remains
21///   available and well-formed for users who configure hooks manually, but
22///   Cursor's documented hook schema (per GitButler deep-dive and Cupcake
23///   reference docs) confirms the response is `{permission, continue,
24///   userMessage, agentMessage}` only — no `updated_input`.
25
26use std::path::{Path, PathBuf};
27
28use crate::error::Result;
29
30/// A tool hook configuration for a specific AI coding tool.
31#[derive(Debug, Clone)]
32pub struct ToolHookConfig {
33    /// Name of the AI tool.
34    pub tool_name: String,
35    /// Path to the hook config file (relative to project root or home).
36    pub config_path: PathBuf,
37    /// The JSON/TOML content to write.
38    pub config_content: String,
39    /// Whether this is a project-level or user-level config.
40    pub scope: HookScope,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HookScope {
45    /// Installed per-project (e.g., .claude/hooks/)
46    Project,
47    /// Installed globally for the user (e.g., ~/.claude/hooks/)
48    User,
49}
50
51/// Which AI tool platform is invoking the hook.
52/// Each platform has a different JSON output format.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum HookPlatform {
55    /// Claude Code: hookSpecificOutput with updatedInput (camelCase)
56    ClaudeCode,
57    /// Cursor: flat { permission, updated_input } (snake_case)
58    Cursor,
59    /// Gemini CLI: decision + hookSpecificOutput.tool_input
60    GeminiCli,
61    /// Windsurf: exit-code based (no JSON rewriting support confirmed)
62    Windsurf,
63}
64
65/// Process a PreToolUse hook invocation from an AI tool.
66///
67/// Reads a JSON payload from `input` describing the tool call, rewrites
68/// bash commands to pipe through sqz, and returns the modified payload.
69///
70/// Input format (Claude Code):
71/// ```json
72/// {
73///   "tool_name": "Bash",
74///   "tool_input": {
75///     "command": "git status"
76///   }
77/// }
78/// ```
79///
80/// Output: same structure with command rewritten to pipe through sqz.
81/// Exit code 0 = proceed with modified command.
82/// Exit code 1 = block the tool call (not used here).
83pub fn process_hook(input: &str) -> Result<String> {
84    process_hook_for_platform(input, HookPlatform::ClaudeCode)
85}
86
87/// Process a hook invocation for Cursor (different output format).
88///
89/// Cursor uses flat JSON: `{ "permission": "allow", "updated_input": { "command": "..." } }`
90/// Returns `{}` when no rewrite (Cursor requires JSON on all code paths).
91pub fn process_hook_cursor(input: &str) -> Result<String> {
92    process_hook_for_platform(input, HookPlatform::Cursor)
93}
94
95/// Process a hook invocation for Gemini CLI.
96///
97/// Gemini uses: `{ "decision": "allow", "hookSpecificOutput": { "tool_input": { "command": "..." } } }`
98pub fn process_hook_gemini(input: &str) -> Result<String> {
99    process_hook_for_platform(input, HookPlatform::GeminiCli)
100}
101
102/// Process a hook invocation for Windsurf.
103///
104/// Windsurf hook support is limited. We attempt the same rewrite as Claude Code
105/// but the output format may not be honored. Falls back to exit-code semantics.
106pub fn process_hook_windsurf(input: &str) -> Result<String> {
107    process_hook_for_platform(input, HookPlatform::Windsurf)
108}
109
110/// Platform-aware hook processing. Extracts the command from the tool-specific
111/// input format, rewrites it, and returns the response in the correct format
112/// for the target platform.
113fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
114    let parsed: serde_json::Value = serde_json::from_str(input)
115        .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
116
117    // Claude Code uses "tool_name" + "tool_input" (official docs).
118    // Cursor uses "hook_event_name": "beforeShellExecution" with "command" at top level.
119    // Some older references show "toolName" + "toolCall" — accept all.
120    let tool_name = parsed
121        .get("tool_name")
122        .or_else(|| parsed.get("toolName"))
123        .and_then(|v| v.as_str())
124        .unwrap_or("");
125
126    let hook_event = parsed
127        .get("hook_event_name")
128        .or_else(|| parsed.get("agent_action_name"))
129        .and_then(|v| v.as_str())
130        .unwrap_or("");
131
132    // Only intercept Bash/shell tool calls.
133    //
134    // Claude Code's built-in tools (Read, Grep, Glob, Write) bypass shell
135    // hooks entirely. PostToolUse hooks can view but NOT modify their output
136    // (confirmed: github.com/anthropics/claude-code/issues/4544). The tool
137    // output enters the context unchanged. We can only compress Bash command
138    // output by rewriting the command via PreToolUse. The MCP server
139    // (sqz-mcp) provides compressed alternatives to these built-in tools.
140    let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
141        | "run_terminal_command" | "run_shell_command")
142        || matches!(hook_event, "beforeShellExecution" | "pre_run_command");
143
144    if !is_shell {
145        // Pass through non-bash tools unchanged.
146        // Cursor requires valid JSON on all code paths (empty object = passthrough).
147        return Ok(match platform {
148            HookPlatform::Cursor => "{}".to_string(),
149            _ => input.to_string(),
150        });
151    }
152
153    // Claude Code puts command in tool_input.command (official docs).
154    // Cursor puts command at top level: { "command": "git status" }.
155    // Windsurf puts command in tool_info.command_line.
156    // Some older references show toolCall.command — accept all.
157    let command = parsed
158        .get("tool_input")
159        .and_then(|v| v.get("command"))
160        .and_then(|v| v.as_str())
161        .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
162        .or_else(|| {
163            parsed
164                .get("tool_info")
165                .and_then(|v| v.get("command_line"))
166                .and_then(|v| v.as_str())
167        })
168        .or_else(|| {
169            parsed
170                .get("toolCall")
171                .and_then(|v| v.get("command"))
172                .and_then(|v| v.as_str())
173        })
174        .unwrap_or("");
175
176    if command.is_empty() {
177        return Ok(match platform {
178            HookPlatform::Cursor => "{}".to_string(),
179            _ => input.to_string(),
180        });
181    }
182
183    // Don't intercept commands that are already piped through sqz.
184    // Check the base command name specifically, not substring — so
185    // "grep sqz logfile" or "cargo search sqz" aren't skipped.
186    let base_cmd = extract_base_command(command);
187    if base_cmd == "sqz" || command.starts_with("SQZ_CMD=") {
188        return Ok(match platform {
189            HookPlatform::Cursor => "{}".to_string(),
190            _ => input.to_string(),
191        });
192    }
193
194    // Don't intercept interactive or long-running commands
195    if is_interactive_command(command) {
196        return Ok(match platform {
197            HookPlatform::Cursor => "{}".to_string(),
198            _ => input.to_string(),
199        });
200    }
201
202    // Don't intercept commands with shell operators that would break piping.
203    // Compound commands (&&, ||, ;), redirects (>, <, >>), background (&),
204    // heredocs (<<), and process substitution would misbehave when we append
205    // `2>&1 | sqz compress` — the pipe only captures the last command.
206    if has_shell_operators(command) {
207        return Ok(match platform {
208            HookPlatform::Cursor => "{}".to_string(),
209            _ => input.to_string(),
210        });
211    }
212
213    // Rewrite: pipe the command's output through sqz compress.
214    // The command is a simple command (no operators), so direct piping is safe.
215    let rewritten = format!(
216        "SQZ_CMD={} {} 2>&1 | sqz compress",
217        shell_escape(extract_base_command(command)),
218        command
219    );
220
221    // Build platform-specific output.
222    //
223    // Each AI tool expects a different JSON response format. Using the wrong
224    // format causes silent failures (the tool ignores the rewrite).
225    //
226    // Verified against official docs + RTK codebase (github.com/rtk-ai/rtk):
227    //
228    // Claude Code (docs.anthropic.com/en/docs/claude-code/hooks):
229    //   hookSpecificOutput.hookEventName = "PreToolUse"
230    //   hookSpecificOutput.permissionDecision = "allow"
231    //   hookSpecificOutput.updatedInput = { "command": "..." }  (camelCase, replaces entire input)
232    //
233    // Cursor (confirmed by RTK hooks/cursor/rtk-rewrite.sh):
234    //   permission = "allow"
235    //   updated_input = { "command": "..." }  (snake_case, flat — NOT nested in hookSpecificOutput)
236    //   Returns {} when no rewrite (Cursor requires JSON on all paths)
237    //
238    // Gemini CLI (geminicli.com/docs/hooks/reference):
239    //   decision = "allow" | "deny"  (top-level)
240    //   hookSpecificOutput.tool_input = { "command": "..." }  (merged with model args)
241    //
242    // Codex (developers.openai.com/codex/hooks):
243    //   Only "deny" works in PreToolUse. "allow", updatedInput, additionalContext
244    //   are parsed but NOT supported — they fail open. RTK uses AGENTS.md instead.
245    //   We do NOT generate hooks for Codex.
246    let output = match platform {
247        HookPlatform::ClaudeCode => serde_json::json!({
248            "hookSpecificOutput": {
249                "hookEventName": "PreToolUse",
250                "permissionDecision": "allow",
251                "permissionDecisionReason": "sqz: command output will be compressed for token savings",
252                "updatedInput": {
253                    "command": rewritten
254                }
255            }
256        }),
257        HookPlatform::Cursor => serde_json::json!({
258            "permission": "allow",
259            "updated_input": {
260                "command": rewritten
261            }
262        }),
263        HookPlatform::GeminiCli => serde_json::json!({
264            "decision": "allow",
265            "hookSpecificOutput": {
266                "tool_input": {
267                    "command": rewritten
268                }
269            }
270        }),
271        HookPlatform::Windsurf => {
272            // Windsurf hook support is unconfirmed for command rewriting.
273            // Use Claude Code format as best-effort; the hook may only work
274            // via exit codes (0 = allow, 2 = block).
275            serde_json::json!({
276                "hookSpecificOutput": {
277                    "hookEventName": "PreToolUse",
278                    "permissionDecision": "allow",
279                    "permissionDecisionReason": "sqz: command output will be compressed for token savings",
280                    "updatedInput": {
281                        "command": rewritten
282                    }
283                }
284            })
285        }
286    };
287
288    serde_json::to_string(&output)
289        .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
290}
291
292/// Generate hook configuration files for all supported AI tools.
293pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
294    // On Windows, `sqz_path` contains backslashes (C:\Users\...\sqz.exe).
295    // Embedding the raw path into JSON string values produces invalid JSON
296    // because `\` must be escaped as `\\` per RFC 8259. Same rule applies
297    // to JS/TS string literals. See issue #2.
298    //
299    // We keep TWO versions of the path:
300    //   - `sqz_path_raw` — the original, shown in markdown files the user
301    //     reads (.windsurfrules, .clinerules) where backslashes should
302    //     render as-is so the user can copy-paste the command.
303    //   - `sqz_path` — JSON/JS-escaped, used in every .json / .ts config.
304    let sqz_path_raw = sqz_path;
305    let sqz_path_json = json_escape_string_value(sqz_path);
306    let sqz_path = &sqz_path_json;
307
308    vec![
309        // Claude Code — goes in .claude/settings.local.json (nested format)
310        // Three hooks, each addressing a different concern:
311        //
312        //   PreToolUse:   compress Bash tool output before the agent sees it
313        //                 (matcher "Bash" keeps other tools untouched)
314        //   PreCompact:   mark sqz's dedup refs stale before Claude Code
315        //                 summarises older turns. Otherwise our §ref:HASH§
316        //                 tokens would outlive the content they pointed at,
317        //                 leading to dangling refs the agent can't resolve.
318        //                 Documented by Anthropic at
319        //                 docs.anthropic.com/en/docs/claude-code/hooks-guide.
320        //   SessionStart: if the session was resumed via /compact, re-inject
321        //                 sqz's session guide (handled by `sqz resume`).
322        ToolHookConfig {
323            tool_name: "Claude Code".to_string(),
324            config_path: PathBuf::from(".claude/settings.local.json"),
325            config_content: format!(
326                r#"{{
327  "hooks": {{
328    "PreToolUse": [
329      {{
330        "matcher": "Bash",
331        "hooks": [
332          {{
333            "type": "command",
334            "command": "{sqz_path} hook claude"
335          }}
336        ]
337      }}
338    ],
339    "PreCompact": [
340      {{
341        "hooks": [
342          {{
343            "type": "command",
344            "command": "{sqz_path} hook precompact"
345          }}
346        ]
347      }}
348    ],
349    "SessionStart": [
350      {{
351        "matcher": "compact",
352        "hooks": [
353          {{
354            "type": "command",
355            "command": "{sqz_path} resume"
356          }}
357        ]
358      }}
359    ]
360  }}
361}}"#
362            ),
363            scope: HookScope::Project,
364        },
365        // Cursor — beforeShellExecution hook can only allow/deny/ask; it
366        // does NOT support rewriting the command via `updated_input` or any
367        // other field (verified against Cursor's documented schema via
368        // GitButler's deep-dive and Cupcake's reference, plus an empirical
369        // trace against cursor-agent 2026.04.14-ee4b43a). We use the modern
370        // .cursor/rules/*.mdc format (legacy .cursorrules is deprecated per
371        // docs.cursor.com/en/context/rules). `alwaysApply: true` guarantees
372        // the rule loads for every agent interaction.
373        ToolHookConfig {
374            tool_name: "Cursor".to_string(),
375            config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
376            config_content: format!(
377                r#"---
378description: sqz — Token-Optimized CLI Output
379alwaysApply: true
380---
381
382# sqz — Token-Optimized CLI Output
383
384When running shell commands whose output may be long (directory listings,
385git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
386pipe the output through `sqz compress` to reduce token consumption.
387
388`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
389usage is to pipe the command's output into `sqz compress`:
390
391```bash
392# Instead of:     Use:
393git status        git status 2>&1 | {sqz_path_raw} compress
394cargo test        cargo test 2>&1 | {sqz_path_raw} compress
395git log -10       git log -10 2>&1 | {sqz_path_raw} compress
396docker ps         docker ps 2>&1 | {sqz_path_raw} compress
397ls -la            ls -la 2>&1 | {sqz_path_raw} compress
398```
399
400The `2>&1` captures stderr too, which is useful for commands like `cargo
401test` where diagnostics go to stderr. `sqz compress` filters and compresses
402the combined output while preserving filenames, paths, and identifiers.
403It typically saves 60-90% tokens on verbose commands.
404
405Do NOT pipe output for:
406- Interactive commands (`vim`, `ssh`, `python`, REPLs)
407- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
408  `cmd; other`) — run those directly
409- Short commands whose output is already a few lines
410
411If `sqz` is not on PATH, run commands normally.
412"#
413            ),
414            scope: HookScope::Project,
415        },
416        // Windsurf — no confirmed hook API for command rewriting.
417        // RTK uses .windsurfrules (prompt-level guidance) instead of hooks.
418        // We generate a rules file that instructs Windsurf to use sqz.
419        ToolHookConfig {
420            tool_name: "Windsurf".to_string(),
421            config_path: PathBuf::from(".windsurfrules"),
422            config_content: format!(
423                r#"# sqz — Token-Optimized CLI Output
424
425Pipe verbose shell command output through `sqz compress` to save tokens.
426`sqz` reads from stdin and writes the compressed output to stdout — it is
427NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
428
429```bash
430# Instead of:     Use:
431git status        git status 2>&1 | {sqz_path_raw} compress
432cargo test        cargo test 2>&1 | {sqz_path_raw} compress
433git log -10       git log -10 2>&1 | {sqz_path_raw} compress
434docker ps         docker ps 2>&1 | {sqz_path_raw} compress
435```
436
437sqz filters and compresses command outputs while preserving filenames,
438paths, and identifiers (typically 60-90% token reduction on verbose
439commands). Skip short commands, interactive commands (vim, ssh, python),
440and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
441not on PATH, run commands normally.
442"#
443            ),
444            scope: HookScope::Project,
445        },
446        // Cline / Roo Code — PreToolUse cannot rewrite commands (only cancel/allow).
447        // RTK uses .clinerules (prompt-level guidance) instead of hooks.
448        // We generate a rules file that instructs Cline to use sqz.
449        ToolHookConfig {
450            tool_name: "Cline".to_string(),
451            config_path: PathBuf::from(".clinerules"),
452            config_content: format!(
453                r#"# sqz — Token-Optimized CLI Output
454
455Pipe verbose shell command output through `sqz compress` to save tokens.
456`sqz` reads from stdin and writes the compressed output to stdout — it is
457NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
458
459```bash
460# Instead of:     Use:
461git status        git status 2>&1 | {sqz_path_raw} compress
462cargo test        cargo test 2>&1 | {sqz_path_raw} compress
463git log -10       git log -10 2>&1 | {sqz_path_raw} compress
464docker ps         docker ps 2>&1 | {sqz_path_raw} compress
465```
466
467sqz filters and compresses command outputs while preserving filenames,
468paths, and identifiers (typically 60-90% token reduction on verbose
469commands). Skip short commands, interactive commands (vim, ssh, python),
470and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
471not on PATH, run commands normally.
472"#
473            ),
474            scope: HookScope::Project,
475        },
476        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
477        ToolHookConfig {
478            tool_name: "Gemini CLI".to_string(),
479            config_path: PathBuf::from(".gemini/settings.json"),
480            config_content: format!(
481                r#"{{
482  "hooks": {{
483    "BeforeTool": [
484      {{
485        "matcher": "run_shell_command",
486        "hooks": [
487          {{
488            "type": "command",
489            "command": "{sqz_path} hook gemini"
490          }}
491        ]
492      }}
493    ]
494  }}
495}}"#
496            ),
497            scope: HookScope::Project,
498        },
499        // OpenCode — TypeScript plugin at ~/.config/opencode/plugins/sqz.ts
500        // plus a config file in project root (opencode.json or
501        // opencode.jsonc). Unlike other tools, OpenCode uses a TS
502        // plugin (not JSON hooks). The `config_path` below is the
503        // fresh-install default; `install_tool_hooks` detects a
504        // pre-existing `.jsonc` and merges into it instead. The actual
505        // plugin (sqz.ts) is installed separately via
506        // `install_opencode_plugin()`.
507        ToolHookConfig {
508            tool_name: "OpenCode".to_string(),
509            config_path: PathBuf::from("opencode.json"),
510            config_content: format!(
511                r#"{{
512  "$schema": "https://opencode.ai/config.json",
513  "mcp": {{
514    "sqz": {{
515      "type": "local",
516      "command": ["sqz-mcp", "--transport", "stdio"]
517    }}
518  }},
519  "plugin": ["sqz"]
520}}"#
521            ),
522            scope: HookScope::Project,
523        },
524    ]
525}
526
527/// Install hook configs for detected AI tools in the given project directory.
528///
529/// Install hook configs for detected AI tools in the given project directory.
530///
531/// Returns the list of tools that were configured.
532pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
533    let configs = generate_hook_configs(sqz_path);
534    let mut installed = Vec::new();
535
536    for config in &configs {
537        // OpenCode config files are special: they live alongside the
538        // user's own config and must be *merged* rather than clobbered.
539        // The placeholder `config_content` is only used on a fresh
540        // install; `update_opencode_config_detailed` handles both the
541        // create-new and merge-into-existing cases, AND picks the
542        // right file extension (opencode.jsonc vs opencode.json) —
543        // fixes issue #6 where the old write-if-missing logic created
544        // a parallel `opencode.json` next to an existing `.jsonc`.
545        if config.tool_name == "OpenCode" {
546            match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
547                Ok((updated, _comments_lost)) => {
548                    if updated && !installed.iter().any(|n| n == "OpenCode") {
549                        installed.push("OpenCode".to_string());
550                    }
551                }
552                Err(_e) => {
553                    // Non-fatal — leave OpenCode out of the installed
554                    // list and continue with other tools.
555                }
556            }
557            continue;
558        }
559
560        let full_path = project_dir.join(&config.config_path);
561
562        // Don't overwrite existing hook configs
563        if full_path.exists() {
564            continue;
565        }
566
567        // Create parent directories
568        if let Some(parent) = full_path.parent() {
569            if std::fs::create_dir_all(parent).is_err() {
570                continue;
571            }
572        }
573
574        if std::fs::write(&full_path, &config.config_content).is_ok() {
575            installed.push(config.tool_name.clone());
576        }
577    }
578
579    // Also install the OpenCode TypeScript plugin (user-level). The
580    // config merge above has already put OpenCode in `installed` if it
581    // wrote anything, so this call only matters for machines where no
582    // project config existed — we still want the user-level plugin so
583    // future OpenCode sessions see sqz.
584    if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
585        if !installed.iter().any(|n| n == "OpenCode") {
586            installed.push("OpenCode".to_string());
587        }
588    }
589
590    installed
591}
592
593// ── Helpers ───────────────────────────────────────────────────────────────
594
595/// Extract the base command name from a full command string.
596fn extract_base_command(cmd: &str) -> &str {
597    cmd.split_whitespace()
598        .next()
599        .unwrap_or("unknown")
600        .rsplit('/')
601        .next()
602        .unwrap_or("unknown")
603}
604
605/// Escape a string for embedding as the contents of a double-quoted JSON
606/// string value (per RFC 8259). Also valid for embedding in a double-quoted
607/// JavaScript/TypeScript string literal — JS string-escape rules for the
608/// characters that appear in filesystem paths (`\`, `"`, control chars) are
609/// a strict subset of JSON's.
610///
611/// Needed because hook configs embed the sqz executable path into JSON/TS
612/// files via `format!`. On Windows, `current_exe()` returns
613/// `C:\Users\...\sqz.exe` — the raw backslashes produce invalid JSON that
614/// Claude/Cursor/Gemini fail to parse. See issue #2.
615pub(crate) fn json_escape_string_value(s: &str) -> String {
616    let mut out = String::with_capacity(s.len() + 2);
617    for ch in s.chars() {
618        match ch {
619            '\\' => out.push_str("\\\\"),
620            '"' => out.push_str("\\\""),
621            '\n' => out.push_str("\\n"),
622            '\r' => out.push_str("\\r"),
623            '\t' => out.push_str("\\t"),
624            '\x08' => out.push_str("\\b"),
625            '\x0c' => out.push_str("\\f"),
626            c if (c as u32) < 0x20 => {
627                // Other control chars: use \u00XX escape
628                out.push_str(&format!("\\u{:04x}", c as u32));
629            }
630            c => out.push(c),
631        }
632    }
633    out
634}
635
636/// Shell-escape a string for use in an environment variable assignment.
637fn shell_escape(s: &str) -> String {
638    if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
639        s.to_string()
640    } else {
641        format!("'{}'", s.replace('\'', "'\\''"))
642    }
643}
644
645/// Check if a command contains shell operators that would break piping.
646/// Commands with these operators are passed through uncompressed rather
647/// than risk incorrect behavior.
648fn has_shell_operators(cmd: &str) -> bool {
649    // Check for operators that would cause the pipe to only capture
650    // the last command in a chain
651    cmd.contains("&&")
652        || cmd.contains("||")
653        || cmd.contains(';')
654        || cmd.contains('>')
655        || cmd.contains('<')
656        || cmd.contains('|') // already has a pipe
657        || cmd.contains('&') && !cmd.contains("&&") // background &
658        || cmd.contains("<<")  // heredoc
659        || cmd.contains("$(")  // command substitution
660        || cmd.contains('`')   // backtick substitution
661}
662
663/// Check if a command is interactive or long-running (should not be intercepted).
664fn is_interactive_command(cmd: &str) -> bool {
665    let base = extract_base_command(cmd);
666    matches!(
667        base,
668        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
669        | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
670        | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
671    ) || cmd.contains("--watch")
672        || cmd.contains("-w ")
673        || cmd.ends_with(" -w")
674        || cmd.contains("run dev")
675        || cmd.contains("run start")
676        || cmd.contains("run serve")
677}
678
679// ── Tests ─────────────────────────────────────────────────────────────────
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_process_hook_rewrites_bash_command() {
687        // Use the official Claude Code input format: tool_name + tool_input
688        let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
689        let result = process_hook(input).unwrap();
690        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
691        // Claude Code format: hookSpecificOutput with updatedInput
692        let hook_output = &parsed["hookSpecificOutput"];
693        assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
694        assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
695        // updatedInput for Claude Code (camelCase)
696        let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
697        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
698        assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
699        assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
700        // Claude Code format should NOT have top-level decision/permission/continue
701        assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
702        assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
703        assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
704    }
705
706    #[test]
707    fn test_process_hook_passes_through_non_bash() {
708        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
709        let result = process_hook(input).unwrap();
710        assert_eq!(result, input, "non-bash tools should pass through unchanged");
711    }
712
713    #[test]
714    fn test_process_hook_skips_sqz_commands() {
715        let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
716        let result = process_hook(input).unwrap();
717        assert_eq!(result, input, "sqz commands should not be double-wrapped");
718    }
719
720    #[test]
721    fn test_process_hook_skips_interactive() {
722        let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
723        let result = process_hook(input).unwrap();
724        assert_eq!(result, input, "interactive commands should pass through");
725    }
726
727    #[test]
728    fn test_process_hook_skips_watch_mode() {
729        let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
730        let result = process_hook(input).unwrap();
731        assert_eq!(result, input, "watch mode should pass through");
732    }
733
734    #[test]
735    fn test_process_hook_empty_command() {
736        let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
737        let result = process_hook(input).unwrap();
738        assert_eq!(result, input);
739    }
740
741    #[test]
742    fn test_process_hook_gemini_format() {
743        // Gemini CLI uses tool_name + tool_input (same field names as Claude Code)
744        let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
745        let result = process_hook_gemini(input).unwrap();
746        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
747        // Gemini uses top-level decision (not hookSpecificOutput.permissionDecision)
748        assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
749        // Gemini format: hookSpecificOutput.tool_input.command (NOT updatedInput)
750        let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
751        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
752        // Should NOT have Claude Code fields
753        assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
754            "Gemini format should not have updatedInput");
755        assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
756            "Gemini format should not have permissionDecision");
757    }
758
759    #[test]
760    fn test_process_hook_legacy_format() {
761        // Test backward compatibility with older toolName/toolCall format
762        let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
763        let result = process_hook(input).unwrap();
764        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
765        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
766        assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
767    }
768
769    #[test]
770    fn test_process_hook_cursor_format() {
771        // Cursor uses tool_name "Shell" + tool_input.command (same as Claude Code input)
772        let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
773        let result = process_hook_cursor(input).unwrap();
774        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
775        // Cursor expects flat permission + updated_input (snake_case)
776        assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
777        let cmd = parsed["updated_input"]["command"].as_str().unwrap();
778        assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
779        assert!(cmd.contains("git status"));
780        // Should NOT have Claude Code hookSpecificOutput
781        assert!(parsed.get("hookSpecificOutput").is_none(),
782            "Cursor format should not have hookSpecificOutput");
783    }
784
785    #[test]
786    fn test_process_hook_cursor_passthrough_returns_empty_json() {
787        // Cursor requires {} on all code paths, even when no rewrite happens
788        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
789        let result = process_hook_cursor(input).unwrap();
790        assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
791    }
792
793    #[test]
794    fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
795        // sqz commands should not be double-wrapped; Cursor still needs {}
796        let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
797        let result = process_hook_cursor(input).unwrap();
798        assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
799    }
800
801    #[test]
802    fn test_process_hook_windsurf_format() {
803        // Windsurf uses agent_action_name + tool_info.command_line
804        let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
805        let result = process_hook_windsurf(input).unwrap();
806        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
807        // Windsurf uses Claude Code format as best-effort
808        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
809        assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
810        assert!(cmd.contains("cargo test"));
811        assert!(cmd.contains("SQZ_CMD=cargo"));
812    }
813
814    #[test]
815    fn test_process_hook_invalid_json() {
816        let result = process_hook("not json");
817        assert!(result.is_err());
818    }
819
820    #[test]
821    fn test_extract_base_command() {
822        assert_eq!(extract_base_command("git status"), "git");
823        assert_eq!(extract_base_command("/usr/bin/git log"), "git");
824        assert_eq!(extract_base_command("cargo test --release"), "cargo");
825    }
826
827    #[test]
828    fn test_is_interactive_command() {
829        assert!(is_interactive_command("vim file.txt"));
830        assert!(is_interactive_command("npm run dev --watch"));
831        assert!(is_interactive_command("python3"));
832        assert!(!is_interactive_command("git status"));
833        assert!(!is_interactive_command("cargo test"));
834    }
835
836    #[test]
837    fn test_generate_hook_configs() {
838        let configs = generate_hook_configs("sqz");
839        assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
840        assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
841        assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
842        assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
843        // Windsurf, Cline, and Cursor should generate rules files, not hook configs
844        // (none of the three support transparent command rewriting via hooks).
845        let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
846        assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
847            "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
848        let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
849        assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
850            "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
851        // Cursor — empirically verified (forum/Cupcake/GitButler docs +
852        // live cursor-agent trace) that beforeShellExecution cannot rewrite
853        // commands. Use the modern .cursor/rules/*.mdc format.
854        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
855        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
856            "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
857             .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
858        assert!(cursor.config_content.starts_with("---"),
859            "Cursor rule should start with YAML frontmatter");
860        assert!(cursor.config_content.contains("alwaysApply: true"),
861            "Cursor rule should use alwaysApply: true so the guidance loads \
862             for every agent interaction");
863        assert!(cursor.config_content.contains("sqz"),
864            "Cursor rule body should mention sqz");
865    }
866
867    #[test]
868    fn test_claude_config_includes_precompact_hook() {
869        // The PreCompact hook is what keeps sqz's dedup refs from dangling
870        // after Claude Code auto-compacts. Without this entry, cached refs
871        // can point at content the LLM no longer has in context.
872        // Documented at docs.anthropic.com/en/docs/claude-code/hooks-guide.
873        let configs = generate_hook_configs("sqz");
874        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
875        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
876            .expect("Claude Code config must be valid JSON");
877
878        let precompact = parsed["hooks"]["PreCompact"]
879            .as_array()
880            .expect("PreCompact hook array must be present");
881        assert!(
882            !precompact.is_empty(),
883            "PreCompact must have at least one registered hook"
884        );
885
886        let cmd = precompact[0]["hooks"][0]["command"]
887            .as_str()
888            .expect("command field must be a string");
889        assert!(
890            cmd.ends_with(" hook precompact"),
891            "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
892        );
893    }
894
895    // ── Issue #2: Windows path escaping in hook configs ───────────────
896
897    #[test]
898    fn test_json_escape_string_value() {
899        // Plain ASCII: unchanged
900        assert_eq!(json_escape_string_value("sqz"), "sqz");
901        assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
902        // Backslash: escaped
903        assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
904                   r"C:\\Users\\Alice\\sqz.exe");
905        // Double quote: escaped
906        assert_eq!(json_escape_string_value(r#"path with "quotes""#),
907                   r#"path with \"quotes\""#);
908        // Control chars
909        assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
910    }
911
912    #[test]
913    fn test_windows_path_produces_valid_json_for_claude() {
914        // Issue #2 repro: on Windows, current_exe() returns a path with
915        // backslashes. Without escaping, the generated JSON is invalid.
916        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
917        let configs = generate_hook_configs(windows_path);
918
919        let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
920            .expect("Claude config should be generated");
921        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
922            .expect("Claude hook config must be valid JSON on Windows paths");
923
924        // Verify the command was written with the original path (not lossy-transformed).
925        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
926            .as_str()
927            .expect("command field must be a string");
928        assert!(cmd.contains(windows_path),
929            "command '{cmd}' must contain the original Windows path '{windows_path}'");
930    }
931
932    #[test]
933    fn test_windows_path_in_cursor_rules_file() {
934        // Cursor's config is now .cursor/rules/sqz.mdc (markdown), not JSON.
935        // Markdown doesn't escape backslashes — the user reads this rule
936        // through the agent and needs to see the raw path so commands are
937        // pasteable. See test_rules_files_use_raw_path_for_readability for
938        // the same property on Windsurf/Cline.
939        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
940        let configs = generate_hook_configs(windows_path);
941
942        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
943        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
944        assert!(cursor.config_content.contains(windows_path),
945            "Cursor rule must contain the raw (unescaped) path so users can \
946             copy-paste the shown commands — got:\n{}", cursor.config_content);
947        assert!(!cursor.config_content.contains(r"C:\\Users"),
948            "Cursor rule must NOT double-escape backslashes in markdown — \
949             got:\n{}", cursor.config_content);
950    }
951
952    #[test]
953    fn test_windows_path_produces_valid_json_for_gemini() {
954        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
955        let configs = generate_hook_configs(windows_path);
956
957        let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
958        let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
959            .expect("Gemini hook config must be valid JSON on Windows paths");
960        let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
961        assert!(cmd.contains(windows_path));
962    }
963
964    #[test]
965    fn test_rules_files_use_raw_path_for_readability() {
966        // The .windsurfrules / .clinerules / .cursor/rules/sqz.mdc files are
967        // markdown for humans. Backslashes should NOT be doubled there — the
968        // user needs to copy-paste the command into their shell.
969        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
970        let configs = generate_hook_configs(windows_path);
971
972        for tool in &["Windsurf", "Cline", "Cursor"] {
973            let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
974            assert!(cfg.config_content.contains(windows_path),
975                "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
976                cfg.config_content);
977            assert!(!cfg.config_content.contains(r"C:\\Users"),
978                "{tool} rules file must NOT double-escape backslashes — got:\n{}",
979                cfg.config_content);
980        }
981    }
982
983    #[test]
984    fn test_unix_path_still_works() {
985        // Regression: make sure the escape path doesn't mangle Unix paths
986        // (which have no backslashes to escape).
987        let unix_path = "/usr/local/bin/sqz";
988        let configs = generate_hook_configs(unix_path);
989
990        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
991        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
992            .expect("Unix path should produce valid JSON");
993        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
994        assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
995    }
996
997    #[test]
998    fn test_shell_escape_simple() {
999        assert_eq!(shell_escape("git"), "git");
1000        assert_eq!(shell_escape("cargo-test"), "cargo-test");
1001    }
1002
1003    #[test]
1004    fn test_shell_escape_special_chars() {
1005        assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1006    }
1007
1008    #[test]
1009    fn test_install_tool_hooks_creates_files() {
1010        let dir = tempfile::tempdir().unwrap();
1011        let installed = install_tool_hooks(dir.path(), "sqz");
1012        // Should install at least some hooks
1013        assert!(!installed.is_empty(), "should install at least one hook config");
1014        // Verify files were created
1015        for name in &installed {
1016            let configs = generate_hook_configs("sqz");
1017            let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1018            let path = dir.path().join(&config.config_path);
1019            assert!(path.exists(), "hook config should exist: {}", path.display());
1020        }
1021    }
1022
1023    #[test]
1024    fn test_install_tool_hooks_does_not_overwrite() {
1025        let dir = tempfile::tempdir().unwrap();
1026        // First install
1027        install_tool_hooks(dir.path(), "sqz");
1028        // Write a custom file to one of the paths
1029        let custom_path = dir.path().join(".claude/settings.local.json");
1030        std::fs::write(&custom_path, "custom content").unwrap();
1031        // Second install should not overwrite
1032        install_tool_hooks(dir.path(), "sqz");
1033        let content = std::fs::read_to_string(&custom_path).unwrap();
1034        assert_eq!(content, "custom content", "should not overwrite existing config");
1035    }
1036}