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 ci_hooks_path(project_dir: &Path) -> PathBuf {
865 project_dir.join(".github").join("hooks").join("sqz.json")
866}
867
868pub fn ci_hook_content() -> String {
877 r#"{
878 "version": 1,
879 "hooks": {
880 "sessionStart": [
881 {
882 "type": "command",
883 "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",
884 "timeoutSec": 120
885 }
886 ],
887 "preToolUse": [
888 {
889 "type": "command",
890 "matcher": "bash|powershell",
891 "bash": "SQZ_BIN=$(command -v sqz || echo $HOME/.local/bin/sqz); [ -x \"$SQZ_BIN\" ] && \"$SQZ_BIN\" hook copilot 2>/dev/null || echo '{}'",
892 "timeoutSec": 10
893 }
894 ]
895 }
896}
897"#
898 .to_string()
899}
900
901pub fn install_ci_hook(project_dir: &Path) -> Result<bool> {
904 let path = ci_hooks_path(project_dir);
905 let content = ci_hook_content();
906 if path.exists() {
907 let existing = std::fs::read_to_string(&path).unwrap_or_default();
908 if existing == content || !existing.contains("hook copilot") {
909 return Ok(false);
910 }
911 }
912 if let Some(parent) = path.parent() {
913 std::fs::create_dir_all(parent).map_err(|e| {
914 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
915 })?;
916 }
917 std::fs::write(&path, content).map_err(|e| {
918 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
919 })?;
920 Ok(true)
921}
922
923pub fn remove_ci_hook(project_dir: &Path) -> Result<bool> {
925 let path = ci_hooks_path(project_dir);
926 if !path.exists() {
927 return Ok(false);
928 }
929 let content = std::fs::read_to_string(&path).unwrap_or_default();
930 if !content.contains("hook copilot") {
931 return Ok(false);
932 }
933 std::fs::remove_file(&path).map_err(|e| {
934 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
935 })?;
936 Ok(true)
937}
938
939pub fn copilot_hook_content(sqz_path: &str) -> String {
941 let escaped = json_escape_string_value(sqz_path);
942 format!(
943 r#"{{
944 "version": 1,
945 "hooks": {{
946 "preToolUse": [
947 {{
948 "type": "command",
949 "matcher": "bash|powershell",
950 "bash": "{escaped} hook copilot",
951 "powershell": "{escaped} hook copilot",
952 "timeoutSec": 10
953 }}
954 ]
955 }}
956}}
957"#
958 )
959}
960
961pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
965 let home = copilot_home();
966 if !home.exists() {
967 return Ok(None);
968 }
969 let path = copilot_hooks_path();
970 let content = copilot_hook_content(sqz_path);
971 if path.exists() {
972 let existing = std::fs::read_to_string(&path).unwrap_or_default();
973 if existing == content {
974 return Ok(Some(false));
975 }
976 }
977 if let Some(parent) = path.parent() {
978 std::fs::create_dir_all(parent).map_err(|e| {
979 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
980 })?;
981 }
982 std::fs::write(&path, content).map_err(|e| {
983 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
984 })?;
985 Ok(Some(true))
986}
987
988pub fn remove_copilot_hook() -> Result<bool> {
990 let path = copilot_hooks_path();
991 if !path.exists() {
992 return Ok(false);
993 }
994 std::fs::remove_file(&path).map_err(|e| {
995 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
996 })?;
997 Ok(true)
998}
999
1000pub fn canonicalize_tool_name(name: &str) -> String {
1019 let lowered: String = name
1020 .chars()
1021 .filter(|c| !c.is_whitespace())
1022 .flat_map(|c| c.to_lowercase())
1023 .filter(|c| *c != '-' && *c != '_')
1024 .collect();
1025 match lowered.as_str() {
1026 "claude" | "claudecode" => "claudecode".to_string(),
1027 "cursor" => "cursor".to_string(),
1028 "windsurf" => "windsurf".to_string(),
1029 "cline" | "roo" | "roocode" => "cline".to_string(),
1033 "gemini" | "geminicli" => "gemini".to_string(),
1034 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
1035 "opencode" => "opencode".to_string(),
1036 "codex" => "codex".to_string(),
1037 "zed" | "zededitor" | "zedagent" => "zed".to_string(),
1038 "copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
1039 other => other.to_string(),
1040 }
1041}
1042
1043pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
1053 let mut out = Vec::new();
1054 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
1055 .iter()
1056 .map(|n| canonicalize_tool_name(n))
1057 .collect();
1058 for part in raw.split(',') {
1059 let trimmed = part.trim();
1060 if trimmed.is_empty() {
1061 continue;
1062 }
1063 let canon = canonicalize_tool_name(trimmed);
1064 if !known.contains(&canon) {
1065 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
1066 .iter()
1067 .map(|n| canonicalize_tool_name(n))
1068 .collect();
1069 return Err(crate::error::SqzError::Other(format!(
1070 "unknown agent name '{}'. Valid options: {}",
1071 trimmed,
1072 valid.join(", ")
1073 )));
1074 }
1075 if !out.contains(&canon) {
1076 out.push(canon);
1077 }
1078 }
1079 Ok(out)
1080}
1081
1082pub fn install_tool_hooks_scoped(
1108 project_dir: &Path,
1109 sqz_path: &str,
1110 scope: InstallScope,
1111) -> Vec<String> {
1112 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
1113}
1114
1115pub fn install_tool_hooks_scoped_filtered(
1129 project_dir: &Path,
1130 sqz_path: &str,
1131 scope: InstallScope,
1132 filter: &ToolFilter,
1133) -> Vec<String> {
1134 let configs = generate_hook_configs(sqz_path);
1135 let mut installed = Vec::new();
1136
1137 for config in &configs {
1138 if !filter.includes(&config.tool_name) {
1142 continue;
1143 }
1144
1145 if config.tool_name == "OpenCode" {
1154 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
1155 Ok((updated, _comments_lost)) => {
1156 if updated && !installed.iter().any(|n| n == "OpenCode") {
1157 installed.push("OpenCode".to_string());
1158 }
1159 }
1160 Err(_e) => {
1161 }
1164 }
1165 continue;
1166 }
1167
1168 if config.tool_name == "Codex" {
1173 let agents_changed = crate::codex_integration::install_agents_md_guidance(
1174 project_dir, sqz_path,
1175 )
1176 .unwrap_or(false);
1177 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
1178 .unwrap_or(false);
1179 if (agents_changed || mcp_changed)
1180 && !installed.iter().any(|n| n == "Codex")
1181 {
1182 installed.push("Codex".to_string());
1183 }
1184 continue;
1185 }
1186
1187 if config.tool_name == "Windsurf" {
1190 let legacy = project_dir.join(".windsurfrules");
1191 let target = project_dir.join(&config.config_path);
1192 let legacy_is_sqz = legacy.exists()
1193 && std::fs::read_to_string(&legacy)
1194 .map(|s| s.starts_with("# sqz — Token-Optimized CLI Output"))
1195 .unwrap_or(false);
1196 let mut changed = false;
1197 if !target.exists() {
1198 if let Some(parent) = target.parent() {
1199 let _ = std::fs::create_dir_all(parent);
1200 }
1201 if std::fs::write(&target, &config.config_content).is_ok() {
1202 changed = true;
1203 }
1204 }
1205 if legacy_is_sqz && target.exists() {
1206 let _ = std::fs::remove_file(&legacy);
1207 changed = true;
1208 }
1209 if changed && !installed.iter().any(|n| n == "Windsurf") {
1210 installed.push("Windsurf".to_string());
1211 }
1212 continue;
1213 }
1214
1215 if config.tool_name == "Cline" {
1218 let base = project_dir.join(".clinerules");
1219 let target = if base.is_dir() { base.join("sqz.md") } else { base };
1220 if !target.exists() {
1221 if std::fs::write(&target, &config.config_content).is_ok()
1222 && !installed.iter().any(|n| n == "Cline")
1223 {
1224 installed.push("Cline".to_string());
1225 }
1226 }
1227 continue;
1228 }
1229
1230 if config.tool_name == "Copilot CLI" {
1232 if let Ok(Some(changed)) = install_copilot_hook(sqz_path) {
1233 if changed && !installed.iter().any(|n| n == "Copilot CLI") {
1234 installed.push("Copilot CLI".to_string());
1235 }
1236 }
1237 continue;
1238 }
1239
1240 if config.tool_name == "Kiro" {
1243 let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
1244 let steering_changed =
1245 crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
1246 .unwrap_or(false);
1247 let mcp_changed = matches!(
1248 crate::kiro_integration::install_kiro_mcp_config(project_dir),
1249 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1250 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1251 );
1252 if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
1253 installed.push("Kiro".to_string());
1254 }
1255 continue;
1256 }
1257
1258 if config.tool_name == "Zed" {
1263 let guidance_changed =
1264 crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
1265 .unwrap_or(false);
1266 let mcp_changed = matches!(
1267 crate::zed_integration::install_zed_mcp_config(),
1268 Ok(crate::zed_integration::ZedMcpInstall::Created)
1269 | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1270 );
1271 if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1272 installed.push("Zed".to_string());
1273 }
1274 continue;
1275 }
1276
1277 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1287 let hook_installed = match install_claude_global(sqz_path) {
1288 Ok(v) => v,
1289 Err(_) => false,
1290 };
1291 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1292 project_dir, sqz_path,
1293 )
1294 .unwrap_or(false);
1295 let mcp_changed =
1296 crate::claude_md_integration::install_claude_mcp_config()
1297 .unwrap_or(false);
1298 if (hook_installed || md_changed || mcp_changed)
1299 && !installed.iter().any(|n| n == "Claude Code")
1300 {
1301 installed.push("Claude Code".to_string());
1302 }
1303 continue;
1304 }
1305
1306 let full_path = project_dir.join(&config.config_path);
1307
1308 if full_path.exists() {
1310 if config.tool_name == "Claude Code" {
1319 let hook_changed =
1320 merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1321 let md_changed =
1322 crate::claude_md_integration::install_claude_md_guidance(
1323 project_dir, sqz_path,
1324 )
1325 .unwrap_or(false);
1326 let mcp_changed =
1327 crate::claude_md_integration::install_claude_mcp_config()
1328 .unwrap_or(false);
1329 if (hook_changed || md_changed || mcp_changed)
1330 && !installed.iter().any(|n| n == "Claude Code")
1331 {
1332 installed.push("Claude Code".to_string());
1333 }
1334 }
1335 continue;
1336 }
1337
1338 if let Some(parent) = full_path.parent() {
1340 if std::fs::create_dir_all(parent).is_err() {
1341 continue;
1342 }
1343 }
1344
1345 if std::fs::write(&full_path, &config.config_content).is_ok() {
1346 installed.push(config.tool_name.clone());
1347 if config.tool_name == "Claude Code" {
1354 let _ = crate::claude_md_integration::install_claude_md_guidance(
1355 project_dir, sqz_path,
1356 );
1357 let _ = crate::claude_md_integration::install_claude_mcp_config();
1358 }
1359 }
1360 }
1361
1362 if filter.includes("OpenCode") {
1373 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1374 if !installed.iter().any(|n| n == "OpenCode") {
1375 installed.push("OpenCode".to_string());
1376 }
1377 }
1378 }
1379
1380 installed
1381}
1382
1383pub fn claude_user_settings_path() -> Option<PathBuf> {
1396 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1397}
1398
1399fn install_claude_global(sqz_path: &str) -> Result<bool> {
1414 install_claude_global_at(sqz_path, None)
1415}
1416
1417fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1422 let path = match home_override {
1423 Some(h) => h.join(".claude").join("settings.json"),
1424 None => claude_user_settings_path().ok_or_else(|| {
1425 crate::error::SqzError::Other(
1426 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1427 )
1428 })?,
1429 };
1430 merge_sqz_hooks_into_settings(&path, sqz_path)
1431}
1432
1433pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1446 if !path.exists() {
1447 return Ok(true);
1448 }
1449 let content = std::fs::read_to_string(path).map_err(|e| {
1450 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1451 })?;
1452 let mut root: serde_json::Value = if content.trim().is_empty() {
1453 serde_json::Value::Object(serde_json::Map::new())
1454 } else {
1455 serde_json::from_str(&content).map_err(|e| {
1456 crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1457 })?
1458 };
1459 let Some(root_obj) = root.as_object_mut() else {
1460 return Err(crate::error::SqzError::Other(format!(
1461 "{} is not a JSON object",
1462 path.display()
1463 )));
1464 };
1465
1466 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1467 let hooks = root_obj
1468 .entry("hooks".to_string())
1469 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1470 let Some(hooks_obj) = hooks.as_object_mut() else {
1471 return Ok(true);
1472 };
1473 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1474 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1475 }
1476 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1477 Ok(before != after)
1478}
1479
1480fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
1484 [
1485 (
1486 "PreToolUse",
1487 serde_json::json!({
1488 "matcher": "Bash|PowerShell",
1489 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1490 }),
1491 "hook claude",
1492 ),
1493 (
1494 "PreCompact",
1495 serde_json::json!({
1496 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1497 }),
1498 "hook precompact",
1499 ),
1500 (
1501 "SessionStart",
1502 serde_json::json!({
1503 "matcher": "compact",
1504 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1505 }),
1506 "resume",
1507 ),
1508 ]
1509}
1510
1511fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1520 let mut root: serde_json::Value = if path.exists() {
1522 let content = std::fs::read_to_string(&path).map_err(|e| {
1523 crate::error::SqzError::Other(format!(
1524 "read {}: {e}",
1525 path.display()
1526 ))
1527 })?;
1528 if content.trim().is_empty() {
1529 serde_json::Value::Object(serde_json::Map::new())
1530 } else {
1531 serde_json::from_str(&content).map_err(|e| {
1532 crate::error::SqzError::Other(format!(
1533 "parse {}: {e} — please fix or move the file before re-running sqz init",
1534 path.display()
1535 ))
1536 })?
1537 }
1538 } else {
1539 serde_json::Value::Object(serde_json::Map::new())
1540 };
1541
1542 let root_obj = root.as_object_mut().ok_or_else(|| {
1545 crate::error::SqzError::Other(format!(
1546 "{} is not a JSON object — refusing to overwrite",
1547 path.display()
1548 ))
1549 })?;
1550
1551 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1553
1554 let hooks = root_obj
1556 .entry("hooks".to_string())
1557 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1558 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1559 crate::error::SqzError::Other(format!(
1560 "{}: `hooks` is not an object — refusing to overwrite",
1561 path.display()
1562 ))
1563 })?;
1564
1565 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1566 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1567 }
1568
1569 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1570 if before == after && path.exists() {
1571 return Ok(false);
1573 }
1574
1575 if let Some(parent) = path.parent() {
1577 std::fs::create_dir_all(parent).map_err(|e| {
1578 crate::error::SqzError::Other(format!(
1579 "create {}: {e}",
1580 parent.display()
1581 ))
1582 })?;
1583 }
1584
1585 let parent = path.parent().ok_or_else(|| {
1589 crate::error::SqzError::Other(format!(
1590 "path {} has no parent directory",
1591 path.display()
1592 ))
1593 })?;
1594 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1595 crate::error::SqzError::Other(format!(
1596 "create temp file in {}: {e}",
1597 parent.display()
1598 ))
1599 })?;
1600 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1601 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1602 std::fs::write(tmp.path(), serialized).map_err(|e| {
1603 crate::error::SqzError::Other(format!(
1604 "write to temp file {}: {e}",
1605 tmp.path().display()
1606 ))
1607 })?;
1608 tmp.persist(&path).map_err(|e| {
1609 crate::error::SqzError::Other(format!(
1610 "rename temp file into place at {}: {e}",
1611 path.display()
1612 ))
1613 })?;
1614
1615 Ok(true)
1616}
1617
1618pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1632 remove_claude_global_hook_at(None)
1633}
1634
1635fn remove_claude_global_hook_at(
1638 home_override: Option<&Path>,
1639) -> Result<Option<(PathBuf, bool)>> {
1640 let path = match home_override {
1641 Some(h) => h.join(".claude").join("settings.json"),
1642 None => match claude_user_settings_path() {
1643 Some(p) => p,
1644 None => return Ok(None),
1645 },
1646 };
1647 if !path.exists() {
1648 return Ok(None);
1649 }
1650
1651 let content = std::fs::read_to_string(&path).map_err(|e| {
1652 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1653 })?;
1654 if content.trim().is_empty() {
1655 return Ok(Some((path, false)));
1656 }
1657
1658 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1659 crate::error::SqzError::Other(format!(
1660 "parse {}: {e} — refusing to rewrite an unparseable file",
1661 path.display()
1662 ))
1663 })?;
1664 let Some(root_obj) = root.as_object_mut() else {
1665 return Ok(Some((path, false)));
1666 };
1667
1668 let mut changed = false;
1669 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1670 for (event, sentinel) in &[
1671 ("PreToolUse", "hook claude"),
1672 ("PreCompact", "hook precompact"),
1673 ("SessionStart", "resume"),
1674 ] {
1675 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1676 let before = arr.len();
1677 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1678 if arr.len() != before {
1679 changed = true;
1680 }
1681 }
1682 }
1683
1684 hooks.retain(|_, v| match v {
1687 serde_json::Value::Array(a) => !a.is_empty(),
1688 _ => true,
1689 });
1690
1691 let hooks_empty = hooks.is_empty();
1694 if hooks_empty {
1695 root_obj.remove("hooks");
1696 changed = true;
1697 }
1698 }
1699
1700 if !changed {
1701 return Ok(Some((path, false)));
1702 }
1703
1704 if root_obj.is_empty() {
1708 std::fs::remove_file(&path).map_err(|e| {
1709 crate::error::SqzError::Other(format!(
1710 "remove {}: {e}",
1711 path.display()
1712 ))
1713 })?;
1714 return Ok(Some((path, true)));
1715 }
1716
1717 let parent = path.parent().ok_or_else(|| {
1719 crate::error::SqzError::Other(format!(
1720 "path {} has no parent directory",
1721 path.display()
1722 ))
1723 })?;
1724 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1725 crate::error::SqzError::Other(format!(
1726 "create temp file in {}: {e}",
1727 parent.display()
1728 ))
1729 })?;
1730 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1731 .map_err(|e| {
1732 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1733 })?;
1734 std::fs::write(tmp.path(), serialized).map_err(|e| {
1735 crate::error::SqzError::Other(format!(
1736 "write to temp file {}: {e}",
1737 tmp.path().display()
1738 ))
1739 })?;
1740 tmp.persist(&path).map_err(|e| {
1741 crate::error::SqzError::Other(format!(
1742 "rename temp file into place at {}: {e}",
1743 path.display()
1744 ))
1745 })?;
1746
1747 Ok(Some((path, true)))
1748}
1749
1750fn upsert_sqz_hook_entry(
1757 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1758 event_name: &str,
1759 new_entry: serde_json::Value,
1760 sentinel: &str,
1761) {
1762 let arr = hooks_obj
1763 .entry(event_name.to_string())
1764 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1765 let Some(arr) = arr.as_array_mut() else {
1766 hooks_obj.insert(
1770 event_name.to_string(),
1771 serde_json::Value::Array(vec![new_entry]),
1772 );
1773 return;
1774 };
1775
1776 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1778
1779 arr.push(new_entry);
1780}
1781
1782fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1786 entry
1787 .get("hooks")
1788 .and_then(|h| h.as_array())
1789 .map(|hooks_arr| {
1790 hooks_arr.iter().any(|h| {
1791 h.get("command")
1792 .and_then(|c| c.as_str())
1793 .map(|c| c.contains(needle))
1794 .unwrap_or(false)
1795 })
1796 })
1797 .unwrap_or(false)
1798}
1799
1800fn extract_base_command(cmd: &str) -> &str {
1804 cmd.split_whitespace()
1805 .next()
1806 .unwrap_or("unknown")
1807 .rsplit('/')
1808 .next()
1809 .unwrap_or("unknown")
1810}
1811
1812pub(crate) fn json_escape_string_value(s: &str) -> String {
1823 let mut out = String::with_capacity(s.len() + 2);
1824 for ch in s.chars() {
1825 match ch {
1826 '\\' => out.push_str("\\\\"),
1827 '"' => out.push_str("\\\""),
1828 '\n' => out.push_str("\\n"),
1829 '\r' => out.push_str("\\r"),
1830 '\t' => out.push_str("\\t"),
1831 '\x08' => out.push_str("\\b"),
1832 '\x0c' => out.push_str("\\f"),
1833 c if (c as u32) < 0x20 => {
1834 out.push_str(&format!("\\u{:04x}", c as u32));
1836 }
1837 c => out.push(c),
1838 }
1839 }
1840 out
1841}
1842
1843fn shell_escape(s: &str) -> String {
1845 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1846 s.to_string()
1847 } else {
1848 format!("'{}'", s.replace('\'', "'\\''"))
1849 }
1850}
1851
1852pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1856 cmd.contains("&&")
1859 || cmd.contains("||")
1860 || cmd.contains(';')
1861 || cmd.contains('>')
1862 || cmd.contains('<')
1863 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1869
1870fn is_interactive_command(cmd: &str) -> bool {
1872 let base = extract_base_command(cmd);
1873 matches!(
1874 base,
1875 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1876 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1877 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1878 ) || cmd.contains("--watch")
1879 || cmd.contains("-w ")
1880 || cmd.ends_with(" -w")
1881 || cmd.contains("run dev")
1882 || cmd.contains("run start")
1883 || cmd.contains("run serve")
1884}
1885
1886#[cfg(test)]
1889mod tests {
1890 use super::*;
1891
1892 #[test]
1893 fn test_copilot_hook_rewrites_bash_with_modified_args() {
1894 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
1895 let result = process_hook_copilot(input).unwrap();
1896 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1897 assert_eq!(parsed["permissionDecision"], "allow");
1898 let args = &parsed["modifiedArgs"];
1899 assert!(args["command"].as_str().unwrap().contains("sqz compress"));
1900 assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
1901 }
1902
1903 #[test]
1904 fn test_copilot_hook_passes_non_shell_through() {
1905 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
1906 let result = process_hook_copilot(input).unwrap();
1907 assert_eq!(result, "{}", "non-shell tools fall through with empty object");
1908 }
1909
1910 #[test]
1911 fn test_copilot_hook_skips_risky_shell_operators() {
1912 let input = r#"{"toolName":"bash","toolArgs":{"command":"make && make install"}}"#;
1913 let result = process_hook_copilot(input).unwrap();
1914 assert_eq!(result, "{}", "compound commands must not be rewritten");
1915 }
1916
1917 #[test]
1918 fn test_claude_updated_input_preserves_sibling_fields() {
1919 let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test","run_in_background":true,"timeout":120000,"description":"Run tests"}}"#;
1922 let result = process_hook(input).unwrap();
1923 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1924 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
1925 assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
1926 assert_eq!(updated["run_in_background"], true);
1927 assert_eq!(updated["timeout"], 120000);
1928 assert_eq!(updated["description"], "Run tests");
1929 }
1930
1931 #[test]
1932 fn test_process_hook_rewrites_bash_command() {
1933 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1935 let result = process_hook(input).unwrap();
1936 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1937 let hook_output = &parsed["hookSpecificOutput"];
1939 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1940 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1941 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1943 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1944 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1945 assert!(
1948 cmd.contains("--cmd 'git status'"),
1949 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1950 );
1951 assert!(
1952 !cmd.contains("SQZ_CMD="),
1953 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1954 );
1955 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1957 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1958 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1959 }
1960
1961 #[test]
1962 fn test_process_hook_passes_through_non_bash() {
1963 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1964 let result = process_hook(input).unwrap();
1965 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1966 }
1967
1968 #[test]
1969 fn test_process_hook_rewrites_powershell_command() {
1970 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1971 let result = process_hook(input).unwrap();
1972 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1973 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1974 .as_str()
1975 .expect("PowerShell tool calls should be rewritten");
1976 assert!(
1977 cmd.contains("Get-Content big.log"),
1978 "should preserve original PowerShell command: {cmd}"
1979 );
1980 assert!(
1981 cmd.contains("sqz compress"),
1982 "should pipe through sqz compress: {cmd}"
1983 );
1984 assert!(
1985 cmd.contains("--cmd 'Get-Content big.log'"),
1986 "should pass full command via --cmd: {cmd}"
1987 );
1988 }
1989
1990 #[test]
1991 fn test_process_hook_rewrites_pwsh_command() {
1992 let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
1993 let result = process_hook(input).unwrap();
1994 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1995 assert!(
1996 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
1997 "pwsh tool calls should be rewritten"
1998 );
1999 }
2000
2001 #[test]
2002 fn test_process_hook_skips_sqz_commands() {
2003 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
2004 let result = process_hook(input).unwrap();
2005 assert_eq!(result, input, "sqz commands should not be double-wrapped");
2006 }
2007
2008 #[test]
2009 fn test_process_hook_skips_interactive() {
2010 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
2011 let result = process_hook(input).unwrap();
2012 assert_eq!(result, input, "interactive commands should pass through");
2013 }
2014
2015 #[test]
2016 fn test_process_hook_skips_watch_mode() {
2017 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
2018 let result = process_hook(input).unwrap();
2019 assert_eq!(result, input, "watch mode should pass through");
2020 }
2021
2022 #[test]
2023 fn test_process_hook_empty_command() {
2024 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
2025 let result = process_hook(input).unwrap();
2026 assert_eq!(result, input);
2027 }
2028
2029 #[test]
2030 fn test_process_hook_gemini_format() {
2031 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
2033 let result = process_hook_gemini(input).unwrap();
2034 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2035 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
2037 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
2039 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
2040 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
2042 "Gemini format should not have updatedInput");
2043 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
2044 "Gemini format should not have permissionDecision");
2045 }
2046
2047 #[test]
2048 fn test_process_hook_legacy_format() {
2049 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
2051 let result = process_hook(input).unwrap();
2052 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2053 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2054 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
2055 }
2056
2057 #[test]
2058 fn test_process_hook_cursor_format() {
2059 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
2061 let result = process_hook_cursor(input).unwrap();
2062 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2063 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
2065 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
2066 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
2067 assert!(cmd.contains("git status"));
2068 assert!(parsed.get("hookSpecificOutput").is_none(),
2070 "Cursor format should not have hookSpecificOutput");
2071 }
2072
2073 #[test]
2074 fn test_process_hook_cursor_passthrough_returns_empty_json() {
2075 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
2077 let result = process_hook_cursor(input).unwrap();
2078 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
2079 }
2080
2081 #[test]
2082 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
2083 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
2085 let result = process_hook_cursor(input).unwrap();
2086 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
2087 }
2088
2089 #[test]
2090 fn test_process_hook_windsurf_format() {
2091 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
2093 let result = process_hook_windsurf(input).unwrap();
2094 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2095 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2097 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
2098 assert!(cmd.contains("cargo test"));
2099 assert!(
2101 cmd.contains("--cmd 'cargo test'"),
2102 "full command must be passed via --cmd flag: {cmd}"
2103 );
2104 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
2105 }
2106
2107 #[test]
2108 fn test_process_hook_invalid_json() {
2109 let result = process_hook("not json");
2110 assert!(result.is_err());
2111 }
2112
2113 #[test]
2114 fn test_extract_base_command() {
2115 assert_eq!(extract_base_command("git status"), "git");
2116 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
2117 assert_eq!(extract_base_command("cargo test --release"), "cargo");
2118 }
2119
2120 #[test]
2121 fn test_is_interactive_command() {
2122 assert!(is_interactive_command("vim file.txt"));
2123 assert!(is_interactive_command("npm run dev --watch"));
2124 assert!(is_interactive_command("python3"));
2125 assert!(!is_interactive_command("git status"));
2126 assert!(!is_interactive_command("cargo test"));
2127 }
2128
2129 #[test]
2144 fn issue_10_rewrite_is_shell_neutral() {
2145 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
2146 let result = process_hook(input).unwrap();
2147 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2148 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2149 .as_str()
2150 .unwrap();
2151
2152 assert!(
2154 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
2155 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
2156 );
2157 assert!(
2159 !cmd.contains("SQZ_CMD="),
2160 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
2161 (broken in PowerShell and cmd.exe), got: {cmd}"
2162 );
2163 assert!(
2165 cmd.contains("dotnet build NewNeonCheckers3.sln"),
2166 "original command must be preserved verbatim: {cmd}"
2167 );
2168 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
2170 }
2171
2172 #[test]
2180 fn issue_10_already_wrapped_command_passes_through() {
2181 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
2182 let result = process_hook(input).unwrap();
2183 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2184 assert_eq!(
2187 result, input,
2188 "already-wrapped command must pass through unchanged; \
2189 otherwise each pass accumulates another `| sqz compress` tail"
2190 );
2191 let _ = parsed; }
2196
2197 #[test]
2198 fn test_generate_hook_configs() {
2199 let configs = generate_hook_configs("sqz");
2200 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
2201 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
2202 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
2203 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
2204 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
2207 assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
2208 "Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
2209 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
2210 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
2211 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
2212 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2216 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
2217 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
2218 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
2219 assert!(cursor.config_content.starts_with("---"),
2220 "Cursor rule should start with YAML frontmatter");
2221 assert!(cursor.config_content.contains("alwaysApply: true"),
2222 "Cursor rule should use alwaysApply: true so the guidance loads \
2223 for every agent interaction");
2224 assert!(cursor.config_content.contains("sqz"),
2225 "Cursor rule body should mention sqz");
2226 }
2227
2228 #[test]
2229 fn test_claude_config_includes_precompact_hook() {
2230 let configs = generate_hook_configs("sqz");
2235 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2236 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2237 .expect("Claude Code config must be valid JSON");
2238
2239 let precompact = parsed["hooks"]["PreCompact"]
2240 .as_array()
2241 .expect("PreCompact hook array must be present");
2242 assert!(
2243 !precompact.is_empty(),
2244 "PreCompact must have at least one registered hook"
2245 );
2246
2247 let cmd = precompact[0]["hooks"][0]["command"]
2248 .as_str()
2249 .expect("command field must be a string");
2250 assert!(
2251 cmd.ends_with(" hook precompact"),
2252 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
2253 );
2254 }
2255
2256 #[test]
2259 fn test_json_escape_string_value() {
2260 assert_eq!(json_escape_string_value("sqz"), "sqz");
2262 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
2263 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
2265 r"C:\\Users\\Alice\\sqz.exe");
2266 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
2268 r#"path with \"quotes\""#);
2269 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
2271 }
2272
2273 #[test]
2274 fn test_windows_path_produces_valid_json_for_claude() {
2275 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2278 let configs = generate_hook_configs(windows_path);
2279
2280 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
2281 .expect("Claude config should be generated");
2282 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2283 .expect("Claude hook config must be valid JSON on Windows paths");
2284
2285 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
2287 .as_str()
2288 .expect("command field must be a string");
2289 assert!(cmd.contains(windows_path),
2290 "command '{cmd}' must contain the original Windows path '{windows_path}'");
2291 }
2292
2293 #[test]
2294 fn test_windows_path_in_cursor_rules_file() {
2295 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2301 let configs = generate_hook_configs(windows_path);
2302
2303 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2304 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
2305 assert!(cursor.config_content.contains(windows_path),
2306 "Cursor rule must contain the raw (unescaped) path so users can \
2307 copy-paste the shown commands — got:\n{}", cursor.config_content);
2308 assert!(!cursor.config_content.contains(r"C:\\Users"),
2309 "Cursor rule must NOT double-escape backslashes in markdown — \
2310 got:\n{}", cursor.config_content);
2311 }
2312
2313 #[test]
2314 fn test_windows_path_produces_valid_json_for_gemini() {
2315 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2316 let configs = generate_hook_configs(windows_path);
2317
2318 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
2319 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
2320 .expect("Gemini hook config must be valid JSON on Windows paths");
2321 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
2322 assert!(cmd.contains(windows_path));
2323 }
2324
2325 #[test]
2326 fn test_rules_files_use_raw_path_for_readability() {
2327 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2331 let configs = generate_hook_configs(windows_path);
2332
2333 for tool in &["Windsurf", "Cline", "Cursor"] {
2334 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
2335 assert!(cfg.config_content.contains(windows_path),
2336 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
2337 cfg.config_content);
2338 assert!(!cfg.config_content.contains(r"C:\\Users"),
2339 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
2340 cfg.config_content);
2341 }
2342 }
2343
2344 #[test]
2345 fn test_unix_path_still_works() {
2346 let unix_path = "/usr/local/bin/sqz";
2349 let configs = generate_hook_configs(unix_path);
2350
2351 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2352 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2353 .expect("Unix path should produce valid JSON");
2354 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
2355 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
2356 }
2357
2358 #[test]
2359 fn test_shell_escape_simple() {
2360 assert_eq!(shell_escape("git"), "git");
2361 assert_eq!(shell_escape("cargo-test"), "cargo-test");
2362 }
2363
2364 #[test]
2365 fn test_shell_escape_special_chars() {
2366 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
2367 }
2368
2369 #[test]
2370 fn test_install_tool_hooks_creates_files() {
2371 let dir = tempfile::tempdir().unwrap();
2372 let installed = install_tool_hooks(dir.path(), "sqz");
2373 assert!(!installed.is_empty(), "should install at least one hook config");
2375 for name in &installed {
2377 let configs = generate_hook_configs("sqz");
2378 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
2379 let path = dir.path().join(&config.config_path);
2380 assert!(path.exists(), "hook config should exist: {}", path.display());
2381 }
2382 }
2383
2384 #[test]
2385 fn test_install_tool_hooks_does_not_overwrite() {
2386 let dir = tempfile::tempdir().unwrap();
2387 install_tool_hooks(dir.path(), "sqz");
2389 let custom_path = dir.path().join(".claude/settings.local.json");
2393 std::fs::write(&custom_path, "custom content").unwrap();
2394 install_tool_hooks(dir.path(), "sqz");
2396 let content = std::fs::read_to_string(&custom_path).unwrap();
2397 assert_eq!(content, "custom content", "should not overwrite existing config");
2398 }
2399
2400 #[test]
2407 fn test_reinit_upgrades_stale_project_scope_matcher() {
2408 let dir = tempfile::tempdir().unwrap();
2409 let settings_path = dir.path().join(".claude").join("settings.local.json");
2410 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
2411
2412 let stale = serde_json::json!({
2415 "permissions": { "allow": ["Bash(ls:*)"] },
2416 "hooks": {
2417 "PreToolUse": [
2418 {
2419 "matcher": "Bash",
2420 "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
2421 },
2422 {
2423 "matcher": "Edit",
2424 "hooks": [{ "type": "command", "command": "my-linter --check" }]
2425 }
2426 ]
2427 }
2428 });
2429 std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
2430
2431 install_tool_hooks(dir.path(), "/new/path/sqz");
2432
2433 let content = std::fs::read_to_string(&settings_path).unwrap();
2434 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2435
2436 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2438 let sqz_entries: Vec<_> = pre
2439 .iter()
2440 .filter(|e| {
2441 e["hooks"][0]["command"]
2442 .as_str()
2443 .is_some_and(|c| c.contains("hook claude"))
2444 })
2445 .collect();
2446 assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
2447 assert_eq!(
2448 sqz_entries[0]["matcher"].as_str().unwrap(),
2449 "Bash|PowerShell",
2450 "stale Bash-only matcher must be upgraded"
2451 );
2452 assert!(
2453 sqz_entries[0]["hooks"][0]["command"]
2454 .as_str()
2455 .unwrap()
2456 .contains("/new/path/sqz"),
2457 "binary path must be refreshed"
2458 );
2459
2460 assert!(
2462 pre.iter().any(|e| e["matcher"] == "Edit"),
2463 "user's non-sqz PreToolUse entry must be preserved"
2464 );
2465 assert_eq!(
2466 parsed["permissions"]["allow"][0].as_str().unwrap(),
2467 "Bash(ls:*)",
2468 "user's permissions must be preserved"
2469 );
2470
2471 assert!(parsed["hooks"]["PreCompact"].is_array());
2473 assert!(parsed["hooks"]["SessionStart"].is_array());
2474 }
2475
2476 #[test]
2477 fn test_windsurf_installs_to_rules_directory() {
2478 let dir = tempfile::tempdir().unwrap();
2479 install_tool_hooks(dir.path(), "sqz");
2480 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2481 assert!(!dir.path().join(".windsurfrules").exists());
2482 }
2483
2484 #[test]
2485 fn test_windsurf_migrates_legacy_sqz_rules_file() {
2486 let dir = tempfile::tempdir().unwrap();
2487 std::fs::write(
2488 dir.path().join(".windsurfrules"),
2489 "# sqz — Token-Optimized CLI Output\n\nold content\n",
2490 )
2491 .unwrap();
2492 install_tool_hooks(dir.path(), "sqz");
2493 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2494 assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
2495 }
2496
2497 #[test]
2498 fn test_windsurf_leaves_user_legacy_rules_alone() {
2499 let dir = tempfile::tempdir().unwrap();
2500 std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
2501 install_tool_hooks(dir.path(), "sqz");
2502 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2503 assert_eq!(
2504 std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
2505 "my own rules\n"
2506 );
2507 }
2508
2509 #[test]
2510 fn test_cline_installs_into_rules_directory_when_present() {
2511 let dir = tempfile::tempdir().unwrap();
2512 std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
2513 std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
2514 install_tool_hooks(dir.path(), "sqz");
2515 assert!(dir.path().join(".clinerules/sqz.md").exists());
2516 assert!(dir.path().join(".clinerules/mine.md").exists());
2517 }
2518
2519 #[test]
2520 fn test_cline_single_file_untouched_when_user_authored() {
2521 let dir = tempfile::tempdir().unwrap();
2522 std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
2523 install_tool_hooks(dir.path(), "sqz");
2524 assert_eq!(
2525 std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
2526 "user rules\n"
2527 );
2528 }
2529
2530 #[test]
2532 fn test_reinit_is_idempotent_at_project_scope() {
2533 let dir = tempfile::tempdir().unwrap();
2534 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2535 let settings_path = dir.path().join(".claude").join("settings.local.json");
2536 let first = std::fs::read_to_string(&settings_path).unwrap();
2537
2538 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2539 let second = std::fs::read_to_string(&settings_path).unwrap();
2540
2541 let a: serde_json::Value = serde_json::from_str(&first).unwrap();
2542 let b: serde_json::Value = serde_json::from_str(&second).unwrap();
2543 assert_eq!(a, b, "second init must not change project settings");
2544 assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
2546 }
2547}
2548
2549#[cfg(test)]
2550mod global_install_tests {
2551 use super::*;
2552
2553 #[test]
2554 fn global_install_creates_fresh_settings_json() {
2555 let tmp = tempfile::tempdir().unwrap();
2556 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2557 assert!(changed, "first install should report a change");
2558
2559 let path = tmp.path().join(".claude").join("settings.json");
2560 assert!(path.exists(), "user settings.json should be created");
2561
2562 let content = std::fs::read_to_string(&path).unwrap();
2563 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2564
2565 let pre = &parsed["hooks"]["PreToolUse"];
2567 assert!(pre.is_array(), "PreToolUse should be an array");
2568 assert_eq!(pre.as_array().unwrap().len(), 1);
2569 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2570 assert!(
2571 cmd.contains("/usr/local/bin/sqz"),
2572 "hook command should use the passed sqz_path, got: {cmd}"
2573 );
2574 assert!(cmd.contains("hook claude"));
2575
2576 let precompact = &parsed["hooks"]["PreCompact"];
2577 assert!(precompact.is_array());
2578 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
2579 assert!(precompact_cmd.contains("hook precompact"));
2580
2581 let session = &parsed["hooks"]["SessionStart"];
2582 assert!(session.is_array());
2583 assert_eq!(
2584 session[0]["matcher"].as_str().unwrap(),
2585 "compact",
2586 "SessionStart should only match /compact resume"
2587 );
2588 }
2589
2590 #[test]
2591 fn global_install_preserves_existing_user_config() {
2592 let tmp = tempfile::tempdir().unwrap();
2593 let settings = tmp.path().join(".claude").join("settings.json");
2594 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2595
2596 let existing = serde_json::json!({
2597 "permissions": {
2598 "allow": ["Bash(npm test *)"],
2599 "deny": ["Read(./.env)"]
2600 },
2601 "env": { "FOO": "bar" },
2602 "statusLine": {
2603 "type": "command",
2604 "command": "~/.claude/statusline.sh"
2605 },
2606 "hooks": {
2607 "PreToolUse": [
2608 {
2609 "matcher": "Edit",
2610 "hooks": [
2611 {
2612 "type": "command",
2613 "command": "~/.claude/hooks/format-on-edit.sh"
2614 }
2615 ]
2616 }
2617 ]
2618 }
2619 });
2620 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2621
2622 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2623 assert!(changed, "install should report a change on new hook");
2624
2625 let content = std::fs::read_to_string(&settings).unwrap();
2626 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2627
2628 assert_eq!(
2630 parsed["permissions"]["allow"][0].as_str().unwrap(),
2631 "Bash(npm test *)"
2632 );
2633 assert_eq!(
2634 parsed["permissions"]["deny"][0].as_str().unwrap(),
2635 "Read(./.env)"
2636 );
2637 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2639 assert_eq!(
2641 parsed["statusLine"]["command"].as_str().unwrap(),
2642 "~/.claude/statusline.sh"
2643 );
2644
2645 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2648 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2649 let matchers: Vec<&str> = pre
2650 .iter()
2651 .map(|e| e["matcher"].as_str().unwrap_or(""))
2652 .collect();
2653 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2654 assert!(
2655 matchers.contains(&"Bash|PowerShell"),
2656 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2657 );
2658 }
2659
2660 #[test]
2661 fn global_install_is_idempotent() {
2662 let tmp = tempfile::tempdir().unwrap();
2663 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2664 assert!(
2665 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2666 "second install with identical args should report no change"
2667 );
2668
2669 let path = tmp.path().join(".claude").join("settings.json");
2670 let parsed: serde_json::Value =
2671 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2672 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2673 let arr = parsed["hooks"][event].as_array().unwrap();
2674 assert_eq!(
2675 arr.len(),
2676 1,
2677 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2678 );
2679 }
2680 }
2681
2682 #[test]
2689 fn global_install_is_idempotent_with_exe_path() {
2690 let tmp = tempfile::tempdir().unwrap();
2691 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2692 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2693 assert!(
2694 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2695 "second install with .exe path should report no change (issue #21)"
2696 );
2697
2698 let path = tmp.path().join(".claude").join("settings.json");
2699 let parsed: serde_json::Value =
2700 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2701 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2702 let arr = parsed["hooks"][event].as_array().unwrap();
2703 assert_eq!(
2704 arr.len(),
2705 1,
2706 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2707 );
2708 }
2709 }
2710
2711 #[test]
2712 fn global_install_upgrades_stale_sqz_hook_in_place() {
2713 let tmp = tempfile::tempdir().unwrap();
2714 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2715 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2716 assert!(changed, "different sqz_path must be seen as a change");
2717
2718 let path = tmp.path().join(".claude").join("settings.json");
2719 let parsed: serde_json::Value =
2720 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2721 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2722 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2723 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2724 assert!(cmd.contains("/new/path/sqz"));
2725 assert!(!cmd.contains("/old/path/sqz"));
2726 }
2727
2728 #[test]
2729 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2730 let tmp = tempfile::tempdir().unwrap();
2731 let settings = tmp.path().join(".claude").join("settings.json");
2732 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2733 std::fs::write(
2734 &settings,
2735 serde_json::json!({
2736 "permissions": { "allow": ["Bash(git status)"] },
2737 "hooks": {
2738 "PreToolUse": [
2739 {
2740 "matcher": "Edit",
2741 "hooks": [
2742 { "type": "command", "command": "~/format.sh" }
2743 ]
2744 }
2745 ]
2746 }
2747 })
2748 .to_string(),
2749 )
2750 .unwrap();
2751
2752 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2753 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2754 assert_eq!(result.0, settings);
2755 assert!(result.1, "should report that the file was modified");
2756
2757 assert!(settings.exists(), "settings.json should be preserved");
2758 let parsed: serde_json::Value =
2759 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2760
2761 assert_eq!(
2762 parsed["permissions"]["allow"][0].as_str().unwrap(),
2763 "Bash(git status)"
2764 );
2765
2766 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2767 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2768 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2769
2770 assert!(parsed["hooks"].get("PreCompact").is_none());
2771 assert!(parsed["hooks"].get("SessionStart").is_none());
2772 }
2773
2774 #[test]
2775 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2776 let tmp = tempfile::tempdir().unwrap();
2777 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2778 let path = tmp.path().join(".claude").join("settings.json");
2779 assert!(path.exists(), "precondition: install created the file");
2780
2781 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2782 assert!(result.1);
2783 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2784 }
2785
2786 #[test]
2787 fn global_uninstall_on_missing_file_is_noop() {
2788 let tmp = tempfile::tempdir().unwrap();
2789 assert!(
2790 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2791 "missing file should return None, not error"
2792 );
2793 }
2794
2795 #[test]
2796 fn global_uninstall_refuses_to_touch_unparseable_file() {
2797 let tmp = tempfile::tempdir().unwrap();
2798 let settings = tmp.path().join(".claude").join("settings.json");
2799 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2800 std::fs::write(&settings, "{ invalid json because").unwrap();
2801
2802 assert!(
2803 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2804 "bad JSON must surface as an error"
2805 );
2806
2807 let after = std::fs::read_to_string(&settings).unwrap();
2808 assert_eq!(after, "{ invalid json because");
2809 }
2810}
2811
2812#[cfg(test)]
2813mod issue_11_tool_filter_tests {
2814 use super::*;
2823
2824 #[test]
2825 fn canonicalize_collapses_common_aliases() {
2826 for aliases in &[
2829 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2830 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2831 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2832 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2836 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2837 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2838 (vec!["Codex", "codex"], "codex"),
2839 ] {
2840 for alias in &aliases.0 {
2841 assert_eq!(
2842 canonicalize_tool_name(alias),
2843 aliases.1,
2844 "alias '{}' must canonicalise to '{}'",
2845 alias,
2846 aliases.1
2847 );
2848 }
2849 }
2850 }
2851
2852 #[test]
2853 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2854 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2859 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2860 }
2861
2862 #[test]
2863 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2864 let names = parse_tool_list("opencode,codex").unwrap();
2867 assert_eq!(names, vec!["opencode", "codex"]);
2868
2869 let names = parse_tool_list(" opencode , codex ").unwrap();
2870 assert_eq!(names, vec!["opencode", "codex"]);
2871
2872 let names = parse_tool_list("opencode").unwrap();
2874 assert_eq!(names, vec!["opencode"]);
2875
2876 let names = parse_tool_list("claude-code").unwrap();
2878 assert_eq!(names, vec!["claudecode"]);
2879 }
2880
2881 #[test]
2882 fn parse_tool_list_dedupes_repeated_entries() {
2883 let names = parse_tool_list("opencode,opencode").unwrap();
2888 assert_eq!(names, vec!["opencode"]);
2889
2890 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2893 assert_eq!(names, vec!["claudecode"]);
2894 }
2895
2896 #[test]
2897 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2898 let err = parse_tool_list("opncode").unwrap_err();
2905 let msg = err.to_string();
2906 assert!(
2907 msg.contains("unknown agent name 'opncode'"),
2908 "error must quote the bad input: {msg}"
2909 );
2910 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2911 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2912 }
2913
2914 #[test]
2915 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2916 let err = parse_tool_list("opencode,xyz").unwrap_err();
2921 assert!(err.to_string().contains("xyz"));
2922 }
2923
2924 #[test]
2925 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2926 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2932 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2933 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2934 }
2935
2936 #[test]
2937 fn tool_filter_all_includes_every_supported_tool() {
2938 let filter = ToolFilter::All;
2939 for tool in SUPPORTED_TOOL_NAMES {
2940 assert!(
2941 filter.includes(tool),
2942 "default filter must include {tool}"
2943 );
2944 }
2945 }
2946
2947 #[test]
2948 fn tool_filter_only_opencode_excludes_everything_else() {
2949 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2951 assert!(filter.includes("OpenCode"));
2952 for tool in SUPPORTED_TOOL_NAMES {
2954 if *tool == "OpenCode" {
2955 continue;
2956 }
2957 assert!(
2958 !filter.includes(tool),
2959 "--only opencode must not include {tool}"
2960 );
2961 }
2962 }
2963
2964 #[test]
2965 fn tool_filter_only_multi_tool_includes_exactly_those() {
2966 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2967 assert!(filter.includes("OpenCode"));
2968 assert!(filter.includes("Codex"));
2969 assert!(!filter.includes("Claude Code"));
2971 assert!(!filter.includes("Cursor"));
2972 assert!(!filter.includes("Windsurf"));
2973 assert!(!filter.includes("Cline"));
2974 assert!(!filter.includes("Gemini CLI"));
2975 }
2976
2977 #[test]
2978 fn tool_filter_skip_inverts_the_set() {
2979 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2982 assert!(!filter.includes("Cursor"));
2983 assert!(!filter.includes("Windsurf"));
2984 assert!(filter.includes("Claude Code"));
2986 assert!(filter.includes("Cline"));
2987 assert!(filter.includes("Gemini CLI"));
2988 assert!(filter.includes("OpenCode"));
2989 assert!(filter.includes("Codex"));
2990 }
2991
2992 #[test]
2993 fn tool_filter_only_empty_excludes_everything() {
2994 let filter = ToolFilter::Only(vec![]);
3000 for tool in SUPPORTED_TOOL_NAMES {
3001 assert!(
3002 !filter.includes(tool),
3003 "empty --only must exclude every tool, got {tool}"
3004 );
3005 }
3006 }
3007
3008 #[test]
3009 fn tool_filter_only_accepts_display_name_or_canonical() {
3010 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
3016 assert!(filter.includes("Claude Code"));
3017 assert!(!filter.includes("Cursor"));
3018
3019 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
3020 assert!(filter.includes("Gemini CLI"));
3021 }
3022
3023 #[test]
3024 fn supported_tool_names_matches_generate_hook_configs_exactly() {
3025 let configs = generate_hook_configs("sqz");
3031 let emitted: std::collections::HashSet<&str> =
3032 configs.iter().map(|c| c.tool_name.as_str()).collect();
3033 let declared: std::collections::HashSet<&str> =
3034 SUPPORTED_TOOL_NAMES.iter().copied().collect();
3035 assert_eq!(
3036 emitted, declared,
3037 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
3038 from generate_hook_configs. emitted={:?}, declared={:?}",
3039 emitted, declared
3040 );
3041 }
3042
3043 #[test]
3044 fn filtered_install_only_opencode_writes_only_opencode_files() {
3045 let dir = tempfile::tempdir().unwrap();
3051 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
3052 let _installed = install_tool_hooks_scoped_filtered(
3053 dir.path(),
3054 "sqz",
3055 InstallScope::Project,
3056 &filter,
3057 );
3058
3059 assert!(
3061 dir.path().join("opencode.json").exists(),
3062 "OpenCode config must be written when --only opencode is used"
3063 );
3064
3065 for (path, tool) in &[
3067 (".claude/settings.local.json", "Claude Code"),
3068 (".cursor/rules/sqz.mdc", "Cursor"),
3069 (".windsurfrules", "Windsurf"),
3070 (".clinerules", "Cline"),
3071 (".gemini/settings.json", "Gemini CLI"),
3072 ("AGENTS.md", "Codex"),
3073 ] {
3074 assert!(
3075 !dir.path().join(path).exists(),
3076 "filter rejected {tool} but the installer still wrote {path}"
3077 );
3078 }
3079 }
3080
3081 #[test]
3082 fn filtered_install_skip_cursor_omits_only_cursor() {
3083 let dir = tempfile::tempdir().unwrap();
3085 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
3086 let _installed = install_tool_hooks_scoped_filtered(
3087 dir.path(),
3088 "sqz",
3089 InstallScope::Project,
3090 &filter,
3091 );
3092
3093 assert!(
3095 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
3096 "skip cursor: .cursor/rules/sqz.mdc must not be written"
3097 );
3098 assert!(
3100 dir.path().join(".windsurf/rules/sqz.md").exists(),
3101 "skip cursor should not skip windsurf"
3102 );
3103 assert!(
3104 dir.path().join(".clinerules").exists(),
3105 "skip cursor should not skip cline"
3106 );
3107 }
3108}
3109
3110#[cfg(test)]
3111mod ci_hook_tests {
3112 use super::*;
3113
3114 #[test]
3115 fn ci_hook_content_is_valid_v1_config() {
3116 let parsed: serde_json::Value =
3117 serde_json::from_str(&ci_hook_content()).expect("CI hook must be valid JSON");
3118 assert_eq!(parsed["version"], 1);
3119 let session_start = parsed["hooks"]["sessionStart"].as_array().unwrap();
3120 assert!(session_start[0]["bash"]
3121 .as_str()
3122 .unwrap()
3123 .contains("install.sh"));
3124 let pre = parsed["hooks"]["preToolUse"].as_array().unwrap();
3125 let bash = pre[0]["bash"].as_str().unwrap();
3126 assert!(bash.contains("hook copilot"));
3127 assert!(bash.ends_with("echo '{}'"));
3130 assert_eq!(pre[0]["matcher"], "bash|powershell");
3131 }
3132
3133 #[test]
3134 fn install_ci_hook_creates_then_noops() {
3135 let dir = tempfile::tempdir().unwrap();
3136 assert!(install_ci_hook(dir.path()).unwrap());
3137 let path = ci_hooks_path(dir.path());
3138 assert!(path.exists());
3139 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
3140 assert!(!install_ci_hook(dir.path()).unwrap());
3142 }
3143
3144 #[test]
3145 fn install_ci_hook_refreshes_stale_sqz_file() {
3146 let dir = tempfile::tempdir().unwrap();
3147 let path = ci_hooks_path(dir.path());
3148 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3149 std::fs::write(&path, r#"{"version":1,"hooks":{"preToolUse":[{"bash":"sqz hook copilot"}]}}"#).unwrap();
3150 assert!(install_ci_hook(dir.path()).unwrap());
3151 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
3152 }
3153
3154 #[test]
3155 fn install_ci_hook_leaves_user_file_alone() {
3156 let dir = tempfile::tempdir().unwrap();
3157 let path = ci_hooks_path(dir.path());
3158 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3159 std::fs::write(&path, r#"{"version":1,"hooks":{}}"#).unwrap();
3160 assert!(!install_ci_hook(dir.path()).unwrap());
3161 assert_eq!(
3162 std::fs::read_to_string(&path).unwrap(),
3163 r#"{"version":1,"hooks":{}}"#
3164 );
3165 }
3166
3167 #[test]
3168 fn remove_ci_hook_only_removes_sqz_authored() {
3169 let dir = tempfile::tempdir().unwrap();
3170 assert!(!remove_ci_hook(dir.path()).unwrap());
3172 install_ci_hook(dir.path()).unwrap();
3174 assert!(remove_ci_hook(dir.path()).unwrap());
3175 assert!(!ci_hooks_path(dir.path()).exists());
3176 let path = ci_hooks_path(dir.path());
3178 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3179 std::fs::write(&path, "{}").unwrap();
3180 assert!(!remove_ci_hook(dir.path()).unwrap());
3181 assert!(path.exists());
3182 }
3183}
3184