1use std::path::{Path, PathBuf};
27
28use crate::error::Result;
29
30#[derive(Debug, Clone)]
32pub struct ToolHookConfig {
33 pub tool_name: String,
35 pub config_path: PathBuf,
37 pub config_content: String,
39 pub scope: HookScope,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HookScope {
45 Project,
47 User,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum HookPlatform {
55 ClaudeCode,
57 Cursor,
59 GeminiCli,
61 Windsurf,
63}
64
65pub fn process_hook(input: &str) -> Result<String> {
84 process_hook_for_platform(input, HookPlatform::ClaudeCode)
85}
86
87pub fn process_hook_cursor(input: &str) -> Result<String> {
92 process_hook_for_platform(input, HookPlatform::Cursor)
93}
94
95pub fn process_hook_gemini(input: &str) -> Result<String> {
99 process_hook_for_platform(input, HookPlatform::GeminiCli)
100}
101
102pub fn process_hook_windsurf(input: &str) -> Result<String> {
107 process_hook_for_platform(input, HookPlatform::Windsurf)
108}
109
110fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
114 let parsed: serde_json::Value = serde_json::from_str(input)
115 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
116
117 let tool_name = parsed
121 .get("tool_name")
122 .or_else(|| parsed.get("toolName"))
123 .and_then(|v| v.as_str())
124 .unwrap_or("");
125
126 let hook_event = parsed
127 .get("hook_event_name")
128 .or_else(|| parsed.get("agent_action_name"))
129 .and_then(|v| v.as_str())
130 .unwrap_or("");
131
132 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
141 | "run_terminal_command" | "run_shell_command")
142 || matches!(hook_event, "beforeShellExecution" | "pre_run_command");
143
144 if !is_shell {
145 return Ok(match platform {
148 HookPlatform::Cursor => "{}".to_string(),
149 _ => input.to_string(),
150 });
151 }
152
153 let command = parsed
158 .get("tool_input")
159 .and_then(|v| v.get("command"))
160 .and_then(|v| v.as_str())
161 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
162 .or_else(|| {
163 parsed
164 .get("tool_info")
165 .and_then(|v| v.get("command_line"))
166 .and_then(|v| v.as_str())
167 })
168 .or_else(|| {
169 parsed
170 .get("toolCall")
171 .and_then(|v| v.get("command"))
172 .and_then(|v| v.as_str())
173 })
174 .unwrap_or("");
175
176 if command.is_empty() {
177 return Ok(match platform {
178 HookPlatform::Cursor => "{}".to_string(),
179 _ => input.to_string(),
180 });
181 }
182
183 let base_cmd = extract_base_command(command);
187 if base_cmd == "sqz" || command.starts_with("SQZ_CMD=") {
188 return Ok(match platform {
189 HookPlatform::Cursor => "{}".to_string(),
190 _ => input.to_string(),
191 });
192 }
193
194 if is_interactive_command(command) {
196 return Ok(match platform {
197 HookPlatform::Cursor => "{}".to_string(),
198 _ => input.to_string(),
199 });
200 }
201
202 if has_shell_operators(command) {
207 return Ok(match platform {
208 HookPlatform::Cursor => "{}".to_string(),
209 _ => input.to_string(),
210 });
211 }
212
213 let rewritten = format!(
216 "SQZ_CMD={} {} 2>&1 | sqz compress",
217 shell_escape(extract_base_command(command)),
218 command
219 );
220
221 let output = match platform {
247 HookPlatform::ClaudeCode => serde_json::json!({
248 "hookSpecificOutput": {
249 "hookEventName": "PreToolUse",
250 "permissionDecision": "allow",
251 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
252 "updatedInput": {
253 "command": rewritten
254 }
255 }
256 }),
257 HookPlatform::Cursor => serde_json::json!({
258 "permission": "allow",
259 "updated_input": {
260 "command": rewritten
261 }
262 }),
263 HookPlatform::GeminiCli => serde_json::json!({
264 "decision": "allow",
265 "hookSpecificOutput": {
266 "tool_input": {
267 "command": rewritten
268 }
269 }
270 }),
271 HookPlatform::Windsurf => {
272 serde_json::json!({
276 "hookSpecificOutput": {
277 "hookEventName": "PreToolUse",
278 "permissionDecision": "allow",
279 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
280 "updatedInput": {
281 "command": rewritten
282 }
283 }
284 })
285 }
286 };
287
288 serde_json::to_string(&output)
289 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
290}
291
292pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
294 let sqz_path_raw = sqz_path;
305 let sqz_path_json = json_escape_string_value(sqz_path);
306 let sqz_path = &sqz_path_json;
307
308 vec![
309 ToolHookConfig {
323 tool_name: "Claude Code".to_string(),
324 config_path: PathBuf::from(".claude/settings.local.json"),
325 config_content: format!(
326 r#"{{
327 "hooks": {{
328 "PreToolUse": [
329 {{
330 "matcher": "Bash",
331 "hooks": [
332 {{
333 "type": "command",
334 "command": "{sqz_path} hook claude"
335 }}
336 ]
337 }}
338 ],
339 "PreCompact": [
340 {{
341 "hooks": [
342 {{
343 "type": "command",
344 "command": "{sqz_path} hook precompact"
345 }}
346 ]
347 }}
348 ],
349 "SessionStart": [
350 {{
351 "matcher": "compact",
352 "hooks": [
353 {{
354 "type": "command",
355 "command": "{sqz_path} resume"
356 }}
357 ]
358 }}
359 ]
360 }}
361}}"#
362 ),
363 scope: HookScope::Project,
364 },
365 ToolHookConfig {
374 tool_name: "Cursor".to_string(),
375 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
376 config_content: format!(
377 r#"---
378description: sqz — Token-Optimized CLI Output
379alwaysApply: true
380---
381
382# sqz — Token-Optimized CLI Output
383
384When running shell commands whose output may be long (directory listings,
385git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
386pipe the output through `sqz compress` to reduce token consumption.
387
388`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
389usage is to pipe the command's output into `sqz compress`:
390
391```bash
392# Instead of: Use:
393git status git status 2>&1 | {sqz_path_raw} compress
394cargo test cargo test 2>&1 | {sqz_path_raw} compress
395git log -10 git log -10 2>&1 | {sqz_path_raw} compress
396docker ps docker ps 2>&1 | {sqz_path_raw} compress
397ls -la ls -la 2>&1 | {sqz_path_raw} compress
398```
399
400The `2>&1` captures stderr too, which is useful for commands like `cargo
401test` where diagnostics go to stderr. `sqz compress` filters and compresses
402the combined output while preserving filenames, paths, and identifiers.
403It typically saves 60-90% tokens on verbose commands.
404
405Do NOT pipe output for:
406- Interactive commands (`vim`, `ssh`, `python`, REPLs)
407- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
408 `cmd; other`) — run those directly
409- Short commands whose output is already a few lines
410
411If `sqz` is not on PATH, run commands normally.
412"#
413 ),
414 scope: HookScope::Project,
415 },
416 ToolHookConfig {
420 tool_name: "Windsurf".to_string(),
421 config_path: PathBuf::from(".windsurfrules"),
422 config_content: format!(
423 r#"# sqz — Token-Optimized CLI Output
424
425Pipe verbose shell command output through `sqz compress` to save tokens.
426`sqz` reads from stdin and writes the compressed output to stdout — it is
427NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
428
429```bash
430# Instead of: Use:
431git status git status 2>&1 | {sqz_path_raw} compress
432cargo test cargo test 2>&1 | {sqz_path_raw} compress
433git log -10 git log -10 2>&1 | {sqz_path_raw} compress
434docker ps docker ps 2>&1 | {sqz_path_raw} compress
435```
436
437sqz filters and compresses command outputs while preserving filenames,
438paths, and identifiers (typically 60-90% token reduction on verbose
439commands). Skip short commands, interactive commands (vim, ssh, python),
440and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
441not on PATH, run commands normally.
442"#
443 ),
444 scope: HookScope::Project,
445 },
446 ToolHookConfig {
450 tool_name: "Cline".to_string(),
451 config_path: PathBuf::from(".clinerules"),
452 config_content: format!(
453 r#"# sqz — Token-Optimized CLI Output
454
455Pipe verbose shell command output through `sqz compress` to save tokens.
456`sqz` reads from stdin and writes the compressed output to stdout — it is
457NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
458
459```bash
460# Instead of: Use:
461git status git status 2>&1 | {sqz_path_raw} compress
462cargo test cargo test 2>&1 | {sqz_path_raw} compress
463git log -10 git log -10 2>&1 | {sqz_path_raw} compress
464docker ps docker ps 2>&1 | {sqz_path_raw} compress
465```
466
467sqz filters and compresses command outputs while preserving filenames,
468paths, and identifiers (typically 60-90% token reduction on verbose
469commands). Skip short commands, interactive commands (vim, ssh, python),
470and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
471not on PATH, run commands normally.
472"#
473 ),
474 scope: HookScope::Project,
475 },
476 ToolHookConfig {
478 tool_name: "Gemini CLI".to_string(),
479 config_path: PathBuf::from(".gemini/settings.json"),
480 config_content: format!(
481 r#"{{
482 "hooks": {{
483 "BeforeTool": [
484 {{
485 "matcher": "run_shell_command",
486 "hooks": [
487 {{
488 "type": "command",
489 "command": "{sqz_path} hook gemini"
490 }}
491 ]
492 }}
493 ]
494 }}
495}}"#
496 ),
497 scope: HookScope::Project,
498 },
499 ToolHookConfig {
508 tool_name: "OpenCode".to_string(),
509 config_path: PathBuf::from("opencode.json"),
510 config_content: format!(
511 r#"{{
512 "$schema": "https://opencode.ai/config.json",
513 "mcp": {{
514 "sqz": {{
515 "type": "local",
516 "command": ["sqz-mcp", "--transport", "stdio"]
517 }}
518 }},
519 "plugin": ["sqz"]
520}}"#
521 ),
522 scope: HookScope::Project,
523 },
524 ToolHookConfig {
543 tool_name: "Codex".to_string(),
544 config_path: PathBuf::from("AGENTS.md"),
545 config_content: crate::codex_integration::agents_md_guidance_block(
546 sqz_path_raw,
547 ),
548 scope: HookScope::Project,
549 },
550 ]
551}
552
553pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
559 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
586pub enum InstallScope {
587 Project,
590 Global,
593}
594
595pub fn install_tool_hooks_scoped(
621 project_dir: &Path,
622 sqz_path: &str,
623 scope: InstallScope,
624) -> Vec<String> {
625 let configs = generate_hook_configs(sqz_path);
626 let mut installed = Vec::new();
627
628 for config in &configs {
629 if config.tool_name == "OpenCode" {
638 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
639 Ok((updated, _comments_lost)) => {
640 if updated && !installed.iter().any(|n| n == "OpenCode") {
641 installed.push("OpenCode".to_string());
642 }
643 }
644 Err(_e) => {
645 }
648 }
649 continue;
650 }
651
652 if config.tool_name == "Codex" {
657 let agents_changed = crate::codex_integration::install_agents_md_guidance(
658 project_dir, sqz_path,
659 )
660 .unwrap_or(false);
661 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
662 .unwrap_or(false);
663 if (agents_changed || mcp_changed)
664 && !installed.iter().any(|n| n == "Codex")
665 {
666 installed.push("Codex".to_string());
667 }
668 continue;
669 }
670
671 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
676 match install_claude_global(sqz_path) {
677 Ok(true) => installed.push("Claude Code".to_string()),
678 Ok(false) => { }
679 Err(_e) => {
680 }
682 }
683 continue;
684 }
685
686 let full_path = project_dir.join(&config.config_path);
687
688 if full_path.exists() {
690 continue;
691 }
692
693 if let Some(parent) = full_path.parent() {
695 if std::fs::create_dir_all(parent).is_err() {
696 continue;
697 }
698 }
699
700 if std::fs::write(&full_path, &config.config_content).is_ok() {
701 installed.push(config.tool_name.clone());
702 }
703 }
704
705 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
711 if !installed.iter().any(|n| n == "OpenCode") {
712 installed.push("OpenCode".to_string());
713 }
714 }
715
716 installed
717}
718
719pub fn claude_user_settings_path() -> Option<PathBuf> {
732 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
733}
734
735fn install_claude_global(sqz_path: &str) -> Result<bool> {
750 let path = claude_user_settings_path().ok_or_else(|| {
751 crate::error::SqzError::Other(
752 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
753 )
754 })?;
755
756 let mut root: serde_json::Value = if path.exists() {
758 let content = std::fs::read_to_string(&path).map_err(|e| {
759 crate::error::SqzError::Other(format!(
760 "read {}: {e}",
761 path.display()
762 ))
763 })?;
764 if content.trim().is_empty() {
765 serde_json::Value::Object(serde_json::Map::new())
766 } else {
767 serde_json::from_str(&content).map_err(|e| {
768 crate::error::SqzError::Other(format!(
769 "parse {}: {e} — please fix or move the file before re-running sqz init",
770 path.display()
771 ))
772 })?
773 }
774 } else {
775 serde_json::Value::Object(serde_json::Map::new())
776 };
777
778 let root_obj = root.as_object_mut().ok_or_else(|| {
781 crate::error::SqzError::Other(format!(
782 "{} is not a JSON object — refusing to overwrite",
783 path.display()
784 ))
785 })?;
786
787 let pre_tool_use = serde_json::json!({
789 "matcher": "Bash",
790 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
791 });
792 let pre_compact = serde_json::json!({
793 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
794 });
795 let session_start = serde_json::json!({
796 "matcher": "compact",
797 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
798 });
799
800 let before = serde_json::to_string(&root_obj).unwrap_or_default();
802
803 let hooks = root_obj
805 .entry("hooks".to_string())
806 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
807 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
808 crate::error::SqzError::Other(format!(
809 "{}: `hooks` is not an object — refusing to overwrite",
810 path.display()
811 ))
812 })?;
813
814 upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "sqz hook claude");
815 upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "sqz hook precompact");
816 upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "sqz resume");
817
818 let after = serde_json::to_string(&root_obj).unwrap_or_default();
819 if before == after && path.exists() {
820 return Ok(false);
822 }
823
824 if let Some(parent) = path.parent() {
826 std::fs::create_dir_all(parent).map_err(|e| {
827 crate::error::SqzError::Other(format!(
828 "create {}: {e}",
829 parent.display()
830 ))
831 })?;
832 }
833
834 let parent = path.parent().ok_or_else(|| {
838 crate::error::SqzError::Other(format!(
839 "path {} has no parent directory",
840 path.display()
841 ))
842 })?;
843 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
844 crate::error::SqzError::Other(format!(
845 "create temp file in {}: {e}",
846 parent.display()
847 ))
848 })?;
849 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
850 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
851 std::fs::write(tmp.path(), serialized).map_err(|e| {
852 crate::error::SqzError::Other(format!(
853 "write to temp file {}: {e}",
854 tmp.path().display()
855 ))
856 })?;
857 tmp.persist(&path).map_err(|e| {
858 crate::error::SqzError::Other(format!(
859 "rename temp file into place at {}: {e}",
860 path.display()
861 ))
862 })?;
863
864 Ok(true)
865}
866
867pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
881 let Some(path) = claude_user_settings_path() else {
882 return Ok(None);
883 };
884 if !path.exists() {
885 return Ok(None);
886 }
887
888 let content = std::fs::read_to_string(&path).map_err(|e| {
889 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
890 })?;
891 if content.trim().is_empty() {
892 return Ok(Some((path, false)));
893 }
894
895 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
896 crate::error::SqzError::Other(format!(
897 "parse {}: {e} — refusing to rewrite an unparseable file",
898 path.display()
899 ))
900 })?;
901 let Some(root_obj) = root.as_object_mut() else {
902 return Ok(Some((path, false)));
903 };
904
905 let mut changed = false;
906 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
907 for (event, sentinel) in &[
908 ("PreToolUse", "sqz hook claude"),
909 ("PreCompact", "sqz hook precompact"),
910 ("SessionStart", "sqz resume"),
911 ] {
912 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
913 let before = arr.len();
914 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
915 if arr.len() != before {
916 changed = true;
917 }
918 }
919 }
920
921 hooks.retain(|_, v| match v {
924 serde_json::Value::Array(a) => !a.is_empty(),
925 _ => true,
926 });
927
928 let hooks_empty = hooks.is_empty();
931 if hooks_empty {
932 root_obj.remove("hooks");
933 changed = true;
934 }
935 }
936
937 if !changed {
938 return Ok(Some((path, false)));
939 }
940
941 if root_obj.is_empty() {
945 std::fs::remove_file(&path).map_err(|e| {
946 crate::error::SqzError::Other(format!(
947 "remove {}: {e}",
948 path.display()
949 ))
950 })?;
951 return Ok(Some((path, true)));
952 }
953
954 let parent = path.parent().ok_or_else(|| {
956 crate::error::SqzError::Other(format!(
957 "path {} has no parent directory",
958 path.display()
959 ))
960 })?;
961 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
962 crate::error::SqzError::Other(format!(
963 "create temp file in {}: {e}",
964 parent.display()
965 ))
966 })?;
967 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
968 .map_err(|e| {
969 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
970 })?;
971 std::fs::write(tmp.path(), serialized).map_err(|e| {
972 crate::error::SqzError::Other(format!(
973 "write to temp file {}: {e}",
974 tmp.path().display()
975 ))
976 })?;
977 tmp.persist(&path).map_err(|e| {
978 crate::error::SqzError::Other(format!(
979 "rename temp file into place at {}: {e}",
980 path.display()
981 ))
982 })?;
983
984 Ok(Some((path, true)))
985}
986
987fn upsert_sqz_hook_entry(
994 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
995 event_name: &str,
996 new_entry: serde_json::Value,
997 sentinel: &str,
998) {
999 let arr = hooks_obj
1000 .entry(event_name.to_string())
1001 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1002 let Some(arr) = arr.as_array_mut() else {
1003 hooks_obj.insert(
1007 event_name.to_string(),
1008 serde_json::Value::Array(vec![new_entry]),
1009 );
1010 return;
1011 };
1012
1013 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1015
1016 arr.push(new_entry);
1017}
1018
1019fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1023 entry
1024 .get("hooks")
1025 .and_then(|h| h.as_array())
1026 .map(|hooks_arr| {
1027 hooks_arr.iter().any(|h| {
1028 h.get("command")
1029 .and_then(|c| c.as_str())
1030 .map(|c| c.contains(needle))
1031 .unwrap_or(false)
1032 })
1033 })
1034 .unwrap_or(false)
1035}
1036
1037fn extract_base_command(cmd: &str) -> &str {
1041 cmd.split_whitespace()
1042 .next()
1043 .unwrap_or("unknown")
1044 .rsplit('/')
1045 .next()
1046 .unwrap_or("unknown")
1047}
1048
1049pub(crate) fn json_escape_string_value(s: &str) -> String {
1060 let mut out = String::with_capacity(s.len() + 2);
1061 for ch in s.chars() {
1062 match ch {
1063 '\\' => out.push_str("\\\\"),
1064 '"' => out.push_str("\\\""),
1065 '\n' => out.push_str("\\n"),
1066 '\r' => out.push_str("\\r"),
1067 '\t' => out.push_str("\\t"),
1068 '\x08' => out.push_str("\\b"),
1069 '\x0c' => out.push_str("\\f"),
1070 c if (c as u32) < 0x20 => {
1071 out.push_str(&format!("\\u{:04x}", c as u32));
1073 }
1074 c => out.push(c),
1075 }
1076 }
1077 out
1078}
1079
1080fn shell_escape(s: &str) -> String {
1082 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1083 s.to_string()
1084 } else {
1085 format!("'{}'", s.replace('\'', "'\\''"))
1086 }
1087}
1088
1089fn has_shell_operators(cmd: &str) -> bool {
1093 cmd.contains("&&")
1096 || cmd.contains("||")
1097 || cmd.contains(';')
1098 || cmd.contains('>')
1099 || cmd.contains('<')
1100 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1106
1107fn is_interactive_command(cmd: &str) -> bool {
1109 let base = extract_base_command(cmd);
1110 matches!(
1111 base,
1112 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1113 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1114 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1115 ) || cmd.contains("--watch")
1116 || cmd.contains("-w ")
1117 || cmd.ends_with(" -w")
1118 || cmd.contains("run dev")
1119 || cmd.contains("run start")
1120 || cmd.contains("run serve")
1121}
1122
1123#[cfg(test)]
1126mod tests {
1127 use super::*;
1128
1129 #[test]
1130 fn test_process_hook_rewrites_bash_command() {
1131 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1133 let result = process_hook(input).unwrap();
1134 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1135 let hook_output = &parsed["hookSpecificOutput"];
1137 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1138 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1139 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1141 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1142 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1143 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
1144 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1146 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1147 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1148 }
1149
1150 #[test]
1151 fn test_process_hook_passes_through_non_bash() {
1152 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1153 let result = process_hook(input).unwrap();
1154 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1155 }
1156
1157 #[test]
1158 fn test_process_hook_skips_sqz_commands() {
1159 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1160 let result = process_hook(input).unwrap();
1161 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1162 }
1163
1164 #[test]
1165 fn test_process_hook_skips_interactive() {
1166 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1167 let result = process_hook(input).unwrap();
1168 assert_eq!(result, input, "interactive commands should pass through");
1169 }
1170
1171 #[test]
1172 fn test_process_hook_skips_watch_mode() {
1173 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1174 let result = process_hook(input).unwrap();
1175 assert_eq!(result, input, "watch mode should pass through");
1176 }
1177
1178 #[test]
1179 fn test_process_hook_empty_command() {
1180 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1181 let result = process_hook(input).unwrap();
1182 assert_eq!(result, input);
1183 }
1184
1185 #[test]
1186 fn test_process_hook_gemini_format() {
1187 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1189 let result = process_hook_gemini(input).unwrap();
1190 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1191 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1193 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1195 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1196 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1198 "Gemini format should not have updatedInput");
1199 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1200 "Gemini format should not have permissionDecision");
1201 }
1202
1203 #[test]
1204 fn test_process_hook_legacy_format() {
1205 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1207 let result = process_hook(input).unwrap();
1208 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1209 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1210 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1211 }
1212
1213 #[test]
1214 fn test_process_hook_cursor_format() {
1215 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1217 let result = process_hook_cursor(input).unwrap();
1218 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1219 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1221 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1222 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1223 assert!(cmd.contains("git status"));
1224 assert!(parsed.get("hookSpecificOutput").is_none(),
1226 "Cursor format should not have hookSpecificOutput");
1227 }
1228
1229 #[test]
1230 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1231 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1233 let result = process_hook_cursor(input).unwrap();
1234 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1235 }
1236
1237 #[test]
1238 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1239 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1241 let result = process_hook_cursor(input).unwrap();
1242 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1243 }
1244
1245 #[test]
1246 fn test_process_hook_windsurf_format() {
1247 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1249 let result = process_hook_windsurf(input).unwrap();
1250 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1251 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1253 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1254 assert!(cmd.contains("cargo test"));
1255 assert!(cmd.contains("SQZ_CMD=cargo"));
1256 }
1257
1258 #[test]
1259 fn test_process_hook_invalid_json() {
1260 let result = process_hook("not json");
1261 assert!(result.is_err());
1262 }
1263
1264 #[test]
1265 fn test_extract_base_command() {
1266 assert_eq!(extract_base_command("git status"), "git");
1267 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1268 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1269 }
1270
1271 #[test]
1272 fn test_is_interactive_command() {
1273 assert!(is_interactive_command("vim file.txt"));
1274 assert!(is_interactive_command("npm run dev --watch"));
1275 assert!(is_interactive_command("python3"));
1276 assert!(!is_interactive_command("git status"));
1277 assert!(!is_interactive_command("cargo test"));
1278 }
1279
1280 #[test]
1281 fn test_generate_hook_configs() {
1282 let configs = generate_hook_configs("sqz");
1283 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1284 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1285 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1286 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1287 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1290 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1291 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1292 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1293 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1294 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1295 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1299 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1300 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1301 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1302 assert!(cursor.config_content.starts_with("---"),
1303 "Cursor rule should start with YAML frontmatter");
1304 assert!(cursor.config_content.contains("alwaysApply: true"),
1305 "Cursor rule should use alwaysApply: true so the guidance loads \
1306 for every agent interaction");
1307 assert!(cursor.config_content.contains("sqz"),
1308 "Cursor rule body should mention sqz");
1309 }
1310
1311 #[test]
1312 fn test_claude_config_includes_precompact_hook() {
1313 let configs = generate_hook_configs("sqz");
1318 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1319 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1320 .expect("Claude Code config must be valid JSON");
1321
1322 let precompact = parsed["hooks"]["PreCompact"]
1323 .as_array()
1324 .expect("PreCompact hook array must be present");
1325 assert!(
1326 !precompact.is_empty(),
1327 "PreCompact must have at least one registered hook"
1328 );
1329
1330 let cmd = precompact[0]["hooks"][0]["command"]
1331 .as_str()
1332 .expect("command field must be a string");
1333 assert!(
1334 cmd.ends_with(" hook precompact"),
1335 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1336 );
1337 }
1338
1339 #[test]
1342 fn test_json_escape_string_value() {
1343 assert_eq!(json_escape_string_value("sqz"), "sqz");
1345 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1346 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1348 r"C:\\Users\\Alice\\sqz.exe");
1349 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1351 r#"path with \"quotes\""#);
1352 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1354 }
1355
1356 #[test]
1357 fn test_windows_path_produces_valid_json_for_claude() {
1358 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1361 let configs = generate_hook_configs(windows_path);
1362
1363 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1364 .expect("Claude config should be generated");
1365 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1366 .expect("Claude hook config must be valid JSON on Windows paths");
1367
1368 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1370 .as_str()
1371 .expect("command field must be a string");
1372 assert!(cmd.contains(windows_path),
1373 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1374 }
1375
1376 #[test]
1377 fn test_windows_path_in_cursor_rules_file() {
1378 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1384 let configs = generate_hook_configs(windows_path);
1385
1386 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1387 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1388 assert!(cursor.config_content.contains(windows_path),
1389 "Cursor rule must contain the raw (unescaped) path so users can \
1390 copy-paste the shown commands — got:\n{}", cursor.config_content);
1391 assert!(!cursor.config_content.contains(r"C:\\Users"),
1392 "Cursor rule must NOT double-escape backslashes in markdown — \
1393 got:\n{}", cursor.config_content);
1394 }
1395
1396 #[test]
1397 fn test_windows_path_produces_valid_json_for_gemini() {
1398 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1399 let configs = generate_hook_configs(windows_path);
1400
1401 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1402 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1403 .expect("Gemini hook config must be valid JSON on Windows paths");
1404 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1405 assert!(cmd.contains(windows_path));
1406 }
1407
1408 #[test]
1409 fn test_rules_files_use_raw_path_for_readability() {
1410 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1414 let configs = generate_hook_configs(windows_path);
1415
1416 for tool in &["Windsurf", "Cline", "Cursor"] {
1417 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1418 assert!(cfg.config_content.contains(windows_path),
1419 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1420 cfg.config_content);
1421 assert!(!cfg.config_content.contains(r"C:\\Users"),
1422 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1423 cfg.config_content);
1424 }
1425 }
1426
1427 #[test]
1428 fn test_unix_path_still_works() {
1429 let unix_path = "/usr/local/bin/sqz";
1432 let configs = generate_hook_configs(unix_path);
1433
1434 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1435 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1436 .expect("Unix path should produce valid JSON");
1437 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1438 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1439 }
1440
1441 #[test]
1442 fn test_shell_escape_simple() {
1443 assert_eq!(shell_escape("git"), "git");
1444 assert_eq!(shell_escape("cargo-test"), "cargo-test");
1445 }
1446
1447 #[test]
1448 fn test_shell_escape_special_chars() {
1449 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1450 }
1451
1452 #[test]
1453 fn test_install_tool_hooks_creates_files() {
1454 let dir = tempfile::tempdir().unwrap();
1455 let installed = install_tool_hooks(dir.path(), "sqz");
1456 assert!(!installed.is_empty(), "should install at least one hook config");
1458 for name in &installed {
1460 let configs = generate_hook_configs("sqz");
1461 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1462 let path = dir.path().join(&config.config_path);
1463 assert!(path.exists(), "hook config should exist: {}", path.display());
1464 }
1465 }
1466
1467 #[test]
1468 fn test_install_tool_hooks_does_not_overwrite() {
1469 let dir = tempfile::tempdir().unwrap();
1470 install_tool_hooks(dir.path(), "sqz");
1472 let custom_path = dir.path().join(".claude/settings.local.json");
1474 std::fs::write(&custom_path, "custom content").unwrap();
1475 install_tool_hooks(dir.path(), "sqz");
1477 let content = std::fs::read_to_string(&custom_path).unwrap();
1478 assert_eq!(content, "custom content", "should not overwrite existing config");
1479 }
1480}
1481
1482#[cfg(test)]
1483mod global_install_tests {
1484 use super::*;
1485
1486 fn with_fake_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
1500 use std::sync::Mutex;
1501 static LOCK: Mutex<()> = Mutex::new(());
1503 let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1504
1505 let prev_home = std::env::var_os("HOME");
1506 let prev_userprofile = std::env::var_os("USERPROFILE");
1507 std::env::set_var("HOME", tmp);
1508 std::env::set_var("USERPROFILE", tmp);
1509 let result = body();
1510 match prev_home {
1511 Some(v) => std::env::set_var("HOME", v),
1512 None => std::env::remove_var("HOME"),
1513 }
1514 match prev_userprofile {
1515 Some(v) => std::env::set_var("USERPROFILE", v),
1516 None => std::env::remove_var("USERPROFILE"),
1517 }
1518 result
1519 }
1520
1521 #[test]
1522 fn global_install_creates_fresh_settings_json() {
1523 let tmp = tempfile::tempdir().unwrap();
1524 with_fake_home(tmp.path(), || {
1525 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1526 assert!(changed, "first install should report a change");
1527
1528 let path = tmp.path().join(".claude").join("settings.json");
1529 assert!(path.exists(), "user settings.json should be created");
1530
1531 let content = std::fs::read_to_string(&path).unwrap();
1532 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1533
1534 let pre = &parsed["hooks"]["PreToolUse"];
1536 assert!(pre.is_array(), "PreToolUse should be an array");
1537 assert_eq!(pre.as_array().unwrap().len(), 1);
1538 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1539 assert!(
1540 cmd.contains("/usr/local/bin/sqz"),
1541 "hook command should use the passed sqz_path, got: {cmd}"
1542 );
1543 assert!(cmd.contains("hook claude"));
1544
1545 let precompact = &parsed["hooks"]["PreCompact"];
1546 assert!(precompact.is_array());
1547 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1548 assert!(precompact_cmd.contains("hook precompact"));
1549
1550 let session = &parsed["hooks"]["SessionStart"];
1551 assert!(session.is_array());
1552 assert_eq!(
1553 session[0]["matcher"].as_str().unwrap(),
1554 "compact",
1555 "SessionStart should only match /compact resume"
1556 );
1557 });
1558 }
1559
1560 #[test]
1561 fn global_install_preserves_existing_user_config() {
1562 let tmp = tempfile::tempdir().unwrap();
1566 let settings = tmp.path().join(".claude").join("settings.json");
1567 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1568
1569 let existing = serde_json::json!({
1570 "permissions": {
1571 "allow": ["Bash(npm test *)"],
1572 "deny": ["Read(./.env)"]
1573 },
1574 "env": { "FOO": "bar" },
1575 "statusLine": {
1576 "type": "command",
1577 "command": "~/.claude/statusline.sh"
1578 },
1579 "hooks": {
1580 "PreToolUse": [
1581 {
1582 "matcher": "Edit",
1583 "hooks": [
1584 {
1585 "type": "command",
1586 "command": "~/.claude/hooks/format-on-edit.sh"
1587 }
1588 ]
1589 }
1590 ]
1591 }
1592 });
1593 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
1594
1595 with_fake_home(tmp.path(), || {
1596 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1597 assert!(changed, "install should report a change on new hook");
1598
1599 let content = std::fs::read_to_string(&settings).unwrap();
1600 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1601
1602 assert_eq!(
1604 parsed["permissions"]["allow"][0].as_str().unwrap(),
1605 "Bash(npm test *)"
1606 );
1607 assert_eq!(
1608 parsed["permissions"]["deny"][0].as_str().unwrap(),
1609 "Read(./.env)"
1610 );
1611 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
1613 assert_eq!(
1615 parsed["statusLine"]["command"].as_str().unwrap(),
1616 "~/.claude/statusline.sh"
1617 );
1618
1619 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1622 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
1623 let matchers: Vec<&str> = pre
1624 .iter()
1625 .map(|e| e["matcher"].as_str().unwrap_or(""))
1626 .collect();
1627 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
1628 assert!(matchers.contains(&"Bash"), "sqz Bash hook must be present");
1629 });
1630 }
1631
1632 #[test]
1633 fn global_install_is_idempotent() {
1634 let tmp = tempfile::tempdir().unwrap();
1638 with_fake_home(tmp.path(), || {
1639 assert!(install_claude_global("sqz").unwrap());
1640 assert!(
1643 !install_claude_global("sqz").unwrap(),
1644 "second install with identical args should report no change"
1645 );
1646
1647 let path = tmp.path().join(".claude").join("settings.json");
1648 let parsed: serde_json::Value =
1649 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1650 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
1652 let arr = parsed["hooks"][event].as_array().unwrap();
1653 assert_eq!(
1654 arr.len(),
1655 1,
1656 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
1657 );
1658 }
1659 });
1660 }
1661
1662 #[test]
1663 fn global_install_upgrades_stale_sqz_hook_in_place() {
1664 let tmp = tempfile::tempdir().unwrap();
1668 with_fake_home(tmp.path(), || {
1669 install_claude_global("/old/path/sqz").unwrap();
1671 let changed = install_claude_global("/new/path/sqz").unwrap();
1673 assert!(changed, "different sqz_path must be seen as a change");
1674
1675 let path = tmp.path().join(".claude").join("settings.json");
1676 let parsed: serde_json::Value =
1677 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1678 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1679 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
1680 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1681 assert!(cmd.contains("/new/path/sqz"));
1682 assert!(!cmd.contains("/old/path/sqz"));
1683 });
1684 }
1685
1686 #[test]
1687 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
1688 let tmp = tempfile::tempdir().unwrap();
1689 let settings = tmp.path().join(".claude").join("settings.json");
1690 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1691 std::fs::write(
1692 &settings,
1693 serde_json::json!({
1694 "permissions": { "allow": ["Bash(git status)"] },
1695 "hooks": {
1696 "PreToolUse": [
1697 {
1698 "matcher": "Edit",
1699 "hooks": [
1700 { "type": "command", "command": "~/format.sh" }
1701 ]
1702 }
1703 ]
1704 }
1705 })
1706 .to_string(),
1707 )
1708 .unwrap();
1709
1710 with_fake_home(tmp.path(), || {
1711 install_claude_global("/usr/local/bin/sqz").unwrap();
1713 let result = remove_claude_global_hook().unwrap().unwrap();
1715 assert_eq!(result.0, settings);
1716 assert!(result.1, "should report that the file was modified");
1717
1718 assert!(settings.exists(), "settings.json should be preserved");
1720 let parsed: serde_json::Value =
1721 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
1722
1723 assert_eq!(
1725 parsed["permissions"]["allow"][0].as_str().unwrap(),
1726 "Bash(git status)"
1727 );
1728
1729 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1731 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
1732 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
1733
1734 assert!(parsed["hooks"].get("PreCompact").is_none());
1736 assert!(parsed["hooks"].get("SessionStart").is_none());
1737 });
1738 }
1739
1740 #[test]
1741 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
1742 let tmp = tempfile::tempdir().unwrap();
1746 with_fake_home(tmp.path(), || {
1747 install_claude_global("sqz").unwrap();
1748 let path = tmp.path().join(".claude").join("settings.json");
1749 assert!(path.exists(), "precondition: install created the file");
1750
1751 let result = remove_claude_global_hook().unwrap().unwrap();
1752 assert!(result.1);
1753 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
1754 });
1755 }
1756
1757 #[test]
1758 fn global_uninstall_on_missing_file_is_noop() {
1759 let tmp = tempfile::tempdir().unwrap();
1760 with_fake_home(tmp.path(), || {
1761 assert!(
1762 remove_claude_global_hook().unwrap().is_none(),
1763 "missing file should return None, not error"
1764 );
1765 });
1766 }
1767
1768 #[test]
1769 fn global_uninstall_refuses_to_touch_unparseable_file() {
1770 let tmp = tempfile::tempdir().unwrap();
1774 let settings = tmp.path().join(".claude").join("settings.json");
1775 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1776 std::fs::write(&settings, "{ invalid json because").unwrap();
1777
1778 with_fake_home(tmp.path(), || {
1779 assert!(
1780 remove_claude_global_hook().is_err(),
1781 "bad JSON must surface as an error"
1782 );
1783 });
1784
1785 let after = std::fs::read_to_string(&settings).unwrap();
1787 assert_eq!(after, "{ invalid json because");
1788 }
1789}