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 or ~/.claude/settings.json (nested
10///   PreToolUse, matcher "Bash|PowerShell", plus PostToolUse for Bash and
11///   two allow rules for the parts the rewrite adds)
12/// - Gemini CLI: .gemini/settings.json (nested BeforeTool, matcher: "run_shell_command")
13/// - OpenCode: ~/.config/opencode/plugins/sqz.ts (TypeScript plugin, tool.execute.before)
14///
15/// Tools that do NOT support command rewriting via hooks (use prompt-level
16/// guidance via rules files instead):
17/// - Codex: only supports deny in PreToolUse; updatedInput is parsed but ignored
18/// - Windsurf: no documented hook API; uses .windsurfrules prompt-level guidance
19/// - Cline: PreToolUse cannot rewrite commands; uses .clinerules prompt-level guidance
20/// - Cursor: beforeShellExecution can't replace the command, and preToolUse
21///   can only together with a permission decision, where "allow" would skip
22///   Cursor's own approval. Uses .cursor/rules/sqz.mdc prompt-level guidance
23///   instead; `sqz hook cursor` prints nothing and exits 1, which Cursor
24///   treats as a failed hook, so its normal approval applies.
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: no answer, so Cursor's own approval applies
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: always an error, so Cursor's own
93/// approval applies.
94pub fn process_hook_cursor(_input: &str) -> Result<String> {
95    Err(crate::error::SqzError::Other(
96        "can't rewrite without approving the command; Cursor's own approval applies".into(),
97    ))
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    if platform == HookPlatform::Cursor {
143        return process_hook_cursor(input);
144    }
145    let mut parsed: serde_json::Value = serde_json::from_str(input)
146        .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
147    // Copilot CLI documents `toolArgs` as a JSON string.
148    if let Some(args) = parsed
149        .get("toolArgs")
150        .and_then(|v| v.as_str())
151        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
152        .filter(serde_json::Value::is_object)
153    {
154        parsed["toolArgs"] = args;
155    }
156
157    // Claude Code uses "tool_name" + "tool_input" (official docs).
158    // Cursor uses "hook_event_name": "beforeShellExecution" with "command" at top level.
159    // Some older references show "toolName" + "toolCall" — accept all.
160    let tool_name = parsed
161        .get("tool_name")
162        .or_else(|| parsed.get("toolName"))
163        .and_then(|v| v.as_str())
164        .unwrap_or("");
165
166    let hook_event = parsed
167        .get("hook_event_name")
168        .or_else(|| parsed.get("agent_action_name"))
169        .and_then(|v| v.as_str())
170        .unwrap_or("");
171
172    // Only intercept Bash/shell tool calls.
173    //
174    // Claude Code's built-in tools (Read, Grep, Glob, Write) bypass shell
175    // hooks entirely. A PostToolUse hook can now replace a result, but the
176    // replacement must match each tool's own result shape and Read's
177    // feeds Claude Code's edit tracking, so only Bash results are
178    // replaced there (`claude_post_tool_use_target`). The MCP server
179    // (sqz-mcp) provides compressed alternatives to the built-in tools.
180    let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
181        | "run_terminal_command" | "run_shell_command" | "execute_bash"
182        | "PowerShell" | "powershell" | "pwsh")
183        || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
184
185    if !is_shell {
186        // Pass through non-bash tools unchanged.
187        return Ok(passthrough(platform, input));
188    }
189
190    // Claude Code puts command in tool_input.command (official docs).
191    // Cursor puts command at top level: { "command": "git status" }.
192    // Windsurf puts command in tool_info.command_line.
193    // Some older references show toolCall.command — accept all.
194    let command = parsed
195        .get("tool_input")
196        .and_then(|v| v.get("command"))
197        .and_then(|v| v.as_str())
198        .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
199        .or_else(|| {
200            parsed
201                .get("tool_info")
202                .and_then(|v| v.get("command_line"))
203                .and_then(|v| v.as_str())
204        })
205        .or_else(|| {
206            parsed
207                .get("toolCall")
208                .and_then(|v| v.get("command"))
209                .and_then(|v| v.as_str())
210        })
211        .or_else(|| {
212            // Copilot CLI camelCase payload: toolArgs.command
213            parsed
214                .get("toolArgs")
215                .and_then(|v| v.get("command"))
216                .and_then(|v| v.as_str())
217        })
218        .unwrap_or("");
219
220    if command.is_empty() {
221        return Ok(passthrough(platform, input));
222    }
223
224    // Don't intercept commands that are already piped through sqz.
225    // Check the base command name specifically, not substring — so
226    // "grep sqz logfile" or "cargo search sqz" aren't skipped.
227    //
228    // Guards:
229    //   * base command is `sqz` (running sqz directly)
230    //   * legacy `SQZ_CMD=…` sh-style prefix (from older sqz versions)
231    //   * new shell-neutral `--cmd NAME` form — we need this because
232    //     the new emission (issue #10 fix) uses `| sqz compress --cmd …`
233    //     and without a guard the hook would re-wrap commands it had
234    //     already rewritten once (runaway-prefix bug from issue #5).
235    let base_cmd = extract_base_command(command);
236    if base_cmd == "sqz"
237        || command.starts_with("SQZ_CMD=")
238        || command.contains("sqz compress --cmd ")
239        || command.contains("sqz.exe compress --cmd ")
240    {
241        return Ok(passthrough(platform, input));
242    }
243
244    // A background call streams its output to the agent while it runs;
245    // through sqz nothing would show until the command exits.
246    if runs_in_background(&parsed) {
247        return Ok(passthrough(platform, input));
248    }
249
250    // Rewrite: pipe the command's output through sqz compress.
251    //
252    // Issue #10: use `--cmd NAME` instead of a `SQZ_CMD=NAME` prefix so
253    // the rewrite works in every shell. The sh-style inline env-var
254    // assignment doesn't parse in PowerShell (the reporter's default on
255    // Windows) or cmd.exe — both treat `SQZ_CMD=cmd` as a literal
256    // command name and raise `CommandNotFoundException`. `--cmd NAME`
257    // is a normal CLI argument and all three shells parse it fine.
258    let is_powershell = matches!(tool_name, "PowerShell" | "powershell" | "pwsh");
259    let rewritten = if platform == HookPlatform::ClaudeCode {
260        let settings = claude_settings_files(&parsed);
261        let shell = crate::shell_scan::Shell::from_env();
262        match claude_rewrite(&parsed, command, is_powershell, &settings, shell) {
263            Some(rewritten) => rewritten,
264            None => return Ok(passthrough(platform, input)),
265        }
266    } else if is_powershell {
267        // PowerShell pipes objects, so only plain commands are rewritten.
268        if is_interactive_command(command) || has_shell_operators(command) {
269            return Ok(passthrough(platform, input));
270        }
271        crate::exit_marker::powershell_rewrite(command, &shell_escape(command))
272    } else {
273        match crate::shell_scan::plan(command) {
274            Ok(plan) => posix_rewrite_plan(&plan),
275            Err(_) => return Ok(passthrough(platform, input)),
276        }
277    };
278
279    // Claude Code's updatedInput and Gemini's tool_input both REPLACE the
280    // arguments, so unchanged fields like run_in_background, timeout,
281    // description or dir_path must be carried over or they are silently
282    // dropped. Start from the original tool_input and swap only the command.
283    let mut updated_input = parsed
284        .get("tool_input")
285        .or_else(|| parsed.get("toolArgs"))
286        .and_then(|v| v.as_object())
287        .cloned()
288        .unwrap_or_default();
289    updated_input.insert(
290        "command".to_string(),
291        serde_json::Value::String(rewritten.clone()),
292    );
293    let updated_input = serde_json::Value::Object(updated_input);
294
295    // Build platform-specific output.
296    //
297    // Each AI tool expects a different JSON response format. Using the wrong
298    // format causes silent failures (the tool ignores the rewrite).
299    //
300    // No output claims a permission decision. In Claude Code and Copilot CLI
301    // an "allow" skips the approval prompt, so the user's own rules and
302    // prompts would stop applying to every command sqz rewrites. Without a
303    // decision the host applies the new input and runs its normal checks.
304    //
305    // Claude Code (code.claude.com/docs/en/hooks):
306    //   hookSpecificOutput.hookEventName = "PreToolUse"
307    //   hookSpecificOutput.updatedInput = { ... }  (replaces the entire input)
308    //
309    // Gemini CLI (geminicli.com/docs/hooks/reference):
310    //   hookSpecificOutput.tool_input = { ... }. The scheduler replaces the
311    //   call's arguments with it rather than merging, so it carries every
312    //   original field.
313    //
314    // Codex (developers.openai.com/codex/hooks):
315    //   Only "deny" works in PreToolUse. "allow", updatedInput, additionalContext
316    //   are parsed but NOT supported — they fail open. AGENTS.md guidance is
317    //   the working alternative; we do NOT generate hooks for Codex.
318    let output = match platform {
319        HookPlatform::ClaudeCode => serde_json::json!({
320            "hookSpecificOutput": {
321                "hookEventName": "PreToolUse",
322                "updatedInput": updated_input
323            }
324        }),
325        HookPlatform::Cursor => return process_hook_cursor(input),
326        HookPlatform::GeminiCli => serde_json::json!({
327            "hookSpecificOutput": {
328                "tool_input": updated_input
329            }
330        }),
331        HookPlatform::Windsurf => {
332            // Windsurf hook support is unconfirmed for command rewriting.
333            // Use Claude Code format as best-effort; the hook may only work
334            // via exit codes (0 = allow, 2 = block).
335            serde_json::json!({
336                "hookSpecificOutput": {
337                    "hookEventName": "PreToolUse",
338                    "updatedInput": updated_input
339                }
340            })
341        }
342        HookPlatform::Copilot => serde_json::json!({
343            "modifiedArgs": updated_input
344        }),
345        HookPlatform::Kiro => {
346            // Kiro CLI/IDE: output the modified tool_input as JSON to STDOUT.
347            // Exit code 0 means "allow with modifications".
348            // The hook runner merges this back into the tool call.
349            serde_json::json!({
350                "tool_input": {
351                    "command": rewritten
352                }
353            })
354        }
355    };
356
357    serde_json::to_string(&output)
358        .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
359}
360
361const SQZ_RULES_SENTINEL: &str = "<!-- sqz-rules (auto-installed by sqz init) -->";
362const LEGACY_RULES_HEADING: &str = "# sqz \u{2014} Token-Optimized CLI Output";
363const LEGACY_CURSOR_FRONTMATTER: &str = "---\ndescription: sqz \u{2014} Token-Optimized CLI Output\n";
364const LEGACY_RULES_LAST_LINES: [&str; 3] = [
365    "path is still what you want for most output.",
366    "not on PATH, run commands normally.",
367    "If sqz is not available, run commands normally.",
368];
369
370pub fn sqz_rules_content(tool_name: &str, sqz_path: &str) -> String {
371    if tool_name == "Cline" {
372        return crate::codex_integration::agents_md_guidance_block(sqz_path);
373    }
374    let body = format!(
375        "{SQZ_RULES_SENTINEL}\n\n{}",
376        crate::codex_integration::sqz_guidance(sqz_path)
377    );
378    if tool_name == "Cursor" {
379        format!("---\ndescription: sqz output compression\nalwaysApply: true\n---\n{body}")
380    } else {
381        body
382    }
383}
384
385fn is_sqz_rules(content: &str) -> bool {
386    content.contains(SQZ_RULES_SENTINEL)
387        || content.starts_with(LEGACY_RULES_HEADING)
388        || content.replace("\r\n", "\n").starts_with(LEGACY_CURSOR_FRONTMATTER)
389}
390
391fn legacy_rules_end(content: &str) -> Option<usize> {
392    if !content.starts_with(LEGACY_RULES_HEADING) {
393        return None;
394    }
395    LEGACY_RULES_LAST_LINES.iter().find_map(|last| {
396        let mut start = 0;
397        content.split_inclusive('\n').find_map(|line| {
398            let text = line.trim_end();
399            let end = start + text.len();
400            start += line.len();
401            text.ends_with(last).then_some(end)
402        })
403    })
404}
405
406fn without_sqz_rules(content: &str) -> Option<String> {
407    match legacy_rules_end(content) {
408        Some(end) => Some(content[end..].trim_start_matches(['\r', '\n']).to_string()),
409        None => crate::codex_integration::without_guidance(content),
410    }
411}
412
413pub fn has_sqz_rules(path: &Path) -> bool {
414    std::fs::read_to_string(path)
415        .ok()
416        .and_then(|content| without_sqz_rules(&content))
417        .is_some()
418}
419
420pub fn remove_sqz_rules(path: &Path) -> Result<bool> {
421    let Ok(content) = std::fs::read_to_string(path) else {
422        return Ok(false);
423    };
424    let Some(rest) = without_sqz_rules(&content) else {
425        return Ok(false);
426    };
427    if rest.trim().is_empty() {
428        std::fs::remove_file(path).map_err(|e| {
429            crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
430        })?;
431    } else {
432        std::fs::write(path, rest)
433            .map_err(|e| crate::error::SqzError::Other(format!("write {}: {e}", path.display())))?;
434    }
435    Ok(true)
436}
437
438pub fn sqz_rules_path(project_dir: &Path, tool_name: &str) -> Option<PathBuf> {
439    match tool_name {
440        "Cursor" => Some(project_dir.join(".cursor").join("rules").join("sqz.mdc")),
441        "Windsurf" => Some(project_dir.join(".windsurf").join("rules").join("sqz.md")),
442        "Cline" => {
443            let base = project_dir.join(".clinerules");
444            Some(if base.is_dir() { base.join("sqz.md") } else { base })
445        }
446        _ => None,
447    }
448}
449
450pub fn sqz_rules_state(path: &Path, tool_name: &str) -> crate::kiro_integration::SteeringState {
451    use crate::kiro_integration::SteeringState;
452    let Ok(content) = std::fs::read_to_string(path) else {
453        return SteeringState::Missing;
454    };
455    if tool_name == "Cline" {
456        return if legacy_rules_end(&content).is_some() {
457            SteeringState::Stale
458        } else {
459            crate::codex_integration::guidance_state(&content)
460        };
461    }
462    if !is_sqz_rules(&content) {
463        return SteeringState::UserAuthored;
464    }
465    match crate::kiro_integration::embedded_sqz_path(&content) {
466        Some(p) if content.replace("\r\n", "\n") == sqz_rules_content(tool_name, p) => SteeringState::Current,
467        _ => SteeringState::Stale,
468    }
469}
470
471pub fn refresh_sqz_rules(path: &Path, tool_name: &str, fallback_sqz_path: &str) -> Result<bool> {
472    if sqz_rules_state(path, tool_name) != crate::kiro_integration::SteeringState::Stale {
473        return Ok(false);
474    }
475    let existing = std::fs::read_to_string(path)
476        .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
477    if tool_name == "Cline" && legacy_rules_end(&existing).is_none() {
478        return crate::codex_integration::refresh_agents_md_guidance(path, fallback_sqz_path);
479    }
480    let sqz_path = crate::kiro_integration::embedded_sqz_path(&existing)
481        .filter(|p| Path::new(p).is_file())
482        .unwrap_or(fallback_sqz_path)
483        .to_string();
484    install_sqz_rules(path, tool_name, &sqz_path)
485}
486
487pub fn sqz_rules_change(
488    path: &Path,
489    tool_name: &str,
490    sqz_path: &str,
491) -> crate::codex_integration::GuidanceChange {
492    use crate::codex_integration::GuidanceChange;
493    match rules_update(path, tool_name, sqz_path) {
494        None => GuidanceChange::Unchanged,
495        Some(_) if path.exists() => GuidanceChange::Replace,
496        Some(_) => GuidanceChange::Create,
497    }
498}
499
500fn rules_update(path: &Path, tool_name: &str, sqz_path: &str) -> Option<String> {
501    let content = sqz_rules_content(tool_name, sqz_path);
502    let Ok(existing) = std::fs::read_to_string(path) else {
503        return (!path.exists()).then_some(content);
504    };
505    let updated = if tool_name != "Cline" {
506        if !is_sqz_rules(&existing) {
507            return None;
508        }
509        content
510    } else if let Some(end) = legacy_rules_end(&existing) {
511        format!("{}{}", content.trim_end_matches('\n'), &existing[end..])
512    } else {
513        crate::codex_integration::replaced_guidance(&existing, sqz_path)?
514    };
515    (updated != existing).then_some(updated)
516}
517
518fn install_sqz_rules(path: &Path, tool_name: &str, sqz_path: &str) -> Result<bool> {
519    let Some(content) = rules_update(path, tool_name, sqz_path) else {
520        return Ok(false);
521    };
522    if let Some(parent) = path.parent() {
523        std::fs::create_dir_all(parent).map_err(|e| {
524            crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
525        })?;
526    }
527    std::fs::write(path, content)
528        .map_err(|e| crate::error::SqzError::Other(format!("write {}: {e}", path.display())))?;
529    Ok(true)
530}
531
532/// Generate hook configuration files for all supported AI tools.
533pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
534    // On Windows, `sqz_path` contains backslashes (C:\Users\...\sqz.exe).
535    // Embedding the raw path into JSON string values produces invalid JSON
536    // because `\` must be escaped as `\\` per RFC 8259. Same rule applies
537    // to JS/TS string literals. See issue #2.
538    //
539    // We keep TWO versions of the path:
540    //   - `sqz_path_raw` — the original, shown in markdown files the user
541    //     reads (.windsurfrules, .clinerules) where backslashes should
542    //     render as-is so the user can copy-paste the command.
543    //   - `sqz_path` — JSON/JS-escaped, used in every .json / .ts config.
544    let sqz_path_raw = sqz_path;
545    let sqz_path_json = json_escape_string_value(sqz_path);
546    let sqz_path = &sqz_path_json;
547
548    vec![
549        // Claude Code — goes in .claude/settings.local.json (nested format)
550        // Four hooks, each addressing a different concern, plus the two
551        // allow rules the PreToolUse rewrite needs:
552        //
553        //   PreToolUse:   compress shell tool output before the agent sees it
554        //                 (matcher "Bash|PowerShell" covers both Unix and
555        //                 Windows shells without firing on Read/Edit/etc.)
556        //   PostToolUse:  compress the output of a successful Bash command
557        //                 the PreToolUse hook left alone
558        //   PreCompact:   mark sqz's dedup refs stale before Claude Code
559        //                 summarises older turns. Otherwise our §ref:HASH§
560        //                 tokens would outlive the content they pointed at,
561        //                 leading to dangling refs the agent can't resolve.
562        //                 Documented by Anthropic at
563        //                 docs.anthropic.com/en/docs/claude-code/hooks-guide.
564        //   SessionStart: if the session was resumed via /compact, re-inject
565        //                 sqz's session guide (handled by `sqz resume`).
566        ToolHookConfig {
567            tool_name: "Claude Code".to_string(),
568            config_path: PathBuf::from(".claude/settings.local.json"),
569            config_content: claude_settings_content(sqz_path_raw),
570            scope: HookScope::Project,
571        },
572        // Cursor — beforeShellExecution hook can only allow/deny/ask; it
573        // does NOT support rewriting the command via `updated_input` or any
574        // other field (verified against Cursor's documented schema via
575        // GitButler's deep-dive and Cupcake's reference, plus an empirical
576        // trace against cursor-agent 2026.04.14-ee4b43a). We use the modern
577        // .cursor/rules/*.mdc format (legacy .cursorrules is deprecated per
578        // docs.cursor.com/en/context/rules). `alwaysApply: true` guarantees
579        // the rule loads for every agent interaction.
580        ToolHookConfig {
581            tool_name: "Cursor".to_string(),
582            config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
583            config_content: sqz_rules_content("Cursor", sqz_path_raw),
584            scope: HookScope::Project,
585        },
586        // Windsurf — no hook API; prompt-level rules. Current docs only
587        // document the .windsurf/rules/ directory; .windsurfrules is legacy.
588        ToolHookConfig {
589            tool_name: "Windsurf".to_string(),
590            config_path: PathBuf::from(".windsurf/rules/sqz.md"),
591            config_content: sqz_rules_content("Windsurf", sqz_path_raw),
592            scope: HookScope::Project,
593        },
594        // Cline / Roo Code — prompt-level guidance. .clinerules can be a
595        // single file or a directory; the install path handles both.
596        ToolHookConfig {
597            tool_name: "Cline".to_string(),
598            config_path: PathBuf::from(".clinerules"),
599            config_content: sqz_rules_content("Cline", sqz_path_raw),
600            scope: HookScope::Project,
601        },
602        // Gemini CLI — goes in .gemini/settings.json (BeforeTool event)
603        ToolHookConfig {
604            tool_name: "Gemini CLI".to_string(),
605            config_path: PathBuf::from(".gemini/settings.json"),
606            config_content: format!(
607                r#"{{
608  "hooks": {{
609    "BeforeTool": [
610      {{
611        "matcher": "run_shell_command",
612        "hooks": [
613          {{
614            "type": "command",
615            "command": "{sqz_path} hook gemini"
616          }}
617        ]
618      }}
619    ]
620  }}
621}}"#
622            ),
623            scope: HookScope::Project,
624        },
625        // Kiro — steering file + workspace MCP registration. Kiro's
626        // PreToolUse hooks can't rewrite tool input, so the old hook-based
627        // integration never worked in Kiro chat; the real install goes
628        // through crate::kiro_integration.
629        ToolHookConfig {
630            tool_name: "Kiro".to_string(),
631            config_path: PathBuf::from(".kiro/steering/sqz.md"),
632            config_content: crate::kiro_integration::kiro_steering_content(sqz_path_raw),
633            scope: HookScope::Project,
634        },
635        // OpenCode — TypeScript plugin at ~/.config/opencode/plugins/sqz.ts
636        // plus a config file in project root (opencode.json or
637        // opencode.jsonc). Unlike other tools, OpenCode uses a TS
638        // plugin (not JSON hooks). The `config_path` below is the
639        // fresh-install default; `install_tool_hooks` detects a
640        // pre-existing `.jsonc` and merges into it instead. The actual
641        // plugin (sqz.ts) is installed separately via
642        // `install_opencode_plugin()`.
643        ToolHookConfig {
644            tool_name: "OpenCode".to_string(),
645            config_path: PathBuf::from("opencode.json"),
646            config_content: format!(
647                r#"{{
648  "$schema": "https://opencode.ai/config.json",
649  "mcp": {{
650    "sqz": {{
651      "type": "local",
652      "command": ["sqz-mcp", "--transport", "stdio"]
653    }}
654  }},
655  "plugin": ["sqz"]
656}}"#
657            ),
658            scope: HookScope::Project,
659        },
660        // Codex (openai/codex) — no stable per-tool-call hook, only a
661        // turn-end `notify` that fires after the agent is done and can't
662        // rewrite tool output. Native integration is therefore two-part:
663        //
664        //   1. AGENTS.md at project root — prompt-level guidance telling
665        //      Codex to pipe shell output through `sqz compress`. This is
666        //      the shape Codex expects (the cross-tool AGENTS.md standard).
667        //   2. ~/.codex/config.toml user-level [mcp_servers.sqz] — Codex
668        //      merges this with any existing entries. Handled specially
669        //      in `install_tool_hooks` via `install_codex_mcp_config`.
670        //
671        // The config_content below is the AGENTS.md guidance block; it
672        // is only used as a placeholder for the (project-level) file and
673        // for surfacing the "create AGENTS.md" line in the install plan.
674        // The actual install goes through
675        // `crate::codex_integration::install_agents_md_guidance` so
676        // pre-existing AGENTS.md files are appended to, not clobbered.
677        ToolHookConfig {
678            tool_name: "Codex".to_string(),
679            config_path: PathBuf::from("AGENTS.md"),
680            config_content: crate::codex_integration::agents_md_guidance_block(
681                sqz_path_raw,
682            ),
683            scope: HookScope::Project,
684        },
685        // Zed (issue #38) has no command-rewrite hooks, so the
686        // integration is instructions + MCP registration:
687        //
688        //   1. Project guidance appended to the file Zed's Agent
689        //      actually reads (`.rules` when present — it shadows
690        //      everything — otherwise `AGENTS.md`).
691        //   2. `context_servers.sqz` merged into Zed's settings.json
692        //      (user-level), which exposes the sqz MCP tools to the
693        //      Zed Agent — including local models.
694        //
695        // The config_path/content below are placeholders for the
696        // install plan display; the real install goes through
697        // `crate::zed_integration` so existing files are appended to,
698        // not clobbered, and JSONC settings are never rewritten.
699        ToolHookConfig {
700            tool_name: "Zed".to_string(),
701            config_path: PathBuf::from("AGENTS.md"),
702            config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
703            scope: HookScope::Project,
704        },
705        // Copilot CLI — user-level hook file at ~/.copilot/hooks/sqz.json.
706        // preToolUse supports modifiedArgs (documented), so this is a full
707        // rewrite host like Claude Code. Install goes through
708        // install_copilot_hook and only runs when ~/.copilot exists.
709        ToolHookConfig {
710            tool_name: "Copilot CLI".to_string(),
711            config_path: PathBuf::from(".copilot/hooks/sqz.json"),
712            config_content: copilot_hook_content(sqz_path_raw),
713            scope: HookScope::User,
714        },
715    ]
716}
717
718/// Install hook configs for detected AI tools in the given project directory.
719///
720/// Install hook configs for detected AI tools in the given project directory.
721///
722/// Returns the list of tools that were configured.
723pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
724    install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
725}
726
727/// Where hooks should be written.
728///
729/// The Claude Code scope table (docs.claude.com/en/docs/claude-code/settings)
730/// defines four settings locations: managed, user, project, and local.
731/// `sqz init` cares about the last three:
732///
733/// * `Project` — writes `.claude/settings.local.json` (per-project, gitignored).
734///   This is what the bare `sqz init` has always done. Good for "I only
735///   want sqz active inside this repo", but a common foot-gun because the
736///   user expects it to work everywhere and then sees "caching nothing"
737///   in every other project. Reported by 76vangel.
738///
739/// * `Global` — writes `~/.claude/settings.json` (user scope, applies to
740///   every Claude Code session on this machine regardless of cwd).
741///   This is what most users actually want on first install. Verified
742///   against the official Anthropic scope table.
743///
744/// Precedence in Claude Code (highest to lowest): managed > local > project > user.
745/// That means a project-level install can still override a global one —
746/// and a user with `.claude/settings.local.json` in their worktree will
747/// silently shadow the global setting. We do NOT auto-delete the local
748/// file; the uninstall flow is responsible for whichever scope was asked for.
749#[derive(Debug, Clone, Copy, PartialEq, Eq)]
750pub enum InstallScope {
751    /// Project-local (gitignored): `.claude/settings.local.json`, `.cursor/rules/`,
752    /// etc. under `project_dir`.
753    Project,
754    /// User-level: `~/.claude/settings.json` and similar home-directory paths.
755    /// Applies to every project on this machine.
756    Global,
757}
758
759/// Which tools `sqz init` should configure.
760///
761/// By default sqz init writes hook configs for every supported tool
762/// (Claude Code, Cursor, Windsurf, Cline, Gemini CLI, OpenCode, Codex,
763/// Zed). Users who only use one agent have asked (issue #11,
764/// @shochdoerfer) for a way to say "just OpenCode, please, leave the
765/// rest alone." This filter is the plumbing for that.
766///
767/// Matching is by canonical tool name. The [`canonicalize_tool_name`]
768/// helper normalises user input (lowercase, hyphens/underscores/spaces
769/// collapsed, known aliases) so `Opencode`, `open-code`, `opencode`,
770/// `OPENCODE` all refer to the same tool.
771#[derive(Debug, Clone, PartialEq, Eq)]
772pub enum ToolFilter {
773    /// Install hook configs for every supported tool. The historical
774    /// default of `sqz init`.
775    All,
776    /// Install hook configs only for the named tools. Unknown names are
777    /// surfaced to the caller as errors by the canonicalisation layer
778    /// so we don't silently ignore typos.
779    Only(Vec<String>),
780    /// Install hook configs for every supported tool EXCEPT the named
781    /// tools. Useful when the user is fine with everything but wants
782    /// one integration skipped (e.g. a project shared with collaborators
783    /// who don't want a `.windsurfrules` file in the repo).
784    Skip(Vec<String>),
785}
786
787impl Default for ToolFilter {
788    fn default() -> Self {
789        ToolFilter::All
790    }
791}
792
793impl ToolFilter {
794    /// Return `true` if `tool_name` (as produced by [`generate_hook_configs`])
795    /// should be installed under this filter.
796    ///
797    /// `tool_name` is the display name sqz uses internally:
798    ///   * `"Claude Code"`, `"Cursor"`, `"Windsurf"`, `"Cline"`,
799    ///     `"Gemini CLI"`, `"OpenCode"`, `"Codex"`.
800    ///
801    /// The caller is expected to have already canonicalised the filter
802    /// entries via [`canonicalize_tool_name`] so the strings line up
803    /// case- and alias-wise.
804    pub fn includes(&self, tool_name: &str) -> bool {
805        let canon = canonicalize_tool_name(tool_name);
806        match self {
807            ToolFilter::All => true,
808            ToolFilter::Only(allow) => allow.iter().any(|n| {
809                // `allow` entries have already been canonicalised by
810                // parse_tool_list; compare canonical to canonical.
811                n == &canon
812            }),
813            ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
814        }
815    }
816}
817
818/// Every canonical tool name sqz knows about, in the same order
819/// [`generate_hook_configs`] emits them. Used by the CLI to list valid
820/// options in `--only`/`--skip` error messages, and by tests that
821/// need to enumerate the supported set.
822pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
823    "Claude Code",
824    "Cursor",
825    "Windsurf",
826    "Cline",
827    "Gemini CLI",
828    "Kiro",
829    "OpenCode",
830    "Codex",
831    "Zed",
832    "Copilot CLI",
833];
834
835/// How sqz reaches a client's tool output.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum IntegrationTier {
838    /// A hook rewrites shell tool calls before they run, so output is
839    /// compressed without the agent's help.
840    Transparent,
841    /// sqz can only ask: guidance files plus MCP tools the agent has to
842    /// choose to call. The client's built-in shell and read tools bypass it.
843    Advisory,
844}
845
846/// Which tier sqz's integration for `tool_name` falls in. Keep in step
847/// with what [`generate_hook_configs`] actually installs.
848pub fn integration_tier(tool_name: &str) -> IntegrationTier {
849    match canonicalize_tool_name(tool_name).as_str() {
850        "claudecode" | "copilot" | "gemini" | "opencode" => IntegrationTier::Transparent,
851        _ => IntegrationTier::Advisory,
852    }
853}
854
855/// Path of the user-level Copilot CLI hooks file sqz manages.
856/// Honors `COPILOT_HOME` the same way the CLI does.
857pub fn copilot_hooks_path() -> PathBuf {
858    copilot_home().join("hooks").join("sqz.json")
859}
860
861fn copilot_home() -> PathBuf {
862    if let Ok(home) = std::env::var("COPILOT_HOME") {
863        if !home.trim().is_empty() {
864            return PathBuf::from(home);
865        }
866    }
867    let home = std::env::var("HOME")
868        .or_else(|_| std::env::var("USERPROFILE"))
869        .map(PathBuf::from)
870        .unwrap_or_else(|_| PathBuf::from("."));
871    home.join(".copilot")
872}
873
874/// Path of the repo-level CI hook file, read by both local Copilot CLI
875/// sessions in this repo and Copilot cloud agent jobs (the sandbox loads
876/// only `.github/hooks/*.json` from the clone).
877pub fn ci_hooks_path(project_dir: &Path) -> PathBuf {
878    project_dir.join(".github").join("hooks").join("sqz.json")
879}
880
881/// CI hook config for Copilot cloud agent + repo-scoped Copilot CLI.
882///
883/// The cloud agent sandbox has no sqz preinstalled and restricts outbound
884/// network to GitHub hosts — which is exactly where sqz's install script
885/// and release binaries live, so a sessionStart hook can bootstrap it.
886/// The preToolUse hook is fail-closed on non-zero exit in Copilot, so
887/// every path ends in `exit 0` with either a decision or `{}` (fall
888/// through) — a missing binary must never block the tool call.
889pub fn ci_hook_content() -> String {
890    r#"{
891  "version": 1,
892  "hooks": {
893    "sessionStart": [
894      {
895        "type": "command",
896        "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",
897        "timeoutSec": 120
898      }
899    ],
900    "preToolUse": [
901      {
902        "type": "command",
903        "matcher": "bash|powershell",
904        "bash": "SQZ_BIN=$(command -v sqz || echo $HOME/.local/bin/sqz); [ -x \"$SQZ_BIN\" ] && \"$SQZ_BIN\" hook copilot 2>/dev/null || echo '{}'",
905        "timeoutSec": 10
906      }
907    ]
908  }
909}
910"#
911    .to_string()
912}
913
914/// Install the repo-level CI hook file. Skip-if-exists unless the existing
915/// file is sqz-authored and stale.
916pub fn install_ci_hook(project_dir: &Path) -> Result<bool> {
917    let path = ci_hooks_path(project_dir);
918    let content = ci_hook_content();
919    if path.exists() {
920        let existing = std::fs::read_to_string(&path).unwrap_or_default();
921        if existing == content || !existing.contains("hook copilot") {
922            return Ok(false);
923        }
924    }
925    if let Some(parent) = path.parent() {
926        std::fs::create_dir_all(parent).map_err(|e| {
927            crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
928        })?;
929    }
930    std::fs::write(&path, content).map_err(|e| {
931        crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
932    })?;
933    Ok(true)
934}
935
936/// Remove the CI hook file when sqz-authored.
937pub fn remove_ci_hook(project_dir: &Path) -> Result<bool> {
938    let path = ci_hooks_path(project_dir);
939    if !path.exists() {
940        return Ok(false);
941    }
942    let content = std::fs::read_to_string(&path).unwrap_or_default();
943    if !content.contains("hook copilot") {
944        return Ok(false);
945    }
946    std::fs::remove_file(&path).map_err(|e| {
947        crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
948    })?;
949    Ok(true)
950}
951
952/// The Copilot CLI hook config sqz installs (v1 camelCase format).
953pub fn copilot_hook_content(sqz_path: &str) -> String {
954    let escaped = json_escape_string_value(sqz_path);
955    format!(
956        r#"{{
957  "version": 1,
958  "hooks": {{
959    "preToolUse": [
960      {{
961        "type": "command",
962        "matcher": "bash|powershell",
963        "bash": "{escaped} hook copilot",
964        "powershell": "{escaped} hook copilot",
965        "timeoutSec": 10
966      }}
967    ]
968  }}
969}}
970"#
971    )
972}
973
974/// Install the Copilot CLI hook file. Returns `Ok(None)` when Copilot CLI
975/// is not installed (no `~/.copilot` directory), `Ok(Some(changed))`
976/// otherwise. Refreshes the file in place when the content is stale.
977pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
978    install_copilot_hook_at(&copilot_home(), sqz_path)
979}
980
981fn install_copilot_hook_at(home: &Path, sqz_path: &str) -> Result<Option<bool>> {
982    if !home.exists() {
983        return Ok(None);
984    }
985    let path = home.join("hooks").join("sqz.json");
986    let content = copilot_hook_content(sqz_path);
987    if path.exists() {
988        let existing = std::fs::read_to_string(&path).unwrap_or_default();
989        if existing == content {
990            return Ok(Some(false));
991        }
992    }
993    if let Some(parent) = path.parent() {
994        std::fs::create_dir_all(parent).map_err(|e| {
995            crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
996        })?;
997    }
998    std::fs::write(&path, content).map_err(|e| {
999        crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
1000    })?;
1001    Ok(Some(true))
1002}
1003
1004/// Remove the sqz-managed Copilot hook file if present.
1005pub fn remove_copilot_hook() -> Result<bool> {
1006    let path = copilot_hooks_path();
1007    if !path.exists() {
1008        return Ok(false);
1009    }
1010    std::fs::remove_file(&path).map_err(|e| {
1011        crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
1012    })?;
1013    Ok(true)
1014}
1015
1016/// Normalise a tool name or alias to its canonical form.
1017///
1018/// Canonical forms are lowercase and hyphen-free. Accepts common
1019/// variants:
1020///
1021/// | Input                                  | Canonical      |
1022/// |----------------------------------------|----------------|
1023/// | `Claude Code`, `claude-code`, `claude` | `claudecode`   |
1024/// | `Cursor`, `cursor`                     | `cursor`       |
1025/// | `Windsurf`, `windsurf`                 | `windsurf`     |
1026/// | `Cline`, `roo`, `roo-code`, `roocode`  | `cline`        |
1027/// | `Gemini CLI`, `gemini-cli`, `gemini`   | `gemini`       |
1028/// | `OpenCode`, `opencode`                 | `opencode`     |
1029/// | `Codex`, `codex`                       | `codex`        |
1030///
1031/// Returns the canonical string unchanged if no alias matches — the
1032/// caller decides whether unknown names are an error. This function
1033/// never fails.
1034pub fn canonicalize_tool_name(name: &str) -> String {
1035    let lowered: String = name
1036        .chars()
1037        .filter(|c| !c.is_whitespace())
1038        .flat_map(|c| c.to_lowercase())
1039        .filter(|c| *c != '-' && *c != '_')
1040        .collect();
1041    match lowered.as_str() {
1042        "claude" | "claudecode" => "claudecode".to_string(),
1043        "cursor" => "cursor".to_string(),
1044        "windsurf" => "windsurf".to_string(),
1045        // Cline is also sold as "Roo Code" — treat the two as one
1046        // integration because that's what sqz actually targets (same
1047        // .clinerules file).
1048        "cline" | "roo" | "roocode" => "cline".to_string(),
1049        "gemini" | "geminicli" => "gemini".to_string(),
1050        "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
1051        "opencode" => "opencode".to_string(),
1052        "codex" => "codex".to_string(),
1053        "zed" | "zededitor" | "zedagent" => "zed".to_string(),
1054        "copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
1055        other => other.to_string(),
1056    }
1057}
1058
1059/// Parse a user-supplied tool list (comma-separated, whitespace-tolerant)
1060/// into a vector of canonical names.
1061///
1062/// Returns an error if any entry does not match a known tool — we never
1063/// silently drop typos because the failure mode ("my filter didn't
1064/// work") is hard to debug.
1065///
1066/// The error message lists every accepted name so the user can see
1067/// exactly what's valid.
1068pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
1069    let mut out = Vec::new();
1070    let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
1071        .iter()
1072        .map(|n| canonicalize_tool_name(n))
1073        .collect();
1074    for part in raw.split(',') {
1075        let trimmed = part.trim();
1076        if trimmed.is_empty() {
1077            continue;
1078        }
1079        let canon = canonicalize_tool_name(trimmed);
1080        if !known.contains(&canon) {
1081            let valid: Vec<String> = SUPPORTED_TOOL_NAMES
1082                .iter()
1083                .map(|n| canonicalize_tool_name(n))
1084                .collect();
1085            return Err(crate::error::SqzError::Other(format!(
1086                "unknown agent name '{}'. Valid options: {}",
1087                trimmed,
1088                valid.join(", ")
1089            )));
1090        }
1091        if !out.contains(&canon) {
1092            out.push(canon);
1093        }
1094    }
1095    Ok(out)
1096}
1097
1098/// Like [`install_tool_hooks`] but lets the caller choose between
1099/// project-local and user-global scope. This is the function `sqz init`
1100/// and `sqz init --global` both call.
1101///
1102/// For `InstallScope::Global`:
1103///
1104/// * Claude Code hook is merged into `~/.claude/settings.json` (the user
1105///   settings file). We merge rather than overwrite because the user may
1106///   already have permissions, env, statusLine, or other hooks there —
1107///   blindly writing would nuke their config. Any existing sqz hook
1108///   entries are replaced in place; unrelated fields are preserved.
1109///
1110/// * Cursor, Windsurf, Cline, Gemini CLI rules files don't have a
1111///   user-level equivalent that Cursor/etc. actually load. We keep those
1112///   at project scope and note it in the plan. Users who want Cursor
1113///   compressed across all projects should follow the Cursor docs
1114///   (docs.cursor.com/en/context/rules) and add the rule at user scope
1115///   manually — Cursor honours ~/.cursor/rules/*.mdc but only within
1116///   workspaces that opt in.
1117///
1118/// * OpenCode plugin is already user-level by design (lives at
1119///   `~/.config/opencode/plugins/sqz.ts`), so scope doesn't matter here.
1120///
1121/// * Codex MCP config is always user-level (`~/.codex/config.toml`).
1122///   AGENTS.md stays per-project because that's where it belongs.
1123pub fn install_tool_hooks_scoped(
1124    project_dir: &Path,
1125    sqz_path: &str,
1126    scope: InstallScope,
1127) -> Vec<String> {
1128    install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
1129}
1130
1131/// Like [`install_tool_hooks_scoped`] but honours a [`ToolFilter`] so
1132/// callers can restrict `sqz init` to a subset of the supported tools.
1133///
1134/// The filter applies to hook-config writes AND to the OpenCode
1135/// TypeScript plugin at `~/.config/opencode/plugins/sqz.ts` — we only
1136/// install the plugin file when OpenCode passes the filter. Writing
1137/// the plugin file to a machine where the user filtered OpenCode out
1138/// would be surprising (they'd see sqz fire next time they opened
1139/// OpenCode even though they never asked for it).
1140///
1141/// Shell hook (rc file) and the default preset are NOT gated by this
1142/// filter — they're user-scoped and not specific to any agent.
1143/// `cmd_init` handles those separately.
1144pub fn install_tool_hooks_scoped_filtered(
1145    project_dir: &Path,
1146    sqz_path: &str,
1147    scope: InstallScope,
1148    filter: &ToolFilter,
1149) -> Vec<String> {
1150    install_tool_hooks_at(project_dir, sqz_path, scope, filter, None)
1151}
1152
1153/// [`install_tool_hooks_scoped_filtered`] with every user-level root under `home_override`.
1154fn install_tool_hooks_at(
1155    project_dir: &Path,
1156    sqz_path: &str,
1157    scope: InstallScope,
1158    filter: &ToolFilter,
1159    home_override: Option<&Path>,
1160) -> Vec<String> {
1161    let configs = generate_hook_configs(sqz_path);
1162    let mut installed = Vec::new();
1163    let kiro_root =
1164        home_override.map_or_else(crate::kiro_integration::kiro_user_root, |h| h.join(".kiro"));
1165
1166    for config in &configs {
1167        // Apply the user's agent filter before we touch anything.
1168        // Each tool the filter rejects is completely skipped — no
1169        // plan lines, no files written, no logging.
1170        if !filter.includes(&config.tool_name) {
1171            continue;
1172        }
1173
1174        // OpenCode config files are special: they live alongside the
1175        // user's own config and must be *merged* rather than clobbered.
1176        // The placeholder `config_content` is only used on a fresh
1177        // install; `update_opencode_config_detailed` handles both the
1178        // create-new and merge-into-existing cases, AND picks the
1179        // right file extension (opencode.jsonc vs opencode.json) —
1180        // fixes issue #6 where the old write-if-missing logic created
1181        // a parallel `opencode.json` next to an existing `.jsonc`.
1182        if config.tool_name == "OpenCode" {
1183            match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
1184                Ok((updated, _comments_lost)) => {
1185                    if updated && !installed.iter().any(|n| n == "OpenCode") {
1186                        installed.push("OpenCode".to_string());
1187                    }
1188                }
1189                Err(_e) => {
1190                    // Non-fatal — leave OpenCode out of the installed
1191                    // list and continue with other tools.
1192                }
1193            }
1194            continue;
1195        }
1196
1197        // Codex has the same merge-not-clobber concern on two fronts:
1198        // the project-level AGENTS.md (may contain unrelated user
1199        // content) and the USER-level ~/.codex/config.toml (may contain
1200        // other MCP servers). Both go through the surgical helpers.
1201        if config.tool_name == "Codex" {
1202            let agents_changed = crate::codex_integration::install_agents_md_guidance(
1203                project_dir, sqz_path,
1204            )
1205            .unwrap_or(false);
1206            let mcp_changed = crate::codex_integration::install_codex_mcp_config_at(
1207                home_override.map(|h| h.join(".codex")).as_deref(),
1208            )
1209            .unwrap_or(false);
1210            if (agents_changed || mcp_changed)
1211                && !installed.iter().any(|n| n == "Codex")
1212            {
1213                installed.push("Codex".to_string());
1214            }
1215            continue;
1216        }
1217
1218        // Windsurf: current docs only document .windsurf/rules/; migrate a
1219        // legacy sqz-authored .windsurfrules to the new location.
1220        if config.tool_name == "Windsurf" {
1221            let legacy = project_dir.join(".windsurfrules");
1222            let target = project_dir.join(&config.config_path);
1223            let mut changed = install_sqz_rules(&target, "Windsurf", sqz_path).unwrap_or(false);
1224            if target.exists() && remove_sqz_rules(&legacy).unwrap_or(false) {
1225                changed = true;
1226            }
1227            if changed && !installed.iter().any(|n| n == "Windsurf") {
1228                installed.push("Windsurf".to_string());
1229            }
1230            continue;
1231        }
1232
1233        if config.tool_name == "Cursor" {
1234            let target = project_dir.join(&config.config_path);
1235            if install_sqz_rules(&target, "Cursor", sqz_path).unwrap_or(false)
1236                && !installed.iter().any(|n| n == "Cursor")
1237            {
1238                installed.push("Cursor".to_string());
1239            }
1240            continue;
1241        }
1242
1243        // Cline: .clinerules can be a single file or a directory of md
1244        // files; when it's a directory, install .clinerules/sqz.md.
1245        if config.tool_name == "Cline" {
1246            let Some(target) = sqz_rules_path(project_dir, "Cline") else {
1247                continue;
1248            };
1249            if install_sqz_rules(&target, "Cline", sqz_path).unwrap_or(false)
1250                && !installed.iter().any(|n| n == "Cline")
1251            {
1252                installed.push("Cline".to_string());
1253            }
1254            continue;
1255        }
1256
1257        // Copilot CLI: user-level hook file, only when Copilot is installed.
1258        if config.tool_name == "Copilot CLI" {
1259            let copilot_root = home_override.map_or_else(copilot_home, |h| h.join(".copilot"));
1260            if let Ok(Some(changed)) = install_copilot_hook_at(&copilot_root, sqz_path) {
1261                if changed && !installed.iter().any(|n| n == "Copilot CLI") {
1262                    installed.push("Copilot CLI".to_string());
1263                }
1264            }
1265            continue;
1266        }
1267
1268        // Kiro: steering file + MCP config, plus cleanup of the legacy
1269        // hook file that Kiro never recognized. At global scope both go
1270        // to the user level (~/.kiro), which Kiro applies to every
1271        // workspace; at project scope they stay in the workspace.
1272        if config.tool_name == "Kiro" {
1273            let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
1274            let user_steering = kiro_root.join("steering").join("sqz.md");
1275            let (steering_changed, mcp_changed) = if scope == InstallScope::Global {
1276                let steering = crate::kiro_integration::install_kiro_steering_at(
1277                    &user_steering,
1278                    &crate::kiro_integration::kiro_user_steering_content(sqz_path),
1279                )
1280                .unwrap_or(false);
1281                let mcp = matches!(
1282                    crate::kiro_integration::install_kiro_mcp_config_at(
1283                        &kiro_root.join("settings").join("mcp.json")
1284                    ),
1285                    Ok(crate::kiro_integration::KiroMcpInstall::Created)
1286                        | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1287                );
1288                (steering, mcp)
1289            } else {
1290                // The user-level copy already reaches this workspace.
1291                let user_installed = matches!(
1292                    crate::kiro_integration::kiro_steering_state(&user_steering, true),
1293                    crate::kiro_integration::SteeringState::Current
1294                        | crate::kiro_integration::SteeringState::Stale
1295                );
1296                let steering = !user_installed
1297                    && crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
1298                        .unwrap_or(false);
1299                let mcp = matches!(
1300                    crate::kiro_integration::install_kiro_mcp_config(project_dir),
1301                    Ok(crate::kiro_integration::KiroMcpInstall::Created)
1302                        | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1303                );
1304                (steering, mcp)
1305            };
1306            if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
1307                installed.push("Kiro".to_string());
1308            }
1309            continue;
1310        }
1311
1312        // Zed (issue #38): guidance into the instruction file Zed
1313        // actually reads, plus context_servers.sqz in the user-level
1314        // settings.json. Both are surgical; JSONC settings are never
1315        // rewritten (the CLI surfaces manual instructions instead).
1316        if config.tool_name == "Zed" {
1317            let guidance_changed =
1318                crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
1319                    .unwrap_or(false);
1320            let zed_settings = home_override.map_or_else(
1321                crate::zed_integration::zed_settings_path,
1322                |h| h.join(".config").join("zed").join("settings.json"),
1323            );
1324            let mcp_changed = matches!(
1325                crate::zed_integration::install_zed_mcp_config_at(&zed_settings),
1326                Ok(crate::zed_integration::ZedMcpInstall::Created)
1327                    | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1328            );
1329            if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1330                installed.push("Zed".to_string());
1331            }
1332            continue;
1333        }
1334
1335        // Claude Code at global scope: merge into ~/.claude/settings.json
1336        // instead of writing a fresh .claude/settings.local.json in cwd.
1337        // This is the fix for "sqz init does nothing outside the project
1338        // I ran it in" — reported by 76vangel.
1339        //
1340        // Also triggers the issue #12 companion installs (CLAUDE.md
1341        // guidance + ~/.claude.json MCP server registration) since those
1342        // belong to Claude Code conceptually — if you're installing the
1343        // hook, you want the guidance and MCP wiring too.
1344        if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1345            let hook_installed = match install_claude_global_at(sqz_path, home_override) {
1346                Ok(v) => v,
1347                Err(_) => false,
1348            };
1349            let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1350                project_dir, sqz_path,
1351            )
1352            .unwrap_or(false);
1353            let mcp_changed =
1354                crate::claude_md_integration::install_claude_mcp_config_at(home_override)
1355                    .unwrap_or(false);
1356            if (hook_installed || md_changed || mcp_changed)
1357                && !installed.iter().any(|n| n == "Claude Code")
1358            {
1359                installed.push("Claude Code".to_string());
1360            }
1361            continue;
1362        }
1363
1364        // User-level hooks already run here; a project copy would run them twice.
1365        if config.tool_name == "Claude Code"
1366            && home_override
1367                .map(|h| h.join(".claude").join("settings.json"))
1368                .or_else(claude_user_settings_path)
1369                .is_some_and(|p| claude_settings_have_sqz_hooks(&p))
1370        {
1371            let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1372                project_dir, sqz_path,
1373            )
1374            .unwrap_or(false);
1375            let mcp_changed =
1376                crate::claude_md_integration::install_claude_mcp_config_at(home_override)
1377                    .unwrap_or(false);
1378            if (md_changed || mcp_changed) && !installed.iter().any(|n| n == "Claude Code") {
1379                installed.push("Claude Code".to_string());
1380            }
1381            continue;
1382        }
1383
1384        let full_path = project_dir.join(&config.config_path);
1385
1386        // Don't overwrite existing hook configs
1387        if full_path.exists() {
1388            // Project-scope Claude Code file already exists. Don't skip:
1389            // merge our entries in, replacing any stale sqz entries. This
1390            // is what upgrades a pre-issue-#26 `"matcher": "Bash"` install
1391            // to `"Bash|PowerShell"` when the user re-runs `sqz init` —
1392            // with the old skip-if-exists behavior, Windows installs kept
1393            // the stale matcher forever and PowerShell tool calls never
1394            // reached the hook. User keys (permissions, env, other hooks)
1395            // are preserved; only sqz's own entries are replaced.
1396            if config.tool_name == "Claude Code" {
1397                let hook_changed =
1398                    merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1399                let md_changed =
1400                    crate::claude_md_integration::install_claude_md_guidance(
1401                        project_dir, sqz_path,
1402                    )
1403                    .unwrap_or(false);
1404                let mcp_changed =
1405                    crate::claude_md_integration::install_claude_mcp_config_at(home_override)
1406                        .unwrap_or(false);
1407                if (hook_changed || md_changed || mcp_changed)
1408                    && !installed.iter().any(|n| n == "Claude Code")
1409                {
1410                    installed.push("Claude Code".to_string());
1411                }
1412            }
1413            if config.tool_name == "Gemini CLI"
1414                && merge_sqz_gemini_hook(&full_path, sqz_path).unwrap_or(false)
1415            {
1416                installed.push(config.tool_name.clone());
1417            }
1418            continue;
1419        }
1420
1421        // Create parent directories
1422        if let Some(parent) = full_path.parent() {
1423            if std::fs::create_dir_all(parent).is_err() {
1424                continue;
1425            }
1426        }
1427
1428        if std::fs::write(&full_path, &config.config_content).is_ok() {
1429            installed.push(config.tool_name.clone());
1430            // Claude Code at project scope: also install the issue #12
1431            // companion artifacts (CLAUDE.md guidance + ~/.claude.json
1432            // MCP registration). The agent needs all three pieces to
1433            // actually use sqz — the hook catches Bash, the MCP server
1434            // provides sqz_read_file/grep/list_dir, and the CLAUDE.md
1435            // tells it when to pick which.
1436            if config.tool_name == "Claude Code" {
1437                let _ = crate::claude_md_integration::install_claude_md_guidance(
1438                    project_dir, sqz_path,
1439                );
1440                let _ = crate::claude_md_integration::install_claude_mcp_config_at(home_override);
1441            }
1442        }
1443    }
1444
1445    // Also install the OpenCode TypeScript plugin (user-level). The
1446    // config merge above has already put OpenCode in `installed` if it
1447    // wrote anything, so this call only matters for machines where no
1448    // project config existed — we still want the user-level plugin so
1449    // future OpenCode sessions see sqz.
1450    //
1451    // Gated by the filter: if the user ran `sqz init --skip opencode`
1452    // we must NOT drop the plugin file in `~/.config/opencode/plugins/`.
1453    // Leaving it there would surprise them on their next OpenCode run
1454    // (sqz would start firing uninvited, reverting the skip).
1455    if filter.includes("OpenCode") {
1456        let plugin = home_override.map_or_else(
1457            crate::opencode_plugin::opencode_plugin_path,
1458            |h| h.join(".config").join("opencode").join("plugins").join("sqz.ts"),
1459        );
1460        if let Ok(true) = crate::opencode_plugin::install_opencode_plugin_at(&plugin, sqz_path) {
1461            if !installed.iter().any(|n| n == "OpenCode") {
1462                installed.push("OpenCode".to_string());
1463            }
1464        }
1465    }
1466
1467    installed
1468}
1469
1470// ── Claude Code user-scope hook install ──────────────────────────────────
1471
1472/// Resolve `~/.claude/settings.json` for the current user.
1473///
1474/// This is the "User" scope file per the Anthropic scope table
1475/// (docs.claude.com/en/docs/claude-code/settings). Applies to every
1476/// Claude Code session on this machine regardless of cwd.
1477///
1478/// Precedence: Managed > Local (`.claude/settings.local.json`) >
1479/// Project (`.claude/settings.json`) > User (this file). Users with a
1480/// local settings file in a worktree can still override the global
1481/// sqz hook — that's intended.
1482pub fn claude_user_settings_path() -> Option<PathBuf> {
1483    dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1484}
1485
1486/// Merge sqz's PreToolUse / PreCompact / SessionStart hook entries
1487/// into `~/.claude/settings.json`.
1488///
1489/// * Creates the file if missing, with just our hooks.
1490/// * If the file exists, parses it as JSON, replaces any existing sqz
1491///   entries (matched by `command` containing `sqz hook` / `sqz resume` /
1492///   `sqz hook precompact`), and inserts ours. Everything else — the
1493///   user's permissions, env, statusLine, other PreToolUse matchers —
1494///   stays untouched.
1495/// * Writes atomically (temp file + rename) so a crash halfway through
1496///   can't leave the user with a corrupted settings.json.
1497///
1498/// Returns `Ok(true)` if the file was created or changed, `Ok(false)`
1499/// if our hook entries were already present identically.
1500///
1501/// `home_override` replaces the home directory in tests. Avoids
1502/// `std::env::set_var("HOME")` which races with parallel tests that
1503/// also read HOME (e.g. the api_proxy property tests that open
1504/// `~/.sqz/sessions.db`).
1505fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1506    let path = match home_override {
1507        Some(h) => h.join(".claude").join("settings.json"),
1508        None => claude_user_settings_path().ok_or_else(|| {
1509            crate::error::SqzError::Other(
1510                "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1511            )
1512        })?,
1513    };
1514    merge_sqz_hooks_into_settings(&path, sqz_path)
1515}
1516
1517/// Dry-run probe for `sqz init`'s plan phase: would merging sqz's hook
1518/// entries change the Claude Code settings file at `path`?
1519///
1520/// Returns `Ok(true)` when the file is missing, or exists with stale or
1521/// absent sqz entries (e.g. the pre-issue-#26 `"Bash"`-only matcher, or
1522/// an outdated binary path). Returns `Ok(false)` when our entries are
1523/// already present identically. Errors on unparseable files — same as
1524/// the real merge, which refuses to touch those.
1525///
1526/// Without this probe the CLI's plan phase treated any existing file as
1527/// "nothing to do" and exited before the installer ran, so the healing
1528/// merge below was unreachable in practice.
1529pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1530    if !path.exists() {
1531        return Ok(true);
1532    }
1533    let content = std::fs::read_to_string(path).map_err(|e| {
1534        crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1535    })?;
1536    let mut root: serde_json::Value = if content.trim().is_empty() {
1537        serde_json::Value::Object(serde_json::Map::new())
1538    } else {
1539        serde_json::from_str(&content).map_err(|e| {
1540            crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1541        })?
1542    };
1543    let Some(root_obj) = root.as_object_mut() else {
1544        return Err(crate::error::SqzError::Other(format!(
1545            "{} is not a JSON object",
1546            path.display()
1547        )));
1548    };
1549
1550    let before = serde_json::to_string(&root_obj).unwrap_or_default();
1551    let hooks = root_obj
1552        .entry("hooks".to_string())
1553        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1554    let Some(hooks_obj) = hooks.as_object_mut() else {
1555        return Ok(true);
1556    };
1557    for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1558        upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1559    }
1560    upsert_claude_allow_rules(root_obj);
1561    let after = serde_json::to_string(&root_obj).unwrap_or_default();
1562    Ok(before != after)
1563}
1564
1565/// A fresh Claude Code settings file holding only sqz's hooks and rules.
1566fn claude_settings_content(sqz_path: &str) -> String {
1567    let mut hooks = serde_json::Map::new();
1568    for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1569        upsert_sqz_hook_entry(&mut hooks, event, entry, sentinel);
1570    }
1571    let mut root = serde_json::Map::new();
1572    root.insert("hooks".to_string(), serde_json::Value::Object(hooks));
1573    upsert_claude_allow_rules(&mut root);
1574    serde_json::to_string_pretty(&serde_json::Value::Object(root))
1575        .expect("a map of strings always serializes")
1576}
1577
1578/// The hook entries sqz installs into Claude Code settings files, as
1579/// `(event_name, entry_json, sentinel)` tuples. Single source of truth for
1580/// the real merge, the dry-run probe and uninstall.
1581fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 4] {
1582    [
1583        (
1584            "PreToolUse",
1585            serde_json::json!({
1586                "matcher": "Bash|PowerShell",
1587                "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1588            }),
1589            "hook claude",
1590        ),
1591        (
1592            "PostToolUse",
1593            serde_json::json!({
1594                "matcher": "Bash",
1595                "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude-post") }]
1596            }),
1597            "hook claude-post",
1598        ),
1599        (
1600            "PreCompact",
1601            serde_json::json!({
1602                "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1603            }),
1604            "hook precompact",
1605        ),
1606        (
1607            "SessionStart",
1608            serde_json::json!({
1609                "matcher": "compact",
1610                "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1611            }),
1612            "resume",
1613        ),
1614    ]
1615}
1616
1617/// Merge sqz's three hook entries into a Claude Code settings JSON at
1618/// `path` — either `~/.claude/settings.json` (global scope) or a
1619/// project's `.claude/settings.local.json`. Shared by both install
1620/// scopes so re-running `sqz init` upgrades stale sqz entries (an old
1621/// `"Bash"`-only matcher, an outdated binary path) at project scope
1622/// too, instead of skipping any file that already exists. That skip is
1623/// how Windows installs from before the issue #26 fix stayed broken:
1624/// their project settings kept the pre-PowerShell matcher forever.
1625fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1626    // Parse the existing file, or start from an empty object.
1627    let mut content = String::new();
1628    let mut root: serde_json::Value = if path.exists() {
1629        content = std::fs::read_to_string(&path).map_err(|e| {
1630            crate::error::SqzError::Other(format!(
1631                "read {}: {e}",
1632                path.display()
1633            ))
1634        })?;
1635        if content.trim().is_empty() {
1636            serde_json::Value::Object(serde_json::Map::new())
1637        } else {
1638            serde_json::from_str(&content).map_err(|e| {
1639                crate::error::SqzError::Other(format!(
1640                    "parse {}: {e} — please fix or move the file before re-running sqz init",
1641                    path.display()
1642                ))
1643            })?
1644        }
1645    } else {
1646        serde_json::Value::Object(serde_json::Map::new())
1647    };
1648
1649    // Ensure root is an object (users occasionally have arrays or
1650    // corrupted files; we refuse to touch those).
1651    let root_obj = root.as_object_mut().ok_or_else(|| {
1652        crate::error::SqzError::Other(format!(
1653            "{} is not a JSON object — refusing to overwrite",
1654            path.display()
1655        ))
1656    })?;
1657
1658    // Snapshot the "before" state for change detection.
1659    let before = serde_json::to_string(&root_obj).unwrap_or_default();
1660
1661    // Get or create the top-level "hooks" object.
1662    let hooks = root_obj
1663        .entry("hooks".to_string())
1664        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1665    let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1666        crate::error::SqzError::Other(format!(
1667            "{}: `hooks` is not an object — refusing to overwrite",
1668            path.display()
1669        ))
1670    })?;
1671
1672    for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1673        upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1674    }
1675    upsert_claude_allow_rules(root_obj);
1676
1677    let after = serde_json::to_string(&root_obj).unwrap_or_default();
1678    if before == after && path.exists() {
1679        // Already present and unchanged — no write needed.
1680        return Ok(false);
1681    }
1682
1683    // Ensure parent directory exists.
1684    if let Some(parent) = path.parent() {
1685        std::fs::create_dir_all(parent).map_err(|e| {
1686            crate::error::SqzError::Other(format!(
1687                "create {}: {e}",
1688                parent.display()
1689            ))
1690        })?;
1691    }
1692
1693    // Atomic write: tempfile in same directory + rename. Keeps the old
1694    // file intact if serialization or write fails halfway.
1695    write_json_config(path, &serde_json::Value::Object(root_obj.clone()), &content)?;
1696
1697    Ok(true)
1698}
1699
1700/// Remove sqz's hook entries from `~/.claude/settings.json` without
1701/// touching any other keys. Symmetric with `install_claude_global_at`.
1702///
1703/// Returns:
1704/// * `Ok(Some((path, true)))` — file existed, sqz entries found and
1705///   stripped. If the resulting `hooks` object is empty, we also remove
1706///   the `hooks` key entirely. If the resulting root object is empty,
1707///   we remove the file — matches the uninstall UX of every other sqz
1708///   surface.
1709/// * `Ok(Some((path, false)))` — file existed but contained no sqz
1710///   entries. No write.
1711/// * `Ok(None)` — file did not exist.
1712/// * `Err(_)` — file existed but could not be read or parsed.
1713pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1714    remove_claude_global_hook_at(None)
1715}
1716
1717/// Internal: home-dir-injectable counterpart used by tests. See
1718/// `install_claude_global_at` for rationale.
1719fn remove_claude_global_hook_at(
1720    home_override: Option<&Path>,
1721) -> Result<Option<(PathBuf, bool)>> {
1722    let path = match home_override {
1723        Some(h) => h.join(".claude").join("settings.json"),
1724        None => match claude_user_settings_path() {
1725            Some(p) => p,
1726            None => return Ok(None),
1727        },
1728    };
1729    remove_sqz_from_claude_settings(path, true)
1730}
1731
1732/// Remove sqz's entries from a project's `.claude/settings.local.json`,
1733/// keeping whatever else is in it, such as the permissions Claude Code
1734/// saves there when you pick "don't ask again". Same results as
1735/// [`remove_claude_global_hook`].
1736pub fn remove_claude_project_hook(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
1737    remove_sqz_from_claude_settings(project_dir.join(".claude").join("settings.local.json"), true)
1738}
1739
1740/// Remove sqz's hooks from the project's `.claude/settings.local.json`, keeping every permission.
1741pub fn remove_claude_project_hooks_only(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
1742    remove_sqz_from_claude_settings(project_dir.join(".claude").join("settings.local.json"), false)
1743}
1744
1745/// Whether the Claude Code settings file at `path` holds any of sqz's hooks.
1746pub fn claude_settings_have_sqz_hooks(path: &Path) -> bool {
1747    let Ok(text) = std::fs::read_to_string(path) else { return false };
1748    let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
1749    sqz_hook_entries("").iter().any(|(event, _, args)| {
1750        root.pointer(&format!("/hooks/{event}"))
1751            .and_then(|v| v.as_array())
1752            .is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, args)))
1753    })
1754}
1755
1756/// Whether uninstall would take sqz's hooks or allow rules out of the settings file at `path`.
1757pub fn claude_settings_have_sqz(path: &Path) -> bool {
1758    let Ok(text) = std::fs::read_to_string(path) else { return false };
1759    match serde_json::from_str::<serde_json::Value>(&text) {
1760        Ok(mut root) => root.as_object_mut().is_some_and(|o| strip_sqz_claude_entries(o, true)),
1761        Err(_) => text.contains("sqz"),
1762    }
1763}
1764
1765/// Whether `~/.claude/settings.json` holds sqz's hooks, which run in every project.
1766pub fn claude_user_hooks_installed() -> bool {
1767    claude_user_settings_path().is_some_and(|p| claude_settings_have_sqz_hooks(&p))
1768}
1769
1770/// Strip sqz's hook entries, and its allow rules with `rules`, from the Claude Code
1771/// settings file at `path`, deleting the file if nothing else is left in it.
1772fn remove_sqz_from_claude_settings(path: PathBuf, rules: bool) -> Result<Option<(PathBuf, bool)>> {
1773    if !path.exists() {
1774        return Ok(None);
1775    }
1776
1777    let content = std::fs::read_to_string(&path).map_err(|e| {
1778        crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1779    })?;
1780    if content.trim().is_empty() {
1781        return Ok(Some((path, false)));
1782    }
1783
1784    let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1785        crate::error::SqzError::Other(format!(
1786            "parse {}: {e} — refusing to rewrite an unparseable file",
1787            path.display()
1788        ))
1789    })?;
1790    let Some(root_obj) = root.as_object_mut() else {
1791        return Ok(Some((path, false)));
1792    };
1793
1794    let changed = strip_sqz_claude_entries(root_obj, rules);
1795
1796    if !changed {
1797        return Ok(Some((path, false)));
1798    }
1799
1800    // If root is now completely empty, delete the file — matches the
1801    // "leave nothing behind" behaviour of the OpenCode/Codex uninstall
1802    // paths.
1803    if root_obj.is_empty() {
1804        std::fs::remove_file(&path).map_err(|e| {
1805            crate::error::SqzError::Other(format!(
1806                "remove {}: {e}",
1807                path.display()
1808            ))
1809        })?;
1810        return Ok(Some((path, true)));
1811    }
1812
1813    // Atomic rewrite.
1814    write_json_config(&path, &root, &content)?;
1815
1816    Ok(Some((path, true)))
1817}
1818
1819/// Remove sqz's hooks, its allow rules with `rules`, and the keys that leaves empty.
1820fn strip_sqz_claude_entries(
1821    root: &mut serde_json::Map<String, serde_json::Value>,
1822    rules: bool,
1823) -> bool {
1824    let mut changed = rules && remove_claude_allow_rules(root);
1825    let Some(hooks) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
1826        return changed;
1827    };
1828    let mut emptied = false;
1829    for (event, _, sentinel) in &sqz_hook_entries("") {
1830        let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) else {
1831            continue;
1832        };
1833        if !remove_sqz_hook_commands(arr, sentinel) {
1834            continue;
1835        }
1836        changed = true;
1837        if arr.is_empty() {
1838            hooks.remove(*event);
1839            emptied = true;
1840        }
1841    }
1842    if emptied && hooks.is_empty() {
1843        root.remove("hooks");
1844    }
1845    changed
1846}
1847
1848/// The sqz binary in `command` when it is `<sqz path> <args>`, as every sqz version wrote it.
1849fn sqz_command_binary<'a>(command: &'a str, args: &str) -> Option<&'a str> {
1850    let bin = command.strip_suffix(args)?.strip_suffix(' ')?;
1851    let path = unquoted(bin);
1852    let absolute = path.starts_with(['/', '\\']) || path.as_bytes().get(1) == Some(&b':');
1853    if path.contains(['"', '\'', '$', '`', '\n'])
1854        || (path == bin
1855            && (bin.contains([';', '&', '|', '<', '>', '(', ')'])
1856                || (bin.contains(char::is_whitespace) && !absolute)
1857                || bin
1858                    .split_whitespace()
1859                    .skip(1)
1860                    .any(|w| w.starts_with(['/', '-', '~']) || w.contains('='))))
1861    {
1862        return None;
1863    }
1864    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
1865    let stem = match name.len().checked_sub(4) {
1866        Some(i) if name.is_char_boundary(i) && name[i..].eq_ignore_ascii_case(".exe") => &name[..i],
1867        _ => name,
1868    };
1869    (stem == "sqz").then_some(bin)
1870}
1871
1872/// `bin` without one pair of surrounding double or single quotes.
1873fn unquoted(bin: &str) -> &str {
1874    ['"', '\'']
1875        .into_iter()
1876        .find_map(|q| bin.strip_prefix(q)?.strip_suffix(q))
1877        .unwrap_or(bin)
1878}
1879
1880/// The file a config's sqz binary names: a bare name is looked up on PATH, nothing is run.
1881pub fn resolve_sqz_binary(bin: &str) -> Option<PathBuf> {
1882    let path = unquoted(bin);
1883    if !path.contains(['/', '\\']) {
1884        let dirs = std::env::var_os("PATH")?;
1885        return std::env::split_paths(&dirs).find_map(|dir| {
1886            let exe = dir.join(format!("{path}.exe"));
1887            [dir.join(path)]
1888                .into_iter()
1889                .chain(cfg!(windows).then_some(exe))
1890                .find(|p| p.is_file())
1891        });
1892    }
1893    let file = match path.strip_prefix("~/").filter(|_| path == bin) {
1894        Some(rest) => PathBuf::from(std::env::var_os("HOME")?).join(rest),
1895        None => PathBuf::from(path),
1896    };
1897    file.is_file().then_some(file)
1898}
1899
1900/// The Cline hook script sqz wrote before it moved Cline to rules only.
1901const LEGACY_CLINE_SCRIPT: &str = "#!/usr/bin/env bash\n# sqz hook for Cline \u{2014} passes through all tool calls.\n# Cline's PreToolUse cannot rewrite commands, so compression\n# is handled by the sqz-mcp server instead.\necho '{\"cancel\": false}'\nexit 0\n";
1902
1903/// Hook files older sqz versions wrote, with the command sqz put in each (`None`: the Cline script).
1904fn legacy_hook_files(
1905    project_dir: &Path,
1906    home_override: Option<&Path>,
1907) -> Vec<(PathBuf, Option<&'static str>)> {
1908    let mut files = vec![
1909        (project_dir.join(".cursor").join("hooks.json"), Some("hook cursor")),
1910        (project_dir.join(".windsurf").join("hooks.json"), Some("hook windsurf")),
1911        (project_dir.join(".cline").join("hooks.json"), Some("hook cline")),
1912        (project_dir.join(".clinerules").join("hooks").join("PreToolUse"), None),
1913    ];
1914    let home = home_override.map(Path::to_path_buf).or_else(|| {
1915        std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
1916    });
1917    if let Some(home) = home {
1918        files.push((home.join(".cursor").join("hooks.json"), Some("hook cursor")));
1919    }
1920    files
1921}
1922
1923/// The file's JSON without sqz's hooks; `Some(None)` when nothing else is left, `None` without sqz hooks.
1924fn legacy_hooks_cut(text: &str, args: Option<&str>) -> Option<Option<serde_json::Value>> {
1925    let Some(args) = args else {
1926        return (text.replace("\r\n", "\n") == LEGACY_CLINE_SCRIPT).then_some(None);
1927    };
1928    let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
1929    let obj = root.as_object_mut()?;
1930    let hooks = obj.get_mut("hooks")?.as_object_mut()?;
1931    let mut changed = false;
1932    let mut emptied = Vec::new();
1933    for (event, entries) in hooks.iter_mut() {
1934        let Some(arr) = entries.as_array_mut() else { continue };
1935        let before = arr.len();
1936        arr.retain(|e| !hook_runs_sqz(e, args));
1937        if remove_sqz_hook_commands(arr, args) || arr.len() != before {
1938            changed = true;
1939            if arr.is_empty() {
1940                emptied.push(event.clone());
1941            }
1942        }
1943    }
1944    if !changed {
1945        return None;
1946    }
1947    for event in &emptied {
1948        hooks.remove(event);
1949    }
1950    if hooks.is_empty() {
1951        obj.remove("hooks");
1952    }
1953    let only_version = obj.len() == 1 && obj.get("version") == Some(&serde_json::json!(1));
1954    Some((!obj.is_empty() && !only_version).then_some(root))
1955}
1956
1957/// Hook files an older sqz wrote that still hold its hooks.
1958pub fn legacy_sqz_hook_files(project_dir: &Path) -> Vec<PathBuf> {
1959    legacy_sqz_hook_files_at(project_dir, None)
1960}
1961
1962fn legacy_sqz_hook_files_at(project_dir: &Path, home_override: Option<&Path>) -> Vec<PathBuf> {
1963    legacy_hook_files(project_dir, home_override)
1964        .into_iter()
1965        .filter(|(path, args)| {
1966            std::fs::read_to_string(path).is_ok_and(|text| legacy_hooks_cut(&text, *args).is_some())
1967        })
1968        .map(|(path, _)| path)
1969        .collect()
1970}
1971
1972/// Remove sqz's hooks from those files, deleting emptied ones; returns each changed file and if it went.
1973pub fn remove_legacy_sqz_hooks(project_dir: &Path) -> Result<Vec<(PathBuf, bool)>> {
1974    remove_legacy_sqz_hooks_at(project_dir, None)
1975}
1976
1977fn remove_legacy_sqz_hooks_at(
1978    project_dir: &Path,
1979    home_override: Option<&Path>,
1980) -> Result<Vec<(PathBuf, bool)>> {
1981    let mut changed = Vec::new();
1982    for (path, args) in legacy_hook_files(project_dir, home_override) {
1983        let Ok(text) = std::fs::read_to_string(&path) else { continue };
1984        match legacy_hooks_cut(&text, args) {
1985            None => continue,
1986            Some(None) => std::fs::remove_file(&path).map_err(|e| {
1987                crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
1988            })?,
1989            Some(Some(rest)) => write_json_config(&path, &rest, &text)?,
1990        }
1991        let deleted = !path.exists();
1992        changed.push((path, deleted));
1993    }
1994    Ok(changed)
1995}
1996
1997/// Every command a sqz version has written into a hook file, after the binary.
1998const SQZ_HOOK_ARGS: [&str; 9] = [
1999    "hook claude",
2000    "hook claude-post",
2001    "hook precompact",
2002    "resume",
2003    "hook gemini",
2004    "hook copilot",
2005    "hook cursor",
2006    "hook windsurf",
2007    "hook cline",
2008];
2009
2010/// The sqz binaries the hooks in a hook config or OpenCode plugin file run.
2011pub fn sqz_hook_binaries(path: &Path) -> Vec<String> {
2012    let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
2013    let mut found = Vec::new();
2014    match serde_json::from_str::<serde_json::Value>(&text) {
2015        Ok(root) => collect_sqz_hook_binaries(&root, &mut found),
2016        Err(_) => found.extend(text.lines().find_map(|line| {
2017            let literal = line.trim().strip_prefix("const SQZ_PATH = ")?.strip_suffix(';')?;
2018            serde_json::from_str::<String>(literal).ok()
2019        })),
2020    }
2021    found
2022}
2023
2024fn collect_sqz_hook_binaries(value: &serde_json::Value, found: &mut Vec<String>) {
2025    match value {
2026        serde_json::Value::Object(map) => {
2027            for (key, v) in map {
2028                let command = v.as_str().filter(|_| matches!(key.as_str(), "command" | "bash" | "powershell"));
2029                match command.and_then(|c| SQZ_HOOK_ARGS.iter().find_map(|args| sqz_command_binary(c, args))) {
2030                    Some(bin) if !found.iter().any(|b| b == bin) => found.push(bin.to_string()),
2031                    Some(_) => {}
2032                    None => collect_sqz_hook_binaries(v, found),
2033                }
2034            }
2035        }
2036        serde_json::Value::Array(items) => {
2037            for v in items {
2038                collect_sqz_hook_binaries(v, found);
2039            }
2040        }
2041        _ => {}
2042    }
2043}
2044
2045/// Whether a hook entry's commands include sqz's `args` command.
2046fn hook_entry_runs_sqz(entry: &serde_json::Value, args: &str) -> bool {
2047    entry
2048        .get("hooks")
2049        .and_then(|h| h.as_array())
2050        .is_some_and(|hooks| hooks.iter().any(|h| hook_runs_sqz(h, args)))
2051}
2052
2053/// Whether one hook command is sqz's `args` command.
2054fn hook_runs_sqz(hook: &serde_json::Value, args: &str) -> bool {
2055    hook.get("command")
2056        .and_then(|c| c.as_str())
2057        .is_some_and(|c| sqz_command_binary(c, args).is_some())
2058}
2059
2060/// Drop sqz's `args` commands and the entries left without one; true when any went.
2061fn remove_sqz_hook_commands(arr: &mut Vec<serde_json::Value>, args: &str) -> bool {
2062    let mut removed = false;
2063    arr.retain_mut(|entry| {
2064        let Some(hooks) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
2065            return true;
2066        };
2067        let before = hooks.len();
2068        hooks.retain(|h| !hook_runs_sqz(h, args));
2069        if hooks.len() == before {
2070            return true;
2071        }
2072        removed = true;
2073        !hooks.is_empty()
2074    });
2075    removed
2076}
2077
2078/// Replace (or insert) sqz's hook entry in the array under
2079/// `hooks[event_name]`. Only commands that run sqz with exactly the
2080/// arguments `sentinel` count as sqz's (see [`sqz_command_binary`]), so a
2081/// user hook that merely mentions `resume` or `hook precompact` stays.
2082///
2083/// Idempotent: calling this twice yields the same JSON.
2084fn upsert_sqz_hook_entry(
2085    hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
2086    event_name: &str,
2087    new_entry: serde_json::Value,
2088    sentinel: &str,
2089) {
2090    let arr = hooks_obj
2091        .entry(event_name.to_string())
2092        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
2093    let Some(arr) = arr.as_array_mut() else {
2094        // `hooks[event]` exists but isn't an array — overwrite it with
2095        // just our entry. Not ideal but matches the behavior the user
2096        // would get on a fresh install.
2097        hooks_obj.insert(
2098            event_name.to_string(),
2099            serde_json::Value::Array(vec![new_entry]),
2100        );
2101        return;
2102    };
2103
2104    // Drop any existing entry whose command matches our sentinel.
2105    remove_sqz_hook_commands(arr, sentinel);
2106
2107    arr.push(new_entry);
2108}
2109
2110/// Add [`CLAUDE_ALLOW_RULES`] to `permissions.allow`, keeping the user's
2111/// own rules. A `permissions` or `allow` of an unexpected type is left alone.
2112fn upsert_claude_allow_rules(root: &mut serde_json::Map<String, serde_json::Value>) {
2113    let perms = root
2114        .entry("permissions".to_string())
2115        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2116    let Some(perms) = perms.as_object_mut() else { return };
2117    let allow = perms
2118        .entry("allow".to_string())
2119        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
2120    let Some(allow) = allow.as_array_mut() else { return };
2121    for rule in CLAUDE_ALLOW_RULES {
2122        if !allow.iter().any(|r| r.as_str() == Some(rule)) {
2123            allow.push(serde_json::Value::String(rule.to_string()));
2124        }
2125    }
2126}
2127
2128/// Take [`CLAUDE_ALLOW_RULES`] back out, dropping `allow` and `permissions`
2129/// if that leaves them empty. Returns whether anything was removed.
2130fn remove_claude_allow_rules(root: &mut serde_json::Map<String, serde_json::Value>) -> bool {
2131    let Some(perms) = root.get_mut("permissions").and_then(|p| p.as_object_mut()) else {
2132        return false;
2133    };
2134    let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) else {
2135        return false;
2136    };
2137    let before = allow.len();
2138    allow.retain(|r| !r.as_str().is_some_and(|s| CLAUDE_ALLOW_RULES.contains(&s)));
2139    if allow.len() == before {
2140        return false;
2141    }
2142    if allow.is_empty() {
2143        perms.remove("allow");
2144    }
2145    if perms.is_empty() {
2146        root.remove("permissions");
2147    }
2148    true
2149}
2150
2151/// The binary path in a settings file's sqz PreToolUse hook, if it has one.
2152fn claude_settings_sqz_path(root: &serde_json::Value) -> Option<String> {
2153    root.pointer("/hooks/PreToolUse")?.as_array()?.iter().find_map(|entry| {
2154        entry.get("hooks")?.as_array()?.iter().find_map(|h| {
2155            let command = h.get("command")?.as_str()?;
2156            sqz_command_binary(command, "hook claude").map(str::to_string)
2157        })
2158    })
2159}
2160
2161/// Whether a Claude Code settings file has sqz's PreToolUse hook but not
2162/// the allow rules or PostToolUse hook `sqz init` now writes, as an
2163/// install from an older sqz does. Without the rules the hook leaves
2164/// every command alone outside bypass mode.
2165pub fn claude_settings_stale(path: &Path) -> bool {
2166    let Ok(text) = std::fs::read_to_string(path) else { return false };
2167    let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
2168    if claude_settings_sqz_path(&root).is_none() {
2169        return false;
2170    }
2171    let post_hook = root
2172        .pointer("/hooks/PostToolUse")
2173        .and_then(|v| v.as_array())
2174        .is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, "hook claude-post")));
2175    !post_hook || !claude_rules_installed(&[path.to_path_buf()])
2176}
2177
2178/// Bring a stale settings file (see [`claude_settings_stale`]) up to date,
2179/// keeping the sqz binary its hook already runs when that still exists.
2180pub fn refresh_claude_settings(path: &Path, fallback_sqz: &str) -> Result<bool> {
2181    let text = std::fs::read_to_string(path)
2182        .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
2183    let root: serde_json::Value = serde_json::from_str(&text)
2184        .map_err(|e| crate::error::SqzError::Other(format!("parse {}: {e}", path.display())))?;
2185    let sqz_path = claude_settings_sqz_path(&root)
2186        .filter(|p| resolve_sqz_binary(p).is_some())
2187        .unwrap_or_else(|| fallback_sqz.to_string());
2188    if resolve_sqz_binary(&sqz_path).is_none() {
2189        return Err(crate::error::SqzError::Other(format!(
2190            "{sqz_path} does not exist, so the hooks were left as they are"
2191        )));
2192    }
2193    merge_sqz_hooks_into_settings(path, &sqz_path)
2194}
2195
2196// ── Gemini CLI project settings ───────────────────────────────────────────
2197
2198/// The BeforeTool entry sqz adds to `.gemini/settings.json`.
2199fn gemini_hook_entry(sqz_path: &str) -> serde_json::Value {
2200    serde_json::json!({
2201        "matcher": "run_shell_command",
2202        "hooks": [{ "type": "command", "command": format!("{sqz_path} hook gemini") }]
2203    })
2204}
2205
2206/// The file's text and its settings with sqz's hook merged in, or `None` when nothing changes.
2207fn gemini_settings_merged(
2208    path: &Path,
2209    sqz_path: &str,
2210) -> Result<Option<(String, serde_json::Value)>> {
2211    let text = std::fs::read_to_string(path)
2212        .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
2213    let mut root: serde_json::Value = if text.trim().is_empty() {
2214        serde_json::Value::Object(serde_json::Map::new())
2215    } else {
2216        serde_json::from_str(&text).map_err(|e| {
2217            crate::error::SqzError::Other(format!("{} is not plain JSON: {e}", path.display()))
2218        })?
2219    };
2220    let root_obj = root.as_object_mut().ok_or_else(|| {
2221        crate::error::SqzError::Other(format!("{} is not a JSON object", path.display()))
2222    })?;
2223    let before = serde_json::to_string(&root_obj).unwrap_or_default();
2224    let hooks = root_obj
2225        .entry("hooks".to_string())
2226        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2227    let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
2228        crate::error::SqzError::Other(format!("{}: `hooks` is not an object", path.display()))
2229    })?;
2230    upsert_sqz_hook_entry(hooks_obj, "BeforeTool", gemini_hook_entry(sqz_path), "hook gemini");
2231    let after = serde_json::to_string(&root_obj).unwrap_or_default();
2232    Ok((before != after).then_some((text, root)))
2233}
2234
2235/// Whether `sqz init` would create or change the Gemini CLI settings at `path`.
2236pub fn gemini_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
2237    if !path.exists() {
2238        return Ok(true);
2239    }
2240    Ok(gemini_settings_merged(path, sqz_path)?.is_some())
2241}
2242
2243/// Merge sqz's hook into existing Gemini CLI settings, keeping everything else.
2244pub fn merge_sqz_gemini_hook(path: &Path, sqz_path: &str) -> Result<bool> {
2245    let Some((text, root)) = gemini_settings_merged(path, sqz_path)? else {
2246        return Ok(false);
2247    };
2248    write_json_config(path, &root, &text)?;
2249    Ok(true)
2250}
2251
2252/// Whether the Gemini CLI settings at `path` hold sqz's hook (by its text when not plain JSON).
2253pub fn gemini_settings_has_sqz(path: &Path) -> bool {
2254    let Ok(text) = std::fs::read_to_string(path) else { return false };
2255    match serde_json::from_str::<serde_json::Value>(&text) {
2256        Ok(root) => root
2257            .pointer("/hooks/BeforeTool")
2258            .and_then(|v| v.as_array())
2259            .is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, "hook gemini"))),
2260        Err(_) => text.contains(" hook gemini"),
2261    }
2262}
2263
2264/// Remove sqz's hook from `.gemini/settings.json`, as [`remove_claude_global_hook`] does.
2265pub fn remove_sqz_from_gemini_settings(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
2266    let path = project_dir.join(".gemini").join("settings.json");
2267    if !path.exists() {
2268        return Ok(None);
2269    }
2270    let text = std::fs::read_to_string(&path)
2271        .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
2272    if text.trim().is_empty() {
2273        return Ok(Some((path, false)));
2274    }
2275    let mut root: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
2276        crate::error::SqzError::Other(format!(
2277            "{} is not plain JSON ({e}); remove the sqz hook by hand",
2278            path.display()
2279        ))
2280    })?;
2281    let Some(root_obj) = root.as_object_mut() else {
2282        return Ok(Some((path, false)));
2283    };
2284    let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
2285        return Ok(Some((path, false)));
2286    };
2287    let Some(arr) = hooks.get_mut("BeforeTool").and_then(|v| v.as_array_mut()) else {
2288        return Ok(Some((path, false)));
2289    };
2290    if !remove_sqz_hook_commands(arr, "hook gemini") {
2291        return Ok(Some((path, false)));
2292    }
2293    if arr.is_empty() {
2294        hooks.remove("BeforeTool");
2295        if hooks.is_empty() {
2296            root_obj.remove("hooks");
2297        }
2298    }
2299    if root_obj.is_empty() {
2300        std::fs::remove_file(&path).map_err(|e| {
2301            crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
2302        })?;
2303        return Ok(Some((path, true)));
2304    }
2305    write_json_config(&path, &root, &text)?;
2306    Ok(Some((path, true)))
2307}
2308
2309/// Rewrite a JSON config through a temp file, keeping its mode and its final newline.
2310fn write_json_config(path: &Path, value: &serde_json::Value, original: &str) -> Result<()> {
2311    let parent = path.parent().ok_or_else(|| {
2312        crate::error::SqzError::Other(format!("path {} has no parent directory", path.display()))
2313    })?;
2314    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
2315        crate::error::SqzError::Other(format!("create temp file in {}: {e}", parent.display()))
2316    })?;
2317    let mut serialized = serde_json::to_string_pretty(value)
2318        .map_err(|e| crate::error::SqzError::Other(format!("serialize {}: {e}", path.display())))?;
2319    if original.ends_with('\n') {
2320        serialized.push('\n');
2321    }
2322    std::fs::write(tmp.path(), serialized).map_err(|e| {
2323        crate::error::SqzError::Other(format!("write to temp file {}: {e}", tmp.path().display()))
2324    })?;
2325    if let Ok(meta) = std::fs::metadata(path) {
2326        let _ = std::fs::set_permissions(tmp.path(), meta.permissions());
2327    }
2328    tmp.persist(path).map_err(|e| {
2329        crate::error::SqzError::Other(format!(
2330            "rename temp file into place at {}: {e}",
2331            path.display()
2332        ))
2333    })?;
2334    Ok(())
2335}
2336
2337// ── Helpers ───────────────────────────────────────────────────────────────
2338
2339/// Extract the base command name from a full command string.
2340fn extract_base_command(cmd: &str) -> &str {
2341    cmd.split_whitespace()
2342        .next()
2343        .unwrap_or("unknown")
2344        .rsplit('/')
2345        .next()
2346        .unwrap_or("unknown")
2347}
2348
2349/// Escape a string for embedding as the contents of a double-quoted JSON
2350/// string value (per RFC 8259). Also valid for embedding in a double-quoted
2351/// JavaScript/TypeScript string literal — JS string-escape rules for the
2352/// characters that appear in filesystem paths (`\`, `"`, control chars) are
2353/// a strict subset of JSON's.
2354///
2355/// Needed because hook configs embed the sqz executable path into JSON/TS
2356/// files via `format!`. On Windows, `current_exe()` returns
2357/// `C:\Users\...\sqz.exe` — the raw backslashes produce invalid JSON that
2358/// Claude/Cursor/Gemini fail to parse. See issue #2.
2359pub(crate) fn json_escape_string_value(s: &str) -> String {
2360    let mut out = String::with_capacity(s.len() + 2);
2361    for ch in s.chars() {
2362        match ch {
2363            '\\' => out.push_str("\\\\"),
2364            '"' => out.push_str("\\\""),
2365            '\n' => out.push_str("\\n"),
2366            '\r' => out.push_str("\\r"),
2367            '\t' => out.push_str("\\t"),
2368            '\x08' => out.push_str("\\b"),
2369            '\x0c' => out.push_str("\\f"),
2370            c if (c as u32) < 0x20 => {
2371                // Other control chars: use \u00XX escape
2372                out.push_str(&format!("\\u{:04x}", c as u32));
2373            }
2374            c => out.push(c),
2375        }
2376    }
2377    out
2378}
2379
2380/// Shell-escape a string for use in an environment variable assignment.
2381fn shell_escape(s: &str) -> String {
2382    if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
2383        s.to_string()
2384    } else {
2385        format!("'{}'", s.replace('\'', "'\\''"))
2386    }
2387}
2388
2389/// What a hook returns when it leaves the tool call alone. Copilot needs a
2390/// JSON object on every path.
2391fn passthrough(platform: HookPlatform, input: &str) -> String {
2392    match platform {
2393        HookPlatform::Copilot => "{}".to_string(),
2394        _ => input.to_string(),
2395    }
2396}
2397
2398/// Background, async or detached shell calls, under each client's name
2399/// for the flag.
2400fn runs_in_background(parsed: &serde_json::Value) -> bool {
2401    let Some(args) = parsed
2402        .get("tool_input")
2403        .or_else(|| parsed.get("toolArgs"))
2404        .or_else(|| parsed.get("args"))
2405    else {
2406        return false;
2407    };
2408    ["run_in_background", "is_background", "background", "async", "detach"]
2409        .iter()
2410        .any(|k| args.get(k).and_then(|v| v.as_bool()) == Some(true))
2411        || args.get("mode").and_then(|v| v.as_str()) == Some("async")
2412}
2413
2414/// Allow rules `sqz init` adds to Claude Code settings so the two parts the
2415/// ungrouped rewrite adds never need approval of their own. Neither runs
2416/// anything: one sets a shell option, the other reads stdin.
2417pub const CLAUDE_ALLOW_RULES: [&str; 2] = ["Bash(set -o pipefail)", "Bash(sqz compress --cmd *)"];
2418
2419/// Claude Code checks permissions against the rewritten command, so a
2420/// rewrite must not make an allowed command prompt. Outside bypass mode
2421/// that holds only for the ungrouped form, and only once the user has
2422/// allowed its extra parts; grouped commands are judged too complex to
2423/// match any rule. Everything else is left alone.
2424fn claude_rewrite(
2425    parsed: &serde_json::Value,
2426    command: &str,
2427    is_powershell: bool,
2428    settings: &[PathBuf],
2429    shell: crate::shell_scan::Shell,
2430) -> Option<String> {
2431    let bypass = parsed.get("permission_mode").and_then(|v| v.as_str()) == Some("bypassPermissions");
2432    if is_powershell {
2433        if !bypass || is_interactive_command(command) || has_shell_operators(command) {
2434            return None;
2435        }
2436        return Some(crate::exit_marker::powershell_rewrite(command, &shell_escape(command)));
2437    }
2438    if crate::shell_scan::flat(command).is_some() {
2439        let cmds = crate::shell_scan::flat_for(command, shell)?;
2440        return (bypass || claude_rules_installed(settings))
2441            .then(|| crate::exit_marker::posix_flat_rewrite(command, &cmds));
2442    }
2443    if bypass {
2444        return crate::shell_scan::plan(command).ok().map(|plan| posix_rewrite_plan(&plan));
2445    }
2446    None
2447}
2448
2449/// A successful Bash result the Claude Code PostToolUse hook may shrink.
2450#[derive(Debug, Clone, PartialEq)]
2451pub struct PostToolUseTarget {
2452    /// The tool's result object. A replacement must keep its shape, so
2453    /// only `stdout` and `stderr` should change.
2454    pub response: serde_json::Map<String, serde_json::Value>,
2455    /// The stats and formatter label for its output.
2456    pub label: String,
2457}
2458
2459/// Decide whether a Claude Code PostToolUse payload is worth compressing:
2460/// a Bash call the PreToolUse hook didn't already pipe through sqz, with
2461/// text output that Claude Code hasn't truncated. Claude Code only runs
2462/// PostToolUse after a command exits 0; a failure goes to
2463/// PostToolUseFailure, which can't replace output.
2464pub fn claude_post_tool_use_target(input: &str) -> Option<PostToolUseTarget> {
2465    let parsed: serde_json::Value = serde_json::from_str(input).ok()?;
2466    if parsed.get("hook_event_name").and_then(|v| v.as_str()) != Some("PostToolUse")
2467        || parsed.get("tool_name").and_then(|v| v.as_str()) != Some("Bash")
2468        || runs_in_background(&parsed)
2469    {
2470        return None;
2471    }
2472    let command = parsed.pointer("/tool_input/command")?.as_str()?;
2473    if command.contains("sqz compress")
2474        || crate::shell_scan::plan(command) == Err(crate::shell_scan::Skip::AlreadySqz)
2475    {
2476        return None;
2477    }
2478    let response = parsed.get("tool_response")?.as_object()?.clone();
2479    let flag = |k: &str| response.get(k).and_then(|v| v.as_bool()) == Some(true);
2480    if flag("isImage") || flag("interrupted") {
2481        return None;
2482    }
2483    // Past this many characters Claude Code has already cut the output and
2484    // shows only a preview, so a smaller replacement changes nothing.
2485    let limit = std::env::var("BASH_MAX_OUTPUT_LENGTH")
2486        .ok()
2487        .and_then(|v| v.parse::<usize>().ok())
2488        .unwrap_or(30_000);
2489    let long = |k: &str| {
2490        response
2491            .get(k)
2492            .and_then(|v| v.as_str())
2493            .is_some_and(|s| s.chars().count() >= limit)
2494    };
2495    if response.get("stdout").and_then(|v| v.as_str()).is_none() || long("stdout") || long("stderr") {
2496        return None;
2497    }
2498    Some(PostToolUseTarget { response, label: post_tool_use_label(command) })
2499}
2500
2501/// Whether sqz's Claude Code hooks, as `sqz init` installs them, compress a
2502/// Bash command's output in a default-mode session: the PreToolUse rewrite
2503/// takes it, or it exits 0 and the PostToolUse hook does. Followers and
2504/// background jobs never finish with output to replace.
2505pub fn claude_hooks_compress(command: &str, failed: bool) -> bool {
2506    use crate::shell_scan::{flat_for, plan, Shell, Skip};
2507    if command.contains("sqz compress") {
2508        return false;
2509    }
2510    match plan(command) {
2511        Err(Skip::AlreadySqz) => false,
2512        _ if flat_for(command, Shell::from_env()).is_some() => true,
2513        Err(Skip::LongRunning | Skip::Background) => false,
2514        _ => !failed,
2515    }
2516}
2517
2518/// One command's label when only one command in the list has output worth
2519/// compressing, so its formatter can run; the generic `sh:` label otherwise.
2520fn post_tool_use_label(command: &str) -> String {
2521    if let Some(cmds) = crate::shell_scan::flat(command) {
2522        let mut labels = cmds.into_iter().filter_map(|c| c.label);
2523        if let (Some(only), None) = (labels.next(), labels.next()) {
2524            return only;
2525        }
2526    }
2527    list_label(command)
2528}
2529
2530/// The settings files Claude Code reads allow rules from, managed policy
2531/// aside: the user file, then the project's shared and local files.
2532fn claude_settings_files(parsed: &serde_json::Value) -> Vec<PathBuf> {
2533    let mut files = Vec::new();
2534    let user = std::env::var_os("CLAUDE_CONFIG_DIR")
2535        .map(PathBuf::from)
2536        .or_else(|| dirs_next::home_dir().map(|h| h.join(".claude")));
2537    if let Some(dir) = user {
2538        files.push(dir.join("settings.json"));
2539    }
2540    let project = std::env::var_os("CLAUDE_PROJECT_DIR")
2541        .map(PathBuf::from)
2542        .or_else(|| parsed.get("cwd").and_then(|v| v.as_str()).map(PathBuf::from));
2543    if let Some(dir) = project {
2544        files.push(dir.join(".claude").join("settings.json"));
2545        files.push(dir.join(".claude").join("settings.local.json"));
2546    }
2547    files
2548}
2549
2550/// Whether the allow rules across `files` cover both parts the ungrouped
2551/// rewrite adds.
2552fn claude_rules_installed(files: &[PathBuf]) -> bool {
2553    let (mut pipefail, mut compress) = (false, false);
2554    for file in files {
2555        let Ok(text) = std::fs::read_to_string(file) else { continue };
2556        let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
2557        let Some(rules) = root.pointer("/permissions/allow").and_then(|a| a.as_array()) else {
2558            continue;
2559        };
2560        for rule in rules.iter().filter_map(|r| r.as_str()) {
2561            pipefail |= bash_rule_allows(rule, "set -o pipefail");
2562            compress |= bash_rule_allows(rule, "sqz compress --cmd ls");
2563        }
2564    }
2565    pipefail && compress
2566}
2567
2568/// Claude Code's matching for the rule shapes that could cover `command`:
2569/// bare `Bash`, an exact command, or one trailing wildcard (`x *`, `x:*`
2570/// and `x*`). Anything else counts as not covering it.
2571fn bash_rule_allows(rule: &str, command: &str) -> bool {
2572    let rule = rule.trim();
2573    if rule == "Bash" {
2574        return true;
2575    }
2576    let Some(spec) = rule.strip_prefix("Bash(").and_then(|r| r.strip_suffix(')')) else {
2577        return false;
2578    };
2579    if let Some(prefix) = spec.strip_suffix(":*").or_else(|| spec.strip_suffix(" *")) {
2580        if prefix.contains('*') {
2581            return false;
2582        }
2583        return command == prefix
2584            || command.strip_prefix(prefix).is_some_and(|rest| rest.starts_with(' '));
2585    }
2586    match spec.strip_suffix('*') {
2587        Some(prefix) if !prefix.contains('*') => command.starts_with(prefix),
2588        Some(_) => false,
2589        None => spec == command,
2590    }
2591}
2592
2593/// The POSIX rewrite for a command `shell_scan` accepted.
2594fn posix_rewrite_plan(plan: &crate::shell_scan::Plan<'_>) -> String {
2595    let grouped = if plan.simple {
2596        // The full command is the label so subcommand formatters
2597        // (`git status`, `cargo test`) dispatch.
2598        let cmd = plan.body.trim();
2599        let label = cmd.strip_suffix(" 2>&1").map_or(cmd, str::trim_end);
2600        crate::exit_marker::posix_rewrite(cmd, &shell_escape(label))
2601    } else {
2602        crate::exit_marker::posix_rewrite_list(plan.body, &shell_escape(&list_label(plan.body)))
2603    };
2604    if plan.prefix.is_empty() {
2605        grouped
2606    } else {
2607        format!("{} {grouped}", plan.prefix)
2608    }
2609}
2610
2611/// Label for a list or pipeline. The `sh:` prefix keeps per-command
2612/// formatters off output that is partial or comes from several commands.
2613fn list_label(body: &str) -> String {
2614    let first = body.trim().lines().next().unwrap_or("");
2615    let label = format!("sh: {}", first.split_whitespace().collect::<Vec<_>>().join(" "));
2616    if label.chars().count() > 100 {
2617        format!("{}…", label.chars().take(99).collect::<String>())
2618    } else {
2619        label
2620    }
2621}
2622
2623/// Check if a command contains shell operators that would break piping.
2624/// Commands with these operators are passed through uncompressed rather
2625/// than risk incorrect behavior.
2626pub(crate) fn has_shell_operators(cmd: &str) -> bool {
2627    // Check for operators that would cause the pipe to only capture
2628    // the last command in a chain
2629    cmd.contains("&&")
2630        || cmd.contains("||")
2631        || cmd.contains(';')
2632        || cmd.contains('>')
2633        || cmd.contains('<')
2634        || cmd.contains('|') // already has a pipe
2635        || cmd.contains('&') && !cmd.contains("&&") // background &
2636        || cmd.contains("<<")  // heredoc
2637        || cmd.contains("$(")  // command substitution
2638        || cmd.contains('`')   // backtick substitution
2639}
2640
2641/// Check if a command is interactive or long-running (should not be intercepted).
2642fn is_interactive_command(cmd: &str) -> bool {
2643    let base = extract_base_command(cmd);
2644    matches!(
2645        base,
2646        "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
2647        | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
2648        | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
2649    ) || cmd.contains("--watch")
2650        || cmd.contains("-w ")
2651        || cmd.ends_with(" -w")
2652        || cmd.contains("run dev")
2653        || cmd.contains("run start")
2654        || cmd.contains("run serve")
2655}
2656
2657// ── Tests ─────────────────────────────────────────────────────────────────
2658
2659#[cfg(test)]
2660mod tests {
2661    use super::*;
2662
2663    #[test]
2664    fn test_copilot_hook_rewrites_bash_with_modified_args() {
2665        let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
2666        let result = process_hook_copilot(input).unwrap();
2667        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2668        // An "allow" would skip Copilot's own approval prompt.
2669        assert!(parsed.get("permissionDecision").is_none(), "{parsed}");
2670        let args = &parsed["modifiedArgs"];
2671        assert!(args["command"].as_str().unwrap().contains("sqz compress"));
2672        assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
2673    }
2674
2675    #[test]
2676    fn test_copilot_hook_reads_tool_args_given_as_a_string() {
2677        let input = serde_json::json!({
2678            "toolName": "bash",
2679            "toolArgs": r#"{"command":"git log -20","timeoutSec":30}"#,
2680        })
2681        .to_string();
2682        let out = process_hook_copilot(&input).unwrap();
2683        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
2684        let args = &parsed["modifiedArgs"];
2685        assert!(args["command"].as_str().unwrap().contains("sqz compress"), "{parsed}");
2686        assert_eq!(args["timeoutSec"], 30, "{parsed}");
2687        let background = serde_json::json!({
2688            "toolName": "bash",
2689            "toolArgs": r#"{"command":"cargo build","mode":"async"}"#,
2690        })
2691        .to_string();
2692        assert_eq!(process_hook_copilot(&background).unwrap(), "{}");
2693    }
2694
2695    #[test]
2696    fn test_copilot_hook_passes_non_shell_through() {
2697        let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
2698        let result = process_hook_copilot(input).unwrap();
2699        assert_eq!(result, "{}", "non-shell tools fall through with empty object");
2700    }
2701
2702    #[test]
2703    fn test_copilot_hook_skips_risky_shell_operators() {
2704        // Copilot keeps one shell per session, so state changes must not
2705        // move into a subshell.
2706        for command in [
2707            "source .venv/bin/activate && pytest",
2708            "npm run build &",
2709            "for f in a b; do last=$f; done",
2710        ] {
2711            let input = serde_json::json!({"toolName": "bash", "toolArgs": {"command": command}}).to_string();
2712            assert_eq!(process_hook_copilot(&input).unwrap(), "{}", "{command}");
2713        }
2714    }
2715
2716    #[test]
2717    fn test_claude_updated_input_preserves_sibling_fields() {
2718        // Claude Code's updatedInput replaces the whole input object, so
2719        // run_in_background / timeout / description must survive the rewrite.
2720        let input = r#"{"tool_name":"Bash","permission_mode":"bypassPermissions","tool_input":{"command":"cargo test","run_in_background":false,"timeout":120000,"description":"Run tests"}}"#;
2721        let result = process_hook(input).unwrap();
2722        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2723        let updated = &parsed["hookSpecificOutput"]["updatedInput"];
2724        assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
2725        assert_eq!(updated["run_in_background"], false);
2726        assert_eq!(updated["timeout"], 120000);
2727        assert_eq!(updated["description"], "Run tests");
2728    }
2729
2730    fn rewritten(input: &str) -> Option<String> {
2731        let out: serde_json::Value = serde_json::from_str(&process_hook(input).unwrap()).unwrap();
2732        out.pointer("/hookSpecificOutput/updatedInput/command")
2733            .and_then(|c| c.as_str())
2734            .map(str::to_string)
2735    }
2736
2737    /// A Claude Code Bash call in bypass mode, where the rewrite doesn't
2738    /// depend on the allow rules in this machine's settings files.
2739    fn bash(command: &str) -> String {
2740        serde_json::json!({
2741            "tool_name": "Bash",
2742            "permission_mode": "bypassPermissions",
2743            "tool_input": {"command": command}
2744        })
2745        .to_string()
2746    }
2747
2748    fn claude_default(command: &str, settings: &[PathBuf]) -> Option<String> {
2749        claude_default_in(command, settings, crate::shell_scan::Shell::Zsh)
2750    }
2751
2752    fn claude_default_in(
2753        command: &str,
2754        settings: &[PathBuf],
2755        shell: crate::shell_scan::Shell,
2756    ) -> Option<String> {
2757        let parsed = serde_json::json!({
2758            "tool_name": "Bash",
2759            "permission_mode": "default",
2760            "tool_input": {"command": command}
2761        });
2762        claude_rewrite(&parsed, command, false, settings, shell)
2763    }
2764
2765    fn settings_with_rules(dir: &Path, rules: &[&str]) -> PathBuf {
2766        let path = dir.join("settings.json");
2767        std::fs::write(&path, serde_json::json!({"permissions": {"allow": rules}}).to_string())
2768            .unwrap();
2769        path
2770    }
2771
2772    #[test]
2773    fn claude_default_mode_rewrites_only_with_the_allow_rules() {
2774        let dir = tempfile::tempdir().unwrap();
2775        let none = dir.path().join("missing.json");
2776        assert_eq!(claude_default("git status", &[none.clone()]), None);
2777
2778        let partial = settings_with_rules(dir.path(), &["Bash(sqz compress --cmd *)"]);
2779        assert_eq!(claude_default("git status", &[partial]), None, "pipefail rule missing");
2780
2781        let full = settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES);
2782        assert_eq!(
2783            claude_default("git status", &[none, full.clone()]).as_deref(),
2784            Some("set -o pipefail; git status 2>&1 | sqz compress --cmd 'git status'")
2785        );
2786        // Grouping is judged too complex for any allow rule to match, so
2787        // pipelines are left alone outside bypass mode.
2788        assert_eq!(claude_default("git log | head -5", &[full.clone()]), None);
2789        assert_eq!(claude_default("for f in *; do wc -l $f; done", &[full]), None);
2790    }
2791
2792    #[test]
2793    fn claude_default_mode_keeps_builtins_in_the_shell() {
2794        let dir = tempfile::tempdir().unwrap();
2795        let rules = settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES);
2796        assert_eq!(
2797            claude_default("cd sub && export CI=1 && cargo test", &[rules]).as_deref(),
2798            Some("set -o pipefail; cd sub && export CI=1 && cargo test 2>&1 | sqz compress --cmd 'cargo test'")
2799        );
2800    }
2801
2802    #[test]
2803    fn claude_flat_form_stops_where_the_shell_would() {
2804        use crate::shell_scan::Shell;
2805        let dir = tempfile::tempdir().unwrap();
2806        let rules = [settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES)];
2807        for shell in [Shell::Bash, Shell::Zsh] {
2808            assert_eq!(claude_default_in("ls ${X:?unset}; echo next", &rules, shell), None);
2809            assert_eq!(claude_default_in("ls $((1/0)) || echo next", &rules, shell), None);
2810            assert_eq!(
2811                claude_default_in("ls ${X:?unset} && echo next", &rules, shell).as_deref(),
2812                Some("set -o pipefail; ls ${X:?unset} 2>&1 | sqz compress --cmd ls && echo next")
2813            );
2814        }
2815        assert_eq!(claude_default_in("ls *.rs; echo next", &rules, Shell::Zsh), None);
2816        assert!(claude_default_in("ls *.rs; echo next", &rules, Shell::Bash).is_some());
2817        let parsed = serde_json::json!({"permission_mode": "bypassPermissions"});
2818        for shell in [Shell::Bash, Shell::Zsh] {
2819            for command in ["ls ${X:?unset}; echo next", "ls $((1/0))\necho next"] {
2820                assert_eq!(claude_rewrite(&parsed, command, false, &[], shell), None, "{command:?}");
2821            }
2822        }
2823    }
2824
2825    #[test]
2826    fn claude_powershell_is_rewritten_only_in_bypass_mode() {
2827        let zsh = crate::shell_scan::Shell::Zsh;
2828        let parsed = serde_json::json!({"tool_name": "PowerShell", "permission_mode": "default"});
2829        assert_eq!(claude_rewrite(&parsed, "Get-ChildItem", true, &[], zsh), None);
2830        let parsed = serde_json::json!({"tool_name": "PowerShell", "permission_mode": "bypassPermissions"});
2831        assert!(claude_rewrite(&parsed, "Get-ChildItem", true, &[], zsh).is_some());
2832    }
2833
2834    #[test]
2835    fn bash_rule_matching() {
2836        for (rule, command, expected) in [
2837            ("Bash", "set -o pipefail", true),
2838            ("Bash(*)", "set -o pipefail", true),
2839            ("Bash(set -o pipefail)", "set -o pipefail", true),
2840            ("Bash(set -o pipefail)", "set -o pipefail -x", false),
2841            ("Bash(set *)", "set -o pipefail", true),
2842            ("Bash(set:*)", "set -o pipefail", true),
2843            ("Bash(sqz compress --cmd *)", "sqz compress --cmd ls", true),
2844            ("Bash(sqz *)", "sqz compress --cmd ls", true),
2845            ("Bash(sqz*)", "sqz compress --cmd ls", true),
2846            ("Bash(sq *)", "sqz compress --cmd ls", false),
2847            ("Bash(* --cmd ls)", "sqz compress --cmd ls", false),
2848            ("Read(./x)", "sqz compress --cmd ls", false),
2849        ] {
2850            assert_eq!(bash_rule_allows(rule, command), expected, "{rule} vs {command}");
2851        }
2852    }
2853
2854    #[test]
2855    fn crlf_copies_of_current_files_are_current() {
2856        use crate::kiro_integration::SteeringState;
2857        let dir = tempfile::tempdir().unwrap();
2858        let crlf = |s: String| s.replace('\n', "\r\n");
2859        let write = |name: &str, text: String| {
2860            let path = dir.path().join(name);
2861            std::fs::write(&path, text).unwrap();
2862            path
2863        };
2864
2865        let cursor = write("sqz.mdc", crlf(sqz_rules_content("Cursor", "/opt/sqz")));
2866        assert_eq!(sqz_rules_state(&cursor, "Cursor"), SteeringState::Current);
2867        let legacy = write("old.mdc", crlf(format!("{LEGACY_CURSOR_FRONTMATTER}alwaysApply: true\n---\nold\n")));
2868        assert_eq!(sqz_rules_state(&legacy, "Cursor"), SteeringState::Stale);
2869        let kiro = write("kiro.md", crlf(crate::kiro_integration::kiro_steering_content("/opt/sqz")));
2870        assert_eq!(crate::kiro_integration::kiro_steering_state(&kiro, false), SteeringState::Current);
2871        let block = crate::codex_integration::agents_md_guidance_block("/opt/sqz");
2872        let agents = write("AGENTS.md", crlf(format!("# Mine\n\n{block}")));
2873        assert_eq!(crate::codex_integration::agents_md_guidance_state(&agents), SteeringState::Current);
2874        let claude_md = write("CLAUDE.md", crlf(crate::claude_md_integration::claude_md_guidance_block("/opt/sqz")));
2875        assert_eq!(crate::claude_md_integration::claude_md_guidance_state(dir.path()), SteeringState::Current);
2876        assert!(!crate::claude_md_integration::claude_md_block_outdated(dir.path(), "/opt/sqz"));
2877        let before = std::fs::read(&claude_md).unwrap();
2878        assert!(!crate::claude_md_integration::install_claude_md_guidance(dir.path(), "/opt/sqz").unwrap());
2879        assert_eq!(std::fs::read(&claude_md).unwrap(), before);
2880    }
2881
2882    #[test]
2883    fn stale_claude_settings_are_detected_and_refreshed() {
2884        let dir = tempfile::tempdir().unwrap();
2885        let path = dir.path().join("settings.json");
2886        let bin = dir.path().join("sqz");
2887        std::fs::write(&bin, "").unwrap();
2888        let bin = bin.to_string_lossy().to_string();
2889        // What a 1.9.0 install wrote, plus one of the user's own hooks.
2890        let old = serde_json::json!({"hooks": {
2891            "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": format!("{bin} hook claude")}]}],
2892            "PostToolUse": [{"matcher": "Edit", "hooks": [{"type": "command", "command": "prettier --write"}]}],
2893        }});
2894        std::fs::write(&path, old.to_string()).unwrap();
2895        assert!(claude_settings_stale(&path));
2896
2897        assert!(refresh_claude_settings(&path, "/fallback/sqz").unwrap());
2898        assert!(!claude_settings_stale(&path));
2899        let v: serde_json::Value =
2900            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2901        let post = v["hooks"]["PostToolUse"].as_array().unwrap();
2902        assert_eq!(post.len(), 2, "the user's own PostToolUse hook stays: {post:?}");
2903        assert_eq!(
2904            v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
2905            format!("{bin} hook claude"),
2906            "keeps the binary the hook already used"
2907        );
2908
2909        std::fs::write(&path, r#"{"permissions":{}}"#).unwrap();
2910        assert!(!claude_settings_stale(&path), "no sqz hook, nothing to refresh");
2911    }
2912
2913    #[test]
2914    fn background_calls_pass_through() {
2915        for input in [
2916            r#"{"tool_name":"Bash","tool_input":{"command":"cargo build","run_in_background":true}}"#,
2917            r#"{"tool_name":"run_shell_command","tool_input":{"command":"cargo build","is_background":true}}"#,
2918            r#"{"toolName":"bash","toolArgs":{"command":"cargo build","mode":"async"}}"#,
2919        ] {
2920            assert_eq!(rewritten(input), None, "{input}");
2921        }
2922    }
2923
2924    #[test]
2925    fn lists_and_pipelines_are_grouped_with_a_generic_label() {
2926        let cmd = rewritten(&bash("cargo test 2>&1 | tail -30")).unwrap();
2927        assert_eq!(
2928            cmd,
2929            "{ ( cargo test 2>&1 | tail -30\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'sh: cargo test 2>&1 | tail -30'"
2930        );
2931        // A trailing comment no longer swallows the closing brace.
2932        let cmd = rewritten(&bash("ls | head # list")).unwrap();
2933        assert!(cmd.starts_with("{ ( ls | head # list\n); printf"), "{cmd}");
2934        // Without a pipe nothing needs grouping, and the comment stays last.
2935        let cmd = rewritten(&bash("ls # list")).unwrap();
2936        assert_eq!(cmd, "set -o pipefail; ls 2>&1 | sqz compress --cmd ls # list");
2937    }
2938
2939    #[test]
2940    fn leading_cd_runs_outside_the_group() {
2941        let cmd = rewritten(&bash("cd sqz_engine && cargo test 2>&1 | tail -5")).unwrap();
2942        assert_eq!(
2943            cmd,
2944            "cd sqz_engine && { (  cargo test 2>&1 | tail -5\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'sh: cargo test 2>&1 | tail -5'"
2945        );
2946        let cmd = rewritten(&bash("cd sqz_engine && cargo test 2>&1")).unwrap();
2947        assert_eq!(
2948            cmd,
2949            "set -o pipefail; cd sqz_engine && cargo test 2>&1 | sqz compress --cmd 'cargo test'"
2950        );
2951    }
2952
2953    #[test]
2954    fn state_changes_pipe_only_the_commands_around_them() {
2955        assert_eq!(
2956            rewritten(&bash("export X=1 && make")).as_deref(),
2957            Some("set -o pipefail; export X=1 && make 2>&1 | sqz compress --cmd make")
2958        );
2959        assert_eq!(
2960            rewritten(&bash("make && cd build")).as_deref(),
2961            Some("set -o pipefail; make 2>&1 | sqz compress --cmd make && cd build")
2962        );
2963    }
2964
2965    #[test]
2966    fn followers_background_jobs_and_sqz_pass_through() {
2967        for command in ["tail -f app.log", "make &", "git log | sqz compress", "cd x && npm run dev"] {
2968            assert_eq!(rewritten(&bash(command)), None, "{command}");
2969        }
2970    }
2971
2972    #[test]
2973    fn test_process_hook_rewrites_bash_command() {
2974        // Use the official Claude Code input format: tool_name + tool_input
2975        let input = bash("git status");
2976        let result = process_hook(&input).unwrap();
2977        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2978        // Claude Code format: hookSpecificOutput with updatedInput
2979        let hook_output = &parsed["hookSpecificOutput"];
2980        assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
2981        // "allow" would skip the permission prompt for the command.
2982        assert!(hook_output.get("permissionDecision").is_none(), "{hook_output}");
2983        assert!(hook_output.get("permissionDecisionReason").is_none(), "{hook_output}");
2984        // updatedInput for Claude Code (camelCase)
2985        let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
2986        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
2987        assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
2988        // Issue #10: the label is now passed as `--cmd NAME`, not as a
2989        // `SQZ_CMD=NAME` prefix (sh-specific, broken on PowerShell/cmd.exe).
2990        assert!(
2991            cmd.contains("--cmd 'git status'"),
2992            "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
2993        );
2994        assert!(
2995            !cmd.contains("SQZ_CMD="),
2996            "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
2997        );
2998        // Claude Code format should NOT have top-level decision/permission/continue
2999        assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
3000        assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
3001        assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
3002    }
3003
3004    #[test]
3005    fn test_process_hook_passes_through_non_bash() {
3006        let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
3007        let result = process_hook(input).unwrap();
3008        assert_eq!(result, input, "non-bash tools should pass through unchanged");
3009    }
3010
3011    #[test]
3012    fn test_process_hook_rewrites_powershell_command() {
3013        let input = r#"{"tool_name":"PowerShell","permission_mode":"bypassPermissions","tool_input":{"command":"Get-Content big.log"}}"#;
3014        let result = process_hook(input).unwrap();
3015        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3016        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3017            .as_str()
3018            .expect("PowerShell tool calls should be rewritten");
3019        assert!(
3020            cmd.contains("Get-Content big.log"),
3021            "should preserve original PowerShell command: {cmd}"
3022        );
3023        assert!(
3024            cmd.contains("sqz compress"),
3025            "should pipe through sqz compress: {cmd}"
3026        );
3027        assert!(
3028            cmd.contains("--cmd 'Get-Content big.log'"),
3029            "should pass full command via --cmd: {cmd}"
3030        );
3031    }
3032
3033    #[test]
3034    fn test_process_hook_rewrites_pwsh_command() {
3035        let input = r#"{"tool_name":"pwsh","permission_mode":"bypassPermissions","tool_input":{"command":"ls"}}"#;
3036        let result = process_hook(input).unwrap();
3037        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3038        assert!(
3039            parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
3040            "pwsh tool calls should be rewritten"
3041        );
3042    }
3043
3044    #[test]
3045    fn bash_rewrite_carries_exit_status_trailer() {
3046        let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"cargo test"}}"#;
3047        let result = process_hook_gemini(input).unwrap();
3048        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3049        let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"]
3050            .as_str()
3051            .unwrap();
3052        assert_eq!(
3053            cmd,
3054            "{ ( cargo test\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'cargo test'",
3055            "POSIX rewrite must send the command's exit status through the pipe"
3056        );
3057        // Claude Code gets the ungrouped form, where pipefail carries it.
3058        let cmd = rewritten(&bash("cargo test")).unwrap();
3059        assert_eq!(cmd, "set -o pipefail; cargo test 2>&1 | sqz compress --cmd 'cargo test'");
3060    }
3061
3062    #[test]
3063    fn powershell_rewrite_uses_lastexitcode_not_printf() {
3064        let input = r#"{"tool_name":"PowerShell","permission_mode":"bypassPermissions","tool_input":{"command":"cargo test"}}"#;
3065        let result = process_hook(input).unwrap();
3066        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3067        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3068            .as_str()
3069            .unwrap();
3070        assert!(cmd.contains("$LASTEXITCODE"), "PowerShell form uses $LASTEXITCODE: {cmd}");
3071        assert!(cmd.contains("__SQZ_EXIT_"), "PowerShell form emits the trailer: {cmd}");
3072        assert!(!cmd.contains("printf"), "printf is not valid PowerShell: {cmd}");
3073    }
3074
3075    #[test]
3076    fn exit_trailer_rewrite_is_not_rewrapped() {
3077        // Feed the hook its own output — the anti-rewrap guard must catch
3078        // the new brace-group form (it contains `sqz compress --cmd`).
3079        let first = process_hook(&bash("git log")).unwrap();
3080        let parsed: serde_json::Value = serde_json::from_str(&first).unwrap();
3081        let rewritten = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3082            .as_str()
3083            .unwrap();
3084        let second_input = serde_json::json!({
3085            "tool_name": "Bash",
3086            "tool_input": { "command": rewritten }
3087        })
3088        .to_string();
3089        let second = process_hook(&second_input).unwrap();
3090        assert_eq!(
3091            second, second_input,
3092            "already-rewritten commands must pass through unchanged"
3093        );
3094    }
3095
3096    #[test]
3097    fn test_process_hook_skips_sqz_commands() {
3098        let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
3099        let result = process_hook(input).unwrap();
3100        assert_eq!(result, input, "sqz commands should not be double-wrapped");
3101    }
3102
3103    #[test]
3104    fn test_process_hook_skips_interactive() {
3105        let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
3106        let result = process_hook(input).unwrap();
3107        assert_eq!(result, input, "interactive commands should pass through");
3108    }
3109
3110    #[test]
3111    fn test_process_hook_skips_watch_mode() {
3112        let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
3113        let result = process_hook(input).unwrap();
3114        assert_eq!(result, input, "watch mode should pass through");
3115    }
3116
3117    #[test]
3118    fn test_process_hook_empty_command() {
3119        let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
3120        let result = process_hook(input).unwrap();
3121        assert_eq!(result, input);
3122    }
3123
3124    #[test]
3125    fn test_process_hook_gemini_format() {
3126        // Gemini CLI uses tool_name + tool_input (same field names as Claude Code)
3127        let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
3128        let result = process_hook_gemini(input).unwrap();
3129        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3130        assert!(parsed.get("decision").is_none(), "the policy engine decides: {parsed}");
3131        // Gemini format: hookSpecificOutput.tool_input.command (NOT updatedInput)
3132        let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
3133        assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
3134        // Should NOT have Claude Code fields
3135        assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
3136            "Gemini format should not have updatedInput");
3137        assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
3138            "Gemini format should not have permissionDecision");
3139    }
3140
3141    #[test]
3142    fn gemini_tool_input_keeps_every_argument() {
3143        // The scheduler replaces the call's arguments with tool_input.
3144        let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log","dir_path":"sub","description":"log"}}"#;
3145        let parsed: serde_json::Value =
3146            serde_json::from_str(&process_hook_gemini(input).unwrap()).unwrap();
3147        let args = &parsed["hookSpecificOutput"]["tool_input"];
3148        assert_eq!(args["dir_path"], "sub");
3149        assert_eq!(args["description"], "log");
3150        assert!(args["command"].as_str().unwrap().contains("sqz compress"));
3151    }
3152
3153    #[test]
3154    fn test_process_hook_legacy_format() {
3155        // Test backward compatibility with older toolName/toolCall format
3156        let input = r#"{"toolName":"Bash","permission_mode":"bypassPermissions","toolCall":{"command":"git status"}}"#;
3157        let result = process_hook(input).unwrap();
3158        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3159        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
3160        assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
3161    }
3162
3163    #[test]
3164    fn test_process_hook_cursor_format() {
3165        // Any answer to a Cursor permission hook is a decision, so there is none.
3166        for input in [
3167            r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#,
3168            r#"{"hook_event_name":"beforeShellExecution","command":"git status","cwd":"/p"}"#,
3169            r#"{"hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"git status"}}"#,
3170            r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#,
3171            r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#,
3172            "not json",
3173        ] {
3174            assert!(process_hook_cursor(input).is_err(), "{input}");
3175            assert!(process_hook_for_platform(input, HookPlatform::Cursor).is_err(), "{input}");
3176        }
3177    }
3178
3179    #[test]
3180    fn test_process_hook_windsurf_format() {
3181        // Windsurf uses agent_action_name + tool_info.command_line
3182        let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
3183        let result = process_hook_windsurf(input).unwrap();
3184        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3185        // Windsurf uses Claude Code format as best-effort
3186        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
3187        assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
3188        assert!(cmd.contains("cargo test"));
3189        // Issue #10: label is passed as `--cmd`, not `SQZ_CMD=` prefix.
3190        assert!(
3191            cmd.contains("--cmd 'cargo test'"),
3192            "full command must be passed via --cmd flag: {cmd}"
3193        );
3194        assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
3195    }
3196
3197    #[test]
3198    fn test_process_hook_invalid_json() {
3199        let result = process_hook("not json");
3200        assert!(result.is_err());
3201    }
3202
3203    #[test]
3204    fn test_extract_base_command() {
3205        assert_eq!(extract_base_command("git status"), "git");
3206        assert_eq!(extract_base_command("/usr/bin/git log"), "git");
3207        assert_eq!(extract_base_command("cargo test --release"), "cargo");
3208    }
3209
3210    #[test]
3211    fn test_is_interactive_command() {
3212        assert!(is_interactive_command("vim file.txt"));
3213        assert!(is_interactive_command("npm run dev --watch"));
3214        assert!(is_interactive_command("python3"));
3215        assert!(!is_interactive_command("git status"));
3216        assert!(!is_interactive_command("cargo test"));
3217    }
3218
3219    // ── Issue #10: Windows shell compatibility ────────────────────────────
3220
3221    /// The rewritten command must use shell-neutral syntax so it works
3222    /// in PowerShell and cmd.exe on Windows, not just POSIX shells.
3223    ///
3224    /// The old form `SQZ_CMD=val cmd` is sh-specific: PowerShell parses
3225    /// `SQZ_CMD=val` as a command name (CommandNotFoundException), and
3226    /// cmd.exe does the same. OpenCode Desktop on Windows routes the
3227    /// bash tool through PowerShell (or cmd.exe when $SHELL is unset),
3228    /// so the old form produced zero compression and a spurious error
3229    /// dialog.
3230    ///
3231    /// Reported in issue #10. The fix: pass the label as `--cmd NAME`,
3232    /// a normal CLI argument that every shell accepts.
3233    #[test]
3234    fn issue_10_rewrite_is_shell_neutral() {
3235        let input = bash("dotnet build NewNeonCheckers3.sln");
3236        let result = process_hook(&input).unwrap();
3237        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3238        let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3239            .as_str()
3240            .unwrap();
3241
3242        // Must use the --cmd flag form, carrying the full command.
3243        assert!(
3244            cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
3245            "issue #10: rewrite must pass label via --cmd, got: {cmd}"
3246        );
3247        // Must NOT use the sh-specific inline-env-var form.
3248        assert!(
3249            !cmd.contains("SQZ_CMD="),
3250            "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
3251             (broken in PowerShell and cmd.exe), got: {cmd}"
3252        );
3253        // Reporter's original command must still be intact.
3254        assert!(
3255            cmd.contains("dotnet build NewNeonCheckers3.sln"),
3256            "original command must be preserved verbatim: {cmd}"
3257        );
3258        // And the pipe to sqz must be there.
3259        assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
3260    }
3261
3262    /// The already-wrapped guard must recognise the new `--cmd` form so
3263    /// that a command the hook has already rewritten doesn't get
3264    /// wrapped again (causing `… | sqz compress --cmd X 2>&1 | sqz
3265    /// compress --cmd sqz` chains).
3266    ///
3267    /// This is the runaway-prefix bug from issue #5 rephrased for the
3268    /// new emission form.
3269    #[test]
3270    fn issue_10_already_wrapped_command_passes_through() {
3271        let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
3272        let result = process_hook(input).unwrap();
3273        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3274        // The hook must leave an already-wrapped command alone.
3275        // (When the guard short-circuits we return the input verbatim.)
3276        assert_eq!(
3277            result, input,
3278            "already-wrapped command must pass through unchanged; \
3279             otherwise each pass accumulates another `| sqz compress` tail"
3280        );
3281        // And explicitly verify the non-rewritten `command` is still the
3282        // original, so someone reading the hook response doesn't think
3283        // we silently re-wrapped.
3284        let _ = parsed; // suppress "unused" in case of future assertion adds
3285    }
3286
3287    #[test]
3288    fn test_generate_hook_configs() {
3289        let configs = generate_hook_configs("sqz");
3290        assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
3291        assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
3292        assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
3293        assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
3294        // Windsurf, Cline, and Cursor should generate rules files, not hook configs
3295        // (none of the three support transparent command rewriting via hooks).
3296        let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
3297        assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
3298            "Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
3299        let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
3300        assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
3301            "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
3302        // Cursor — empirically verified (forum/Cupcake/GitButler docs +
3303        // live cursor-agent trace) that beforeShellExecution cannot rewrite
3304        // commands. Use the modern .cursor/rules/*.mdc format.
3305        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
3306        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
3307            "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
3308             .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
3309        assert!(cursor.config_content.starts_with("---"),
3310            "Cursor rule should start with YAML frontmatter");
3311        assert!(cursor.config_content.contains("alwaysApply: true"),
3312            "Cursor rule should use alwaysApply: true so the guidance loads \
3313             for every agent interaction");
3314        assert!(cursor.config_content.contains("sqz"),
3315            "Cursor rule body should mention sqz");
3316    }
3317
3318    #[test]
3319    fn test_claude_config_includes_precompact_hook() {
3320        // The PreCompact hook is what keeps sqz's dedup refs from dangling
3321        // after Claude Code auto-compacts. Without this entry, cached refs
3322        // can point at content the LLM no longer has in context.
3323        // Documented at docs.anthropic.com/en/docs/claude-code/hooks-guide.
3324        let configs = generate_hook_configs("sqz");
3325        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
3326        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
3327            .expect("Claude Code config must be valid JSON");
3328
3329        let precompact = parsed["hooks"]["PreCompact"]
3330            .as_array()
3331            .expect("PreCompact hook array must be present");
3332        assert!(
3333            !precompact.is_empty(),
3334            "PreCompact must have at least one registered hook"
3335        );
3336
3337        let cmd = precompact[0]["hooks"][0]["command"]
3338            .as_str()
3339            .expect("command field must be a string");
3340        assert!(
3341            cmd.ends_with(" hook precompact"),
3342            "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
3343        );
3344    }
3345
3346    // ── Issue #2: Windows path escaping in hook configs ───────────────
3347
3348    #[test]
3349    fn test_json_escape_string_value() {
3350        // Plain ASCII: unchanged
3351        assert_eq!(json_escape_string_value("sqz"), "sqz");
3352        assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
3353        // Backslash: escaped
3354        assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
3355                   r"C:\\Users\\Alice\\sqz.exe");
3356        // Double quote: escaped
3357        assert_eq!(json_escape_string_value(r#"path with "quotes""#),
3358                   r#"path with \"quotes\""#);
3359        // Control chars
3360        assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
3361    }
3362
3363    #[test]
3364    fn test_windows_path_produces_valid_json_for_claude() {
3365        // Issue #2 repro: on Windows, current_exe() returns a path with
3366        // backslashes. Without escaping, the generated JSON is invalid.
3367        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3368        let configs = generate_hook_configs(windows_path);
3369
3370        let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
3371            .expect("Claude config should be generated");
3372        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
3373            .expect("Claude hook config must be valid JSON on Windows paths");
3374
3375        // Verify the command was written with the original path (not lossy-transformed).
3376        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
3377            .as_str()
3378            .expect("command field must be a string");
3379        assert!(cmd.contains(windows_path),
3380            "command '{cmd}' must contain the original Windows path '{windows_path}'");
3381    }
3382
3383    #[test]
3384    fn test_windows_path_in_cursor_rules_file() {
3385        // Cursor's config is now .cursor/rules/sqz.mdc (markdown), not JSON.
3386        // Markdown doesn't escape backslashes — the user reads this rule
3387        // through the agent and needs to see the raw path so commands are
3388        // pasteable. See test_rules_files_use_raw_path_for_readability for
3389        // the same property on Windsurf/Cline.
3390        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3391        let configs = generate_hook_configs(windows_path);
3392
3393        let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
3394        assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
3395        assert!(cursor.config_content.contains(windows_path),
3396            "Cursor rule must contain the raw (unescaped) path so users can \
3397             copy-paste the shown commands — got:\n{}", cursor.config_content);
3398        assert!(!cursor.config_content.contains(r"C:\\Users"),
3399            "Cursor rule must NOT double-escape backslashes in markdown — \
3400             got:\n{}", cursor.config_content);
3401    }
3402
3403    #[test]
3404    fn test_windows_path_produces_valid_json_for_gemini() {
3405        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3406        let configs = generate_hook_configs(windows_path);
3407
3408        let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
3409        let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
3410            .expect("Gemini hook config must be valid JSON on Windows paths");
3411        let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
3412        assert!(cmd.contains(windows_path));
3413    }
3414
3415    #[test]
3416    fn test_rules_files_use_raw_path_for_readability() {
3417        // The .windsurfrules / .clinerules / .cursor/rules/sqz.mdc files are
3418        // markdown for humans. Backslashes should NOT be doubled there — the
3419        // user needs to copy-paste the command into their shell.
3420        let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3421        let configs = generate_hook_configs(windows_path);
3422
3423        for tool in &["Windsurf", "Cline", "Cursor"] {
3424            let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
3425            assert!(cfg.config_content.contains(windows_path),
3426                "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
3427                cfg.config_content);
3428            assert!(!cfg.config_content.contains(r"C:\\Users"),
3429                "{tool} rules file must NOT double-escape backslashes — got:\n{}",
3430                cfg.config_content);
3431        }
3432    }
3433
3434    #[test]
3435    fn test_unix_path_still_works() {
3436        // Regression: make sure the escape path doesn't mangle Unix paths
3437        // (which have no backslashes to escape).
3438        let unix_path = "/usr/local/bin/sqz";
3439        let configs = generate_hook_configs(unix_path);
3440
3441        let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
3442        let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
3443            .expect("Unix path should produce valid JSON");
3444        let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
3445        assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
3446    }
3447
3448    #[test]
3449    fn test_shell_escape_simple() {
3450        assert_eq!(shell_escape("git"), "git");
3451        assert_eq!(shell_escape("cargo-test"), "cargo-test");
3452    }
3453
3454    #[test]
3455    fn test_shell_escape_special_chars() {
3456        assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
3457    }
3458
3459    fn install_in(project: &Path, home: &Path, sqz: &str) -> Vec<String> {
3460        install_tool_hooks_at(project, sqz, InstallScope::Project, &ToolFilter::All, Some(home))
3461    }
3462
3463    #[test]
3464    fn user_level_files_go_to_the_given_home() {
3465        let dir = tempfile::tempdir().unwrap();
3466        let home = tempfile::tempdir().unwrap();
3467        std::fs::create_dir_all(home.path().join(".copilot")).unwrap();
3468        install_in(dir.path(), home.path(), "/opt/sqz");
3469        for rel in [
3470            ".claude.json",
3471            ".codex/config.toml",
3472            ".config/zed/settings.json",
3473            ".config/opencode/plugins/sqz.ts",
3474            ".copilot/hooks/sqz.json",
3475        ] {
3476            assert!(home.path().join(rel).exists(), "{rel}");
3477        }
3478        let copilot = std::fs::read_to_string(home.path().join(".copilot/hooks/sqz.json")).unwrap();
3479        assert!(copilot.contains("/opt/sqz hook copilot"));
3480
3481        let global = tempfile::tempdir().unwrap();
3482        let filter = ToolFilter::All;
3483        install_tool_hooks_at(dir.path(), "/opt/sqz", InstallScope::Global, &filter, Some(global.path()));
3484        for rel in [".claude/settings.json", ".kiro/steering/sqz.md", ".kiro/settings/mcp.json"] {
3485            assert!(global.path().join(rel).exists(), "{rel}");
3486        }
3487    }
3488
3489    #[test]
3490    fn test_install_tool_hooks_creates_files() {
3491        let dir = tempfile::tempdir().unwrap();
3492        let home = tempfile::tempdir().unwrap();
3493        let installed = install_in(dir.path(), home.path(), "sqz");
3494        // Should install at least some hooks
3495        assert!(!installed.is_empty(), "should install at least one hook config");
3496        // Verify files were created. Copilot CLI installs at user scope
3497        // (~/.copilot or $COPILOT_HOME), not under the project dir, so its
3498        // placeholder config_path is not checked here.
3499        for name in &installed {
3500            if name == "Copilot CLI" {
3501                continue;
3502            }
3503            let configs = generate_hook_configs("sqz");
3504            let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
3505            let path = dir.path().join(&config.config_path);
3506            assert!(path.exists(), "hook config should exist: {}", path.display());
3507        }
3508    }
3509
3510    #[test]
3511    fn test_install_tool_hooks_does_not_overwrite() {
3512        let dir = tempfile::tempdir().unwrap();
3513        let home = tempfile::tempdir().unwrap();
3514        // First install
3515        install_in(dir.path(), home.path(), "sqz");
3516        // Write a custom file to one of the paths. It is deliberately NOT
3517        // valid JSON: the Claude Code merge path refuses to touch files it
3518        // can't parse, and every other tool's config is skip-if-exists.
3519        let custom_path = dir.path().join(".claude/settings.local.json");
3520        std::fs::write(&custom_path, "custom content").unwrap();
3521        // Second install must leave the unparseable file alone.
3522        install_in(dir.path(), home.path(), "sqz");
3523        let content = std::fs::read_to_string(&custom_path).unwrap();
3524        assert_eq!(content, "custom content", "should not overwrite existing config");
3525    }
3526
3527    /// Regression for the issue #26 leftover: a project-scope settings file
3528    /// from a pre-PowerShell install (matcher "Bash", old binary path) must
3529    /// be upgraded in place when `sqz init` runs again — while every key
3530    /// the user added themselves survives untouched. The old behavior was
3531    /// skip-if-exists, which left Windows installs on the stale matcher
3532    /// forever.
3533    #[test]
3534    fn test_reinit_upgrades_stale_project_scope_matcher() {
3535        let dir = tempfile::tempdir().unwrap();
3536        let settings_path = dir.path().join(".claude").join("settings.local.json");
3537        std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
3538
3539        // A pre-issue-#26 install: Bash-only matcher, stale binary path,
3540        // plus keys the user added by hand.
3541        let stale = serde_json::json!({
3542            "permissions": { "allow": ["Bash(ls:*)"] },
3543            "hooks": {
3544                "PreToolUse": [
3545                    {
3546                        "matcher": "Bash",
3547                        "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
3548                    },
3549                    {
3550                        "matcher": "Edit",
3551                        "hooks": [{ "type": "command", "command": "my-linter --check" }]
3552                    }
3553                ]
3554            }
3555        });
3556        std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
3557
3558        let home = tempfile::tempdir().unwrap();
3559        install_in(dir.path(), home.path(), "/new/path/sqz");
3560
3561        let content = std::fs::read_to_string(&settings_path).unwrap();
3562        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
3563
3564        // sqz's entry: matcher upgraded, binary path refreshed, no duplicate.
3565        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3566        let sqz_entries: Vec<_> = pre
3567            .iter()
3568            .filter(|e| {
3569                e["hooks"][0]["command"]
3570                    .as_str()
3571                    .is_some_and(|c| c.contains("hook claude"))
3572            })
3573            .collect();
3574        assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
3575        assert_eq!(
3576            sqz_entries[0]["matcher"].as_str().unwrap(),
3577            "Bash|PowerShell",
3578            "stale Bash-only matcher must be upgraded"
3579        );
3580        assert!(
3581            sqz_entries[0]["hooks"][0]["command"]
3582                .as_str()
3583                .unwrap()
3584                .contains("/new/path/sqz"),
3585            "binary path must be refreshed"
3586        );
3587
3588        // The user's own entries survive.
3589        assert!(
3590            pre.iter().any(|e| e["matcher"] == "Edit"),
3591            "user's non-sqz PreToolUse entry must be preserved"
3592        );
3593        assert_eq!(
3594            parsed["permissions"]["allow"][0].as_str().unwrap(),
3595            "Bash(ls:*)",
3596            "user's permissions must be preserved"
3597        );
3598
3599        // The other two sqz hooks got added too (full heal, not partial).
3600        assert!(parsed["hooks"]["PreCompact"].is_array());
3601        assert!(parsed["hooks"]["SessionStart"].is_array());
3602    }
3603
3604    #[test]
3605    fn test_windsurf_installs_to_rules_directory() {
3606        let dir = tempfile::tempdir().unwrap();
3607        let home = tempfile::tempdir().unwrap();
3608        install_in(dir.path(), home.path(), "sqz");
3609        assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3610        assert!(!dir.path().join(".windsurfrules").exists());
3611    }
3612
3613    fn legacy_rules(sqz: &str) -> String {
3614        format!(
3615            "# sqz \u{2014} Token-Optimized CLI Output\n\n```bash\ngit status        git status 2>&1 | {sqz} compress\n```\n\nUse these when you genuinely need raw data. The default compressed\npath is still what you want for most output.\n"
3616        )
3617    }
3618
3619    #[test]
3620    fn test_windsurf_migrates_legacy_sqz_rules_file() {
3621        let dir = tempfile::tempdir().unwrap();
3622        let home = tempfile::tempdir().unwrap();
3623        std::fs::write(dir.path().join(".windsurfrules"), legacy_rules("sqz")).unwrap();
3624        install_in(dir.path(), home.path(), "sqz");
3625        assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3626        assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
3627    }
3628
3629    #[test]
3630    fn test_windsurf_migration_keeps_user_rules_after_the_sqz_text() {
3631        let dir = tempfile::tempdir().unwrap();
3632        let home = tempfile::tempdir().unwrap();
3633        let legacy = dir.path().join(".windsurfrules");
3634        std::fs::write(&legacy, format!("{}\n## Team rules\n\nUse tabs.\n", legacy_rules("sqz"))).unwrap();
3635        install_in(dir.path(), home.path(), "sqz");
3636        assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3637        assert_eq!(std::fs::read_to_string(&legacy).unwrap(), "## Team rules\n\nUse tabs.\n");
3638    }
3639
3640    #[test]
3641    fn test_windsurf_leaves_user_legacy_rules_alone() {
3642        let dir = tempfile::tempdir().unwrap();
3643        let home = tempfile::tempdir().unwrap();
3644        std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
3645        install_in(dir.path(), home.path(), "sqz");
3646        assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3647        assert_eq!(
3648            std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
3649            "my own rules\n"
3650        );
3651    }
3652
3653    #[test]
3654    fn test_cline_installs_into_rules_directory_when_present() {
3655        let dir = tempfile::tempdir().unwrap();
3656        let home = tempfile::tempdir().unwrap();
3657        std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
3658        std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
3659        install_in(dir.path(), home.path(), "sqz");
3660        assert!(dir.path().join(".clinerules/sqz.md").exists());
3661        assert!(dir.path().join(".clinerules/mine.md").exists());
3662    }
3663
3664    #[test]
3665    fn test_cline_single_file_untouched_when_user_authored() {
3666        let dir = tempfile::tempdir().unwrap();
3667        let home = tempfile::tempdir().unwrap();
3668        std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
3669        install_in(dir.path(), home.path(), "sqz");
3670        assert_eq!(
3671            std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
3672            "user rules\n"
3673        );
3674    }
3675
3676    /// Re-running init on an already-current project file is a no-op.
3677    #[test]
3678    fn test_reinit_is_idempotent_at_project_scope() {
3679        let dir = tempfile::tempdir().unwrap();
3680        let home = tempfile::tempdir().unwrap();
3681        install_in(dir.path(), home.path(), "/usr/bin/sqz");
3682        let settings_path = dir.path().join(".claude").join("settings.local.json");
3683        let first = std::fs::read_to_string(&settings_path).unwrap();
3684
3685        install_in(dir.path(), home.path(), "/usr/bin/sqz");
3686        let second = std::fs::read_to_string(&settings_path).unwrap();
3687
3688        let a: serde_json::Value = serde_json::from_str(&first).unwrap();
3689        let b: serde_json::Value = serde_json::from_str(&second).unwrap();
3690        assert_eq!(a, b, "second init must not change project settings");
3691        // No duplicated sqz entries.
3692        assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
3693    }
3694
3695    #[test]
3696    fn init_refreshes_old_sqz_rules_in_place() {
3697        let dir = tempfile::tempdir().unwrap();
3698        let filter = ToolFilter::Only(vec!["cursor".into(), "windsurf".into(), "cline".into()]);
3699        let old_cursor = "---\ndescription: sqz \u{2014} Token-Optimized CLI Output\nalwaysApply: true\n---\n\n# sqz \u{2014} Token-Optimized CLI Output\n\ngit status        git status 2>&1 | /old/sqz compress\n";
3700        std::fs::create_dir_all(dir.path().join(".cursor/rules")).unwrap();
3701        std::fs::write(dir.path().join(".cursor/rules/sqz.mdc"), old_cursor).unwrap();
3702        std::fs::write(dir.path().join(".clinerules"), legacy_rules("/old/sqz")).unwrap();
3703
3704        let installed =
3705            install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter);
3706        assert_eq!(installed, vec!["Cursor", "Windsurf", "Cline"]);
3707        for (tool, rel) in [
3708            ("Cursor", ".cursor/rules/sqz.mdc"),
3709            ("Windsurf", ".windsurf/rules/sqz.md"),
3710            ("Cline", ".clinerules"),
3711        ] {
3712            let content = std::fs::read_to_string(dir.path().join(rel)).unwrap();
3713            assert_eq!(content, sqz_rules_content(tool, "/new/sqz"), "{tool}");
3714            assert!(!content.contains('%') && !content.contains('\u{2014}'), "{tool}");
3715        }
3716        assert!(
3717            install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
3718                .is_empty()
3719        );
3720    }
3721
3722    #[test]
3723    fn init_leaves_user_written_rules_alone() {
3724        let dir = tempfile::tempdir().unwrap();
3725        let cursor = dir.path().join(".cursor/rules/sqz.mdc");
3726        std::fs::create_dir_all(cursor.parent().unwrap()).unwrap();
3727        std::fs::write(&cursor, "my own rule\n").unwrap();
3728        let filter = ToolFilter::Only(vec!["cursor".into()]);
3729        assert!(
3730            install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
3731                .is_empty()
3732        );
3733        assert_eq!(std::fs::read_to_string(&cursor).unwrap(), "my own rule\n");
3734    }
3735
3736    #[test]
3737    fn rules_refresh_keeps_the_binary_the_old_text_points_at() {
3738        use crate::kiro_integration::SteeringState;
3739        let dir = tempfile::tempdir().unwrap();
3740        let bin = dir.path().join("sqz");
3741        std::fs::write(&bin, "").unwrap();
3742        let bin = bin.to_string_lossy().to_string();
3743        let path = dir.path().join("sqz.md");
3744        std::fs::write(
3745            &path,
3746            format!("# sqz \u{2014} Token-Optimized CLI Output\n\ngit status        git status 2>&1 | {bin} compress\n"),
3747        )
3748        .unwrap();
3749
3750        assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::Stale);
3751        assert!(refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
3752        assert_eq!(std::fs::read_to_string(&path).unwrap(), sqz_rules_content("Windsurf", &bin));
3753        assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::Current);
3754        assert!(!refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
3755
3756        std::fs::write(&path, "my own rules\n").unwrap();
3757        assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::UserAuthored);
3758        assert!(!refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
3759        assert_eq!(std::fs::read_to_string(&path).unwrap(), "my own rules\n");
3760    }
3761
3762    #[test]
3763    fn cline_rules_keep_the_user_rules_after_them() {
3764        use crate::kiro_integration::SteeringState;
3765        let dir = tempfile::tempdir().unwrap();
3766        let path = dir.path().join(".clinerules");
3767        let filter = ToolFilter::Only(vec!["cline".into()]);
3768        let install = |sqz: &str| {
3769            install_tool_hooks_scoped_filtered(dir.path(), sqz, InstallScope::Project, &filter)
3770        };
3771        let mine = "\n## Team rules\n\nUse tabs.\n";
3772        std::fs::write(&path, format!("{}{mine}", legacy_rules("/old/sqz"))).unwrap();
3773
3774        assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::Stale);
3775        assert_eq!(install("/new/sqz"), vec!["Cline"]);
3776        assert_eq!(
3777            std::fs::read_to_string(&path).unwrap(),
3778            format!("{}{mine}", sqz_rules_content("Cline", "/new/sqz"))
3779        );
3780        assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::Current);
3781        assert!(install("/new/sqz").is_empty());
3782
3783        assert_eq!(install("/other/sqz"), vec!["Cline"]);
3784        assert_eq!(
3785            std::fs::read_to_string(&path).unwrap(),
3786            format!("{}{mine}", sqz_rules_content("Cline", "/other/sqz"))
3787        );
3788
3789        assert!(remove_sqz_rules(&path).unwrap());
3790        assert_eq!(std::fs::read_to_string(&path).unwrap(), "## Team rules\n\nUse tabs.\n");
3791        assert!(!has_sqz_rules(&path));
3792    }
3793
3794    #[test]
3795    fn cline_refresh_keeps_the_old_binary_and_the_user_rules() {
3796        let dir = tempfile::tempdir().unwrap();
3797        let bin = dir.path().join("sqz");
3798        std::fs::write(&bin, "").unwrap();
3799        let bin = bin.to_string_lossy().to_string();
3800        let path = dir.path().join(".clinerules");
3801        let v2_ending = "and commands with shell operators. If sqz is\nnot on PATH, run commands normally.\n";
3802        let old = legacy_rules(&bin).replace(
3803            "Use these when you genuinely need raw data. The default compressed\npath is still what you want for most output.\n",
3804            v2_ending,
3805        );
3806        std::fs::write(&path, format!("{old}\nUse tabs.\n")).unwrap();
3807
3808        assert!(refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3809        let block = sqz_rules_content("Cline", &bin);
3810        assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{block}\nUse tabs.\n"));
3811        assert!(!refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3812
3813        std::fs::write(&path, format!("{}\nUse tabs.\n", block.replace("sqz_recall", "recall"))).unwrap();
3814        assert!(refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3815        assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{block}\nUse tabs.\n"));
3816    }
3817
3818    #[test]
3819    fn cline_rules_without_a_known_end_are_left_alone() {
3820        use crate::kiro_integration::SteeringState;
3821        let dir = tempfile::tempdir().unwrap();
3822        let path = dir.path().join(".clinerules");
3823        let edited = "# sqz \u{2014} Token-Optimized CLI Output\n\nPipe output through sqz.\n\n## Team rules\n";
3824        std::fs::write(&path, edited).unwrap();
3825        let filter = ToolFilter::Only(vec!["cline".into()]);
3826
3827        assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::UserAuthored);
3828        assert!(
3829            install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
3830                .is_empty()
3831        );
3832        assert!(!refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3833        assert!(!remove_sqz_rules(&path).unwrap());
3834        assert_eq!(std::fs::read_to_string(&path).unwrap(), edited);
3835    }
3836}
3837
3838#[cfg(test)]
3839mod global_install_tests {
3840    use super::*;
3841
3842    #[test]
3843    fn global_install_creates_fresh_settings_json() {
3844        let tmp = tempfile::tempdir().unwrap();
3845        let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
3846        assert!(changed, "first install should report a change");
3847
3848        let path = tmp.path().join(".claude").join("settings.json");
3849        assert!(path.exists(), "user settings.json should be created");
3850
3851        let content = std::fs::read_to_string(&path).unwrap();
3852        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
3853
3854        // All three hook entries should be present.
3855        let pre = &parsed["hooks"]["PreToolUse"];
3856        assert!(pre.is_array(), "PreToolUse should be an array");
3857        assert_eq!(pre.as_array().unwrap().len(), 1);
3858        let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
3859        assert!(
3860            cmd.contains("/usr/local/bin/sqz"),
3861            "hook command should use the passed sqz_path, got: {cmd}"
3862        );
3863        assert!(cmd.contains("hook claude"));
3864
3865        let precompact = &parsed["hooks"]["PreCompact"];
3866        assert!(precompact.is_array());
3867        let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
3868        assert!(precompact_cmd.contains("hook precompact"));
3869
3870        let session = &parsed["hooks"]["SessionStart"];
3871        assert!(session.is_array());
3872        assert_eq!(
3873            session[0]["matcher"].as_str().unwrap(),
3874            "compact",
3875            "SessionStart should only match /compact resume"
3876        );
3877    }
3878
3879    #[test]
3880    fn global_install_preserves_existing_user_config() {
3881        let tmp = tempfile::tempdir().unwrap();
3882        let settings = tmp.path().join(".claude").join("settings.json");
3883        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
3884
3885        let existing = serde_json::json!({
3886            "permissions": {
3887                "allow": ["Bash(npm test *)"],
3888                "deny":  ["Read(./.env)"]
3889            },
3890            "env": { "FOO": "bar" },
3891            "statusLine": {
3892                "type": "command",
3893                "command": "~/.claude/statusline.sh"
3894            },
3895            "hooks": {
3896                "PreToolUse": [
3897                    {
3898                        "matcher": "Edit",
3899                        "hooks": [
3900                            {
3901                                "type": "command",
3902                                "command": "~/.claude/hooks/format-on-edit.sh"
3903                            }
3904                        ]
3905                    }
3906                ]
3907            }
3908        });
3909        std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
3910
3911        let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
3912        assert!(changed, "install should report a change on new hook");
3913
3914        let content = std::fs::read_to_string(&settings).unwrap();
3915        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
3916
3917        // User's permissions survived.
3918        assert_eq!(
3919            parsed["permissions"]["allow"][0].as_str().unwrap(),
3920            "Bash(npm test *)"
3921        );
3922        assert_eq!(
3923            parsed["permissions"]["deny"][0].as_str().unwrap(),
3924            "Read(./.env)"
3925        );
3926        // User's env block survived.
3927        assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
3928        // User's statusLine survived.
3929        assert_eq!(
3930            parsed["statusLine"]["command"].as_str().unwrap(),
3931            "~/.claude/statusline.sh"
3932        );
3933
3934        // PreToolUse should now contain BOTH the user's format-on-edit
3935        // hook and sqz's Bash hook — our install appends, not replaces.
3936        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3937        assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
3938        let matchers: Vec<&str> = pre
3939            .iter()
3940            .map(|e| e["matcher"].as_str().unwrap_or(""))
3941            .collect();
3942        assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
3943        assert!(
3944            matchers.contains(&"Bash|PowerShell"),
3945            "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
3946        );
3947    }
3948
3949    #[test]
3950    fn global_install_is_idempotent() {
3951        let tmp = tempfile::tempdir().unwrap();
3952        assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
3953        assert!(
3954            !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
3955            "second install with identical args should report no change"
3956        );
3957
3958        let path = tmp.path().join(".claude").join("settings.json");
3959        let parsed: serde_json::Value =
3960            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
3961        for event in &["PreToolUse", "PreCompact", "SessionStart"] {
3962            let arr = parsed["hooks"][event].as_array().unwrap();
3963            assert_eq!(
3964                arr.len(),
3965                1,
3966                "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
3967            );
3968        }
3969    }
3970
3971    /// Regression for issue #21: on Windows, `sqz_path` contains `.exe`
3972    /// so the stored command is `C:\...\sqz.exe hook claude`. The old
3973    /// sentinel was `"sqz hook claude"` which is NOT a substring of
3974    /// `"sqz.exe hook claude"` — causing every re-run to append a
3975    /// duplicate instead of replacing. The fix uses `"hook claude"` as
3976    /// the sentinel which matches regardless of `.exe`.
3977    #[test]
3978    fn global_install_is_idempotent_with_exe_path() {
3979        let tmp = tempfile::tempdir().unwrap();
3980        let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
3981        assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
3982        assert!(
3983            !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
3984            "second install with .exe path should report no change (issue #21)"
3985        );
3986
3987        let path = tmp.path().join(".claude").join("settings.json");
3988        let parsed: serde_json::Value =
3989            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
3990        for event in &["PreToolUse", "PreCompact", "SessionStart"] {
3991            let arr = parsed["hooks"][event].as_array().unwrap();
3992            assert_eq!(
3993                arr.len(),
3994                1,
3995                "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
3996            );
3997        }
3998    }
3999
4000    #[test]
4001    fn global_install_upgrades_stale_sqz_hook_in_place() {
4002        let tmp = tempfile::tempdir().unwrap();
4003        install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
4004        let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
4005        assert!(changed, "different sqz_path must be seen as a change");
4006
4007        let path = tmp.path().join(".claude").join("settings.json");
4008        let parsed: serde_json::Value =
4009            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4010        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
4011        assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
4012        let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
4013        assert!(cmd.contains("/new/path/sqz"));
4014        assert!(!cmd.contains("/old/path/sqz"));
4015    }
4016
4017    #[test]
4018    fn global_uninstall_removes_sqz_and_preserves_the_rest() {
4019        let tmp = tempfile::tempdir().unwrap();
4020        let settings = tmp.path().join(".claude").join("settings.json");
4021        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4022        std::fs::write(
4023            &settings,
4024            serde_json::json!({
4025                "permissions": { "allow": ["Bash(git status)"] },
4026                "hooks": {
4027                    "PreToolUse": [
4028                        {
4029                            "matcher": "Edit",
4030                            "hooks": [
4031                                { "type": "command", "command": "~/format.sh" }
4032                            ]
4033                        }
4034                    ]
4035                }
4036            })
4037            .to_string(),
4038        )
4039        .unwrap();
4040
4041        install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
4042        let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
4043        assert_eq!(result.0, settings);
4044        assert!(result.1, "should report that the file was modified");
4045
4046        assert!(settings.exists(), "settings.json should be preserved");
4047        let parsed: serde_json::Value =
4048            serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
4049
4050        assert_eq!(
4051            parsed["permissions"]["allow"][0].as_str().unwrap(),
4052            "Bash(git status)"
4053        );
4054
4055        let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
4056        assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
4057        assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
4058
4059        assert!(parsed["hooks"].get("PreCompact").is_none());
4060        assert!(parsed["hooks"].get("SessionStart").is_none());
4061    }
4062
4063    #[test]
4064    fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
4065        let tmp = tempfile::tempdir().unwrap();
4066        install_claude_global_at("sqz", Some(tmp.path())).unwrap();
4067        let path = tmp.path().join(".claude").join("settings.json");
4068        assert!(path.exists(), "precondition: install created the file");
4069
4070        let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
4071        assert!(result.1);
4072        assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
4073    }
4074
4075    #[test]
4076    fn only_commands_that_run_sqz_count_as_sqz_hooks() {
4077        for (command, args) in [
4078            ("sqz hook claude", "hook claude"),
4079            ("/usr/local/bin/sqz hook claude-post", "hook claude-post"),
4080            (r"C:\Users\me\.cargo\bin\sqz.exe hook precompact", "hook precompact"),
4081            ("C:/Users/Jane Doe/.cargo/bin/sqz.EXE resume", "resume"),
4082            (r"C:\Users\Jane Doe\.cargo\bin\sqz.exe hook claude", "hook claude"),
4083            ("/Users/Jane Doe/bin/sqz resume", "resume"),
4084            ("~/.cargo/bin/sqz hook claude", "hook claude"),
4085            ("\"/opt/sqz\" hook claude", "hook claude"),
4086            ("'/opt/my tools (x86)/sqz' hook claude-post", "hook claude-post"),
4087        ] {
4088            assert!(sqz_command_binary(command, args).is_some(), "{command}");
4089        }
4090        for (command, args) in [
4091            ("/usr/local/bin/on-resume.sh", "resume"),
4092            ("~/bin/save-notes --hook precompact", "hook precompact"),
4093            ("~/bin/audit-hook claude", "hook claude"),
4094            ("echo hi && /usr/bin/sqz hook claude", "hook claude"),
4095            ("nice -n 5 sqz hook claude", "hook claude"),
4096            ("/usr/bin/time /opt/sqz hook claude", "hook claude"),
4097            ("/usr/bin/env SQZ_NO_DEDUP=1 /opt/sqz hook claude", "hook claude"),
4098            ("/usr/bin/nice -n 5 /opt/sqz hook claude", "hook claude"),
4099            ("/usr/bin/time ~/bin/sqz hook claude", "hook claude"),
4100            ("sqz hook claude --verbose", "hook claude"),
4101            ("notsqz hook claude", "hook claude"),
4102            ("sqz hook claude-post", "hook claude"),
4103            ("\"/opt/sqz hook claude", "hook claude"),
4104            ("\"$HOME/bin/sqz\" hook claude", "hook claude"),
4105            ("\"/opt/notsqz\" hook claude", "hook claude"),
4106        ] {
4107            assert!(sqz_command_binary(command, args).is_none(), "{command}");
4108        }
4109    }
4110
4111    #[test]
4112    fn legacy_hook_files_lose_only_sqz_hooks() {
4113        let project = tempfile::tempdir().unwrap();
4114        let home = tempfile::tempdir().unwrap();
4115        let write = |root: &Path, rel: &str, text: &str| {
4116            let path = root.join(rel);
4117            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4118            std::fs::write(&path, text).unwrap();
4119            path
4120        };
4121        let p = project.path();
4122        let cursor = write(
4123            p,
4124            ".cursor/hooks.json",
4125            r#"{"version":1,"hooks":{"beforeShellExecution":[{"command":"/opt/sqz hook cursor"},{"command":"./audit.sh"}]}}"#,
4126        );
4127        let windsurf = write(p, ".windsurf/hooks.json", r#"{"hooks":{"pre_run_command":[{"command":"/opt/sqz hook windsurf"}]}}"#);
4128        let cline = write(
4129            p,
4130            ".cline/hooks.json",
4131            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"sqz hook cline"}]}]}}"#,
4132        );
4133        let script = write(p, ".clinerules/hooks/PreToolUse", &LEGACY_CLINE_SCRIPT.replace('\n', "\r\n"));
4134        let user_cursor = write(
4135            home.path(),
4136            ".cursor/hooks.json",
4137            r#"{"version":1,"hooks":{"preToolUse":[{"command":"C:/tools/sqz.exe hook cursor","matcher":"Shell"}]}}"#,
4138        );
4139
4140        let all = vec![cursor.clone(), windsurf.clone(), cline.clone(), script.clone(), user_cursor.clone()];
4141        assert_eq!(legacy_sqz_hook_files_at(p, Some(home.path())), all);
4142        assert_eq!(
4143            remove_legacy_sqz_hooks_at(p, Some(home.path())).unwrap(),
4144            vec![(cursor.clone(), false), (windsurf, true), (cline, true), (script.clone(), true), (user_cursor, true)]
4145        );
4146        let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cursor).unwrap()).unwrap();
4147        assert_eq!(v, serde_json::json!({"version": 1, "hooks": {"beforeShellExecution": [{"command": "./audit.sh"}]}}));
4148        assert!(legacy_sqz_hook_files_at(p, Some(home.path())).is_empty());
4149
4150        write(p, ".clinerules/hooks/PreToolUse", &LEGACY_CLINE_SCRIPT.replace("exit 0", "exit 1"));
4151        assert!(legacy_sqz_hook_files_at(p, Some(home.path())).is_empty(), "an edited script is the user's");
4152    }
4153
4154    #[test]
4155    fn hook_binaries_are_read_from_every_hook_file_kind() {
4156        let dir = tempfile::tempdir().unwrap();
4157        let settings = dir.path().join("settings.json");
4158        std::fs::write(&settings, claude_settings_content("/opt/sqz")).unwrap();
4159        assert_eq!(sqz_hook_binaries(&settings), vec!["/opt/sqz"]);
4160        let copilot = dir.path().join("sqz.json");
4161        std::fs::write(&copilot, copilot_hook_content("C:\\tools\\sqz.exe")).unwrap();
4162        assert_eq!(sqz_hook_binaries(&copilot), vec!["C:\\tools\\sqz.exe"]);
4163        let plugin = dir.path().join("sqz.ts");
4164        std::fs::write(&plugin, crate::opencode_plugin::generate_opencode_plugin("/gone/sqz")).unwrap();
4165        assert_eq!(sqz_hook_binaries(&plugin), vec!["/gone/sqz"]);
4166        let user = dir.path().join("user.json");
4167        std::fs::write(
4168            &user,
4169            r#"{"hooks":{"PreCompact":[{"hooks":[{"command":"~/bin/save-notes --hook precompact"}]}],"PreToolUse":[{"matcher":"Bash","hooks":[{"command":"/usr/bin/time /opt/sqz hook claude"}]}]}}"#,
4170        )
4171        .unwrap();
4172        assert!(sqz_hook_binaries(&user).is_empty());
4173    }
4174
4175    #[test]
4176    fn refresh_never_points_a_hook_at_a_missing_binary() {
4177        let dir = tempfile::tempdir().unwrap();
4178        let path = dir.path().join("settings.json");
4179        let old = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/gone/sqz hook claude"}]}]}}"#;
4180        std::fs::write(&path, old).unwrap();
4181        assert!(refresh_claude_settings(&path, "/also-gone/sqz").is_err());
4182        assert_eq!(std::fs::read_to_string(&path).unwrap(), old);
4183    }
4184
4185    #[test]
4186    fn hook_binaries_resolve_to_files_without_running_them() {
4187        let dir = tempfile::tempdir().unwrap();
4188        let bin = dir.path().join("my tools").join("sqz");
4189        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
4190        std::fs::write(&bin, "").unwrap();
4191        let shown = bin.to_string_lossy().to_string();
4192        assert_eq!(resolve_sqz_binary(&shown), Some(bin.clone()));
4193        assert_eq!(resolve_sqz_binary(&format!("\"{shown}\"")), Some(bin.clone()));
4194        assert_eq!(resolve_sqz_binary(&format!("{shown}-gone")), None);
4195        assert_eq!(resolve_sqz_binary("sqz-not-on-any-path-xyz"), None);
4196    }
4197
4198    #[test]
4199    fn user_hooks_that_look_like_sqz_ones_are_kept() {
4200        let tmp = tempfile::tempdir().unwrap();
4201        let settings = tmp.path().join(".claude").join("settings.json");
4202        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4203        let cmd = |c: &str| serde_json::json!({"type": "command", "command": c});
4204        let user = serde_json::json!({
4205            "hooks": {
4206                "SessionStart": [
4207                    {"matcher": "resume", "hooks": [cmd("/usr/local/bin/on-resume.sh")]},
4208                    {"matcher": "startup", "hooks": [cmd("echo user-startup")]}
4209                ],
4210                "PreCompact": [{"hooks": [cmd("~/bin/save-notes --hook precompact")]}],
4211                "PreToolUse": [{"matcher": "Bash", "hooks": [
4212                    cmd("~/bin/audit-hook claude"),
4213                    cmd("/usr/bin/env SQZ_NO_DEDUP=1 /opt/sqz hook claude")
4214                ]}]
4215            }
4216        });
4217        let text = serde_json::to_string_pretty(&user).unwrap();
4218        std::fs::write(&settings, &text).unwrap();
4219        let read = || -> serde_json::Value {
4220            serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap()
4221        };
4222
4223        assert_eq!(remove_claude_global_hook_at(Some(tmp.path())).unwrap(), Some((settings.clone(), false)));
4224        assert_eq!(std::fs::read_to_string(&settings).unwrap(), text);
4225
4226        assert!(install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
4227        assert!(!install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
4228        let installed = read();
4229        for (event, user_count) in [("SessionStart", 2), ("PreCompact", 1), ("PreToolUse", 1)] {
4230            let arr = installed["hooks"][event].as_array().unwrap();
4231            assert_eq!(arr.len(), user_count + 1, "{event}: {arr:?}");
4232            assert_eq!(arr[..user_count], user["hooks"][event].as_array().unwrap()[..], "{event}");
4233        }
4234
4235        let mut shared = installed.clone();
4236        shared["hooks"]["PreToolUse"][1]["hooks"]
4237            .as_array_mut()
4238            .unwrap()
4239            .push(cmd("echo also-mine"));
4240        std::fs::write(&settings, serde_json::to_string_pretty(&shared).unwrap()).unwrap();
4241        assert!(remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap().1);
4242        let mut expected = user.clone();
4243        expected["hooks"]["PreToolUse"]
4244            .as_array_mut()
4245            .unwrap()
4246            .push(serde_json::json!({"matcher": "Bash|PowerShell", "hooks": [cmd("echo also-mine")]}));
4247        assert_eq!(read(), expected);
4248    }
4249
4250    #[test]
4251    fn project_install_leaves_claude_hooks_to_the_user_level_ones() {
4252        let home = tempfile::tempdir().unwrap();
4253        let project = tempfile::tempdir().unwrap();
4254        install_claude_global_at("/opt/sqz", Some(home.path())).unwrap();
4255        let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
4256        let installed =
4257            install_tool_hooks_at(project.path(), "/opt/sqz", InstallScope::Project, &filter, Some(home.path()));
4258        assert_eq!(installed, vec!["Claude Code"]);
4259        assert!(!project.path().join(".claude").join("settings.local.json").exists());
4260        assert!(project.path().join("CLAUDE.md").exists());
4261    }
4262
4263    #[test]
4264    fn duplicate_project_hooks_go_and_permissions_stay() {
4265        let project = tempfile::tempdir().unwrap();
4266        let local = project.path().join(".claude").join("settings.local.json");
4267        std::fs::create_dir_all(local.parent().unwrap()).unwrap();
4268        std::fs::write(&local, claude_settings_content("/opt/sqz")).unwrap();
4269        assert!(claude_settings_have_sqz_hooks(&local));
4270        assert!(remove_claude_project_hooks_only(project.path()).unwrap().unwrap().1);
4271        let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&local).unwrap()).unwrap();
4272        assert_eq!(v, serde_json::json!({"permissions": {"allow": CLAUDE_ALLOW_RULES}}));
4273        assert!(!claude_settings_have_sqz_hooks(&local));
4274
4275        let hook = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"sqz hook claude"}]}]}}"#;
4276        std::fs::write(&local, hook).unwrap();
4277        assert!(remove_claude_project_hooks_only(project.path()).unwrap().unwrap().1);
4278        assert!(!local.exists());
4279    }
4280
4281    #[test]
4282    fn settings_rewrites_keep_the_final_newline_and_mode() {
4283        let tmp = tempfile::tempdir().unwrap();
4284        let settings = tmp.path().join(".claude").join("settings.json");
4285        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4286        let text = "{\n  \"theme\": \"dark\"\n}\n";
4287        std::fs::write(&settings, text).unwrap();
4288        #[cfg(unix)]
4289        {
4290            use std::os::unix::fs::PermissionsExt;
4291            std::fs::set_permissions(&settings, std::fs::Permissions::from_mode(0o644)).unwrap();
4292        }
4293
4294        assert!(install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
4295        assert!(std::fs::read_to_string(&settings).unwrap().ends_with("}\n"));
4296        assert!(remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap().1);
4297        assert_eq!(std::fs::read_to_string(&settings).unwrap(), text);
4298        #[cfg(unix)]
4299        {
4300            use std::os::unix::fs::PermissionsExt;
4301            let mode = std::fs::metadata(&settings).unwrap().permissions().mode() & 0o777;
4302            assert_eq!(mode, 0o644);
4303        }
4304    }
4305
4306    #[test]
4307    fn global_uninstall_on_missing_file_is_noop() {
4308        let tmp = tempfile::tempdir().unwrap();
4309        assert!(
4310            remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
4311            "missing file should return None, not error"
4312        );
4313    }
4314
4315    #[test]
4316    fn global_uninstall_refuses_to_touch_unparseable_file() {
4317        let tmp = tempfile::tempdir().unwrap();
4318        let settings = tmp.path().join(".claude").join("settings.json");
4319        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4320        std::fs::write(&settings, "{ invalid json because").unwrap();
4321
4322        assert!(
4323            remove_claude_global_hook_at(Some(tmp.path())).is_err(),
4324            "bad JSON must surface as an error"
4325        );
4326
4327        let after = std::fs::read_to_string(&settings).unwrap();
4328        assert_eq!(after, "{ invalid json because");
4329    }
4330}
4331
4332#[cfg(test)]
4333mod issue_11_tool_filter_tests {
4334    //! Regression tests for issue #11 (@shochdoerfer): let the user
4335    //! choose for which agent sqz init creates configs.
4336    //!
4337    //! Every assertion here pins a behaviour the filter should have.
4338    //! If any one of these flips, users are either getting configs for
4339    //! tools they asked to skip OR getting no config for tools they
4340    //! explicitly asked for — both are issue #11 regressions.
4341
4342    use super::*;
4343
4344    #[test]
4345    fn canonicalize_collapses_common_aliases() {
4346        // Each row: list of aliases a user might type, followed by the
4347        // canonical form they should all normalise to.
4348        for aliases in &[
4349            (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
4350            (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
4351            (vec!["Windsurf", "WINDSURF"], "windsurf"),
4352            // Cline is also marketed as "Roo Code" — sqz treats them
4353            // as one integration (same .clinerules file) so the
4354            // aliases must collapse.
4355            (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
4356            (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
4357            (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
4358            (vec!["Codex", "codex"], "codex"),
4359        ] {
4360            for alias in &aliases.0 {
4361                assert_eq!(
4362                    canonicalize_tool_name(alias),
4363                    aliases.1,
4364                    "alias '{}' must canonicalise to '{}'",
4365                    alias,
4366                    aliases.1
4367                );
4368            }
4369        }
4370    }
4371
4372    #[test]
4373    fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
4374        // Unknown names fall through — we don't guess. The caller
4375        // (parse_tool_list) is responsible for turning unknown values
4376        // into user-facing errors; canonicalize itself just
4377        // normalises case, whitespace, and hyphens/underscores.
4378        assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
4379        assert_eq!(canonicalize_tool_name("Some Thing"), "something");
4380    }
4381
4382    #[test]
4383    fn parse_tool_list_accepts_comma_separated_with_whitespace() {
4384        // Typical shell invocation: `--only opencode,codex` or
4385        // `--only "opencode, codex"` — both should work.
4386        let names = parse_tool_list("opencode,codex").unwrap();
4387        assert_eq!(names, vec!["opencode", "codex"]);
4388
4389        let names = parse_tool_list(" opencode ,  codex ").unwrap();
4390        assert_eq!(names, vec!["opencode", "codex"]);
4391
4392        // Single entry.
4393        let names = parse_tool_list("opencode").unwrap();
4394        assert_eq!(names, vec!["opencode"]);
4395
4396        // Alias with hyphen.
4397        let names = parse_tool_list("claude-code").unwrap();
4398        assert_eq!(names, vec!["claudecode"]);
4399    }
4400
4401    #[test]
4402    fn parse_tool_list_dedupes_repeated_entries() {
4403        // `--only opencode,opencode` shouldn't produce two "opencode"
4404        // entries that the filter then matches twice. Harmless today
4405        // but tomorrow someone could code `filter.allow.len()` against
4406        // it and silently count wrong.
4407        let names = parse_tool_list("opencode,opencode").unwrap();
4408        assert_eq!(names, vec!["opencode"]);
4409
4410        // Same name via different aliases still dedupes because they
4411        // canonicalise to the same string.
4412        let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
4413        assert_eq!(names, vec!["claudecode"]);
4414    }
4415
4416    #[test]
4417    fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
4418        // This is the critical failure path: a typo in --only must
4419        // fail, not silently drop. Otherwise the user runs `sqz init
4420        // --only opncode` (typo), sees "OK" with nothing installed,
4421        // and spends 20 minutes figuring out why. The error message
4422        // must list valid options verbatim so they can spot their
4423        // typo.
4424        let err = parse_tool_list("opncode").unwrap_err();
4425        let msg = err.to_string();
4426        assert!(
4427            msg.contains("unknown agent name 'opncode'"),
4428            "error must quote the bad input: {msg}"
4429        );
4430        assert!(msg.contains("opencode"), "error must list valid options: {msg}");
4431        assert!(msg.contains("cursor"), "error must list valid options: {msg}");
4432    }
4433
4434    #[test]
4435    fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
4436        // `--only opencode,xyz` — fail the whole list, don't just drop
4437        // xyz. User either typed a typo they need to see, or they
4438        // don't understand the vocabulary — in both cases pretending
4439        // the input was valid hurts them.
4440        let err = parse_tool_list("opencode,xyz").unwrap_err();
4441        assert!(err.to_string().contains("xyz"));
4442    }
4443
4444    #[test]
4445    fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
4446        // `--only ""` and `--only "   "` produce an empty filter —
4447        // semantically "install nothing" rather than "install all".
4448        // cmd_init surfaces this as ToolFilter::Only(empty) which
4449        // skips every config file but still installs the shell hook
4450        // and preset.
4451        assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
4452        assert_eq!(parse_tool_list("   ").unwrap(), Vec::<String>::new());
4453        assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
4454    }
4455
4456    #[test]
4457    fn tool_filter_all_includes_every_supported_tool() {
4458        let filter = ToolFilter::All;
4459        for tool in SUPPORTED_TOOL_NAMES {
4460            assert!(
4461                filter.includes(tool),
4462                "default filter must include {tool}"
4463            );
4464        }
4465    }
4466
4467    #[test]
4468    fn tool_filter_only_opencode_excludes_everything_else() {
4469        // The exact scenario from issue #11.
4470        let filter = ToolFilter::Only(vec!["opencode".to_string()]);
4471        assert!(filter.includes("OpenCode"));
4472        // Every other supported tool is rejected.
4473        for tool in SUPPORTED_TOOL_NAMES {
4474            if *tool == "OpenCode" {
4475                continue;
4476            }
4477            assert!(
4478                !filter.includes(tool),
4479                "--only opencode must not include {tool}"
4480            );
4481        }
4482    }
4483
4484    #[test]
4485    fn tool_filter_only_multi_tool_includes_exactly_those() {
4486        let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
4487        assert!(filter.includes("OpenCode"));
4488        assert!(filter.includes("Codex"));
4489        // Everything else: excluded.
4490        assert!(!filter.includes("Claude Code"));
4491        assert!(!filter.includes("Cursor"));
4492        assert!(!filter.includes("Windsurf"));
4493        assert!(!filter.includes("Cline"));
4494        assert!(!filter.includes("Gemini CLI"));
4495    }
4496
4497    #[test]
4498    fn tool_filter_skip_inverts_the_set() {
4499        // `--skip cursor,windsurf` means "install everything except
4500        // those two." Opposite of --only.
4501        let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
4502        assert!(!filter.includes("Cursor"));
4503        assert!(!filter.includes("Windsurf"));
4504        // Everything else stays on.
4505        assert!(filter.includes("Claude Code"));
4506        assert!(filter.includes("Cline"));
4507        assert!(filter.includes("Gemini CLI"));
4508        assert!(filter.includes("OpenCode"));
4509        assert!(filter.includes("Codex"));
4510    }
4511
4512    #[test]
4513    fn tool_filter_only_empty_excludes_everything() {
4514        // Edge: `--only ""` → empty filter → nothing passes. This is
4515        // semantically "install no AI-tool configs, just the shell
4516        // hook and preset." Surprising if the user typed it by
4517        // accident, but consistent — the plan output will show only
4518        // shell/preset lines and the user can abort.
4519        let filter = ToolFilter::Only(vec![]);
4520        for tool in SUPPORTED_TOOL_NAMES {
4521            assert!(
4522                !filter.includes(tool),
4523                "empty --only must exclude every tool, got {tool}"
4524            );
4525        }
4526    }
4527
4528    #[test]
4529    fn tool_filter_only_accepts_display_name_or_canonical() {
4530        // The filter lives in the engine; callers pass the
4531        // display-name strings ("Claude Code", "Gemini CLI") straight
4532        // from generate_hook_configs. But filter entries come from
4533        // the CLI via parse_tool_list, which canonicalises. Both
4534        // sides must line up — assert the cross-path works.
4535        let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
4536        assert!(filter.includes("Claude Code"));
4537        assert!(!filter.includes("Cursor"));
4538
4539        let filter = ToolFilter::Only(vec!["gemini".to_string()]);
4540        assert!(filter.includes("Gemini CLI"));
4541    }
4542
4543    #[test]
4544    fn supported_tool_names_matches_generate_hook_configs_exactly() {
4545        // Invariant: the SUPPORTED_TOOL_NAMES constant (used in error
4546        // messages, docs, tests) must match the tool_name fields the
4547        // config generator actually emits. If someone adds a new tool
4548        // config but forgets to add its name to the constant, this
4549        // test fails loudly.
4550        let configs = generate_hook_configs("sqz");
4551        let emitted: std::collections::HashSet<&str> =
4552            configs.iter().map(|c| c.tool_name.as_str()).collect();
4553        let declared: std::collections::HashSet<&str> =
4554            SUPPORTED_TOOL_NAMES.iter().copied().collect();
4555        assert_eq!(
4556            emitted, declared,
4557            "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
4558             from generate_hook_configs. emitted={:?}, declared={:?}",
4559            emitted, declared
4560        );
4561    }
4562
4563    #[test]
4564    fn filtered_install_only_opencode_writes_only_opencode_files() {
4565        // End-to-end: exercise install_tool_hooks_scoped_filtered
4566        // against a tempdir and assert that only OpenCode's files
4567        // appear. This is the single most important regression for
4568        // issue #11: no Cursor rules, no Windsurf rules, no Cline
4569        // rules, no Gemini settings, no AGENTS.md, no .claude/.
4570        let dir = tempfile::tempdir().unwrap();
4571        let home = tempfile::tempdir().unwrap();
4572        let filter = ToolFilter::Only(vec!["opencode".to_string()]);
4573        let _installed = install_tool_hooks_at(
4574            dir.path(),
4575            "sqz",
4576            InstallScope::Project,
4577            &filter,
4578            Some(home.path()),
4579        );
4580
4581        // OpenCode SHOULD be there.
4582        assert!(
4583            dir.path().join("opencode.json").exists(),
4584            "OpenCode config must be written when --only opencode is used"
4585        );
4586
4587        // None of the other agents' files should exist.
4588        for (path, tool) in &[
4589            (".claude/settings.local.json", "Claude Code"),
4590            (".cursor/rules/sqz.mdc", "Cursor"),
4591            (".windsurfrules", "Windsurf"),
4592            (".clinerules", "Cline"),
4593            (".gemini/settings.json", "Gemini CLI"),
4594            ("AGENTS.md", "Codex"),
4595        ] {
4596            assert!(
4597                !dir.path().join(path).exists(),
4598                "filter rejected {tool} but the installer still wrote {path}"
4599            );
4600        }
4601    }
4602
4603    #[test]
4604    fn filtered_install_skip_cursor_omits_only_cursor() {
4605        // Symmetric: --skip cursor should leave everything else intact.
4606        let dir = tempfile::tempdir().unwrap();
4607        let home = tempfile::tempdir().unwrap();
4608        let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
4609        let _installed = install_tool_hooks_at(
4610            dir.path(),
4611            "sqz",
4612            InstallScope::Project,
4613            &filter,
4614            Some(home.path()),
4615        );
4616
4617        // Cursor rules must NOT exist.
4618        assert!(
4619            !dir.path().join(".cursor/rules/sqz.mdc").exists(),
4620            "skip cursor: .cursor/rules/sqz.mdc must not be written"
4621        );
4622        // Windsurf and Cline rules SHOULD still exist.
4623        assert!(
4624            dir.path().join(".windsurf/rules/sqz.md").exists(),
4625            "skip cursor should not skip windsurf"
4626        );
4627        assert!(
4628            dir.path().join(".clinerules").exists(),
4629            "skip cursor should not skip cline"
4630        );
4631    }
4632}
4633
4634#[cfg(test)]
4635mod ci_hook_tests {
4636    use super::*;
4637
4638    #[test]
4639    fn ci_hook_content_is_valid_v1_config() {
4640        let parsed: serde_json::Value =
4641            serde_json::from_str(&ci_hook_content()).expect("CI hook must be valid JSON");
4642        assert_eq!(parsed["version"], 1);
4643        let session_start = parsed["hooks"]["sessionStart"].as_array().unwrap();
4644        assert!(session_start[0]["bash"]
4645            .as_str()
4646            .unwrap()
4647            .contains("install.sh"));
4648        let pre = parsed["hooks"]["preToolUse"].as_array().unwrap();
4649        let bash = pre[0]["bash"].as_str().unwrap();
4650        assert!(bash.contains("hook copilot"));
4651        // Fail-closed platform: every branch must end in output, never
4652        // a bare non-zero exit.
4653        assert!(bash.ends_with("echo '{}'"));
4654        assert_eq!(pre[0]["matcher"], "bash|powershell");
4655    }
4656
4657    #[test]
4658    fn install_ci_hook_creates_then_noops() {
4659        let dir = tempfile::tempdir().unwrap();
4660        assert!(install_ci_hook(dir.path()).unwrap());
4661        let path = ci_hooks_path(dir.path());
4662        assert!(path.exists());
4663        assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
4664        // Second run: unchanged content, no rewrite reported.
4665        assert!(!install_ci_hook(dir.path()).unwrap());
4666    }
4667
4668    #[test]
4669    fn install_ci_hook_refreshes_stale_sqz_file() {
4670        let dir = tempfile::tempdir().unwrap();
4671        let path = ci_hooks_path(dir.path());
4672        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4673        std::fs::write(&path, r#"{"version":1,"hooks":{"preToolUse":[{"bash":"sqz hook copilot"}]}}"#).unwrap();
4674        assert!(install_ci_hook(dir.path()).unwrap());
4675        assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
4676    }
4677
4678    #[test]
4679    fn install_ci_hook_leaves_user_file_alone() {
4680        let dir = tempfile::tempdir().unwrap();
4681        let path = ci_hooks_path(dir.path());
4682        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4683        std::fs::write(&path, r#"{"version":1,"hooks":{}}"#).unwrap();
4684        assert!(!install_ci_hook(dir.path()).unwrap());
4685        assert_eq!(
4686            std::fs::read_to_string(&path).unwrap(),
4687            r#"{"version":1,"hooks":{}}"#
4688        );
4689    }
4690
4691    #[test]
4692    fn remove_ci_hook_only_removes_sqz_authored() {
4693        let dir = tempfile::tempdir().unwrap();
4694        // Missing file: no-op.
4695        assert!(!remove_ci_hook(dir.path()).unwrap());
4696        // sqz-authored: removed.
4697        install_ci_hook(dir.path()).unwrap();
4698        assert!(remove_ci_hook(dir.path()).unwrap());
4699        assert!(!ci_hooks_path(dir.path()).exists());
4700        // User-owned: left alone.
4701        let path = ci_hooks_path(dir.path());
4702        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4703        std::fs::write(&path, "{}").unwrap();
4704        assert!(!remove_ci_hook(dir.path()).unwrap());
4705        assert!(path.exists());
4706    }
4707
4708    fn read_json(path: &Path) -> serde_json::Value {
4709        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
4710    }
4711
4712    #[test]
4713    fn claude_install_adds_the_post_hook_and_allow_rules_once() {
4714        let tmp = tempfile::tempdir().unwrap();
4715        let path = tmp.path().join(".claude").join("settings.json");
4716        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4717        std::fs::write(&path, r#"{"permissions":{"allow":["Bash(npm test)"],"deny":["Bash(rm *)"]}}"#)
4718            .unwrap();
4719        assert!(install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap());
4720        let v = read_json(&path);
4721        assert_eq!(
4722            v["permissions"]["allow"],
4723            serde_json::json!(["Bash(npm test)", "Bash(set -o pipefail)", "Bash(sqz compress --cmd *)"])
4724        );
4725        assert_eq!(v["permissions"]["deny"], serde_json::json!(["Bash(rm *)"]));
4726        assert_eq!(v["hooks"]["PostToolUse"][0]["matcher"], "Bash");
4727        assert_eq!(
4728            v["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
4729            "/usr/local/bin/sqz hook claude-post"
4730        );
4731        assert!(claude_rules_installed(&[path.clone()]));
4732        assert!(!install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap());
4733
4734        let (_, changed) = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
4735        assert!(changed);
4736        assert_eq!(
4737            read_json(&path),
4738            serde_json::json!({"permissions": {"allow": ["Bash(npm test)"], "deny": ["Bash(rm *)"]}})
4739        );
4740    }
4741
4742    #[test]
4743    fn gemini_merge_and_removal_touch_only_sqz_entries() {
4744        let dir = tempfile::tempdir().unwrap();
4745        let path = dir.path().join(".gemini").join("settings.json");
4746        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4747        let mine = serde_json::json!({"matcher": "write_file", "hooks": [{"type": "command", "command": "lint"}]});
4748        let settings = serde_json::json!({"theme": "x", "hooks": {"BeforeTool": [mine, gemini_hook_entry("/old/sqz")]}});
4749        std::fs::write(&path, settings.to_string()).unwrap();
4750
4751        assert!(gemini_settings_has_sqz(&path));
4752        assert!(gemini_settings_needs_update(&path, "/new/sqz").unwrap());
4753        assert!(merge_sqz_gemini_hook(&path, "/new/sqz").unwrap());
4754        assert!(!gemini_settings_needs_update(&path, "/new/sqz").unwrap());
4755        assert!(!merge_sqz_gemini_hook(&path, "/new/sqz").unwrap());
4756        assert_eq!(
4757            read_json(&path)["hooks"]["BeforeTool"],
4758            serde_json::json!([mine, gemini_hook_entry("/new/sqz")])
4759        );
4760
4761        let (_, changed) = remove_sqz_from_gemini_settings(dir.path()).unwrap().unwrap();
4762        assert!(changed);
4763        assert_eq!(
4764            read_json(&path),
4765            serde_json::json!({"theme": "x", "hooks": {"BeforeTool": [mine]}})
4766        );
4767        assert!(!gemini_settings_has_sqz(&path));
4768        assert_eq!(remove_sqz_from_gemini_settings(dir.path()).unwrap(), Some((path.clone(), false)));
4769
4770        std::fs::write(&path, "{\n  // comment\n  \"theme\": \"x\"\n}\n").unwrap();
4771        assert!(gemini_settings_needs_update(&path, "/new/sqz").is_err());
4772        assert!(!merge_sqz_gemini_hook(&path, "/new/sqz").unwrap_or(false));
4773        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\n  // comment\n  \"theme\": \"x\"\n}\n");
4774    }
4775
4776    #[test]
4777    fn fresh_project_settings_carry_the_rules_and_post_hook() {
4778        let dir = tempfile::tempdir().unwrap();
4779        let path = dir.path().join("settings.local.json");
4780        std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
4781        assert!(claude_rules_installed(&[path.clone()]));
4782        assert_eq!(
4783            read_json(&path)["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
4784            "/usr/bin/sqz hook claude-post"
4785        );
4786        assert!(!claude_project_settings_needs_update(&path, "/usr/bin/sqz").unwrap());
4787
4788        // A file from before the rules existed needs the update.
4789        let mut old = read_json(&path);
4790        old.as_object_mut().unwrap().remove("permissions");
4791        std::fs::write(&path, old.to_string()).unwrap();
4792        assert!(claude_settings_stale(&path));
4793        assert!(claude_project_settings_needs_update(&path, "/usr/bin/sqz").unwrap());
4794        assert!(merge_sqz_hooks_into_settings(&path, "/usr/bin/sqz").unwrap());
4795        assert!(!claude_settings_stale(&path));
4796    }
4797
4798    #[test]
4799    fn project_uninstall_keeps_permissions_claude_saved() {
4800        let dir = tempfile::tempdir().unwrap();
4801        let path = dir.path().join(".claude").join("settings.local.json");
4802        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4803        std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
4804        // "Yes, and don't ask again" lands in the same file.
4805        let mut v = read_json(&path);
4806        v["permissions"]["allow"].as_array_mut().unwrap().push(serde_json::json!("Bash(make)"));
4807        std::fs::write(&path, v.to_string()).unwrap();
4808
4809        let (_, changed) = remove_claude_project_hook(dir.path()).unwrap().unwrap();
4810        assert!(changed);
4811        assert_eq!(read_json(&path), serde_json::json!({"permissions": {"allow": ["Bash(make)"]}}));
4812
4813        // A file holding only sqz's entries goes away entirely.
4814        std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
4815        remove_claude_project_hook(dir.path()).unwrap();
4816        assert!(!path.exists());
4817        assert!(remove_claude_project_hook(dir.path()).unwrap().is_none());
4818    }
4819
4820    fn post(command: &str, response: serde_json::Value) -> String {
4821        serde_json::json!({
4822            "hook_event_name": "PostToolUse",
4823            "tool_name": "Bash",
4824            "tool_input": {"command": command},
4825            "tool_response": response,
4826        })
4827        .to_string()
4828    }
4829
4830    #[test]
4831    fn post_tool_use_picks_successful_bash_output_sqz_never_saw() {
4832        let ok = serde_json::json!({"stdout": "out", "stderr": "", "interrupted": false, "isImage": false});
4833        let t = claude_post_tool_use_target(&post("cargo test | tail -5", ok.clone())).unwrap();
4834        assert_eq!(t.label, "sh: cargo test | tail -5");
4835        assert_eq!(serde_json::Value::Object(t.response), ok);
4836        // One command with output: its formatter can run.
4837        let t = claude_post_tool_use_target(&post("cd a && cargo test", ok.clone())).unwrap();
4838        assert_eq!(t.label, "cargo test");
4839
4840        let skipped = [
4841            post("set -o pipefail; ls 2>&1 | sqz compress --cmd ls", ok.clone()),
4842            post("sqz stats", ok.clone()),
4843            post("ls", serde_json::json!({"stdout": "x", "isImage": true})),
4844            post("ls", serde_json::json!({"stdout": "x", "interrupted": true})),
4845            post("ls", serde_json::json!({"content": "x"})),
4846            post("ls", serde_json::json!({"stdout": "a".repeat(30_000), "stderr": ""})),
4847            serde_json::json!({"hook_event_name": "PreToolUse", "tool_name": "Bash",
4848                "tool_input": {"command": "ls"}, "tool_response": ok}).to_string(),
4849            serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Read",
4850                "tool_input": {"file_path": "/x"}, "tool_response": ok}).to_string(),
4851            serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Bash",
4852                "tool_input": {"command": "make", "run_in_background": true}, "tool_response": ok}).to_string(),
4853            "not json".to_string(),
4854        ];
4855        for input in skipped {
4856            assert_eq!(claude_post_tool_use_target(&input), None, "{input}");
4857        }
4858    }
4859}
4860