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 Kiro,
65}
66
67pub fn process_hook(input: &str) -> Result<String> {
86 process_hook_for_platform(input, HookPlatform::ClaudeCode)
87}
88
89pub fn process_hook_cursor(input: &str) -> Result<String> {
94 process_hook_for_platform(input, HookPlatform::Cursor)
95}
96
97pub fn process_hook_gemini(input: &str) -> Result<String> {
101 process_hook_for_platform(input, HookPlatform::GeminiCli)
102}
103
104pub fn process_hook_windsurf(input: &str) -> Result<String> {
109 process_hook_for_platform(input, HookPlatform::Windsurf)
110}
111
112pub fn process_hook_kiro(input: &str) -> Result<String> {
122 process_hook_for_platform(input, HookPlatform::Kiro)
123}
124
125fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
129 let parsed: serde_json::Value = serde_json::from_str(input)
130 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
131
132 let tool_name = parsed
136 .get("tool_name")
137 .or_else(|| parsed.get("toolName"))
138 .and_then(|v| v.as_str())
139 .unwrap_or("");
140
141 let hook_event = parsed
142 .get("hook_event_name")
143 .or_else(|| parsed.get("agent_action_name"))
144 .and_then(|v| v.as_str())
145 .unwrap_or("");
146
147 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
156 | "run_terminal_command" | "run_shell_command" | "execute_bash")
157 || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
158
159 if !is_shell {
160 return Ok(match platform {
163 HookPlatform::Cursor => "{}".to_string(),
164 _ => input.to_string(),
165 });
166 }
167
168 let command = parsed
173 .get("tool_input")
174 .and_then(|v| v.get("command"))
175 .and_then(|v| v.as_str())
176 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
177 .or_else(|| {
178 parsed
179 .get("tool_info")
180 .and_then(|v| v.get("command_line"))
181 .and_then(|v| v.as_str())
182 })
183 .or_else(|| {
184 parsed
185 .get("toolCall")
186 .and_then(|v| v.get("command"))
187 .and_then(|v| v.as_str())
188 })
189 .unwrap_or("");
190
191 if command.is_empty() {
192 return Ok(match platform {
193 HookPlatform::Cursor => "{}".to_string(),
194 _ => input.to_string(),
195 });
196 }
197
198 let base_cmd = extract_base_command(command);
210 if base_cmd == "sqz"
211 || command.starts_with("SQZ_CMD=")
212 || command.contains("sqz compress --cmd ")
213 || command.contains("sqz.exe compress --cmd ")
214 {
215 return Ok(match platform {
216 HookPlatform::Cursor => "{}".to_string(),
217 _ => input.to_string(),
218 });
219 }
220
221 if is_interactive_command(command) {
223 return Ok(match platform {
224 HookPlatform::Cursor => "{}".to_string(),
225 _ => input.to_string(),
226 });
227 }
228
229 if has_shell_operators(command) {
234 return Ok(match platform {
235 HookPlatform::Cursor => "{}".to_string(),
236 _ => input.to_string(),
237 });
238 }
239
240 let rewritten = format!(
250 "{} 2>&1 | sqz compress --cmd {}",
251 command,
252 shell_escape(extract_base_command(command)),
253 );
254
255 let output = match platform {
281 HookPlatform::ClaudeCode => serde_json::json!({
282 "hookSpecificOutput": {
283 "hookEventName": "PreToolUse",
284 "permissionDecision": "allow",
285 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
286 "updatedInput": {
287 "command": rewritten
288 }
289 }
290 }),
291 HookPlatform::Cursor => serde_json::json!({
292 "permission": "allow",
293 "updated_input": {
294 "command": rewritten
295 }
296 }),
297 HookPlatform::GeminiCli => serde_json::json!({
298 "decision": "allow",
299 "hookSpecificOutput": {
300 "tool_input": {
301 "command": rewritten
302 }
303 }
304 }),
305 HookPlatform::Windsurf => {
306 serde_json::json!({
310 "hookSpecificOutput": {
311 "hookEventName": "PreToolUse",
312 "permissionDecision": "allow",
313 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
314 "updatedInput": {
315 "command": rewritten
316 }
317 }
318 })
319 }
320 HookPlatform::Kiro => {
321 serde_json::json!({
325 "tool_input": {
326 "command": rewritten
327 }
328 })
329 }
330 };
331
332 serde_json::to_string(&output)
333 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
334}
335
336pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
338 let sqz_path_raw = sqz_path;
349 let sqz_path_json = json_escape_string_value(sqz_path);
350 let sqz_path = &sqz_path_json;
351
352 let escape_hatch = format!(
360 r#"## Escape hatch — when sqz output confuses the agent
361
362If you see a `§ref:HASH§` token in tool output and can't parse it, or if
363compressed output is making it harder to work (loops, repeated tiny
364requests), you have three ways out:
365
366- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
367 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
368 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
369- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
370 status 2>&1 | sqz compress`) to disable dedup for that one command.
371 Strictly more tokens, strictly less ambiguous.
372- **`--no-cache`** — the same opt-out as a CLI flag:
373 `git status 2>&1 | sqz compress --no-cache`.
374
375Use these when you genuinely need raw data. The default compressed
376path is still what you want for most output."#,
377 );
378 let escape_hatch = &escape_hatch;
379
380 vec![
381 ToolHookConfig {
395 tool_name: "Claude Code".to_string(),
396 config_path: PathBuf::from(".claude/settings.local.json"),
397 config_content: format!(
398 r#"{{
399 "hooks": {{
400 "PreToolUse": [
401 {{
402 "matcher": "Bash",
403 "hooks": [
404 {{
405 "type": "command",
406 "command": "{sqz_path} hook claude"
407 }}
408 ]
409 }}
410 ],
411 "PreCompact": [
412 {{
413 "hooks": [
414 {{
415 "type": "command",
416 "command": "{sqz_path} hook precompact"
417 }}
418 ]
419 }}
420 ],
421 "SessionStart": [
422 {{
423 "matcher": "compact",
424 "hooks": [
425 {{
426 "type": "command",
427 "command": "{sqz_path} resume"
428 }}
429 ]
430 }}
431 ]
432 }}
433}}"#
434 ),
435 scope: HookScope::Project,
436 },
437 ToolHookConfig {
446 tool_name: "Cursor".to_string(),
447 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
448 config_content: format!(
449 r#"---
450description: sqz — Token-Optimized CLI Output
451alwaysApply: true
452---
453
454# sqz — Token-Optimized CLI Output
455
456When running shell commands whose output may be long (directory listings,
457git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
458pipe the output through `sqz compress` to reduce token consumption.
459
460`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
461usage is to pipe the command's output into `sqz compress`:
462
463```bash
464# Instead of: Use:
465git status git status 2>&1 | {sqz_path_raw} compress
466cargo test cargo test 2>&1 | {sqz_path_raw} compress
467git log -10 git log -10 2>&1 | {sqz_path_raw} compress
468docker ps docker ps 2>&1 | {sqz_path_raw} compress
469ls -la ls -la 2>&1 | {sqz_path_raw} compress
470```
471
472The `2>&1` captures stderr too, which is useful for commands like `cargo
473test` where diagnostics go to stderr. `sqz compress` filters and compresses
474the combined output while preserving filenames, paths, and identifiers.
475It typically saves 60-90% tokens on verbose commands.
476
477Do NOT pipe output for:
478- Interactive commands (`vim`, `ssh`, `python`, REPLs)
479- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
480 `cmd; other`) — run those directly
481- Short commands whose output is already a few lines
482
483If `sqz` is not on PATH, run commands normally.
484
485{escape_hatch}
486"#
487 ),
488 scope: HookScope::Project,
489 },
490 ToolHookConfig {
494 tool_name: "Windsurf".to_string(),
495 config_path: PathBuf::from(".windsurfrules"),
496 config_content: format!(
497 r#"# sqz — Token-Optimized CLI Output
498
499Pipe verbose shell command output through `sqz compress` to save tokens.
500`sqz` reads from stdin and writes the compressed output to stdout — it is
501NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
502
503```bash
504# Instead of: Use:
505git status git status 2>&1 | {sqz_path_raw} compress
506cargo test cargo test 2>&1 | {sqz_path_raw} compress
507git log -10 git log -10 2>&1 | {sqz_path_raw} compress
508docker ps docker ps 2>&1 | {sqz_path_raw} compress
509```
510
511sqz filters and compresses command outputs while preserving filenames,
512paths, and identifiers (typically 60-90% token reduction on verbose
513commands). Skip short commands, interactive commands (vim, ssh, python),
514and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
515not on PATH, run commands normally.
516
517{escape_hatch}
518"#
519 ),
520 scope: HookScope::Project,
521 },
522 ToolHookConfig {
526 tool_name: "Cline".to_string(),
527 config_path: PathBuf::from(".clinerules"),
528 config_content: format!(
529 r#"# sqz — Token-Optimized CLI Output
530
531Pipe verbose shell command output through `sqz compress` to save tokens.
532`sqz` reads from stdin and writes the compressed output to stdout — it is
533NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
534
535```bash
536# Instead of: Use:
537git status git status 2>&1 | {sqz_path_raw} compress
538cargo test cargo test 2>&1 | {sqz_path_raw} compress
539git log -10 git log -10 2>&1 | {sqz_path_raw} compress
540docker ps docker ps 2>&1 | {sqz_path_raw} compress
541```
542
543sqz filters and compresses command outputs while preserving filenames,
544paths, and identifiers (typically 60-90% token reduction on verbose
545commands). Skip short commands, interactive commands (vim, ssh, python),
546and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
547not on PATH, run commands normally.
548
549{escape_hatch}
550"#
551 ),
552 scope: HookScope::Project,
553 },
554 ToolHookConfig {
556 tool_name: "Gemini CLI".to_string(),
557 config_path: PathBuf::from(".gemini/settings.json"),
558 config_content: format!(
559 r#"{{
560 "hooks": {{
561 "BeforeTool": [
562 {{
563 "matcher": "run_shell_command",
564 "hooks": [
565 {{
566 "type": "command",
567 "command": "{sqz_path} hook gemini"
568 }}
569 ]
570 }}
571 ]
572 }}
573}}"#
574 ),
575 scope: HookScope::Project,
576 },
577 ToolHookConfig {
580 tool_name: "Kiro".to_string(),
581 config_path: PathBuf::from(".kiro/hooks/sqz-compress.json"),
582 config_content: format!(
583 r#"{{
584 "name": "sqz compress",
585 "version": "1.0.0",
586 "description": "Compress shell command output through sqz for token savings",
587 "when": {{
588 "type": "preToolUse",
589 "toolTypes": ["shell"]
590 }},
591 "then": {{
592 "type": "runCommand",
593 "command": "{sqz_path} hook kiro"
594 }}
595}}"#
596 ),
597 scope: HookScope::Project,
598 },
599 ToolHookConfig {
608 tool_name: "OpenCode".to_string(),
609 config_path: PathBuf::from("opencode.json"),
610 config_content: format!(
611 r#"{{
612 "$schema": "https://opencode.ai/config.json",
613 "mcp": {{
614 "sqz": {{
615 "type": "local",
616 "command": ["sqz-mcp", "--transport", "stdio"]
617 }}
618 }},
619 "plugin": ["sqz"]
620}}"#
621 ),
622 scope: HookScope::Project,
623 },
624 ToolHookConfig {
643 tool_name: "Codex".to_string(),
644 config_path: PathBuf::from("AGENTS.md"),
645 config_content: crate::codex_integration::agents_md_guidance_block(
646 sqz_path_raw,
647 ),
648 scope: HookScope::Project,
649 },
650 ]
651}
652
653pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
659 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum InstallScope {
687 Project,
690 Global,
693}
694
695#[derive(Debug, Clone, PartialEq, Eq)]
708pub enum ToolFilter {
709 All,
712 Only(Vec<String>),
716 Skip(Vec<String>),
721}
722
723impl Default for ToolFilter {
724 fn default() -> Self {
725 ToolFilter::All
726 }
727}
728
729impl ToolFilter {
730 pub fn includes(&self, tool_name: &str) -> bool {
741 let canon = canonicalize_tool_name(tool_name);
742 match self {
743 ToolFilter::All => true,
744 ToolFilter::Only(allow) => allow.iter().any(|n| {
745 n == &canon
748 }),
749 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
750 }
751 }
752}
753
754pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
759 "Claude Code",
760 "Cursor",
761 "Windsurf",
762 "Cline",
763 "Gemini CLI",
764 "Kiro",
765 "OpenCode",
766 "Codex",
767];
768
769pub fn canonicalize_tool_name(name: &str) -> String {
788 let lowered: String = name
789 .chars()
790 .filter(|c| !c.is_whitespace())
791 .flat_map(|c| c.to_lowercase())
792 .filter(|c| *c != '-' && *c != '_')
793 .collect();
794 match lowered.as_str() {
795 "claude" | "claudecode" => "claudecode".to_string(),
796 "cursor" => "cursor".to_string(),
797 "windsurf" => "windsurf".to_string(),
798 "cline" | "roo" | "roocode" => "cline".to_string(),
802 "gemini" | "geminicli" => "gemini".to_string(),
803 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
804 "opencode" => "opencode".to_string(),
805 "codex" => "codex".to_string(),
806 other => other.to_string(),
807 }
808}
809
810pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
820 let mut out = Vec::new();
821 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
822 .iter()
823 .map(|n| canonicalize_tool_name(n))
824 .collect();
825 for part in raw.split(',') {
826 let trimmed = part.trim();
827 if trimmed.is_empty() {
828 continue;
829 }
830 let canon = canonicalize_tool_name(trimmed);
831 if !known.contains(&canon) {
832 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
833 .iter()
834 .map(|n| canonicalize_tool_name(n))
835 .collect();
836 return Err(crate::error::SqzError::Other(format!(
837 "unknown agent name '{}'. Valid options: {}",
838 trimmed,
839 valid.join(", ")
840 )));
841 }
842 if !out.contains(&canon) {
843 out.push(canon);
844 }
845 }
846 Ok(out)
847}
848
849pub fn install_tool_hooks_scoped(
875 project_dir: &Path,
876 sqz_path: &str,
877 scope: InstallScope,
878) -> Vec<String> {
879 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
880}
881
882pub fn install_tool_hooks_scoped_filtered(
896 project_dir: &Path,
897 sqz_path: &str,
898 scope: InstallScope,
899 filter: &ToolFilter,
900) -> Vec<String> {
901 let configs = generate_hook_configs(sqz_path);
902 let mut installed = Vec::new();
903
904 for config in &configs {
905 if !filter.includes(&config.tool_name) {
909 continue;
910 }
911
912 if config.tool_name == "OpenCode" {
921 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
922 Ok((updated, _comments_lost)) => {
923 if updated && !installed.iter().any(|n| n == "OpenCode") {
924 installed.push("OpenCode".to_string());
925 }
926 }
927 Err(_e) => {
928 }
931 }
932 continue;
933 }
934
935 if config.tool_name == "Codex" {
940 let agents_changed = crate::codex_integration::install_agents_md_guidance(
941 project_dir, sqz_path,
942 )
943 .unwrap_or(false);
944 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
945 .unwrap_or(false);
946 if (agents_changed || mcp_changed)
947 && !installed.iter().any(|n| n == "Codex")
948 {
949 installed.push("Codex".to_string());
950 }
951 continue;
952 }
953
954 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
964 let hook_installed = match install_claude_global(sqz_path) {
965 Ok(v) => v,
966 Err(_) => false,
967 };
968 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
969 project_dir, sqz_path,
970 )
971 .unwrap_or(false);
972 let mcp_changed =
973 crate::claude_md_integration::install_claude_mcp_config()
974 .unwrap_or(false);
975 if (hook_installed || md_changed || mcp_changed)
976 && !installed.iter().any(|n| n == "Claude Code")
977 {
978 installed.push("Claude Code".to_string());
979 }
980 continue;
981 }
982
983 let full_path = project_dir.join(&config.config_path);
984
985 if full_path.exists() {
987 if config.tool_name == "Claude Code" {
992 let md_changed =
993 crate::claude_md_integration::install_claude_md_guidance(
994 project_dir, sqz_path,
995 )
996 .unwrap_or(false);
997 let mcp_changed =
998 crate::claude_md_integration::install_claude_mcp_config()
999 .unwrap_or(false);
1000 if (md_changed || mcp_changed)
1001 && !installed.iter().any(|n| n == "Claude Code")
1002 {
1003 installed.push("Claude Code".to_string());
1004 }
1005 }
1006 continue;
1007 }
1008
1009 if let Some(parent) = full_path.parent() {
1011 if std::fs::create_dir_all(parent).is_err() {
1012 continue;
1013 }
1014 }
1015
1016 if std::fs::write(&full_path, &config.config_content).is_ok() {
1017 installed.push(config.tool_name.clone());
1018 if config.tool_name == "Claude Code" {
1025 let _ = crate::claude_md_integration::install_claude_md_guidance(
1026 project_dir, sqz_path,
1027 );
1028 let _ = crate::claude_md_integration::install_claude_mcp_config();
1029 }
1030 }
1031 }
1032
1033 if filter.includes("OpenCode") {
1044 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1045 if !installed.iter().any(|n| n == "OpenCode") {
1046 installed.push("OpenCode".to_string());
1047 }
1048 }
1049 }
1050
1051 installed
1052}
1053
1054pub fn claude_user_settings_path() -> Option<PathBuf> {
1067 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1068}
1069
1070fn install_claude_global(sqz_path: &str) -> Result<bool> {
1085 install_claude_global_at(sqz_path, None)
1086}
1087
1088fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1093 let path = match home_override {
1094 Some(h) => h.join(".claude").join("settings.json"),
1095 None => claude_user_settings_path().ok_or_else(|| {
1096 crate::error::SqzError::Other(
1097 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1098 )
1099 })?,
1100 };
1101
1102 let mut root: serde_json::Value = if path.exists() {
1104 let content = std::fs::read_to_string(&path).map_err(|e| {
1105 crate::error::SqzError::Other(format!(
1106 "read {}: {e}",
1107 path.display()
1108 ))
1109 })?;
1110 if content.trim().is_empty() {
1111 serde_json::Value::Object(serde_json::Map::new())
1112 } else {
1113 serde_json::from_str(&content).map_err(|e| {
1114 crate::error::SqzError::Other(format!(
1115 "parse {}: {e} — please fix or move the file before re-running sqz init",
1116 path.display()
1117 ))
1118 })?
1119 }
1120 } else {
1121 serde_json::Value::Object(serde_json::Map::new())
1122 };
1123
1124 let root_obj = root.as_object_mut().ok_or_else(|| {
1127 crate::error::SqzError::Other(format!(
1128 "{} is not a JSON object — refusing to overwrite",
1129 path.display()
1130 ))
1131 })?;
1132
1133 let pre_tool_use = serde_json::json!({
1135 "matcher": "Bash",
1136 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1137 });
1138 let pre_compact = serde_json::json!({
1139 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1140 });
1141 let session_start = serde_json::json!({
1142 "matcher": "compact",
1143 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1144 });
1145
1146 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1148
1149 let hooks = root_obj
1151 .entry("hooks".to_string())
1152 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1153 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1154 crate::error::SqzError::Other(format!(
1155 "{}: `hooks` is not an object — refusing to overwrite",
1156 path.display()
1157 ))
1158 })?;
1159
1160 upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "hook claude");
1161 upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "hook precompact");
1162 upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "resume");
1163
1164 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1165 if before == after && path.exists() {
1166 return Ok(false);
1168 }
1169
1170 if let Some(parent) = path.parent() {
1172 std::fs::create_dir_all(parent).map_err(|e| {
1173 crate::error::SqzError::Other(format!(
1174 "create {}: {e}",
1175 parent.display()
1176 ))
1177 })?;
1178 }
1179
1180 let parent = path.parent().ok_or_else(|| {
1184 crate::error::SqzError::Other(format!(
1185 "path {} has no parent directory",
1186 path.display()
1187 ))
1188 })?;
1189 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1190 crate::error::SqzError::Other(format!(
1191 "create temp file in {}: {e}",
1192 parent.display()
1193 ))
1194 })?;
1195 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1196 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1197 std::fs::write(tmp.path(), serialized).map_err(|e| {
1198 crate::error::SqzError::Other(format!(
1199 "write to temp file {}: {e}",
1200 tmp.path().display()
1201 ))
1202 })?;
1203 tmp.persist(&path).map_err(|e| {
1204 crate::error::SqzError::Other(format!(
1205 "rename temp file into place at {}: {e}",
1206 path.display()
1207 ))
1208 })?;
1209
1210 Ok(true)
1211}
1212
1213pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1227 remove_claude_global_hook_at(None)
1228}
1229
1230fn remove_claude_global_hook_at(
1233 home_override: Option<&Path>,
1234) -> Result<Option<(PathBuf, bool)>> {
1235 let path = match home_override {
1236 Some(h) => h.join(".claude").join("settings.json"),
1237 None => match claude_user_settings_path() {
1238 Some(p) => p,
1239 None => return Ok(None),
1240 },
1241 };
1242 if !path.exists() {
1243 return Ok(None);
1244 }
1245
1246 let content = std::fs::read_to_string(&path).map_err(|e| {
1247 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1248 })?;
1249 if content.trim().is_empty() {
1250 return Ok(Some((path, false)));
1251 }
1252
1253 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1254 crate::error::SqzError::Other(format!(
1255 "parse {}: {e} — refusing to rewrite an unparseable file",
1256 path.display()
1257 ))
1258 })?;
1259 let Some(root_obj) = root.as_object_mut() else {
1260 return Ok(Some((path, false)));
1261 };
1262
1263 let mut changed = false;
1264 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1265 for (event, sentinel) in &[
1266 ("PreToolUse", "hook claude"),
1267 ("PreCompact", "hook precompact"),
1268 ("SessionStart", "resume"),
1269 ] {
1270 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1271 let before = arr.len();
1272 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1273 if arr.len() != before {
1274 changed = true;
1275 }
1276 }
1277 }
1278
1279 hooks.retain(|_, v| match v {
1282 serde_json::Value::Array(a) => !a.is_empty(),
1283 _ => true,
1284 });
1285
1286 let hooks_empty = hooks.is_empty();
1289 if hooks_empty {
1290 root_obj.remove("hooks");
1291 changed = true;
1292 }
1293 }
1294
1295 if !changed {
1296 return Ok(Some((path, false)));
1297 }
1298
1299 if root_obj.is_empty() {
1303 std::fs::remove_file(&path).map_err(|e| {
1304 crate::error::SqzError::Other(format!(
1305 "remove {}: {e}",
1306 path.display()
1307 ))
1308 })?;
1309 return Ok(Some((path, true)));
1310 }
1311
1312 let parent = path.parent().ok_or_else(|| {
1314 crate::error::SqzError::Other(format!(
1315 "path {} has no parent directory",
1316 path.display()
1317 ))
1318 })?;
1319 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1320 crate::error::SqzError::Other(format!(
1321 "create temp file in {}: {e}",
1322 parent.display()
1323 ))
1324 })?;
1325 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1326 .map_err(|e| {
1327 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1328 })?;
1329 std::fs::write(tmp.path(), serialized).map_err(|e| {
1330 crate::error::SqzError::Other(format!(
1331 "write to temp file {}: {e}",
1332 tmp.path().display()
1333 ))
1334 })?;
1335 tmp.persist(&path).map_err(|e| {
1336 crate::error::SqzError::Other(format!(
1337 "rename temp file into place at {}: {e}",
1338 path.display()
1339 ))
1340 })?;
1341
1342 Ok(Some((path, true)))
1343}
1344
1345fn upsert_sqz_hook_entry(
1352 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1353 event_name: &str,
1354 new_entry: serde_json::Value,
1355 sentinel: &str,
1356) {
1357 let arr = hooks_obj
1358 .entry(event_name.to_string())
1359 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1360 let Some(arr) = arr.as_array_mut() else {
1361 hooks_obj.insert(
1365 event_name.to_string(),
1366 serde_json::Value::Array(vec![new_entry]),
1367 );
1368 return;
1369 };
1370
1371 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1373
1374 arr.push(new_entry);
1375}
1376
1377fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1381 entry
1382 .get("hooks")
1383 .and_then(|h| h.as_array())
1384 .map(|hooks_arr| {
1385 hooks_arr.iter().any(|h| {
1386 h.get("command")
1387 .and_then(|c| c.as_str())
1388 .map(|c| c.contains(needle))
1389 .unwrap_or(false)
1390 })
1391 })
1392 .unwrap_or(false)
1393}
1394
1395fn extract_base_command(cmd: &str) -> &str {
1399 cmd.split_whitespace()
1400 .next()
1401 .unwrap_or("unknown")
1402 .rsplit('/')
1403 .next()
1404 .unwrap_or("unknown")
1405}
1406
1407pub(crate) fn json_escape_string_value(s: &str) -> String {
1418 let mut out = String::with_capacity(s.len() + 2);
1419 for ch in s.chars() {
1420 match ch {
1421 '\\' => out.push_str("\\\\"),
1422 '"' => out.push_str("\\\""),
1423 '\n' => out.push_str("\\n"),
1424 '\r' => out.push_str("\\r"),
1425 '\t' => out.push_str("\\t"),
1426 '\x08' => out.push_str("\\b"),
1427 '\x0c' => out.push_str("\\f"),
1428 c if (c as u32) < 0x20 => {
1429 out.push_str(&format!("\\u{:04x}", c as u32));
1431 }
1432 c => out.push(c),
1433 }
1434 }
1435 out
1436}
1437
1438fn shell_escape(s: &str) -> String {
1440 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1441 s.to_string()
1442 } else {
1443 format!("'{}'", s.replace('\'', "'\\''"))
1444 }
1445}
1446
1447pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1451 cmd.contains("&&")
1454 || cmd.contains("||")
1455 || cmd.contains(';')
1456 || cmd.contains('>')
1457 || cmd.contains('<')
1458 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1464
1465fn is_interactive_command(cmd: &str) -> bool {
1467 let base = extract_base_command(cmd);
1468 matches!(
1469 base,
1470 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1471 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1472 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1473 ) || cmd.contains("--watch")
1474 || cmd.contains("-w ")
1475 || cmd.ends_with(" -w")
1476 || cmd.contains("run dev")
1477 || cmd.contains("run start")
1478 || cmd.contains("run serve")
1479}
1480
1481#[cfg(test)]
1484mod tests {
1485 use super::*;
1486
1487 #[test]
1488 fn test_process_hook_rewrites_bash_command() {
1489 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1491 let result = process_hook(input).unwrap();
1492 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1493 let hook_output = &parsed["hookSpecificOutput"];
1495 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1496 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1497 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1499 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1500 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1501 assert!(cmd.contains("--cmd git"), "should pass base command as --cmd: {cmd}");
1504 assert!(
1505 !cmd.contains("SQZ_CMD="),
1506 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1507 );
1508 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1510 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1511 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1512 }
1513
1514 #[test]
1515 fn test_process_hook_passes_through_non_bash() {
1516 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1517 let result = process_hook(input).unwrap();
1518 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1519 }
1520
1521 #[test]
1522 fn test_process_hook_skips_sqz_commands() {
1523 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1524 let result = process_hook(input).unwrap();
1525 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1526 }
1527
1528 #[test]
1529 fn test_process_hook_skips_interactive() {
1530 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1531 let result = process_hook(input).unwrap();
1532 assert_eq!(result, input, "interactive commands should pass through");
1533 }
1534
1535 #[test]
1536 fn test_process_hook_skips_watch_mode() {
1537 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1538 let result = process_hook(input).unwrap();
1539 assert_eq!(result, input, "watch mode should pass through");
1540 }
1541
1542 #[test]
1543 fn test_process_hook_empty_command() {
1544 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1545 let result = process_hook(input).unwrap();
1546 assert_eq!(result, input);
1547 }
1548
1549 #[test]
1550 fn test_process_hook_gemini_format() {
1551 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1553 let result = process_hook_gemini(input).unwrap();
1554 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1555 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1557 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1559 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1560 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1562 "Gemini format should not have updatedInput");
1563 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1564 "Gemini format should not have permissionDecision");
1565 }
1566
1567 #[test]
1568 fn test_process_hook_legacy_format() {
1569 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1571 let result = process_hook(input).unwrap();
1572 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1573 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1574 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1575 }
1576
1577 #[test]
1578 fn test_process_hook_cursor_format() {
1579 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1581 let result = process_hook_cursor(input).unwrap();
1582 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1583 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1585 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1586 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1587 assert!(cmd.contains("git status"));
1588 assert!(parsed.get("hookSpecificOutput").is_none(),
1590 "Cursor format should not have hookSpecificOutput");
1591 }
1592
1593 #[test]
1594 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1595 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1597 let result = process_hook_cursor(input).unwrap();
1598 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1599 }
1600
1601 #[test]
1602 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1603 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1605 let result = process_hook_cursor(input).unwrap();
1606 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1607 }
1608
1609 #[test]
1610 fn test_process_hook_windsurf_format() {
1611 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1613 let result = process_hook_windsurf(input).unwrap();
1614 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1615 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1617 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1618 assert!(cmd.contains("cargo test"));
1619 assert!(cmd.contains("--cmd cargo"), "label must be passed via --cmd flag");
1621 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
1622 }
1623
1624 #[test]
1625 fn test_process_hook_invalid_json() {
1626 let result = process_hook("not json");
1627 assert!(result.is_err());
1628 }
1629
1630 #[test]
1631 fn test_extract_base_command() {
1632 assert_eq!(extract_base_command("git status"), "git");
1633 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1634 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1635 }
1636
1637 #[test]
1638 fn test_is_interactive_command() {
1639 assert!(is_interactive_command("vim file.txt"));
1640 assert!(is_interactive_command("npm run dev --watch"));
1641 assert!(is_interactive_command("python3"));
1642 assert!(!is_interactive_command("git status"));
1643 assert!(!is_interactive_command("cargo test"));
1644 }
1645
1646 #[test]
1661 fn issue_10_rewrite_is_shell_neutral() {
1662 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1663 let result = process_hook(input).unwrap();
1664 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1665 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1666 .as_str()
1667 .unwrap();
1668
1669 assert!(
1671 cmd.contains("--cmd dotnet"),
1672 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
1673 );
1674 assert!(
1676 !cmd.contains("SQZ_CMD="),
1677 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
1678 (broken in PowerShell and cmd.exe), got: {cmd}"
1679 );
1680 assert!(
1682 cmd.contains("dotnet build NewNeonCheckers3.sln"),
1683 "original command must be preserved verbatim: {cmd}"
1684 );
1685 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
1687 }
1688
1689 #[test]
1697 fn issue_10_already_wrapped_command_passes_through() {
1698 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
1699 let result = process_hook(input).unwrap();
1700 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1701 assert_eq!(
1704 result, input,
1705 "already-wrapped command must pass through unchanged; \
1706 otherwise each pass accumulates another `| sqz compress` tail"
1707 );
1708 let _ = parsed; }
1713
1714 #[test]
1715 fn test_generate_hook_configs() {
1716 let configs = generate_hook_configs("sqz");
1717 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1718 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1719 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1720 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1721 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1724 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1725 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1726 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1727 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1728 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1729 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1733 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1734 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1735 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1736 assert!(cursor.config_content.starts_with("---"),
1737 "Cursor rule should start with YAML frontmatter");
1738 assert!(cursor.config_content.contains("alwaysApply: true"),
1739 "Cursor rule should use alwaysApply: true so the guidance loads \
1740 for every agent interaction");
1741 assert!(cursor.config_content.contains("sqz"),
1742 "Cursor rule body should mention sqz");
1743 }
1744
1745 #[test]
1746 fn test_claude_config_includes_precompact_hook() {
1747 let configs = generate_hook_configs("sqz");
1752 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1753 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1754 .expect("Claude Code config must be valid JSON");
1755
1756 let precompact = parsed["hooks"]["PreCompact"]
1757 .as_array()
1758 .expect("PreCompact hook array must be present");
1759 assert!(
1760 !precompact.is_empty(),
1761 "PreCompact must have at least one registered hook"
1762 );
1763
1764 let cmd = precompact[0]["hooks"][0]["command"]
1765 .as_str()
1766 .expect("command field must be a string");
1767 assert!(
1768 cmd.ends_with(" hook precompact"),
1769 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1770 );
1771 }
1772
1773 #[test]
1776 fn test_json_escape_string_value() {
1777 assert_eq!(json_escape_string_value("sqz"), "sqz");
1779 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1780 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1782 r"C:\\Users\\Alice\\sqz.exe");
1783 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1785 r#"path with \"quotes\""#);
1786 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1788 }
1789
1790 #[test]
1791 fn test_windows_path_produces_valid_json_for_claude() {
1792 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1795 let configs = generate_hook_configs(windows_path);
1796
1797 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1798 .expect("Claude config should be generated");
1799 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1800 .expect("Claude hook config must be valid JSON on Windows paths");
1801
1802 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1804 .as_str()
1805 .expect("command field must be a string");
1806 assert!(cmd.contains(windows_path),
1807 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1808 }
1809
1810 #[test]
1811 fn test_windows_path_in_cursor_rules_file() {
1812 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1818 let configs = generate_hook_configs(windows_path);
1819
1820 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1821 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1822 assert!(cursor.config_content.contains(windows_path),
1823 "Cursor rule must contain the raw (unescaped) path so users can \
1824 copy-paste the shown commands — got:\n{}", cursor.config_content);
1825 assert!(!cursor.config_content.contains(r"C:\\Users"),
1826 "Cursor rule must NOT double-escape backslashes in markdown — \
1827 got:\n{}", cursor.config_content);
1828 }
1829
1830 #[test]
1831 fn test_windows_path_produces_valid_json_for_gemini() {
1832 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1833 let configs = generate_hook_configs(windows_path);
1834
1835 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1836 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1837 .expect("Gemini hook config must be valid JSON on Windows paths");
1838 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1839 assert!(cmd.contains(windows_path));
1840 }
1841
1842 #[test]
1843 fn test_rules_files_use_raw_path_for_readability() {
1844 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1848 let configs = generate_hook_configs(windows_path);
1849
1850 for tool in &["Windsurf", "Cline", "Cursor"] {
1851 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1852 assert!(cfg.config_content.contains(windows_path),
1853 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1854 cfg.config_content);
1855 assert!(!cfg.config_content.contains(r"C:\\Users"),
1856 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1857 cfg.config_content);
1858 }
1859 }
1860
1861 #[test]
1862 fn test_unix_path_still_works() {
1863 let unix_path = "/usr/local/bin/sqz";
1866 let configs = generate_hook_configs(unix_path);
1867
1868 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1869 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1870 .expect("Unix path should produce valid JSON");
1871 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1872 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1873 }
1874
1875 #[test]
1876 fn test_shell_escape_simple() {
1877 assert_eq!(shell_escape("git"), "git");
1878 assert_eq!(shell_escape("cargo-test"), "cargo-test");
1879 }
1880
1881 #[test]
1882 fn test_shell_escape_special_chars() {
1883 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1884 }
1885
1886 #[test]
1887 fn test_install_tool_hooks_creates_files() {
1888 let dir = tempfile::tempdir().unwrap();
1889 let installed = install_tool_hooks(dir.path(), "sqz");
1890 assert!(!installed.is_empty(), "should install at least one hook config");
1892 for name in &installed {
1894 let configs = generate_hook_configs("sqz");
1895 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1896 let path = dir.path().join(&config.config_path);
1897 assert!(path.exists(), "hook config should exist: {}", path.display());
1898 }
1899 }
1900
1901 #[test]
1902 fn test_install_tool_hooks_does_not_overwrite() {
1903 let dir = tempfile::tempdir().unwrap();
1904 install_tool_hooks(dir.path(), "sqz");
1906 let custom_path = dir.path().join(".claude/settings.local.json");
1908 std::fs::write(&custom_path, "custom content").unwrap();
1909 install_tool_hooks(dir.path(), "sqz");
1911 let content = std::fs::read_to_string(&custom_path).unwrap();
1912 assert_eq!(content, "custom content", "should not overwrite existing config");
1913 }
1914}
1915
1916#[cfg(test)]
1917mod global_install_tests {
1918 use super::*;
1919
1920 #[test]
1921 fn global_install_creates_fresh_settings_json() {
1922 let tmp = tempfile::tempdir().unwrap();
1923 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
1924 assert!(changed, "first install should report a change");
1925
1926 let path = tmp.path().join(".claude").join("settings.json");
1927 assert!(path.exists(), "user settings.json should be created");
1928
1929 let content = std::fs::read_to_string(&path).unwrap();
1930 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1931
1932 let pre = &parsed["hooks"]["PreToolUse"];
1934 assert!(pre.is_array(), "PreToolUse should be an array");
1935 assert_eq!(pre.as_array().unwrap().len(), 1);
1936 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1937 assert!(
1938 cmd.contains("/usr/local/bin/sqz"),
1939 "hook command should use the passed sqz_path, got: {cmd}"
1940 );
1941 assert!(cmd.contains("hook claude"));
1942
1943 let precompact = &parsed["hooks"]["PreCompact"];
1944 assert!(precompact.is_array());
1945 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1946 assert!(precompact_cmd.contains("hook precompact"));
1947
1948 let session = &parsed["hooks"]["SessionStart"];
1949 assert!(session.is_array());
1950 assert_eq!(
1951 session[0]["matcher"].as_str().unwrap(),
1952 "compact",
1953 "SessionStart should only match /compact resume"
1954 );
1955 }
1956
1957 #[test]
1958 fn global_install_preserves_existing_user_config() {
1959 let tmp = tempfile::tempdir().unwrap();
1960 let settings = tmp.path().join(".claude").join("settings.json");
1961 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1962
1963 let existing = serde_json::json!({
1964 "permissions": {
1965 "allow": ["Bash(npm test *)"],
1966 "deny": ["Read(./.env)"]
1967 },
1968 "env": { "FOO": "bar" },
1969 "statusLine": {
1970 "type": "command",
1971 "command": "~/.claude/statusline.sh"
1972 },
1973 "hooks": {
1974 "PreToolUse": [
1975 {
1976 "matcher": "Edit",
1977 "hooks": [
1978 {
1979 "type": "command",
1980 "command": "~/.claude/hooks/format-on-edit.sh"
1981 }
1982 ]
1983 }
1984 ]
1985 }
1986 });
1987 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
1988
1989 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
1990 assert!(changed, "install should report a change on new hook");
1991
1992 let content = std::fs::read_to_string(&settings).unwrap();
1993 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1994
1995 assert_eq!(
1997 parsed["permissions"]["allow"][0].as_str().unwrap(),
1998 "Bash(npm test *)"
1999 );
2000 assert_eq!(
2001 parsed["permissions"]["deny"][0].as_str().unwrap(),
2002 "Read(./.env)"
2003 );
2004 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2006 assert_eq!(
2008 parsed["statusLine"]["command"].as_str().unwrap(),
2009 "~/.claude/statusline.sh"
2010 );
2011
2012 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2015 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2016 let matchers: Vec<&str> = pre
2017 .iter()
2018 .map(|e| e["matcher"].as_str().unwrap_or(""))
2019 .collect();
2020 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2021 assert!(matchers.contains(&"Bash"), "sqz Bash hook must be present");
2022 }
2023
2024 #[test]
2025 fn global_install_is_idempotent() {
2026 let tmp = tempfile::tempdir().unwrap();
2027 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2028 assert!(
2029 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2030 "second install with identical args should report no change"
2031 );
2032
2033 let path = tmp.path().join(".claude").join("settings.json");
2034 let parsed: serde_json::Value =
2035 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2036 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2037 let arr = parsed["hooks"][event].as_array().unwrap();
2038 assert_eq!(
2039 arr.len(),
2040 1,
2041 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2042 );
2043 }
2044 }
2045
2046 #[test]
2053 fn global_install_is_idempotent_with_exe_path() {
2054 let tmp = tempfile::tempdir().unwrap();
2055 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2056 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2057 assert!(
2058 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2059 "second install with .exe path should report no change (issue #21)"
2060 );
2061
2062 let path = tmp.path().join(".claude").join("settings.json");
2063 let parsed: serde_json::Value =
2064 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2065 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2066 let arr = parsed["hooks"][event].as_array().unwrap();
2067 assert_eq!(
2068 arr.len(),
2069 1,
2070 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2071 );
2072 }
2073 }
2074
2075 #[test]
2076 fn global_install_upgrades_stale_sqz_hook_in_place() {
2077 let tmp = tempfile::tempdir().unwrap();
2078 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2079 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2080 assert!(changed, "different sqz_path must be seen as a change");
2081
2082 let path = tmp.path().join(".claude").join("settings.json");
2083 let parsed: serde_json::Value =
2084 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2085 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2086 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2087 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2088 assert!(cmd.contains("/new/path/sqz"));
2089 assert!(!cmd.contains("/old/path/sqz"));
2090 }
2091
2092 #[test]
2093 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2094 let tmp = tempfile::tempdir().unwrap();
2095 let settings = tmp.path().join(".claude").join("settings.json");
2096 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2097 std::fs::write(
2098 &settings,
2099 serde_json::json!({
2100 "permissions": { "allow": ["Bash(git status)"] },
2101 "hooks": {
2102 "PreToolUse": [
2103 {
2104 "matcher": "Edit",
2105 "hooks": [
2106 { "type": "command", "command": "~/format.sh" }
2107 ]
2108 }
2109 ]
2110 }
2111 })
2112 .to_string(),
2113 )
2114 .unwrap();
2115
2116 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2117 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2118 assert_eq!(result.0, settings);
2119 assert!(result.1, "should report that the file was modified");
2120
2121 assert!(settings.exists(), "settings.json should be preserved");
2122 let parsed: serde_json::Value =
2123 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2124
2125 assert_eq!(
2126 parsed["permissions"]["allow"][0].as_str().unwrap(),
2127 "Bash(git status)"
2128 );
2129
2130 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2131 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2132 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2133
2134 assert!(parsed["hooks"].get("PreCompact").is_none());
2135 assert!(parsed["hooks"].get("SessionStart").is_none());
2136 }
2137
2138 #[test]
2139 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2140 let tmp = tempfile::tempdir().unwrap();
2141 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2142 let path = tmp.path().join(".claude").join("settings.json");
2143 assert!(path.exists(), "precondition: install created the file");
2144
2145 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2146 assert!(result.1);
2147 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2148 }
2149
2150 #[test]
2151 fn global_uninstall_on_missing_file_is_noop() {
2152 let tmp = tempfile::tempdir().unwrap();
2153 assert!(
2154 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2155 "missing file should return None, not error"
2156 );
2157 }
2158
2159 #[test]
2160 fn global_uninstall_refuses_to_touch_unparseable_file() {
2161 let tmp = tempfile::tempdir().unwrap();
2162 let settings = tmp.path().join(".claude").join("settings.json");
2163 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2164 std::fs::write(&settings, "{ invalid json because").unwrap();
2165
2166 assert!(
2167 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2168 "bad JSON must surface as an error"
2169 );
2170
2171 let after = std::fs::read_to_string(&settings).unwrap();
2172 assert_eq!(after, "{ invalid json because");
2173 }
2174}
2175
2176#[cfg(test)]
2177mod issue_11_tool_filter_tests {
2178 use super::*;
2187
2188 #[test]
2189 fn canonicalize_collapses_common_aliases() {
2190 for aliases in &[
2193 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2194 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2195 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2196 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2200 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2201 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2202 (vec!["Codex", "codex"], "codex"),
2203 ] {
2204 for alias in &aliases.0 {
2205 assert_eq!(
2206 canonicalize_tool_name(alias),
2207 aliases.1,
2208 "alias '{}' must canonicalise to '{}'",
2209 alias,
2210 aliases.1
2211 );
2212 }
2213 }
2214 }
2215
2216 #[test]
2217 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2218 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2223 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2224 }
2225
2226 #[test]
2227 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2228 let names = parse_tool_list("opencode,codex").unwrap();
2231 assert_eq!(names, vec!["opencode", "codex"]);
2232
2233 let names = parse_tool_list(" opencode , codex ").unwrap();
2234 assert_eq!(names, vec!["opencode", "codex"]);
2235
2236 let names = parse_tool_list("opencode").unwrap();
2238 assert_eq!(names, vec!["opencode"]);
2239
2240 let names = parse_tool_list("claude-code").unwrap();
2242 assert_eq!(names, vec!["claudecode"]);
2243 }
2244
2245 #[test]
2246 fn parse_tool_list_dedupes_repeated_entries() {
2247 let names = parse_tool_list("opencode,opencode").unwrap();
2252 assert_eq!(names, vec!["opencode"]);
2253
2254 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2257 assert_eq!(names, vec!["claudecode"]);
2258 }
2259
2260 #[test]
2261 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2262 let err = parse_tool_list("opncode").unwrap_err();
2269 let msg = err.to_string();
2270 assert!(
2271 msg.contains("unknown agent name 'opncode'"),
2272 "error must quote the bad input: {msg}"
2273 );
2274 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2275 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2276 }
2277
2278 #[test]
2279 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2280 let err = parse_tool_list("opencode,xyz").unwrap_err();
2285 assert!(err.to_string().contains("xyz"));
2286 }
2287
2288 #[test]
2289 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2290 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2296 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2297 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2298 }
2299
2300 #[test]
2301 fn tool_filter_all_includes_every_supported_tool() {
2302 let filter = ToolFilter::All;
2303 for tool in SUPPORTED_TOOL_NAMES {
2304 assert!(
2305 filter.includes(tool),
2306 "default filter must include {tool}"
2307 );
2308 }
2309 }
2310
2311 #[test]
2312 fn tool_filter_only_opencode_excludes_everything_else() {
2313 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2315 assert!(filter.includes("OpenCode"));
2316 for tool in SUPPORTED_TOOL_NAMES {
2318 if *tool == "OpenCode" {
2319 continue;
2320 }
2321 assert!(
2322 !filter.includes(tool),
2323 "--only opencode must not include {tool}"
2324 );
2325 }
2326 }
2327
2328 #[test]
2329 fn tool_filter_only_multi_tool_includes_exactly_those() {
2330 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2331 assert!(filter.includes("OpenCode"));
2332 assert!(filter.includes("Codex"));
2333 assert!(!filter.includes("Claude Code"));
2335 assert!(!filter.includes("Cursor"));
2336 assert!(!filter.includes("Windsurf"));
2337 assert!(!filter.includes("Cline"));
2338 assert!(!filter.includes("Gemini CLI"));
2339 }
2340
2341 #[test]
2342 fn tool_filter_skip_inverts_the_set() {
2343 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2346 assert!(!filter.includes("Cursor"));
2347 assert!(!filter.includes("Windsurf"));
2348 assert!(filter.includes("Claude Code"));
2350 assert!(filter.includes("Cline"));
2351 assert!(filter.includes("Gemini CLI"));
2352 assert!(filter.includes("OpenCode"));
2353 assert!(filter.includes("Codex"));
2354 }
2355
2356 #[test]
2357 fn tool_filter_only_empty_excludes_everything() {
2358 let filter = ToolFilter::Only(vec![]);
2364 for tool in SUPPORTED_TOOL_NAMES {
2365 assert!(
2366 !filter.includes(tool),
2367 "empty --only must exclude every tool, got {tool}"
2368 );
2369 }
2370 }
2371
2372 #[test]
2373 fn tool_filter_only_accepts_display_name_or_canonical() {
2374 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2380 assert!(filter.includes("Claude Code"));
2381 assert!(!filter.includes("Cursor"));
2382
2383 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2384 assert!(filter.includes("Gemini CLI"));
2385 }
2386
2387 #[test]
2388 fn supported_tool_names_matches_generate_hook_configs_exactly() {
2389 let configs = generate_hook_configs("sqz");
2395 let emitted: std::collections::HashSet<&str> =
2396 configs.iter().map(|c| c.tool_name.as_str()).collect();
2397 let declared: std::collections::HashSet<&str> =
2398 SUPPORTED_TOOL_NAMES.iter().copied().collect();
2399 assert_eq!(
2400 emitted, declared,
2401 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2402 from generate_hook_configs. emitted={:?}, declared={:?}",
2403 emitted, declared
2404 );
2405 }
2406
2407 #[test]
2408 fn filtered_install_only_opencode_writes_only_opencode_files() {
2409 let dir = tempfile::tempdir().unwrap();
2415 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2416 let _installed = install_tool_hooks_scoped_filtered(
2417 dir.path(),
2418 "sqz",
2419 InstallScope::Project,
2420 &filter,
2421 );
2422
2423 assert!(
2425 dir.path().join("opencode.json").exists(),
2426 "OpenCode config must be written when --only opencode is used"
2427 );
2428
2429 for (path, tool) in &[
2431 (".claude/settings.local.json", "Claude Code"),
2432 (".cursor/rules/sqz.mdc", "Cursor"),
2433 (".windsurfrules", "Windsurf"),
2434 (".clinerules", "Cline"),
2435 (".gemini/settings.json", "Gemini CLI"),
2436 ("AGENTS.md", "Codex"),
2437 ] {
2438 assert!(
2439 !dir.path().join(path).exists(),
2440 "filter rejected {tool} but the installer still wrote {path}"
2441 );
2442 }
2443 }
2444
2445 #[test]
2446 fn filtered_install_skip_cursor_omits_only_cursor() {
2447 let dir = tempfile::tempdir().unwrap();
2449 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
2450 let _installed = install_tool_hooks_scoped_filtered(
2451 dir.path(),
2452 "sqz",
2453 InstallScope::Project,
2454 &filter,
2455 );
2456
2457 assert!(
2459 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
2460 "skip cursor: .cursor/rules/sqz.mdc must not be written"
2461 );
2462 assert!(
2464 dir.path().join(".windsurfrules").exists(),
2465 "skip cursor should not skip windsurf"
2466 );
2467 assert!(
2468 dir.path().join(".clinerules").exists(),
2469 "skip cursor should not skip cline"
2470 );
2471 }
2472}
2473