Skip to main content

sqz_engine/
tool_hooks.rs

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