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 {
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)]
756pub enum InstallScope {
757 Project,
760 Global,
763}
764
765#[derive(Debug, Clone, PartialEq, Eq)]
778pub enum ToolFilter {
779 All,
782 Only(Vec<String>),
786 Skip(Vec<String>),
791}
792
793impl Default for ToolFilter {
794 fn default() -> Self {
795 ToolFilter::All
796 }
797}
798
799impl ToolFilter {
800 pub fn includes(&self, tool_name: &str) -> bool {
811 let canon = canonicalize_tool_name(tool_name);
812 match self {
813 ToolFilter::All => true,
814 ToolFilter::Only(allow) => allow.iter().any(|n| {
815 n == &canon
818 }),
819 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
820 }
821 }
822}
823
824pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
829 "Claude Code",
830 "Cursor",
831 "Windsurf",
832 "Cline",
833 "Gemini CLI",
834 "Kiro",
835 "OpenCode",
836 "Codex",
837 "Zed",
838 "Copilot CLI",
839];
840
841pub fn copilot_hooks_path() -> PathBuf {
844 copilot_home().join("hooks").join("sqz.json")
845}
846
847fn copilot_home() -> PathBuf {
848 if let Ok(home) = std::env::var("COPILOT_HOME") {
849 if !home.trim().is_empty() {
850 return PathBuf::from(home);
851 }
852 }
853 let home = std::env::var("HOME")
854 .or_else(|_| std::env::var("USERPROFILE"))
855 .map(PathBuf::from)
856 .unwrap_or_else(|_| PathBuf::from("."));
857 home.join(".copilot")
858}
859
860pub fn ci_hooks_path(project_dir: &Path) -> PathBuf {
864 project_dir.join(".github").join("hooks").join("sqz.json")
865}
866
867pub fn ci_hook_content() -> String {
876 r#"{
877 "version": 1,
878 "hooks": {
879 "sessionStart": [
880 {
881 "type": "command",
882 "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",
883 "timeoutSec": 120
884 }
885 ],
886 "preToolUse": [
887 {
888 "type": "command",
889 "matcher": "bash|powershell",
890 "bash": "SQZ_BIN=$(command -v sqz || echo $HOME/.local/bin/sqz); [ -x \"$SQZ_BIN\" ] && \"$SQZ_BIN\" hook copilot 2>/dev/null || echo '{}'",
891 "timeoutSec": 10
892 }
893 ]
894 }
895}
896"#
897 .to_string()
898}
899
900pub fn install_ci_hook(project_dir: &Path) -> Result<bool> {
903 let path = ci_hooks_path(project_dir);
904 let content = ci_hook_content();
905 if path.exists() {
906 let existing = std::fs::read_to_string(&path).unwrap_or_default();
907 if existing == content || !existing.contains("hook copilot") {
908 return Ok(false);
909 }
910 }
911 if let Some(parent) = path.parent() {
912 std::fs::create_dir_all(parent).map_err(|e| {
913 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
914 })?;
915 }
916 std::fs::write(&path, content).map_err(|e| {
917 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
918 })?;
919 Ok(true)
920}
921
922pub fn remove_ci_hook(project_dir: &Path) -> Result<bool> {
924 let path = ci_hooks_path(project_dir);
925 if !path.exists() {
926 return Ok(false);
927 }
928 let content = std::fs::read_to_string(&path).unwrap_or_default();
929 if !content.contains("hook copilot") {
930 return Ok(false);
931 }
932 std::fs::remove_file(&path).map_err(|e| {
933 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
934 })?;
935 Ok(true)
936}
937
938pub fn copilot_hook_content(sqz_path: &str) -> String {
940 let escaped = json_escape_string_value(sqz_path);
941 format!(
942 r#"{{
943 "version": 1,
944 "hooks": {{
945 "preToolUse": [
946 {{
947 "type": "command",
948 "matcher": "bash|powershell",
949 "bash": "{escaped} hook copilot",
950 "powershell": "{escaped} hook copilot",
951 "timeoutSec": 10
952 }}
953 ]
954 }}
955}}
956"#
957 )
958}
959
960pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
964 let home = copilot_home();
965 if !home.exists() {
966 return Ok(None);
967 }
968 let path = copilot_hooks_path();
969 let content = copilot_hook_content(sqz_path);
970 if path.exists() {
971 let existing = std::fs::read_to_string(&path).unwrap_or_default();
972 if existing == content {
973 return Ok(Some(false));
974 }
975 }
976 if let Some(parent) = path.parent() {
977 std::fs::create_dir_all(parent).map_err(|e| {
978 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
979 })?;
980 }
981 std::fs::write(&path, content).map_err(|e| {
982 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
983 })?;
984 Ok(Some(true))
985}
986
987pub fn remove_copilot_hook() -> Result<bool> {
989 let path = copilot_hooks_path();
990 if !path.exists() {
991 return Ok(false);
992 }
993 std::fs::remove_file(&path).map_err(|e| {
994 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
995 })?;
996 Ok(true)
997}
998
999pub fn canonicalize_tool_name(name: &str) -> String {
1018 let lowered: String = name
1019 .chars()
1020 .filter(|c| !c.is_whitespace())
1021 .flat_map(|c| c.to_lowercase())
1022 .filter(|c| *c != '-' && *c != '_')
1023 .collect();
1024 match lowered.as_str() {
1025 "claude" | "claudecode" => "claudecode".to_string(),
1026 "cursor" => "cursor".to_string(),
1027 "windsurf" => "windsurf".to_string(),
1028 "cline" | "roo" | "roocode" => "cline".to_string(),
1032 "gemini" | "geminicli" => "gemini".to_string(),
1033 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
1034 "opencode" => "opencode".to_string(),
1035 "codex" => "codex".to_string(),
1036 "zed" | "zededitor" | "zedagent" => "zed".to_string(),
1037 "copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
1038 other => other.to_string(),
1039 }
1040}
1041
1042pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
1052 let mut out = Vec::new();
1053 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
1054 .iter()
1055 .map(|n| canonicalize_tool_name(n))
1056 .collect();
1057 for part in raw.split(',') {
1058 let trimmed = part.trim();
1059 if trimmed.is_empty() {
1060 continue;
1061 }
1062 let canon = canonicalize_tool_name(trimmed);
1063 if !known.contains(&canon) {
1064 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
1065 .iter()
1066 .map(|n| canonicalize_tool_name(n))
1067 .collect();
1068 return Err(crate::error::SqzError::Other(format!(
1069 "unknown agent name '{}'. Valid options: {}",
1070 trimmed,
1071 valid.join(", ")
1072 )));
1073 }
1074 if !out.contains(&canon) {
1075 out.push(canon);
1076 }
1077 }
1078 Ok(out)
1079}
1080
1081pub fn install_tool_hooks_scoped(
1107 project_dir: &Path,
1108 sqz_path: &str,
1109 scope: InstallScope,
1110) -> Vec<String> {
1111 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
1112}
1113
1114pub fn install_tool_hooks_scoped_filtered(
1128 project_dir: &Path,
1129 sqz_path: &str,
1130 scope: InstallScope,
1131 filter: &ToolFilter,
1132) -> Vec<String> {
1133 let configs = generate_hook_configs(sqz_path);
1134 let mut installed = Vec::new();
1135
1136 for config in &configs {
1137 if !filter.includes(&config.tool_name) {
1141 continue;
1142 }
1143
1144 if config.tool_name == "OpenCode" {
1153 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
1154 Ok((updated, _comments_lost)) => {
1155 if updated && !installed.iter().any(|n| n == "OpenCode") {
1156 installed.push("OpenCode".to_string());
1157 }
1158 }
1159 Err(_e) => {
1160 }
1163 }
1164 continue;
1165 }
1166
1167 if config.tool_name == "Codex" {
1172 let agents_changed = crate::codex_integration::install_agents_md_guidance(
1173 project_dir, sqz_path,
1174 )
1175 .unwrap_or(false);
1176 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
1177 .unwrap_or(false);
1178 if (agents_changed || mcp_changed)
1179 && !installed.iter().any(|n| n == "Codex")
1180 {
1181 installed.push("Codex".to_string());
1182 }
1183 continue;
1184 }
1185
1186 if config.tool_name == "Windsurf" {
1189 let legacy = project_dir.join(".windsurfrules");
1190 let target = project_dir.join(&config.config_path);
1191 let legacy_is_sqz = legacy.exists()
1192 && std::fs::read_to_string(&legacy)
1193 .map(|s| s.starts_with("# sqz — Token-Optimized CLI Output"))
1194 .unwrap_or(false);
1195 let mut changed = false;
1196 if !target.exists() {
1197 if let Some(parent) = target.parent() {
1198 let _ = std::fs::create_dir_all(parent);
1199 }
1200 if std::fs::write(&target, &config.config_content).is_ok() {
1201 changed = true;
1202 }
1203 }
1204 if legacy_is_sqz && target.exists() {
1205 let _ = std::fs::remove_file(&legacy);
1206 changed = true;
1207 }
1208 if changed && !installed.iter().any(|n| n == "Windsurf") {
1209 installed.push("Windsurf".to_string());
1210 }
1211 continue;
1212 }
1213
1214 if config.tool_name == "Cline" {
1217 let base = project_dir.join(".clinerules");
1218 let target = if base.is_dir() { base.join("sqz.md") } else { base };
1219 if !target.exists() {
1220 if std::fs::write(&target, &config.config_content).is_ok()
1221 && !installed.iter().any(|n| n == "Cline")
1222 {
1223 installed.push("Cline".to_string());
1224 }
1225 }
1226 continue;
1227 }
1228
1229 if config.tool_name == "Copilot CLI" {
1231 if let Ok(Some(changed)) = install_copilot_hook(sqz_path) {
1232 if changed && !installed.iter().any(|n| n == "Copilot CLI") {
1233 installed.push("Copilot CLI".to_string());
1234 }
1235 }
1236 continue;
1237 }
1238
1239 if config.tool_name == "Kiro" {
1244 let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
1245 let (steering_changed, mcp_changed) = if scope == InstallScope::Global {
1246 let steering =
1247 crate::kiro_integration::install_kiro_user_steering(sqz_path)
1248 .unwrap_or(false);
1249 let mcp = matches!(
1250 crate::kiro_integration::install_kiro_user_mcp_config(),
1251 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1252 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1253 );
1254 (steering, mcp)
1255 } else {
1256 let steering =
1257 crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
1258 .unwrap_or(false);
1259 let mcp = matches!(
1260 crate::kiro_integration::install_kiro_mcp_config(project_dir),
1261 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1262 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1263 );
1264 (steering, mcp)
1265 };
1266 if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
1267 installed.push("Kiro".to_string());
1268 }
1269 continue;
1270 }
1271
1272 if config.tool_name == "Zed" {
1277 let guidance_changed =
1278 crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
1279 .unwrap_or(false);
1280 let mcp_changed = matches!(
1281 crate::zed_integration::install_zed_mcp_config(),
1282 Ok(crate::zed_integration::ZedMcpInstall::Created)
1283 | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1284 );
1285 if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1286 installed.push("Zed".to_string());
1287 }
1288 continue;
1289 }
1290
1291 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1301 let hook_installed = match install_claude_global(sqz_path) {
1302 Ok(v) => v,
1303 Err(_) => false,
1304 };
1305 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1306 project_dir, sqz_path,
1307 )
1308 .unwrap_or(false);
1309 let mcp_changed =
1310 crate::claude_md_integration::install_claude_mcp_config()
1311 .unwrap_or(false);
1312 if (hook_installed || md_changed || mcp_changed)
1313 && !installed.iter().any(|n| n == "Claude Code")
1314 {
1315 installed.push("Claude Code".to_string());
1316 }
1317 continue;
1318 }
1319
1320 let full_path = project_dir.join(&config.config_path);
1321
1322 if full_path.exists() {
1324 if config.tool_name == "Claude Code" {
1333 let hook_changed =
1334 merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1335 let md_changed =
1336 crate::claude_md_integration::install_claude_md_guidance(
1337 project_dir, sqz_path,
1338 )
1339 .unwrap_or(false);
1340 let mcp_changed =
1341 crate::claude_md_integration::install_claude_mcp_config()
1342 .unwrap_or(false);
1343 if (hook_changed || md_changed || mcp_changed)
1344 && !installed.iter().any(|n| n == "Claude Code")
1345 {
1346 installed.push("Claude Code".to_string());
1347 }
1348 }
1349 continue;
1350 }
1351
1352 if let Some(parent) = full_path.parent() {
1354 if std::fs::create_dir_all(parent).is_err() {
1355 continue;
1356 }
1357 }
1358
1359 if std::fs::write(&full_path, &config.config_content).is_ok() {
1360 installed.push(config.tool_name.clone());
1361 if config.tool_name == "Claude Code" {
1368 let _ = crate::claude_md_integration::install_claude_md_guidance(
1369 project_dir, sqz_path,
1370 );
1371 let _ = crate::claude_md_integration::install_claude_mcp_config();
1372 }
1373 }
1374 }
1375
1376 if filter.includes("OpenCode") {
1387 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1388 if !installed.iter().any(|n| n == "OpenCode") {
1389 installed.push("OpenCode".to_string());
1390 }
1391 }
1392 }
1393
1394 installed
1395}
1396
1397pub fn claude_user_settings_path() -> Option<PathBuf> {
1410 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1411}
1412
1413fn install_claude_global(sqz_path: &str) -> Result<bool> {
1428 install_claude_global_at(sqz_path, None)
1429}
1430
1431fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1436 let path = match home_override {
1437 Some(h) => h.join(".claude").join("settings.json"),
1438 None => claude_user_settings_path().ok_or_else(|| {
1439 crate::error::SqzError::Other(
1440 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1441 )
1442 })?,
1443 };
1444 merge_sqz_hooks_into_settings(&path, sqz_path)
1445}
1446
1447pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1460 if !path.exists() {
1461 return Ok(true);
1462 }
1463 let content = std::fs::read_to_string(path).map_err(|e| {
1464 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1465 })?;
1466 let mut root: serde_json::Value = if content.trim().is_empty() {
1467 serde_json::Value::Object(serde_json::Map::new())
1468 } else {
1469 serde_json::from_str(&content).map_err(|e| {
1470 crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1471 })?
1472 };
1473 let Some(root_obj) = root.as_object_mut() else {
1474 return Err(crate::error::SqzError::Other(format!(
1475 "{} is not a JSON object",
1476 path.display()
1477 )));
1478 };
1479
1480 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1481 let hooks = root_obj
1482 .entry("hooks".to_string())
1483 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1484 let Some(hooks_obj) = hooks.as_object_mut() else {
1485 return Ok(true);
1486 };
1487 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1488 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1489 }
1490 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1491 Ok(before != after)
1492}
1493
1494fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
1498 [
1499 (
1500 "PreToolUse",
1501 serde_json::json!({
1502 "matcher": "Bash|PowerShell",
1503 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1504 }),
1505 "hook claude",
1506 ),
1507 (
1508 "PreCompact",
1509 serde_json::json!({
1510 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1511 }),
1512 "hook precompact",
1513 ),
1514 (
1515 "SessionStart",
1516 serde_json::json!({
1517 "matcher": "compact",
1518 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1519 }),
1520 "resume",
1521 ),
1522 ]
1523}
1524
1525fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1534 let mut root: serde_json::Value = if path.exists() {
1536 let content = std::fs::read_to_string(&path).map_err(|e| {
1537 crate::error::SqzError::Other(format!(
1538 "read {}: {e}",
1539 path.display()
1540 ))
1541 })?;
1542 if content.trim().is_empty() {
1543 serde_json::Value::Object(serde_json::Map::new())
1544 } else {
1545 serde_json::from_str(&content).map_err(|e| {
1546 crate::error::SqzError::Other(format!(
1547 "parse {}: {e} — please fix or move the file before re-running sqz init",
1548 path.display()
1549 ))
1550 })?
1551 }
1552 } else {
1553 serde_json::Value::Object(serde_json::Map::new())
1554 };
1555
1556 let root_obj = root.as_object_mut().ok_or_else(|| {
1559 crate::error::SqzError::Other(format!(
1560 "{} is not a JSON object — refusing to overwrite",
1561 path.display()
1562 ))
1563 })?;
1564
1565 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1567
1568 let hooks = root_obj
1570 .entry("hooks".to_string())
1571 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1572 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1573 crate::error::SqzError::Other(format!(
1574 "{}: `hooks` is not an object — refusing to overwrite",
1575 path.display()
1576 ))
1577 })?;
1578
1579 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1580 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1581 }
1582
1583 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1584 if before == after && path.exists() {
1585 return Ok(false);
1587 }
1588
1589 if let Some(parent) = path.parent() {
1591 std::fs::create_dir_all(parent).map_err(|e| {
1592 crate::error::SqzError::Other(format!(
1593 "create {}: {e}",
1594 parent.display()
1595 ))
1596 })?;
1597 }
1598
1599 let parent = path.parent().ok_or_else(|| {
1602 crate::error::SqzError::Other(format!(
1603 "path {} has no parent directory",
1604 path.display()
1605 ))
1606 })?;
1607 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1608 crate::error::SqzError::Other(format!(
1609 "create temp file in {}: {e}",
1610 parent.display()
1611 ))
1612 })?;
1613 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1614 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1615 std::fs::write(tmp.path(), serialized).map_err(|e| {
1616 crate::error::SqzError::Other(format!(
1617 "write to temp file {}: {e}",
1618 tmp.path().display()
1619 ))
1620 })?;
1621 tmp.persist(&path).map_err(|e| {
1622 crate::error::SqzError::Other(format!(
1623 "rename temp file into place at {}: {e}",
1624 path.display()
1625 ))
1626 })?;
1627
1628 Ok(true)
1629}
1630
1631pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1645 remove_claude_global_hook_at(None)
1646}
1647
1648fn remove_claude_global_hook_at(
1651 home_override: Option<&Path>,
1652) -> Result<Option<(PathBuf, bool)>> {
1653 let path = match home_override {
1654 Some(h) => h.join(".claude").join("settings.json"),
1655 None => match claude_user_settings_path() {
1656 Some(p) => p,
1657 None => return Ok(None),
1658 },
1659 };
1660 if !path.exists() {
1661 return Ok(None);
1662 }
1663
1664 let content = std::fs::read_to_string(&path).map_err(|e| {
1665 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1666 })?;
1667 if content.trim().is_empty() {
1668 return Ok(Some((path, false)));
1669 }
1670
1671 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1672 crate::error::SqzError::Other(format!(
1673 "parse {}: {e} — refusing to rewrite an unparseable file",
1674 path.display()
1675 ))
1676 })?;
1677 let Some(root_obj) = root.as_object_mut() else {
1678 return Ok(Some((path, false)));
1679 };
1680
1681 let mut changed = false;
1682 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1683 for (event, sentinel) in &[
1684 ("PreToolUse", "hook claude"),
1685 ("PreCompact", "hook precompact"),
1686 ("SessionStart", "resume"),
1687 ] {
1688 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1689 let before = arr.len();
1690 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1691 if arr.len() != before {
1692 changed = true;
1693 }
1694 }
1695 }
1696
1697 hooks.retain(|_, v| match v {
1700 serde_json::Value::Array(a) => !a.is_empty(),
1701 _ => true,
1702 });
1703
1704 let hooks_empty = hooks.is_empty();
1707 if hooks_empty {
1708 root_obj.remove("hooks");
1709 changed = true;
1710 }
1711 }
1712
1713 if !changed {
1714 return Ok(Some((path, false)));
1715 }
1716
1717 if root_obj.is_empty() {
1721 std::fs::remove_file(&path).map_err(|e| {
1722 crate::error::SqzError::Other(format!(
1723 "remove {}: {e}",
1724 path.display()
1725 ))
1726 })?;
1727 return Ok(Some((path, true)));
1728 }
1729
1730 let parent = path.parent().ok_or_else(|| {
1732 crate::error::SqzError::Other(format!(
1733 "path {} has no parent directory",
1734 path.display()
1735 ))
1736 })?;
1737 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1738 crate::error::SqzError::Other(format!(
1739 "create temp file in {}: {e}",
1740 parent.display()
1741 ))
1742 })?;
1743 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1744 .map_err(|e| {
1745 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1746 })?;
1747 std::fs::write(tmp.path(), serialized).map_err(|e| {
1748 crate::error::SqzError::Other(format!(
1749 "write to temp file {}: {e}",
1750 tmp.path().display()
1751 ))
1752 })?;
1753 tmp.persist(&path).map_err(|e| {
1754 crate::error::SqzError::Other(format!(
1755 "rename temp file into place at {}: {e}",
1756 path.display()
1757 ))
1758 })?;
1759
1760 Ok(Some((path, true)))
1761}
1762
1763fn upsert_sqz_hook_entry(
1770 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1771 event_name: &str,
1772 new_entry: serde_json::Value,
1773 sentinel: &str,
1774) {
1775 let arr = hooks_obj
1776 .entry(event_name.to_string())
1777 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1778 let Some(arr) = arr.as_array_mut() else {
1779 hooks_obj.insert(
1783 event_name.to_string(),
1784 serde_json::Value::Array(vec![new_entry]),
1785 );
1786 return;
1787 };
1788
1789 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1791
1792 arr.push(new_entry);
1793}
1794
1795fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1799 entry
1800 .get("hooks")
1801 .and_then(|h| h.as_array())
1802 .map(|hooks_arr| {
1803 hooks_arr.iter().any(|h| {
1804 h.get("command")
1805 .and_then(|c| c.as_str())
1806 .map(|c| c.contains(needle))
1807 .unwrap_or(false)
1808 })
1809 })
1810 .unwrap_or(false)
1811}
1812
1813fn extract_base_command(cmd: &str) -> &str {
1817 cmd.split_whitespace()
1818 .next()
1819 .unwrap_or("unknown")
1820 .rsplit('/')
1821 .next()
1822 .unwrap_or("unknown")
1823}
1824
1825pub(crate) fn json_escape_string_value(s: &str) -> String {
1836 let mut out = String::with_capacity(s.len() + 2);
1837 for ch in s.chars() {
1838 match ch {
1839 '\\' => out.push_str("\\\\"),
1840 '"' => out.push_str("\\\""),
1841 '\n' => out.push_str("\\n"),
1842 '\r' => out.push_str("\\r"),
1843 '\t' => out.push_str("\\t"),
1844 '\x08' => out.push_str("\\b"),
1845 '\x0c' => out.push_str("\\f"),
1846 c if (c as u32) < 0x20 => {
1847 out.push_str(&format!("\\u{:04x}", c as u32));
1849 }
1850 c => out.push(c),
1851 }
1852 }
1853 out
1854}
1855
1856fn shell_escape(s: &str) -> String {
1858 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1859 s.to_string()
1860 } else {
1861 format!("'{}'", s.replace('\'', "'\\''"))
1862 }
1863}
1864
1865pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1869 cmd.contains("&&")
1872 || cmd.contains("||")
1873 || cmd.contains(';')
1874 || cmd.contains('>')
1875 || cmd.contains('<')
1876 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1882
1883fn is_interactive_command(cmd: &str) -> bool {
1885 let base = extract_base_command(cmd);
1886 matches!(
1887 base,
1888 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1889 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1890 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1891 ) || cmd.contains("--watch")
1892 || cmd.contains("-w ")
1893 || cmd.ends_with(" -w")
1894 || cmd.contains("run dev")
1895 || cmd.contains("run start")
1896 || cmd.contains("run serve")
1897}
1898
1899#[cfg(test)]
1902mod tests {
1903 use super::*;
1904
1905 #[test]
1906 fn test_copilot_hook_rewrites_bash_with_modified_args() {
1907 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
1908 let result = process_hook_copilot(input).unwrap();
1909 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1910 assert_eq!(parsed["permissionDecision"], "allow");
1911 let args = &parsed["modifiedArgs"];
1912 assert!(args["command"].as_str().unwrap().contains("sqz compress"));
1913 assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
1914 }
1915
1916 #[test]
1917 fn test_copilot_hook_passes_non_shell_through() {
1918 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
1919 let result = process_hook_copilot(input).unwrap();
1920 assert_eq!(result, "{}", "non-shell tools fall through with empty object");
1921 }
1922
1923 #[test]
1924 fn test_copilot_hook_skips_risky_shell_operators() {
1925 let input = r#"{"toolName":"bash","toolArgs":{"command":"make && make install"}}"#;
1926 let result = process_hook_copilot(input).unwrap();
1927 assert_eq!(result, "{}", "compound commands must not be rewritten");
1928 }
1929
1930 #[test]
1931 fn test_claude_updated_input_preserves_sibling_fields() {
1932 let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test","run_in_background":true,"timeout":120000,"description":"Run tests"}}"#;
1935 let result = process_hook(input).unwrap();
1936 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1937 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
1938 assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
1939 assert_eq!(updated["run_in_background"], true);
1940 assert_eq!(updated["timeout"], 120000);
1941 assert_eq!(updated["description"], "Run tests");
1942 }
1943
1944 #[test]
1945 fn test_process_hook_rewrites_bash_command() {
1946 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1948 let result = process_hook(input).unwrap();
1949 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1950 let hook_output = &parsed["hookSpecificOutput"];
1952 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1953 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1954 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1956 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1957 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1958 assert!(
1961 cmd.contains("--cmd 'git status'"),
1962 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1963 );
1964 assert!(
1965 !cmd.contains("SQZ_CMD="),
1966 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1967 );
1968 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1970 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1971 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1972 }
1973
1974 #[test]
1975 fn test_process_hook_passes_through_non_bash() {
1976 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1977 let result = process_hook(input).unwrap();
1978 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1979 }
1980
1981 #[test]
1982 fn test_process_hook_rewrites_powershell_command() {
1983 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1984 let result = process_hook(input).unwrap();
1985 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1986 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1987 .as_str()
1988 .expect("PowerShell tool calls should be rewritten");
1989 assert!(
1990 cmd.contains("Get-Content big.log"),
1991 "should preserve original PowerShell command: {cmd}"
1992 );
1993 assert!(
1994 cmd.contains("sqz compress"),
1995 "should pipe through sqz compress: {cmd}"
1996 );
1997 assert!(
1998 cmd.contains("--cmd 'Get-Content big.log'"),
1999 "should pass full command via --cmd: {cmd}"
2000 );
2001 }
2002
2003 #[test]
2004 fn test_process_hook_rewrites_pwsh_command() {
2005 let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
2006 let result = process_hook(input).unwrap();
2007 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2008 assert!(
2009 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
2010 "pwsh tool calls should be rewritten"
2011 );
2012 }
2013
2014 #[test]
2015 fn bash_rewrite_carries_exit_status_trailer() {
2016 let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test"}}"#;
2017 let result = process_hook(input).unwrap();
2018 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2019 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2020 .as_str()
2021 .unwrap();
2022 assert_eq!(
2023 cmd,
2024 "{ cargo test 2>&1; printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } | sqz compress --cmd 'cargo test'",
2025 "POSIX rewrite must send the command's exit status through the pipe"
2026 );
2027 }
2028
2029 #[test]
2030 fn powershell_rewrite_uses_lastexitcode_not_printf() {
2031 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"cargo test"}}"#;
2032 let result = process_hook(input).unwrap();
2033 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2034 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2035 .as_str()
2036 .unwrap();
2037 assert!(cmd.contains("$LASTEXITCODE"), "PowerShell form uses $LASTEXITCODE: {cmd}");
2038 assert!(cmd.contains("__SQZ_EXIT_"), "PowerShell form emits the trailer: {cmd}");
2039 assert!(!cmd.contains("printf"), "printf is not valid PowerShell: {cmd}");
2040 }
2041
2042 #[test]
2043 fn exit_trailer_rewrite_is_not_rewrapped() {
2044 let first = process_hook(
2047 r#"{"tool_name":"Bash","tool_input":{"command":"git log"}}"#,
2048 )
2049 .unwrap();
2050 let parsed: serde_json::Value = serde_json::from_str(&first).unwrap();
2051 let rewritten = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2052 .as_str()
2053 .unwrap();
2054 let second_input = serde_json::json!({
2055 "tool_name": "Bash",
2056 "tool_input": { "command": rewritten }
2057 })
2058 .to_string();
2059 let second = process_hook(&second_input).unwrap();
2060 assert_eq!(
2061 second, second_input,
2062 "already-rewritten commands must pass through unchanged"
2063 );
2064 }
2065
2066 #[test]
2067 fn test_process_hook_skips_sqz_commands() {
2068 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
2069 let result = process_hook(input).unwrap();
2070 assert_eq!(result, input, "sqz commands should not be double-wrapped");
2071 }
2072
2073 #[test]
2074 fn test_process_hook_skips_interactive() {
2075 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
2076 let result = process_hook(input).unwrap();
2077 assert_eq!(result, input, "interactive commands should pass through");
2078 }
2079
2080 #[test]
2081 fn test_process_hook_skips_watch_mode() {
2082 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
2083 let result = process_hook(input).unwrap();
2084 assert_eq!(result, input, "watch mode should pass through");
2085 }
2086
2087 #[test]
2088 fn test_process_hook_empty_command() {
2089 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
2090 let result = process_hook(input).unwrap();
2091 assert_eq!(result, input);
2092 }
2093
2094 #[test]
2095 fn test_process_hook_gemini_format() {
2096 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
2098 let result = process_hook_gemini(input).unwrap();
2099 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2100 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
2102 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
2104 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
2105 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
2107 "Gemini format should not have updatedInput");
2108 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
2109 "Gemini format should not have permissionDecision");
2110 }
2111
2112 #[test]
2113 fn test_process_hook_legacy_format() {
2114 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
2116 let result = process_hook(input).unwrap();
2117 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2118 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2119 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
2120 }
2121
2122 #[test]
2123 fn test_process_hook_cursor_format() {
2124 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
2126 let result = process_hook_cursor(input).unwrap();
2127 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2128 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
2130 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
2131 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
2132 assert!(cmd.contains("git status"));
2133 assert!(parsed.get("hookSpecificOutput").is_none(),
2135 "Cursor format should not have hookSpecificOutput");
2136 }
2137
2138 #[test]
2139 fn test_process_hook_cursor_passthrough_returns_empty_json() {
2140 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
2142 let result = process_hook_cursor(input).unwrap();
2143 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
2144 }
2145
2146 #[test]
2147 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
2148 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
2150 let result = process_hook_cursor(input).unwrap();
2151 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
2152 }
2153
2154 #[test]
2155 fn test_process_hook_windsurf_format() {
2156 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
2158 let result = process_hook_windsurf(input).unwrap();
2159 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2160 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
2162 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
2163 assert!(cmd.contains("cargo test"));
2164 assert!(
2166 cmd.contains("--cmd 'cargo test'"),
2167 "full command must be passed via --cmd flag: {cmd}"
2168 );
2169 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
2170 }
2171
2172 #[test]
2173 fn test_process_hook_invalid_json() {
2174 let result = process_hook("not json");
2175 assert!(result.is_err());
2176 }
2177
2178 #[test]
2179 fn test_extract_base_command() {
2180 assert_eq!(extract_base_command("git status"), "git");
2181 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
2182 assert_eq!(extract_base_command("cargo test --release"), "cargo");
2183 }
2184
2185 #[test]
2186 fn test_is_interactive_command() {
2187 assert!(is_interactive_command("vim file.txt"));
2188 assert!(is_interactive_command("npm run dev --watch"));
2189 assert!(is_interactive_command("python3"));
2190 assert!(!is_interactive_command("git status"));
2191 assert!(!is_interactive_command("cargo test"));
2192 }
2193
2194 #[test]
2209 fn issue_10_rewrite_is_shell_neutral() {
2210 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
2211 let result = process_hook(input).unwrap();
2212 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2213 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
2214 .as_str()
2215 .unwrap();
2216
2217 assert!(
2219 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
2220 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
2221 );
2222 assert!(
2224 !cmd.contains("SQZ_CMD="),
2225 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
2226 (broken in PowerShell and cmd.exe), got: {cmd}"
2227 );
2228 assert!(
2230 cmd.contains("dotnet build NewNeonCheckers3.sln"),
2231 "original command must be preserved verbatim: {cmd}"
2232 );
2233 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
2235 }
2236
2237 #[test]
2245 fn issue_10_already_wrapped_command_passes_through() {
2246 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
2247 let result = process_hook(input).unwrap();
2248 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2249 assert_eq!(
2252 result, input,
2253 "already-wrapped command must pass through unchanged; \
2254 otherwise each pass accumulates another `| sqz compress` tail"
2255 );
2256 let _ = parsed; }
2261
2262 #[test]
2263 fn test_generate_hook_configs() {
2264 let configs = generate_hook_configs("sqz");
2265 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
2266 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
2267 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
2268 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
2269 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
2272 assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
2273 "Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
2274 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
2275 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
2276 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
2277 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2281 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
2282 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
2283 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
2284 assert!(cursor.config_content.starts_with("---"),
2285 "Cursor rule should start with YAML frontmatter");
2286 assert!(cursor.config_content.contains("alwaysApply: true"),
2287 "Cursor rule should use alwaysApply: true so the guidance loads \
2288 for every agent interaction");
2289 assert!(cursor.config_content.contains("sqz"),
2290 "Cursor rule body should mention sqz");
2291 }
2292
2293 #[test]
2294 fn test_claude_config_includes_precompact_hook() {
2295 let configs = generate_hook_configs("sqz");
2300 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2301 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2302 .expect("Claude Code config must be valid JSON");
2303
2304 let precompact = parsed["hooks"]["PreCompact"]
2305 .as_array()
2306 .expect("PreCompact hook array must be present");
2307 assert!(
2308 !precompact.is_empty(),
2309 "PreCompact must have at least one registered hook"
2310 );
2311
2312 let cmd = precompact[0]["hooks"][0]["command"]
2313 .as_str()
2314 .expect("command field must be a string");
2315 assert!(
2316 cmd.ends_with(" hook precompact"),
2317 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
2318 );
2319 }
2320
2321 #[test]
2324 fn test_json_escape_string_value() {
2325 assert_eq!(json_escape_string_value("sqz"), "sqz");
2327 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
2328 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
2330 r"C:\\Users\\Alice\\sqz.exe");
2331 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
2333 r#"path with \"quotes\""#);
2334 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
2336 }
2337
2338 #[test]
2339 fn test_windows_path_produces_valid_json_for_claude() {
2340 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2343 let configs = generate_hook_configs(windows_path);
2344
2345 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
2346 .expect("Claude config should be generated");
2347 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2348 .expect("Claude hook config must be valid JSON on Windows paths");
2349
2350 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
2352 .as_str()
2353 .expect("command field must be a string");
2354 assert!(cmd.contains(windows_path),
2355 "command '{cmd}' must contain the original Windows path '{windows_path}'");
2356 }
2357
2358 #[test]
2359 fn test_windows_path_in_cursor_rules_file() {
2360 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2366 let configs = generate_hook_configs(windows_path);
2367
2368 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
2369 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
2370 assert!(cursor.config_content.contains(windows_path),
2371 "Cursor rule must contain the raw (unescaped) path so users can \
2372 copy-paste the shown commands — got:\n{}", cursor.config_content);
2373 assert!(!cursor.config_content.contains(r"C:\\Users"),
2374 "Cursor rule must NOT double-escape backslashes in markdown — \
2375 got:\n{}", cursor.config_content);
2376 }
2377
2378 #[test]
2379 fn test_windows_path_produces_valid_json_for_gemini() {
2380 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2381 let configs = generate_hook_configs(windows_path);
2382
2383 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
2384 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
2385 .expect("Gemini hook config must be valid JSON on Windows paths");
2386 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
2387 assert!(cmd.contains(windows_path));
2388 }
2389
2390 #[test]
2391 fn test_rules_files_use_raw_path_for_readability() {
2392 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2396 let configs = generate_hook_configs(windows_path);
2397
2398 for tool in &["Windsurf", "Cline", "Cursor"] {
2399 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
2400 assert!(cfg.config_content.contains(windows_path),
2401 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
2402 cfg.config_content);
2403 assert!(!cfg.config_content.contains(r"C:\\Users"),
2404 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
2405 cfg.config_content);
2406 }
2407 }
2408
2409 #[test]
2410 fn test_unix_path_still_works() {
2411 let unix_path = "/usr/local/bin/sqz";
2414 let configs = generate_hook_configs(unix_path);
2415
2416 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2417 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2418 .expect("Unix path should produce valid JSON");
2419 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
2420 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
2421 }
2422
2423 #[test]
2424 fn test_shell_escape_simple() {
2425 assert_eq!(shell_escape("git"), "git");
2426 assert_eq!(shell_escape("cargo-test"), "cargo-test");
2427 }
2428
2429 #[test]
2430 fn test_shell_escape_special_chars() {
2431 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
2432 }
2433
2434 #[test]
2435 fn test_install_tool_hooks_creates_files() {
2436 let dir = tempfile::tempdir().unwrap();
2437 let installed = install_tool_hooks(dir.path(), "sqz");
2438 assert!(!installed.is_empty(), "should install at least one hook config");
2440 for name in &installed {
2444 if name == "Copilot CLI" {
2445 continue;
2446 }
2447 let configs = generate_hook_configs("sqz");
2448 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
2449 let path = dir.path().join(&config.config_path);
2450 assert!(path.exists(), "hook config should exist: {}", path.display());
2451 }
2452 }
2453
2454 #[test]
2455 fn test_install_tool_hooks_does_not_overwrite() {
2456 let dir = tempfile::tempdir().unwrap();
2457 install_tool_hooks(dir.path(), "sqz");
2459 let custom_path = dir.path().join(".claude/settings.local.json");
2463 std::fs::write(&custom_path, "custom content").unwrap();
2464 install_tool_hooks(dir.path(), "sqz");
2466 let content = std::fs::read_to_string(&custom_path).unwrap();
2467 assert_eq!(content, "custom content", "should not overwrite existing config");
2468 }
2469
2470 #[test]
2477 fn test_reinit_upgrades_stale_project_scope_matcher() {
2478 let dir = tempfile::tempdir().unwrap();
2479 let settings_path = dir.path().join(".claude").join("settings.local.json");
2480 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
2481
2482 let stale = serde_json::json!({
2485 "permissions": { "allow": ["Bash(ls:*)"] },
2486 "hooks": {
2487 "PreToolUse": [
2488 {
2489 "matcher": "Bash",
2490 "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
2491 },
2492 {
2493 "matcher": "Edit",
2494 "hooks": [{ "type": "command", "command": "my-linter --check" }]
2495 }
2496 ]
2497 }
2498 });
2499 std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
2500
2501 install_tool_hooks(dir.path(), "/new/path/sqz");
2502
2503 let content = std::fs::read_to_string(&settings_path).unwrap();
2504 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2505
2506 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2508 let sqz_entries: Vec<_> = pre
2509 .iter()
2510 .filter(|e| {
2511 e["hooks"][0]["command"]
2512 .as_str()
2513 .is_some_and(|c| c.contains("hook claude"))
2514 })
2515 .collect();
2516 assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
2517 assert_eq!(
2518 sqz_entries[0]["matcher"].as_str().unwrap(),
2519 "Bash|PowerShell",
2520 "stale Bash-only matcher must be upgraded"
2521 );
2522 assert!(
2523 sqz_entries[0]["hooks"][0]["command"]
2524 .as_str()
2525 .unwrap()
2526 .contains("/new/path/sqz"),
2527 "binary path must be refreshed"
2528 );
2529
2530 assert!(
2532 pre.iter().any(|e| e["matcher"] == "Edit"),
2533 "user's non-sqz PreToolUse entry must be preserved"
2534 );
2535 assert_eq!(
2536 parsed["permissions"]["allow"][0].as_str().unwrap(),
2537 "Bash(ls:*)",
2538 "user's permissions must be preserved"
2539 );
2540
2541 assert!(parsed["hooks"]["PreCompact"].is_array());
2543 assert!(parsed["hooks"]["SessionStart"].is_array());
2544 }
2545
2546 #[test]
2547 fn test_windsurf_installs_to_rules_directory() {
2548 let dir = tempfile::tempdir().unwrap();
2549 install_tool_hooks(dir.path(), "sqz");
2550 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2551 assert!(!dir.path().join(".windsurfrules").exists());
2552 }
2553
2554 #[test]
2555 fn test_windsurf_migrates_legacy_sqz_rules_file() {
2556 let dir = tempfile::tempdir().unwrap();
2557 std::fs::write(
2558 dir.path().join(".windsurfrules"),
2559 "# sqz — Token-Optimized CLI Output\n\nold content\n",
2560 )
2561 .unwrap();
2562 install_tool_hooks(dir.path(), "sqz");
2563 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2564 assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
2565 }
2566
2567 #[test]
2568 fn test_windsurf_leaves_user_legacy_rules_alone() {
2569 let dir = tempfile::tempdir().unwrap();
2570 std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
2571 install_tool_hooks(dir.path(), "sqz");
2572 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
2573 assert_eq!(
2574 std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
2575 "my own rules\n"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_cline_installs_into_rules_directory_when_present() {
2581 let dir = tempfile::tempdir().unwrap();
2582 std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
2583 std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
2584 install_tool_hooks(dir.path(), "sqz");
2585 assert!(dir.path().join(".clinerules/sqz.md").exists());
2586 assert!(dir.path().join(".clinerules/mine.md").exists());
2587 }
2588
2589 #[test]
2590 fn test_cline_single_file_untouched_when_user_authored() {
2591 let dir = tempfile::tempdir().unwrap();
2592 std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
2593 install_tool_hooks(dir.path(), "sqz");
2594 assert_eq!(
2595 std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
2596 "user rules\n"
2597 );
2598 }
2599
2600 #[test]
2602 fn test_reinit_is_idempotent_at_project_scope() {
2603 let dir = tempfile::tempdir().unwrap();
2604 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2605 let settings_path = dir.path().join(".claude").join("settings.local.json");
2606 let first = std::fs::read_to_string(&settings_path).unwrap();
2607
2608 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2609 let second = std::fs::read_to_string(&settings_path).unwrap();
2610
2611 let a: serde_json::Value = serde_json::from_str(&first).unwrap();
2612 let b: serde_json::Value = serde_json::from_str(&second).unwrap();
2613 assert_eq!(a, b, "second init must not change project settings");
2614 assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
2616 }
2617}
2618
2619#[cfg(test)]
2620mod global_install_tests {
2621 use super::*;
2622
2623 #[test]
2624 fn global_install_creates_fresh_settings_json() {
2625 let tmp = tempfile::tempdir().unwrap();
2626 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2627 assert!(changed, "first install should report a change");
2628
2629 let path = tmp.path().join(".claude").join("settings.json");
2630 assert!(path.exists(), "user settings.json should be created");
2631
2632 let content = std::fs::read_to_string(&path).unwrap();
2633 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2634
2635 let pre = &parsed["hooks"]["PreToolUse"];
2637 assert!(pre.is_array(), "PreToolUse should be an array");
2638 assert_eq!(pre.as_array().unwrap().len(), 1);
2639 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2640 assert!(
2641 cmd.contains("/usr/local/bin/sqz"),
2642 "hook command should use the passed sqz_path, got: {cmd}"
2643 );
2644 assert!(cmd.contains("hook claude"));
2645
2646 let precompact = &parsed["hooks"]["PreCompact"];
2647 assert!(precompact.is_array());
2648 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
2649 assert!(precompact_cmd.contains("hook precompact"));
2650
2651 let session = &parsed["hooks"]["SessionStart"];
2652 assert!(session.is_array());
2653 assert_eq!(
2654 session[0]["matcher"].as_str().unwrap(),
2655 "compact",
2656 "SessionStart should only match /compact resume"
2657 );
2658 }
2659
2660 #[test]
2661 fn global_install_preserves_existing_user_config() {
2662 let tmp = tempfile::tempdir().unwrap();
2663 let settings = tmp.path().join(".claude").join("settings.json");
2664 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2665
2666 let existing = serde_json::json!({
2667 "permissions": {
2668 "allow": ["Bash(npm test *)"],
2669 "deny": ["Read(./.env)"]
2670 },
2671 "env": { "FOO": "bar" },
2672 "statusLine": {
2673 "type": "command",
2674 "command": "~/.claude/statusline.sh"
2675 },
2676 "hooks": {
2677 "PreToolUse": [
2678 {
2679 "matcher": "Edit",
2680 "hooks": [
2681 {
2682 "type": "command",
2683 "command": "~/.claude/hooks/format-on-edit.sh"
2684 }
2685 ]
2686 }
2687 ]
2688 }
2689 });
2690 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2691
2692 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2693 assert!(changed, "install should report a change on new hook");
2694
2695 let content = std::fs::read_to_string(&settings).unwrap();
2696 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2697
2698 assert_eq!(
2700 parsed["permissions"]["allow"][0].as_str().unwrap(),
2701 "Bash(npm test *)"
2702 );
2703 assert_eq!(
2704 parsed["permissions"]["deny"][0].as_str().unwrap(),
2705 "Read(./.env)"
2706 );
2707 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2709 assert_eq!(
2711 parsed["statusLine"]["command"].as_str().unwrap(),
2712 "~/.claude/statusline.sh"
2713 );
2714
2715 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2718 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2719 let matchers: Vec<&str> = pre
2720 .iter()
2721 .map(|e| e["matcher"].as_str().unwrap_or(""))
2722 .collect();
2723 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2724 assert!(
2725 matchers.contains(&"Bash|PowerShell"),
2726 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2727 );
2728 }
2729
2730 #[test]
2731 fn global_install_is_idempotent() {
2732 let tmp = tempfile::tempdir().unwrap();
2733 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2734 assert!(
2735 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2736 "second install with identical args should report no change"
2737 );
2738
2739 let path = tmp.path().join(".claude").join("settings.json");
2740 let parsed: serde_json::Value =
2741 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2742 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2743 let arr = parsed["hooks"][event].as_array().unwrap();
2744 assert_eq!(
2745 arr.len(),
2746 1,
2747 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2748 );
2749 }
2750 }
2751
2752 #[test]
2759 fn global_install_is_idempotent_with_exe_path() {
2760 let tmp = tempfile::tempdir().unwrap();
2761 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2762 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2763 assert!(
2764 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2765 "second install with .exe path should report no change (issue #21)"
2766 );
2767
2768 let path = tmp.path().join(".claude").join("settings.json");
2769 let parsed: serde_json::Value =
2770 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2771 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2772 let arr = parsed["hooks"][event].as_array().unwrap();
2773 assert_eq!(
2774 arr.len(),
2775 1,
2776 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2777 );
2778 }
2779 }
2780
2781 #[test]
2782 fn global_install_upgrades_stale_sqz_hook_in_place() {
2783 let tmp = tempfile::tempdir().unwrap();
2784 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2785 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2786 assert!(changed, "different sqz_path must be seen as a change");
2787
2788 let path = tmp.path().join(".claude").join("settings.json");
2789 let parsed: serde_json::Value =
2790 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2791 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2792 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2793 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2794 assert!(cmd.contains("/new/path/sqz"));
2795 assert!(!cmd.contains("/old/path/sqz"));
2796 }
2797
2798 #[test]
2799 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2800 let tmp = tempfile::tempdir().unwrap();
2801 let settings = tmp.path().join(".claude").join("settings.json");
2802 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2803 std::fs::write(
2804 &settings,
2805 serde_json::json!({
2806 "permissions": { "allow": ["Bash(git status)"] },
2807 "hooks": {
2808 "PreToolUse": [
2809 {
2810 "matcher": "Edit",
2811 "hooks": [
2812 { "type": "command", "command": "~/format.sh" }
2813 ]
2814 }
2815 ]
2816 }
2817 })
2818 .to_string(),
2819 )
2820 .unwrap();
2821
2822 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2823 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2824 assert_eq!(result.0, settings);
2825 assert!(result.1, "should report that the file was modified");
2826
2827 assert!(settings.exists(), "settings.json should be preserved");
2828 let parsed: serde_json::Value =
2829 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2830
2831 assert_eq!(
2832 parsed["permissions"]["allow"][0].as_str().unwrap(),
2833 "Bash(git status)"
2834 );
2835
2836 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2837 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2838 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2839
2840 assert!(parsed["hooks"].get("PreCompact").is_none());
2841 assert!(parsed["hooks"].get("SessionStart").is_none());
2842 }
2843
2844 #[test]
2845 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2846 let tmp = tempfile::tempdir().unwrap();
2847 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2848 let path = tmp.path().join(".claude").join("settings.json");
2849 assert!(path.exists(), "precondition: install created the file");
2850
2851 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2852 assert!(result.1);
2853 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2854 }
2855
2856 #[test]
2857 fn global_uninstall_on_missing_file_is_noop() {
2858 let tmp = tempfile::tempdir().unwrap();
2859 assert!(
2860 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2861 "missing file should return None, not error"
2862 );
2863 }
2864
2865 #[test]
2866 fn global_uninstall_refuses_to_touch_unparseable_file() {
2867 let tmp = tempfile::tempdir().unwrap();
2868 let settings = tmp.path().join(".claude").join("settings.json");
2869 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2870 std::fs::write(&settings, "{ invalid json because").unwrap();
2871
2872 assert!(
2873 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2874 "bad JSON must surface as an error"
2875 );
2876
2877 let after = std::fs::read_to_string(&settings).unwrap();
2878 assert_eq!(after, "{ invalid json because");
2879 }
2880}
2881
2882#[cfg(test)]
2883mod issue_11_tool_filter_tests {
2884 use super::*;
2893
2894 #[test]
2895 fn canonicalize_collapses_common_aliases() {
2896 for aliases in &[
2899 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2900 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2901 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2902 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2906 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2907 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2908 (vec!["Codex", "codex"], "codex"),
2909 ] {
2910 for alias in &aliases.0 {
2911 assert_eq!(
2912 canonicalize_tool_name(alias),
2913 aliases.1,
2914 "alias '{}' must canonicalise to '{}'",
2915 alias,
2916 aliases.1
2917 );
2918 }
2919 }
2920 }
2921
2922 #[test]
2923 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2924 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2929 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2930 }
2931
2932 #[test]
2933 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2934 let names = parse_tool_list("opencode,codex").unwrap();
2937 assert_eq!(names, vec!["opencode", "codex"]);
2938
2939 let names = parse_tool_list(" opencode , codex ").unwrap();
2940 assert_eq!(names, vec!["opencode", "codex"]);
2941
2942 let names = parse_tool_list("opencode").unwrap();
2944 assert_eq!(names, vec!["opencode"]);
2945
2946 let names = parse_tool_list("claude-code").unwrap();
2948 assert_eq!(names, vec!["claudecode"]);
2949 }
2950
2951 #[test]
2952 fn parse_tool_list_dedupes_repeated_entries() {
2953 let names = parse_tool_list("opencode,opencode").unwrap();
2958 assert_eq!(names, vec!["opencode"]);
2959
2960 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2963 assert_eq!(names, vec!["claudecode"]);
2964 }
2965
2966 #[test]
2967 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2968 let err = parse_tool_list("opncode").unwrap_err();
2975 let msg = err.to_string();
2976 assert!(
2977 msg.contains("unknown agent name 'opncode'"),
2978 "error must quote the bad input: {msg}"
2979 );
2980 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2981 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2982 }
2983
2984 #[test]
2985 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2986 let err = parse_tool_list("opencode,xyz").unwrap_err();
2991 assert!(err.to_string().contains("xyz"));
2992 }
2993
2994 #[test]
2995 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2996 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
3002 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
3003 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
3004 }
3005
3006 #[test]
3007 fn tool_filter_all_includes_every_supported_tool() {
3008 let filter = ToolFilter::All;
3009 for tool in SUPPORTED_TOOL_NAMES {
3010 assert!(
3011 filter.includes(tool),
3012 "default filter must include {tool}"
3013 );
3014 }
3015 }
3016
3017 #[test]
3018 fn tool_filter_only_opencode_excludes_everything_else() {
3019 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
3021 assert!(filter.includes("OpenCode"));
3022 for tool in SUPPORTED_TOOL_NAMES {
3024 if *tool == "OpenCode" {
3025 continue;
3026 }
3027 assert!(
3028 !filter.includes(tool),
3029 "--only opencode must not include {tool}"
3030 );
3031 }
3032 }
3033
3034 #[test]
3035 fn tool_filter_only_multi_tool_includes_exactly_those() {
3036 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
3037 assert!(filter.includes("OpenCode"));
3038 assert!(filter.includes("Codex"));
3039 assert!(!filter.includes("Claude Code"));
3041 assert!(!filter.includes("Cursor"));
3042 assert!(!filter.includes("Windsurf"));
3043 assert!(!filter.includes("Cline"));
3044 assert!(!filter.includes("Gemini CLI"));
3045 }
3046
3047 #[test]
3048 fn tool_filter_skip_inverts_the_set() {
3049 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
3052 assert!(!filter.includes("Cursor"));
3053 assert!(!filter.includes("Windsurf"));
3054 assert!(filter.includes("Claude Code"));
3056 assert!(filter.includes("Cline"));
3057 assert!(filter.includes("Gemini CLI"));
3058 assert!(filter.includes("OpenCode"));
3059 assert!(filter.includes("Codex"));
3060 }
3061
3062 #[test]
3063 fn tool_filter_only_empty_excludes_everything() {
3064 let filter = ToolFilter::Only(vec![]);
3070 for tool in SUPPORTED_TOOL_NAMES {
3071 assert!(
3072 !filter.includes(tool),
3073 "empty --only must exclude every tool, got {tool}"
3074 );
3075 }
3076 }
3077
3078 #[test]
3079 fn tool_filter_only_accepts_display_name_or_canonical() {
3080 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
3086 assert!(filter.includes("Claude Code"));
3087 assert!(!filter.includes("Cursor"));
3088
3089 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
3090 assert!(filter.includes("Gemini CLI"));
3091 }
3092
3093 #[test]
3094 fn supported_tool_names_matches_generate_hook_configs_exactly() {
3095 let configs = generate_hook_configs("sqz");
3101 let emitted: std::collections::HashSet<&str> =
3102 configs.iter().map(|c| c.tool_name.as_str()).collect();
3103 let declared: std::collections::HashSet<&str> =
3104 SUPPORTED_TOOL_NAMES.iter().copied().collect();
3105 assert_eq!(
3106 emitted, declared,
3107 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
3108 from generate_hook_configs. emitted={:?}, declared={:?}",
3109 emitted, declared
3110 );
3111 }
3112
3113 #[test]
3114 fn filtered_install_only_opencode_writes_only_opencode_files() {
3115 let dir = tempfile::tempdir().unwrap();
3121 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
3122 let _installed = install_tool_hooks_scoped_filtered(
3123 dir.path(),
3124 "sqz",
3125 InstallScope::Project,
3126 &filter,
3127 );
3128
3129 assert!(
3131 dir.path().join("opencode.json").exists(),
3132 "OpenCode config must be written when --only opencode is used"
3133 );
3134
3135 for (path, tool) in &[
3137 (".claude/settings.local.json", "Claude Code"),
3138 (".cursor/rules/sqz.mdc", "Cursor"),
3139 (".windsurfrules", "Windsurf"),
3140 (".clinerules", "Cline"),
3141 (".gemini/settings.json", "Gemini CLI"),
3142 ("AGENTS.md", "Codex"),
3143 ] {
3144 assert!(
3145 !dir.path().join(path).exists(),
3146 "filter rejected {tool} but the installer still wrote {path}"
3147 );
3148 }
3149 }
3150
3151 #[test]
3152 fn filtered_install_skip_cursor_omits_only_cursor() {
3153 let dir = tempfile::tempdir().unwrap();
3155 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
3156 let _installed = install_tool_hooks_scoped_filtered(
3157 dir.path(),
3158 "sqz",
3159 InstallScope::Project,
3160 &filter,
3161 );
3162
3163 assert!(
3165 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
3166 "skip cursor: .cursor/rules/sqz.mdc must not be written"
3167 );
3168 assert!(
3170 dir.path().join(".windsurf/rules/sqz.md").exists(),
3171 "skip cursor should not skip windsurf"
3172 );
3173 assert!(
3174 dir.path().join(".clinerules").exists(),
3175 "skip cursor should not skip cline"
3176 );
3177 }
3178}
3179
3180#[cfg(test)]
3181mod ci_hook_tests {
3182 use super::*;
3183
3184 #[test]
3185 fn ci_hook_content_is_valid_v1_config() {
3186 let parsed: serde_json::Value =
3187 serde_json::from_str(&ci_hook_content()).expect("CI hook must be valid JSON");
3188 assert_eq!(parsed["version"], 1);
3189 let session_start = parsed["hooks"]["sessionStart"].as_array().unwrap();
3190 assert!(session_start[0]["bash"]
3191 .as_str()
3192 .unwrap()
3193 .contains("install.sh"));
3194 let pre = parsed["hooks"]["preToolUse"].as_array().unwrap();
3195 let bash = pre[0]["bash"].as_str().unwrap();
3196 assert!(bash.contains("hook copilot"));
3197 assert!(bash.ends_with("echo '{}'"));
3200 assert_eq!(pre[0]["matcher"], "bash|powershell");
3201 }
3202
3203 #[test]
3204 fn install_ci_hook_creates_then_noops() {
3205 let dir = tempfile::tempdir().unwrap();
3206 assert!(install_ci_hook(dir.path()).unwrap());
3207 let path = ci_hooks_path(dir.path());
3208 assert!(path.exists());
3209 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
3210 assert!(!install_ci_hook(dir.path()).unwrap());
3212 }
3213
3214 #[test]
3215 fn install_ci_hook_refreshes_stale_sqz_file() {
3216 let dir = tempfile::tempdir().unwrap();
3217 let path = ci_hooks_path(dir.path());
3218 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3219 std::fs::write(&path, r#"{"version":1,"hooks":{"preToolUse":[{"bash":"sqz hook copilot"}]}}"#).unwrap();
3220 assert!(install_ci_hook(dir.path()).unwrap());
3221 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
3222 }
3223
3224 #[test]
3225 fn install_ci_hook_leaves_user_file_alone() {
3226 let dir = tempfile::tempdir().unwrap();
3227 let path = ci_hooks_path(dir.path());
3228 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3229 std::fs::write(&path, r#"{"version":1,"hooks":{}}"#).unwrap();
3230 assert!(!install_ci_hook(dir.path()).unwrap());
3231 assert_eq!(
3232 std::fs::read_to_string(&path).unwrap(),
3233 r#"{"version":1,"hooks":{}}"#
3234 );
3235 }
3236
3237 #[test]
3238 fn remove_ci_hook_only_removes_sqz_authored() {
3239 let dir = tempfile::tempdir().unwrap();
3240 assert!(!remove_ci_hook(dir.path()).unwrap());
3242 install_ci_hook(dir.path()).unwrap();
3244 assert!(remove_ci_hook(dir.path()).unwrap());
3245 assert!(!ci_hooks_path(dir.path()).exists());
3246 let path = ci_hooks_path(dir.path());
3248 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3249 std::fs::write(&path, "{}").unwrap();
3250 assert!(!remove_ci_hook(dir.path()).unwrap());
3251 assert!(path.exists());
3252 }
3253}
3254