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    // Escape-hatch paragraph shared across every markdown-style rules
309    // file (Cursor/Windsurf/Cline). Documents the three ways an agent
310    // can opt out of compression when it can't parse sqz's output.
311    // Reported by SquireNed on Synthetic: GLM 5.1 loops on `§ref:…§`
312    // dedup tokens. Visible-in-every-rules-file so any agent reading
313    // the rules sees the escape hatch, not just the one that hit the
314    // loop.
315    let escape_hatch = format!(
316        r#"## Escape hatch — when sqz output confuses the agent
317
318If you see a `§ref:HASH§` token in tool output and can't parse it, or if
319compressed output is making it harder to work (loops, repeated tiny
320requests), you have three ways out:
321
322- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
323  original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
324  or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
325- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
326  status 2>&1 | sqz compress`) to disable dedup for that one command.
327  Strictly more tokens, strictly less ambiguous.
328- **`--no-cache`** — the same opt-out as a CLI flag:
329  `git status 2>&1 | sqz compress --no-cache`.
330
331Use these when you genuinely need raw data. The default compressed
332path is still what you want for most output."#,
333    );
334    let escape_hatch = &escape_hatch;
335
336    vec![
337        // Claude Code — goes in .claude/settings.local.json (nested format)
338        // Three hooks, each addressing a different concern:
339        //
340        //   PreToolUse:   compress Bash tool output before the agent sees it
341        //                 (matcher "Bash" keeps other tools untouched)
342        //   PreCompact:   mark sqz's dedup refs stale before Claude Code
343        //                 summarises older turns. Otherwise our §ref:HASH§
344        //                 tokens would outlive the content they pointed at,
345        //                 leading to dangling refs the agent can't resolve.
346        //                 Documented by Anthropic at
347        //                 docs.anthropic.com/en/docs/claude-code/hooks-guide.
348        //   SessionStart: if the session was resumed via /compact, re-inject
349        //                 sqz's session guide (handled by `sqz resume`).
350        ToolHookConfig {
351            tool_name: "Claude Code".to_string(),
352            config_path: PathBuf::from(".claude/settings.local.json"),
353            config_content: format!(
354                r#"{{
355  "hooks": {{
356    "PreToolUse": [
357      {{
358        "matcher": "Bash",
359        "hooks": [
360          {{
361            "type": "command",
362            "command": "{sqz_path} hook claude"
363          }}
364        ]
365      }}
366    ],
367    "PreCompact": [
368      {{
369        "hooks": [
370          {{
371            "type": "command",
372            "command": "{sqz_path} hook precompact"
373          }}
374        ]
375      }}
376    ],
377    "SessionStart": [
378      {{
379        "matcher": "compact",
380        "hooks": [
381          {{
382            "type": "command",
383            "command": "{sqz_path} resume"
384          }}
385        ]
386      }}
387    ]
388  }}
389}}"#
390            ),
391            scope: HookScope::Project,
392        },
393        // Cursor — beforeShellExecution hook can only allow/deny/ask; it
394        // does NOT support rewriting the command via `updated_input` or any
395        // other field (verified against Cursor's documented schema via
396        // GitButler's deep-dive and Cupcake's reference, plus an empirical
397        // trace against cursor-agent 2026.04.14-ee4b43a). We use the modern
398        // .cursor/rules/*.mdc format (legacy .cursorrules is deprecated per
399        // docs.cursor.com/en/context/rules). `alwaysApply: true` guarantees
400        // the rule loads for every agent interaction.
401        ToolHookConfig {
402            tool_name: "Cursor".to_string(),
403            config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
404            config_content: format!(
405                r#"---
406description: sqz — Token-Optimized CLI Output
407alwaysApply: true
408---
409
410# sqz — Token-Optimized CLI Output
411
412When running shell commands whose output may be long (directory listings,
413git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
414pipe the output through `sqz compress` to reduce token consumption.
415
416`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
417usage is to pipe the command's output into `sqz compress`:
418
419```bash
420# Instead of:     Use:
421git status        git status 2>&1 | {sqz_path_raw} compress
422cargo test        cargo test 2>&1 | {sqz_path_raw} compress
423git log -10       git log -10 2>&1 | {sqz_path_raw} compress
424docker ps         docker ps 2>&1 | {sqz_path_raw} compress
425ls -la            ls -la 2>&1 | {sqz_path_raw} compress
426```
427
428The `2>&1` captures stderr too, which is useful for commands like `cargo
429test` where diagnostics go to stderr. `sqz compress` filters and compresses
430the combined output while preserving filenames, paths, and identifiers.
431It typically saves 60-90% tokens on verbose commands.
432
433Do NOT pipe output for:
434- Interactive commands (`vim`, `ssh`, `python`, REPLs)
435- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
436  `cmd; other`) — run those directly
437- Short commands whose output is already a few lines
438
439If `sqz` is not on PATH, run commands normally.
440
441{escape_hatch}
442"#
443            ),
444            scope: HookScope::Project,
445        },
446        // Windsurf — no confirmed hook API for command rewriting.
447        // RTK uses .windsurfrules (prompt-level guidance) instead of hooks.
448        // We generate a rules file that instructs Windsurf to use sqz.
449        ToolHookConfig {
450            tool_name: "Windsurf".to_string(),
451            config_path: PathBuf::from(".windsurfrules"),
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{escape_hatch}
474"#
475            ),
476            scope: HookScope::Project,
477        },
478        // Cline / Roo Code — PreToolUse cannot rewrite commands (only cancel/allow).
479        // RTK uses .clinerules (prompt-level guidance) instead of hooks.
480        // We generate a rules file that instructs Cline to use sqz.
481        ToolHookConfig {
482            tool_name: "Cline".to_string(),
483            config_path: PathBuf::from(".clinerules"),
484            config_content: format!(
485                r#"# sqz — Token-Optimized CLI Output
486
487Pipe verbose shell command output through `sqz compress` to save tokens.
488`sqz` reads from stdin and writes the compressed output to stdout — it is
489NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
490
491```bash
492# Instead of:     Use:
493git status        git status 2>&1 | {sqz_path_raw} compress
494cargo test        cargo test 2>&1 | {sqz_path_raw} compress
495git log -10       git log -10 2>&1 | {sqz_path_raw} compress
496docker ps         docker ps 2>&1 | {sqz_path_raw} compress
497```
498
499sqz filters and compresses command outputs while preserving filenames,
500paths, and identifiers (typically 60-90% token reduction on verbose
501commands). Skip short commands, interactive commands (vim, ssh, python),
502and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
503not on PATH, run commands normally.
504
505{escape_hatch}
506"#
507            ),
508            scope: HookScope::Project,
509        },
510        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
511        ToolHookConfig {
512            tool_name: "Gemini CLI".to_string(),
513            config_path: PathBuf::from(".gemini/settings.json"),
514            config_content: format!(
515                r#"{{
516  "hooks": {{
517    "BeforeTool": [
518      {{
519        "matcher": "run_shell_command",
520        "hooks": [
521          {{
522            "type": "command",
523            "command": "{sqz_path} hook gemini"
524          }}
525        ]
526      }}
527    ]
528  }}
529}}"#
530            ),
531            scope: HookScope::Project,
532        },
533        // OpenCode — TypeScript plugin at ~/.config/opencode/plugins/sqz.ts
534        // plus a config file in project root (opencode.json or
535        // opencode.jsonc). Unlike other tools, OpenCode uses a TS
536        // plugin (not JSON hooks). The `config_path` below is the
537        // fresh-install default; `install_tool_hooks` detects a
538        // pre-existing `.jsonc` and merges into it instead. The actual
539        // plugin (sqz.ts) is installed separately via
540        // `install_opencode_plugin()`.
541        ToolHookConfig {
542            tool_name: "OpenCode".to_string(),
543            config_path: PathBuf::from("opencode.json"),
544            config_content: format!(
545                r#"{{
546  "$schema": "https://opencode.ai/config.json",
547  "mcp": {{
548    "sqz": {{
549      "type": "local",
550      "command": ["sqz-mcp", "--transport", "stdio"]
551    }}
552  }},
553  "plugin": ["sqz"]
554}}"#
555            ),
556            scope: HookScope::Project,
557        },
558        // Codex (openai/codex) — no stable per-tool-call hook, only a
559        // turn-end `notify` that fires after the agent is done and can't
560        // rewrite tool output. Native integration is therefore two-part:
561        //
562        //   1. AGENTS.md at project root — prompt-level guidance telling
563        //      Codex to pipe shell output through `sqz compress`. This is
564        //      the same approach RTK uses for Codex and the shape Codex
565        //      expects (the cross-tool AGENTS.md standard).
566        //   2. ~/.codex/config.toml user-level [mcp_servers.sqz] — Codex
567        //      merges this with any existing entries. Handled specially
568        //      in `install_tool_hooks` via `install_codex_mcp_config`.
569        //
570        // The config_content below is the AGENTS.md guidance block; it
571        // is only used as a placeholder for the (project-level) file and
572        // for surfacing the "create AGENTS.md" line in the install plan.
573        // The actual install goes through
574        // `crate::codex_integration::install_agents_md_guidance` so
575        // pre-existing AGENTS.md files are appended to, not clobbered.
576        ToolHookConfig {
577            tool_name: "Codex".to_string(),
578            config_path: PathBuf::from("AGENTS.md"),
579            config_content: crate::codex_integration::agents_md_guidance_block(
580                sqz_path_raw,
581            ),
582            scope: HookScope::Project,
583        },
584    ]
585}
586
587/// Install hook configs for detected AI tools in the given project directory.
588///
589/// Install hook configs for detected AI tools in the given project directory.
590///
591/// Returns the list of tools that were configured.
592pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
593    install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
594}
595
596/// Where hooks should be written.
597///
598/// The Claude Code scope table (docs.claude.com/en/docs/claude-code/settings)
599/// defines four settings locations: managed, user, project, and local.
600/// `sqz init` cares about the last three:
601///
602/// * `Project` — writes `.claude/settings.local.json` (per-project, gitignored).
603///   This is what the bare `sqz init` has always done. Good for "I only
604///   want sqz active inside this repo", but a common foot-gun because the
605///   user expects it to work everywhere and then sees "caching nothing"
606///   in every other project. Reported by 76vangel.
607///
608/// * `Global` — writes `~/.claude/settings.json` (user scope, applies to
609///   every Claude Code session on this machine regardless of cwd).
610///   This is what RTK's `rtk init -g` does and what most users actually
611///   want on first install. Verified against the official Anthropic scope
612///   table; verified against rtk-ai/rtk's `resolve_claude_dir` helper.
613///
614/// Precedence in Claude Code (highest to lowest): managed > local > project > user.
615/// That means a project-level install can still override a global one —
616/// and a user with `.claude/settings.local.json` in their worktree will
617/// silently shadow the global setting. We do NOT auto-delete the local
618/// file; the uninstall flow is responsible for whichever scope was asked for.
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub enum InstallScope {
621    /// Project-local (gitignored): `.claude/settings.local.json`, `.cursor/rules/`,
622    /// etc. under `project_dir`.
623    Project,
624    /// User-level: `~/.claude/settings.json` and similar home-directory paths.
625    /// Applies to every project on this machine.
626    Global,
627}
628
629/// Like [`install_tool_hooks`] but lets the caller choose between
630/// project-local and user-global scope. This is the function `sqz init`
631/// and `sqz init --global` both call.
632///
633/// For `InstallScope::Global`:
634///
635/// * Claude Code hook is merged into `~/.claude/settings.json` (the user
636///   settings file). We merge rather than overwrite because the user may
637///   already have permissions, env, statusLine, or other hooks there —
638///   blindly writing would nuke their config. Any existing sqz hook
639///   entries are replaced in place; unrelated fields are preserved.
640///
641/// * Cursor, Windsurf, Cline, Gemini CLI rules files don't have a
642///   user-level equivalent that Cursor/etc. actually load. We keep those
643///   at project scope and note it in the plan. Users who want Cursor
644///   compressed across all projects should follow the Cursor docs
645///   (docs.cursor.com/en/context/rules) and add the rule at user scope
646///   manually — Cursor honours ~/.cursor/rules/*.mdc but only within
647///   workspaces that opt in.
648///
649/// * OpenCode plugin is already user-level by design (lives at
650///   `~/.config/opencode/plugins/sqz.ts`), so scope doesn't matter here.
651///
652/// * Codex MCP config is always user-level (`~/.codex/config.toml`).
653///   AGENTS.md stays per-project because that's where it belongs.
654pub fn install_tool_hooks_scoped(
655    project_dir: &Path,
656    sqz_path: &str,
657    scope: InstallScope,
658) -> Vec<String> {
659    let configs = generate_hook_configs(sqz_path);
660    let mut installed = Vec::new();
661
662    for config in &configs {
663        // OpenCode config files are special: they live alongside the
664        // user's own config and must be *merged* rather than clobbered.
665        // The placeholder `config_content` is only used on a fresh
666        // install; `update_opencode_config_detailed` handles both the
667        // create-new and merge-into-existing cases, AND picks the
668        // right file extension (opencode.jsonc vs opencode.json) —
669        // fixes issue #6 where the old write-if-missing logic created
670        // a parallel `opencode.json` next to an existing `.jsonc`.
671        if config.tool_name == "OpenCode" {
672            match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
673                Ok((updated, _comments_lost)) => {
674                    if updated && !installed.iter().any(|n| n == "OpenCode") {
675                        installed.push("OpenCode".to_string());
676                    }
677                }
678                Err(_e) => {
679                    // Non-fatal — leave OpenCode out of the installed
680                    // list and continue with other tools.
681                }
682            }
683            continue;
684        }
685
686        // Codex has the same merge-not-clobber concern on two fronts:
687        // the project-level AGENTS.md (may contain unrelated user
688        // content) and the USER-level ~/.codex/config.toml (may contain
689        // other MCP servers). Both go through the surgical helpers.
690        if config.tool_name == "Codex" {
691            let agents_changed = crate::codex_integration::install_agents_md_guidance(
692                project_dir, sqz_path,
693            )
694            .unwrap_or(false);
695            let mcp_changed = crate::codex_integration::install_codex_mcp_config()
696                .unwrap_or(false);
697            if (agents_changed || mcp_changed)
698                && !installed.iter().any(|n| n == "Codex")
699            {
700                installed.push("Codex".to_string());
701            }
702            continue;
703        }
704
705        // Claude Code at global scope: merge into ~/.claude/settings.json
706        // instead of writing a fresh .claude/settings.local.json in cwd.
707        // This is the fix for "sqz init does nothing outside the project
708        // I ran it in" — reported by 76vangel. Design mirrors rtk init -g.
709        if config.tool_name == "Claude Code" && scope == InstallScope::Global {
710            match install_claude_global(sqz_path) {
711                Ok(true) => installed.push("Claude Code".to_string()),
712                Ok(false) => { /* nothing new to install — already present */ }
713                Err(_e) => {
714                    // Non-fatal: leave Claude Code out and continue.
715                }
716            }
717            continue;
718        }
719
720        let full_path = project_dir.join(&config.config_path);
721
722        // Don't overwrite existing hook configs
723        if full_path.exists() {
724            continue;
725        }
726
727        // Create parent directories
728        if let Some(parent) = full_path.parent() {
729            if std::fs::create_dir_all(parent).is_err() {
730                continue;
731            }
732        }
733
734        if std::fs::write(&full_path, &config.config_content).is_ok() {
735            installed.push(config.tool_name.clone());
736        }
737    }
738
739    // Also install the OpenCode TypeScript plugin (user-level). The
740    // config merge above has already put OpenCode in `installed` if it
741    // wrote anything, so this call only matters for machines where no
742    // project config existed — we still want the user-level plugin so
743    // future OpenCode sessions see sqz.
744    if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
745        if !installed.iter().any(|n| n == "OpenCode") {
746            installed.push("OpenCode".to_string());
747        }
748    }
749
750    installed
751}
752
753// ── Claude Code user-scope hook install ──────────────────────────────────
754
755/// Resolve `~/.claude/settings.json` for the current user.
756///
757/// This is the "User" scope file per the Anthropic scope table
758/// (docs.claude.com/en/docs/claude-code/settings). Applies to every
759/// Claude Code session on this machine regardless of cwd.
760///
761/// Precedence: Managed > Local (`.claude/settings.local.json`) >
762/// Project (`.claude/settings.json`) > User (this file). Users with a
763/// local settings file in a worktree can still override the global
764/// sqz hook — that's intended.
765pub fn claude_user_settings_path() -> Option<PathBuf> {
766    dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
767}
768
769/// Merge sqz's PreToolUse / PreCompact / SessionStart hook entries
770/// into `~/.claude/settings.json`.
771///
772/// * Creates the file if missing, with just our hooks.
773/// * If the file exists, parses it as JSON, replaces any existing sqz
774///   entries (matched by `command` containing `sqz hook` / `sqz resume` /
775///   `sqz hook precompact`), and inserts ours. Everything else — the
776///   user's permissions, env, statusLine, other PreToolUse matchers —
777///   stays untouched.
778/// * Writes atomically (temp file + rename) so a crash halfway through
779///   can't leave the user with a corrupted settings.json.
780///
781/// Returns `Ok(true)` if the file was created or changed, `Ok(false)`
782/// if our hook entries were already present identically.
783fn install_claude_global(sqz_path: &str) -> Result<bool> {
784    let path = claude_user_settings_path().ok_or_else(|| {
785        crate::error::SqzError::Other(
786            "Could not resolve home directory for ~/.claude/settings.json".to_string(),
787        )
788    })?;
789
790    // Parse the existing file, or start from an empty object.
791    let mut root: serde_json::Value = if path.exists() {
792        let content = std::fs::read_to_string(&path).map_err(|e| {
793            crate::error::SqzError::Other(format!(
794                "read {}: {e}",
795                path.display()
796            ))
797        })?;
798        if content.trim().is_empty() {
799            serde_json::Value::Object(serde_json::Map::new())
800        } else {
801            serde_json::from_str(&content).map_err(|e| {
802                crate::error::SqzError::Other(format!(
803                    "parse {}: {e} — please fix or move the file before re-running sqz init",
804                    path.display()
805                ))
806            })?
807        }
808    } else {
809        serde_json::Value::Object(serde_json::Map::new())
810    };
811
812    // Ensure root is an object (users occasionally have arrays or
813    // corrupted files; we refuse to touch those).
814    let root_obj = root.as_object_mut().ok_or_else(|| {
815        crate::error::SqzError::Other(format!(
816            "{} is not a JSON object — refusing to overwrite",
817            path.display()
818        ))
819    })?;
820
821    // Build our three hook entries as fresh JSON values.
822    let pre_tool_use = serde_json::json!({
823        "matcher": "Bash",
824        "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
825    });
826    let pre_compact = serde_json::json!({
827        "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
828    });
829    let session_start = serde_json::json!({
830        "matcher": "compact",
831        "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
832    });
833
834    // Snapshot the "before" state for change detection.
835    let before = serde_json::to_string(&root_obj).unwrap_or_default();
836
837    // Get or create the top-level "hooks" object.
838    let hooks = root_obj
839        .entry("hooks".to_string())
840        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
841    let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
842        crate::error::SqzError::Other(format!(
843            "{}: `hooks` is not an object — refusing to overwrite",
844            path.display()
845        ))
846    })?;
847
848    upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "sqz hook claude");
849    upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "sqz hook precompact");
850    upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "sqz resume");
851
852    let after = serde_json::to_string(&root_obj).unwrap_or_default();
853    if before == after && path.exists() {
854        // Already present and unchanged — no write needed.
855        return Ok(false);
856    }
857
858    // Ensure parent directory exists.
859    if let Some(parent) = path.parent() {
860        std::fs::create_dir_all(parent).map_err(|e| {
861            crate::error::SqzError::Other(format!(
862                "create {}: {e}",
863                parent.display()
864            ))
865        })?;
866    }
867
868    // Atomic write: tempfile in same directory + rename. Modelled after
869    // rtk's `atomic_write` in src/hooks/init.rs. Keeps the old file
870    // intact if serialization or write fails halfway.
871    let parent = path.parent().ok_or_else(|| {
872        crate::error::SqzError::Other(format!(
873            "path {} has no parent directory",
874            path.display()
875        ))
876    })?;
877    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
878        crate::error::SqzError::Other(format!(
879            "create temp file in {}: {e}",
880            parent.display()
881        ))
882    })?;
883    let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
884        .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
885    std::fs::write(tmp.path(), serialized).map_err(|e| {
886        crate::error::SqzError::Other(format!(
887            "write to temp file {}: {e}",
888            tmp.path().display()
889        ))
890    })?;
891    tmp.persist(&path).map_err(|e| {
892        crate::error::SqzError::Other(format!(
893            "rename temp file into place at {}: {e}",
894            path.display()
895        ))
896    })?;
897
898    Ok(true)
899}
900
901/// Remove sqz's hook entries from `~/.claude/settings.json` without
902/// touching any other keys. Symmetric with [`install_claude_global`].
903///
904/// Returns:
905/// * `Ok(Some((path, true)))` — file existed, sqz entries found and
906///   stripped. If the resulting `hooks` object is empty, we also remove
907///   the `hooks` key entirely. If the resulting root object is empty,
908///   we remove the file — matches the uninstall UX of every other sqz
909///   surface.
910/// * `Ok(Some((path, false)))` — file existed but contained no sqz
911///   entries. No write.
912/// * `Ok(None)` — file did not exist.
913/// * `Err(_)` — file existed but could not be read or parsed.
914pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
915    let Some(path) = claude_user_settings_path() else {
916        return Ok(None);
917    };
918    if !path.exists() {
919        return Ok(None);
920    }
921
922    let content = std::fs::read_to_string(&path).map_err(|e| {
923        crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
924    })?;
925    if content.trim().is_empty() {
926        return Ok(Some((path, false)));
927    }
928
929    let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
930        crate::error::SqzError::Other(format!(
931            "parse {}: {e} — refusing to rewrite an unparseable file",
932            path.display()
933        ))
934    })?;
935    let Some(root_obj) = root.as_object_mut() else {
936        return Ok(Some((path, false)));
937    };
938
939    let mut changed = false;
940    if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
941        for (event, sentinel) in &[
942            ("PreToolUse", "sqz hook claude"),
943            ("PreCompact", "sqz hook precompact"),
944            ("SessionStart", "sqz resume"),
945        ] {
946            if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
947                let before = arr.len();
948                arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
949                if arr.len() != before {
950                    changed = true;
951                }
952            }
953        }
954
955        // Drop any now-empty hook event arrays so we don't leave
956        // `"PreToolUse": []` clutter in the user's settings.
957        hooks.retain(|_, v| match v {
958            serde_json::Value::Array(a) => !a.is_empty(),
959            _ => true,
960        });
961
962        // If the whole `hooks` object is now empty, drop it so sqz's
963        // uninstall leaves no trace.
964        let hooks_empty = hooks.is_empty();
965        if hooks_empty {
966            root_obj.remove("hooks");
967            changed = true;
968        }
969    }
970
971    if !changed {
972        return Ok(Some((path, false)));
973    }
974
975    // If root is now completely empty, delete the file — matches the
976    // "leave nothing behind" behaviour of the OpenCode/Codex uninstall
977    // paths.
978    if root_obj.is_empty() {
979        std::fs::remove_file(&path).map_err(|e| {
980            crate::error::SqzError::Other(format!(
981                "remove {}: {e}",
982                path.display()
983            ))
984        })?;
985        return Ok(Some((path, true)));
986    }
987
988    // Atomic rewrite.
989    let parent = path.parent().ok_or_else(|| {
990        crate::error::SqzError::Other(format!(
991            "path {} has no parent directory",
992            path.display()
993        ))
994    })?;
995    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
996        crate::error::SqzError::Other(format!(
997            "create temp file in {}: {e}",
998            parent.display()
999        ))
1000    })?;
1001    let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1002        .map_err(|e| {
1003            crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1004        })?;
1005    std::fs::write(tmp.path(), serialized).map_err(|e| {
1006        crate::error::SqzError::Other(format!(
1007            "write to temp file {}: {e}",
1008            tmp.path().display()
1009        ))
1010    })?;
1011    tmp.persist(&path).map_err(|e| {
1012        crate::error::SqzError::Other(format!(
1013            "rename temp file into place at {}: {e}",
1014            path.display()
1015        ))
1016    })?;
1017
1018    Ok(Some((path, true)))
1019}
1020
1021/// Replace (or insert) sqz's hook entry in the array under
1022/// `hooks[event_name]`. Entries are matched by the `command` substring
1023/// `sentinel` — that way, an upgrade from `sqz hook claude` to a future
1024/// renamed command won't accumulate stale entries.
1025///
1026/// Idempotent: calling this twice yields the same JSON.
1027fn upsert_sqz_hook_entry(
1028    hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1029    event_name: &str,
1030    new_entry: serde_json::Value,
1031    sentinel: &str,
1032) {
1033    let arr = hooks_obj
1034        .entry(event_name.to_string())
1035        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1036    let Some(arr) = arr.as_array_mut() else {
1037        // `hooks[event]` exists but isn't an array — overwrite it with
1038        // just our entry. Not ideal but matches the behavior the user
1039        // would get on a fresh install.
1040        hooks_obj.insert(
1041            event_name.to_string(),
1042            serde_json::Value::Array(vec![new_entry]),
1043        );
1044        return;
1045    };
1046
1047    // Drop any existing entry whose command matches our sentinel.
1048    arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1049
1050    arr.push(new_entry);
1051}
1052
1053/// True if any command in a hook entry contains the given substring.
1054/// Used to locate sqz's own entries without pinning to an exact command
1055/// (so future format changes still upgrade cleanly).
1056fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1057    entry
1058        .get("hooks")
1059        .and_then(|h| h.as_array())
1060        .map(|hooks_arr| {
1061            hooks_arr.iter().any(|h| {
1062                h.get("command")
1063                    .and_then(|c| c.as_str())
1064                    .map(|c| c.contains(needle))
1065                    .unwrap_or(false)
1066            })
1067        })
1068        .unwrap_or(false)
1069}
1070
1071// ── Helpers ───────────────────────────────────────────────────────────────
1072
1073/// Extract the base command name from a full command string.
1074fn extract_base_command(cmd: &str) -> &str {
1075    cmd.split_whitespace()
1076        .next()
1077        .unwrap_or("unknown")
1078        .rsplit('/')
1079        .next()
1080        .unwrap_or("unknown")
1081}
1082
1083/// Escape a string for embedding as the contents of a double-quoted JSON
1084/// string value (per RFC 8259). Also valid for embedding in a double-quoted
1085/// JavaScript/TypeScript string literal — JS string-escape rules for the
1086/// characters that appear in filesystem paths (`\`, `"`, control chars) are
1087/// a strict subset of JSON's.
1088///
1089/// Needed because hook configs embed the sqz executable path into JSON/TS
1090/// files via `format!`. On Windows, `current_exe()` returns
1091/// `C:\Users\...\sqz.exe` — the raw backslashes produce invalid JSON that
1092/// Claude/Cursor/Gemini fail to parse. See issue #2.
1093pub(crate) fn json_escape_string_value(s: &str) -> String {
1094    let mut out = String::with_capacity(s.len() + 2);
1095    for ch in s.chars() {
1096        match ch {
1097            '\\' => out.push_str("\\\\"),
1098            '"' => out.push_str("\\\""),
1099            '\n' => out.push_str("\\n"),
1100            '\r' => out.push_str("\\r"),
1101            '\t' => out.push_str("\\t"),
1102            '\x08' => out.push_str("\\b"),
1103            '\x0c' => out.push_str("\\f"),
1104            c if (c as u32) < 0x20 => {
1105                // Other control chars: use \u00XX escape
1106                out.push_str(&format!("\\u{:04x}", c as u32));
1107            }
1108            c => out.push(c),
1109        }
1110    }
1111    out
1112}
1113
1114/// Shell-escape a string for use in an environment variable assignment.
1115fn shell_escape(s: &str) -> String {
1116    if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1117        s.to_string()
1118    } else {
1119        format!("'{}'", s.replace('\'', "'\\''"))
1120    }
1121}
1122
1123/// Check if a command contains shell operators that would break piping.
1124/// Commands with these operators are passed through uncompressed rather
1125/// than risk incorrect behavior.
1126fn has_shell_operators(cmd: &str) -> bool {
1127    // Check for operators that would cause the pipe to only capture
1128    // the last command in a chain
1129    cmd.contains("&&")
1130        || cmd.contains("||")
1131        || cmd.contains(';')
1132        || cmd.contains('>')
1133        || cmd.contains('<')
1134        || cmd.contains('|') // already has a pipe
1135        || cmd.contains('&') && !cmd.contains("&&") // background &
1136        || cmd.contains("<<")  // heredoc
1137        || cmd.contains("$(")  // command substitution
1138        || cmd.contains('`')   // backtick substitution
1139}
1140
1141/// Check if a command is interactive or long-running (should not be intercepted).
1142fn is_interactive_command(cmd: &str) -> bool {
1143    let base = extract_base_command(cmd);
1144    matches!(
1145        base,
1146        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1147        | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1148        | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1149    ) || cmd.contains("--watch")
1150        || cmd.contains("-w ")
1151        || cmd.ends_with(" -w")
1152        || cmd.contains("run dev")
1153        || cmd.contains("run start")
1154        || cmd.contains("run serve")
1155}
1156
1157// ── Tests ─────────────────────────────────────────────────────────────────
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    #[test]
1164    fn test_process_hook_rewrites_bash_command() {
1165        // Use the official Claude Code input format: tool_name + tool_input
1166        let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1167        let result = process_hook(input).unwrap();
1168        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1169        // Claude Code format: hookSpecificOutput with updatedInput
1170        let hook_output = &parsed["hookSpecificOutput"];
1171        assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1172        assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1173        // updatedInput for Claude Code (camelCase)
1174        let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1175        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1176        assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1177        assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
1178        // Claude Code format should NOT have top-level decision/permission/continue
1179        assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1180        assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1181        assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1182    }
1183
1184    #[test]
1185    fn test_process_hook_passes_through_non_bash() {
1186        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1187        let result = process_hook(input).unwrap();
1188        assert_eq!(result, input, "non-bash tools should pass through unchanged");
1189    }
1190
1191    #[test]
1192    fn test_process_hook_skips_sqz_commands() {
1193        let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1194        let result = process_hook(input).unwrap();
1195        assert_eq!(result, input, "sqz commands should not be double-wrapped");
1196    }
1197
1198    #[test]
1199    fn test_process_hook_skips_interactive() {
1200        let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1201        let result = process_hook(input).unwrap();
1202        assert_eq!(result, input, "interactive commands should pass through");
1203    }
1204
1205    #[test]
1206    fn test_process_hook_skips_watch_mode() {
1207        let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1208        let result = process_hook(input).unwrap();
1209        assert_eq!(result, input, "watch mode should pass through");
1210    }
1211
1212    #[test]
1213    fn test_process_hook_empty_command() {
1214        let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1215        let result = process_hook(input).unwrap();
1216        assert_eq!(result, input);
1217    }
1218
1219    #[test]
1220    fn test_process_hook_gemini_format() {
1221        // Gemini CLI uses tool_name + tool_input (same field names as Claude Code)
1222        let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1223        let result = process_hook_gemini(input).unwrap();
1224        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1225        // Gemini uses top-level decision (not hookSpecificOutput.permissionDecision)
1226        assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1227        // Gemini format: hookSpecificOutput.tool_input.command (NOT updatedInput)
1228        let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1229        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1230        // Should NOT have Claude Code fields
1231        assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1232            "Gemini format should not have updatedInput");
1233        assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1234            "Gemini format should not have permissionDecision");
1235    }
1236
1237    #[test]
1238    fn test_process_hook_legacy_format() {
1239        // Test backward compatibility with older toolName/toolCall format
1240        let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1241        let result = process_hook(input).unwrap();
1242        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1243        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1244        assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1245    }
1246
1247    #[test]
1248    fn test_process_hook_cursor_format() {
1249        // Cursor uses tool_name "Shell" + tool_input.command (same as Claude Code input)
1250        let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1251        let result = process_hook_cursor(input).unwrap();
1252        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1253        // Cursor expects flat permission + updated_input (snake_case)
1254        assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1255        let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1256        assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1257        assert!(cmd.contains("git status"));
1258        // Should NOT have Claude Code hookSpecificOutput
1259        assert!(parsed.get("hookSpecificOutput").is_none(),
1260            "Cursor format should not have hookSpecificOutput");
1261    }
1262
1263    #[test]
1264    fn test_process_hook_cursor_passthrough_returns_empty_json() {
1265        // Cursor requires {} on all code paths, even when no rewrite happens
1266        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1267        let result = process_hook_cursor(input).unwrap();
1268        assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1269    }
1270
1271    #[test]
1272    fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1273        // sqz commands should not be double-wrapped; Cursor still needs {}
1274        let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1275        let result = process_hook_cursor(input).unwrap();
1276        assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1277    }
1278
1279    #[test]
1280    fn test_process_hook_windsurf_format() {
1281        // Windsurf uses agent_action_name + tool_info.command_line
1282        let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1283        let result = process_hook_windsurf(input).unwrap();
1284        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1285        // Windsurf uses Claude Code format as best-effort
1286        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1287        assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1288        assert!(cmd.contains("cargo test"));
1289        assert!(cmd.contains("SQZ_CMD=cargo"));
1290    }
1291
1292    #[test]
1293    fn test_process_hook_invalid_json() {
1294        let result = process_hook("not json");
1295        assert!(result.is_err());
1296    }
1297
1298    #[test]
1299    fn test_extract_base_command() {
1300        assert_eq!(extract_base_command("git status"), "git");
1301        assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1302        assert_eq!(extract_base_command("cargo test --release"), "cargo");
1303    }
1304
1305    #[test]
1306    fn test_is_interactive_command() {
1307        assert!(is_interactive_command("vim file.txt"));
1308        assert!(is_interactive_command("npm run dev --watch"));
1309        assert!(is_interactive_command("python3"));
1310        assert!(!is_interactive_command("git status"));
1311        assert!(!is_interactive_command("cargo test"));
1312    }
1313
1314    #[test]
1315    fn test_generate_hook_configs() {
1316        let configs = generate_hook_configs("sqz");
1317        assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1318        assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1319        assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1320        assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1321        // Windsurf, Cline, and Cursor should generate rules files, not hook configs
1322        // (none of the three support transparent command rewriting via hooks).
1323        let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1324        assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1325            "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1326        let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1327        assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1328            "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1329        // Cursor — empirically verified (forum/Cupcake/GitButler docs +
1330        // live cursor-agent trace) that beforeShellExecution cannot rewrite
1331        // commands. Use the modern .cursor/rules/*.mdc format.
1332        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1333        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1334            "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1335             .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1336        assert!(cursor.config_content.starts_with("---"),
1337            "Cursor rule should start with YAML frontmatter");
1338        assert!(cursor.config_content.contains("alwaysApply: true"),
1339            "Cursor rule should use alwaysApply: true so the guidance loads \
1340             for every agent interaction");
1341        assert!(cursor.config_content.contains("sqz"),
1342            "Cursor rule body should mention sqz");
1343    }
1344
1345    #[test]
1346    fn test_claude_config_includes_precompact_hook() {
1347        // The PreCompact hook is what keeps sqz's dedup refs from dangling
1348        // after Claude Code auto-compacts. Without this entry, cached refs
1349        // can point at content the LLM no longer has in context.
1350        // Documented at docs.anthropic.com/en/docs/claude-code/hooks-guide.
1351        let configs = generate_hook_configs("sqz");
1352        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1353        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1354            .expect("Claude Code config must be valid JSON");
1355
1356        let precompact = parsed["hooks"]["PreCompact"]
1357            .as_array()
1358            .expect("PreCompact hook array must be present");
1359        assert!(
1360            !precompact.is_empty(),
1361            "PreCompact must have at least one registered hook"
1362        );
1363
1364        let cmd = precompact[0]["hooks"][0]["command"]
1365            .as_str()
1366            .expect("command field must be a string");
1367        assert!(
1368            cmd.ends_with(" hook precompact"),
1369            "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1370        );
1371    }
1372
1373    // ── Issue #2: Windows path escaping in hook configs ───────────────
1374
1375    #[test]
1376    fn test_json_escape_string_value() {
1377        // Plain ASCII: unchanged
1378        assert_eq!(json_escape_string_value("sqz"), "sqz");
1379        assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1380        // Backslash: escaped
1381        assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1382                   r"C:\\Users\\Alice\\sqz.exe");
1383        // Double quote: escaped
1384        assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1385                   r#"path with \"quotes\""#);
1386        // Control chars
1387        assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1388    }
1389
1390    #[test]
1391    fn test_windows_path_produces_valid_json_for_claude() {
1392        // Issue #2 repro: on Windows, current_exe() returns a path with
1393        // backslashes. Without escaping, the generated JSON is invalid.
1394        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1395        let configs = generate_hook_configs(windows_path);
1396
1397        let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1398            .expect("Claude config should be generated");
1399        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1400            .expect("Claude hook config must be valid JSON on Windows paths");
1401
1402        // Verify the command was written with the original path (not lossy-transformed).
1403        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1404            .as_str()
1405            .expect("command field must be a string");
1406        assert!(cmd.contains(windows_path),
1407            "command '{cmd}' must contain the original Windows path '{windows_path}'");
1408    }
1409
1410    #[test]
1411    fn test_windows_path_in_cursor_rules_file() {
1412        // Cursor's config is now .cursor/rules/sqz.mdc (markdown), not JSON.
1413        // Markdown doesn't escape backslashes — the user reads this rule
1414        // through the agent and needs to see the raw path so commands are
1415        // pasteable. See test_rules_files_use_raw_path_for_readability for
1416        // the same property on Windsurf/Cline.
1417        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1418        let configs = generate_hook_configs(windows_path);
1419
1420        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1421        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1422        assert!(cursor.config_content.contains(windows_path),
1423            "Cursor rule must contain the raw (unescaped) path so users can \
1424             copy-paste the shown commands — got:\n{}", cursor.config_content);
1425        assert!(!cursor.config_content.contains(r"C:\\Users"),
1426            "Cursor rule must NOT double-escape backslashes in markdown — \
1427             got:\n{}", cursor.config_content);
1428    }
1429
1430    #[test]
1431    fn test_windows_path_produces_valid_json_for_gemini() {
1432        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1433        let configs = generate_hook_configs(windows_path);
1434
1435        let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1436        let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1437            .expect("Gemini hook config must be valid JSON on Windows paths");
1438        let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1439        assert!(cmd.contains(windows_path));
1440    }
1441
1442    #[test]
1443    fn test_rules_files_use_raw_path_for_readability() {
1444        // The .windsurfrules / .clinerules / .cursor/rules/sqz.mdc files are
1445        // markdown for humans. Backslashes should NOT be doubled there — the
1446        // user needs to copy-paste the command into their shell.
1447        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1448        let configs = generate_hook_configs(windows_path);
1449
1450        for tool in &["Windsurf", "Cline", "Cursor"] {
1451            let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1452            assert!(cfg.config_content.contains(windows_path),
1453                "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1454                cfg.config_content);
1455            assert!(!cfg.config_content.contains(r"C:\\Users"),
1456                "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1457                cfg.config_content);
1458        }
1459    }
1460
1461    #[test]
1462    fn test_unix_path_still_works() {
1463        // Regression: make sure the escape path doesn't mangle Unix paths
1464        // (which have no backslashes to escape).
1465        let unix_path = "/usr/local/bin/sqz";
1466        let configs = generate_hook_configs(unix_path);
1467
1468        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1469        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1470            .expect("Unix path should produce valid JSON");
1471        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1472        assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1473    }
1474
1475    #[test]
1476    fn test_shell_escape_simple() {
1477        assert_eq!(shell_escape("git"), "git");
1478        assert_eq!(shell_escape("cargo-test"), "cargo-test");
1479    }
1480
1481    #[test]
1482    fn test_shell_escape_special_chars() {
1483        assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1484    }
1485
1486    #[test]
1487    fn test_install_tool_hooks_creates_files() {
1488        let dir = tempfile::tempdir().unwrap();
1489        let installed = install_tool_hooks(dir.path(), "sqz");
1490        // Should install at least some hooks
1491        assert!(!installed.is_empty(), "should install at least one hook config");
1492        // Verify files were created
1493        for name in &installed {
1494            let configs = generate_hook_configs("sqz");
1495            let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1496            let path = dir.path().join(&config.config_path);
1497            assert!(path.exists(), "hook config should exist: {}", path.display());
1498        }
1499    }
1500
1501    #[test]
1502    fn test_install_tool_hooks_does_not_overwrite() {
1503        let dir = tempfile::tempdir().unwrap();
1504        // First install
1505        install_tool_hooks(dir.path(), "sqz");
1506        // Write a custom file to one of the paths
1507        let custom_path = dir.path().join(".claude/settings.local.json");
1508        std::fs::write(&custom_path, "custom content").unwrap();
1509        // Second install should not overwrite
1510        install_tool_hooks(dir.path(), "sqz");
1511        let content = std::fs::read_to_string(&custom_path).unwrap();
1512        assert_eq!(content, "custom content", "should not overwrite existing config");
1513    }
1514}
1515
1516#[cfg(test)]
1517mod global_install_tests {
1518    use super::*;
1519
1520    /// Run `body` with `HOME` (and `USERPROFILE` on Windows) pointing at
1521    /// `tmp`, then restore the original values. Without this, the tests
1522    /// would write to the real user's `~/.claude/settings.json` and
1523    /// wreck their config.
1524    ///
1525    /// `dirs_next::home_dir` reads `HOME` on Unix and `USERPROFILE` on
1526    /// Windows, so we set both — keeps the tests portable.
1527    ///
1528    /// SAFETY: `set_var` / `remove_var` are marked unsafe on the
1529    /// unstable `std::env` edition; this helper stays on the stable
1530    /// API that doesn't require `unsafe`. Tests that run in parallel
1531    /// must serialize through a mutex because the process-level env
1532    /// is shared.
1533    fn with_fake_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
1534        use std::sync::Mutex;
1535        // Serialize so parallel tests don't race on $HOME.
1536        static LOCK: Mutex<()> = Mutex::new(());
1537        let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1538
1539        let prev_home = std::env::var_os("HOME");
1540        let prev_userprofile = std::env::var_os("USERPROFILE");
1541        std::env::set_var("HOME", tmp);
1542        std::env::set_var("USERPROFILE", tmp);
1543        let result = body();
1544        match prev_home {
1545            Some(v) => std::env::set_var("HOME", v),
1546            None => std::env::remove_var("HOME"),
1547        }
1548        match prev_userprofile {
1549            Some(v) => std::env::set_var("USERPROFILE", v),
1550            None => std::env::remove_var("USERPROFILE"),
1551        }
1552        result
1553    }
1554
1555    #[test]
1556    fn global_install_creates_fresh_settings_json() {
1557        let tmp = tempfile::tempdir().unwrap();
1558        with_fake_home(tmp.path(), || {
1559            let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1560            assert!(changed, "first install should report a change");
1561
1562            let path = tmp.path().join(".claude").join("settings.json");
1563            assert!(path.exists(), "user settings.json should be created");
1564
1565            let content = std::fs::read_to_string(&path).unwrap();
1566            let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1567
1568            // All three hook entries should be present.
1569            let pre = &parsed["hooks"]["PreToolUse"];
1570            assert!(pre.is_array(), "PreToolUse should be an array");
1571            assert_eq!(pre.as_array().unwrap().len(), 1);
1572            let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1573            assert!(
1574                cmd.contains("/usr/local/bin/sqz"),
1575                "hook command should use the passed sqz_path, got: {cmd}"
1576            );
1577            assert!(cmd.contains("hook claude"));
1578
1579            let precompact = &parsed["hooks"]["PreCompact"];
1580            assert!(precompact.is_array());
1581            let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1582            assert!(precompact_cmd.contains("hook precompact"));
1583
1584            let session = &parsed["hooks"]["SessionStart"];
1585            assert!(session.is_array());
1586            assert_eq!(
1587                session[0]["matcher"].as_str().unwrap(),
1588                "compact",
1589                "SessionStart should only match /compact resume"
1590            );
1591        });
1592    }
1593
1594    #[test]
1595    fn global_install_preserves_existing_user_config() {
1596        // This is the big safety guarantee: if the user already has
1597        // permissions, env, statusLine, or unrelated hooks in
1598        // ~/.claude/settings.json, sqz must NOT stomp on them.
1599        let tmp = tempfile::tempdir().unwrap();
1600        let settings = tmp.path().join(".claude").join("settings.json");
1601        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1602
1603        let existing = serde_json::json!({
1604            "permissions": {
1605                "allow": ["Bash(npm test *)"],
1606                "deny":  ["Read(./.env)"]
1607            },
1608            "env": { "FOO": "bar" },
1609            "statusLine": {
1610                "type": "command",
1611                "command": "~/.claude/statusline.sh"
1612            },
1613            "hooks": {
1614                "PreToolUse": [
1615                    {
1616                        "matcher": "Edit",
1617                        "hooks": [
1618                            {
1619                                "type": "command",
1620                                "command": "~/.claude/hooks/format-on-edit.sh"
1621                            }
1622                        ]
1623                    }
1624                ]
1625            }
1626        });
1627        std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
1628
1629        with_fake_home(tmp.path(), || {
1630            let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1631            assert!(changed, "install should report a change on new hook");
1632
1633            let content = std::fs::read_to_string(&settings).unwrap();
1634            let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1635
1636            // User's permissions survived.
1637            assert_eq!(
1638                parsed["permissions"]["allow"][0].as_str().unwrap(),
1639                "Bash(npm test *)"
1640            );
1641            assert_eq!(
1642                parsed["permissions"]["deny"][0].as_str().unwrap(),
1643                "Read(./.env)"
1644            );
1645            // User's env block survived.
1646            assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
1647            // User's statusLine survived.
1648            assert_eq!(
1649                parsed["statusLine"]["command"].as_str().unwrap(),
1650                "~/.claude/statusline.sh"
1651            );
1652
1653            // PreToolUse should now contain BOTH the user's format-on-edit
1654            // hook and sqz's Bash hook — our install appends, not replaces.
1655            let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1656            assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
1657            let matchers: Vec<&str> = pre
1658                .iter()
1659                .map(|e| e["matcher"].as_str().unwrap_or(""))
1660                .collect();
1661            assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
1662            assert!(matchers.contains(&"Bash"), "sqz Bash hook must be present");
1663        });
1664    }
1665
1666    #[test]
1667    fn global_install_is_idempotent() {
1668        // Running `sqz init --global` twice should leave exactly ONE sqz
1669        // hook entry per event, not two. This is the foot-gun the
1670        // upsert_sqz_hook_entry helper defends against.
1671        let tmp = tempfile::tempdir().unwrap();
1672        with_fake_home(tmp.path(), || {
1673            assert!(install_claude_global("sqz").unwrap());
1674            // Second call: same sqz_path → no change reported, file is
1675            // byte-identical.
1676            assert!(
1677                !install_claude_global("sqz").unwrap(),
1678                "second install with identical args should report no change"
1679            );
1680
1681            let path = tmp.path().join(".claude").join("settings.json");
1682            let parsed: serde_json::Value =
1683                serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1684            // Exactly one entry per hook event.
1685            for event in &["PreToolUse", "PreCompact", "SessionStart"] {
1686                let arr = parsed["hooks"][event].as_array().unwrap();
1687                assert_eq!(
1688                    arr.len(),
1689                    1,
1690                    "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
1691                );
1692            }
1693        });
1694    }
1695
1696    #[test]
1697    fn global_install_upgrades_stale_sqz_hook_in_place() {
1698        // If a previous sqz release wrote a hook with a different sqz
1699        // path, re-running `sqz init --global` should replace it, not
1700        // leave two entries pointing at different binaries.
1701        let tmp = tempfile::tempdir().unwrap();
1702        with_fake_home(tmp.path(), || {
1703            // First install with old path.
1704            install_claude_global("/old/path/sqz").unwrap();
1705            // Second install with new path.
1706            let changed = install_claude_global("/new/path/sqz").unwrap();
1707            assert!(changed, "different sqz_path must be seen as a change");
1708
1709            let path = tmp.path().join(".claude").join("settings.json");
1710            let parsed: serde_json::Value =
1711                serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1712            let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1713            assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
1714            let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1715            assert!(cmd.contains("/new/path/sqz"));
1716            assert!(!cmd.contains("/old/path/sqz"));
1717        });
1718    }
1719
1720    #[test]
1721    fn global_uninstall_removes_sqz_and_preserves_the_rest() {
1722        let tmp = tempfile::tempdir().unwrap();
1723        let settings = tmp.path().join(".claude").join("settings.json");
1724        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1725        std::fs::write(
1726            &settings,
1727            serde_json::json!({
1728                "permissions": { "allow": ["Bash(git status)"] },
1729                "hooks": {
1730                    "PreToolUse": [
1731                        {
1732                            "matcher": "Edit",
1733                            "hooks": [
1734                                { "type": "command", "command": "~/format.sh" }
1735                            ]
1736                        }
1737                    ]
1738                }
1739            })
1740            .to_string(),
1741        )
1742        .unwrap();
1743
1744        with_fake_home(tmp.path(), || {
1745            // Install so there's something to remove.
1746            install_claude_global("/usr/local/bin/sqz").unwrap();
1747            // And remove.
1748            let result = remove_claude_global_hook().unwrap().unwrap();
1749            assert_eq!(result.0, settings);
1750            assert!(result.1, "should report that the file was modified");
1751
1752            // File must still exist (user had non-sqz config in it).
1753            assert!(settings.exists(), "settings.json should be preserved");
1754            let parsed: serde_json::Value =
1755                serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
1756
1757            // Permissions must survive.
1758            assert_eq!(
1759                parsed["permissions"]["allow"][0].as_str().unwrap(),
1760                "Bash(git status)"
1761            );
1762
1763            // User's Edit hook must survive; sqz's Bash hook must be gone.
1764            let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1765            assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
1766            assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
1767
1768            // sqz-only hook events should be cleaned up entirely.
1769            assert!(parsed["hooks"].get("PreCompact").is_none());
1770            assert!(parsed["hooks"].get("SessionStart").is_none());
1771        });
1772    }
1773
1774    #[test]
1775    fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
1776        // If the user's ~/.claude/settings.json contained ONLY sqz's
1777        // hooks (common case: sqz installed it from scratch), uninstall
1778        // removes the whole file so there's no trace left.
1779        let tmp = tempfile::tempdir().unwrap();
1780        with_fake_home(tmp.path(), || {
1781            install_claude_global("sqz").unwrap();
1782            let path = tmp.path().join(".claude").join("settings.json");
1783            assert!(path.exists(), "precondition: install created the file");
1784
1785            let result = remove_claude_global_hook().unwrap().unwrap();
1786            assert!(result.1);
1787            assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
1788        });
1789    }
1790
1791    #[test]
1792    fn global_uninstall_on_missing_file_is_noop() {
1793        let tmp = tempfile::tempdir().unwrap();
1794        with_fake_home(tmp.path(), || {
1795            assert!(
1796                remove_claude_global_hook().unwrap().is_none(),
1797                "missing file should return None, not error"
1798            );
1799        });
1800    }
1801
1802    #[test]
1803    fn global_uninstall_refuses_to_touch_unparseable_file() {
1804        // If the user's ~/.claude/settings.json is corrupt (or they
1805        // started editing it manually and saved mid-flight), uninstall
1806        // should refuse rather than delete data.
1807        let tmp = tempfile::tempdir().unwrap();
1808        let settings = tmp.path().join(".claude").join("settings.json");
1809        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1810        std::fs::write(&settings, "{ invalid json because").unwrap();
1811
1812        with_fake_home(tmp.path(), || {
1813            assert!(
1814                remove_claude_global_hook().is_err(),
1815                "bad JSON must surface as an error"
1816            );
1817        });
1818
1819        // File preserved, no data loss.
1820        let after = std::fs::read_to_string(&settings).unwrap();
1821        assert_eq!(after, "{ invalid json because");
1822    }
1823}