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 Copilot,
68}
69
70pub fn process_hook(input: &str) -> Result<String> {
89 process_hook_for_platform(input, HookPlatform::ClaudeCode)
90}
91
92pub fn process_hook_cursor(input: &str) -> Result<String> {
97 process_hook_for_platform(input, HookPlatform::Cursor)
98}
99
100pub fn process_hook_gemini(input: &str) -> Result<String> {
104 process_hook_for_platform(input, HookPlatform::GeminiCli)
105}
106
107pub fn process_hook_windsurf(input: &str) -> Result<String> {
112 process_hook_for_platform(input, HookPlatform::Windsurf)
113}
114
115pub fn process_hook_kiro(input: &str) -> Result<String> {
125 process_hook_for_platform(input, HookPlatform::Kiro)
126}
127
128pub fn process_hook_copilot(input: &str) -> Result<String> {
135 process_hook_for_platform(input, HookPlatform::Copilot)
136}
137
138fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
142 let parsed: serde_json::Value = serde_json::from_str(input)
143 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
144
145 let tool_name = parsed
149 .get("tool_name")
150 .or_else(|| parsed.get("toolName"))
151 .and_then(|v| v.as_str())
152 .unwrap_or("");
153
154 let hook_event = parsed
155 .get("hook_event_name")
156 .or_else(|| parsed.get("agent_action_name"))
157 .and_then(|v| v.as_str())
158 .unwrap_or("");
159
160 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
169 | "run_terminal_command" | "run_shell_command" | "execute_bash"
170 | "PowerShell" | "powershell" | "pwsh")
171 || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
172
173 if !is_shell {
174 return Ok(match platform {
177 HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
178 _ => input.to_string(),
179 });
180 }
181
182 let command = parsed
187 .get("tool_input")
188 .and_then(|v| v.get("command"))
189 .and_then(|v| v.as_str())
190 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
191 .or_else(|| {
192 parsed
193 .get("tool_info")
194 .and_then(|v| v.get("command_line"))
195 .and_then(|v| v.as_str())
196 })
197 .or_else(|| {
198 parsed
199 .get("toolCall")
200 .and_then(|v| v.get("command"))
201 .and_then(|v| v.as_str())
202 })
203 .or_else(|| {
204 parsed
206 .get("toolArgs")
207 .and_then(|v| v.get("command"))
208 .and_then(|v| v.as_str())
209 })
210 .unwrap_or("");
211
212 if command.is_empty() {
213 return Ok(match platform {
214 HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
215 _ => input.to_string(),
216 });
217 }
218
219 let base_cmd = extract_base_command(command);
231 if base_cmd == "sqz"
232 || command.starts_with("SQZ_CMD=")
233 || command.contains("sqz compress --cmd ")
234 || command.contains("sqz.exe compress --cmd ")
235 {
236 return Ok(match platform {
237 HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
238 _ => input.to_string(),
239 });
240 }
241
242 if is_interactive_command(command) {
244 return Ok(match platform {
245 HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
246 _ => input.to_string(),
247 });
248 }
249
250 if has_shell_operators(command) {
255 return Ok(match platform {
256 HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
257 _ => input.to_string(),
258 });
259 }
260
261 let rewritten = format!(
279 "{} 2>&1 | sqz compress --cmd {}",
280 command,
281 shell_escape(command),
282 );
283
284 let mut updated_input = parsed
291 .get("tool_input")
292 .or_else(|| parsed.get("toolArgs"))
293 .and_then(|v| v.as_object())
294 .cloned()
295 .unwrap_or_default();
296 updated_input.insert(
297 "command".to_string(),
298 serde_json::Value::String(rewritten.clone()),
299 );
300 let updated_input = serde_json::Value::Object(updated_input);
301
302 let output = match platform {
328 HookPlatform::ClaudeCode => serde_json::json!({
329 "hookSpecificOutput": {
330 "hookEventName": "PreToolUse",
331 "permissionDecision": "allow",
332 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
333 "updatedInput": updated_input
334 }
335 }),
336 HookPlatform::Cursor => serde_json::json!({
337 "permission": "allow",
338 "updated_input": {
339 "command": rewritten
340 }
341 }),
342 HookPlatform::GeminiCli => serde_json::json!({
343 "decision": "allow",
344 "hookSpecificOutput": {
345 "tool_input": {
346 "command": rewritten
347 }
348 }
349 }),
350 HookPlatform::Windsurf => {
351 serde_json::json!({
355 "hookSpecificOutput": {
356 "hookEventName": "PreToolUse",
357 "permissionDecision": "allow",
358 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
359 "updatedInput": updated_input
360 }
361 })
362 }
363 HookPlatform::Copilot => serde_json::json!({
364 "permissionDecision": "allow",
365 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
366 "modifiedArgs": updated_input
367 }),
368 HookPlatform::Kiro => {
369 serde_json::json!({
373 "tool_input": {
374 "command": rewritten
375 }
376 })
377 }
378 };
379
380 serde_json::to_string(&output)
381 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
382}
383
384pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
386 let sqz_path_raw = sqz_path;
397 let sqz_path_json = json_escape_string_value(sqz_path);
398 let sqz_path = &sqz_path_json;
399
400 let escape_hatch = format!(
408 r#"## Escape hatch — when sqz output confuses the agent
409
410If you see a `§ref:HASH§` token in tool output and can't parse it, or if
411compressed output is making it harder to work (loops, repeated tiny
412requests), or if a `«A1»` abbreviation symbol has replaced a value you
413need verbatim (a SHA, path, or URL), you have these ways out:
414
415- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
416 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
417 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
418- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
419 status 2>&1 | sqz compress`) to disable dedup for that one command.
420 Strictly more tokens, strictly less ambiguous.
421- **`--no-cache`** — the same opt-out as a CLI flag:
422 `git status 2>&1 | sqz compress --no-cache`.
423- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
424 abbreviation, which replaces repeated phrases with `«A1»` symbols.
425 Use this for output full of SHAs/paths/URLs you'll copy-paste, since
426 abbreviation keeps only the first occurrence of a repeated phrase:
427 `SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
428
429Use these when you genuinely need raw data. The default compressed
430path is still what you want for most output."#,
431 );
432 let escape_hatch = &escape_hatch;
433
434 vec![
435 ToolHookConfig {
450 tool_name: "Claude Code".to_string(),
451 config_path: PathBuf::from(".claude/settings.local.json"),
452 config_content: format!(
453 r#"{{
454 "hooks": {{
455 "PreToolUse": [
456 {{
457 "matcher": "Bash|PowerShell",
458 "hooks": [
459 {{
460 "type": "command",
461 "command": "{sqz_path} hook claude"
462 }}
463 ]
464 }}
465 ],
466 "PreCompact": [
467 {{
468 "hooks": [
469 {{
470 "type": "command",
471 "command": "{sqz_path} hook precompact"
472 }}
473 ]
474 }}
475 ],
476 "SessionStart": [
477 {{
478 "matcher": "compact",
479 "hooks": [
480 {{
481 "type": "command",
482 "command": "{sqz_path} resume"
483 }}
484 ]
485 }}
486 ]
487 }}
488}}"#
489 ),
490 scope: HookScope::Project,
491 },
492 ToolHookConfig {
501 tool_name: "Cursor".to_string(),
502 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
503 config_content: format!(
504 r#"---
505description: sqz — Token-Optimized CLI Output
506alwaysApply: true
507---
508
509# sqz — Token-Optimized CLI Output
510
511When running shell commands whose output may be long (directory listings,
512git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
513pipe the output through `sqz compress` to reduce token consumption.
514
515`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
516usage is to pipe the command's output into `sqz compress`:
517
518```bash
519# Instead of: Use:
520git status git status 2>&1 | {sqz_path_raw} compress
521cargo test cargo test 2>&1 | {sqz_path_raw} compress
522git log -10 git log -10 2>&1 | {sqz_path_raw} compress
523docker ps docker ps 2>&1 | {sqz_path_raw} compress
524ls -la ls -la 2>&1 | {sqz_path_raw} compress
525```
526
527The `2>&1` captures stderr too, which is useful for commands like `cargo
528test` where diagnostics go to stderr. `sqz compress` filters and compresses
529the combined output while preserving filenames, paths, and identifiers.
530It typically saves 60-90% tokens on verbose commands.
531
532Do NOT pipe output for:
533- Interactive commands (`vim`, `ssh`, `python`, REPLs)
534- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
535 `cmd; other`) — run those directly
536- Short commands whose output is already a few lines
537
538If `sqz` is not on PATH, run commands normally.
539
540{escape_hatch}
541"#
542 ),
543 scope: HookScope::Project,
544 },
545 ToolHookConfig {
548 tool_name: "Windsurf".to_string(),
549 config_path: PathBuf::from(".windsurf/rules/sqz.md"),
550 config_content: format!(
551 r#"# sqz — Token-Optimized CLI Output
552
553Pipe verbose shell command output through `sqz compress` to save tokens.
554`sqz` reads from stdin and writes the compressed output to stdout — it is
555NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
556
557```bash
558# Instead of: Use:
559git status git status 2>&1 | {sqz_path_raw} compress
560cargo test cargo test 2>&1 | {sqz_path_raw} compress
561git log -10 git log -10 2>&1 | {sqz_path_raw} compress
562docker ps docker ps 2>&1 | {sqz_path_raw} compress
563```
564
565sqz filters and compresses command outputs while preserving filenames,
566paths, and identifiers (typically 60-90% token reduction on verbose
567commands). Skip short commands, interactive commands (vim, ssh, python),
568and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
569not on PATH, run commands normally.
570
571{escape_hatch}
572"#
573 ),
574 scope: HookScope::Project,
575 },
576 ToolHookConfig {
579 tool_name: "Cline".to_string(),
580 config_path: PathBuf::from(".clinerules"),
581 config_content: format!(
582 r#"# sqz — Token-Optimized CLI Output
583
584Pipe verbose shell command output through `sqz compress` to save tokens.
585`sqz` reads from stdin and writes the compressed output to stdout — it is
586NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
587
588```bash
589# Instead of: Use:
590git status git status 2>&1 | {sqz_path_raw} compress
591cargo test cargo test 2>&1 | {sqz_path_raw} compress
592git log -10 git log -10 2>&1 | {sqz_path_raw} compress
593docker ps docker ps 2>&1 | {sqz_path_raw} compress
594```
595
596sqz filters and compresses command outputs while preserving filenames,
597paths, and identifiers (typically 60-90% token reduction on verbose
598commands). Skip short commands, interactive commands (vim, ssh, python),
599and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
600not on PATH, run commands normally.
601
602{escape_hatch}
603"#
604 ),
605 scope: HookScope::Project,
606 },
607 ToolHookConfig {
609 tool_name: "Gemini CLI".to_string(),
610 config_path: PathBuf::from(".gemini/settings.json"),
611 config_content: format!(
612 r#"{{
613 "hooks": {{
614 "BeforeTool": [
615 {{
616 "matcher": "run_shell_command",
617 "hooks": [
618 {{
619 "type": "command",
620 "command": "{sqz_path} hook gemini"
621 }}
622 ]
623 }}
624 ]
625 }}
626}}"#
627 ),
628 scope: HookScope::Project,
629 },
630 ToolHookConfig {
635 tool_name: "Kiro".to_string(),
636 config_path: PathBuf::from(".kiro/steering/sqz.md"),
637 config_content: crate::kiro_integration::kiro_steering_content(sqz_path_raw),
638 scope: HookScope::Project,
639 },
640 ToolHookConfig {
649 tool_name: "OpenCode".to_string(),
650 config_path: PathBuf::from("opencode.json"),
651 config_content: format!(
652 r#"{{
653 "$schema": "https://opencode.ai/config.json",
654 "mcp": {{
655 "sqz": {{
656 "type": "local",
657 "command": ["sqz-mcp", "--transport", "stdio"]
658 }}
659 }},
660 "plugin": ["sqz"]
661}}"#
662 ),
663 scope: HookScope::Project,
664 },
665 ToolHookConfig {
684 tool_name: "Codex".to_string(),
685 config_path: PathBuf::from("AGENTS.md"),
686 config_content: crate::codex_integration::agents_md_guidance_block(
687 sqz_path_raw,
688 ),
689 scope: HookScope::Project,
690 },
691 ToolHookConfig {
706 tool_name: "Zed".to_string(),
707 config_path: PathBuf::from("AGENTS.md"),
708 config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
709 scope: HookScope::Project,
710 },
711 ToolHookConfig {
716 tool_name: "Copilot CLI".to_string(),
717 config_path: PathBuf::from(".copilot/hooks/sqz.json"),
718 config_content: copilot_hook_content(sqz_path_raw),
719 scope: HookScope::User,
720 },
721 ]
722}
723
724pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
730 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum InstallScope {
758 Project,
761 Global,
764}
765
766#[derive(Debug, Clone, PartialEq, Eq)]
779pub enum ToolFilter {
780 All,
783 Only(Vec<String>),
787 Skip(Vec<String>),
792}
793
794impl Default for ToolFilter {
795 fn default() -> Self {
796 ToolFilter::All
797 }
798}
799
800impl ToolFilter {
801 pub fn includes(&self, tool_name: &str) -> bool {
812 let canon = canonicalize_tool_name(tool_name);
813 match self {
814 ToolFilter::All => true,
815 ToolFilter::Only(allow) => allow.iter().any(|n| {
816 n == &canon
819 }),
820 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
821 }
822 }
823}
824
825pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
830 "Claude Code",
831 "Cursor",
832 "Windsurf",
833 "Cline",
834 "Gemini CLI",
835 "Kiro",
836 "OpenCode",
837 "Codex",
838 "Zed",
839 "Copilot CLI",
840];
841
842pub fn copilot_hooks_path() -> PathBuf {
845 copilot_home().join("hooks").join("sqz.json")
846}
847
848fn copilot_home() -> PathBuf {
849 if let Ok(home) = std::env::var("COPILOT_HOME") {
850 if !home.trim().is_empty() {
851 return PathBuf::from(home);
852 }
853 }
854 let home = std::env::var("HOME")
855 .or_else(|_| std::env::var("USERPROFILE"))
856 .map(PathBuf::from)
857 .unwrap_or_else(|_| PathBuf::from("."));
858 home.join(".copilot")
859}
860
861pub fn copilot_hook_content(sqz_path: &str) -> String {
863 let escaped = json_escape_string_value(sqz_path);
864 format!(
865 r#"{{
866 "version": 1,
867 "hooks": {{
868 "preToolUse": [
869 {{
870 "type": "command",
871 "matcher": "bash|powershell",
872 "bash": "{escaped} hook copilot",
873 "powershell": "{escaped} hook copilot",
874 "timeoutSec": 10
875 }}
876 ]
877 }}
878}}
879"#
880 )
881}
882
883pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
887 let home = copilot_home();
888 if !home.exists() {
889 return Ok(None);
890 }
891 let path = copilot_hooks_path();
892 let content = copilot_hook_content(sqz_path);
893 if path.exists() {
894 let existing = std::fs::read_to_string(&path).unwrap_or_default();
895 if existing == content {
896 return Ok(Some(false));
897 }
898 }
899 if let Some(parent) = path.parent() {
900 std::fs::create_dir_all(parent).map_err(|e| {
901 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
902 })?;
903 }
904 std::fs::write(&path, content).map_err(|e| {
905 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
906 })?;
907 Ok(Some(true))
908}
909
910pub fn remove_copilot_hook() -> Result<bool> {
912 let path = copilot_hooks_path();
913 if !path.exists() {
914 return Ok(false);
915 }
916 std::fs::remove_file(&path).map_err(|e| {
917 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
918 })?;
919 Ok(true)
920}
921
922pub fn canonicalize_tool_name(name: &str) -> String {
941 let lowered: String = name
942 .chars()
943 .filter(|c| !c.is_whitespace())
944 .flat_map(|c| c.to_lowercase())
945 .filter(|c| *c != '-' && *c != '_')
946 .collect();
947 match lowered.as_str() {
948 "claude" | "claudecode" => "claudecode".to_string(),
949 "cursor" => "cursor".to_string(),
950 "windsurf" => "windsurf".to_string(),
951 "cline" | "roo" | "roocode" => "cline".to_string(),
955 "gemini" | "geminicli" => "gemini".to_string(),
956 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
957 "opencode" => "opencode".to_string(),
958 "codex" => "codex".to_string(),
959 "zed" | "zededitor" | "zedagent" => "zed".to_string(),
960 "copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
961 other => other.to_string(),
962 }
963}
964
965pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
975 let mut out = Vec::new();
976 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
977 .iter()
978 .map(|n| canonicalize_tool_name(n))
979 .collect();
980 for part in raw.split(',') {
981 let trimmed = part.trim();
982 if trimmed.is_empty() {
983 continue;
984 }
985 let canon = canonicalize_tool_name(trimmed);
986 if !known.contains(&canon) {
987 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
988 .iter()
989 .map(|n| canonicalize_tool_name(n))
990 .collect();
991 return Err(crate::error::SqzError::Other(format!(
992 "unknown agent name '{}'. Valid options: {}",
993 trimmed,
994 valid.join(", ")
995 )));
996 }
997 if !out.contains(&canon) {
998 out.push(canon);
999 }
1000 }
1001 Ok(out)
1002}
1003
1004pub fn install_tool_hooks_scoped(
1030 project_dir: &Path,
1031 sqz_path: &str,
1032 scope: InstallScope,
1033) -> Vec<String> {
1034 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
1035}
1036
1037pub fn install_tool_hooks_scoped_filtered(
1051 project_dir: &Path,
1052 sqz_path: &str,
1053 scope: InstallScope,
1054 filter: &ToolFilter,
1055) -> Vec<String> {
1056 let configs = generate_hook_configs(sqz_path);
1057 let mut installed = Vec::new();
1058
1059 for config in &configs {
1060 if !filter.includes(&config.tool_name) {
1064 continue;
1065 }
1066
1067 if config.tool_name == "OpenCode" {
1076 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
1077 Ok((updated, _comments_lost)) => {
1078 if updated && !installed.iter().any(|n| n == "OpenCode") {
1079 installed.push("OpenCode".to_string());
1080 }
1081 }
1082 Err(_e) => {
1083 }
1086 }
1087 continue;
1088 }
1089
1090 if config.tool_name == "Codex" {
1095 let agents_changed = crate::codex_integration::install_agents_md_guidance(
1096 project_dir, sqz_path,
1097 )
1098 .unwrap_or(false);
1099 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
1100 .unwrap_or(false);
1101 if (agents_changed || mcp_changed)
1102 && !installed.iter().any(|n| n == "Codex")
1103 {
1104 installed.push("Codex".to_string());
1105 }
1106 continue;
1107 }
1108
1109 if config.tool_name == "Windsurf" {
1112 let legacy = project_dir.join(".windsurfrules");
1113 let target = project_dir.join(&config.config_path);
1114 let legacy_is_sqz = legacy.exists()
1115 && std::fs::read_to_string(&legacy)
1116 .map(|s| s.starts_with("# sqz — Token-Optimized CLI Output"))
1117 .unwrap_or(false);
1118 let mut changed = false;
1119 if !target.exists() {
1120 if let Some(parent) = target.parent() {
1121 let _ = std::fs::create_dir_all(parent);
1122 }
1123 if std::fs::write(&target, &config.config_content).is_ok() {
1124 changed = true;
1125 }
1126 }
1127 if legacy_is_sqz && target.exists() {
1128 let _ = std::fs::remove_file(&legacy);
1129 changed = true;
1130 }
1131 if changed && !installed.iter().any(|n| n == "Windsurf") {
1132 installed.push("Windsurf".to_string());
1133 }
1134 continue;
1135 }
1136
1137 if config.tool_name == "Cline" {
1140 let base = project_dir.join(".clinerules");
1141 let target = if base.is_dir() { base.join("sqz.md") } else { base };
1142 if !target.exists() {
1143 if std::fs::write(&target, &config.config_content).is_ok()
1144 && !installed.iter().any(|n| n == "Cline")
1145 {
1146 installed.push("Cline".to_string());
1147 }
1148 }
1149 continue;
1150 }
1151
1152 if config.tool_name == "Copilot CLI" {
1154 if let Ok(Some(changed)) = install_copilot_hook(sqz_path) {
1155 if changed && !installed.iter().any(|n| n == "Copilot CLI") {
1156 installed.push("Copilot CLI".to_string());
1157 }
1158 }
1159 continue;
1160 }
1161
1162 if config.tool_name == "Kiro" {
1165 let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
1166 let steering_changed =
1167 crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
1168 .unwrap_or(false);
1169 let mcp_changed = matches!(
1170 crate::kiro_integration::install_kiro_mcp_config(project_dir),
1171 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1172 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1173 );
1174 if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
1175 installed.push("Kiro".to_string());
1176 }
1177 continue;
1178 }
1179
1180 if config.tool_name == "Zed" {
1185 let guidance_changed =
1186 crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
1187 .unwrap_or(false);
1188 let mcp_changed = matches!(
1189 crate::zed_integration::install_zed_mcp_config(),
1190 Ok(crate::zed_integration::ZedMcpInstall::Created)
1191 | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1192 );
1193 if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1194 installed.push("Zed".to_string());
1195 }
1196 continue;
1197 }
1198
1199 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1209 let hook_installed = match install_claude_global(sqz_path) {
1210 Ok(v) => v,
1211 Err(_) => false,
1212 };
1213 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1214 project_dir, sqz_path,
1215 )
1216 .unwrap_or(false);
1217 let mcp_changed =
1218 crate::claude_md_integration::install_claude_mcp_config()
1219 .unwrap_or(false);
1220 if (hook_installed || md_changed || mcp_changed)
1221 && !installed.iter().any(|n| n == "Claude Code")
1222 {
1223 installed.push("Claude Code".to_string());
1224 }
1225 continue;
1226 }
1227
1228 let full_path = project_dir.join(&config.config_path);
1229
1230 if full_path.exists() {
1232 if config.tool_name == "Claude Code" {
1241 let hook_changed =
1242 merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1243 let md_changed =
1244 crate::claude_md_integration::install_claude_md_guidance(
1245 project_dir, sqz_path,
1246 )
1247 .unwrap_or(false);
1248 let mcp_changed =
1249 crate::claude_md_integration::install_claude_mcp_config()
1250 .unwrap_or(false);
1251 if (hook_changed || md_changed || mcp_changed)
1252 && !installed.iter().any(|n| n == "Claude Code")
1253 {
1254 installed.push("Claude Code".to_string());
1255 }
1256 }
1257 continue;
1258 }
1259
1260 if let Some(parent) = full_path.parent() {
1262 if std::fs::create_dir_all(parent).is_err() {
1263 continue;
1264 }
1265 }
1266
1267 if std::fs::write(&full_path, &config.config_content).is_ok() {
1268 installed.push(config.tool_name.clone());
1269 if config.tool_name == "Claude Code" {
1276 let _ = crate::claude_md_integration::install_claude_md_guidance(
1277 project_dir, sqz_path,
1278 );
1279 let _ = crate::claude_md_integration::install_claude_mcp_config();
1280 }
1281 }
1282 }
1283
1284 if filter.includes("OpenCode") {
1295 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1296 if !installed.iter().any(|n| n == "OpenCode") {
1297 installed.push("OpenCode".to_string());
1298 }
1299 }
1300 }
1301
1302 installed
1303}
1304
1305pub fn claude_user_settings_path() -> Option<PathBuf> {
1318 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1319}
1320
1321fn install_claude_global(sqz_path: &str) -> Result<bool> {
1336 install_claude_global_at(sqz_path, None)
1337}
1338
1339fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1344 let path = match home_override {
1345 Some(h) => h.join(".claude").join("settings.json"),
1346 None => claude_user_settings_path().ok_or_else(|| {
1347 crate::error::SqzError::Other(
1348 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1349 )
1350 })?,
1351 };
1352 merge_sqz_hooks_into_settings(&path, sqz_path)
1353}
1354
1355pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1368 if !path.exists() {
1369 return Ok(true);
1370 }
1371 let content = std::fs::read_to_string(path).map_err(|e| {
1372 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1373 })?;
1374 let mut root: serde_json::Value = if content.trim().is_empty() {
1375 serde_json::Value::Object(serde_json::Map::new())
1376 } else {
1377 serde_json::from_str(&content).map_err(|e| {
1378 crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1379 })?
1380 };
1381 let Some(root_obj) = root.as_object_mut() else {
1382 return Err(crate::error::SqzError::Other(format!(
1383 "{} is not a JSON object",
1384 path.display()
1385 )));
1386 };
1387
1388 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1389 let hooks = root_obj
1390 .entry("hooks".to_string())
1391 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1392 let Some(hooks_obj) = hooks.as_object_mut() else {
1393 return Ok(true);
1394 };
1395 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1396 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1397 }
1398 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1399 Ok(before != after)
1400}
1401
1402fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
1406 [
1407 (
1408 "PreToolUse",
1409 serde_json::json!({
1410 "matcher": "Bash|PowerShell",
1411 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1412 }),
1413 "hook claude",
1414 ),
1415 (
1416 "PreCompact",
1417 serde_json::json!({
1418 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1419 }),
1420 "hook precompact",
1421 ),
1422 (
1423 "SessionStart",
1424 serde_json::json!({
1425 "matcher": "compact",
1426 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1427 }),
1428 "resume",
1429 ),
1430 ]
1431}
1432
1433fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1442 let mut root: serde_json::Value = if path.exists() {
1444 let content = std::fs::read_to_string(&path).map_err(|e| {
1445 crate::error::SqzError::Other(format!(
1446 "read {}: {e}",
1447 path.display()
1448 ))
1449 })?;
1450 if content.trim().is_empty() {
1451 serde_json::Value::Object(serde_json::Map::new())
1452 } else {
1453 serde_json::from_str(&content).map_err(|e| {
1454 crate::error::SqzError::Other(format!(
1455 "parse {}: {e} — please fix or move the file before re-running sqz init",
1456 path.display()
1457 ))
1458 })?
1459 }
1460 } else {
1461 serde_json::Value::Object(serde_json::Map::new())
1462 };
1463
1464 let root_obj = root.as_object_mut().ok_or_else(|| {
1467 crate::error::SqzError::Other(format!(
1468 "{} is not a JSON object — refusing to overwrite",
1469 path.display()
1470 ))
1471 })?;
1472
1473 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1475
1476 let hooks = root_obj
1478 .entry("hooks".to_string())
1479 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1480 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1481 crate::error::SqzError::Other(format!(
1482 "{}: `hooks` is not an object — refusing to overwrite",
1483 path.display()
1484 ))
1485 })?;
1486
1487 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1488 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1489 }
1490
1491 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1492 if before == after && path.exists() {
1493 return Ok(false);
1495 }
1496
1497 if let Some(parent) = path.parent() {
1499 std::fs::create_dir_all(parent).map_err(|e| {
1500 crate::error::SqzError::Other(format!(
1501 "create {}: {e}",
1502 parent.display()
1503 ))
1504 })?;
1505 }
1506
1507 let parent = path.parent().ok_or_else(|| {
1511 crate::error::SqzError::Other(format!(
1512 "path {} has no parent directory",
1513 path.display()
1514 ))
1515 })?;
1516 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1517 crate::error::SqzError::Other(format!(
1518 "create temp file in {}: {e}",
1519 parent.display()
1520 ))
1521 })?;
1522 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1523 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1524 std::fs::write(tmp.path(), serialized).map_err(|e| {
1525 crate::error::SqzError::Other(format!(
1526 "write to temp file {}: {e}",
1527 tmp.path().display()
1528 ))
1529 })?;
1530 tmp.persist(&path).map_err(|e| {
1531 crate::error::SqzError::Other(format!(
1532 "rename temp file into place at {}: {e}",
1533 path.display()
1534 ))
1535 })?;
1536
1537 Ok(true)
1538}
1539
1540pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1554 remove_claude_global_hook_at(None)
1555}
1556
1557fn remove_claude_global_hook_at(
1560 home_override: Option<&Path>,
1561) -> Result<Option<(PathBuf, bool)>> {
1562 let path = match home_override {
1563 Some(h) => h.join(".claude").join("settings.json"),
1564 None => match claude_user_settings_path() {
1565 Some(p) => p,
1566 None => return Ok(None),
1567 },
1568 };
1569 if !path.exists() {
1570 return Ok(None);
1571 }
1572
1573 let content = std::fs::read_to_string(&path).map_err(|e| {
1574 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1575 })?;
1576 if content.trim().is_empty() {
1577 return Ok(Some((path, false)));
1578 }
1579
1580 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1581 crate::error::SqzError::Other(format!(
1582 "parse {}: {e} — refusing to rewrite an unparseable file",
1583 path.display()
1584 ))
1585 })?;
1586 let Some(root_obj) = root.as_object_mut() else {
1587 return Ok(Some((path, false)));
1588 };
1589
1590 let mut changed = false;
1591 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1592 for (event, sentinel) in &[
1593 ("PreToolUse", "hook claude"),
1594 ("PreCompact", "hook precompact"),
1595 ("SessionStart", "resume"),
1596 ] {
1597 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1598 let before = arr.len();
1599 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1600 if arr.len() != before {
1601 changed = true;
1602 }
1603 }
1604 }
1605
1606 hooks.retain(|_, v| match v {
1609 serde_json::Value::Array(a) => !a.is_empty(),
1610 _ => true,
1611 });
1612
1613 let hooks_empty = hooks.is_empty();
1616 if hooks_empty {
1617 root_obj.remove("hooks");
1618 changed = true;
1619 }
1620 }
1621
1622 if !changed {
1623 return Ok(Some((path, false)));
1624 }
1625
1626 if root_obj.is_empty() {
1630 std::fs::remove_file(&path).map_err(|e| {
1631 crate::error::SqzError::Other(format!(
1632 "remove {}: {e}",
1633 path.display()
1634 ))
1635 })?;
1636 return Ok(Some((path, true)));
1637 }
1638
1639 let parent = path.parent().ok_or_else(|| {
1641 crate::error::SqzError::Other(format!(
1642 "path {} has no parent directory",
1643 path.display()
1644 ))
1645 })?;
1646 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1647 crate::error::SqzError::Other(format!(
1648 "create temp file in {}: {e}",
1649 parent.display()
1650 ))
1651 })?;
1652 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1653 .map_err(|e| {
1654 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1655 })?;
1656 std::fs::write(tmp.path(), serialized).map_err(|e| {
1657 crate::error::SqzError::Other(format!(
1658 "write to temp file {}: {e}",
1659 tmp.path().display()
1660 ))
1661 })?;
1662 tmp.persist(&path).map_err(|e| {
1663 crate::error::SqzError::Other(format!(
1664 "rename temp file into place at {}: {e}",
1665 path.display()
1666 ))
1667 })?;
1668
1669 Ok(Some((path, true)))
1670}
1671
1672fn upsert_sqz_hook_entry(
1679 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1680 event_name: &str,
1681 new_entry: serde_json::Value,
1682 sentinel: &str,
1683) {
1684 let arr = hooks_obj
1685 .entry(event_name.to_string())
1686 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1687 let Some(arr) = arr.as_array_mut() else {
1688 hooks_obj.insert(
1692 event_name.to_string(),
1693 serde_json::Value::Array(vec![new_entry]),
1694 );
1695 return;
1696 };
1697
1698 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1700
1701 arr.push(new_entry);
1702}
1703
1704fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1708 entry
1709 .get("hooks")
1710 .and_then(|h| h.as_array())
1711 .map(|hooks_arr| {
1712 hooks_arr.iter().any(|h| {
1713 h.get("command")
1714 .and_then(|c| c.as_str())
1715 .map(|c| c.contains(needle))
1716 .unwrap_or(false)
1717 })
1718 })
1719 .unwrap_or(false)
1720}
1721
1722fn extract_base_command(cmd: &str) -> &str {
1726 cmd.split_whitespace()
1727 .next()
1728 .unwrap_or("unknown")
1729 .rsplit('/')
1730 .next()
1731 .unwrap_or("unknown")
1732}
1733
1734pub(crate) fn json_escape_string_value(s: &str) -> String {
1745 let mut out = String::with_capacity(s.len() + 2);
1746 for ch in s.chars() {
1747 match ch {
1748 '\\' => out.push_str("\\\\"),
1749 '"' => out.push_str("\\\""),
1750 '\n' => out.push_str("\\n"),
1751 '\r' => out.push_str("\\r"),
1752 '\t' => out.push_str("\\t"),
1753 '\x08' => out.push_str("\\b"),
1754 '\x0c' => out.push_str("\\f"),
1755 c if (c as u32) < 0x20 => {
1756 out.push_str(&format!("\\u{:04x}", c as u32));
1758 }
1759 c => out.push(c),
1760 }
1761 }
1762 out
1763}
1764
1765fn shell_escape(s: &str) -> String {
1767 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1768 s.to_string()
1769 } else {
1770 format!("'{}'", s.replace('\'', "'\\''"))
1771 }
1772}
1773
1774pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1778 cmd.contains("&&")
1781 || cmd.contains("||")
1782 || cmd.contains(';')
1783 || cmd.contains('>')
1784 || cmd.contains('<')
1785 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1791
1792fn is_interactive_command(cmd: &str) -> bool {
1794 let base = extract_base_command(cmd);
1795 matches!(
1796 base,
1797 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1798 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1799 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1800 ) || cmd.contains("--watch")
1801 || cmd.contains("-w ")
1802 || cmd.ends_with(" -w")
1803 || cmd.contains("run dev")
1804 || cmd.contains("run start")
1805 || cmd.contains("run serve")
1806}
1807
1808#[cfg(test)]
1811mod tests {
1812 use super::*;
1813
1814 #[test]
1815 fn test_copilot_hook_rewrites_bash_with_modified_args() {
1816 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
1817 let result = process_hook_copilot(input).unwrap();
1818 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1819 assert_eq!(parsed["permissionDecision"], "allow");
1820 let args = &parsed["modifiedArgs"];
1821 assert!(args["command"].as_str().unwrap().contains("sqz compress"));
1822 assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
1823 }
1824
1825 #[test]
1826 fn test_copilot_hook_passes_non_shell_through() {
1827 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
1828 let result = process_hook_copilot(input).unwrap();
1829 assert_eq!(result, "{}", "non-shell tools fall through with empty object");
1830 }
1831
1832 #[test]
1833 fn test_copilot_hook_skips_risky_shell_operators() {
1834 let input = r#"{"toolName":"bash","toolArgs":{"command":"make && make install"}}"#;
1835 let result = process_hook_copilot(input).unwrap();
1836 assert_eq!(result, "{}", "compound commands must not be rewritten");
1837 }
1838
1839 #[test]
1840 fn test_claude_updated_input_preserves_sibling_fields() {
1841 let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test","run_in_background":true,"timeout":120000,"description":"Run tests"}}"#;
1844 let result = process_hook(input).unwrap();
1845 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1846 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
1847 assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
1848 assert_eq!(updated["run_in_background"], true);
1849 assert_eq!(updated["timeout"], 120000);
1850 assert_eq!(updated["description"], "Run tests");
1851 }
1852
1853 #[test]
1854 fn test_process_hook_rewrites_bash_command() {
1855 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1857 let result = process_hook(input).unwrap();
1858 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1859 let hook_output = &parsed["hookSpecificOutput"];
1861 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1862 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1863 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1865 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1866 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1867 assert!(
1870 cmd.contains("--cmd 'git status'"),
1871 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1872 );
1873 assert!(
1874 !cmd.contains("SQZ_CMD="),
1875 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1876 );
1877 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1879 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1880 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1881 }
1882
1883 #[test]
1884 fn test_process_hook_passes_through_non_bash() {
1885 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1886 let result = process_hook(input).unwrap();
1887 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1888 }
1889
1890 #[test]
1891 fn test_process_hook_rewrites_powershell_command() {
1892 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1893 let result = process_hook(input).unwrap();
1894 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1895 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1896 .as_str()
1897 .expect("PowerShell tool calls should be rewritten");
1898 assert!(
1899 cmd.contains("Get-Content big.log"),
1900 "should preserve original PowerShell command: {cmd}"
1901 );
1902 assert!(
1903 cmd.contains("sqz compress"),
1904 "should pipe through sqz compress: {cmd}"
1905 );
1906 assert!(
1907 cmd.contains("--cmd 'Get-Content big.log'"),
1908 "should pass full command via --cmd: {cmd}"
1909 );
1910 }
1911
1912 #[test]
1913 fn test_process_hook_rewrites_pwsh_command() {
1914 let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
1915 let result = process_hook(input).unwrap();
1916 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1917 assert!(
1918 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
1919 "pwsh tool calls should be rewritten"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_process_hook_skips_sqz_commands() {
1925 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1926 let result = process_hook(input).unwrap();
1927 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1928 }
1929
1930 #[test]
1931 fn test_process_hook_skips_interactive() {
1932 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1933 let result = process_hook(input).unwrap();
1934 assert_eq!(result, input, "interactive commands should pass through");
1935 }
1936
1937 #[test]
1938 fn test_process_hook_skips_watch_mode() {
1939 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1940 let result = process_hook(input).unwrap();
1941 assert_eq!(result, input, "watch mode should pass through");
1942 }
1943
1944 #[test]
1945 fn test_process_hook_empty_command() {
1946 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1947 let result = process_hook(input).unwrap();
1948 assert_eq!(result, input);
1949 }
1950
1951 #[test]
1952 fn test_process_hook_gemini_format() {
1953 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1955 let result = process_hook_gemini(input).unwrap();
1956 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1957 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1959 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1961 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1962 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1964 "Gemini format should not have updatedInput");
1965 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1966 "Gemini format should not have permissionDecision");
1967 }
1968
1969 #[test]
1970 fn test_process_hook_legacy_format() {
1971 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1973 let result = process_hook(input).unwrap();
1974 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1975 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1976 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1977 }
1978
1979 #[test]
1980 fn test_process_hook_cursor_format() {
1981 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1983 let result = process_hook_cursor(input).unwrap();
1984 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1985 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1987 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1988 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1989 assert!(cmd.contains("git status"));
1990 assert!(parsed.get("hookSpecificOutput").is_none(),
1992 "Cursor format should not have hookSpecificOutput");
1993 }
1994
1995 #[test]
1996 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1997 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1999 let result = process_hook_cursor(input).unwrap();
2000 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
2001 }
2002
2003 #[test]
2004 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
2005 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
2007 let result = process_hook_cursor(input).unwrap();
2008 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
2009 }
2010
2011 #[test]
2012 fn test_process_hook_windsurf_format() {
2013 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
2015 let result = process_hook_windsurf(input).unwrap();
2016 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2017 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2019 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
2020 assert!(cmd.contains("cargo test"));
2021 assert!(
2023 cmd.contains("--cmd 'cargo test'"),
2024 "full command must be passed via --cmd flag: {cmd}"
2025 );
2026 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
2027 }
2028
2029 #[test]
2030 fn test_process_hook_invalid_json() {
2031 let result = process_hook("not json");
2032 assert!(result.is_err());
2033 }
2034
2035 #[test]
2036 fn test_extract_base_command() {
2037 assert_eq!(extract_base_command("git status"), "git");
2038 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
2039 assert_eq!(extract_base_command("cargo test --release"), "cargo");
2040 }
2041
2042 #[test]
2043 fn test_is_interactive_command() {
2044 assert!(is_interactive_command("vim file.txt"));
2045 assert!(is_interactive_command("npm run dev --watch"));
2046 assert!(is_interactive_command("python3"));
2047 assert!(!is_interactive_command("git status"));
2048 assert!(!is_interactive_command("cargo test"));
2049 }
2050
2051 #[test]
2066 fn issue_10_rewrite_is_shell_neutral() {
2067 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
2068 let result = process_hook(input).unwrap();
2069 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2070 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2071 .as_str()
2072 .unwrap();
2073
2074 assert!(
2076 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
2077 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
2078 );
2079 assert!(
2081 !cmd.contains("SQZ_CMD="),
2082 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
2083 (broken in PowerShell and cmd.exe), got: {cmd}"
2084 );
2085 assert!(
2087 cmd.contains("dotnet build NewNeonCheckers3.sln"),
2088 "original command must be preserved verbatim: {cmd}"
2089 );
2090 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
2092 }
2093
2094 #[test]
2102 fn issue_10_already_wrapped_command_passes_through() {
2103 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
2104 let result = process_hook(input).unwrap();
2105 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2106 assert_eq!(
2109 result, input,
2110 "already-wrapped command must pass through unchanged; \
2111 otherwise each pass accumulates another `| sqz compress` tail"
2112 );
2113 let _ = parsed; }
2118
2119 #[test]
2120 fn test_generate_hook_configs() {
2121 let configs = generate_hook_configs("sqz");
2122 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
2123 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
2124 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
2125 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
2126 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
2129 assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
2130 "Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
2131 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
2132 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
2133 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
2134 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2138 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
2139 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
2140 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
2141 assert!(cursor.config_content.starts_with("---"),
2142 "Cursor rule should start with YAML frontmatter");
2143 assert!(cursor.config_content.contains("alwaysApply: true"),
2144 "Cursor rule should use alwaysApply: true so the guidance loads \
2145 for every agent interaction");
2146 assert!(cursor.config_content.contains("sqz"),
2147 "Cursor rule body should mention sqz");
2148 }
2149
2150 #[test]
2151 fn test_claude_config_includes_precompact_hook() {
2152 let configs = generate_hook_configs("sqz");
2157 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2158 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2159 .expect("Claude Code config must be valid JSON");
2160
2161 let precompact = parsed["hooks"]["PreCompact"]
2162 .as_array()
2163 .expect("PreCompact hook array must be present");
2164 assert!(
2165 !precompact.is_empty(),
2166 "PreCompact must have at least one registered hook"
2167 );
2168
2169 let cmd = precompact[0]["hooks"][0]["command"]
2170 .as_str()
2171 .expect("command field must be a string");
2172 assert!(
2173 cmd.ends_with(" hook precompact"),
2174 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
2175 );
2176 }
2177
2178 #[test]
2181 fn test_json_escape_string_value() {
2182 assert_eq!(json_escape_string_value("sqz"), "sqz");
2184 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
2185 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
2187 r"C:\\Users\\Alice\\sqz.exe");
2188 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
2190 r#"path with \"quotes\""#);
2191 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
2193 }
2194
2195 #[test]
2196 fn test_windows_path_produces_valid_json_for_claude() {
2197 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2200 let configs = generate_hook_configs(windows_path);
2201
2202 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
2203 .expect("Claude config should be generated");
2204 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2205 .expect("Claude hook config must be valid JSON on Windows paths");
2206
2207 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
2209 .as_str()
2210 .expect("command field must be a string");
2211 assert!(cmd.contains(windows_path),
2212 "command '{cmd}' must contain the original Windows path '{windows_path}'");
2213 }
2214
2215 #[test]
2216 fn test_windows_path_in_cursor_rules_file() {
2217 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2223 let configs = generate_hook_configs(windows_path);
2224
2225 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2226 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
2227 assert!(cursor.config_content.contains(windows_path),
2228 "Cursor rule must contain the raw (unescaped) path so users can \
2229 copy-paste the shown commands — got:\n{}", cursor.config_content);
2230 assert!(!cursor.config_content.contains(r"C:\\Users"),
2231 "Cursor rule must NOT double-escape backslashes in markdown — \
2232 got:\n{}", cursor.config_content);
2233 }
2234
2235 #[test]
2236 fn test_windows_path_produces_valid_json_for_gemini() {
2237 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2238 let configs = generate_hook_configs(windows_path);
2239
2240 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
2241 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
2242 .expect("Gemini hook config must be valid JSON on Windows paths");
2243 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
2244 assert!(cmd.contains(windows_path));
2245 }
2246
2247 #[test]
2248 fn test_rules_files_use_raw_path_for_readability() {
2249 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2253 let configs = generate_hook_configs(windows_path);
2254
2255 for tool in &["Windsurf", "Cline", "Cursor"] {
2256 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
2257 assert!(cfg.config_content.contains(windows_path),
2258 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
2259 cfg.config_content);
2260 assert!(!cfg.config_content.contains(r"C:\\Users"),
2261 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
2262 cfg.config_content);
2263 }
2264 }
2265
2266 #[test]
2267 fn test_unix_path_still_works() {
2268 let unix_path = "/usr/local/bin/sqz";
2271 let configs = generate_hook_configs(unix_path);
2272
2273 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2274 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2275 .expect("Unix path should produce valid JSON");
2276 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
2277 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
2278 }
2279
2280 #[test]
2281 fn test_shell_escape_simple() {
2282 assert_eq!(shell_escape("git"), "git");
2283 assert_eq!(shell_escape("cargo-test"), "cargo-test");
2284 }
2285
2286 #[test]
2287 fn test_shell_escape_special_chars() {
2288 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
2289 }
2290
2291 #[test]
2292 fn test_install_tool_hooks_creates_files() {
2293 let dir = tempfile::tempdir().unwrap();
2294 let installed = install_tool_hooks(dir.path(), "sqz");
2295 assert!(!installed.is_empty(), "should install at least one hook config");
2297 for name in &installed {
2299 let configs = generate_hook_configs("sqz");
2300 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
2301 let path = dir.path().join(&config.config_path);
2302 assert!(path.exists(), "hook config should exist: {}", path.display());
2303 }
2304 }
2305
2306 #[test]
2307 fn test_install_tool_hooks_does_not_overwrite() {
2308 let dir = tempfile::tempdir().unwrap();
2309 install_tool_hooks(dir.path(), "sqz");
2311 let custom_path = dir.path().join(".claude/settings.local.json");
2315 std::fs::write(&custom_path, "custom content").unwrap();
2316 install_tool_hooks(dir.path(), "sqz");
2318 let content = std::fs::read_to_string(&custom_path).unwrap();
2319 assert_eq!(content, "custom content", "should not overwrite existing config");
2320 }
2321
2322 #[test]
2329 fn test_reinit_upgrades_stale_project_scope_matcher() {
2330 let dir = tempfile::tempdir().unwrap();
2331 let settings_path = dir.path().join(".claude").join("settings.local.json");
2332 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
2333
2334 let stale = serde_json::json!({
2337 "permissions": { "allow": ["Bash(ls:*)"] },
2338 "hooks": {
2339 "PreToolUse": [
2340 {
2341 "matcher": "Bash",
2342 "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
2343 },
2344 {
2345 "matcher": "Edit",
2346 "hooks": [{ "type": "command", "command": "my-linter --check" }]
2347 }
2348 ]
2349 }
2350 });
2351 std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
2352
2353 install_tool_hooks(dir.path(), "/new/path/sqz");
2354
2355 let content = std::fs::read_to_string(&settings_path).unwrap();
2356 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2357
2358 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2360 let sqz_entries: Vec<_> = pre
2361 .iter()
2362 .filter(|e| {
2363 e["hooks"][0]["command"]
2364 .as_str()
2365 .is_some_and(|c| c.contains("hook claude"))
2366 })
2367 .collect();
2368 assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
2369 assert_eq!(
2370 sqz_entries[0]["matcher"].as_str().unwrap(),
2371 "Bash|PowerShell",
2372 "stale Bash-only matcher must be upgraded"
2373 );
2374 assert!(
2375 sqz_entries[0]["hooks"][0]["command"]
2376 .as_str()
2377 .unwrap()
2378 .contains("/new/path/sqz"),
2379 "binary path must be refreshed"
2380 );
2381
2382 assert!(
2384 pre.iter().any(|e| e["matcher"] == "Edit"),
2385 "user's non-sqz PreToolUse entry must be preserved"
2386 );
2387 assert_eq!(
2388 parsed["permissions"]["allow"][0].as_str().unwrap(),
2389 "Bash(ls:*)",
2390 "user's permissions must be preserved"
2391 );
2392
2393 assert!(parsed["hooks"]["PreCompact"].is_array());
2395 assert!(parsed["hooks"]["SessionStart"].is_array());
2396 }
2397
2398 #[test]
2399 fn test_windsurf_installs_to_rules_directory() {
2400 let dir = tempfile::tempdir().unwrap();
2401 install_tool_hooks(dir.path(), "sqz");
2402 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2403 assert!(!dir.path().join(".windsurfrules").exists());
2404 }
2405
2406 #[test]
2407 fn test_windsurf_migrates_legacy_sqz_rules_file() {
2408 let dir = tempfile::tempdir().unwrap();
2409 std::fs::write(
2410 dir.path().join(".windsurfrules"),
2411 "# sqz — Token-Optimized CLI Output\n\nold content\n",
2412 )
2413 .unwrap();
2414 install_tool_hooks(dir.path(), "sqz");
2415 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2416 assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
2417 }
2418
2419 #[test]
2420 fn test_windsurf_leaves_user_legacy_rules_alone() {
2421 let dir = tempfile::tempdir().unwrap();
2422 std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
2423 install_tool_hooks(dir.path(), "sqz");
2424 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2425 assert_eq!(
2426 std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
2427 "my own rules\n"
2428 );
2429 }
2430
2431 #[test]
2432 fn test_cline_installs_into_rules_directory_when_present() {
2433 let dir = tempfile::tempdir().unwrap();
2434 std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
2435 std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
2436 install_tool_hooks(dir.path(), "sqz");
2437 assert!(dir.path().join(".clinerules/sqz.md").exists());
2438 assert!(dir.path().join(".clinerules/mine.md").exists());
2439 }
2440
2441 #[test]
2442 fn test_cline_single_file_untouched_when_user_authored() {
2443 let dir = tempfile::tempdir().unwrap();
2444 std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
2445 install_tool_hooks(dir.path(), "sqz");
2446 assert_eq!(
2447 std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
2448 "user rules\n"
2449 );
2450 }
2451
2452 #[test]
2454 fn test_reinit_is_idempotent_at_project_scope() {
2455 let dir = tempfile::tempdir().unwrap();
2456 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2457 let settings_path = dir.path().join(".claude").join("settings.local.json");
2458 let first = std::fs::read_to_string(&settings_path).unwrap();
2459
2460 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2461 let second = std::fs::read_to_string(&settings_path).unwrap();
2462
2463 let a: serde_json::Value = serde_json::from_str(&first).unwrap();
2464 let b: serde_json::Value = serde_json::from_str(&second).unwrap();
2465 assert_eq!(a, b, "second init must not change project settings");
2466 assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
2468 }
2469}
2470
2471#[cfg(test)]
2472mod global_install_tests {
2473 use super::*;
2474
2475 #[test]
2476 fn global_install_creates_fresh_settings_json() {
2477 let tmp = tempfile::tempdir().unwrap();
2478 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2479 assert!(changed, "first install should report a change");
2480
2481 let path = tmp.path().join(".claude").join("settings.json");
2482 assert!(path.exists(), "user settings.json should be created");
2483
2484 let content = std::fs::read_to_string(&path).unwrap();
2485 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2486
2487 let pre = &parsed["hooks"]["PreToolUse"];
2489 assert!(pre.is_array(), "PreToolUse should be an array");
2490 assert_eq!(pre.as_array().unwrap().len(), 1);
2491 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2492 assert!(
2493 cmd.contains("/usr/local/bin/sqz"),
2494 "hook command should use the passed sqz_path, got: {cmd}"
2495 );
2496 assert!(cmd.contains("hook claude"));
2497
2498 let precompact = &parsed["hooks"]["PreCompact"];
2499 assert!(precompact.is_array());
2500 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
2501 assert!(precompact_cmd.contains("hook precompact"));
2502
2503 let session = &parsed["hooks"]["SessionStart"];
2504 assert!(session.is_array());
2505 assert_eq!(
2506 session[0]["matcher"].as_str().unwrap(),
2507 "compact",
2508 "SessionStart should only match /compact resume"
2509 );
2510 }
2511
2512 #[test]
2513 fn global_install_preserves_existing_user_config() {
2514 let tmp = tempfile::tempdir().unwrap();
2515 let settings = tmp.path().join(".claude").join("settings.json");
2516 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2517
2518 let existing = serde_json::json!({
2519 "permissions": {
2520 "allow": ["Bash(npm test *)"],
2521 "deny": ["Read(./.env)"]
2522 },
2523 "env": { "FOO": "bar" },
2524 "statusLine": {
2525 "type": "command",
2526 "command": "~/.claude/statusline.sh"
2527 },
2528 "hooks": {
2529 "PreToolUse": [
2530 {
2531 "matcher": "Edit",
2532 "hooks": [
2533 {
2534 "type": "command",
2535 "command": "~/.claude/hooks/format-on-edit.sh"
2536 }
2537 ]
2538 }
2539 ]
2540 }
2541 });
2542 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2543
2544 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2545 assert!(changed, "install should report a change on new hook");
2546
2547 let content = std::fs::read_to_string(&settings).unwrap();
2548 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2549
2550 assert_eq!(
2552 parsed["permissions"]["allow"][0].as_str().unwrap(),
2553 "Bash(npm test *)"
2554 );
2555 assert_eq!(
2556 parsed["permissions"]["deny"][0].as_str().unwrap(),
2557 "Read(./.env)"
2558 );
2559 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2561 assert_eq!(
2563 parsed["statusLine"]["command"].as_str().unwrap(),
2564 "~/.claude/statusline.sh"
2565 );
2566
2567 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2570 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2571 let matchers: Vec<&str> = pre
2572 .iter()
2573 .map(|e| e["matcher"].as_str().unwrap_or(""))
2574 .collect();
2575 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2576 assert!(
2577 matchers.contains(&"Bash|PowerShell"),
2578 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2579 );
2580 }
2581
2582 #[test]
2583 fn global_install_is_idempotent() {
2584 let tmp = tempfile::tempdir().unwrap();
2585 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2586 assert!(
2587 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2588 "second install with identical args should report no change"
2589 );
2590
2591 let path = tmp.path().join(".claude").join("settings.json");
2592 let parsed: serde_json::Value =
2593 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2594 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2595 let arr = parsed["hooks"][event].as_array().unwrap();
2596 assert_eq!(
2597 arr.len(),
2598 1,
2599 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2600 );
2601 }
2602 }
2603
2604 #[test]
2611 fn global_install_is_idempotent_with_exe_path() {
2612 let tmp = tempfile::tempdir().unwrap();
2613 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2614 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2615 assert!(
2616 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2617 "second install with .exe path should report no change (issue #21)"
2618 );
2619
2620 let path = tmp.path().join(".claude").join("settings.json");
2621 let parsed: serde_json::Value =
2622 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2623 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2624 let arr = parsed["hooks"][event].as_array().unwrap();
2625 assert_eq!(
2626 arr.len(),
2627 1,
2628 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2629 );
2630 }
2631 }
2632
2633 #[test]
2634 fn global_install_upgrades_stale_sqz_hook_in_place() {
2635 let tmp = tempfile::tempdir().unwrap();
2636 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2637 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2638 assert!(changed, "different sqz_path must be seen as a change");
2639
2640 let path = tmp.path().join(".claude").join("settings.json");
2641 let parsed: serde_json::Value =
2642 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2643 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2644 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2645 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2646 assert!(cmd.contains("/new/path/sqz"));
2647 assert!(!cmd.contains("/old/path/sqz"));
2648 }
2649
2650 #[test]
2651 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2652 let tmp = tempfile::tempdir().unwrap();
2653 let settings = tmp.path().join(".claude").join("settings.json");
2654 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2655 std::fs::write(
2656 &settings,
2657 serde_json::json!({
2658 "permissions": { "allow": ["Bash(git status)"] },
2659 "hooks": {
2660 "PreToolUse": [
2661 {
2662 "matcher": "Edit",
2663 "hooks": [
2664 { "type": "command", "command": "~/format.sh" }
2665 ]
2666 }
2667 ]
2668 }
2669 })
2670 .to_string(),
2671 )
2672 .unwrap();
2673
2674 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2675 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2676 assert_eq!(result.0, settings);
2677 assert!(result.1, "should report that the file was modified");
2678
2679 assert!(settings.exists(), "settings.json should be preserved");
2680 let parsed: serde_json::Value =
2681 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2682
2683 assert_eq!(
2684 parsed["permissions"]["allow"][0].as_str().unwrap(),
2685 "Bash(git status)"
2686 );
2687
2688 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2689 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2690 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2691
2692 assert!(parsed["hooks"].get("PreCompact").is_none());
2693 assert!(parsed["hooks"].get("SessionStart").is_none());
2694 }
2695
2696 #[test]
2697 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2698 let tmp = tempfile::tempdir().unwrap();
2699 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2700 let path = tmp.path().join(".claude").join("settings.json");
2701 assert!(path.exists(), "precondition: install created the file");
2702
2703 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2704 assert!(result.1);
2705 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2706 }
2707
2708 #[test]
2709 fn global_uninstall_on_missing_file_is_noop() {
2710 let tmp = tempfile::tempdir().unwrap();
2711 assert!(
2712 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2713 "missing file should return None, not error"
2714 );
2715 }
2716
2717 #[test]
2718 fn global_uninstall_refuses_to_touch_unparseable_file() {
2719 let tmp = tempfile::tempdir().unwrap();
2720 let settings = tmp.path().join(".claude").join("settings.json");
2721 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2722 std::fs::write(&settings, "{ invalid json because").unwrap();
2723
2724 assert!(
2725 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2726 "bad JSON must surface as an error"
2727 );
2728
2729 let after = std::fs::read_to_string(&settings).unwrap();
2730 assert_eq!(after, "{ invalid json because");
2731 }
2732}
2733
2734#[cfg(test)]
2735mod issue_11_tool_filter_tests {
2736 use super::*;
2745
2746 #[test]
2747 fn canonicalize_collapses_common_aliases() {
2748 for aliases in &[
2751 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2752 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2753 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2754 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2758 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2759 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2760 (vec!["Codex", "codex"], "codex"),
2761 ] {
2762 for alias in &aliases.0 {
2763 assert_eq!(
2764 canonicalize_tool_name(alias),
2765 aliases.1,
2766 "alias '{}' must canonicalise to '{}'",
2767 alias,
2768 aliases.1
2769 );
2770 }
2771 }
2772 }
2773
2774 #[test]
2775 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2776 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2781 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2782 }
2783
2784 #[test]
2785 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2786 let names = parse_tool_list("opencode,codex").unwrap();
2789 assert_eq!(names, vec!["opencode", "codex"]);
2790
2791 let names = parse_tool_list(" opencode , codex ").unwrap();
2792 assert_eq!(names, vec!["opencode", "codex"]);
2793
2794 let names = parse_tool_list("opencode").unwrap();
2796 assert_eq!(names, vec!["opencode"]);
2797
2798 let names = parse_tool_list("claude-code").unwrap();
2800 assert_eq!(names, vec!["claudecode"]);
2801 }
2802
2803 #[test]
2804 fn parse_tool_list_dedupes_repeated_entries() {
2805 let names = parse_tool_list("opencode,opencode").unwrap();
2810 assert_eq!(names, vec!["opencode"]);
2811
2812 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2815 assert_eq!(names, vec!["claudecode"]);
2816 }
2817
2818 #[test]
2819 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2820 let err = parse_tool_list("opncode").unwrap_err();
2827 let msg = err.to_string();
2828 assert!(
2829 msg.contains("unknown agent name 'opncode'"),
2830 "error must quote the bad input: {msg}"
2831 );
2832 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2833 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2834 }
2835
2836 #[test]
2837 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2838 let err = parse_tool_list("opencode,xyz").unwrap_err();
2843 assert!(err.to_string().contains("xyz"));
2844 }
2845
2846 #[test]
2847 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2848 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2854 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2855 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2856 }
2857
2858 #[test]
2859 fn tool_filter_all_includes_every_supported_tool() {
2860 let filter = ToolFilter::All;
2861 for tool in SUPPORTED_TOOL_NAMES {
2862 assert!(
2863 filter.includes(tool),
2864 "default filter must include {tool}"
2865 );
2866 }
2867 }
2868
2869 #[test]
2870 fn tool_filter_only_opencode_excludes_everything_else() {
2871 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2873 assert!(filter.includes("OpenCode"));
2874 for tool in SUPPORTED_TOOL_NAMES {
2876 if *tool == "OpenCode" {
2877 continue;
2878 }
2879 assert!(
2880 !filter.includes(tool),
2881 "--only opencode must not include {tool}"
2882 );
2883 }
2884 }
2885
2886 #[test]
2887 fn tool_filter_only_multi_tool_includes_exactly_those() {
2888 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2889 assert!(filter.includes("OpenCode"));
2890 assert!(filter.includes("Codex"));
2891 assert!(!filter.includes("Claude Code"));
2893 assert!(!filter.includes("Cursor"));
2894 assert!(!filter.includes("Windsurf"));
2895 assert!(!filter.includes("Cline"));
2896 assert!(!filter.includes("Gemini CLI"));
2897 }
2898
2899 #[test]
2900 fn tool_filter_skip_inverts_the_set() {
2901 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2904 assert!(!filter.includes("Cursor"));
2905 assert!(!filter.includes("Windsurf"));
2906 assert!(filter.includes("Claude Code"));
2908 assert!(filter.includes("Cline"));
2909 assert!(filter.includes("Gemini CLI"));
2910 assert!(filter.includes("OpenCode"));
2911 assert!(filter.includes("Codex"));
2912 }
2913
2914 #[test]
2915 fn tool_filter_only_empty_excludes_everything() {
2916 let filter = ToolFilter::Only(vec![]);
2922 for tool in SUPPORTED_TOOL_NAMES {
2923 assert!(
2924 !filter.includes(tool),
2925 "empty --only must exclude every tool, got {tool}"
2926 );
2927 }
2928 }
2929
2930 #[test]
2931 fn tool_filter_only_accepts_display_name_or_canonical() {
2932 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2938 assert!(filter.includes("Claude Code"));
2939 assert!(!filter.includes("Cursor"));
2940
2941 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2942 assert!(filter.includes("Gemini CLI"));
2943 }
2944
2945 #[test]
2946 fn supported_tool_names_matches_generate_hook_configs_exactly() {
2947 let configs = generate_hook_configs("sqz");
2953 let emitted: std::collections::HashSet<&str> =
2954 configs.iter().map(|c| c.tool_name.as_str()).collect();
2955 let declared: std::collections::HashSet<&str> =
2956 SUPPORTED_TOOL_NAMES.iter().copied().collect();
2957 assert_eq!(
2958 emitted, declared,
2959 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2960 from generate_hook_configs. emitted={:?}, declared={:?}",
2961 emitted, declared
2962 );
2963 }
2964
2965 #[test]
2966 fn filtered_install_only_opencode_writes_only_opencode_files() {
2967 let dir = tempfile::tempdir().unwrap();
2973 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2974 let _installed = install_tool_hooks_scoped_filtered(
2975 dir.path(),
2976 "sqz",
2977 InstallScope::Project,
2978 &filter,
2979 );
2980
2981 assert!(
2983 dir.path().join("opencode.json").exists(),
2984 "OpenCode config must be written when --only opencode is used"
2985 );
2986
2987 for (path, tool) in &[
2989 (".claude/settings.local.json", "Claude Code"),
2990 (".cursor/rules/sqz.mdc", "Cursor"),
2991 (".windsurfrules", "Windsurf"),
2992 (".clinerules", "Cline"),
2993 (".gemini/settings.json", "Gemini CLI"),
2994 ("AGENTS.md", "Codex"),
2995 ] {
2996 assert!(
2997 !dir.path().join(path).exists(),
2998 "filter rejected {tool} but the installer still wrote {path}"
2999 );
3000 }
3001 }
3002
3003 #[test]
3004 fn filtered_install_skip_cursor_omits_only_cursor() {
3005 let dir = tempfile::tempdir().unwrap();
3007 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
3008 let _installed = install_tool_hooks_scoped_filtered(
3009 dir.path(),
3010 "sqz",
3011 InstallScope::Project,
3012 &filter,
3013 );
3014
3015 assert!(
3017 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
3018 "skip cursor: .cursor/rules/sqz.mdc must not be written"
3019 );
3020 assert!(
3022 dir.path().join(".windsurf/rules/sqz.md").exists(),
3023 "skip cursor should not skip windsurf"
3024 );
3025 assert!(
3026 dir.path().join(".clinerules").exists(),
3027 "skip cursor should not skip cline"
3028 );
3029 }
3030}
3031