Skip to main content

sqz_engine/
tool_hooks.rs

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