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|PowerShell")
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    /// Kiro: STDOUT with rewritten tool_input JSON, exit 0 = allow
64    Kiro,
65}
66
67/// Process a PreToolUse hook invocation from an AI tool.
68///
69/// Reads a JSON payload from `input` describing the tool call, rewrites
70/// bash commands to pipe through sqz, and returns the modified payload.
71///
72/// Input format (Claude Code):
73/// ```json
74/// {
75///   "tool_name": "Bash",
76///   "tool_input": {
77///     "command": "git status"
78///   }
79/// }
80/// ```
81///
82/// Output: same structure with command rewritten to pipe through sqz.
83/// Exit code 0 = proceed with modified command.
84/// Exit code 1 = block the tool call (not used here).
85pub fn process_hook(input: &str) -> Result<String> {
86    process_hook_for_platform(input, HookPlatform::ClaudeCode)
87}
88
89/// Process a hook invocation for Cursor (different output format).
90///
91/// Cursor uses flat JSON: `{ "permission": "allow", "updated_input": { "command": "..." } }`
92/// Returns `{}` when no rewrite (Cursor requires JSON on all code paths).
93pub fn process_hook_cursor(input: &str) -> Result<String> {
94    process_hook_for_platform(input, HookPlatform::Cursor)
95}
96
97/// Process a hook invocation for Gemini CLI.
98///
99/// Gemini uses: `{ "decision": "allow", "hookSpecificOutput": { "tool_input": { "command": "..." } } }`
100pub fn process_hook_gemini(input: &str) -> Result<String> {
101    process_hook_for_platform(input, HookPlatform::GeminiCli)
102}
103
104/// Process a hook invocation for Windsurf.
105///
106/// Windsurf hook support is limited. We attempt the same rewrite as Claude Code
107/// but the output format may not be honored. Falls back to exit-code semantics.
108pub fn process_hook_windsurf(input: &str) -> Result<String> {
109    process_hook_for_platform(input, HookPlatform::Windsurf)
110}
111
112/// Process a hook invocation for Kiro (IDE and CLI).
113///
114/// Kiro's PreToolUse hook receives JSON on STDIN with `tool_name` and
115/// `tool_input`. The hook outputs the modified tool_input JSON to STDOUT.
116/// Exit code 0 = allow (proceed with modified input).
117/// Exit code 2 = block.
118///
119/// Kiro uses `execute_bash` (alias `shell`) as the tool name for shell
120/// commands, with `tool_input.command` containing the command string.
121pub fn process_hook_kiro(input: &str) -> Result<String> {
122    process_hook_for_platform(input, HookPlatform::Kiro)
123}
124
125/// Platform-aware hook processing. Extracts the command from the tool-specific
126/// input format, rewrites it, and returns the response in the correct format
127/// for the target platform.
128fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
129    let parsed: serde_json::Value = serde_json::from_str(input)
130        .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
131
132    // Claude Code uses "tool_name" + "tool_input" (official docs).
133    // Cursor uses "hook_event_name": "beforeShellExecution" with "command" at top level.
134    // Some older references show "toolName" + "toolCall" — accept all.
135    let tool_name = parsed
136        .get("tool_name")
137        .or_else(|| parsed.get("toolName"))
138        .and_then(|v| v.as_str())
139        .unwrap_or("");
140
141    let hook_event = parsed
142        .get("hook_event_name")
143        .or_else(|| parsed.get("agent_action_name"))
144        .and_then(|v| v.as_str())
145        .unwrap_or("");
146
147    // Only intercept Bash/shell tool calls.
148    //
149    // Claude Code's built-in tools (Read, Grep, Glob, Write) bypass shell
150    // hooks entirely. PostToolUse hooks can view but NOT modify their output
151    // (confirmed: github.com/anthropics/claude-code/issues/4544). The tool
152    // output enters the context unchanged. We can only compress Bash command
153    // output by rewriting the command via PreToolUse. The MCP server
154    // (sqz-mcp) provides compressed alternatives to these built-in tools.
155    let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
156        | "run_terminal_command" | "run_shell_command" | "execute_bash"
157        | "PowerShell" | "powershell" | "pwsh")
158        || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
159
160    if !is_shell {
161        // Pass through non-bash tools unchanged.
162        // Cursor requires valid JSON on all code paths (empty object = passthrough).
163        return Ok(match platform {
164            HookPlatform::Cursor => "{}".to_string(),
165            _ => input.to_string(),
166        });
167    }
168
169    // Claude Code puts command in tool_input.command (official docs).
170    // Cursor puts command at top level: { "command": "git status" }.
171    // Windsurf puts command in tool_info.command_line.
172    // Some older references show toolCall.command — accept all.
173    let command = parsed
174        .get("tool_input")
175        .and_then(|v| v.get("command"))
176        .and_then(|v| v.as_str())
177        .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
178        .or_else(|| {
179            parsed
180                .get("tool_info")
181                .and_then(|v| v.get("command_line"))
182                .and_then(|v| v.as_str())
183        })
184        .or_else(|| {
185            parsed
186                .get("toolCall")
187                .and_then(|v| v.get("command"))
188                .and_then(|v| v.as_str())
189        })
190        .unwrap_or("");
191
192    if command.is_empty() {
193        return Ok(match platform {
194            HookPlatform::Cursor => "{}".to_string(),
195            _ => input.to_string(),
196        });
197    }
198
199    // Don't intercept commands that are already piped through sqz.
200    // Check the base command name specifically, not substring — so
201    // "grep sqz logfile" or "cargo search sqz" aren't skipped.
202    //
203    // Guards:
204    //   * base command is `sqz` (running sqz directly)
205    //   * legacy `SQZ_CMD=…` sh-style prefix (from older sqz versions)
206    //   * new shell-neutral `--cmd NAME` form — we need this because
207    //     the new emission (issue #10 fix) uses `| sqz compress --cmd …`
208    //     and without a guard the hook would re-wrap commands it had
209    //     already rewritten once (runaway-prefix bug from issue #5).
210    let base_cmd = extract_base_command(command);
211    if base_cmd == "sqz"
212        || command.starts_with("SQZ_CMD=")
213        || command.contains("sqz compress --cmd ")
214        || command.contains("sqz.exe compress --cmd ")
215    {
216        return Ok(match platform {
217            HookPlatform::Cursor => "{}".to_string(),
218            _ => input.to_string(),
219        });
220    }
221
222    // Don't intercept interactive or long-running commands
223    if is_interactive_command(command) {
224        return Ok(match platform {
225            HookPlatform::Cursor => "{}".to_string(),
226            _ => input.to_string(),
227        });
228    }
229
230    // Don't intercept commands with shell operators that would break piping.
231    // Compound commands (&&, ||, ;), redirects (>, <, >>), background (&),
232    // heredocs (<<), and process substitution would misbehave when we append
233    // `2>&1 | sqz compress` — the pipe only captures the last command.
234    if has_shell_operators(command) {
235        return Ok(match platform {
236            HookPlatform::Cursor => "{}".to_string(),
237            _ => input.to_string(),
238        });
239    }
240
241    // Rewrite: pipe the command's output through sqz compress.
242    // The command is a simple command (no operators), so direct piping is safe.
243    //
244    // Issue #10: use `--cmd NAME` instead of a `SQZ_CMD=NAME` prefix so
245    // the rewrite works in every shell. The sh-style inline env-var
246    // assignment doesn't parse in PowerShell (the reporter's default on
247    // Windows) or cmd.exe — both treat `SQZ_CMD=cmd` as a literal
248    // command name and raise `CommandNotFoundException`. `--cmd NAME`
249    // is a normal CLI argument and all three shells parse it fine.
250    //
251    // Pass the *full* command (not just the base name): every subcommand-routed
252    // formatter — `git status`, `cargo test`, `rake test`, `go test` — needs the
253    // subcommand to dispatch. `format_command` splits and strips the path itself,
254    // so a base-name-only label silently skipped those formatters in the live
255    // hook (they only fired in unit tests / the MCP path). The command is a
256    // simple command here (compound commands bailed out above via
257    // `has_shell_operators`), so quoting it as one arg is safe.
258    let rewritten = format!(
259        "{} 2>&1 | sqz compress --cmd {}",
260        command,
261        shell_escape(command),
262    );
263
264    // Build platform-specific output.
265    //
266    // Each AI tool expects a different JSON response format. Using the wrong
267    // format causes silent failures (the tool ignores the rewrite).
268    //
269    // Verified against official docs + RTK codebase (github.com/rtk-ai/rtk):
270    //
271    // Claude Code (docs.anthropic.com/en/docs/claude-code/hooks):
272    //   hookSpecificOutput.hookEventName = "PreToolUse"
273    //   hookSpecificOutput.permissionDecision = "allow"
274    //   hookSpecificOutput.updatedInput = { "command": "..." }  (camelCase, replaces entire input)
275    //
276    // Cursor (confirmed by RTK hooks/cursor/rtk-rewrite.sh):
277    //   permission = "allow"
278    //   updated_input = { "command": "..." }  (snake_case, flat — NOT nested in hookSpecificOutput)
279    //   Returns {} when no rewrite (Cursor requires JSON on all paths)
280    //
281    // Gemini CLI (geminicli.com/docs/hooks/reference):
282    //   decision = "allow" | "deny"  (top-level)
283    //   hookSpecificOutput.tool_input = { "command": "..." }  (merged with model args)
284    //
285    // Codex (developers.openai.com/codex/hooks):
286    //   Only "deny" works in PreToolUse. "allow", updatedInput, additionalContext
287    //   are parsed but NOT supported — they fail open. RTK uses AGENTS.md instead.
288    //   We do NOT generate hooks for Codex.
289    let output = match platform {
290        HookPlatform::ClaudeCode => serde_json::json!({
291            "hookSpecificOutput": {
292                "hookEventName": "PreToolUse",
293                "permissionDecision": "allow",
294                "permissionDecisionReason": "sqz: command output will be compressed for token savings",
295                "updatedInput": {
296                    "command": rewritten
297                }
298            }
299        }),
300        HookPlatform::Cursor => serde_json::json!({
301            "permission": "allow",
302            "updated_input": {
303                "command": rewritten
304            }
305        }),
306        HookPlatform::GeminiCli => serde_json::json!({
307            "decision": "allow",
308            "hookSpecificOutput": {
309                "tool_input": {
310                    "command": rewritten
311                }
312            }
313        }),
314        HookPlatform::Windsurf => {
315            // Windsurf hook support is unconfirmed for command rewriting.
316            // Use Claude Code format as best-effort; the hook may only work
317            // via exit codes (0 = allow, 2 = block).
318            serde_json::json!({
319                "hookSpecificOutput": {
320                    "hookEventName": "PreToolUse",
321                    "permissionDecision": "allow",
322                    "permissionDecisionReason": "sqz: command output will be compressed for token savings",
323                    "updatedInput": {
324                        "command": rewritten
325                    }
326                }
327            })
328        }
329        HookPlatform::Kiro => {
330            // Kiro CLI/IDE: output the modified tool_input as JSON to STDOUT.
331            // Exit code 0 means "allow with modifications".
332            // The hook runner merges this back into the tool call.
333            serde_json::json!({
334                "tool_input": {
335                    "command": rewritten
336                }
337            })
338        }
339    };
340
341    serde_json::to_string(&output)
342        .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
343}
344
345/// Generate hook configuration files for all supported AI tools.
346pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
347    // On Windows, `sqz_path` contains backslashes (C:\Users\...\sqz.exe).
348    // Embedding the raw path into JSON string values produces invalid JSON
349    // because `\` must be escaped as `\\` per RFC 8259. Same rule applies
350    // to JS/TS string literals. See issue #2.
351    //
352    // We keep TWO versions of the path:
353    //   - `sqz_path_raw` — the original, shown in markdown files the user
354    //     reads (.windsurfrules, .clinerules) where backslashes should
355    //     render as-is so the user can copy-paste the command.
356    //   - `sqz_path` — JSON/JS-escaped, used in every .json / .ts config.
357    let sqz_path_raw = sqz_path;
358    let sqz_path_json = json_escape_string_value(sqz_path);
359    let sqz_path = &sqz_path_json;
360
361    // Escape-hatch paragraph shared across every markdown-style rules
362    // file (Cursor/Windsurf/Cline). Documents the three ways an agent
363    // can opt out of compression when it can't parse sqz's output.
364    // Reported by SquireNed on Synthetic: GLM 5.1 loops on `§ref:…§`
365    // dedup tokens. Visible-in-every-rules-file so any agent reading
366    // the rules sees the escape hatch, not just the one that hit the
367    // loop.
368    let escape_hatch = format!(
369        r#"## Escape hatch — when sqz output confuses the agent
370
371If you see a `§ref:HASH§` token in tool output and can't parse it, or if
372compressed output is making it harder to work (loops, repeated tiny
373requests), or if a `«A1»` abbreviation symbol has replaced a value you
374need verbatim (a SHA, path, or URL), you have these ways out:
375
376- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
377  original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
378  or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
379- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
380  status 2>&1 | sqz compress`) to disable dedup for that one command.
381  Strictly more tokens, strictly less ambiguous.
382- **`--no-cache`** — the same opt-out as a CLI flag:
383  `git status 2>&1 | sqz compress --no-cache`.
384- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
385  abbreviation, which replaces repeated phrases with `«A1»` symbols.
386  Use this for output full of SHAs/paths/URLs you'll copy-paste, since
387  abbreviation keeps only the first occurrence of a repeated phrase:
388  `SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
389
390Use these when you genuinely need raw data. The default compressed
391path is still what you want for most output."#,
392    );
393    let escape_hatch = &escape_hatch;
394
395    vec![
396        // Claude Code — goes in .claude/settings.local.json (nested format)
397        // Three hooks, each addressing a different concern:
398        //
399        //   PreToolUse:   compress shell tool output before the agent sees it
400        //                 (matcher "Bash|PowerShell" covers both Unix and
401        //                 Windows shells without firing on Read/Edit/etc.)
402        //   PreCompact:   mark sqz's dedup refs stale before Claude Code
403        //                 summarises older turns. Otherwise our §ref:HASH§
404        //                 tokens would outlive the content they pointed at,
405        //                 leading to dangling refs the agent can't resolve.
406        //                 Documented by Anthropic at
407        //                 docs.anthropic.com/en/docs/claude-code/hooks-guide.
408        //   SessionStart: if the session was resumed via /compact, re-inject
409        //                 sqz's session guide (handled by `sqz resume`).
410        ToolHookConfig {
411            tool_name: "Claude Code".to_string(),
412            config_path: PathBuf::from(".claude/settings.local.json"),
413            config_content: format!(
414                r#"{{
415  "hooks": {{
416    "PreToolUse": [
417      {{
418        "matcher": "Bash|PowerShell",
419        "hooks": [
420          {{
421            "type": "command",
422            "command": "{sqz_path} hook claude"
423          }}
424        ]
425      }}
426    ],
427    "PreCompact": [
428      {{
429        "hooks": [
430          {{
431            "type": "command",
432            "command": "{sqz_path} hook precompact"
433          }}
434        ]
435      }}
436    ],
437    "SessionStart": [
438      {{
439        "matcher": "compact",
440        "hooks": [
441          {{
442            "type": "command",
443            "command": "{sqz_path} resume"
444          }}
445        ]
446      }}
447    ]
448  }}
449}}"#
450            ),
451            scope: HookScope::Project,
452        },
453        // Cursor — beforeShellExecution hook can only allow/deny/ask; it
454        // does NOT support rewriting the command via `updated_input` or any
455        // other field (verified against Cursor's documented schema via
456        // GitButler's deep-dive and Cupcake's reference, plus an empirical
457        // trace against cursor-agent 2026.04.14-ee4b43a). We use the modern
458        // .cursor/rules/*.mdc format (legacy .cursorrules is deprecated per
459        // docs.cursor.com/en/context/rules). `alwaysApply: true` guarantees
460        // the rule loads for every agent interaction.
461        ToolHookConfig {
462            tool_name: "Cursor".to_string(),
463            config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
464            config_content: format!(
465                r#"---
466description: sqz — Token-Optimized CLI Output
467alwaysApply: true
468---
469
470# sqz — Token-Optimized CLI Output
471
472When running shell commands whose output may be long (directory listings,
473git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
474pipe the output through `sqz compress` to reduce token consumption.
475
476`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
477usage is to pipe the command's output into `sqz compress`:
478
479```bash
480# Instead of:     Use:
481git status        git status 2>&1 | {sqz_path_raw} compress
482cargo test        cargo test 2>&1 | {sqz_path_raw} compress
483git log -10       git log -10 2>&1 | {sqz_path_raw} compress
484docker ps         docker ps 2>&1 | {sqz_path_raw} compress
485ls -la            ls -la 2>&1 | {sqz_path_raw} compress
486```
487
488The `2>&1` captures stderr too, which is useful for commands like `cargo
489test` where diagnostics go to stderr. `sqz compress` filters and compresses
490the combined output while preserving filenames, paths, and identifiers.
491It typically saves 60-90% tokens on verbose commands.
492
493Do NOT pipe output for:
494- Interactive commands (`vim`, `ssh`, `python`, REPLs)
495- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
496  `cmd; other`) — run those directly
497- Short commands whose output is already a few lines
498
499If `sqz` is not on PATH, run commands normally.
500
501{escape_hatch}
502"#
503            ),
504            scope: HookScope::Project,
505        },
506        // Windsurf — no confirmed hook API for command rewriting.
507        // RTK uses .windsurfrules (prompt-level guidance) instead of hooks.
508        // We generate a rules file that instructs Windsurf to use sqz.
509        ToolHookConfig {
510            tool_name: "Windsurf".to_string(),
511            config_path: PathBuf::from(".windsurfrules"),
512            config_content: format!(
513                r#"# sqz — Token-Optimized CLI Output
514
515Pipe verbose shell command output through `sqz compress` to save tokens.
516`sqz` reads from stdin and writes the compressed output to stdout — it is
517NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
518
519```bash
520# Instead of:     Use:
521git status        git status 2>&1 | {sqz_path_raw} compress
522cargo test        cargo test 2>&1 | {sqz_path_raw} compress
523git log -10       git log -10 2>&1 | {sqz_path_raw} compress
524docker ps         docker ps 2>&1 | {sqz_path_raw} compress
525```
526
527sqz filters and compresses command outputs while preserving filenames,
528paths, and identifiers (typically 60-90% token reduction on verbose
529commands). Skip short commands, interactive commands (vim, ssh, python),
530and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
531not on PATH, run commands normally.
532
533{escape_hatch}
534"#
535            ),
536            scope: HookScope::Project,
537        },
538        // Cline / Roo Code — PreToolUse cannot rewrite commands (only cancel/allow).
539        // RTK uses .clinerules (prompt-level guidance) instead of hooks.
540        // We generate a rules file that instructs Cline to use sqz.
541        ToolHookConfig {
542            tool_name: "Cline".to_string(),
543            config_path: PathBuf::from(".clinerules"),
544            config_content: format!(
545                r#"# sqz — Token-Optimized CLI Output
546
547Pipe verbose shell command output through `sqz compress` to save tokens.
548`sqz` reads from stdin and writes the compressed output to stdout — it is
549NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
550
551```bash
552# Instead of:     Use:
553git status        git status 2>&1 | {sqz_path_raw} compress
554cargo test        cargo test 2>&1 | {sqz_path_raw} compress
555git log -10       git log -10 2>&1 | {sqz_path_raw} compress
556docker ps         docker ps 2>&1 | {sqz_path_raw} compress
557```
558
559sqz filters and compresses command outputs while preserving filenames,
560paths, and identifiers (typically 60-90% token reduction on verbose
561commands). Skip short commands, interactive commands (vim, ssh, python),
562and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
563not on PATH, run commands normally.
564
565{escape_hatch}
566"#
567            ),
568            scope: HookScope::Project,
569        },
570        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
571        ToolHookConfig {
572            tool_name: "Gemini CLI".to_string(),
573            config_path: PathBuf::from(".gemini/settings.json"),
574            config_content: format!(
575                r#"{{
576  "hooks": {{
577    "BeforeTool": [
578      {{
579        "matcher": "run_shell_command",
580        "hooks": [
581          {{
582            "type": "command",
583            "command": "{sqz_path} hook gemini"
584          }}
585        ]
586      }}
587    ]
588  }}
589}}"#
590            ),
591            scope: HookScope::Project,
592        },
593        // Kiro (IDE and CLI) — uses .kiro/hooks/ directory with JSON hook files.
594        // The hook intercepts shell tool calls and pipes output through sqz.
595        ToolHookConfig {
596            tool_name: "Kiro".to_string(),
597            config_path: PathBuf::from(".kiro/hooks/sqz-compress.json"),
598            config_content: format!(
599                r#"{{
600  "name": "sqz compress",
601  "version": "1.0.0",
602  "description": "Compress shell command output through sqz for token savings",
603  "when": {{
604    "type": "preToolUse",
605    "toolTypes": ["shell"]
606  }},
607  "then": {{
608    "type": "runCommand",
609    "command": "{sqz_path} hook kiro"
610  }}
611}}"#
612            ),
613            scope: HookScope::Project,
614        },
615        // OpenCode — TypeScript plugin at ~/.config/opencode/plugins/sqz.ts
616        // plus a config file in project root (opencode.json or
617        // opencode.jsonc). Unlike other tools, OpenCode uses a TS
618        // plugin (not JSON hooks). The `config_path` below is the
619        // fresh-install default; `install_tool_hooks` detects a
620        // pre-existing `.jsonc` and merges into it instead. The actual
621        // plugin (sqz.ts) is installed separately via
622        // `install_opencode_plugin()`.
623        ToolHookConfig {
624            tool_name: "OpenCode".to_string(),
625            config_path: PathBuf::from("opencode.json"),
626            config_content: format!(
627                r#"{{
628  "$schema": "https://opencode.ai/config.json",
629  "mcp": {{
630    "sqz": {{
631      "type": "local",
632      "command": ["sqz-mcp", "--transport", "stdio"]
633    }}
634  }},
635  "plugin": ["sqz"]
636}}"#
637            ),
638            scope: HookScope::Project,
639        },
640        // Codex (openai/codex) — no stable per-tool-call hook, only a
641        // turn-end `notify` that fires after the agent is done and can't
642        // rewrite tool output. Native integration is therefore two-part:
643        //
644        //   1. AGENTS.md at project root — prompt-level guidance telling
645        //      Codex to pipe shell output through `sqz compress`. This is
646        //      the same approach RTK uses for Codex and the shape Codex
647        //      expects (the cross-tool AGENTS.md standard).
648        //   2. ~/.codex/config.toml user-level [mcp_servers.sqz] — Codex
649        //      merges this with any existing entries. Handled specially
650        //      in `install_tool_hooks` via `install_codex_mcp_config`.
651        //
652        // The config_content below is the AGENTS.md guidance block; it
653        // is only used as a placeholder for the (project-level) file and
654        // for surfacing the "create AGENTS.md" line in the install plan.
655        // The actual install goes through
656        // `crate::codex_integration::install_agents_md_guidance` so
657        // pre-existing AGENTS.md files are appended to, not clobbered.
658        ToolHookConfig {
659            tool_name: "Codex".to_string(),
660            config_path: PathBuf::from("AGENTS.md"),
661            config_content: crate::codex_integration::agents_md_guidance_block(
662                sqz_path_raw,
663            ),
664            scope: HookScope::Project,
665        },
666        // Zed (issue #38) has no command-rewrite hooks, so the
667        // integration is instructions + MCP registration:
668        //
669        //   1. Project guidance appended to the file Zed's Agent
670        //      actually reads (`.rules` when present — it shadows
671        //      everything — otherwise `AGENTS.md`).
672        //   2. `context_servers.sqz` merged into Zed's settings.json
673        //      (user-level), which exposes the sqz MCP tools to the
674        //      Zed Agent — including local models.
675        //
676        // The config_path/content below are placeholders for the
677        // install plan display; the real install goes through
678        // `crate::zed_integration` so existing files are appended to,
679        // not clobbered, and JSONC settings are never rewritten.
680        ToolHookConfig {
681            tool_name: "Zed".to_string(),
682            config_path: PathBuf::from("AGENTS.md"),
683            config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
684            scope: HookScope::Project,
685        },
686    ]
687}
688
689/// Install hook configs for detected AI tools in the given project directory.
690///
691/// Install hook configs for detected AI tools in the given project directory.
692///
693/// Returns the list of tools that were configured.
694pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
695    install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
696}
697
698/// Where hooks should be written.
699///
700/// The Claude Code scope table (docs.claude.com/en/docs/claude-code/settings)
701/// defines four settings locations: managed, user, project, and local.
702/// `sqz init` cares about the last three:
703///
704/// * `Project` — writes `.claude/settings.local.json` (per-project, gitignored).
705///   This is what the bare `sqz init` has always done. Good for "I only
706///   want sqz active inside this repo", but a common foot-gun because the
707///   user expects it to work everywhere and then sees "caching nothing"
708///   in every other project. Reported by 76vangel.
709///
710/// * `Global` — writes `~/.claude/settings.json` (user scope, applies to
711///   every Claude Code session on this machine regardless of cwd).
712///   This is what RTK's `rtk init -g` does and what most users actually
713///   want on first install. Verified against the official Anthropic scope
714///   table; verified against rtk-ai/rtk's `resolve_claude_dir` helper.
715///
716/// Precedence in Claude Code (highest to lowest): managed > local > project > user.
717/// That means a project-level install can still override a global one —
718/// and a user with `.claude/settings.local.json` in their worktree will
719/// silently shadow the global setting. We do NOT auto-delete the local
720/// file; the uninstall flow is responsible for whichever scope was asked for.
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum InstallScope {
723    /// Project-local (gitignored): `.claude/settings.local.json`, `.cursor/rules/`,
724    /// etc. under `project_dir`.
725    Project,
726    /// User-level: `~/.claude/settings.json` and similar home-directory paths.
727    /// Applies to every project on this machine.
728    Global,
729}
730
731/// Which tools `sqz init` should configure.
732///
733/// By default sqz init writes hook configs for every supported tool
734/// (Claude Code, Cursor, Windsurf, Cline, Gemini CLI, OpenCode, Codex,
735/// Zed). Users who only use one agent have asked (issue #11,
736/// @shochdoerfer) for a way to say "just OpenCode, please, leave the
737/// rest alone." This filter is the plumbing for that.
738///
739/// Matching is by canonical tool name. The [`canonicalize_tool_name`]
740/// helper normalises user input (lowercase, hyphens/underscores/spaces
741/// collapsed, known aliases) so `Opencode`, `open-code`, `opencode`,
742/// `OPENCODE` all refer to the same tool.
743#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum ToolFilter {
745    /// Install hook configs for every supported tool. The historical
746    /// default of `sqz init`.
747    All,
748    /// Install hook configs only for the named tools. Unknown names are
749    /// surfaced to the caller as errors by the canonicalisation layer
750    /// so we don't silently ignore typos.
751    Only(Vec<String>),
752    /// Install hook configs for every supported tool EXCEPT the named
753    /// tools. Useful when the user is fine with everything but wants
754    /// one integration skipped (e.g. a project shared with collaborators
755    /// who don't want a `.windsurfrules` file in the repo).
756    Skip(Vec<String>),
757}
758
759impl Default for ToolFilter {
760    fn default() -> Self {
761        ToolFilter::All
762    }
763}
764
765impl ToolFilter {
766    /// Return `true` if `tool_name` (as produced by [`generate_hook_configs`])
767    /// should be installed under this filter.
768    ///
769    /// `tool_name` is the display name sqz uses internally:
770    ///   * `"Claude Code"`, `"Cursor"`, `"Windsurf"`, `"Cline"`,
771    ///     `"Gemini CLI"`, `"OpenCode"`, `"Codex"`.
772    ///
773    /// The caller is expected to have already canonicalised the filter
774    /// entries via [`canonicalize_tool_name`] so the strings line up
775    /// case- and alias-wise.
776    pub fn includes(&self, tool_name: &str) -> bool {
777        let canon = canonicalize_tool_name(tool_name);
778        match self {
779            ToolFilter::All => true,
780            ToolFilter::Only(allow) => allow.iter().any(|n| {
781                // `allow` entries have already been canonicalised by
782                // parse_tool_list; compare canonical to canonical.
783                n == &canon
784            }),
785            ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
786        }
787    }
788}
789
790/// Every canonical tool name sqz knows about, in the same order
791/// [`generate_hook_configs`] emits them. Used by the CLI to list valid
792/// options in `--only`/`--skip` error messages, and by tests that
793/// need to enumerate the supported set.
794pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
795    "Claude Code",
796    "Cursor",
797    "Windsurf",
798    "Cline",
799    "Gemini CLI",
800    "Kiro",
801    "OpenCode",
802    "Codex",
803    "Zed",
804];
805
806/// Normalise a tool name or alias to its canonical form.
807///
808/// Canonical forms are lowercase and hyphen-free. Accepts common
809/// variants:
810///
811/// | Input                                  | Canonical      |
812/// |----------------------------------------|----------------|
813/// | `Claude Code`, `claude-code`, `claude` | `claudecode`   |
814/// | `Cursor`, `cursor`                     | `cursor`       |
815/// | `Windsurf`, `windsurf`                 | `windsurf`     |
816/// | `Cline`, `roo`, `roo-code`, `roocode`  | `cline`        |
817/// | `Gemini CLI`, `gemini-cli`, `gemini`   | `gemini`       |
818/// | `OpenCode`, `opencode`                 | `opencode`     |
819/// | `Codex`, `codex`                       | `codex`        |
820///
821/// Returns the canonical string unchanged if no alias matches — the
822/// caller decides whether unknown names are an error. This function
823/// never fails.
824pub fn canonicalize_tool_name(name: &str) -> String {
825    let lowered: String = name
826        .chars()
827        .filter(|c| !c.is_whitespace())
828        .flat_map(|c| c.to_lowercase())
829        .filter(|c| *c != '-' && *c != '_')
830        .collect();
831    match lowered.as_str() {
832        "claude" | "claudecode" => "claudecode".to_string(),
833        "cursor" => "cursor".to_string(),
834        "windsurf" => "windsurf".to_string(),
835        // Cline is also sold as "Roo Code" — treat the two as one
836        // integration because that's what sqz actually targets (same
837        // .clinerules file).
838        "cline" | "roo" | "roocode" => "cline".to_string(),
839        "gemini" | "geminicli" => "gemini".to_string(),
840        "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
841        "opencode" => "opencode".to_string(),
842        "codex" => "codex".to_string(),
843        "zed" | "zededitor" | "zedagent" => "zed".to_string(),
844        other => other.to_string(),
845    }
846}
847
848/// Parse a user-supplied tool list (comma-separated, whitespace-tolerant)
849/// into a vector of canonical names.
850///
851/// Returns an error if any entry does not match a known tool — we never
852/// silently drop typos because the failure mode ("my filter didn't
853/// work") is hard to debug.
854///
855/// The error message lists every accepted name so the user can see
856/// exactly what's valid.
857pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
858    let mut out = Vec::new();
859    let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
860        .iter()
861        .map(|n| canonicalize_tool_name(n))
862        .collect();
863    for part in raw.split(',') {
864        let trimmed = part.trim();
865        if trimmed.is_empty() {
866            continue;
867        }
868        let canon = canonicalize_tool_name(trimmed);
869        if !known.contains(&canon) {
870            let valid: Vec<String> = SUPPORTED_TOOL_NAMES
871                .iter()
872                .map(|n| canonicalize_tool_name(n))
873                .collect();
874            return Err(crate::error::SqzError::Other(format!(
875                "unknown agent name '{}'. Valid options: {}",
876                trimmed,
877                valid.join(", ")
878            )));
879        }
880        if !out.contains(&canon) {
881            out.push(canon);
882        }
883    }
884    Ok(out)
885}
886
887/// Like [`install_tool_hooks`] but lets the caller choose between
888/// project-local and user-global scope. This is the function `sqz init`
889/// and `sqz init --global` both call.
890///
891/// For `InstallScope::Global`:
892///
893/// * Claude Code hook is merged into `~/.claude/settings.json` (the user
894///   settings file). We merge rather than overwrite because the user may
895///   already have permissions, env, statusLine, or other hooks there —
896///   blindly writing would nuke their config. Any existing sqz hook
897///   entries are replaced in place; unrelated fields are preserved.
898///
899/// * Cursor, Windsurf, Cline, Gemini CLI rules files don't have a
900///   user-level equivalent that Cursor/etc. actually load. We keep those
901///   at project scope and note it in the plan. Users who want Cursor
902///   compressed across all projects should follow the Cursor docs
903///   (docs.cursor.com/en/context/rules) and add the rule at user scope
904///   manually — Cursor honours ~/.cursor/rules/*.mdc but only within
905///   workspaces that opt in.
906///
907/// * OpenCode plugin is already user-level by design (lives at
908///   `~/.config/opencode/plugins/sqz.ts`), so scope doesn't matter here.
909///
910/// * Codex MCP config is always user-level (`~/.codex/config.toml`).
911///   AGENTS.md stays per-project because that's where it belongs.
912pub fn install_tool_hooks_scoped(
913    project_dir: &Path,
914    sqz_path: &str,
915    scope: InstallScope,
916) -> Vec<String> {
917    install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
918}
919
920/// Like [`install_tool_hooks_scoped`] but honours a [`ToolFilter`] so
921/// callers can restrict `sqz init` to a subset of the supported tools.
922///
923/// The filter applies to hook-config writes AND to the OpenCode
924/// TypeScript plugin at `~/.config/opencode/plugins/sqz.ts` — we only
925/// install the plugin file when OpenCode passes the filter. Writing
926/// the plugin file to a machine where the user filtered OpenCode out
927/// would be surprising (they'd see sqz fire next time they opened
928/// OpenCode even though they never asked for it).
929///
930/// Shell hook (rc file) and the default preset are NOT gated by this
931/// filter — they're user-scoped and not specific to any agent.
932/// `cmd_init` handles those separately.
933pub fn install_tool_hooks_scoped_filtered(
934    project_dir: &Path,
935    sqz_path: &str,
936    scope: InstallScope,
937    filter: &ToolFilter,
938) -> Vec<String> {
939    let configs = generate_hook_configs(sqz_path);
940    let mut installed = Vec::new();
941
942    for config in &configs {
943        // Apply the user's agent filter before we touch anything.
944        // Each tool the filter rejects is completely skipped — no
945        // plan lines, no files written, no logging.
946        if !filter.includes(&config.tool_name) {
947            continue;
948        }
949
950        // OpenCode config files are special: they live alongside the
951        // user's own config and must be *merged* rather than clobbered.
952        // The placeholder `config_content` is only used on a fresh
953        // install; `update_opencode_config_detailed` handles both the
954        // create-new and merge-into-existing cases, AND picks the
955        // right file extension (opencode.jsonc vs opencode.json) —
956        // fixes issue #6 where the old write-if-missing logic created
957        // a parallel `opencode.json` next to an existing `.jsonc`.
958        if config.tool_name == "OpenCode" {
959            match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
960                Ok((updated, _comments_lost)) => {
961                    if updated && !installed.iter().any(|n| n == "OpenCode") {
962                        installed.push("OpenCode".to_string());
963                    }
964                }
965                Err(_e) => {
966                    // Non-fatal — leave OpenCode out of the installed
967                    // list and continue with other tools.
968                }
969            }
970            continue;
971        }
972
973        // Codex has the same merge-not-clobber concern on two fronts:
974        // the project-level AGENTS.md (may contain unrelated user
975        // content) and the USER-level ~/.codex/config.toml (may contain
976        // other MCP servers). Both go through the surgical helpers.
977        if config.tool_name == "Codex" {
978            let agents_changed = crate::codex_integration::install_agents_md_guidance(
979                project_dir, sqz_path,
980            )
981            .unwrap_or(false);
982            let mcp_changed = crate::codex_integration::install_codex_mcp_config()
983                .unwrap_or(false);
984            if (agents_changed || mcp_changed)
985                && !installed.iter().any(|n| n == "Codex")
986            {
987                installed.push("Codex".to_string());
988            }
989            continue;
990        }
991
992        // Zed (issue #38): guidance into the instruction file Zed
993        // actually reads, plus context_servers.sqz in the user-level
994        // settings.json. Both are surgical; JSONC settings are never
995        // rewritten (the CLI surfaces manual instructions instead).
996        if config.tool_name == "Zed" {
997            let guidance_changed =
998                crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
999                    .unwrap_or(false);
1000            let mcp_changed = matches!(
1001                crate::zed_integration::install_zed_mcp_config(),
1002                Ok(crate::zed_integration::ZedMcpInstall::Created)
1003                    | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1004            );
1005            if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1006                installed.push("Zed".to_string());
1007            }
1008            continue;
1009        }
1010
1011        // Claude Code at global scope: merge into ~/.claude/settings.json
1012        // instead of writing a fresh .claude/settings.local.json in cwd.
1013        // This is the fix for "sqz init does nothing outside the project
1014        // I ran it in" — reported by 76vangel. Design mirrors rtk init -g.
1015        //
1016        // Also triggers the issue #12 companion installs (CLAUDE.md
1017        // guidance + ~/.claude.json MCP server registration) since those
1018        // belong to Claude Code conceptually — if you're installing the
1019        // hook, you want the guidance and MCP wiring too.
1020        if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1021            let hook_installed = match install_claude_global(sqz_path) {
1022                Ok(v) => v,
1023                Err(_) => false,
1024            };
1025            let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1026                project_dir, sqz_path,
1027            )
1028            .unwrap_or(false);
1029            let mcp_changed =
1030                crate::claude_md_integration::install_claude_mcp_config()
1031                    .unwrap_or(false);
1032            if (hook_installed || md_changed || mcp_changed)
1033                && !installed.iter().any(|n| n == "Claude Code")
1034            {
1035                installed.push("Claude Code".to_string());
1036            }
1037            continue;
1038        }
1039
1040        let full_path = project_dir.join(&config.config_path);
1041
1042        // Don't overwrite existing hook configs
1043        if full_path.exists() {
1044            // Project-scope Claude Code file already exists. Don't skip:
1045            // merge our entries in, replacing any stale sqz entries. This
1046            // is what upgrades a pre-issue-#26 `"matcher": "Bash"` install
1047            // to `"Bash|PowerShell"` when the user re-runs `sqz init` —
1048            // with the old skip-if-exists behavior, Windows installs kept
1049            // the stale matcher forever and PowerShell tool calls never
1050            // reached the hook. User keys (permissions, env, other hooks)
1051            // are preserved; only sqz's own entries are replaced.
1052            if config.tool_name == "Claude Code" {
1053                let hook_changed =
1054                    merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1055                let md_changed =
1056                    crate::claude_md_integration::install_claude_md_guidance(
1057                        project_dir, sqz_path,
1058                    )
1059                    .unwrap_or(false);
1060                let mcp_changed =
1061                    crate::claude_md_integration::install_claude_mcp_config()
1062                        .unwrap_or(false);
1063                if (hook_changed || md_changed || mcp_changed)
1064                    && !installed.iter().any(|n| n == "Claude Code")
1065                {
1066                    installed.push("Claude Code".to_string());
1067                }
1068            }
1069            continue;
1070        }
1071
1072        // Create parent directories
1073        if let Some(parent) = full_path.parent() {
1074            if std::fs::create_dir_all(parent).is_err() {
1075                continue;
1076            }
1077        }
1078
1079        if std::fs::write(&full_path, &config.config_content).is_ok() {
1080            installed.push(config.tool_name.clone());
1081            // Claude Code at project scope: also install the issue #12
1082            // companion artifacts (CLAUDE.md guidance + ~/.claude.json
1083            // MCP registration). The agent needs all three pieces to
1084            // actually use sqz — the hook catches Bash, the MCP server
1085            // provides sqz_read_file/grep/list_dir, and the CLAUDE.md
1086            // tells it when to pick which.
1087            if config.tool_name == "Claude Code" {
1088                let _ = crate::claude_md_integration::install_claude_md_guidance(
1089                    project_dir, sqz_path,
1090                );
1091                let _ = crate::claude_md_integration::install_claude_mcp_config();
1092            }
1093        }
1094    }
1095
1096    // Also install the OpenCode TypeScript plugin (user-level). The
1097    // config merge above has already put OpenCode in `installed` if it
1098    // wrote anything, so this call only matters for machines where no
1099    // project config existed — we still want the user-level plugin so
1100    // future OpenCode sessions see sqz.
1101    //
1102    // Gated by the filter: if the user ran `sqz init --skip opencode`
1103    // we must NOT drop the plugin file in `~/.config/opencode/plugins/`.
1104    // Leaving it there would surprise them on their next OpenCode run
1105    // (sqz would start firing uninvited, reverting the skip).
1106    if filter.includes("OpenCode") {
1107        if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1108            if !installed.iter().any(|n| n == "OpenCode") {
1109                installed.push("OpenCode".to_string());
1110            }
1111        }
1112    }
1113
1114    installed
1115}
1116
1117// ── Claude Code user-scope hook install ──────────────────────────────────
1118
1119/// Resolve `~/.claude/settings.json` for the current user.
1120///
1121/// This is the "User" scope file per the Anthropic scope table
1122/// (docs.claude.com/en/docs/claude-code/settings). Applies to every
1123/// Claude Code session on this machine regardless of cwd.
1124///
1125/// Precedence: Managed > Local (`.claude/settings.local.json`) >
1126/// Project (`.claude/settings.json`) > User (this file). Users with a
1127/// local settings file in a worktree can still override the global
1128/// sqz hook — that's intended.
1129pub fn claude_user_settings_path() -> Option<PathBuf> {
1130    dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1131}
1132
1133/// Merge sqz's PreToolUse / PreCompact / SessionStart hook entries
1134/// into `~/.claude/settings.json`.
1135///
1136/// * Creates the file if missing, with just our hooks.
1137/// * If the file exists, parses it as JSON, replaces any existing sqz
1138///   entries (matched by `command` containing `sqz hook` / `sqz resume` /
1139///   `sqz hook precompact`), and inserts ours. Everything else — the
1140///   user's permissions, env, statusLine, other PreToolUse matchers —
1141///   stays untouched.
1142/// * Writes atomically (temp file + rename) so a crash halfway through
1143///   can't leave the user with a corrupted settings.json.
1144///
1145/// Returns `Ok(true)` if the file was created or changed, `Ok(false)`
1146/// if our hook entries were already present identically.
1147fn install_claude_global(sqz_path: &str) -> Result<bool> {
1148    install_claude_global_at(sqz_path, None)
1149}
1150
1151/// Internal: home-dir-injectable counterpart used by tests. Avoids
1152/// `std::env::set_var("HOME")` which races with parallel tests that
1153/// also read HOME (e.g. the api_proxy property tests that open
1154/// `~/.sqz/sessions.db`).
1155fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1156    let path = match home_override {
1157        Some(h) => h.join(".claude").join("settings.json"),
1158        None => claude_user_settings_path().ok_or_else(|| {
1159            crate::error::SqzError::Other(
1160                "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1161            )
1162        })?,
1163    };
1164    merge_sqz_hooks_into_settings(&path, sqz_path)
1165}
1166
1167/// Dry-run probe for `sqz init`'s plan phase: would merging sqz's hook
1168/// entries change the Claude Code settings file at `path`?
1169///
1170/// Returns `Ok(true)` when the file is missing, or exists with stale or
1171/// absent sqz entries (e.g. the pre-issue-#26 `"Bash"`-only matcher, or
1172/// an outdated binary path). Returns `Ok(false)` when our entries are
1173/// already present identically. Errors on unparseable files — same as
1174/// the real merge, which refuses to touch those.
1175///
1176/// Without this probe the CLI's plan phase treated any existing file as
1177/// "nothing to do" and exited before the installer ran, so the healing
1178/// merge below was unreachable in practice.
1179pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1180    if !path.exists() {
1181        return Ok(true);
1182    }
1183    let content = std::fs::read_to_string(path).map_err(|e| {
1184        crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1185    })?;
1186    let mut root: serde_json::Value = if content.trim().is_empty() {
1187        serde_json::Value::Object(serde_json::Map::new())
1188    } else {
1189        serde_json::from_str(&content).map_err(|e| {
1190            crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1191        })?
1192    };
1193    let Some(root_obj) = root.as_object_mut() else {
1194        return Err(crate::error::SqzError::Other(format!(
1195            "{} is not a JSON object",
1196            path.display()
1197        )));
1198    };
1199
1200    let before = serde_json::to_string(&root_obj).unwrap_or_default();
1201    let hooks = root_obj
1202        .entry("hooks".to_string())
1203        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1204    let Some(hooks_obj) = hooks.as_object_mut() else {
1205        return Ok(true);
1206    };
1207    for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1208        upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1209    }
1210    let after = serde_json::to_string(&root_obj).unwrap_or_default();
1211    Ok(before != after)
1212}
1213
1214/// The three hook entries sqz installs into Claude Code settings files,
1215/// as `(event_name, entry_json, sentinel)` tuples. Single source of
1216/// truth for both the real merge and the dry-run probe.
1217fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
1218    [
1219        (
1220            "PreToolUse",
1221            serde_json::json!({
1222                "matcher": "Bash|PowerShell",
1223                "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1224            }),
1225            "hook claude",
1226        ),
1227        (
1228            "PreCompact",
1229            serde_json::json!({
1230                "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1231            }),
1232            "hook precompact",
1233        ),
1234        (
1235            "SessionStart",
1236            serde_json::json!({
1237                "matcher": "compact",
1238                "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1239            }),
1240            "resume",
1241        ),
1242    ]
1243}
1244
1245/// Merge sqz's three hook entries into a Claude Code settings JSON at
1246/// `path` — either `~/.claude/settings.json` (global scope) or a
1247/// project's `.claude/settings.local.json`. Shared by both install
1248/// scopes so re-running `sqz init` upgrades stale sqz entries (an old
1249/// `"Bash"`-only matcher, an outdated binary path) at project scope
1250/// too, instead of skipping any file that already exists. That skip is
1251/// how Windows installs from before the issue #26 fix stayed broken:
1252/// their project settings kept the pre-PowerShell matcher forever.
1253fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1254    // Parse the existing file, or start from an empty object.
1255    let mut root: serde_json::Value = if path.exists() {
1256        let content = std::fs::read_to_string(&path).map_err(|e| {
1257            crate::error::SqzError::Other(format!(
1258                "read {}: {e}",
1259                path.display()
1260            ))
1261        })?;
1262        if content.trim().is_empty() {
1263            serde_json::Value::Object(serde_json::Map::new())
1264        } else {
1265            serde_json::from_str(&content).map_err(|e| {
1266                crate::error::SqzError::Other(format!(
1267                    "parse {}: {e} — please fix or move the file before re-running sqz init",
1268                    path.display()
1269                ))
1270            })?
1271        }
1272    } else {
1273        serde_json::Value::Object(serde_json::Map::new())
1274    };
1275
1276    // Ensure root is an object (users occasionally have arrays or
1277    // corrupted files; we refuse to touch those).
1278    let root_obj = root.as_object_mut().ok_or_else(|| {
1279        crate::error::SqzError::Other(format!(
1280            "{} is not a JSON object — refusing to overwrite",
1281            path.display()
1282        ))
1283    })?;
1284
1285    // Snapshot the "before" state for change detection.
1286    let before = serde_json::to_string(&root_obj).unwrap_or_default();
1287
1288    // Get or create the top-level "hooks" object.
1289    let hooks = root_obj
1290        .entry("hooks".to_string())
1291        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1292    let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1293        crate::error::SqzError::Other(format!(
1294            "{}: `hooks` is not an object — refusing to overwrite",
1295            path.display()
1296        ))
1297    })?;
1298
1299    for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1300        upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1301    }
1302
1303    let after = serde_json::to_string(&root_obj).unwrap_or_default();
1304    if before == after && path.exists() {
1305        // Already present and unchanged — no write needed.
1306        return Ok(false);
1307    }
1308
1309    // Ensure parent directory exists.
1310    if let Some(parent) = path.parent() {
1311        std::fs::create_dir_all(parent).map_err(|e| {
1312            crate::error::SqzError::Other(format!(
1313                "create {}: {e}",
1314                parent.display()
1315            ))
1316        })?;
1317    }
1318
1319    // Atomic write: tempfile in same directory + rename. Modelled after
1320    // rtk's `atomic_write` in src/hooks/init.rs. Keeps the old file
1321    // intact if serialization or write fails halfway.
1322    let parent = path.parent().ok_or_else(|| {
1323        crate::error::SqzError::Other(format!(
1324            "path {} has no parent directory",
1325            path.display()
1326        ))
1327    })?;
1328    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1329        crate::error::SqzError::Other(format!(
1330            "create temp file in {}: {e}",
1331            parent.display()
1332        ))
1333    })?;
1334    let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1335        .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1336    std::fs::write(tmp.path(), serialized).map_err(|e| {
1337        crate::error::SqzError::Other(format!(
1338            "write to temp file {}: {e}",
1339            tmp.path().display()
1340        ))
1341    })?;
1342    tmp.persist(&path).map_err(|e| {
1343        crate::error::SqzError::Other(format!(
1344            "rename temp file into place at {}: {e}",
1345            path.display()
1346        ))
1347    })?;
1348
1349    Ok(true)
1350}
1351
1352/// Remove sqz's hook entries from `~/.claude/settings.json` without
1353/// touching any other keys. Symmetric with [`install_claude_global`].
1354///
1355/// Returns:
1356/// * `Ok(Some((path, true)))` — file existed, sqz entries found and
1357///   stripped. If the resulting `hooks` object is empty, we also remove
1358///   the `hooks` key entirely. If the resulting root object is empty,
1359///   we remove the file — matches the uninstall UX of every other sqz
1360///   surface.
1361/// * `Ok(Some((path, false)))` — file existed but contained no sqz
1362///   entries. No write.
1363/// * `Ok(None)` — file did not exist.
1364/// * `Err(_)` — file existed but could not be read or parsed.
1365pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1366    remove_claude_global_hook_at(None)
1367}
1368
1369/// Internal: home-dir-injectable counterpart used by tests. See
1370/// `install_claude_global_at` for rationale.
1371fn remove_claude_global_hook_at(
1372    home_override: Option<&Path>,
1373) -> Result<Option<(PathBuf, bool)>> {
1374    let path = match home_override {
1375        Some(h) => h.join(".claude").join("settings.json"),
1376        None => match claude_user_settings_path() {
1377            Some(p) => p,
1378            None => return Ok(None),
1379        },
1380    };
1381    if !path.exists() {
1382        return Ok(None);
1383    }
1384
1385    let content = std::fs::read_to_string(&path).map_err(|e| {
1386        crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1387    })?;
1388    if content.trim().is_empty() {
1389        return Ok(Some((path, false)));
1390    }
1391
1392    let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1393        crate::error::SqzError::Other(format!(
1394            "parse {}: {e} — refusing to rewrite an unparseable file",
1395            path.display()
1396        ))
1397    })?;
1398    let Some(root_obj) = root.as_object_mut() else {
1399        return Ok(Some((path, false)));
1400    };
1401
1402    let mut changed = false;
1403    if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1404        for (event, sentinel) in &[
1405            ("PreToolUse", "hook claude"),
1406            ("PreCompact", "hook precompact"),
1407            ("SessionStart", "resume"),
1408        ] {
1409            if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1410                let before = arr.len();
1411                arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1412                if arr.len() != before {
1413                    changed = true;
1414                }
1415            }
1416        }
1417
1418        // Drop any now-empty hook event arrays so we don't leave
1419        // `"PreToolUse": []` clutter in the user's settings.
1420        hooks.retain(|_, v| match v {
1421            serde_json::Value::Array(a) => !a.is_empty(),
1422            _ => true,
1423        });
1424
1425        // If the whole `hooks` object is now empty, drop it so sqz's
1426        // uninstall leaves no trace.
1427        let hooks_empty = hooks.is_empty();
1428        if hooks_empty {
1429            root_obj.remove("hooks");
1430            changed = true;
1431        }
1432    }
1433
1434    if !changed {
1435        return Ok(Some((path, false)));
1436    }
1437
1438    // If root is now completely empty, delete the file — matches the
1439    // "leave nothing behind" behaviour of the OpenCode/Codex uninstall
1440    // paths.
1441    if root_obj.is_empty() {
1442        std::fs::remove_file(&path).map_err(|e| {
1443            crate::error::SqzError::Other(format!(
1444                "remove {}: {e}",
1445                path.display()
1446            ))
1447        })?;
1448        return Ok(Some((path, true)));
1449    }
1450
1451    // Atomic rewrite.
1452    let parent = path.parent().ok_or_else(|| {
1453        crate::error::SqzError::Other(format!(
1454            "path {} has no parent directory",
1455            path.display()
1456        ))
1457    })?;
1458    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1459        crate::error::SqzError::Other(format!(
1460            "create temp file in {}: {e}",
1461            parent.display()
1462        ))
1463    })?;
1464    let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1465        .map_err(|e| {
1466            crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1467        })?;
1468    std::fs::write(tmp.path(), serialized).map_err(|e| {
1469        crate::error::SqzError::Other(format!(
1470            "write to temp file {}: {e}",
1471            tmp.path().display()
1472        ))
1473    })?;
1474    tmp.persist(&path).map_err(|e| {
1475        crate::error::SqzError::Other(format!(
1476            "rename temp file into place at {}: {e}",
1477            path.display()
1478        ))
1479    })?;
1480
1481    Ok(Some((path, true)))
1482}
1483
1484/// Replace (or insert) sqz's hook entry in the array under
1485/// `hooks[event_name]`. Entries are matched by the `command` substring
1486/// `sentinel` — that way, an upgrade from `sqz hook claude` to a future
1487/// renamed command won't accumulate stale entries.
1488///
1489/// Idempotent: calling this twice yields the same JSON.
1490fn upsert_sqz_hook_entry(
1491    hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1492    event_name: &str,
1493    new_entry: serde_json::Value,
1494    sentinel: &str,
1495) {
1496    let arr = hooks_obj
1497        .entry(event_name.to_string())
1498        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1499    let Some(arr) = arr.as_array_mut() else {
1500        // `hooks[event]` exists but isn't an array — overwrite it with
1501        // just our entry. Not ideal but matches the behavior the user
1502        // would get on a fresh install.
1503        hooks_obj.insert(
1504            event_name.to_string(),
1505            serde_json::Value::Array(vec![new_entry]),
1506        );
1507        return;
1508    };
1509
1510    // Drop any existing entry whose command matches our sentinel.
1511    arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1512
1513    arr.push(new_entry);
1514}
1515
1516/// True if any command in a hook entry contains the given substring.
1517/// Used to locate sqz's own entries without pinning to an exact command
1518/// (so future format changes still upgrade cleanly).
1519fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1520    entry
1521        .get("hooks")
1522        .and_then(|h| h.as_array())
1523        .map(|hooks_arr| {
1524            hooks_arr.iter().any(|h| {
1525                h.get("command")
1526                    .and_then(|c| c.as_str())
1527                    .map(|c| c.contains(needle))
1528                    .unwrap_or(false)
1529            })
1530        })
1531        .unwrap_or(false)
1532}
1533
1534// ── Helpers ───────────────────────────────────────────────────────────────
1535
1536/// Extract the base command name from a full command string.
1537fn extract_base_command(cmd: &str) -> &str {
1538    cmd.split_whitespace()
1539        .next()
1540        .unwrap_or("unknown")
1541        .rsplit('/')
1542        .next()
1543        .unwrap_or("unknown")
1544}
1545
1546/// Escape a string for embedding as the contents of a double-quoted JSON
1547/// string value (per RFC 8259). Also valid for embedding in a double-quoted
1548/// JavaScript/TypeScript string literal — JS string-escape rules for the
1549/// characters that appear in filesystem paths (`\`, `"`, control chars) are
1550/// a strict subset of JSON's.
1551///
1552/// Needed because hook configs embed the sqz executable path into JSON/TS
1553/// files via `format!`. On Windows, `current_exe()` returns
1554/// `C:\Users\...\sqz.exe` — the raw backslashes produce invalid JSON that
1555/// Claude/Cursor/Gemini fail to parse. See issue #2.
1556pub(crate) fn json_escape_string_value(s: &str) -> String {
1557    let mut out = String::with_capacity(s.len() + 2);
1558    for ch in s.chars() {
1559        match ch {
1560            '\\' => out.push_str("\\\\"),
1561            '"' => out.push_str("\\\""),
1562            '\n' => out.push_str("\\n"),
1563            '\r' => out.push_str("\\r"),
1564            '\t' => out.push_str("\\t"),
1565            '\x08' => out.push_str("\\b"),
1566            '\x0c' => out.push_str("\\f"),
1567            c if (c as u32) < 0x20 => {
1568                // Other control chars: use \u00XX escape
1569                out.push_str(&format!("\\u{:04x}", c as u32));
1570            }
1571            c => out.push(c),
1572        }
1573    }
1574    out
1575}
1576
1577/// Shell-escape a string for use in an environment variable assignment.
1578fn shell_escape(s: &str) -> String {
1579    if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1580        s.to_string()
1581    } else {
1582        format!("'{}'", s.replace('\'', "'\\''"))
1583    }
1584}
1585
1586/// Check if a command contains shell operators that would break piping.
1587/// Commands with these operators are passed through uncompressed rather
1588/// than risk incorrect behavior.
1589pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1590    // Check for operators that would cause the pipe to only capture
1591    // the last command in a chain
1592    cmd.contains("&&")
1593        || cmd.contains("||")
1594        || cmd.contains(';')
1595        || cmd.contains('>')
1596        || cmd.contains('<')
1597        || cmd.contains('|') // already has a pipe
1598        || cmd.contains('&') && !cmd.contains("&&") // background &
1599        || cmd.contains("<<")  // heredoc
1600        || cmd.contains("$(")  // command substitution
1601        || cmd.contains('`')   // backtick substitution
1602}
1603
1604/// Check if a command is interactive or long-running (should not be intercepted).
1605fn is_interactive_command(cmd: &str) -> bool {
1606    let base = extract_base_command(cmd);
1607    matches!(
1608        base,
1609        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1610        | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1611        | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1612    ) || cmd.contains("--watch")
1613        || cmd.contains("-w ")
1614        || cmd.ends_with(" -w")
1615        || cmd.contains("run dev")
1616        || cmd.contains("run start")
1617        || cmd.contains("run serve")
1618}
1619
1620// ── Tests ─────────────────────────────────────────────────────────────────
1621
1622#[cfg(test)]
1623mod tests {
1624    use super::*;
1625
1626    #[test]
1627    fn test_process_hook_rewrites_bash_command() {
1628        // Use the official Claude Code input format: tool_name + tool_input
1629        let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1630        let result = process_hook(input).unwrap();
1631        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1632        // Claude Code format: hookSpecificOutput with updatedInput
1633        let hook_output = &parsed["hookSpecificOutput"];
1634        assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1635        assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1636        // updatedInput for Claude Code (camelCase)
1637        let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1638        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1639        assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1640        // Issue #10: the label is now passed as `--cmd NAME`, not as a
1641        // `SQZ_CMD=NAME` prefix (sh-specific, broken on PowerShell/cmd.exe).
1642        assert!(
1643            cmd.contains("--cmd 'git status'"),
1644            "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1645        );
1646        assert!(
1647            !cmd.contains("SQZ_CMD="),
1648            "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1649        );
1650        // Claude Code format should NOT have top-level decision/permission/continue
1651        assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1652        assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1653        assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1654    }
1655
1656    #[test]
1657    fn test_process_hook_passes_through_non_bash() {
1658        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1659        let result = process_hook(input).unwrap();
1660        assert_eq!(result, input, "non-bash tools should pass through unchanged");
1661    }
1662
1663    #[test]
1664    fn test_process_hook_rewrites_powershell_command() {
1665        let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1666        let result = process_hook(input).unwrap();
1667        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1668        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1669            .as_str()
1670            .expect("PowerShell tool calls should be rewritten");
1671        assert!(
1672            cmd.contains("Get-Content big.log"),
1673            "should preserve original PowerShell command: {cmd}"
1674        );
1675        assert!(
1676            cmd.contains("sqz compress"),
1677            "should pipe through sqz compress: {cmd}"
1678        );
1679        assert!(
1680            cmd.contains("--cmd 'Get-Content big.log'"),
1681            "should pass full command via --cmd: {cmd}"
1682        );
1683    }
1684
1685    #[test]
1686    fn test_process_hook_rewrites_pwsh_command() {
1687        let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
1688        let result = process_hook(input).unwrap();
1689        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1690        assert!(
1691            parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
1692            "pwsh tool calls should be rewritten"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_process_hook_skips_sqz_commands() {
1698        let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1699        let result = process_hook(input).unwrap();
1700        assert_eq!(result, input, "sqz commands should not be double-wrapped");
1701    }
1702
1703    #[test]
1704    fn test_process_hook_skips_interactive() {
1705        let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1706        let result = process_hook(input).unwrap();
1707        assert_eq!(result, input, "interactive commands should pass through");
1708    }
1709
1710    #[test]
1711    fn test_process_hook_skips_watch_mode() {
1712        let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1713        let result = process_hook(input).unwrap();
1714        assert_eq!(result, input, "watch mode should pass through");
1715    }
1716
1717    #[test]
1718    fn test_process_hook_empty_command() {
1719        let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1720        let result = process_hook(input).unwrap();
1721        assert_eq!(result, input);
1722    }
1723
1724    #[test]
1725    fn test_process_hook_gemini_format() {
1726        // Gemini CLI uses tool_name + tool_input (same field names as Claude Code)
1727        let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1728        let result = process_hook_gemini(input).unwrap();
1729        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1730        // Gemini uses top-level decision (not hookSpecificOutput.permissionDecision)
1731        assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1732        // Gemini format: hookSpecificOutput.tool_input.command (NOT updatedInput)
1733        let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1734        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1735        // Should NOT have Claude Code fields
1736        assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1737            "Gemini format should not have updatedInput");
1738        assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1739            "Gemini format should not have permissionDecision");
1740    }
1741
1742    #[test]
1743    fn test_process_hook_legacy_format() {
1744        // Test backward compatibility with older toolName/toolCall format
1745        let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1746        let result = process_hook(input).unwrap();
1747        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1748        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1749        assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1750    }
1751
1752    #[test]
1753    fn test_process_hook_cursor_format() {
1754        // Cursor uses tool_name "Shell" + tool_input.command (same as Claude Code input)
1755        let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1756        let result = process_hook_cursor(input).unwrap();
1757        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1758        // Cursor expects flat permission + updated_input (snake_case)
1759        assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1760        let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1761        assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1762        assert!(cmd.contains("git status"));
1763        // Should NOT have Claude Code hookSpecificOutput
1764        assert!(parsed.get("hookSpecificOutput").is_none(),
1765            "Cursor format should not have hookSpecificOutput");
1766    }
1767
1768    #[test]
1769    fn test_process_hook_cursor_passthrough_returns_empty_json() {
1770        // Cursor requires {} on all code paths, even when no rewrite happens
1771        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1772        let result = process_hook_cursor(input).unwrap();
1773        assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1774    }
1775
1776    #[test]
1777    fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1778        // sqz commands should not be double-wrapped; Cursor still needs {}
1779        let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1780        let result = process_hook_cursor(input).unwrap();
1781        assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1782    }
1783
1784    #[test]
1785    fn test_process_hook_windsurf_format() {
1786        // Windsurf uses agent_action_name + tool_info.command_line
1787        let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1788        let result = process_hook_windsurf(input).unwrap();
1789        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1790        // Windsurf uses Claude Code format as best-effort
1791        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1792        assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1793        assert!(cmd.contains("cargo test"));
1794        // Issue #10: label is passed as `--cmd`, not `SQZ_CMD=` prefix.
1795        assert!(
1796            cmd.contains("--cmd 'cargo test'"),
1797            "full command must be passed via --cmd flag: {cmd}"
1798        );
1799        assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
1800    }
1801
1802    #[test]
1803    fn test_process_hook_invalid_json() {
1804        let result = process_hook("not json");
1805        assert!(result.is_err());
1806    }
1807
1808    #[test]
1809    fn test_extract_base_command() {
1810        assert_eq!(extract_base_command("git status"), "git");
1811        assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1812        assert_eq!(extract_base_command("cargo test --release"), "cargo");
1813    }
1814
1815    #[test]
1816    fn test_is_interactive_command() {
1817        assert!(is_interactive_command("vim file.txt"));
1818        assert!(is_interactive_command("npm run dev --watch"));
1819        assert!(is_interactive_command("python3"));
1820        assert!(!is_interactive_command("git status"));
1821        assert!(!is_interactive_command("cargo test"));
1822    }
1823
1824    // ── Issue #10: Windows shell compatibility ────────────────────────────
1825
1826    /// The rewritten command must use shell-neutral syntax so it works
1827    /// in PowerShell and cmd.exe on Windows, not just POSIX shells.
1828    ///
1829    /// The old form `SQZ_CMD=val cmd` is sh-specific: PowerShell parses
1830    /// `SQZ_CMD=val` as a command name (CommandNotFoundException), and
1831    /// cmd.exe does the same. OpenCode Desktop on Windows routes the
1832    /// bash tool through PowerShell (or cmd.exe when $SHELL is unset),
1833    /// so the old form produced zero compression and a spurious error
1834    /// dialog.
1835    ///
1836    /// Reported in issue #10. The fix: pass the label as `--cmd NAME`,
1837    /// a normal CLI argument that every shell accepts.
1838    #[test]
1839    fn issue_10_rewrite_is_shell_neutral() {
1840        let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1841        let result = process_hook(input).unwrap();
1842        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1843        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1844            .as_str()
1845            .unwrap();
1846
1847        // Must use the --cmd flag form, carrying the full command.
1848        assert!(
1849            cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
1850            "issue #10: rewrite must pass label via --cmd, got: {cmd}"
1851        );
1852        // Must NOT use the sh-specific inline-env-var form.
1853        assert!(
1854            !cmd.contains("SQZ_CMD="),
1855            "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
1856             (broken in PowerShell and cmd.exe), got: {cmd}"
1857        );
1858        // Reporter's original command must still be intact.
1859        assert!(
1860            cmd.contains("dotnet build NewNeonCheckers3.sln"),
1861            "original command must be preserved verbatim: {cmd}"
1862        );
1863        // And the pipe to sqz must be there.
1864        assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
1865    }
1866
1867    /// The already-wrapped guard must recognise the new `--cmd` form so
1868    /// that a command the hook has already rewritten doesn't get
1869    /// wrapped again (causing `… | sqz compress --cmd X 2>&1 | sqz
1870    /// compress --cmd sqz` chains).
1871    ///
1872    /// This is the runaway-prefix bug from issue #5 rephrased for the
1873    /// new emission form.
1874    #[test]
1875    fn issue_10_already_wrapped_command_passes_through() {
1876        let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
1877        let result = process_hook(input).unwrap();
1878        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1879        // The hook must leave an already-wrapped command alone.
1880        // (When the guard short-circuits we return the input verbatim.)
1881        assert_eq!(
1882            result, input,
1883            "already-wrapped command must pass through unchanged; \
1884             otherwise each pass accumulates another `| sqz compress` tail"
1885        );
1886        // And explicitly verify the non-rewritten `command` is still the
1887        // original, so someone reading the hook response doesn't think
1888        // we silently re-wrapped.
1889        let _ = parsed; // suppress "unused" in case of future assertion adds
1890    }
1891
1892    #[test]
1893    fn test_generate_hook_configs() {
1894        let configs = generate_hook_configs("sqz");
1895        assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1896        assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1897        assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1898        assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1899        // Windsurf, Cline, and Cursor should generate rules files, not hook configs
1900        // (none of the three support transparent command rewriting via hooks).
1901        let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1902        assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1903            "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1904        let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1905        assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1906            "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1907        // Cursor — empirically verified (forum/Cupcake/GitButler docs +
1908        // live cursor-agent trace) that beforeShellExecution cannot rewrite
1909        // commands. Use the modern .cursor/rules/*.mdc format.
1910        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1911        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1912            "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1913             .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1914        assert!(cursor.config_content.starts_with("---"),
1915            "Cursor rule should start with YAML frontmatter");
1916        assert!(cursor.config_content.contains("alwaysApply: true"),
1917            "Cursor rule should use alwaysApply: true so the guidance loads \
1918             for every agent interaction");
1919        assert!(cursor.config_content.contains("sqz"),
1920            "Cursor rule body should mention sqz");
1921    }
1922
1923    #[test]
1924    fn test_claude_config_includes_precompact_hook() {
1925        // The PreCompact hook is what keeps sqz's dedup refs from dangling
1926        // after Claude Code auto-compacts. Without this entry, cached refs
1927        // can point at content the LLM no longer has in context.
1928        // Documented at docs.anthropic.com/en/docs/claude-code/hooks-guide.
1929        let configs = generate_hook_configs("sqz");
1930        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1931        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1932            .expect("Claude Code config must be valid JSON");
1933
1934        let precompact = parsed["hooks"]["PreCompact"]
1935            .as_array()
1936            .expect("PreCompact hook array must be present");
1937        assert!(
1938            !precompact.is_empty(),
1939            "PreCompact must have at least one registered hook"
1940        );
1941
1942        let cmd = precompact[0]["hooks"][0]["command"]
1943            .as_str()
1944            .expect("command field must be a string");
1945        assert!(
1946            cmd.ends_with(" hook precompact"),
1947            "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1948        );
1949    }
1950
1951    // ── Issue #2: Windows path escaping in hook configs ───────────────
1952
1953    #[test]
1954    fn test_json_escape_string_value() {
1955        // Plain ASCII: unchanged
1956        assert_eq!(json_escape_string_value("sqz"), "sqz");
1957        assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1958        // Backslash: escaped
1959        assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1960                   r"C:\\Users\\Alice\\sqz.exe");
1961        // Double quote: escaped
1962        assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1963                   r#"path with \"quotes\""#);
1964        // Control chars
1965        assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1966    }
1967
1968    #[test]
1969    fn test_windows_path_produces_valid_json_for_claude() {
1970        // Issue #2 repro: on Windows, current_exe() returns a path with
1971        // backslashes. Without escaping, the generated JSON is invalid.
1972        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1973        let configs = generate_hook_configs(windows_path);
1974
1975        let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1976            .expect("Claude config should be generated");
1977        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1978            .expect("Claude hook config must be valid JSON on Windows paths");
1979
1980        // Verify the command was written with the original path (not lossy-transformed).
1981        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1982            .as_str()
1983            .expect("command field must be a string");
1984        assert!(cmd.contains(windows_path),
1985            "command '{cmd}' must contain the original Windows path '{windows_path}'");
1986    }
1987
1988    #[test]
1989    fn test_windows_path_in_cursor_rules_file() {
1990        // Cursor's config is now .cursor/rules/sqz.mdc (markdown), not JSON.
1991        // Markdown doesn't escape backslashes — the user reads this rule
1992        // through the agent and needs to see the raw path so commands are
1993        // pasteable. See test_rules_files_use_raw_path_for_readability for
1994        // the same property on Windsurf/Cline.
1995        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1996        let configs = generate_hook_configs(windows_path);
1997
1998        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1999        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
2000        assert!(cursor.config_content.contains(windows_path),
2001            "Cursor rule must contain the raw (unescaped) path so users can \
2002             copy-paste the shown commands — got:\n{}", cursor.config_content);
2003        assert!(!cursor.config_content.contains(r"C:\\Users"),
2004            "Cursor rule must NOT double-escape backslashes in markdown — \
2005             got:\n{}", cursor.config_content);
2006    }
2007
2008    #[test]
2009    fn test_windows_path_produces_valid_json_for_gemini() {
2010        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2011        let configs = generate_hook_configs(windows_path);
2012
2013        let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
2014        let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
2015            .expect("Gemini hook config must be valid JSON on Windows paths");
2016        let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
2017        assert!(cmd.contains(windows_path));
2018    }
2019
2020    #[test]
2021    fn test_rules_files_use_raw_path_for_readability() {
2022        // The .windsurfrules / .clinerules / .cursor/rules/sqz.mdc files are
2023        // markdown for humans. Backslashes should NOT be doubled there — the
2024        // user needs to copy-paste the command into their shell.
2025        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2026        let configs = generate_hook_configs(windows_path);
2027
2028        for tool in &["Windsurf", "Cline", "Cursor"] {
2029            let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
2030            assert!(cfg.config_content.contains(windows_path),
2031                "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
2032                cfg.config_content);
2033            assert!(!cfg.config_content.contains(r"C:\\Users"),
2034                "{tool} rules file must NOT double-escape backslashes — got:\n{}",
2035                cfg.config_content);
2036        }
2037    }
2038
2039    #[test]
2040    fn test_unix_path_still_works() {
2041        // Regression: make sure the escape path doesn't mangle Unix paths
2042        // (which have no backslashes to escape).
2043        let unix_path = "/usr/local/bin/sqz";
2044        let configs = generate_hook_configs(unix_path);
2045
2046        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2047        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2048            .expect("Unix path should produce valid JSON");
2049        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
2050        assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
2051    }
2052
2053    #[test]
2054    fn test_shell_escape_simple() {
2055        assert_eq!(shell_escape("git"), "git");
2056        assert_eq!(shell_escape("cargo-test"), "cargo-test");
2057    }
2058
2059    #[test]
2060    fn test_shell_escape_special_chars() {
2061        assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
2062    }
2063
2064    #[test]
2065    fn test_install_tool_hooks_creates_files() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let installed = install_tool_hooks(dir.path(), "sqz");
2068        // Should install at least some hooks
2069        assert!(!installed.is_empty(), "should install at least one hook config");
2070        // Verify files were created
2071        for name in &installed {
2072            let configs = generate_hook_configs("sqz");
2073            let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
2074            let path = dir.path().join(&config.config_path);
2075            assert!(path.exists(), "hook config should exist: {}", path.display());
2076        }
2077    }
2078
2079    #[test]
2080    fn test_install_tool_hooks_does_not_overwrite() {
2081        let dir = tempfile::tempdir().unwrap();
2082        // First install
2083        install_tool_hooks(dir.path(), "sqz");
2084        // Write a custom file to one of the paths. It is deliberately NOT
2085        // valid JSON: the Claude Code merge path refuses to touch files it
2086        // can't parse, and every other tool's config is skip-if-exists.
2087        let custom_path = dir.path().join(".claude/settings.local.json");
2088        std::fs::write(&custom_path, "custom content").unwrap();
2089        // Second install must leave the unparseable file alone.
2090        install_tool_hooks(dir.path(), "sqz");
2091        let content = std::fs::read_to_string(&custom_path).unwrap();
2092        assert_eq!(content, "custom content", "should not overwrite existing config");
2093    }
2094
2095    /// Regression for the issue #26 leftover: a project-scope settings file
2096    /// from a pre-PowerShell install (matcher "Bash", old binary path) must
2097    /// be upgraded in place when `sqz init` runs again — while every key
2098    /// the user added themselves survives untouched. The old behavior was
2099    /// skip-if-exists, which left Windows installs on the stale matcher
2100    /// forever.
2101    #[test]
2102    fn test_reinit_upgrades_stale_project_scope_matcher() {
2103        let dir = tempfile::tempdir().unwrap();
2104        let settings_path = dir.path().join(".claude").join("settings.local.json");
2105        std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
2106
2107        // A pre-issue-#26 install: Bash-only matcher, stale binary path,
2108        // plus keys the user added by hand.
2109        let stale = serde_json::json!({
2110            "permissions": { "allow": ["Bash(ls:*)"] },
2111            "hooks": {
2112                "PreToolUse": [
2113                    {
2114                        "matcher": "Bash",
2115                        "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
2116                    },
2117                    {
2118                        "matcher": "Edit",
2119                        "hooks": [{ "type": "command", "command": "my-linter --check" }]
2120                    }
2121                ]
2122            }
2123        });
2124        std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
2125
2126        install_tool_hooks(dir.path(), "/new/path/sqz");
2127
2128        let content = std::fs::read_to_string(&settings_path).unwrap();
2129        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2130
2131        // sqz's entry: matcher upgraded, binary path refreshed, no duplicate.
2132        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2133        let sqz_entries: Vec<_> = pre
2134            .iter()
2135            .filter(|e| {
2136                e["hooks"][0]["command"]
2137                    .as_str()
2138                    .is_some_and(|c| c.contains("hook claude"))
2139            })
2140            .collect();
2141        assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
2142        assert_eq!(
2143            sqz_entries[0]["matcher"].as_str().unwrap(),
2144            "Bash|PowerShell",
2145            "stale Bash-only matcher must be upgraded"
2146        );
2147        assert!(
2148            sqz_entries[0]["hooks"][0]["command"]
2149                .as_str()
2150                .unwrap()
2151                .contains("/new/path/sqz"),
2152            "binary path must be refreshed"
2153        );
2154
2155        // The user's own entries survive.
2156        assert!(
2157            pre.iter().any(|e| e["matcher"] == "Edit"),
2158            "user's non-sqz PreToolUse entry must be preserved"
2159        );
2160        assert_eq!(
2161            parsed["permissions"]["allow"][0].as_str().unwrap(),
2162            "Bash(ls:*)",
2163            "user's permissions must be preserved"
2164        );
2165
2166        // The other two sqz hooks got added too (full heal, not partial).
2167        assert!(parsed["hooks"]["PreCompact"].is_array());
2168        assert!(parsed["hooks"]["SessionStart"].is_array());
2169    }
2170
2171    /// Re-running init on an already-current project file is a no-op.
2172    #[test]
2173    fn test_reinit_is_idempotent_at_project_scope() {
2174        let dir = tempfile::tempdir().unwrap();
2175        install_tool_hooks(dir.path(), "/usr/bin/sqz");
2176        let settings_path = dir.path().join(".claude").join("settings.local.json");
2177        let first = std::fs::read_to_string(&settings_path).unwrap();
2178
2179        install_tool_hooks(dir.path(), "/usr/bin/sqz");
2180        let second = std::fs::read_to_string(&settings_path).unwrap();
2181
2182        let a: serde_json::Value = serde_json::from_str(&first).unwrap();
2183        let b: serde_json::Value = serde_json::from_str(&second).unwrap();
2184        assert_eq!(a, b, "second init must not change project settings");
2185        // No duplicated sqz entries.
2186        assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
2187    }
2188}
2189
2190#[cfg(test)]
2191mod global_install_tests {
2192    use super::*;
2193
2194    #[test]
2195    fn global_install_creates_fresh_settings_json() {
2196        let tmp = tempfile::tempdir().unwrap();
2197        let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2198        assert!(changed, "first install should report a change");
2199
2200        let path = tmp.path().join(".claude").join("settings.json");
2201        assert!(path.exists(), "user settings.json should be created");
2202
2203        let content = std::fs::read_to_string(&path).unwrap();
2204        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2205
2206        // All three hook entries should be present.
2207        let pre = &parsed["hooks"]["PreToolUse"];
2208        assert!(pre.is_array(), "PreToolUse should be an array");
2209        assert_eq!(pre.as_array().unwrap().len(), 1);
2210        let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2211        assert!(
2212            cmd.contains("/usr/local/bin/sqz"),
2213            "hook command should use the passed sqz_path, got: {cmd}"
2214        );
2215        assert!(cmd.contains("hook claude"));
2216
2217        let precompact = &parsed["hooks"]["PreCompact"];
2218        assert!(precompact.is_array());
2219        let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
2220        assert!(precompact_cmd.contains("hook precompact"));
2221
2222        let session = &parsed["hooks"]["SessionStart"];
2223        assert!(session.is_array());
2224        assert_eq!(
2225            session[0]["matcher"].as_str().unwrap(),
2226            "compact",
2227            "SessionStart should only match /compact resume"
2228        );
2229    }
2230
2231    #[test]
2232    fn global_install_preserves_existing_user_config() {
2233        let tmp = tempfile::tempdir().unwrap();
2234        let settings = tmp.path().join(".claude").join("settings.json");
2235        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2236
2237        let existing = serde_json::json!({
2238            "permissions": {
2239                "allow": ["Bash(npm test *)"],
2240                "deny":  ["Read(./.env)"]
2241            },
2242            "env": { "FOO": "bar" },
2243            "statusLine": {
2244                "type": "command",
2245                "command": "~/.claude/statusline.sh"
2246            },
2247            "hooks": {
2248                "PreToolUse": [
2249                    {
2250                        "matcher": "Edit",
2251                        "hooks": [
2252                            {
2253                                "type": "command",
2254                                "command": "~/.claude/hooks/format-on-edit.sh"
2255                            }
2256                        ]
2257                    }
2258                ]
2259            }
2260        });
2261        std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2262
2263        let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2264        assert!(changed, "install should report a change on new hook");
2265
2266        let content = std::fs::read_to_string(&settings).unwrap();
2267        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2268
2269        // User's permissions survived.
2270        assert_eq!(
2271            parsed["permissions"]["allow"][0].as_str().unwrap(),
2272            "Bash(npm test *)"
2273        );
2274        assert_eq!(
2275            parsed["permissions"]["deny"][0].as_str().unwrap(),
2276            "Read(./.env)"
2277        );
2278        // User's env block survived.
2279        assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2280        // User's statusLine survived.
2281        assert_eq!(
2282            parsed["statusLine"]["command"].as_str().unwrap(),
2283            "~/.claude/statusline.sh"
2284        );
2285
2286        // PreToolUse should now contain BOTH the user's format-on-edit
2287        // hook and sqz's Bash hook — our install appends, not replaces.
2288        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2289        assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2290        let matchers: Vec<&str> = pre
2291            .iter()
2292            .map(|e| e["matcher"].as_str().unwrap_or(""))
2293            .collect();
2294        assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2295        assert!(
2296            matchers.contains(&"Bash|PowerShell"),
2297            "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2298        );
2299    }
2300
2301    #[test]
2302    fn global_install_is_idempotent() {
2303        let tmp = tempfile::tempdir().unwrap();
2304        assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2305        assert!(
2306            !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2307            "second install with identical args should report no change"
2308        );
2309
2310        let path = tmp.path().join(".claude").join("settings.json");
2311        let parsed: serde_json::Value =
2312            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2313        for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2314            let arr = parsed["hooks"][event].as_array().unwrap();
2315            assert_eq!(
2316                arr.len(),
2317                1,
2318                "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2319            );
2320        }
2321    }
2322
2323    /// Regression for issue #21: on Windows, `sqz_path` contains `.exe`
2324    /// so the stored command is `C:\...\sqz.exe hook claude`. The old
2325    /// sentinel was `"sqz hook claude"` which is NOT a substring of
2326    /// `"sqz.exe hook claude"` — causing every re-run to append a
2327    /// duplicate instead of replacing. The fix uses `"hook claude"` as
2328    /// the sentinel which matches regardless of `.exe`.
2329    #[test]
2330    fn global_install_is_idempotent_with_exe_path() {
2331        let tmp = tempfile::tempdir().unwrap();
2332        let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2333        assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2334        assert!(
2335            !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2336            "second install with .exe path should report no change (issue #21)"
2337        );
2338
2339        let path = tmp.path().join(".claude").join("settings.json");
2340        let parsed: serde_json::Value =
2341            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2342        for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2343            let arr = parsed["hooks"][event].as_array().unwrap();
2344            assert_eq!(
2345                arr.len(),
2346                1,
2347                "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2348            );
2349        }
2350    }
2351
2352    #[test]
2353    fn global_install_upgrades_stale_sqz_hook_in_place() {
2354        let tmp = tempfile::tempdir().unwrap();
2355        install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2356        let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2357        assert!(changed, "different sqz_path must be seen as a change");
2358
2359        let path = tmp.path().join(".claude").join("settings.json");
2360        let parsed: serde_json::Value =
2361            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2362        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2363        assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2364        let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2365        assert!(cmd.contains("/new/path/sqz"));
2366        assert!(!cmd.contains("/old/path/sqz"));
2367    }
2368
2369    #[test]
2370    fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2371        let tmp = tempfile::tempdir().unwrap();
2372        let settings = tmp.path().join(".claude").join("settings.json");
2373        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2374        std::fs::write(
2375            &settings,
2376            serde_json::json!({
2377                "permissions": { "allow": ["Bash(git status)"] },
2378                "hooks": {
2379                    "PreToolUse": [
2380                        {
2381                            "matcher": "Edit",
2382                            "hooks": [
2383                                { "type": "command", "command": "~/format.sh" }
2384                            ]
2385                        }
2386                    ]
2387                }
2388            })
2389            .to_string(),
2390        )
2391        .unwrap();
2392
2393        install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2394        let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2395        assert_eq!(result.0, settings);
2396        assert!(result.1, "should report that the file was modified");
2397
2398        assert!(settings.exists(), "settings.json should be preserved");
2399        let parsed: serde_json::Value =
2400            serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2401
2402        assert_eq!(
2403            parsed["permissions"]["allow"][0].as_str().unwrap(),
2404            "Bash(git status)"
2405        );
2406
2407        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2408        assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2409        assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2410
2411        assert!(parsed["hooks"].get("PreCompact").is_none());
2412        assert!(parsed["hooks"].get("SessionStart").is_none());
2413    }
2414
2415    #[test]
2416    fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2417        let tmp = tempfile::tempdir().unwrap();
2418        install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2419        let path = tmp.path().join(".claude").join("settings.json");
2420        assert!(path.exists(), "precondition: install created the file");
2421
2422        let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2423        assert!(result.1);
2424        assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2425    }
2426
2427    #[test]
2428    fn global_uninstall_on_missing_file_is_noop() {
2429        let tmp = tempfile::tempdir().unwrap();
2430        assert!(
2431            remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2432            "missing file should return None, not error"
2433        );
2434    }
2435
2436    #[test]
2437    fn global_uninstall_refuses_to_touch_unparseable_file() {
2438        let tmp = tempfile::tempdir().unwrap();
2439        let settings = tmp.path().join(".claude").join("settings.json");
2440        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2441        std::fs::write(&settings, "{ invalid json because").unwrap();
2442
2443        assert!(
2444            remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2445            "bad JSON must surface as an error"
2446        );
2447
2448        let after = std::fs::read_to_string(&settings).unwrap();
2449        assert_eq!(after, "{ invalid json because");
2450    }
2451}
2452
2453#[cfg(test)]
2454mod issue_11_tool_filter_tests {
2455    //! Regression tests for issue #11 (@shochdoerfer): let the user
2456    //! choose for which agent sqz init creates configs.
2457    //!
2458    //! Every assertion here pins a behaviour the filter should have.
2459    //! If any one of these flips, users are either getting configs for
2460    //! tools they asked to skip OR getting no config for tools they
2461    //! explicitly asked for — both are issue #11 regressions.
2462
2463    use super::*;
2464
2465    #[test]
2466    fn canonicalize_collapses_common_aliases() {
2467        // Each row: list of aliases a user might type, followed by the
2468        // canonical form they should all normalise to.
2469        for aliases in &[
2470            (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2471            (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2472            (vec!["Windsurf", "WINDSURF"], "windsurf"),
2473            // Cline is also marketed as "Roo Code" — sqz treats them
2474            // as one integration (same .clinerules file) so the
2475            // aliases must collapse.
2476            (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2477            (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2478            (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2479            (vec!["Codex", "codex"], "codex"),
2480        ] {
2481            for alias in &aliases.0 {
2482                assert_eq!(
2483                    canonicalize_tool_name(alias),
2484                    aliases.1,
2485                    "alias '{}' must canonicalise to '{}'",
2486                    alias,
2487                    aliases.1
2488                );
2489            }
2490        }
2491    }
2492
2493    #[test]
2494    fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2495        // Unknown names fall through — we don't guess. The caller
2496        // (parse_tool_list) is responsible for turning unknown values
2497        // into user-facing errors; canonicalize itself just
2498        // normalises case, whitespace, and hyphens/underscores.
2499        assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2500        assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2501    }
2502
2503    #[test]
2504    fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2505        // Typical shell invocation: `--only opencode,codex` or
2506        // `--only "opencode, codex"` — both should work.
2507        let names = parse_tool_list("opencode,codex").unwrap();
2508        assert_eq!(names, vec!["opencode", "codex"]);
2509
2510        let names = parse_tool_list(" opencode ,  codex ").unwrap();
2511        assert_eq!(names, vec!["opencode", "codex"]);
2512
2513        // Single entry.
2514        let names = parse_tool_list("opencode").unwrap();
2515        assert_eq!(names, vec!["opencode"]);
2516
2517        // Alias with hyphen.
2518        let names = parse_tool_list("claude-code").unwrap();
2519        assert_eq!(names, vec!["claudecode"]);
2520    }
2521
2522    #[test]
2523    fn parse_tool_list_dedupes_repeated_entries() {
2524        // `--only opencode,opencode` shouldn't produce two "opencode"
2525        // entries that the filter then matches twice. Harmless today
2526        // but tomorrow someone could code `filter.allow.len()` against
2527        // it and silently count wrong.
2528        let names = parse_tool_list("opencode,opencode").unwrap();
2529        assert_eq!(names, vec!["opencode"]);
2530
2531        // Same name via different aliases still dedupes because they
2532        // canonicalise to the same string.
2533        let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2534        assert_eq!(names, vec!["claudecode"]);
2535    }
2536
2537    #[test]
2538    fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2539        // This is the critical failure path: a typo in --only must
2540        // fail, not silently drop. Otherwise the user runs `sqz init
2541        // --only opncode` (typo), sees "OK" with nothing installed,
2542        // and spends 20 minutes figuring out why. The error message
2543        // must list valid options verbatim so they can spot their
2544        // typo.
2545        let err = parse_tool_list("opncode").unwrap_err();
2546        let msg = err.to_string();
2547        assert!(
2548            msg.contains("unknown agent name 'opncode'"),
2549            "error must quote the bad input: {msg}"
2550        );
2551        assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2552        assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2553    }
2554
2555    #[test]
2556    fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2557        // `--only opencode,xyz` — fail the whole list, don't just drop
2558        // xyz. User either typed a typo they need to see, or they
2559        // don't understand the vocabulary — in both cases pretending
2560        // the input was valid hurts them.
2561        let err = parse_tool_list("opencode,xyz").unwrap_err();
2562        assert!(err.to_string().contains("xyz"));
2563    }
2564
2565    #[test]
2566    fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2567        // `--only ""` and `--only "   "` produce an empty filter —
2568        // semantically "install nothing" rather than "install all".
2569        // cmd_init surfaces this as ToolFilter::Only(empty) which
2570        // skips every config file but still installs the shell hook
2571        // and preset.
2572        assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2573        assert_eq!(parse_tool_list("   ").unwrap(), Vec::<String>::new());
2574        assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2575    }
2576
2577    #[test]
2578    fn tool_filter_all_includes_every_supported_tool() {
2579        let filter = ToolFilter::All;
2580        for tool in SUPPORTED_TOOL_NAMES {
2581            assert!(
2582                filter.includes(tool),
2583                "default filter must include {tool}"
2584            );
2585        }
2586    }
2587
2588    #[test]
2589    fn tool_filter_only_opencode_excludes_everything_else() {
2590        // The exact scenario from issue #11.
2591        let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2592        assert!(filter.includes("OpenCode"));
2593        // Every other supported tool is rejected.
2594        for tool in SUPPORTED_TOOL_NAMES {
2595            if *tool == "OpenCode" {
2596                continue;
2597            }
2598            assert!(
2599                !filter.includes(tool),
2600                "--only opencode must not include {tool}"
2601            );
2602        }
2603    }
2604
2605    #[test]
2606    fn tool_filter_only_multi_tool_includes_exactly_those() {
2607        let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2608        assert!(filter.includes("OpenCode"));
2609        assert!(filter.includes("Codex"));
2610        // Everything else: excluded.
2611        assert!(!filter.includes("Claude Code"));
2612        assert!(!filter.includes("Cursor"));
2613        assert!(!filter.includes("Windsurf"));
2614        assert!(!filter.includes("Cline"));
2615        assert!(!filter.includes("Gemini CLI"));
2616    }
2617
2618    #[test]
2619    fn tool_filter_skip_inverts_the_set() {
2620        // `--skip cursor,windsurf` means "install everything except
2621        // those two." Opposite of --only.
2622        let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2623        assert!(!filter.includes("Cursor"));
2624        assert!(!filter.includes("Windsurf"));
2625        // Everything else stays on.
2626        assert!(filter.includes("Claude Code"));
2627        assert!(filter.includes("Cline"));
2628        assert!(filter.includes("Gemini CLI"));
2629        assert!(filter.includes("OpenCode"));
2630        assert!(filter.includes("Codex"));
2631    }
2632
2633    #[test]
2634    fn tool_filter_only_empty_excludes_everything() {
2635        // Edge: `--only ""` → empty filter → nothing passes. This is
2636        // semantically "install no AI-tool configs, just the shell
2637        // hook and preset." Surprising if the user typed it by
2638        // accident, but consistent — the plan output will show only
2639        // shell/preset lines and the user can abort.
2640        let filter = ToolFilter::Only(vec![]);
2641        for tool in SUPPORTED_TOOL_NAMES {
2642            assert!(
2643                !filter.includes(tool),
2644                "empty --only must exclude every tool, got {tool}"
2645            );
2646        }
2647    }
2648
2649    #[test]
2650    fn tool_filter_only_accepts_display_name_or_canonical() {
2651        // The filter lives in the engine; callers pass the
2652        // display-name strings ("Claude Code", "Gemini CLI") straight
2653        // from generate_hook_configs. But filter entries come from
2654        // the CLI via parse_tool_list, which canonicalises. Both
2655        // sides must line up — assert the cross-path works.
2656        let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2657        assert!(filter.includes("Claude Code"));
2658        assert!(!filter.includes("Cursor"));
2659
2660        let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2661        assert!(filter.includes("Gemini CLI"));
2662    }
2663
2664    #[test]
2665    fn supported_tool_names_matches_generate_hook_configs_exactly() {
2666        // Invariant: the SUPPORTED_TOOL_NAMES constant (used in error
2667        // messages, docs, tests) must match the tool_name fields the
2668        // config generator actually emits. If someone adds a new tool
2669        // config but forgets to add its name to the constant, this
2670        // test fails loudly.
2671        let configs = generate_hook_configs("sqz");
2672        let emitted: std::collections::HashSet<&str> =
2673            configs.iter().map(|c| c.tool_name.as_str()).collect();
2674        let declared: std::collections::HashSet<&str> =
2675            SUPPORTED_TOOL_NAMES.iter().copied().collect();
2676        assert_eq!(
2677            emitted, declared,
2678            "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2679             from generate_hook_configs. emitted={:?}, declared={:?}",
2680            emitted, declared
2681        );
2682    }
2683
2684    #[test]
2685    fn filtered_install_only_opencode_writes_only_opencode_files() {
2686        // End-to-end: exercise install_tool_hooks_scoped_filtered
2687        // against a tempdir and assert that only OpenCode's files
2688        // appear. This is the single most important regression for
2689        // issue #11: no Cursor rules, no Windsurf rules, no Cline
2690        // rules, no Gemini settings, no AGENTS.md, no .claude/.
2691        let dir = tempfile::tempdir().unwrap();
2692        let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2693        let _installed = install_tool_hooks_scoped_filtered(
2694            dir.path(),
2695            "sqz",
2696            InstallScope::Project,
2697            &filter,
2698        );
2699
2700        // OpenCode SHOULD be there.
2701        assert!(
2702            dir.path().join("opencode.json").exists(),
2703            "OpenCode config must be written when --only opencode is used"
2704        );
2705
2706        // None of the other agents' files should exist.
2707        for (path, tool) in &[
2708            (".claude/settings.local.json", "Claude Code"),
2709            (".cursor/rules/sqz.mdc", "Cursor"),
2710            (".windsurfrules", "Windsurf"),
2711            (".clinerules", "Cline"),
2712            (".gemini/settings.json", "Gemini CLI"),
2713            ("AGENTS.md", "Codex"),
2714        ] {
2715            assert!(
2716                !dir.path().join(path).exists(),
2717                "filter rejected {tool} but the installer still wrote {path}"
2718            );
2719        }
2720    }
2721
2722    #[test]
2723    fn filtered_install_skip_cursor_omits_only_cursor() {
2724        // Symmetric: --skip cursor should leave everything else intact.
2725        let dir = tempfile::tempdir().unwrap();
2726        let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
2727        let _installed = install_tool_hooks_scoped_filtered(
2728            dir.path(),
2729            "sqz",
2730            InstallScope::Project,
2731            &filter,
2732        );
2733
2734        // Cursor rules must NOT exist.
2735        assert!(
2736            !dir.path().join(".cursor/rules/sqz.mdc").exists(),
2737            "skip cursor: .cursor/rules/sqz.mdc must not be written"
2738        );
2739        // Windsurf and Cline rules SHOULD still exist.
2740        assert!(
2741            dir.path().join(".windsurfrules").exists(),
2742            "skip cursor should not skip windsurf"
2743        );
2744        assert!(
2745            dir.path().join(".clinerules").exists(),
2746            "skip cursor should not skip cline"
2747        );
2748    }
2749}
2750