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 let escape_hatch = format!(
316 r#"## Escape hatch — when sqz output confuses the agent
317
318If you see a `§ref:HASH§` token in tool output and can't parse it, or if
319compressed output is making it harder to work (loops, repeated tiny
320requests), you have three ways out:
321
322- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
323 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
324 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
325- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
326 status 2>&1 | sqz compress`) to disable dedup for that one command.
327 Strictly more tokens, strictly less ambiguous.
328- **`--no-cache`** — the same opt-out as a CLI flag:
329 `git status 2>&1 | sqz compress --no-cache`.
330
331Use these when you genuinely need raw data. The default compressed
332path is still what you want for most output."#,
333 );
334 let escape_hatch = &escape_hatch;
335
336 vec![
337 ToolHookConfig {
351 tool_name: "Claude Code".to_string(),
352 config_path: PathBuf::from(".claude/settings.local.json"),
353 config_content: format!(
354 r#"{{
355 "hooks": {{
356 "PreToolUse": [
357 {{
358 "matcher": "Bash",
359 "hooks": [
360 {{
361 "type": "command",
362 "command": "{sqz_path} hook claude"
363 }}
364 ]
365 }}
366 ],
367 "PreCompact": [
368 {{
369 "hooks": [
370 {{
371 "type": "command",
372 "command": "{sqz_path} hook precompact"
373 }}
374 ]
375 }}
376 ],
377 "SessionStart": [
378 {{
379 "matcher": "compact",
380 "hooks": [
381 {{
382 "type": "command",
383 "command": "{sqz_path} resume"
384 }}
385 ]
386 }}
387 ]
388 }}
389}}"#
390 ),
391 scope: HookScope::Project,
392 },
393 ToolHookConfig {
402 tool_name: "Cursor".to_string(),
403 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
404 config_content: format!(
405 r#"---
406description: sqz — Token-Optimized CLI Output
407alwaysApply: true
408---
409
410# sqz — Token-Optimized CLI Output
411
412When running shell commands whose output may be long (directory listings,
413git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
414pipe the output through `sqz compress` to reduce token consumption.
415
416`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
417usage is to pipe the command's output into `sqz compress`:
418
419```bash
420# Instead of: Use:
421git status git status 2>&1 | {sqz_path_raw} compress
422cargo test cargo test 2>&1 | {sqz_path_raw} compress
423git log -10 git log -10 2>&1 | {sqz_path_raw} compress
424docker ps docker ps 2>&1 | {sqz_path_raw} compress
425ls -la ls -la 2>&1 | {sqz_path_raw} compress
426```
427
428The `2>&1` captures stderr too, which is useful for commands like `cargo
429test` where diagnostics go to stderr. `sqz compress` filters and compresses
430the combined output while preserving filenames, paths, and identifiers.
431It typically saves 60-90% tokens on verbose commands.
432
433Do NOT pipe output for:
434- Interactive commands (`vim`, `ssh`, `python`, REPLs)
435- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
436 `cmd; other`) — run those directly
437- Short commands whose output is already a few lines
438
439If `sqz` is not on PATH, run commands normally.
440
441{escape_hatch}
442"#
443 ),
444 scope: HookScope::Project,
445 },
446 ToolHookConfig {
450 tool_name: "Windsurf".to_string(),
451 config_path: PathBuf::from(".windsurfrules"),
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{escape_hatch}
474"#
475 ),
476 scope: HookScope::Project,
477 },
478 ToolHookConfig {
482 tool_name: "Cline".to_string(),
483 config_path: PathBuf::from(".clinerules"),
484 config_content: format!(
485 r#"# sqz — Token-Optimized CLI Output
486
487Pipe verbose shell command output through `sqz compress` to save tokens.
488`sqz` reads from stdin and writes the compressed output to stdout — it is
489NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
490
491```bash
492# Instead of: Use:
493git status git status 2>&1 | {sqz_path_raw} compress
494cargo test cargo test 2>&1 | {sqz_path_raw} compress
495git log -10 git log -10 2>&1 | {sqz_path_raw} compress
496docker ps docker ps 2>&1 | {sqz_path_raw} compress
497```
498
499sqz filters and compresses command outputs while preserving filenames,
500paths, and identifiers (typically 60-90% token reduction on verbose
501commands). Skip short commands, interactive commands (vim, ssh, python),
502and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
503not on PATH, run commands normally.
504
505{escape_hatch}
506"#
507 ),
508 scope: HookScope::Project,
509 },
510 ToolHookConfig {
512 tool_name: "Gemini CLI".to_string(),
513 config_path: PathBuf::from(".gemini/settings.json"),
514 config_content: format!(
515 r#"{{
516 "hooks": {{
517 "BeforeTool": [
518 {{
519 "matcher": "run_shell_command",
520 "hooks": [
521 {{
522 "type": "command",
523 "command": "{sqz_path} hook gemini"
524 }}
525 ]
526 }}
527 ]
528 }}
529}}"#
530 ),
531 scope: HookScope::Project,
532 },
533 ToolHookConfig {
542 tool_name: "OpenCode".to_string(),
543 config_path: PathBuf::from("opencode.json"),
544 config_content: format!(
545 r#"{{
546 "$schema": "https://opencode.ai/config.json",
547 "mcp": {{
548 "sqz": {{
549 "type": "local",
550 "command": ["sqz-mcp", "--transport", "stdio"]
551 }}
552 }},
553 "plugin": ["sqz"]
554}}"#
555 ),
556 scope: HookScope::Project,
557 },
558 ToolHookConfig {
577 tool_name: "Codex".to_string(),
578 config_path: PathBuf::from("AGENTS.md"),
579 config_content: crate::codex_integration::agents_md_guidance_block(
580 sqz_path_raw,
581 ),
582 scope: HookScope::Project,
583 },
584 ]
585}
586
587pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
593 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub enum InstallScope {
621 Project,
624 Global,
627}
628
629pub fn install_tool_hooks_scoped(
655 project_dir: &Path,
656 sqz_path: &str,
657 scope: InstallScope,
658) -> Vec<String> {
659 let configs = generate_hook_configs(sqz_path);
660 let mut installed = Vec::new();
661
662 for config in &configs {
663 if config.tool_name == "OpenCode" {
672 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
673 Ok((updated, _comments_lost)) => {
674 if updated && !installed.iter().any(|n| n == "OpenCode") {
675 installed.push("OpenCode".to_string());
676 }
677 }
678 Err(_e) => {
679 }
682 }
683 continue;
684 }
685
686 if config.tool_name == "Codex" {
691 let agents_changed = crate::codex_integration::install_agents_md_guidance(
692 project_dir, sqz_path,
693 )
694 .unwrap_or(false);
695 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
696 .unwrap_or(false);
697 if (agents_changed || mcp_changed)
698 && !installed.iter().any(|n| n == "Codex")
699 {
700 installed.push("Codex".to_string());
701 }
702 continue;
703 }
704
705 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
710 match install_claude_global(sqz_path) {
711 Ok(true) => installed.push("Claude Code".to_string()),
712 Ok(false) => { }
713 Err(_e) => {
714 }
716 }
717 continue;
718 }
719
720 let full_path = project_dir.join(&config.config_path);
721
722 if full_path.exists() {
724 continue;
725 }
726
727 if let Some(parent) = full_path.parent() {
729 if std::fs::create_dir_all(parent).is_err() {
730 continue;
731 }
732 }
733
734 if std::fs::write(&full_path, &config.config_content).is_ok() {
735 installed.push(config.tool_name.clone());
736 }
737 }
738
739 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
745 if !installed.iter().any(|n| n == "OpenCode") {
746 installed.push("OpenCode".to_string());
747 }
748 }
749
750 installed
751}
752
753pub fn claude_user_settings_path() -> Option<PathBuf> {
766 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
767}
768
769fn install_claude_global(sqz_path: &str) -> Result<bool> {
784 let path = claude_user_settings_path().ok_or_else(|| {
785 crate::error::SqzError::Other(
786 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
787 )
788 })?;
789
790 let mut root: serde_json::Value = if path.exists() {
792 let content = std::fs::read_to_string(&path).map_err(|e| {
793 crate::error::SqzError::Other(format!(
794 "read {}: {e}",
795 path.display()
796 ))
797 })?;
798 if content.trim().is_empty() {
799 serde_json::Value::Object(serde_json::Map::new())
800 } else {
801 serde_json::from_str(&content).map_err(|e| {
802 crate::error::SqzError::Other(format!(
803 "parse {}: {e} — please fix or move the file before re-running sqz init",
804 path.display()
805 ))
806 })?
807 }
808 } else {
809 serde_json::Value::Object(serde_json::Map::new())
810 };
811
812 let root_obj = root.as_object_mut().ok_or_else(|| {
815 crate::error::SqzError::Other(format!(
816 "{} is not a JSON object — refusing to overwrite",
817 path.display()
818 ))
819 })?;
820
821 let pre_tool_use = serde_json::json!({
823 "matcher": "Bash",
824 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
825 });
826 let pre_compact = serde_json::json!({
827 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
828 });
829 let session_start = serde_json::json!({
830 "matcher": "compact",
831 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
832 });
833
834 let before = serde_json::to_string(&root_obj).unwrap_or_default();
836
837 let hooks = root_obj
839 .entry("hooks".to_string())
840 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
841 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
842 crate::error::SqzError::Other(format!(
843 "{}: `hooks` is not an object — refusing to overwrite",
844 path.display()
845 ))
846 })?;
847
848 upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "sqz hook claude");
849 upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "sqz hook precompact");
850 upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "sqz resume");
851
852 let after = serde_json::to_string(&root_obj).unwrap_or_default();
853 if before == after && path.exists() {
854 return Ok(false);
856 }
857
858 if let Some(parent) = path.parent() {
860 std::fs::create_dir_all(parent).map_err(|e| {
861 crate::error::SqzError::Other(format!(
862 "create {}: {e}",
863 parent.display()
864 ))
865 })?;
866 }
867
868 let parent = path.parent().ok_or_else(|| {
872 crate::error::SqzError::Other(format!(
873 "path {} has no parent directory",
874 path.display()
875 ))
876 })?;
877 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
878 crate::error::SqzError::Other(format!(
879 "create temp file in {}: {e}",
880 parent.display()
881 ))
882 })?;
883 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
884 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
885 std::fs::write(tmp.path(), serialized).map_err(|e| {
886 crate::error::SqzError::Other(format!(
887 "write to temp file {}: {e}",
888 tmp.path().display()
889 ))
890 })?;
891 tmp.persist(&path).map_err(|e| {
892 crate::error::SqzError::Other(format!(
893 "rename temp file into place at {}: {e}",
894 path.display()
895 ))
896 })?;
897
898 Ok(true)
899}
900
901pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
915 let Some(path) = claude_user_settings_path() else {
916 return Ok(None);
917 };
918 if !path.exists() {
919 return Ok(None);
920 }
921
922 let content = std::fs::read_to_string(&path).map_err(|e| {
923 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
924 })?;
925 if content.trim().is_empty() {
926 return Ok(Some((path, false)));
927 }
928
929 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
930 crate::error::SqzError::Other(format!(
931 "parse {}: {e} — refusing to rewrite an unparseable file",
932 path.display()
933 ))
934 })?;
935 let Some(root_obj) = root.as_object_mut() else {
936 return Ok(Some((path, false)));
937 };
938
939 let mut changed = false;
940 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
941 for (event, sentinel) in &[
942 ("PreToolUse", "sqz hook claude"),
943 ("PreCompact", "sqz hook precompact"),
944 ("SessionStart", "sqz resume"),
945 ] {
946 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
947 let before = arr.len();
948 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
949 if arr.len() != before {
950 changed = true;
951 }
952 }
953 }
954
955 hooks.retain(|_, v| match v {
958 serde_json::Value::Array(a) => !a.is_empty(),
959 _ => true,
960 });
961
962 let hooks_empty = hooks.is_empty();
965 if hooks_empty {
966 root_obj.remove("hooks");
967 changed = true;
968 }
969 }
970
971 if !changed {
972 return Ok(Some((path, false)));
973 }
974
975 if root_obj.is_empty() {
979 std::fs::remove_file(&path).map_err(|e| {
980 crate::error::SqzError::Other(format!(
981 "remove {}: {e}",
982 path.display()
983 ))
984 })?;
985 return Ok(Some((path, true)));
986 }
987
988 let parent = path.parent().ok_or_else(|| {
990 crate::error::SqzError::Other(format!(
991 "path {} has no parent directory",
992 path.display()
993 ))
994 })?;
995 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
996 crate::error::SqzError::Other(format!(
997 "create temp file in {}: {e}",
998 parent.display()
999 ))
1000 })?;
1001 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1002 .map_err(|e| {
1003 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1004 })?;
1005 std::fs::write(tmp.path(), serialized).map_err(|e| {
1006 crate::error::SqzError::Other(format!(
1007 "write to temp file {}: {e}",
1008 tmp.path().display()
1009 ))
1010 })?;
1011 tmp.persist(&path).map_err(|e| {
1012 crate::error::SqzError::Other(format!(
1013 "rename temp file into place at {}: {e}",
1014 path.display()
1015 ))
1016 })?;
1017
1018 Ok(Some((path, true)))
1019}
1020
1021fn upsert_sqz_hook_entry(
1028 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1029 event_name: &str,
1030 new_entry: serde_json::Value,
1031 sentinel: &str,
1032) {
1033 let arr = hooks_obj
1034 .entry(event_name.to_string())
1035 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1036 let Some(arr) = arr.as_array_mut() else {
1037 hooks_obj.insert(
1041 event_name.to_string(),
1042 serde_json::Value::Array(vec![new_entry]),
1043 );
1044 return;
1045 };
1046
1047 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1049
1050 arr.push(new_entry);
1051}
1052
1053fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1057 entry
1058 .get("hooks")
1059 .and_then(|h| h.as_array())
1060 .map(|hooks_arr| {
1061 hooks_arr.iter().any(|h| {
1062 h.get("command")
1063 .and_then(|c| c.as_str())
1064 .map(|c| c.contains(needle))
1065 .unwrap_or(false)
1066 })
1067 })
1068 .unwrap_or(false)
1069}
1070
1071fn extract_base_command(cmd: &str) -> &str {
1075 cmd.split_whitespace()
1076 .next()
1077 .unwrap_or("unknown")
1078 .rsplit('/')
1079 .next()
1080 .unwrap_or("unknown")
1081}
1082
1083pub(crate) fn json_escape_string_value(s: &str) -> String {
1094 let mut out = String::with_capacity(s.len() + 2);
1095 for ch in s.chars() {
1096 match ch {
1097 '\\' => out.push_str("\\\\"),
1098 '"' => out.push_str("\\\""),
1099 '\n' => out.push_str("\\n"),
1100 '\r' => out.push_str("\\r"),
1101 '\t' => out.push_str("\\t"),
1102 '\x08' => out.push_str("\\b"),
1103 '\x0c' => out.push_str("\\f"),
1104 c if (c as u32) < 0x20 => {
1105 out.push_str(&format!("\\u{:04x}", c as u32));
1107 }
1108 c => out.push(c),
1109 }
1110 }
1111 out
1112}
1113
1114fn shell_escape(s: &str) -> String {
1116 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1117 s.to_string()
1118 } else {
1119 format!("'{}'", s.replace('\'', "'\\''"))
1120 }
1121}
1122
1123fn has_shell_operators(cmd: &str) -> bool {
1127 cmd.contains("&&")
1130 || cmd.contains("||")
1131 || cmd.contains(';')
1132 || cmd.contains('>')
1133 || cmd.contains('<')
1134 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1140
1141fn is_interactive_command(cmd: &str) -> bool {
1143 let base = extract_base_command(cmd);
1144 matches!(
1145 base,
1146 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1147 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1148 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1149 ) || cmd.contains("--watch")
1150 || cmd.contains("-w ")
1151 || cmd.ends_with(" -w")
1152 || cmd.contains("run dev")
1153 || cmd.contains("run start")
1154 || cmd.contains("run serve")
1155}
1156
1157#[cfg(test)]
1160mod tests {
1161 use super::*;
1162
1163 #[test]
1164 fn test_process_hook_rewrites_bash_command() {
1165 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1167 let result = process_hook(input).unwrap();
1168 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1169 let hook_output = &parsed["hookSpecificOutput"];
1171 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1172 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1173 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1175 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1176 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1177 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
1178 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1180 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1181 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1182 }
1183
1184 #[test]
1185 fn test_process_hook_passes_through_non_bash() {
1186 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1187 let result = process_hook(input).unwrap();
1188 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1189 }
1190
1191 #[test]
1192 fn test_process_hook_skips_sqz_commands() {
1193 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1194 let result = process_hook(input).unwrap();
1195 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1196 }
1197
1198 #[test]
1199 fn test_process_hook_skips_interactive() {
1200 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1201 let result = process_hook(input).unwrap();
1202 assert_eq!(result, input, "interactive commands should pass through");
1203 }
1204
1205 #[test]
1206 fn test_process_hook_skips_watch_mode() {
1207 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1208 let result = process_hook(input).unwrap();
1209 assert_eq!(result, input, "watch mode should pass through");
1210 }
1211
1212 #[test]
1213 fn test_process_hook_empty_command() {
1214 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1215 let result = process_hook(input).unwrap();
1216 assert_eq!(result, input);
1217 }
1218
1219 #[test]
1220 fn test_process_hook_gemini_format() {
1221 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1223 let result = process_hook_gemini(input).unwrap();
1224 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1225 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1227 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1229 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1230 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1232 "Gemini format should not have updatedInput");
1233 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1234 "Gemini format should not have permissionDecision");
1235 }
1236
1237 #[test]
1238 fn test_process_hook_legacy_format() {
1239 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1241 let result = process_hook(input).unwrap();
1242 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1243 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1244 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1245 }
1246
1247 #[test]
1248 fn test_process_hook_cursor_format() {
1249 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1251 let result = process_hook_cursor(input).unwrap();
1252 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1253 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1255 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1256 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1257 assert!(cmd.contains("git status"));
1258 assert!(parsed.get("hookSpecificOutput").is_none(),
1260 "Cursor format should not have hookSpecificOutput");
1261 }
1262
1263 #[test]
1264 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1265 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1267 let result = process_hook_cursor(input).unwrap();
1268 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1269 }
1270
1271 #[test]
1272 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1273 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1275 let result = process_hook_cursor(input).unwrap();
1276 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1277 }
1278
1279 #[test]
1280 fn test_process_hook_windsurf_format() {
1281 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1283 let result = process_hook_windsurf(input).unwrap();
1284 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1285 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1287 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1288 assert!(cmd.contains("cargo test"));
1289 assert!(cmd.contains("SQZ_CMD=cargo"));
1290 }
1291
1292 #[test]
1293 fn test_process_hook_invalid_json() {
1294 let result = process_hook("not json");
1295 assert!(result.is_err());
1296 }
1297
1298 #[test]
1299 fn test_extract_base_command() {
1300 assert_eq!(extract_base_command("git status"), "git");
1301 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1302 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1303 }
1304
1305 #[test]
1306 fn test_is_interactive_command() {
1307 assert!(is_interactive_command("vim file.txt"));
1308 assert!(is_interactive_command("npm run dev --watch"));
1309 assert!(is_interactive_command("python3"));
1310 assert!(!is_interactive_command("git status"));
1311 assert!(!is_interactive_command("cargo test"));
1312 }
1313
1314 #[test]
1315 fn test_generate_hook_configs() {
1316 let configs = generate_hook_configs("sqz");
1317 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1318 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1319 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1320 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1321 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1324 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1325 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1326 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1327 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1328 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1329 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1333 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1334 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1335 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1336 assert!(cursor.config_content.starts_with("---"),
1337 "Cursor rule should start with YAML frontmatter");
1338 assert!(cursor.config_content.contains("alwaysApply: true"),
1339 "Cursor rule should use alwaysApply: true so the guidance loads \
1340 for every agent interaction");
1341 assert!(cursor.config_content.contains("sqz"),
1342 "Cursor rule body should mention sqz");
1343 }
1344
1345 #[test]
1346 fn test_claude_config_includes_precompact_hook() {
1347 let configs = generate_hook_configs("sqz");
1352 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1353 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1354 .expect("Claude Code config must be valid JSON");
1355
1356 let precompact = parsed["hooks"]["PreCompact"]
1357 .as_array()
1358 .expect("PreCompact hook array must be present");
1359 assert!(
1360 !precompact.is_empty(),
1361 "PreCompact must have at least one registered hook"
1362 );
1363
1364 let cmd = precompact[0]["hooks"][0]["command"]
1365 .as_str()
1366 .expect("command field must be a string");
1367 assert!(
1368 cmd.ends_with(" hook precompact"),
1369 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1370 );
1371 }
1372
1373 #[test]
1376 fn test_json_escape_string_value() {
1377 assert_eq!(json_escape_string_value("sqz"), "sqz");
1379 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1380 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1382 r"C:\\Users\\Alice\\sqz.exe");
1383 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1385 r#"path with \"quotes\""#);
1386 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1388 }
1389
1390 #[test]
1391 fn test_windows_path_produces_valid_json_for_claude() {
1392 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1395 let configs = generate_hook_configs(windows_path);
1396
1397 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1398 .expect("Claude config should be generated");
1399 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1400 .expect("Claude hook config must be valid JSON on Windows paths");
1401
1402 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1404 .as_str()
1405 .expect("command field must be a string");
1406 assert!(cmd.contains(windows_path),
1407 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1408 }
1409
1410 #[test]
1411 fn test_windows_path_in_cursor_rules_file() {
1412 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1418 let configs = generate_hook_configs(windows_path);
1419
1420 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1421 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1422 assert!(cursor.config_content.contains(windows_path),
1423 "Cursor rule must contain the raw (unescaped) path so users can \
1424 copy-paste the shown commands — got:\n{}", cursor.config_content);
1425 assert!(!cursor.config_content.contains(r"C:\\Users"),
1426 "Cursor rule must NOT double-escape backslashes in markdown — \
1427 got:\n{}", cursor.config_content);
1428 }
1429
1430 #[test]
1431 fn test_windows_path_produces_valid_json_for_gemini() {
1432 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1433 let configs = generate_hook_configs(windows_path);
1434
1435 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1436 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1437 .expect("Gemini hook config must be valid JSON on Windows paths");
1438 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1439 assert!(cmd.contains(windows_path));
1440 }
1441
1442 #[test]
1443 fn test_rules_files_use_raw_path_for_readability() {
1444 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1448 let configs = generate_hook_configs(windows_path);
1449
1450 for tool in &["Windsurf", "Cline", "Cursor"] {
1451 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1452 assert!(cfg.config_content.contains(windows_path),
1453 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1454 cfg.config_content);
1455 assert!(!cfg.config_content.contains(r"C:\\Users"),
1456 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1457 cfg.config_content);
1458 }
1459 }
1460
1461 #[test]
1462 fn test_unix_path_still_works() {
1463 let unix_path = "/usr/local/bin/sqz";
1466 let configs = generate_hook_configs(unix_path);
1467
1468 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1469 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1470 .expect("Unix path should produce valid JSON");
1471 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1472 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1473 }
1474
1475 #[test]
1476 fn test_shell_escape_simple() {
1477 assert_eq!(shell_escape("git"), "git");
1478 assert_eq!(shell_escape("cargo-test"), "cargo-test");
1479 }
1480
1481 #[test]
1482 fn test_shell_escape_special_chars() {
1483 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1484 }
1485
1486 #[test]
1487 fn test_install_tool_hooks_creates_files() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let installed = install_tool_hooks(dir.path(), "sqz");
1490 assert!(!installed.is_empty(), "should install at least one hook config");
1492 for name in &installed {
1494 let configs = generate_hook_configs("sqz");
1495 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1496 let path = dir.path().join(&config.config_path);
1497 assert!(path.exists(), "hook config should exist: {}", path.display());
1498 }
1499 }
1500
1501 #[test]
1502 fn test_install_tool_hooks_does_not_overwrite() {
1503 let dir = tempfile::tempdir().unwrap();
1504 install_tool_hooks(dir.path(), "sqz");
1506 let custom_path = dir.path().join(".claude/settings.local.json");
1508 std::fs::write(&custom_path, "custom content").unwrap();
1509 install_tool_hooks(dir.path(), "sqz");
1511 let content = std::fs::read_to_string(&custom_path).unwrap();
1512 assert_eq!(content, "custom content", "should not overwrite existing config");
1513 }
1514}
1515
1516#[cfg(test)]
1517mod global_install_tests {
1518 use super::*;
1519
1520 fn with_fake_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
1534 use std::sync::Mutex;
1535 static LOCK: Mutex<()> = Mutex::new(());
1537 let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1538
1539 let prev_home = std::env::var_os("HOME");
1540 let prev_userprofile = std::env::var_os("USERPROFILE");
1541 std::env::set_var("HOME", tmp);
1542 std::env::set_var("USERPROFILE", tmp);
1543 let result = body();
1544 match prev_home {
1545 Some(v) => std::env::set_var("HOME", v),
1546 None => std::env::remove_var("HOME"),
1547 }
1548 match prev_userprofile {
1549 Some(v) => std::env::set_var("USERPROFILE", v),
1550 None => std::env::remove_var("USERPROFILE"),
1551 }
1552 result
1553 }
1554
1555 #[test]
1556 fn global_install_creates_fresh_settings_json() {
1557 let tmp = tempfile::tempdir().unwrap();
1558 with_fake_home(tmp.path(), || {
1559 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1560 assert!(changed, "first install should report a change");
1561
1562 let path = tmp.path().join(".claude").join("settings.json");
1563 assert!(path.exists(), "user settings.json should be created");
1564
1565 let content = std::fs::read_to_string(&path).unwrap();
1566 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1567
1568 let pre = &parsed["hooks"]["PreToolUse"];
1570 assert!(pre.is_array(), "PreToolUse should be an array");
1571 assert_eq!(pre.as_array().unwrap().len(), 1);
1572 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1573 assert!(
1574 cmd.contains("/usr/local/bin/sqz"),
1575 "hook command should use the passed sqz_path, got: {cmd}"
1576 );
1577 assert!(cmd.contains("hook claude"));
1578
1579 let precompact = &parsed["hooks"]["PreCompact"];
1580 assert!(precompact.is_array());
1581 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1582 assert!(precompact_cmd.contains("hook precompact"));
1583
1584 let session = &parsed["hooks"]["SessionStart"];
1585 assert!(session.is_array());
1586 assert_eq!(
1587 session[0]["matcher"].as_str().unwrap(),
1588 "compact",
1589 "SessionStart should only match /compact resume"
1590 );
1591 });
1592 }
1593
1594 #[test]
1595 fn global_install_preserves_existing_user_config() {
1596 let tmp = tempfile::tempdir().unwrap();
1600 let settings = tmp.path().join(".claude").join("settings.json");
1601 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1602
1603 let existing = serde_json::json!({
1604 "permissions": {
1605 "allow": ["Bash(npm test *)"],
1606 "deny": ["Read(./.env)"]
1607 },
1608 "env": { "FOO": "bar" },
1609 "statusLine": {
1610 "type": "command",
1611 "command": "~/.claude/statusline.sh"
1612 },
1613 "hooks": {
1614 "PreToolUse": [
1615 {
1616 "matcher": "Edit",
1617 "hooks": [
1618 {
1619 "type": "command",
1620 "command": "~/.claude/hooks/format-on-edit.sh"
1621 }
1622 ]
1623 }
1624 ]
1625 }
1626 });
1627 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
1628
1629 with_fake_home(tmp.path(), || {
1630 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1631 assert!(changed, "install should report a change on new hook");
1632
1633 let content = std::fs::read_to_string(&settings).unwrap();
1634 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1635
1636 assert_eq!(
1638 parsed["permissions"]["allow"][0].as_str().unwrap(),
1639 "Bash(npm test *)"
1640 );
1641 assert_eq!(
1642 parsed["permissions"]["deny"][0].as_str().unwrap(),
1643 "Read(./.env)"
1644 );
1645 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
1647 assert_eq!(
1649 parsed["statusLine"]["command"].as_str().unwrap(),
1650 "~/.claude/statusline.sh"
1651 );
1652
1653 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1656 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
1657 let matchers: Vec<&str> = pre
1658 .iter()
1659 .map(|e| e["matcher"].as_str().unwrap_or(""))
1660 .collect();
1661 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
1662 assert!(matchers.contains(&"Bash"), "sqz Bash hook must be present");
1663 });
1664 }
1665
1666 #[test]
1667 fn global_install_is_idempotent() {
1668 let tmp = tempfile::tempdir().unwrap();
1672 with_fake_home(tmp.path(), || {
1673 assert!(install_claude_global("sqz").unwrap());
1674 assert!(
1677 !install_claude_global("sqz").unwrap(),
1678 "second install with identical args should report no change"
1679 );
1680
1681 let path = tmp.path().join(".claude").join("settings.json");
1682 let parsed: serde_json::Value =
1683 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1684 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
1686 let arr = parsed["hooks"][event].as_array().unwrap();
1687 assert_eq!(
1688 arr.len(),
1689 1,
1690 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
1691 );
1692 }
1693 });
1694 }
1695
1696 #[test]
1697 fn global_install_upgrades_stale_sqz_hook_in_place() {
1698 let tmp = tempfile::tempdir().unwrap();
1702 with_fake_home(tmp.path(), || {
1703 install_claude_global("/old/path/sqz").unwrap();
1705 let changed = install_claude_global("/new/path/sqz").unwrap();
1707 assert!(changed, "different sqz_path must be seen as a change");
1708
1709 let path = tmp.path().join(".claude").join("settings.json");
1710 let parsed: serde_json::Value =
1711 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1712 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1713 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
1714 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1715 assert!(cmd.contains("/new/path/sqz"));
1716 assert!(!cmd.contains("/old/path/sqz"));
1717 });
1718 }
1719
1720 #[test]
1721 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
1722 let tmp = tempfile::tempdir().unwrap();
1723 let settings = tmp.path().join(".claude").join("settings.json");
1724 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1725 std::fs::write(
1726 &settings,
1727 serde_json::json!({
1728 "permissions": { "allow": ["Bash(git status)"] },
1729 "hooks": {
1730 "PreToolUse": [
1731 {
1732 "matcher": "Edit",
1733 "hooks": [
1734 { "type": "command", "command": "~/format.sh" }
1735 ]
1736 }
1737 ]
1738 }
1739 })
1740 .to_string(),
1741 )
1742 .unwrap();
1743
1744 with_fake_home(tmp.path(), || {
1745 install_claude_global("/usr/local/bin/sqz").unwrap();
1747 let result = remove_claude_global_hook().unwrap().unwrap();
1749 assert_eq!(result.0, settings);
1750 assert!(result.1, "should report that the file was modified");
1751
1752 assert!(settings.exists(), "settings.json should be preserved");
1754 let parsed: serde_json::Value =
1755 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
1756
1757 assert_eq!(
1759 parsed["permissions"]["allow"][0].as_str().unwrap(),
1760 "Bash(git status)"
1761 );
1762
1763 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1765 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
1766 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
1767
1768 assert!(parsed["hooks"].get("PreCompact").is_none());
1770 assert!(parsed["hooks"].get("SessionStart").is_none());
1771 });
1772 }
1773
1774 #[test]
1775 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
1776 let tmp = tempfile::tempdir().unwrap();
1780 with_fake_home(tmp.path(), || {
1781 install_claude_global("sqz").unwrap();
1782 let path = tmp.path().join(".claude").join("settings.json");
1783 assert!(path.exists(), "precondition: install created the file");
1784
1785 let result = remove_claude_global_hook().unwrap().unwrap();
1786 assert!(result.1);
1787 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
1788 });
1789 }
1790
1791 #[test]
1792 fn global_uninstall_on_missing_file_is_noop() {
1793 let tmp = tempfile::tempdir().unwrap();
1794 with_fake_home(tmp.path(), || {
1795 assert!(
1796 remove_claude_global_hook().unwrap().is_none(),
1797 "missing file should return None, not error"
1798 );
1799 });
1800 }
1801
1802 #[test]
1803 fn global_uninstall_refuses_to_touch_unparseable_file() {
1804 let tmp = tempfile::tempdir().unwrap();
1808 let settings = tmp.path().join(".claude").join("settings.json");
1809 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1810 std::fs::write(&settings, "{ invalid json because").unwrap();
1811
1812 with_fake_home(tmp.path(), || {
1813 assert!(
1814 remove_claude_global_hook().is_err(),
1815 "bad JSON must surface as an error"
1816 );
1817 });
1818
1819 let after = std::fs::read_to_string(&settings).unwrap();
1821 assert_eq!(after, "{ invalid json because");
1822 }
1823}