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 | "PowerShell" | "powershell" | "pwsh")
158 || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
159
160 if !is_shell {
161 return Ok(match platform {
164 HookPlatform::Cursor => "{}".to_string(),
165 _ => input.to_string(),
166 });
167 }
168
169 let command = parsed
174 .get("tool_input")
175 .and_then(|v| v.get("command"))
176 .and_then(|v| v.as_str())
177 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
178 .or_else(|| {
179 parsed
180 .get("tool_info")
181 .and_then(|v| v.get("command_line"))
182 .and_then(|v| v.as_str())
183 })
184 .or_else(|| {
185 parsed
186 .get("toolCall")
187 .and_then(|v| v.get("command"))
188 .and_then(|v| v.as_str())
189 })
190 .unwrap_or("");
191
192 if command.is_empty() {
193 return Ok(match platform {
194 HookPlatform::Cursor => "{}".to_string(),
195 _ => input.to_string(),
196 });
197 }
198
199 let base_cmd = extract_base_command(command);
211 if base_cmd == "sqz"
212 || command.starts_with("SQZ_CMD=")
213 || command.contains("sqz compress --cmd ")
214 || command.contains("sqz.exe compress --cmd ")
215 {
216 return Ok(match platform {
217 HookPlatform::Cursor => "{}".to_string(),
218 _ => input.to_string(),
219 });
220 }
221
222 if is_interactive_command(command) {
224 return Ok(match platform {
225 HookPlatform::Cursor => "{}".to_string(),
226 _ => input.to_string(),
227 });
228 }
229
230 if has_shell_operators(command) {
235 return Ok(match platform {
236 HookPlatform::Cursor => "{}".to_string(),
237 _ => input.to_string(),
238 });
239 }
240
241 let rewritten = format!(
259 "{} 2>&1 | sqz compress --cmd {}",
260 command,
261 shell_escape(command),
262 );
263
264 let output = match platform {
290 HookPlatform::ClaudeCode => serde_json::json!({
291 "hookSpecificOutput": {
292 "hookEventName": "PreToolUse",
293 "permissionDecision": "allow",
294 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
295 "updatedInput": {
296 "command": rewritten
297 }
298 }
299 }),
300 HookPlatform::Cursor => serde_json::json!({
301 "permission": "allow",
302 "updated_input": {
303 "command": rewritten
304 }
305 }),
306 HookPlatform::GeminiCli => serde_json::json!({
307 "decision": "allow",
308 "hookSpecificOutput": {
309 "tool_input": {
310 "command": rewritten
311 }
312 }
313 }),
314 HookPlatform::Windsurf => {
315 serde_json::json!({
319 "hookSpecificOutput": {
320 "hookEventName": "PreToolUse",
321 "permissionDecision": "allow",
322 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
323 "updatedInput": {
324 "command": rewritten
325 }
326 }
327 })
328 }
329 HookPlatform::Kiro => {
330 serde_json::json!({
334 "tool_input": {
335 "command": rewritten
336 }
337 })
338 }
339 };
340
341 serde_json::to_string(&output)
342 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
343}
344
345pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
347 let sqz_path_raw = sqz_path;
358 let sqz_path_json = json_escape_string_value(sqz_path);
359 let sqz_path = &sqz_path_json;
360
361 let escape_hatch = format!(
369 r#"## Escape hatch — when sqz output confuses the agent
370
371If you see a `§ref:HASH§` token in tool output and can't parse it, or if
372compressed output is making it harder to work (loops, repeated tiny
373requests), you have three ways out:
374
375- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
376 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
377 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
378- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
379 status 2>&1 | sqz compress`) to disable dedup for that one command.
380 Strictly more tokens, strictly less ambiguous.
381- **`--no-cache`** — the same opt-out as a CLI flag:
382 `git status 2>&1 | sqz compress --no-cache`.
383
384Use these when you genuinely need raw data. The default compressed
385path is still what you want for most output."#,
386 );
387 let escape_hatch = &escape_hatch;
388
389 vec![
390 ToolHookConfig {
405 tool_name: "Claude Code".to_string(),
406 config_path: PathBuf::from(".claude/settings.local.json"),
407 config_content: format!(
408 r#"{{
409 "hooks": {{
410 "PreToolUse": [
411 {{
412 "matcher": "Bash|PowerShell",
413 "hooks": [
414 {{
415 "type": "command",
416 "command": "{sqz_path} hook claude"
417 }}
418 ]
419 }}
420 ],
421 "PreCompact": [
422 {{
423 "hooks": [
424 {{
425 "type": "command",
426 "command": "{sqz_path} hook precompact"
427 }}
428 ]
429 }}
430 ],
431 "SessionStart": [
432 {{
433 "matcher": "compact",
434 "hooks": [
435 {{
436 "type": "command",
437 "command": "{sqz_path} resume"
438 }}
439 ]
440 }}
441 ]
442 }}
443}}"#
444 ),
445 scope: HookScope::Project,
446 },
447 ToolHookConfig {
456 tool_name: "Cursor".to_string(),
457 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
458 config_content: format!(
459 r#"---
460description: sqz — Token-Optimized CLI Output
461alwaysApply: true
462---
463
464# sqz — Token-Optimized CLI Output
465
466When running shell commands whose output may be long (directory listings,
467git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
468pipe the output through `sqz compress` to reduce token consumption.
469
470`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
471usage is to pipe the command's output into `sqz compress`:
472
473```bash
474# Instead of: Use:
475git status git status 2>&1 | {sqz_path_raw} compress
476cargo test cargo test 2>&1 | {sqz_path_raw} compress
477git log -10 git log -10 2>&1 | {sqz_path_raw} compress
478docker ps docker ps 2>&1 | {sqz_path_raw} compress
479ls -la ls -la 2>&1 | {sqz_path_raw} compress
480```
481
482The `2>&1` captures stderr too, which is useful for commands like `cargo
483test` where diagnostics go to stderr. `sqz compress` filters and compresses
484the combined output while preserving filenames, paths, and identifiers.
485It typically saves 60-90% tokens on verbose commands.
486
487Do NOT pipe output for:
488- Interactive commands (`vim`, `ssh`, `python`, REPLs)
489- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
490 `cmd; other`) — run those directly
491- Short commands whose output is already a few lines
492
493If `sqz` is not on PATH, run commands normally.
494
495{escape_hatch}
496"#
497 ),
498 scope: HookScope::Project,
499 },
500 ToolHookConfig {
504 tool_name: "Windsurf".to_string(),
505 config_path: PathBuf::from(".windsurfrules"),
506 config_content: format!(
507 r#"# sqz — Token-Optimized CLI Output
508
509Pipe verbose shell command output through `sqz compress` to save tokens.
510`sqz` reads from stdin and writes the compressed output to stdout — it is
511NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
512
513```bash
514# Instead of: Use:
515git status git status 2>&1 | {sqz_path_raw} compress
516cargo test cargo test 2>&1 | {sqz_path_raw} compress
517git log -10 git log -10 2>&1 | {sqz_path_raw} compress
518docker ps docker ps 2>&1 | {sqz_path_raw} compress
519```
520
521sqz filters and compresses command outputs while preserving filenames,
522paths, and identifiers (typically 60-90% token reduction on verbose
523commands). Skip short commands, interactive commands (vim, ssh, python),
524and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
525not on PATH, run commands normally.
526
527{escape_hatch}
528"#
529 ),
530 scope: HookScope::Project,
531 },
532 ToolHookConfig {
536 tool_name: "Cline".to_string(),
537 config_path: PathBuf::from(".clinerules"),
538 config_content: format!(
539 r#"# sqz — Token-Optimized CLI Output
540
541Pipe verbose shell command output through `sqz compress` to save tokens.
542`sqz` reads from stdin and writes the compressed output to stdout — it is
543NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
544
545```bash
546# Instead of: Use:
547git status git status 2>&1 | {sqz_path_raw} compress
548cargo test cargo test 2>&1 | {sqz_path_raw} compress
549git log -10 git log -10 2>&1 | {sqz_path_raw} compress
550docker ps docker ps 2>&1 | {sqz_path_raw} compress
551```
552
553sqz filters and compresses command outputs while preserving filenames,
554paths, and identifiers (typically 60-90% token reduction on verbose
555commands). Skip short commands, interactive commands (vim, ssh, python),
556and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
557not on PATH, run commands normally.
558
559{escape_hatch}
560"#
561 ),
562 scope: HookScope::Project,
563 },
564 ToolHookConfig {
566 tool_name: "Gemini CLI".to_string(),
567 config_path: PathBuf::from(".gemini/settings.json"),
568 config_content: format!(
569 r#"{{
570 "hooks": {{
571 "BeforeTool": [
572 {{
573 "matcher": "run_shell_command",
574 "hooks": [
575 {{
576 "type": "command",
577 "command": "{sqz_path} hook gemini"
578 }}
579 ]
580 }}
581 ]
582 }}
583}}"#
584 ),
585 scope: HookScope::Project,
586 },
587 ToolHookConfig {
590 tool_name: "Kiro".to_string(),
591 config_path: PathBuf::from(".kiro/hooks/sqz-compress.json"),
592 config_content: format!(
593 r#"{{
594 "name": "sqz compress",
595 "version": "1.0.0",
596 "description": "Compress shell command output through sqz for token savings",
597 "when": {{
598 "type": "preToolUse",
599 "toolTypes": ["shell"]
600 }},
601 "then": {{
602 "type": "runCommand",
603 "command": "{sqz_path} hook kiro"
604 }}
605}}"#
606 ),
607 scope: HookScope::Project,
608 },
609 ToolHookConfig {
618 tool_name: "OpenCode".to_string(),
619 config_path: PathBuf::from("opencode.json"),
620 config_content: format!(
621 r#"{{
622 "$schema": "https://opencode.ai/config.json",
623 "mcp": {{
624 "sqz": {{
625 "type": "local",
626 "command": ["sqz-mcp", "--transport", "stdio"]
627 }}
628 }},
629 "plugin": ["sqz"]
630}}"#
631 ),
632 scope: HookScope::Project,
633 },
634 ToolHookConfig {
653 tool_name: "Codex".to_string(),
654 config_path: PathBuf::from("AGENTS.md"),
655 config_content: crate::codex_integration::agents_md_guidance_block(
656 sqz_path_raw,
657 ),
658 scope: HookScope::Project,
659 },
660 ]
661}
662
663pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
669 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum InstallScope {
697 Project,
700 Global,
703}
704
705#[derive(Debug, Clone, PartialEq, Eq)]
718pub enum ToolFilter {
719 All,
722 Only(Vec<String>),
726 Skip(Vec<String>),
731}
732
733impl Default for ToolFilter {
734 fn default() -> Self {
735 ToolFilter::All
736 }
737}
738
739impl ToolFilter {
740 pub fn includes(&self, tool_name: &str) -> bool {
751 let canon = canonicalize_tool_name(tool_name);
752 match self {
753 ToolFilter::All => true,
754 ToolFilter::Only(allow) => allow.iter().any(|n| {
755 n == &canon
758 }),
759 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
760 }
761 }
762}
763
764pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
769 "Claude Code",
770 "Cursor",
771 "Windsurf",
772 "Cline",
773 "Gemini CLI",
774 "Kiro",
775 "OpenCode",
776 "Codex",
777];
778
779pub fn canonicalize_tool_name(name: &str) -> String {
798 let lowered: String = name
799 .chars()
800 .filter(|c| !c.is_whitespace())
801 .flat_map(|c| c.to_lowercase())
802 .filter(|c| *c != '-' && *c != '_')
803 .collect();
804 match lowered.as_str() {
805 "claude" | "claudecode" => "claudecode".to_string(),
806 "cursor" => "cursor".to_string(),
807 "windsurf" => "windsurf".to_string(),
808 "cline" | "roo" | "roocode" => "cline".to_string(),
812 "gemini" | "geminicli" => "gemini".to_string(),
813 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
814 "opencode" => "opencode".to_string(),
815 "codex" => "codex".to_string(),
816 other => other.to_string(),
817 }
818}
819
820pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
830 let mut out = Vec::new();
831 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
832 .iter()
833 .map(|n| canonicalize_tool_name(n))
834 .collect();
835 for part in raw.split(',') {
836 let trimmed = part.trim();
837 if trimmed.is_empty() {
838 continue;
839 }
840 let canon = canonicalize_tool_name(trimmed);
841 if !known.contains(&canon) {
842 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
843 .iter()
844 .map(|n| canonicalize_tool_name(n))
845 .collect();
846 return Err(crate::error::SqzError::Other(format!(
847 "unknown agent name '{}'. Valid options: {}",
848 trimmed,
849 valid.join(", ")
850 )));
851 }
852 if !out.contains(&canon) {
853 out.push(canon);
854 }
855 }
856 Ok(out)
857}
858
859pub fn install_tool_hooks_scoped(
885 project_dir: &Path,
886 sqz_path: &str,
887 scope: InstallScope,
888) -> Vec<String> {
889 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
890}
891
892pub fn install_tool_hooks_scoped_filtered(
906 project_dir: &Path,
907 sqz_path: &str,
908 scope: InstallScope,
909 filter: &ToolFilter,
910) -> Vec<String> {
911 let configs = generate_hook_configs(sqz_path);
912 let mut installed = Vec::new();
913
914 for config in &configs {
915 if !filter.includes(&config.tool_name) {
919 continue;
920 }
921
922 if config.tool_name == "OpenCode" {
931 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
932 Ok((updated, _comments_lost)) => {
933 if updated && !installed.iter().any(|n| n == "OpenCode") {
934 installed.push("OpenCode".to_string());
935 }
936 }
937 Err(_e) => {
938 }
941 }
942 continue;
943 }
944
945 if config.tool_name == "Codex" {
950 let agents_changed = crate::codex_integration::install_agents_md_guidance(
951 project_dir, sqz_path,
952 )
953 .unwrap_or(false);
954 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
955 .unwrap_or(false);
956 if (agents_changed || mcp_changed)
957 && !installed.iter().any(|n| n == "Codex")
958 {
959 installed.push("Codex".to_string());
960 }
961 continue;
962 }
963
964 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
974 let hook_installed = match install_claude_global(sqz_path) {
975 Ok(v) => v,
976 Err(_) => false,
977 };
978 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
979 project_dir, sqz_path,
980 )
981 .unwrap_or(false);
982 let mcp_changed =
983 crate::claude_md_integration::install_claude_mcp_config()
984 .unwrap_or(false);
985 if (hook_installed || md_changed || mcp_changed)
986 && !installed.iter().any(|n| n == "Claude Code")
987 {
988 installed.push("Claude Code".to_string());
989 }
990 continue;
991 }
992
993 let full_path = project_dir.join(&config.config_path);
994
995 if full_path.exists() {
997 if config.tool_name == "Claude Code" {
1002 let md_changed =
1003 crate::claude_md_integration::install_claude_md_guidance(
1004 project_dir, sqz_path,
1005 )
1006 .unwrap_or(false);
1007 let mcp_changed =
1008 crate::claude_md_integration::install_claude_mcp_config()
1009 .unwrap_or(false);
1010 if (md_changed || mcp_changed)
1011 && !installed.iter().any(|n| n == "Claude Code")
1012 {
1013 installed.push("Claude Code".to_string());
1014 }
1015 }
1016 continue;
1017 }
1018
1019 if let Some(parent) = full_path.parent() {
1021 if std::fs::create_dir_all(parent).is_err() {
1022 continue;
1023 }
1024 }
1025
1026 if std::fs::write(&full_path, &config.config_content).is_ok() {
1027 installed.push(config.tool_name.clone());
1028 if config.tool_name == "Claude Code" {
1035 let _ = crate::claude_md_integration::install_claude_md_guidance(
1036 project_dir, sqz_path,
1037 );
1038 let _ = crate::claude_md_integration::install_claude_mcp_config();
1039 }
1040 }
1041 }
1042
1043 if filter.includes("OpenCode") {
1054 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1055 if !installed.iter().any(|n| n == "OpenCode") {
1056 installed.push("OpenCode".to_string());
1057 }
1058 }
1059 }
1060
1061 installed
1062}
1063
1064pub fn claude_user_settings_path() -> Option<PathBuf> {
1077 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1078}
1079
1080fn install_claude_global(sqz_path: &str) -> Result<bool> {
1095 install_claude_global_at(sqz_path, None)
1096}
1097
1098fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1103 let path = match home_override {
1104 Some(h) => h.join(".claude").join("settings.json"),
1105 None => claude_user_settings_path().ok_or_else(|| {
1106 crate::error::SqzError::Other(
1107 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1108 )
1109 })?,
1110 };
1111
1112 let mut root: serde_json::Value = if path.exists() {
1114 let content = std::fs::read_to_string(&path).map_err(|e| {
1115 crate::error::SqzError::Other(format!(
1116 "read {}: {e}",
1117 path.display()
1118 ))
1119 })?;
1120 if content.trim().is_empty() {
1121 serde_json::Value::Object(serde_json::Map::new())
1122 } else {
1123 serde_json::from_str(&content).map_err(|e| {
1124 crate::error::SqzError::Other(format!(
1125 "parse {}: {e} — please fix or move the file before re-running sqz init",
1126 path.display()
1127 ))
1128 })?
1129 }
1130 } else {
1131 serde_json::Value::Object(serde_json::Map::new())
1132 };
1133
1134 let root_obj = root.as_object_mut().ok_or_else(|| {
1137 crate::error::SqzError::Other(format!(
1138 "{} is not a JSON object — refusing to overwrite",
1139 path.display()
1140 ))
1141 })?;
1142
1143 let pre_tool_use = serde_json::json!({
1145 "matcher": "Bash|PowerShell",
1146 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1147 });
1148 let pre_compact = serde_json::json!({
1149 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1150 });
1151 let session_start = serde_json::json!({
1152 "matcher": "compact",
1153 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1154 });
1155
1156 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1158
1159 let hooks = root_obj
1161 .entry("hooks".to_string())
1162 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1163 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1164 crate::error::SqzError::Other(format!(
1165 "{}: `hooks` is not an object — refusing to overwrite",
1166 path.display()
1167 ))
1168 })?;
1169
1170 upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "hook claude");
1171 upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "hook precompact");
1172 upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "resume");
1173
1174 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1175 if before == after && path.exists() {
1176 return Ok(false);
1178 }
1179
1180 if let Some(parent) = path.parent() {
1182 std::fs::create_dir_all(parent).map_err(|e| {
1183 crate::error::SqzError::Other(format!(
1184 "create {}: {e}",
1185 parent.display()
1186 ))
1187 })?;
1188 }
1189
1190 let parent = path.parent().ok_or_else(|| {
1194 crate::error::SqzError::Other(format!(
1195 "path {} has no parent directory",
1196 path.display()
1197 ))
1198 })?;
1199 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1200 crate::error::SqzError::Other(format!(
1201 "create temp file in {}: {e}",
1202 parent.display()
1203 ))
1204 })?;
1205 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1206 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1207 std::fs::write(tmp.path(), serialized).map_err(|e| {
1208 crate::error::SqzError::Other(format!(
1209 "write to temp file {}: {e}",
1210 tmp.path().display()
1211 ))
1212 })?;
1213 tmp.persist(&path).map_err(|e| {
1214 crate::error::SqzError::Other(format!(
1215 "rename temp file into place at {}: {e}",
1216 path.display()
1217 ))
1218 })?;
1219
1220 Ok(true)
1221}
1222
1223pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1237 remove_claude_global_hook_at(None)
1238}
1239
1240fn remove_claude_global_hook_at(
1243 home_override: Option<&Path>,
1244) -> Result<Option<(PathBuf, bool)>> {
1245 let path = match home_override {
1246 Some(h) => h.join(".claude").join("settings.json"),
1247 None => match claude_user_settings_path() {
1248 Some(p) => p,
1249 None => return Ok(None),
1250 },
1251 };
1252 if !path.exists() {
1253 return Ok(None);
1254 }
1255
1256 let content = std::fs::read_to_string(&path).map_err(|e| {
1257 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1258 })?;
1259 if content.trim().is_empty() {
1260 return Ok(Some((path, false)));
1261 }
1262
1263 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1264 crate::error::SqzError::Other(format!(
1265 "parse {}: {e} — refusing to rewrite an unparseable file",
1266 path.display()
1267 ))
1268 })?;
1269 let Some(root_obj) = root.as_object_mut() else {
1270 return Ok(Some((path, false)));
1271 };
1272
1273 let mut changed = false;
1274 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1275 for (event, sentinel) in &[
1276 ("PreToolUse", "hook claude"),
1277 ("PreCompact", "hook precompact"),
1278 ("SessionStart", "resume"),
1279 ] {
1280 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1281 let before = arr.len();
1282 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1283 if arr.len() != before {
1284 changed = true;
1285 }
1286 }
1287 }
1288
1289 hooks.retain(|_, v| match v {
1292 serde_json::Value::Array(a) => !a.is_empty(),
1293 _ => true,
1294 });
1295
1296 let hooks_empty = hooks.is_empty();
1299 if hooks_empty {
1300 root_obj.remove("hooks");
1301 changed = true;
1302 }
1303 }
1304
1305 if !changed {
1306 return Ok(Some((path, false)));
1307 }
1308
1309 if root_obj.is_empty() {
1313 std::fs::remove_file(&path).map_err(|e| {
1314 crate::error::SqzError::Other(format!(
1315 "remove {}: {e}",
1316 path.display()
1317 ))
1318 })?;
1319 return Ok(Some((path, true)));
1320 }
1321
1322 let parent = path.parent().ok_or_else(|| {
1324 crate::error::SqzError::Other(format!(
1325 "path {} has no parent directory",
1326 path.display()
1327 ))
1328 })?;
1329 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1330 crate::error::SqzError::Other(format!(
1331 "create temp file in {}: {e}",
1332 parent.display()
1333 ))
1334 })?;
1335 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1336 .map_err(|e| {
1337 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1338 })?;
1339 std::fs::write(tmp.path(), serialized).map_err(|e| {
1340 crate::error::SqzError::Other(format!(
1341 "write to temp file {}: {e}",
1342 tmp.path().display()
1343 ))
1344 })?;
1345 tmp.persist(&path).map_err(|e| {
1346 crate::error::SqzError::Other(format!(
1347 "rename temp file into place at {}: {e}",
1348 path.display()
1349 ))
1350 })?;
1351
1352 Ok(Some((path, true)))
1353}
1354
1355fn upsert_sqz_hook_entry(
1362 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1363 event_name: &str,
1364 new_entry: serde_json::Value,
1365 sentinel: &str,
1366) {
1367 let arr = hooks_obj
1368 .entry(event_name.to_string())
1369 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1370 let Some(arr) = arr.as_array_mut() else {
1371 hooks_obj.insert(
1375 event_name.to_string(),
1376 serde_json::Value::Array(vec![new_entry]),
1377 );
1378 return;
1379 };
1380
1381 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1383
1384 arr.push(new_entry);
1385}
1386
1387fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1391 entry
1392 .get("hooks")
1393 .and_then(|h| h.as_array())
1394 .map(|hooks_arr| {
1395 hooks_arr.iter().any(|h| {
1396 h.get("command")
1397 .and_then(|c| c.as_str())
1398 .map(|c| c.contains(needle))
1399 .unwrap_or(false)
1400 })
1401 })
1402 .unwrap_or(false)
1403}
1404
1405fn extract_base_command(cmd: &str) -> &str {
1409 cmd.split_whitespace()
1410 .next()
1411 .unwrap_or("unknown")
1412 .rsplit('/')
1413 .next()
1414 .unwrap_or("unknown")
1415}
1416
1417pub(crate) fn json_escape_string_value(s: &str) -> String {
1428 let mut out = String::with_capacity(s.len() + 2);
1429 for ch in s.chars() {
1430 match ch {
1431 '\\' => out.push_str("\\\\"),
1432 '"' => out.push_str("\\\""),
1433 '\n' => out.push_str("\\n"),
1434 '\r' => out.push_str("\\r"),
1435 '\t' => out.push_str("\\t"),
1436 '\x08' => out.push_str("\\b"),
1437 '\x0c' => out.push_str("\\f"),
1438 c if (c as u32) < 0x20 => {
1439 out.push_str(&format!("\\u{:04x}", c as u32));
1441 }
1442 c => out.push(c),
1443 }
1444 }
1445 out
1446}
1447
1448fn shell_escape(s: &str) -> String {
1450 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1451 s.to_string()
1452 } else {
1453 format!("'{}'", s.replace('\'', "'\\''"))
1454 }
1455}
1456
1457pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1461 cmd.contains("&&")
1464 || cmd.contains("||")
1465 || cmd.contains(';')
1466 || cmd.contains('>')
1467 || cmd.contains('<')
1468 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1474
1475fn is_interactive_command(cmd: &str) -> bool {
1477 let base = extract_base_command(cmd);
1478 matches!(
1479 base,
1480 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1481 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1482 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1483 ) || cmd.contains("--watch")
1484 || cmd.contains("-w ")
1485 || cmd.ends_with(" -w")
1486 || cmd.contains("run dev")
1487 || cmd.contains("run start")
1488 || cmd.contains("run serve")
1489}
1490
1491#[cfg(test)]
1494mod tests {
1495 use super::*;
1496
1497 #[test]
1498 fn test_process_hook_rewrites_bash_command() {
1499 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1501 let result = process_hook(input).unwrap();
1502 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1503 let hook_output = &parsed["hookSpecificOutput"];
1505 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1506 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1507 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1509 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1510 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1511 assert!(
1514 cmd.contains("--cmd 'git status'"),
1515 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1516 );
1517 assert!(
1518 !cmd.contains("SQZ_CMD="),
1519 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1520 );
1521 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1523 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1524 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1525 }
1526
1527 #[test]
1528 fn test_process_hook_passes_through_non_bash() {
1529 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1530 let result = process_hook(input).unwrap();
1531 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1532 }
1533
1534 #[test]
1535 fn test_process_hook_rewrites_powershell_command() {
1536 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1537 let result = process_hook(input).unwrap();
1538 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1539 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1540 .as_str()
1541 .expect("PowerShell tool calls should be rewritten");
1542 assert!(
1543 cmd.contains("Get-Content big.log"),
1544 "should preserve original PowerShell command: {cmd}"
1545 );
1546 assert!(
1547 cmd.contains("sqz compress"),
1548 "should pipe through sqz compress: {cmd}"
1549 );
1550 assert!(
1551 cmd.contains("--cmd 'Get-Content big.log'"),
1552 "should pass full command via --cmd: {cmd}"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_process_hook_rewrites_pwsh_command() {
1558 let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
1559 let result = process_hook(input).unwrap();
1560 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1561 assert!(
1562 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
1563 "pwsh tool calls should be rewritten"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_process_hook_skips_sqz_commands() {
1569 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1570 let result = process_hook(input).unwrap();
1571 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1572 }
1573
1574 #[test]
1575 fn test_process_hook_skips_interactive() {
1576 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1577 let result = process_hook(input).unwrap();
1578 assert_eq!(result, input, "interactive commands should pass through");
1579 }
1580
1581 #[test]
1582 fn test_process_hook_skips_watch_mode() {
1583 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1584 let result = process_hook(input).unwrap();
1585 assert_eq!(result, input, "watch mode should pass through");
1586 }
1587
1588 #[test]
1589 fn test_process_hook_empty_command() {
1590 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1591 let result = process_hook(input).unwrap();
1592 assert_eq!(result, input);
1593 }
1594
1595 #[test]
1596 fn test_process_hook_gemini_format() {
1597 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1599 let result = process_hook_gemini(input).unwrap();
1600 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1601 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1603 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1605 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1606 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1608 "Gemini format should not have updatedInput");
1609 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1610 "Gemini format should not have permissionDecision");
1611 }
1612
1613 #[test]
1614 fn test_process_hook_legacy_format() {
1615 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1617 let result = process_hook(input).unwrap();
1618 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1619 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1620 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1621 }
1622
1623 #[test]
1624 fn test_process_hook_cursor_format() {
1625 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1627 let result = process_hook_cursor(input).unwrap();
1628 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1629 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1631 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1632 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1633 assert!(cmd.contains("git status"));
1634 assert!(parsed.get("hookSpecificOutput").is_none(),
1636 "Cursor format should not have hookSpecificOutput");
1637 }
1638
1639 #[test]
1640 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1641 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1643 let result = process_hook_cursor(input).unwrap();
1644 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1645 }
1646
1647 #[test]
1648 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1649 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1651 let result = process_hook_cursor(input).unwrap();
1652 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1653 }
1654
1655 #[test]
1656 fn test_process_hook_windsurf_format() {
1657 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1659 let result = process_hook_windsurf(input).unwrap();
1660 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1661 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1663 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1664 assert!(cmd.contains("cargo test"));
1665 assert!(
1667 cmd.contains("--cmd 'cargo test'"),
1668 "full command must be passed via --cmd flag: {cmd}"
1669 );
1670 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
1671 }
1672
1673 #[test]
1674 fn test_process_hook_invalid_json() {
1675 let result = process_hook("not json");
1676 assert!(result.is_err());
1677 }
1678
1679 #[test]
1680 fn test_extract_base_command() {
1681 assert_eq!(extract_base_command("git status"), "git");
1682 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1683 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1684 }
1685
1686 #[test]
1687 fn test_is_interactive_command() {
1688 assert!(is_interactive_command("vim file.txt"));
1689 assert!(is_interactive_command("npm run dev --watch"));
1690 assert!(is_interactive_command("python3"));
1691 assert!(!is_interactive_command("git status"));
1692 assert!(!is_interactive_command("cargo test"));
1693 }
1694
1695 #[test]
1710 fn issue_10_rewrite_is_shell_neutral() {
1711 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1712 let result = process_hook(input).unwrap();
1713 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1714 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1715 .as_str()
1716 .unwrap();
1717
1718 assert!(
1720 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
1721 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
1722 );
1723 assert!(
1725 !cmd.contains("SQZ_CMD="),
1726 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
1727 (broken in PowerShell and cmd.exe), got: {cmd}"
1728 );
1729 assert!(
1731 cmd.contains("dotnet build NewNeonCheckers3.sln"),
1732 "original command must be preserved verbatim: {cmd}"
1733 );
1734 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
1736 }
1737
1738 #[test]
1746 fn issue_10_already_wrapped_command_passes_through() {
1747 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
1748 let result = process_hook(input).unwrap();
1749 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1750 assert_eq!(
1753 result, input,
1754 "already-wrapped command must pass through unchanged; \
1755 otherwise each pass accumulates another `| sqz compress` tail"
1756 );
1757 let _ = parsed; }
1762
1763 #[test]
1764 fn test_generate_hook_configs() {
1765 let configs = generate_hook_configs("sqz");
1766 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1767 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1768 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1769 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1770 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1773 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1774 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1775 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1776 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1777 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1778 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1782 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1783 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1784 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1785 assert!(cursor.config_content.starts_with("---"),
1786 "Cursor rule should start with YAML frontmatter");
1787 assert!(cursor.config_content.contains("alwaysApply: true"),
1788 "Cursor rule should use alwaysApply: true so the guidance loads \
1789 for every agent interaction");
1790 assert!(cursor.config_content.contains("sqz"),
1791 "Cursor rule body should mention sqz");
1792 }
1793
1794 #[test]
1795 fn test_claude_config_includes_precompact_hook() {
1796 let configs = generate_hook_configs("sqz");
1801 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1802 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1803 .expect("Claude Code config must be valid JSON");
1804
1805 let precompact = parsed["hooks"]["PreCompact"]
1806 .as_array()
1807 .expect("PreCompact hook array must be present");
1808 assert!(
1809 !precompact.is_empty(),
1810 "PreCompact must have at least one registered hook"
1811 );
1812
1813 let cmd = precompact[0]["hooks"][0]["command"]
1814 .as_str()
1815 .expect("command field must be a string");
1816 assert!(
1817 cmd.ends_with(" hook precompact"),
1818 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1819 );
1820 }
1821
1822 #[test]
1825 fn test_json_escape_string_value() {
1826 assert_eq!(json_escape_string_value("sqz"), "sqz");
1828 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1829 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1831 r"C:\\Users\\Alice\\sqz.exe");
1832 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1834 r#"path with \"quotes\""#);
1835 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1837 }
1838
1839 #[test]
1840 fn test_windows_path_produces_valid_json_for_claude() {
1841 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1844 let configs = generate_hook_configs(windows_path);
1845
1846 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1847 .expect("Claude config should be generated");
1848 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1849 .expect("Claude hook config must be valid JSON on Windows paths");
1850
1851 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1853 .as_str()
1854 .expect("command field must be a string");
1855 assert!(cmd.contains(windows_path),
1856 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1857 }
1858
1859 #[test]
1860 fn test_windows_path_in_cursor_rules_file() {
1861 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1867 let configs = generate_hook_configs(windows_path);
1868
1869 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1870 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1871 assert!(cursor.config_content.contains(windows_path),
1872 "Cursor rule must contain the raw (unescaped) path so users can \
1873 copy-paste the shown commands — got:\n{}", cursor.config_content);
1874 assert!(!cursor.config_content.contains(r"C:\\Users"),
1875 "Cursor rule must NOT double-escape backslashes in markdown — \
1876 got:\n{}", cursor.config_content);
1877 }
1878
1879 #[test]
1880 fn test_windows_path_produces_valid_json_for_gemini() {
1881 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1882 let configs = generate_hook_configs(windows_path);
1883
1884 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1885 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1886 .expect("Gemini hook config must be valid JSON on Windows paths");
1887 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1888 assert!(cmd.contains(windows_path));
1889 }
1890
1891 #[test]
1892 fn test_rules_files_use_raw_path_for_readability() {
1893 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1897 let configs = generate_hook_configs(windows_path);
1898
1899 for tool in &["Windsurf", "Cline", "Cursor"] {
1900 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1901 assert!(cfg.config_content.contains(windows_path),
1902 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1903 cfg.config_content);
1904 assert!(!cfg.config_content.contains(r"C:\\Users"),
1905 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1906 cfg.config_content);
1907 }
1908 }
1909
1910 #[test]
1911 fn test_unix_path_still_works() {
1912 let unix_path = "/usr/local/bin/sqz";
1915 let configs = generate_hook_configs(unix_path);
1916
1917 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1918 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1919 .expect("Unix path should produce valid JSON");
1920 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1921 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1922 }
1923
1924 #[test]
1925 fn test_shell_escape_simple() {
1926 assert_eq!(shell_escape("git"), "git");
1927 assert_eq!(shell_escape("cargo-test"), "cargo-test");
1928 }
1929
1930 #[test]
1931 fn test_shell_escape_special_chars() {
1932 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1933 }
1934
1935 #[test]
1936 fn test_install_tool_hooks_creates_files() {
1937 let dir = tempfile::tempdir().unwrap();
1938 let installed = install_tool_hooks(dir.path(), "sqz");
1939 assert!(!installed.is_empty(), "should install at least one hook config");
1941 for name in &installed {
1943 let configs = generate_hook_configs("sqz");
1944 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1945 let path = dir.path().join(&config.config_path);
1946 assert!(path.exists(), "hook config should exist: {}", path.display());
1947 }
1948 }
1949
1950 #[test]
1951 fn test_install_tool_hooks_does_not_overwrite() {
1952 let dir = tempfile::tempdir().unwrap();
1953 install_tool_hooks(dir.path(), "sqz");
1955 let custom_path = dir.path().join(".claude/settings.local.json");
1957 std::fs::write(&custom_path, "custom content").unwrap();
1958 install_tool_hooks(dir.path(), "sqz");
1960 let content = std::fs::read_to_string(&custom_path).unwrap();
1961 assert_eq!(content, "custom content", "should not overwrite existing config");
1962 }
1963}
1964
1965#[cfg(test)]
1966mod global_install_tests {
1967 use super::*;
1968
1969 #[test]
1970 fn global_install_creates_fresh_settings_json() {
1971 let tmp = tempfile::tempdir().unwrap();
1972 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
1973 assert!(changed, "first install should report a change");
1974
1975 let path = tmp.path().join(".claude").join("settings.json");
1976 assert!(path.exists(), "user settings.json should be created");
1977
1978 let content = std::fs::read_to_string(&path).unwrap();
1979 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1980
1981 let pre = &parsed["hooks"]["PreToolUse"];
1983 assert!(pre.is_array(), "PreToolUse should be an array");
1984 assert_eq!(pre.as_array().unwrap().len(), 1);
1985 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1986 assert!(
1987 cmd.contains("/usr/local/bin/sqz"),
1988 "hook command should use the passed sqz_path, got: {cmd}"
1989 );
1990 assert!(cmd.contains("hook claude"));
1991
1992 let precompact = &parsed["hooks"]["PreCompact"];
1993 assert!(precompact.is_array());
1994 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1995 assert!(precompact_cmd.contains("hook precompact"));
1996
1997 let session = &parsed["hooks"]["SessionStart"];
1998 assert!(session.is_array());
1999 assert_eq!(
2000 session[0]["matcher"].as_str().unwrap(),
2001 "compact",
2002 "SessionStart should only match /compact resume"
2003 );
2004 }
2005
2006 #[test]
2007 fn global_install_preserves_existing_user_config() {
2008 let tmp = tempfile::tempdir().unwrap();
2009 let settings = tmp.path().join(".claude").join("settings.json");
2010 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2011
2012 let existing = serde_json::json!({
2013 "permissions": {
2014 "allow": ["Bash(npm test *)"],
2015 "deny": ["Read(./.env)"]
2016 },
2017 "env": { "FOO": "bar" },
2018 "statusLine": {
2019 "type": "command",
2020 "command": "~/.claude/statusline.sh"
2021 },
2022 "hooks": {
2023 "PreToolUse": [
2024 {
2025 "matcher": "Edit",
2026 "hooks": [
2027 {
2028 "type": "command",
2029 "command": "~/.claude/hooks/format-on-edit.sh"
2030 }
2031 ]
2032 }
2033 ]
2034 }
2035 });
2036 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2037
2038 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2039 assert!(changed, "install should report a change on new hook");
2040
2041 let content = std::fs::read_to_string(&settings).unwrap();
2042 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2043
2044 assert_eq!(
2046 parsed["permissions"]["allow"][0].as_str().unwrap(),
2047 "Bash(npm test *)"
2048 );
2049 assert_eq!(
2050 parsed["permissions"]["deny"][0].as_str().unwrap(),
2051 "Read(./.env)"
2052 );
2053 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2055 assert_eq!(
2057 parsed["statusLine"]["command"].as_str().unwrap(),
2058 "~/.claude/statusline.sh"
2059 );
2060
2061 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2064 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2065 let matchers: Vec<&str> = pre
2066 .iter()
2067 .map(|e| e["matcher"].as_str().unwrap_or(""))
2068 .collect();
2069 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2070 assert!(
2071 matchers.contains(&"Bash|PowerShell"),
2072 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2073 );
2074 }
2075
2076 #[test]
2077 fn global_install_is_idempotent() {
2078 let tmp = tempfile::tempdir().unwrap();
2079 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2080 assert!(
2081 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2082 "second install with identical args should report no change"
2083 );
2084
2085 let path = tmp.path().join(".claude").join("settings.json");
2086 let parsed: serde_json::Value =
2087 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2088 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2089 let arr = parsed["hooks"][event].as_array().unwrap();
2090 assert_eq!(
2091 arr.len(),
2092 1,
2093 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2094 );
2095 }
2096 }
2097
2098 #[test]
2105 fn global_install_is_idempotent_with_exe_path() {
2106 let tmp = tempfile::tempdir().unwrap();
2107 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2108 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2109 assert!(
2110 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2111 "second install with .exe path should report no change (issue #21)"
2112 );
2113
2114 let path = tmp.path().join(".claude").join("settings.json");
2115 let parsed: serde_json::Value =
2116 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2117 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2118 let arr = parsed["hooks"][event].as_array().unwrap();
2119 assert_eq!(
2120 arr.len(),
2121 1,
2122 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2123 );
2124 }
2125 }
2126
2127 #[test]
2128 fn global_install_upgrades_stale_sqz_hook_in_place() {
2129 let tmp = tempfile::tempdir().unwrap();
2130 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2131 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2132 assert!(changed, "different sqz_path must be seen as a change");
2133
2134 let path = tmp.path().join(".claude").join("settings.json");
2135 let parsed: serde_json::Value =
2136 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2137 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2138 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2139 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2140 assert!(cmd.contains("/new/path/sqz"));
2141 assert!(!cmd.contains("/old/path/sqz"));
2142 }
2143
2144 #[test]
2145 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2146 let tmp = tempfile::tempdir().unwrap();
2147 let settings = tmp.path().join(".claude").join("settings.json");
2148 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2149 std::fs::write(
2150 &settings,
2151 serde_json::json!({
2152 "permissions": { "allow": ["Bash(git status)"] },
2153 "hooks": {
2154 "PreToolUse": [
2155 {
2156 "matcher": "Edit",
2157 "hooks": [
2158 { "type": "command", "command": "~/format.sh" }
2159 ]
2160 }
2161 ]
2162 }
2163 })
2164 .to_string(),
2165 )
2166 .unwrap();
2167
2168 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2169 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2170 assert_eq!(result.0, settings);
2171 assert!(result.1, "should report that the file was modified");
2172
2173 assert!(settings.exists(), "settings.json should be preserved");
2174 let parsed: serde_json::Value =
2175 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2176
2177 assert_eq!(
2178 parsed["permissions"]["allow"][0].as_str().unwrap(),
2179 "Bash(git status)"
2180 );
2181
2182 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2183 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2184 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2185
2186 assert!(parsed["hooks"].get("PreCompact").is_none());
2187 assert!(parsed["hooks"].get("SessionStart").is_none());
2188 }
2189
2190 #[test]
2191 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2192 let tmp = tempfile::tempdir().unwrap();
2193 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2194 let path = tmp.path().join(".claude").join("settings.json");
2195 assert!(path.exists(), "precondition: install created the file");
2196
2197 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2198 assert!(result.1);
2199 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2200 }
2201
2202 #[test]
2203 fn global_uninstall_on_missing_file_is_noop() {
2204 let tmp = tempfile::tempdir().unwrap();
2205 assert!(
2206 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2207 "missing file should return None, not error"
2208 );
2209 }
2210
2211 #[test]
2212 fn global_uninstall_refuses_to_touch_unparseable_file() {
2213 let tmp = tempfile::tempdir().unwrap();
2214 let settings = tmp.path().join(".claude").join("settings.json");
2215 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2216 std::fs::write(&settings, "{ invalid json because").unwrap();
2217
2218 assert!(
2219 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2220 "bad JSON must surface as an error"
2221 );
2222
2223 let after = std::fs::read_to_string(&settings).unwrap();
2224 assert_eq!(after, "{ invalid json because");
2225 }
2226}
2227
2228#[cfg(test)]
2229mod issue_11_tool_filter_tests {
2230 use super::*;
2239
2240 #[test]
2241 fn canonicalize_collapses_common_aliases() {
2242 for aliases in &[
2245 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2246 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2247 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2248 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2252 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2253 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2254 (vec!["Codex", "codex"], "codex"),
2255 ] {
2256 for alias in &aliases.0 {
2257 assert_eq!(
2258 canonicalize_tool_name(alias),
2259 aliases.1,
2260 "alias '{}' must canonicalise to '{}'",
2261 alias,
2262 aliases.1
2263 );
2264 }
2265 }
2266 }
2267
2268 #[test]
2269 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2270 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2275 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2276 }
2277
2278 #[test]
2279 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2280 let names = parse_tool_list("opencode,codex").unwrap();
2283 assert_eq!(names, vec!["opencode", "codex"]);
2284
2285 let names = parse_tool_list(" opencode , codex ").unwrap();
2286 assert_eq!(names, vec!["opencode", "codex"]);
2287
2288 let names = parse_tool_list("opencode").unwrap();
2290 assert_eq!(names, vec!["opencode"]);
2291
2292 let names = parse_tool_list("claude-code").unwrap();
2294 assert_eq!(names, vec!["claudecode"]);
2295 }
2296
2297 #[test]
2298 fn parse_tool_list_dedupes_repeated_entries() {
2299 let names = parse_tool_list("opencode,opencode").unwrap();
2304 assert_eq!(names, vec!["opencode"]);
2305
2306 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2309 assert_eq!(names, vec!["claudecode"]);
2310 }
2311
2312 #[test]
2313 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2314 let err = parse_tool_list("opncode").unwrap_err();
2321 let msg = err.to_string();
2322 assert!(
2323 msg.contains("unknown agent name 'opncode'"),
2324 "error must quote the bad input: {msg}"
2325 );
2326 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2327 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2328 }
2329
2330 #[test]
2331 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2332 let err = parse_tool_list("opencode,xyz").unwrap_err();
2337 assert!(err.to_string().contains("xyz"));
2338 }
2339
2340 #[test]
2341 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2342 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2348 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2349 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2350 }
2351
2352 #[test]
2353 fn tool_filter_all_includes_every_supported_tool() {
2354 let filter = ToolFilter::All;
2355 for tool in SUPPORTED_TOOL_NAMES {
2356 assert!(
2357 filter.includes(tool),
2358 "default filter must include {tool}"
2359 );
2360 }
2361 }
2362
2363 #[test]
2364 fn tool_filter_only_opencode_excludes_everything_else() {
2365 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2367 assert!(filter.includes("OpenCode"));
2368 for tool in SUPPORTED_TOOL_NAMES {
2370 if *tool == "OpenCode" {
2371 continue;
2372 }
2373 assert!(
2374 !filter.includes(tool),
2375 "--only opencode must not include {tool}"
2376 );
2377 }
2378 }
2379
2380 #[test]
2381 fn tool_filter_only_multi_tool_includes_exactly_those() {
2382 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2383 assert!(filter.includes("OpenCode"));
2384 assert!(filter.includes("Codex"));
2385 assert!(!filter.includes("Claude Code"));
2387 assert!(!filter.includes("Cursor"));
2388 assert!(!filter.includes("Windsurf"));
2389 assert!(!filter.includes("Cline"));
2390 assert!(!filter.includes("Gemini CLI"));
2391 }
2392
2393 #[test]
2394 fn tool_filter_skip_inverts_the_set() {
2395 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2398 assert!(!filter.includes("Cursor"));
2399 assert!(!filter.includes("Windsurf"));
2400 assert!(filter.includes("Claude Code"));
2402 assert!(filter.includes("Cline"));
2403 assert!(filter.includes("Gemini CLI"));
2404 assert!(filter.includes("OpenCode"));
2405 assert!(filter.includes("Codex"));
2406 }
2407
2408 #[test]
2409 fn tool_filter_only_empty_excludes_everything() {
2410 let filter = ToolFilter::Only(vec![]);
2416 for tool in SUPPORTED_TOOL_NAMES {
2417 assert!(
2418 !filter.includes(tool),
2419 "empty --only must exclude every tool, got {tool}"
2420 );
2421 }
2422 }
2423
2424 #[test]
2425 fn tool_filter_only_accepts_display_name_or_canonical() {
2426 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2432 assert!(filter.includes("Claude Code"));
2433 assert!(!filter.includes("Cursor"));
2434
2435 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2436 assert!(filter.includes("Gemini CLI"));
2437 }
2438
2439 #[test]
2440 fn supported_tool_names_matches_generate_hook_configs_exactly() {
2441 let configs = generate_hook_configs("sqz");
2447 let emitted: std::collections::HashSet<&str> =
2448 configs.iter().map(|c| c.tool_name.as_str()).collect();
2449 let declared: std::collections::HashSet<&str> =
2450 SUPPORTED_TOOL_NAMES.iter().copied().collect();
2451 assert_eq!(
2452 emitted, declared,
2453 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2454 from generate_hook_configs. emitted={:?}, declared={:?}",
2455 emitted, declared
2456 );
2457 }
2458
2459 #[test]
2460 fn filtered_install_only_opencode_writes_only_opencode_files() {
2461 let dir = tempfile::tempdir().unwrap();
2467 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2468 let _installed = install_tool_hooks_scoped_filtered(
2469 dir.path(),
2470 "sqz",
2471 InstallScope::Project,
2472 &filter,
2473 );
2474
2475 assert!(
2477 dir.path().join("opencode.json").exists(),
2478 "OpenCode config must be written when --only opencode is used"
2479 );
2480
2481 for (path, tool) in &[
2483 (".claude/settings.local.json", "Claude Code"),
2484 (".cursor/rules/sqz.mdc", "Cursor"),
2485 (".windsurfrules", "Windsurf"),
2486 (".clinerules", "Cline"),
2487 (".gemini/settings.json", "Gemini CLI"),
2488 ("AGENTS.md", "Codex"),
2489 ] {
2490 assert!(
2491 !dir.path().join(path).exists(),
2492 "filter rejected {tool} but the installer still wrote {path}"
2493 );
2494 }
2495 }
2496
2497 #[test]
2498 fn filtered_install_skip_cursor_omits_only_cursor() {
2499 let dir = tempfile::tempdir().unwrap();
2501 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
2502 let _installed = install_tool_hooks_scoped_filtered(
2503 dir.path(),
2504 "sqz",
2505 InstallScope::Project,
2506 &filter,
2507 );
2508
2509 assert!(
2511 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
2512 "skip cursor: .cursor/rules/sqz.mdc must not be written"
2513 );
2514 assert!(
2516 dir.path().join(".windsurfrules").exists(),
2517 "skip cursor should not skip windsurf"
2518 );
2519 assert!(
2520 dir.path().join(".clinerules").exists(),
2521 "skip cursor should not skip cline"
2522 );
2523 }
2524}
2525