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