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