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