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 is_powershell = matches!(tool_name, "PowerShell" | "powershell" | "pwsh");
279 let rewritten = if is_powershell {
280 crate::exit_marker::powershell_rewrite(command, &shell_escape(command))
281 } else {
282 crate::exit_marker::posix_rewrite(command, &shell_escape(command))
283 };
284
285 let mut updated_input = parsed
292 .get("tool_input")
293 .or_else(|| parsed.get("toolArgs"))
294 .and_then(|v| v.as_object())
295 .cloned()
296 .unwrap_or_default();
297 updated_input.insert(
298 "command".to_string(),
299 serde_json::Value::String(rewritten.clone()),
300 );
301 let updated_input = serde_json::Value::Object(updated_input);
302
303 let output = match platform {
329 HookPlatform::ClaudeCode => serde_json::json!({
330 "hookSpecificOutput": {
331 "hookEventName": "PreToolUse",
332 "permissionDecision": "allow",
333 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
334 "updatedInput": updated_input
335 }
336 }),
337 HookPlatform::Cursor => serde_json::json!({
338 "permission": "allow",
339 "updated_input": {
340 "command": rewritten
341 }
342 }),
343 HookPlatform::GeminiCli => serde_json::json!({
344 "decision": "allow",
345 "hookSpecificOutput": {
346 "tool_input": {
347 "command": rewritten
348 }
349 }
350 }),
351 HookPlatform::Windsurf => {
352 serde_json::json!({
356 "hookSpecificOutput": {
357 "hookEventName": "PreToolUse",
358 "permissionDecision": "allow",
359 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
360 "updatedInput": updated_input
361 }
362 })
363 }
364 HookPlatform::Copilot => serde_json::json!({
365 "permissionDecision": "allow",
366 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
367 "modifiedArgs": updated_input
368 }),
369 HookPlatform::Kiro => {
370 serde_json::json!({
374 "tool_input": {
375 "command": rewritten
376 }
377 })
378 }
379 };
380
381 serde_json::to_string(&output)
382 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
383}
384
385pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
387 let sqz_path_raw = sqz_path;
398 let sqz_path_json = json_escape_string_value(sqz_path);
399 let sqz_path = &sqz_path_json;
400
401 let escape_hatch = format!(
409 r#"## Escape hatch — when sqz output confuses the agent
410
411If you see a `§ref:HASH§` token in tool output and can't parse it, or if
412compressed output is making it harder to work (loops, repeated tiny
413requests), or if a `«A1»` abbreviation symbol has replaced a value you
414need verbatim (a SHA, path, or URL), you have these ways out:
415
416- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
417 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
418 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
419- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
420 status 2>&1 | sqz compress`) to disable dedup for that one command.
421 Strictly more tokens, strictly less ambiguous.
422- **`--no-cache`** — the same opt-out as a CLI flag:
423 `git status 2>&1 | sqz compress --no-cache`.
424- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
425 abbreviation, which replaces repeated phrases with `«A1»` symbols.
426 Use this for output full of SHAs/paths/URLs you'll copy-paste, since
427 abbreviation keeps only the first occurrence of a repeated phrase:
428 `SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
429
430Use these when you genuinely need raw data. The default compressed
431path is still what you want for most output."#,
432 );
433 let escape_hatch = &escape_hatch;
434
435 vec![
436 ToolHookConfig {
451 tool_name: "Claude Code".to_string(),
452 config_path: PathBuf::from(".claude/settings.local.json"),
453 config_content: format!(
454 r#"{{
455 "hooks": {{
456 "PreToolUse": [
457 {{
458 "matcher": "Bash|PowerShell",
459 "hooks": [
460 {{
461 "type": "command",
462 "command": "{sqz_path} hook claude"
463 }}
464 ]
465 }}
466 ],
467 "PreCompact": [
468 {{
469 "hooks": [
470 {{
471 "type": "command",
472 "command": "{sqz_path} hook precompact"
473 }}
474 ]
475 }}
476 ],
477 "SessionStart": [
478 {{
479 "matcher": "compact",
480 "hooks": [
481 {{
482 "type": "command",
483 "command": "{sqz_path} resume"
484 }}
485 ]
486 }}
487 ]
488 }}
489}}"#
490 ),
491 scope: HookScope::Project,
492 },
493 ToolHookConfig {
502 tool_name: "Cursor".to_string(),
503 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
504 config_content: format!(
505 r#"---
506description: sqz — Token-Optimized CLI Output
507alwaysApply: true
508---
509
510# sqz — Token-Optimized CLI Output
511
512When running shell commands whose output may be long (directory listings,
513git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
514pipe the output through `sqz compress` to reduce token consumption.
515
516`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
517usage is to pipe the command's output into `sqz compress`:
518
519```bash
520# Instead of: Use:
521git status git status 2>&1 | {sqz_path_raw} compress
522cargo test cargo test 2>&1 | {sqz_path_raw} compress
523git log -10 git log -10 2>&1 | {sqz_path_raw} compress
524docker ps docker ps 2>&1 | {sqz_path_raw} compress
525ls -la ls -la 2>&1 | {sqz_path_raw} compress
526```
527
528The `2>&1` captures stderr too, which is useful for commands like `cargo
529test` where diagnostics go to stderr. `sqz compress` filters and compresses
530the combined output while preserving filenames, paths, and identifiers.
531It typically saves 60-90% tokens on verbose commands.
532
533Do NOT pipe output for:
534- Interactive commands (`vim`, `ssh`, `python`, REPLs)
535- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
536 `cmd; other`) — run those directly
537- Short commands whose output is already a few lines
538
539If `sqz` is not on PATH, run commands normally.
540
541{escape_hatch}
542"#
543 ),
544 scope: HookScope::Project,
545 },
546 ToolHookConfig {
549 tool_name: "Windsurf".to_string(),
550 config_path: PathBuf::from(".windsurf/rules/sqz.md"),
551 config_content: format!(
552 r#"# sqz — Token-Optimized CLI Output
553
554Pipe verbose shell command output through `sqz compress` to save tokens.
555`sqz` reads from stdin and writes the compressed output to stdout — it is
556NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
557
558```bash
559# Instead of: Use:
560git status git status 2>&1 | {sqz_path_raw} compress
561cargo test cargo test 2>&1 | {sqz_path_raw} compress
562git log -10 git log -10 2>&1 | {sqz_path_raw} compress
563docker ps docker ps 2>&1 | {sqz_path_raw} compress
564```
565
566sqz filters and compresses command outputs while preserving filenames,
567paths, and identifiers (typically 60-90% token reduction on verbose
568commands). Skip short commands, interactive commands (vim, ssh, python),
569and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
570not on PATH, run commands normally.
571
572{escape_hatch}
573"#
574 ),
575 scope: HookScope::Project,
576 },
577 ToolHookConfig {
580 tool_name: "Cline".to_string(),
581 config_path: PathBuf::from(".clinerules"),
582 config_content: format!(
583 r#"# sqz — Token-Optimized CLI Output
584
585Pipe verbose shell command output through `sqz compress` to save tokens.
586`sqz` reads from stdin and writes the compressed output to stdout — it is
587NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
588
589```bash
590# Instead of: Use:
591git status git status 2>&1 | {sqz_path_raw} compress
592cargo test cargo test 2>&1 | {sqz_path_raw} compress
593git log -10 git log -10 2>&1 | {sqz_path_raw} compress
594docker ps docker ps 2>&1 | {sqz_path_raw} compress
595```
596
597sqz filters and compresses command outputs while preserving filenames,
598paths, and identifiers (typically 60-90% token reduction on verbose
599commands). Skip short commands, interactive commands (vim, ssh, python),
600and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
601not on PATH, run commands normally.
602
603{escape_hatch}
604"#
605 ),
606 scope: HookScope::Project,
607 },
608 ToolHookConfig {
610 tool_name: "Gemini CLI".to_string(),
611 config_path: PathBuf::from(".gemini/settings.json"),
612 config_content: format!(
613 r#"{{
614 "hooks": {{
615 "BeforeTool": [
616 {{
617 "matcher": "run_shell_command",
618 "hooks": [
619 {{
620 "type": "command",
621 "command": "{sqz_path} hook gemini"
622 }}
623 ]
624 }}
625 ]
626 }}
627}}"#
628 ),
629 scope: HookScope::Project,
630 },
631 ToolHookConfig {
636 tool_name: "Kiro".to_string(),
637 config_path: PathBuf::from(".kiro/steering/sqz.md"),
638 config_content: crate::kiro_integration::kiro_steering_content(sqz_path_raw),
639 scope: HookScope::Project,
640 },
641 ToolHookConfig {
650 tool_name: "OpenCode".to_string(),
651 config_path: PathBuf::from("opencode.json"),
652 config_content: format!(
653 r#"{{
654 "$schema": "https://opencode.ai/config.json",
655 "mcp": {{
656 "sqz": {{
657 "type": "local",
658 "command": ["sqz-mcp", "--transport", "stdio"]
659 }}
660 }},
661 "plugin": ["sqz"]
662}}"#
663 ),
664 scope: HookScope::Project,
665 },
666 ToolHookConfig {
685 tool_name: "Codex".to_string(),
686 config_path: PathBuf::from("AGENTS.md"),
687 config_content: crate::codex_integration::agents_md_guidance_block(
688 sqz_path_raw,
689 ),
690 scope: HookScope::Project,
691 },
692 ToolHookConfig {
707 tool_name: "Zed".to_string(),
708 config_path: PathBuf::from("AGENTS.md"),
709 config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
710 scope: HookScope::Project,
711 },
712 ToolHookConfig {
717 tool_name: "Copilot CLI".to_string(),
718 config_path: PathBuf::from(".copilot/hooks/sqz.json"),
719 config_content: copilot_hook_content(sqz_path_raw),
720 scope: HookScope::User,
721 },
722 ]
723}
724
725pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
731 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758pub enum InstallScope {
759 Project,
762 Global,
765}
766
767#[derive(Debug, Clone, PartialEq, Eq)]
780pub enum ToolFilter {
781 All,
784 Only(Vec<String>),
788 Skip(Vec<String>),
793}
794
795impl Default for ToolFilter {
796 fn default() -> Self {
797 ToolFilter::All
798 }
799}
800
801impl ToolFilter {
802 pub fn includes(&self, tool_name: &str) -> bool {
813 let canon = canonicalize_tool_name(tool_name);
814 match self {
815 ToolFilter::All => true,
816 ToolFilter::Only(allow) => allow.iter().any(|n| {
817 n == &canon
820 }),
821 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
822 }
823 }
824}
825
826pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
831 "Claude Code",
832 "Cursor",
833 "Windsurf",
834 "Cline",
835 "Gemini CLI",
836 "Kiro",
837 "OpenCode",
838 "Codex",
839 "Zed",
840 "Copilot CLI",
841];
842
843pub fn copilot_hooks_path() -> PathBuf {
846 copilot_home().join("hooks").join("sqz.json")
847}
848
849fn copilot_home() -> PathBuf {
850 if let Ok(home) = std::env::var("COPILOT_HOME") {
851 if !home.trim().is_empty() {
852 return PathBuf::from(home);
853 }
854 }
855 let home = std::env::var("HOME")
856 .or_else(|_| std::env::var("USERPROFILE"))
857 .map(PathBuf::from)
858 .unwrap_or_else(|_| PathBuf::from("."));
859 home.join(".copilot")
860}
861
862pub fn ci_hooks_path(project_dir: &Path) -> PathBuf {
866 project_dir.join(".github").join("hooks").join("sqz.json")
867}
868
869pub fn ci_hook_content() -> String {
878 r#"{
879 "version": 1,
880 "hooks": {
881 "sessionStart": [
882 {
883 "type": "command",
884 "bash": "command -v sqz >/dev/null 2>&1 || curl -fsSL https://raw.githubusercontent.com/ojuschugh1/sqz/main/install.sh | sh >/dev/null 2>&1 || true",
885 "timeoutSec": 120
886 }
887 ],
888 "preToolUse": [
889 {
890 "type": "command",
891 "matcher": "bash|powershell",
892 "bash": "SQZ_BIN=$(command -v sqz || echo $HOME/.local/bin/sqz); [ -x \"$SQZ_BIN\" ] && \"$SQZ_BIN\" hook copilot 2>/dev/null || echo '{}'",
893 "timeoutSec": 10
894 }
895 ]
896 }
897}
898"#
899 .to_string()
900}
901
902pub fn install_ci_hook(project_dir: &Path) -> Result<bool> {
905 let path = ci_hooks_path(project_dir);
906 let content = ci_hook_content();
907 if path.exists() {
908 let existing = std::fs::read_to_string(&path).unwrap_or_default();
909 if existing == content || !existing.contains("hook copilot") {
910 return Ok(false);
911 }
912 }
913 if let Some(parent) = path.parent() {
914 std::fs::create_dir_all(parent).map_err(|e| {
915 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
916 })?;
917 }
918 std::fs::write(&path, content).map_err(|e| {
919 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
920 })?;
921 Ok(true)
922}
923
924pub fn remove_ci_hook(project_dir: &Path) -> Result<bool> {
926 let path = ci_hooks_path(project_dir);
927 if !path.exists() {
928 return Ok(false);
929 }
930 let content = std::fs::read_to_string(&path).unwrap_or_default();
931 if !content.contains("hook copilot") {
932 return Ok(false);
933 }
934 std::fs::remove_file(&path).map_err(|e| {
935 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
936 })?;
937 Ok(true)
938}
939
940pub fn copilot_hook_content(sqz_path: &str) -> String {
942 let escaped = json_escape_string_value(sqz_path);
943 format!(
944 r#"{{
945 "version": 1,
946 "hooks": {{
947 "preToolUse": [
948 {{
949 "type": "command",
950 "matcher": "bash|powershell",
951 "bash": "{escaped} hook copilot",
952 "powershell": "{escaped} hook copilot",
953 "timeoutSec": 10
954 }}
955 ]
956 }}
957}}
958"#
959 )
960}
961
962pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
966 let home = copilot_home();
967 if !home.exists() {
968 return Ok(None);
969 }
970 let path = copilot_hooks_path();
971 let content = copilot_hook_content(sqz_path);
972 if path.exists() {
973 let existing = std::fs::read_to_string(&path).unwrap_or_default();
974 if existing == content {
975 return Ok(Some(false));
976 }
977 }
978 if let Some(parent) = path.parent() {
979 std::fs::create_dir_all(parent).map_err(|e| {
980 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
981 })?;
982 }
983 std::fs::write(&path, content).map_err(|e| {
984 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
985 })?;
986 Ok(Some(true))
987}
988
989pub fn remove_copilot_hook() -> Result<bool> {
991 let path = copilot_hooks_path();
992 if !path.exists() {
993 return Ok(false);
994 }
995 std::fs::remove_file(&path).map_err(|e| {
996 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
997 })?;
998 Ok(true)
999}
1000
1001pub fn canonicalize_tool_name(name: &str) -> String {
1020 let lowered: String = name
1021 .chars()
1022 .filter(|c| !c.is_whitespace())
1023 .flat_map(|c| c.to_lowercase())
1024 .filter(|c| *c != '-' && *c != '_')
1025 .collect();
1026 match lowered.as_str() {
1027 "claude" | "claudecode" => "claudecode".to_string(),
1028 "cursor" => "cursor".to_string(),
1029 "windsurf" => "windsurf".to_string(),
1030 "cline" | "roo" | "roocode" => "cline".to_string(),
1034 "gemini" | "geminicli" => "gemini".to_string(),
1035 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
1036 "opencode" => "opencode".to_string(),
1037 "codex" => "codex".to_string(),
1038 "zed" | "zededitor" | "zedagent" => "zed".to_string(),
1039 "copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
1040 other => other.to_string(),
1041 }
1042}
1043
1044pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
1054 let mut out = Vec::new();
1055 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
1056 .iter()
1057 .map(|n| canonicalize_tool_name(n))
1058 .collect();
1059 for part in raw.split(',') {
1060 let trimmed = part.trim();
1061 if trimmed.is_empty() {
1062 continue;
1063 }
1064 let canon = canonicalize_tool_name(trimmed);
1065 if !known.contains(&canon) {
1066 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
1067 .iter()
1068 .map(|n| canonicalize_tool_name(n))
1069 .collect();
1070 return Err(crate::error::SqzError::Other(format!(
1071 "unknown agent name '{}'. Valid options: {}",
1072 trimmed,
1073 valid.join(", ")
1074 )));
1075 }
1076 if !out.contains(&canon) {
1077 out.push(canon);
1078 }
1079 }
1080 Ok(out)
1081}
1082
1083pub fn install_tool_hooks_scoped(
1109 project_dir: &Path,
1110 sqz_path: &str,
1111 scope: InstallScope,
1112) -> Vec<String> {
1113 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
1114}
1115
1116pub fn install_tool_hooks_scoped_filtered(
1130 project_dir: &Path,
1131 sqz_path: &str,
1132 scope: InstallScope,
1133 filter: &ToolFilter,
1134) -> Vec<String> {
1135 let configs = generate_hook_configs(sqz_path);
1136 let mut installed = Vec::new();
1137
1138 for config in &configs {
1139 if !filter.includes(&config.tool_name) {
1143 continue;
1144 }
1145
1146 if config.tool_name == "OpenCode" {
1155 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
1156 Ok((updated, _comments_lost)) => {
1157 if updated && !installed.iter().any(|n| n == "OpenCode") {
1158 installed.push("OpenCode".to_string());
1159 }
1160 }
1161 Err(_e) => {
1162 }
1165 }
1166 continue;
1167 }
1168
1169 if config.tool_name == "Codex" {
1174 let agents_changed = crate::codex_integration::install_agents_md_guidance(
1175 project_dir, sqz_path,
1176 )
1177 .unwrap_or(false);
1178 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
1179 .unwrap_or(false);
1180 if (agents_changed || mcp_changed)
1181 && !installed.iter().any(|n| n == "Codex")
1182 {
1183 installed.push("Codex".to_string());
1184 }
1185 continue;
1186 }
1187
1188 if config.tool_name == "Windsurf" {
1191 let legacy = project_dir.join(".windsurfrules");
1192 let target = project_dir.join(&config.config_path);
1193 let legacy_is_sqz = legacy.exists()
1194 && std::fs::read_to_string(&legacy)
1195 .map(|s| s.starts_with("# sqz — Token-Optimized CLI Output"))
1196 .unwrap_or(false);
1197 let mut changed = false;
1198 if !target.exists() {
1199 if let Some(parent) = target.parent() {
1200 let _ = std::fs::create_dir_all(parent);
1201 }
1202 if std::fs::write(&target, &config.config_content).is_ok() {
1203 changed = true;
1204 }
1205 }
1206 if legacy_is_sqz && target.exists() {
1207 let _ = std::fs::remove_file(&legacy);
1208 changed = true;
1209 }
1210 if changed && !installed.iter().any(|n| n == "Windsurf") {
1211 installed.push("Windsurf".to_string());
1212 }
1213 continue;
1214 }
1215
1216 if config.tool_name == "Cline" {
1219 let base = project_dir.join(".clinerules");
1220 let target = if base.is_dir() { base.join("sqz.md") } else { base };
1221 if !target.exists() {
1222 if std::fs::write(&target, &config.config_content).is_ok()
1223 && !installed.iter().any(|n| n == "Cline")
1224 {
1225 installed.push("Cline".to_string());
1226 }
1227 }
1228 continue;
1229 }
1230
1231 if config.tool_name == "Copilot CLI" {
1233 if let Ok(Some(changed)) = install_copilot_hook(sqz_path) {
1234 if changed && !installed.iter().any(|n| n == "Copilot CLI") {
1235 installed.push("Copilot CLI".to_string());
1236 }
1237 }
1238 continue;
1239 }
1240
1241 if config.tool_name == "Kiro" {
1244 let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
1245 let steering_changed =
1246 crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
1247 .unwrap_or(false);
1248 let mcp_changed = matches!(
1249 crate::kiro_integration::install_kiro_mcp_config(project_dir),
1250 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1251 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1252 );
1253 if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
1254 installed.push("Kiro".to_string());
1255 }
1256 continue;
1257 }
1258
1259 if config.tool_name == "Zed" {
1264 let guidance_changed =
1265 crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
1266 .unwrap_or(false);
1267 let mcp_changed = matches!(
1268 crate::zed_integration::install_zed_mcp_config(),
1269 Ok(crate::zed_integration::ZedMcpInstall::Created)
1270 | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1271 );
1272 if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1273 installed.push("Zed".to_string());
1274 }
1275 continue;
1276 }
1277
1278 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1288 let hook_installed = match install_claude_global(sqz_path) {
1289 Ok(v) => v,
1290 Err(_) => false,
1291 };
1292 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1293 project_dir, sqz_path,
1294 )
1295 .unwrap_or(false);
1296 let mcp_changed =
1297 crate::claude_md_integration::install_claude_mcp_config()
1298 .unwrap_or(false);
1299 if (hook_installed || md_changed || mcp_changed)
1300 && !installed.iter().any(|n| n == "Claude Code")
1301 {
1302 installed.push("Claude Code".to_string());
1303 }
1304 continue;
1305 }
1306
1307 let full_path = project_dir.join(&config.config_path);
1308
1309 if full_path.exists() {
1311 if config.tool_name == "Claude Code" {
1320 let hook_changed =
1321 merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1322 let md_changed =
1323 crate::claude_md_integration::install_claude_md_guidance(
1324 project_dir, sqz_path,
1325 )
1326 .unwrap_or(false);
1327 let mcp_changed =
1328 crate::claude_md_integration::install_claude_mcp_config()
1329 .unwrap_or(false);
1330 if (hook_changed || md_changed || mcp_changed)
1331 && !installed.iter().any(|n| n == "Claude Code")
1332 {
1333 installed.push("Claude Code".to_string());
1334 }
1335 }
1336 continue;
1337 }
1338
1339 if let Some(parent) = full_path.parent() {
1341 if std::fs::create_dir_all(parent).is_err() {
1342 continue;
1343 }
1344 }
1345
1346 if std::fs::write(&full_path, &config.config_content).is_ok() {
1347 installed.push(config.tool_name.clone());
1348 if config.tool_name == "Claude Code" {
1355 let _ = crate::claude_md_integration::install_claude_md_guidance(
1356 project_dir, sqz_path,
1357 );
1358 let _ = crate::claude_md_integration::install_claude_mcp_config();
1359 }
1360 }
1361 }
1362
1363 if filter.includes("OpenCode") {
1374 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1375 if !installed.iter().any(|n| n == "OpenCode") {
1376 installed.push("OpenCode".to_string());
1377 }
1378 }
1379 }
1380
1381 installed
1382}
1383
1384pub fn claude_user_settings_path() -> Option<PathBuf> {
1397 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1398}
1399
1400fn install_claude_global(sqz_path: &str) -> Result<bool> {
1415 install_claude_global_at(sqz_path, None)
1416}
1417
1418fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1423 let path = match home_override {
1424 Some(h) => h.join(".claude").join("settings.json"),
1425 None => claude_user_settings_path().ok_or_else(|| {
1426 crate::error::SqzError::Other(
1427 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1428 )
1429 })?,
1430 };
1431 merge_sqz_hooks_into_settings(&path, sqz_path)
1432}
1433
1434pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1447 if !path.exists() {
1448 return Ok(true);
1449 }
1450 let content = std::fs::read_to_string(path).map_err(|e| {
1451 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1452 })?;
1453 let mut root: serde_json::Value = if content.trim().is_empty() {
1454 serde_json::Value::Object(serde_json::Map::new())
1455 } else {
1456 serde_json::from_str(&content).map_err(|e| {
1457 crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1458 })?
1459 };
1460 let Some(root_obj) = root.as_object_mut() else {
1461 return Err(crate::error::SqzError::Other(format!(
1462 "{} is not a JSON object",
1463 path.display()
1464 )));
1465 };
1466
1467 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1468 let hooks = root_obj
1469 .entry("hooks".to_string())
1470 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1471 let Some(hooks_obj) = hooks.as_object_mut() else {
1472 return Ok(true);
1473 };
1474 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1475 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1476 }
1477 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1478 Ok(before != after)
1479}
1480
1481fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
1485 [
1486 (
1487 "PreToolUse",
1488 serde_json::json!({
1489 "matcher": "Bash|PowerShell",
1490 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1491 }),
1492 "hook claude",
1493 ),
1494 (
1495 "PreCompact",
1496 serde_json::json!({
1497 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1498 }),
1499 "hook precompact",
1500 ),
1501 (
1502 "SessionStart",
1503 serde_json::json!({
1504 "matcher": "compact",
1505 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1506 }),
1507 "resume",
1508 ),
1509 ]
1510}
1511
1512fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1521 let mut root: serde_json::Value = if path.exists() {
1523 let content = std::fs::read_to_string(&path).map_err(|e| {
1524 crate::error::SqzError::Other(format!(
1525 "read {}: {e}",
1526 path.display()
1527 ))
1528 })?;
1529 if content.trim().is_empty() {
1530 serde_json::Value::Object(serde_json::Map::new())
1531 } else {
1532 serde_json::from_str(&content).map_err(|e| {
1533 crate::error::SqzError::Other(format!(
1534 "parse {}: {e} — please fix or move the file before re-running sqz init",
1535 path.display()
1536 ))
1537 })?
1538 }
1539 } else {
1540 serde_json::Value::Object(serde_json::Map::new())
1541 };
1542
1543 let root_obj = root.as_object_mut().ok_or_else(|| {
1546 crate::error::SqzError::Other(format!(
1547 "{} is not a JSON object — refusing to overwrite",
1548 path.display()
1549 ))
1550 })?;
1551
1552 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1554
1555 let hooks = root_obj
1557 .entry("hooks".to_string())
1558 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1559 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1560 crate::error::SqzError::Other(format!(
1561 "{}: `hooks` is not an object — refusing to overwrite",
1562 path.display()
1563 ))
1564 })?;
1565
1566 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1567 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1568 }
1569
1570 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1571 if before == after && path.exists() {
1572 return Ok(false);
1574 }
1575
1576 if let Some(parent) = path.parent() {
1578 std::fs::create_dir_all(parent).map_err(|e| {
1579 crate::error::SqzError::Other(format!(
1580 "create {}: {e}",
1581 parent.display()
1582 ))
1583 })?;
1584 }
1585
1586 let parent = path.parent().ok_or_else(|| {
1590 crate::error::SqzError::Other(format!(
1591 "path {} has no parent directory",
1592 path.display()
1593 ))
1594 })?;
1595 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1596 crate::error::SqzError::Other(format!(
1597 "create temp file in {}: {e}",
1598 parent.display()
1599 ))
1600 })?;
1601 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1602 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1603 std::fs::write(tmp.path(), serialized).map_err(|e| {
1604 crate::error::SqzError::Other(format!(
1605 "write to temp file {}: {e}",
1606 tmp.path().display()
1607 ))
1608 })?;
1609 tmp.persist(&path).map_err(|e| {
1610 crate::error::SqzError::Other(format!(
1611 "rename temp file into place at {}: {e}",
1612 path.display()
1613 ))
1614 })?;
1615
1616 Ok(true)
1617}
1618
1619pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1633 remove_claude_global_hook_at(None)
1634}
1635
1636fn remove_claude_global_hook_at(
1639 home_override: Option<&Path>,
1640) -> Result<Option<(PathBuf, bool)>> {
1641 let path = match home_override {
1642 Some(h) => h.join(".claude").join("settings.json"),
1643 None => match claude_user_settings_path() {
1644 Some(p) => p,
1645 None => return Ok(None),
1646 },
1647 };
1648 if !path.exists() {
1649 return Ok(None);
1650 }
1651
1652 let content = std::fs::read_to_string(&path).map_err(|e| {
1653 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1654 })?;
1655 if content.trim().is_empty() {
1656 return Ok(Some((path, false)));
1657 }
1658
1659 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1660 crate::error::SqzError::Other(format!(
1661 "parse {}: {e} — refusing to rewrite an unparseable file",
1662 path.display()
1663 ))
1664 })?;
1665 let Some(root_obj) = root.as_object_mut() else {
1666 return Ok(Some((path, false)));
1667 };
1668
1669 let mut changed = false;
1670 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1671 for (event, sentinel) in &[
1672 ("PreToolUse", "hook claude"),
1673 ("PreCompact", "hook precompact"),
1674 ("SessionStart", "resume"),
1675 ] {
1676 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1677 let before = arr.len();
1678 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1679 if arr.len() != before {
1680 changed = true;
1681 }
1682 }
1683 }
1684
1685 hooks.retain(|_, v| match v {
1688 serde_json::Value::Array(a) => !a.is_empty(),
1689 _ => true,
1690 });
1691
1692 let hooks_empty = hooks.is_empty();
1695 if hooks_empty {
1696 root_obj.remove("hooks");
1697 changed = true;
1698 }
1699 }
1700
1701 if !changed {
1702 return Ok(Some((path, false)));
1703 }
1704
1705 if root_obj.is_empty() {
1709 std::fs::remove_file(&path).map_err(|e| {
1710 crate::error::SqzError::Other(format!(
1711 "remove {}: {e}",
1712 path.display()
1713 ))
1714 })?;
1715 return Ok(Some((path, true)));
1716 }
1717
1718 let parent = path.parent().ok_or_else(|| {
1720 crate::error::SqzError::Other(format!(
1721 "path {} has no parent directory",
1722 path.display()
1723 ))
1724 })?;
1725 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1726 crate::error::SqzError::Other(format!(
1727 "create temp file in {}: {e}",
1728 parent.display()
1729 ))
1730 })?;
1731 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1732 .map_err(|e| {
1733 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1734 })?;
1735 std::fs::write(tmp.path(), serialized).map_err(|e| {
1736 crate::error::SqzError::Other(format!(
1737 "write to temp file {}: {e}",
1738 tmp.path().display()
1739 ))
1740 })?;
1741 tmp.persist(&path).map_err(|e| {
1742 crate::error::SqzError::Other(format!(
1743 "rename temp file into place at {}: {e}",
1744 path.display()
1745 ))
1746 })?;
1747
1748 Ok(Some((path, true)))
1749}
1750
1751fn upsert_sqz_hook_entry(
1758 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1759 event_name: &str,
1760 new_entry: serde_json::Value,
1761 sentinel: &str,
1762) {
1763 let arr = hooks_obj
1764 .entry(event_name.to_string())
1765 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1766 let Some(arr) = arr.as_array_mut() else {
1767 hooks_obj.insert(
1771 event_name.to_string(),
1772 serde_json::Value::Array(vec![new_entry]),
1773 );
1774 return;
1775 };
1776
1777 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1779
1780 arr.push(new_entry);
1781}
1782
1783fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1787 entry
1788 .get("hooks")
1789 .and_then(|h| h.as_array())
1790 .map(|hooks_arr| {
1791 hooks_arr.iter().any(|h| {
1792 h.get("command")
1793 .and_then(|c| c.as_str())
1794 .map(|c| c.contains(needle))
1795 .unwrap_or(false)
1796 })
1797 })
1798 .unwrap_or(false)
1799}
1800
1801fn extract_base_command(cmd: &str) -> &str {
1805 cmd.split_whitespace()
1806 .next()
1807 .unwrap_or("unknown")
1808 .rsplit('/')
1809 .next()
1810 .unwrap_or("unknown")
1811}
1812
1813pub(crate) fn json_escape_string_value(s: &str) -> String {
1824 let mut out = String::with_capacity(s.len() + 2);
1825 for ch in s.chars() {
1826 match ch {
1827 '\\' => out.push_str("\\\\"),
1828 '"' => out.push_str("\\\""),
1829 '\n' => out.push_str("\\n"),
1830 '\r' => out.push_str("\\r"),
1831 '\t' => out.push_str("\\t"),
1832 '\x08' => out.push_str("\\b"),
1833 '\x0c' => out.push_str("\\f"),
1834 c if (c as u32) < 0x20 => {
1835 out.push_str(&format!("\\u{:04x}", c as u32));
1837 }
1838 c => out.push(c),
1839 }
1840 }
1841 out
1842}
1843
1844fn shell_escape(s: &str) -> String {
1846 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1847 s.to_string()
1848 } else {
1849 format!("'{}'", s.replace('\'', "'\\''"))
1850 }
1851}
1852
1853pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1857 cmd.contains("&&")
1860 || cmd.contains("||")
1861 || cmd.contains(';')
1862 || cmd.contains('>')
1863 || cmd.contains('<')
1864 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1870
1871fn is_interactive_command(cmd: &str) -> bool {
1873 let base = extract_base_command(cmd);
1874 matches!(
1875 base,
1876 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1877 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1878 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1879 ) || cmd.contains("--watch")
1880 || cmd.contains("-w ")
1881 || cmd.ends_with(" -w")
1882 || cmd.contains("run dev")
1883 || cmd.contains("run start")
1884 || cmd.contains("run serve")
1885}
1886
1887#[cfg(test)]
1890mod tests {
1891 use super::*;
1892
1893 #[test]
1894 fn test_copilot_hook_rewrites_bash_with_modified_args() {
1895 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
1896 let result = process_hook_copilot(input).unwrap();
1897 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1898 assert_eq!(parsed["permissionDecision"], "allow");
1899 let args = &parsed["modifiedArgs"];
1900 assert!(args["command"].as_str().unwrap().contains("sqz compress"));
1901 assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
1902 }
1903
1904 #[test]
1905 fn test_copilot_hook_passes_non_shell_through() {
1906 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
1907 let result = process_hook_copilot(input).unwrap();
1908 assert_eq!(result, "{}", "non-shell tools fall through with empty object");
1909 }
1910
1911 #[test]
1912 fn test_copilot_hook_skips_risky_shell_operators() {
1913 let input = r#"{"toolName":"bash","toolArgs":{"command":"make && make install"}}"#;
1914 let result = process_hook_copilot(input).unwrap();
1915 assert_eq!(result, "{}", "compound commands must not be rewritten");
1916 }
1917
1918 #[test]
1919 fn test_claude_updated_input_preserves_sibling_fields() {
1920 let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test","run_in_background":true,"timeout":120000,"description":"Run tests"}}"#;
1923 let result = process_hook(input).unwrap();
1924 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1925 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
1926 assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
1927 assert_eq!(updated["run_in_background"], true);
1928 assert_eq!(updated["timeout"], 120000);
1929 assert_eq!(updated["description"], "Run tests");
1930 }
1931
1932 #[test]
1933 fn test_process_hook_rewrites_bash_command() {
1934 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1936 let result = process_hook(input).unwrap();
1937 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1938 let hook_output = &parsed["hookSpecificOutput"];
1940 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1941 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1942 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1944 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1945 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1946 assert!(
1949 cmd.contains("--cmd 'git status'"),
1950 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1951 );
1952 assert!(
1953 !cmd.contains("SQZ_CMD="),
1954 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1955 );
1956 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1958 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1959 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1960 }
1961
1962 #[test]
1963 fn test_process_hook_passes_through_non_bash() {
1964 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1965 let result = process_hook(input).unwrap();
1966 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1967 }
1968
1969 #[test]
1970 fn test_process_hook_rewrites_powershell_command() {
1971 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1972 let result = process_hook(input).unwrap();
1973 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1974 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1975 .as_str()
1976 .expect("PowerShell tool calls should be rewritten");
1977 assert!(
1978 cmd.contains("Get-Content big.log"),
1979 "should preserve original PowerShell command: {cmd}"
1980 );
1981 assert!(
1982 cmd.contains("sqz compress"),
1983 "should pipe through sqz compress: {cmd}"
1984 );
1985 assert!(
1986 cmd.contains("--cmd 'Get-Content big.log'"),
1987 "should pass full command via --cmd: {cmd}"
1988 );
1989 }
1990
1991 #[test]
1992 fn test_process_hook_rewrites_pwsh_command() {
1993 let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
1994 let result = process_hook(input).unwrap();
1995 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1996 assert!(
1997 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
1998 "pwsh tool calls should be rewritten"
1999 );
2000 }
2001
2002 #[test]
2003 fn bash_rewrite_carries_exit_status_trailer() {
2004 let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test"}}"#;
2005 let result = process_hook(input).unwrap();
2006 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2007 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2008 .as_str()
2009 .unwrap();
2010 assert_eq!(
2011 cmd,
2012 "{ cargo test 2>&1; printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } | sqz compress --cmd 'cargo test'",
2013 "POSIX rewrite must send the command's exit status through the pipe"
2014 );
2015 }
2016
2017 #[test]
2018 fn powershell_rewrite_uses_lastexitcode_not_printf() {
2019 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"cargo test"}}"#;
2020 let result = process_hook(input).unwrap();
2021 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2022 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2023 .as_str()
2024 .unwrap();
2025 assert!(cmd.contains("$LASTEXITCODE"), "PowerShell form uses $LASTEXITCODE: {cmd}");
2026 assert!(cmd.contains("__SQZ_EXIT_"), "PowerShell form emits the trailer: {cmd}");
2027 assert!(!cmd.contains("printf"), "printf is not valid PowerShell: {cmd}");
2028 }
2029
2030 #[test]
2031 fn exit_trailer_rewrite_is_not_rewrapped() {
2032 let first = process_hook(
2035 r#"{"tool_name":"Bash","tool_input":{"command":"git log"}}"#,
2036 )
2037 .unwrap();
2038 let parsed: serde_json::Value = serde_json::from_str(&first).unwrap();
2039 let rewritten = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2040 .as_str()
2041 .unwrap();
2042 let second_input = serde_json::json!({
2043 "tool_name": "Bash",
2044 "tool_input": { "command": rewritten }
2045 })
2046 .to_string();
2047 let second = process_hook(&second_input).unwrap();
2048 assert_eq!(
2049 second, second_input,
2050 "already-rewritten commands must pass through unchanged"
2051 );
2052 }
2053
2054 #[test]
2055 fn test_process_hook_skips_sqz_commands() {
2056 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
2057 let result = process_hook(input).unwrap();
2058 assert_eq!(result, input, "sqz commands should not be double-wrapped");
2059 }
2060
2061 #[test]
2062 fn test_process_hook_skips_interactive() {
2063 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
2064 let result = process_hook(input).unwrap();
2065 assert_eq!(result, input, "interactive commands should pass through");
2066 }
2067
2068 #[test]
2069 fn test_process_hook_skips_watch_mode() {
2070 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
2071 let result = process_hook(input).unwrap();
2072 assert_eq!(result, input, "watch mode should pass through");
2073 }
2074
2075 #[test]
2076 fn test_process_hook_empty_command() {
2077 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
2078 let result = process_hook(input).unwrap();
2079 assert_eq!(result, input);
2080 }
2081
2082 #[test]
2083 fn test_process_hook_gemini_format() {
2084 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
2086 let result = process_hook_gemini(input).unwrap();
2087 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2088 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
2090 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
2092 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
2093 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
2095 "Gemini format should not have updatedInput");
2096 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
2097 "Gemini format should not have permissionDecision");
2098 }
2099
2100 #[test]
2101 fn test_process_hook_legacy_format() {
2102 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
2104 let result = process_hook(input).unwrap();
2105 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2106 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2107 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
2108 }
2109
2110 #[test]
2111 fn test_process_hook_cursor_format() {
2112 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
2114 let result = process_hook_cursor(input).unwrap();
2115 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2116 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
2118 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
2119 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
2120 assert!(cmd.contains("git status"));
2121 assert!(parsed.get("hookSpecificOutput").is_none(),
2123 "Cursor format should not have hookSpecificOutput");
2124 }
2125
2126 #[test]
2127 fn test_process_hook_cursor_passthrough_returns_empty_json() {
2128 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
2130 let result = process_hook_cursor(input).unwrap();
2131 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
2132 }
2133
2134 #[test]
2135 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
2136 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
2138 let result = process_hook_cursor(input).unwrap();
2139 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
2140 }
2141
2142 #[test]
2143 fn test_process_hook_windsurf_format() {
2144 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
2146 let result = process_hook_windsurf(input).unwrap();
2147 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2148 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2150 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
2151 assert!(cmd.contains("cargo test"));
2152 assert!(
2154 cmd.contains("--cmd 'cargo test'"),
2155 "full command must be passed via --cmd flag: {cmd}"
2156 );
2157 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
2158 }
2159
2160 #[test]
2161 fn test_process_hook_invalid_json() {
2162 let result = process_hook("not json");
2163 assert!(result.is_err());
2164 }
2165
2166 #[test]
2167 fn test_extract_base_command() {
2168 assert_eq!(extract_base_command("git status"), "git");
2169 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
2170 assert_eq!(extract_base_command("cargo test --release"), "cargo");
2171 }
2172
2173 #[test]
2174 fn test_is_interactive_command() {
2175 assert!(is_interactive_command("vim file.txt"));
2176 assert!(is_interactive_command("npm run dev --watch"));
2177 assert!(is_interactive_command("python3"));
2178 assert!(!is_interactive_command("git status"));
2179 assert!(!is_interactive_command("cargo test"));
2180 }
2181
2182 #[test]
2197 fn issue_10_rewrite_is_shell_neutral() {
2198 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
2199 let result = process_hook(input).unwrap();
2200 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2201 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2202 .as_str()
2203 .unwrap();
2204
2205 assert!(
2207 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
2208 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
2209 );
2210 assert!(
2212 !cmd.contains("SQZ_CMD="),
2213 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
2214 (broken in PowerShell and cmd.exe), got: {cmd}"
2215 );
2216 assert!(
2218 cmd.contains("dotnet build NewNeonCheckers3.sln"),
2219 "original command must be preserved verbatim: {cmd}"
2220 );
2221 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
2223 }
2224
2225 #[test]
2233 fn issue_10_already_wrapped_command_passes_through() {
2234 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
2235 let result = process_hook(input).unwrap();
2236 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2237 assert_eq!(
2240 result, input,
2241 "already-wrapped command must pass through unchanged; \
2242 otherwise each pass accumulates another `| sqz compress` tail"
2243 );
2244 let _ = parsed; }
2249
2250 #[test]
2251 fn test_generate_hook_configs() {
2252 let configs = generate_hook_configs("sqz");
2253 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
2254 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
2255 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
2256 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
2257 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
2260 assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
2261 "Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
2262 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
2263 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
2264 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
2265 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2269 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
2270 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
2271 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
2272 assert!(cursor.config_content.starts_with("---"),
2273 "Cursor rule should start with YAML frontmatter");
2274 assert!(cursor.config_content.contains("alwaysApply: true"),
2275 "Cursor rule should use alwaysApply: true so the guidance loads \
2276 for every agent interaction");
2277 assert!(cursor.config_content.contains("sqz"),
2278 "Cursor rule body should mention sqz");
2279 }
2280
2281 #[test]
2282 fn test_claude_config_includes_precompact_hook() {
2283 let configs = generate_hook_configs("sqz");
2288 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2289 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2290 .expect("Claude Code config must be valid JSON");
2291
2292 let precompact = parsed["hooks"]["PreCompact"]
2293 .as_array()
2294 .expect("PreCompact hook array must be present");
2295 assert!(
2296 !precompact.is_empty(),
2297 "PreCompact must have at least one registered hook"
2298 );
2299
2300 let cmd = precompact[0]["hooks"][0]["command"]
2301 .as_str()
2302 .expect("command field must be a string");
2303 assert!(
2304 cmd.ends_with(" hook precompact"),
2305 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
2306 );
2307 }
2308
2309 #[test]
2312 fn test_json_escape_string_value() {
2313 assert_eq!(json_escape_string_value("sqz"), "sqz");
2315 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
2316 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
2318 r"C:\\Users\\Alice\\sqz.exe");
2319 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
2321 r#"path with \"quotes\""#);
2322 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
2324 }
2325
2326 #[test]
2327 fn test_windows_path_produces_valid_json_for_claude() {
2328 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2331 let configs = generate_hook_configs(windows_path);
2332
2333 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
2334 .expect("Claude config should be generated");
2335 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2336 .expect("Claude hook config must be valid JSON on Windows paths");
2337
2338 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
2340 .as_str()
2341 .expect("command field must be a string");
2342 assert!(cmd.contains(windows_path),
2343 "command '{cmd}' must contain the original Windows path '{windows_path}'");
2344 }
2345
2346 #[test]
2347 fn test_windows_path_in_cursor_rules_file() {
2348 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2354 let configs = generate_hook_configs(windows_path);
2355
2356 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2357 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
2358 assert!(cursor.config_content.contains(windows_path),
2359 "Cursor rule must contain the raw (unescaped) path so users can \
2360 copy-paste the shown commands — got:\n{}", cursor.config_content);
2361 assert!(!cursor.config_content.contains(r"C:\\Users"),
2362 "Cursor rule must NOT double-escape backslashes in markdown — \
2363 got:\n{}", cursor.config_content);
2364 }
2365
2366 #[test]
2367 fn test_windows_path_produces_valid_json_for_gemini() {
2368 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2369 let configs = generate_hook_configs(windows_path);
2370
2371 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
2372 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
2373 .expect("Gemini hook config must be valid JSON on Windows paths");
2374 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
2375 assert!(cmd.contains(windows_path));
2376 }
2377
2378 #[test]
2379 fn test_rules_files_use_raw_path_for_readability() {
2380 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2384 let configs = generate_hook_configs(windows_path);
2385
2386 for tool in &["Windsurf", "Cline", "Cursor"] {
2387 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
2388 assert!(cfg.config_content.contains(windows_path),
2389 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
2390 cfg.config_content);
2391 assert!(!cfg.config_content.contains(r"C:\\Users"),
2392 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
2393 cfg.config_content);
2394 }
2395 }
2396
2397 #[test]
2398 fn test_unix_path_still_works() {
2399 let unix_path = "/usr/local/bin/sqz";
2402 let configs = generate_hook_configs(unix_path);
2403
2404 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2405 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2406 .expect("Unix path should produce valid JSON");
2407 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
2408 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
2409 }
2410
2411 #[test]
2412 fn test_shell_escape_simple() {
2413 assert_eq!(shell_escape("git"), "git");
2414 assert_eq!(shell_escape("cargo-test"), "cargo-test");
2415 }
2416
2417 #[test]
2418 fn test_shell_escape_special_chars() {
2419 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
2420 }
2421
2422 #[test]
2423 fn test_install_tool_hooks_creates_files() {
2424 let dir = tempfile::tempdir().unwrap();
2425 let installed = install_tool_hooks(dir.path(), "sqz");
2426 assert!(!installed.is_empty(), "should install at least one hook config");
2428 for name in &installed {
2430 let configs = generate_hook_configs("sqz");
2431 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
2432 let path = dir.path().join(&config.config_path);
2433 assert!(path.exists(), "hook config should exist: {}", path.display());
2434 }
2435 }
2436
2437 #[test]
2438 fn test_install_tool_hooks_does_not_overwrite() {
2439 let dir = tempfile::tempdir().unwrap();
2440 install_tool_hooks(dir.path(), "sqz");
2442 let custom_path = dir.path().join(".claude/settings.local.json");
2446 std::fs::write(&custom_path, "custom content").unwrap();
2447 install_tool_hooks(dir.path(), "sqz");
2449 let content = std::fs::read_to_string(&custom_path).unwrap();
2450 assert_eq!(content, "custom content", "should not overwrite existing config");
2451 }
2452
2453 #[test]
2460 fn test_reinit_upgrades_stale_project_scope_matcher() {
2461 let dir = tempfile::tempdir().unwrap();
2462 let settings_path = dir.path().join(".claude").join("settings.local.json");
2463 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
2464
2465 let stale = serde_json::json!({
2468 "permissions": { "allow": ["Bash(ls:*)"] },
2469 "hooks": {
2470 "PreToolUse": [
2471 {
2472 "matcher": "Bash",
2473 "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
2474 },
2475 {
2476 "matcher": "Edit",
2477 "hooks": [{ "type": "command", "command": "my-linter --check" }]
2478 }
2479 ]
2480 }
2481 });
2482 std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
2483
2484 install_tool_hooks(dir.path(), "/new/path/sqz");
2485
2486 let content = std::fs::read_to_string(&settings_path).unwrap();
2487 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2488
2489 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2491 let sqz_entries: Vec<_> = pre
2492 .iter()
2493 .filter(|e| {
2494 e["hooks"][0]["command"]
2495 .as_str()
2496 .is_some_and(|c| c.contains("hook claude"))
2497 })
2498 .collect();
2499 assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
2500 assert_eq!(
2501 sqz_entries[0]["matcher"].as_str().unwrap(),
2502 "Bash|PowerShell",
2503 "stale Bash-only matcher must be upgraded"
2504 );
2505 assert!(
2506 sqz_entries[0]["hooks"][0]["command"]
2507 .as_str()
2508 .unwrap()
2509 .contains("/new/path/sqz"),
2510 "binary path must be refreshed"
2511 );
2512
2513 assert!(
2515 pre.iter().any(|e| e["matcher"] == "Edit"),
2516 "user's non-sqz PreToolUse entry must be preserved"
2517 );
2518 assert_eq!(
2519 parsed["permissions"]["allow"][0].as_str().unwrap(),
2520 "Bash(ls:*)",
2521 "user's permissions must be preserved"
2522 );
2523
2524 assert!(parsed["hooks"]["PreCompact"].is_array());
2526 assert!(parsed["hooks"]["SessionStart"].is_array());
2527 }
2528
2529 #[test]
2530 fn test_windsurf_installs_to_rules_directory() {
2531 let dir = tempfile::tempdir().unwrap();
2532 install_tool_hooks(dir.path(), "sqz");
2533 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2534 assert!(!dir.path().join(".windsurfrules").exists());
2535 }
2536
2537 #[test]
2538 fn test_windsurf_migrates_legacy_sqz_rules_file() {
2539 let dir = tempfile::tempdir().unwrap();
2540 std::fs::write(
2541 dir.path().join(".windsurfrules"),
2542 "# sqz — Token-Optimized CLI Output\n\nold content\n",
2543 )
2544 .unwrap();
2545 install_tool_hooks(dir.path(), "sqz");
2546 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2547 assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
2548 }
2549
2550 #[test]
2551 fn test_windsurf_leaves_user_legacy_rules_alone() {
2552 let dir = tempfile::tempdir().unwrap();
2553 std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
2554 install_tool_hooks(dir.path(), "sqz");
2555 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2556 assert_eq!(
2557 std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
2558 "my own rules\n"
2559 );
2560 }
2561
2562 #[test]
2563 fn test_cline_installs_into_rules_directory_when_present() {
2564 let dir = tempfile::tempdir().unwrap();
2565 std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
2566 std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
2567 install_tool_hooks(dir.path(), "sqz");
2568 assert!(dir.path().join(".clinerules/sqz.md").exists());
2569 assert!(dir.path().join(".clinerules/mine.md").exists());
2570 }
2571
2572 #[test]
2573 fn test_cline_single_file_untouched_when_user_authored() {
2574 let dir = tempfile::tempdir().unwrap();
2575 std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
2576 install_tool_hooks(dir.path(), "sqz");
2577 assert_eq!(
2578 std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
2579 "user rules\n"
2580 );
2581 }
2582
2583 #[test]
2585 fn test_reinit_is_idempotent_at_project_scope() {
2586 let dir = tempfile::tempdir().unwrap();
2587 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2588 let settings_path = dir.path().join(".claude").join("settings.local.json");
2589 let first = std::fs::read_to_string(&settings_path).unwrap();
2590
2591 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2592 let second = std::fs::read_to_string(&settings_path).unwrap();
2593
2594 let a: serde_json::Value = serde_json::from_str(&first).unwrap();
2595 let b: serde_json::Value = serde_json::from_str(&second).unwrap();
2596 assert_eq!(a, b, "second init must not change project settings");
2597 assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
2599 }
2600}
2601
2602#[cfg(test)]
2603mod global_install_tests {
2604 use super::*;
2605
2606 #[test]
2607 fn global_install_creates_fresh_settings_json() {
2608 let tmp = tempfile::tempdir().unwrap();
2609 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2610 assert!(changed, "first install should report a change");
2611
2612 let path = tmp.path().join(".claude").join("settings.json");
2613 assert!(path.exists(), "user settings.json should be created");
2614
2615 let content = std::fs::read_to_string(&path).unwrap();
2616 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2617
2618 let pre = &parsed["hooks"]["PreToolUse"];
2620 assert!(pre.is_array(), "PreToolUse should be an array");
2621 assert_eq!(pre.as_array().unwrap().len(), 1);
2622 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2623 assert!(
2624 cmd.contains("/usr/local/bin/sqz"),
2625 "hook command should use the passed sqz_path, got: {cmd}"
2626 );
2627 assert!(cmd.contains("hook claude"));
2628
2629 let precompact = &parsed["hooks"]["PreCompact"];
2630 assert!(precompact.is_array());
2631 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
2632 assert!(precompact_cmd.contains("hook precompact"));
2633
2634 let session = &parsed["hooks"]["SessionStart"];
2635 assert!(session.is_array());
2636 assert_eq!(
2637 session[0]["matcher"].as_str().unwrap(),
2638 "compact",
2639 "SessionStart should only match /compact resume"
2640 );
2641 }
2642
2643 #[test]
2644 fn global_install_preserves_existing_user_config() {
2645 let tmp = tempfile::tempdir().unwrap();
2646 let settings = tmp.path().join(".claude").join("settings.json");
2647 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2648
2649 let existing = serde_json::json!({
2650 "permissions": {
2651 "allow": ["Bash(npm test *)"],
2652 "deny": ["Read(./.env)"]
2653 },
2654 "env": { "FOO": "bar" },
2655 "statusLine": {
2656 "type": "command",
2657 "command": "~/.claude/statusline.sh"
2658 },
2659 "hooks": {
2660 "PreToolUse": [
2661 {
2662 "matcher": "Edit",
2663 "hooks": [
2664 {
2665 "type": "command",
2666 "command": "~/.claude/hooks/format-on-edit.sh"
2667 }
2668 ]
2669 }
2670 ]
2671 }
2672 });
2673 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2674
2675 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2676 assert!(changed, "install should report a change on new hook");
2677
2678 let content = std::fs::read_to_string(&settings).unwrap();
2679 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2680
2681 assert_eq!(
2683 parsed["permissions"]["allow"][0].as_str().unwrap(),
2684 "Bash(npm test *)"
2685 );
2686 assert_eq!(
2687 parsed["permissions"]["deny"][0].as_str().unwrap(),
2688 "Read(./.env)"
2689 );
2690 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2692 assert_eq!(
2694 parsed["statusLine"]["command"].as_str().unwrap(),
2695 "~/.claude/statusline.sh"
2696 );
2697
2698 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2701 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2702 let matchers: Vec<&str> = pre
2703 .iter()
2704 .map(|e| e["matcher"].as_str().unwrap_or(""))
2705 .collect();
2706 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2707 assert!(
2708 matchers.contains(&"Bash|PowerShell"),
2709 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2710 );
2711 }
2712
2713 #[test]
2714 fn global_install_is_idempotent() {
2715 let tmp = tempfile::tempdir().unwrap();
2716 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2717 assert!(
2718 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2719 "second install with identical args should report no change"
2720 );
2721
2722 let path = tmp.path().join(".claude").join("settings.json");
2723 let parsed: serde_json::Value =
2724 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2725 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2726 let arr = parsed["hooks"][event].as_array().unwrap();
2727 assert_eq!(
2728 arr.len(),
2729 1,
2730 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2731 );
2732 }
2733 }
2734
2735 #[test]
2742 fn global_install_is_idempotent_with_exe_path() {
2743 let tmp = tempfile::tempdir().unwrap();
2744 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2745 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2746 assert!(
2747 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2748 "second install with .exe path should report no change (issue #21)"
2749 );
2750
2751 let path = tmp.path().join(".claude").join("settings.json");
2752 let parsed: serde_json::Value =
2753 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2754 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2755 let arr = parsed["hooks"][event].as_array().unwrap();
2756 assert_eq!(
2757 arr.len(),
2758 1,
2759 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2760 );
2761 }
2762 }
2763
2764 #[test]
2765 fn global_install_upgrades_stale_sqz_hook_in_place() {
2766 let tmp = tempfile::tempdir().unwrap();
2767 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2768 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2769 assert!(changed, "different sqz_path must be seen as a change");
2770
2771 let path = tmp.path().join(".claude").join("settings.json");
2772 let parsed: serde_json::Value =
2773 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2774 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2775 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2776 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2777 assert!(cmd.contains("/new/path/sqz"));
2778 assert!(!cmd.contains("/old/path/sqz"));
2779 }
2780
2781 #[test]
2782 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2783 let tmp = tempfile::tempdir().unwrap();
2784 let settings = tmp.path().join(".claude").join("settings.json");
2785 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2786 std::fs::write(
2787 &settings,
2788 serde_json::json!({
2789 "permissions": { "allow": ["Bash(git status)"] },
2790 "hooks": {
2791 "PreToolUse": [
2792 {
2793 "matcher": "Edit",
2794 "hooks": [
2795 { "type": "command", "command": "~/format.sh" }
2796 ]
2797 }
2798 ]
2799 }
2800 })
2801 .to_string(),
2802 )
2803 .unwrap();
2804
2805 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2806 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2807 assert_eq!(result.0, settings);
2808 assert!(result.1, "should report that the file was modified");
2809
2810 assert!(settings.exists(), "settings.json should be preserved");
2811 let parsed: serde_json::Value =
2812 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2813
2814 assert_eq!(
2815 parsed["permissions"]["allow"][0].as_str().unwrap(),
2816 "Bash(git status)"
2817 );
2818
2819 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2820 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2821 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2822
2823 assert!(parsed["hooks"].get("PreCompact").is_none());
2824 assert!(parsed["hooks"].get("SessionStart").is_none());
2825 }
2826
2827 #[test]
2828 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2829 let tmp = tempfile::tempdir().unwrap();
2830 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2831 let path = tmp.path().join(".claude").join("settings.json");
2832 assert!(path.exists(), "precondition: install created the file");
2833
2834 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2835 assert!(result.1);
2836 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2837 }
2838
2839 #[test]
2840 fn global_uninstall_on_missing_file_is_noop() {
2841 let tmp = tempfile::tempdir().unwrap();
2842 assert!(
2843 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2844 "missing file should return None, not error"
2845 );
2846 }
2847
2848 #[test]
2849 fn global_uninstall_refuses_to_touch_unparseable_file() {
2850 let tmp = tempfile::tempdir().unwrap();
2851 let settings = tmp.path().join(".claude").join("settings.json");
2852 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2853 std::fs::write(&settings, "{ invalid json because").unwrap();
2854
2855 assert!(
2856 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2857 "bad JSON must surface as an error"
2858 );
2859
2860 let after = std::fs::read_to_string(&settings).unwrap();
2861 assert_eq!(after, "{ invalid json because");
2862 }
2863}
2864
2865#[cfg(test)]
2866mod issue_11_tool_filter_tests {
2867 use super::*;
2876
2877 #[test]
2878 fn canonicalize_collapses_common_aliases() {
2879 for aliases in &[
2882 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2883 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2884 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2885 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2889 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2890 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2891 (vec!["Codex", "codex"], "codex"),
2892 ] {
2893 for alias in &aliases.0 {
2894 assert_eq!(
2895 canonicalize_tool_name(alias),
2896 aliases.1,
2897 "alias '{}' must canonicalise to '{}'",
2898 alias,
2899 aliases.1
2900 );
2901 }
2902 }
2903 }
2904
2905 #[test]
2906 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2907 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2912 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2913 }
2914
2915 #[test]
2916 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2917 let names = parse_tool_list("opencode,codex").unwrap();
2920 assert_eq!(names, vec!["opencode", "codex"]);
2921
2922 let names = parse_tool_list(" opencode , codex ").unwrap();
2923 assert_eq!(names, vec!["opencode", "codex"]);
2924
2925 let names = parse_tool_list("opencode").unwrap();
2927 assert_eq!(names, vec!["opencode"]);
2928
2929 let names = parse_tool_list("claude-code").unwrap();
2931 assert_eq!(names, vec!["claudecode"]);
2932 }
2933
2934 #[test]
2935 fn parse_tool_list_dedupes_repeated_entries() {
2936 let names = parse_tool_list("opencode,opencode").unwrap();
2941 assert_eq!(names, vec!["opencode"]);
2942
2943 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2946 assert_eq!(names, vec!["claudecode"]);
2947 }
2948
2949 #[test]
2950 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2951 let err = parse_tool_list("opncode").unwrap_err();
2958 let msg = err.to_string();
2959 assert!(
2960 msg.contains("unknown agent name 'opncode'"),
2961 "error must quote the bad input: {msg}"
2962 );
2963 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2964 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2965 }
2966
2967 #[test]
2968 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2969 let err = parse_tool_list("opencode,xyz").unwrap_err();
2974 assert!(err.to_string().contains("xyz"));
2975 }
2976
2977 #[test]
2978 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2979 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2985 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2986 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2987 }
2988
2989 #[test]
2990 fn tool_filter_all_includes_every_supported_tool() {
2991 let filter = ToolFilter::All;
2992 for tool in SUPPORTED_TOOL_NAMES {
2993 assert!(
2994 filter.includes(tool),
2995 "default filter must include {tool}"
2996 );
2997 }
2998 }
2999
3000 #[test]
3001 fn tool_filter_only_opencode_excludes_everything_else() {
3002 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
3004 assert!(filter.includes("OpenCode"));
3005 for tool in SUPPORTED_TOOL_NAMES {
3007 if *tool == "OpenCode" {
3008 continue;
3009 }
3010 assert!(
3011 !filter.includes(tool),
3012 "--only opencode must not include {tool}"
3013 );
3014 }
3015 }
3016
3017 #[test]
3018 fn tool_filter_only_multi_tool_includes_exactly_those() {
3019 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
3020 assert!(filter.includes("OpenCode"));
3021 assert!(filter.includes("Codex"));
3022 assert!(!filter.includes("Claude Code"));
3024 assert!(!filter.includes("Cursor"));
3025 assert!(!filter.includes("Windsurf"));
3026 assert!(!filter.includes("Cline"));
3027 assert!(!filter.includes("Gemini CLI"));
3028 }
3029
3030 #[test]
3031 fn tool_filter_skip_inverts_the_set() {
3032 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
3035 assert!(!filter.includes("Cursor"));
3036 assert!(!filter.includes("Windsurf"));
3037 assert!(filter.includes("Claude Code"));
3039 assert!(filter.includes("Cline"));
3040 assert!(filter.includes("Gemini CLI"));
3041 assert!(filter.includes("OpenCode"));
3042 assert!(filter.includes("Codex"));
3043 }
3044
3045 #[test]
3046 fn tool_filter_only_empty_excludes_everything() {
3047 let filter = ToolFilter::Only(vec![]);
3053 for tool in SUPPORTED_TOOL_NAMES {
3054 assert!(
3055 !filter.includes(tool),
3056 "empty --only must exclude every tool, got {tool}"
3057 );
3058 }
3059 }
3060
3061 #[test]
3062 fn tool_filter_only_accepts_display_name_or_canonical() {
3063 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
3069 assert!(filter.includes("Claude Code"));
3070 assert!(!filter.includes("Cursor"));
3071
3072 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
3073 assert!(filter.includes("Gemini CLI"));
3074 }
3075
3076 #[test]
3077 fn supported_tool_names_matches_generate_hook_configs_exactly() {
3078 let configs = generate_hook_configs("sqz");
3084 let emitted: std::collections::HashSet<&str> =
3085 configs.iter().map(|c| c.tool_name.as_str()).collect();
3086 let declared: std::collections::HashSet<&str> =
3087 SUPPORTED_TOOL_NAMES.iter().copied().collect();
3088 assert_eq!(
3089 emitted, declared,
3090 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
3091 from generate_hook_configs. emitted={:?}, declared={:?}",
3092 emitted, declared
3093 );
3094 }
3095
3096 #[test]
3097 fn filtered_install_only_opencode_writes_only_opencode_files() {
3098 let dir = tempfile::tempdir().unwrap();
3104 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
3105 let _installed = install_tool_hooks_scoped_filtered(
3106 dir.path(),
3107 "sqz",
3108 InstallScope::Project,
3109 &filter,
3110 );
3111
3112 assert!(
3114 dir.path().join("opencode.json").exists(),
3115 "OpenCode config must be written when --only opencode is used"
3116 );
3117
3118 for (path, tool) in &[
3120 (".claude/settings.local.json", "Claude Code"),
3121 (".cursor/rules/sqz.mdc", "Cursor"),
3122 (".windsurfrules", "Windsurf"),
3123 (".clinerules", "Cline"),
3124 (".gemini/settings.json", "Gemini CLI"),
3125 ("AGENTS.md", "Codex"),
3126 ] {
3127 assert!(
3128 !dir.path().join(path).exists(),
3129 "filter rejected {tool} but the installer still wrote {path}"
3130 );
3131 }
3132 }
3133
3134 #[test]
3135 fn filtered_install_skip_cursor_omits_only_cursor() {
3136 let dir = tempfile::tempdir().unwrap();
3138 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
3139 let _installed = install_tool_hooks_scoped_filtered(
3140 dir.path(),
3141 "sqz",
3142 InstallScope::Project,
3143 &filter,
3144 );
3145
3146 assert!(
3148 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
3149 "skip cursor: .cursor/rules/sqz.mdc must not be written"
3150 );
3151 assert!(
3153 dir.path().join(".windsurf/rules/sqz.md").exists(),
3154 "skip cursor should not skip windsurf"
3155 );
3156 assert!(
3157 dir.path().join(".clinerules").exists(),
3158 "skip cursor should not skip cline"
3159 );
3160 }
3161}
3162
3163#[cfg(test)]
3164mod ci_hook_tests {
3165 use super::*;
3166
3167 #[test]
3168 fn ci_hook_content_is_valid_v1_config() {
3169 let parsed: serde_json::Value =
3170 serde_json::from_str(&ci_hook_content()).expect("CI hook must be valid JSON");
3171 assert_eq!(parsed["version"], 1);
3172 let session_start = parsed["hooks"]["sessionStart"].as_array().unwrap();
3173 assert!(session_start[0]["bash"]
3174 .as_str()
3175 .unwrap()
3176 .contains("install.sh"));
3177 let pre = parsed["hooks"]["preToolUse"].as_array().unwrap();
3178 let bash = pre[0]["bash"].as_str().unwrap();
3179 assert!(bash.contains("hook copilot"));
3180 assert!(bash.ends_with("echo '{}'"));
3183 assert_eq!(pre[0]["matcher"], "bash|powershell");
3184 }
3185
3186 #[test]
3187 fn install_ci_hook_creates_then_noops() {
3188 let dir = tempfile::tempdir().unwrap();
3189 assert!(install_ci_hook(dir.path()).unwrap());
3190 let path = ci_hooks_path(dir.path());
3191 assert!(path.exists());
3192 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
3193 assert!(!install_ci_hook(dir.path()).unwrap());
3195 }
3196
3197 #[test]
3198 fn install_ci_hook_refreshes_stale_sqz_file() {
3199 let dir = tempfile::tempdir().unwrap();
3200 let path = ci_hooks_path(dir.path());
3201 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3202 std::fs::write(&path, r#"{"version":1,"hooks":{"preToolUse":[{"bash":"sqz hook copilot"}]}}"#).unwrap();
3203 assert!(install_ci_hook(dir.path()).unwrap());
3204 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
3205 }
3206
3207 #[test]
3208 fn install_ci_hook_leaves_user_file_alone() {
3209 let dir = tempfile::tempdir().unwrap();
3210 let path = ci_hooks_path(dir.path());
3211 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3212 std::fs::write(&path, r#"{"version":1,"hooks":{}}"#).unwrap();
3213 assert!(!install_ci_hook(dir.path()).unwrap());
3214 assert_eq!(
3215 std::fs::read_to_string(&path).unwrap(),
3216 r#"{"version":1,"hooks":{}}"#
3217 );
3218 }
3219
3220 #[test]
3221 fn remove_ci_hook_only_removes_sqz_authored() {
3222 let dir = tempfile::tempdir().unwrap();
3223 assert!(!remove_ci_hook(dir.path()).unwrap());
3225 install_ci_hook(dir.path()).unwrap();
3227 assert!(remove_ci_hook(dir.path()).unwrap());
3228 assert!(!ci_hooks_path(dir.path()).exists());
3229 let path = ci_hooks_path(dir.path());
3231 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3232 std::fs::write(&path, "{}").unwrap();
3233 assert!(!remove_ci_hook(dir.path()).unwrap());
3234 assert!(path.exists());
3235 }
3236}
3237