Skip to main content

sqz_engine/
tool_hooks.rs

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