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}
64
65pub fn process_hook(input: &str) -> Result<String> {
84 process_hook_for_platform(input, HookPlatform::ClaudeCode)
85}
86
87pub fn process_hook_cursor(input: &str) -> Result<String> {
92 process_hook_for_platform(input, HookPlatform::Cursor)
93}
94
95pub fn process_hook_gemini(input: &str) -> Result<String> {
99 process_hook_for_platform(input, HookPlatform::GeminiCli)
100}
101
102pub fn process_hook_windsurf(input: &str) -> Result<String> {
107 process_hook_for_platform(input, HookPlatform::Windsurf)
108}
109
110fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
114 let parsed: serde_json::Value = serde_json::from_str(input)
115 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
116
117 let tool_name = parsed
121 .get("tool_name")
122 .or_else(|| parsed.get("toolName"))
123 .and_then(|v| v.as_str())
124 .unwrap_or("");
125
126 let hook_event = parsed
127 .get("hook_event_name")
128 .or_else(|| parsed.get("agent_action_name"))
129 .and_then(|v| v.as_str())
130 .unwrap_or("");
131
132 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
141 | "run_terminal_command" | "run_shell_command")
142 || matches!(hook_event, "beforeShellExecution" | "pre_run_command");
143
144 if !is_shell {
145 return Ok(match platform {
148 HookPlatform::Cursor => "{}".to_string(),
149 _ => input.to_string(),
150 });
151 }
152
153 let command = parsed
158 .get("tool_input")
159 .and_then(|v| v.get("command"))
160 .and_then(|v| v.as_str())
161 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
162 .or_else(|| {
163 parsed
164 .get("tool_info")
165 .and_then(|v| v.get("command_line"))
166 .and_then(|v| v.as_str())
167 })
168 .or_else(|| {
169 parsed
170 .get("toolCall")
171 .and_then(|v| v.get("command"))
172 .and_then(|v| v.as_str())
173 })
174 .unwrap_or("");
175
176 if command.is_empty() {
177 return Ok(match platform {
178 HookPlatform::Cursor => "{}".to_string(),
179 _ => input.to_string(),
180 });
181 }
182
183 let base_cmd = extract_base_command(command);
195 if base_cmd == "sqz"
196 || command.starts_with("SQZ_CMD=")
197 || command.contains("sqz compress --cmd ")
198 || command.contains("sqz.exe compress --cmd ")
199 {
200 return Ok(match platform {
201 HookPlatform::Cursor => "{}".to_string(),
202 _ => input.to_string(),
203 });
204 }
205
206 if is_interactive_command(command) {
208 return Ok(match platform {
209 HookPlatform::Cursor => "{}".to_string(),
210 _ => input.to_string(),
211 });
212 }
213
214 if has_shell_operators(command) {
219 return Ok(match platform {
220 HookPlatform::Cursor => "{}".to_string(),
221 _ => input.to_string(),
222 });
223 }
224
225 let rewritten = format!(
235 "{} 2>&1 | sqz compress --cmd {}",
236 command,
237 shell_escape(extract_base_command(command)),
238 );
239
240 let output = match platform {
266 HookPlatform::ClaudeCode => serde_json::json!({
267 "hookSpecificOutput": {
268 "hookEventName": "PreToolUse",
269 "permissionDecision": "allow",
270 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
271 "updatedInput": {
272 "command": rewritten
273 }
274 }
275 }),
276 HookPlatform::Cursor => serde_json::json!({
277 "permission": "allow",
278 "updated_input": {
279 "command": rewritten
280 }
281 }),
282 HookPlatform::GeminiCli => serde_json::json!({
283 "decision": "allow",
284 "hookSpecificOutput": {
285 "tool_input": {
286 "command": rewritten
287 }
288 }
289 }),
290 HookPlatform::Windsurf => {
291 serde_json::json!({
295 "hookSpecificOutput": {
296 "hookEventName": "PreToolUse",
297 "permissionDecision": "allow",
298 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
299 "updatedInput": {
300 "command": rewritten
301 }
302 }
303 })
304 }
305 };
306
307 serde_json::to_string(&output)
308 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
309}
310
311pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
313 let sqz_path_raw = sqz_path;
324 let sqz_path_json = json_escape_string_value(sqz_path);
325 let sqz_path = &sqz_path_json;
326
327 let escape_hatch = format!(
335 r#"## Escape hatch — when sqz output confuses the agent
336
337If you see a `§ref:HASH§` token in tool output and can't parse it, or if
338compressed output is making it harder to work (loops, repeated tiny
339requests), you have three ways out:
340
341- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
342 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
343 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
344- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
345 status 2>&1 | sqz compress`) to disable dedup for that one command.
346 Strictly more tokens, strictly less ambiguous.
347- **`--no-cache`** — the same opt-out as a CLI flag:
348 `git status 2>&1 | sqz compress --no-cache`.
349
350Use these when you genuinely need raw data. The default compressed
351path is still what you want for most output."#,
352 );
353 let escape_hatch = &escape_hatch;
354
355 vec![
356 ToolHookConfig {
370 tool_name: "Claude Code".to_string(),
371 config_path: PathBuf::from(".claude/settings.local.json"),
372 config_content: format!(
373 r#"{{
374 "hooks": {{
375 "PreToolUse": [
376 {{
377 "matcher": "Bash",
378 "hooks": [
379 {{
380 "type": "command",
381 "command": "{sqz_path} hook claude"
382 }}
383 ]
384 }}
385 ],
386 "PreCompact": [
387 {{
388 "hooks": [
389 {{
390 "type": "command",
391 "command": "{sqz_path} hook precompact"
392 }}
393 ]
394 }}
395 ],
396 "SessionStart": [
397 {{
398 "matcher": "compact",
399 "hooks": [
400 {{
401 "type": "command",
402 "command": "{sqz_path} resume"
403 }}
404 ]
405 }}
406 ]
407 }}
408}}"#
409 ),
410 scope: HookScope::Project,
411 },
412 ToolHookConfig {
421 tool_name: "Cursor".to_string(),
422 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
423 config_content: format!(
424 r#"---
425description: sqz — Token-Optimized CLI Output
426alwaysApply: true
427---
428
429# sqz — Token-Optimized CLI Output
430
431When running shell commands whose output may be long (directory listings,
432git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
433pipe the output through `sqz compress` to reduce token consumption.
434
435`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
436usage is to pipe the command's output into `sqz compress`:
437
438```bash
439# Instead of: Use:
440git status git status 2>&1 | {sqz_path_raw} compress
441cargo test cargo test 2>&1 | {sqz_path_raw} compress
442git log -10 git log -10 2>&1 | {sqz_path_raw} compress
443docker ps docker ps 2>&1 | {sqz_path_raw} compress
444ls -la ls -la 2>&1 | {sqz_path_raw} compress
445```
446
447The `2>&1` captures stderr too, which is useful for commands like `cargo
448test` where diagnostics go to stderr. `sqz compress` filters and compresses
449the combined output while preserving filenames, paths, and identifiers.
450It typically saves 60-90% tokens on verbose commands.
451
452Do NOT pipe output for:
453- Interactive commands (`vim`, `ssh`, `python`, REPLs)
454- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
455 `cmd; other`) — run those directly
456- Short commands whose output is already a few lines
457
458If `sqz` is not on PATH, run commands normally.
459
460{escape_hatch}
461"#
462 ),
463 scope: HookScope::Project,
464 },
465 ToolHookConfig {
469 tool_name: "Windsurf".to_string(),
470 config_path: PathBuf::from(".windsurfrules"),
471 config_content: format!(
472 r#"# sqz — Token-Optimized CLI Output
473
474Pipe verbose shell command output through `sqz compress` to save tokens.
475`sqz` reads from stdin and writes the compressed output to stdout — it is
476NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
477
478```bash
479# Instead of: Use:
480git status git status 2>&1 | {sqz_path_raw} compress
481cargo test cargo test 2>&1 | {sqz_path_raw} compress
482git log -10 git log -10 2>&1 | {sqz_path_raw} compress
483docker ps docker ps 2>&1 | {sqz_path_raw} compress
484```
485
486sqz filters and compresses command outputs while preserving filenames,
487paths, and identifiers (typically 60-90% token reduction on verbose
488commands). Skip short commands, interactive commands (vim, ssh, python),
489and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
490not on PATH, run commands normally.
491
492{escape_hatch}
493"#
494 ),
495 scope: HookScope::Project,
496 },
497 ToolHookConfig {
501 tool_name: "Cline".to_string(),
502 config_path: PathBuf::from(".clinerules"),
503 config_content: format!(
504 r#"# sqz — Token-Optimized CLI Output
505
506Pipe verbose shell command output through `sqz compress` to save tokens.
507`sqz` reads from stdin and writes the compressed output to stdout — it is
508NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
509
510```bash
511# Instead of: Use:
512git status git status 2>&1 | {sqz_path_raw} compress
513cargo test cargo test 2>&1 | {sqz_path_raw} compress
514git log -10 git log -10 2>&1 | {sqz_path_raw} compress
515docker ps docker ps 2>&1 | {sqz_path_raw} compress
516```
517
518sqz filters and compresses command outputs while preserving filenames,
519paths, and identifiers (typically 60-90% token reduction on verbose
520commands). Skip short commands, interactive commands (vim, ssh, python),
521and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
522not on PATH, run commands normally.
523
524{escape_hatch}
525"#
526 ),
527 scope: HookScope::Project,
528 },
529 ToolHookConfig {
531 tool_name: "Gemini CLI".to_string(),
532 config_path: PathBuf::from(".gemini/settings.json"),
533 config_content: format!(
534 r#"{{
535 "hooks": {{
536 "BeforeTool": [
537 {{
538 "matcher": "run_shell_command",
539 "hooks": [
540 {{
541 "type": "command",
542 "command": "{sqz_path} hook gemini"
543 }}
544 ]
545 }}
546 ]
547 }}
548}}"#
549 ),
550 scope: HookScope::Project,
551 },
552 ToolHookConfig {
561 tool_name: "OpenCode".to_string(),
562 config_path: PathBuf::from("opencode.json"),
563 config_content: format!(
564 r#"{{
565 "$schema": "https://opencode.ai/config.json",
566 "mcp": {{
567 "sqz": {{
568 "type": "local",
569 "command": ["sqz-mcp", "--transport", "stdio"]
570 }}
571 }},
572 "plugin": ["sqz"]
573}}"#
574 ),
575 scope: HookScope::Project,
576 },
577 ToolHookConfig {
596 tool_name: "Codex".to_string(),
597 config_path: PathBuf::from("AGENTS.md"),
598 config_content: crate::codex_integration::agents_md_guidance_block(
599 sqz_path_raw,
600 ),
601 scope: HookScope::Project,
602 },
603 ]
604}
605
606pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
612 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub enum InstallScope {
640 Project,
643 Global,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq)]
661pub enum ToolFilter {
662 All,
665 Only(Vec<String>),
669 Skip(Vec<String>),
674}
675
676impl Default for ToolFilter {
677 fn default() -> Self {
678 ToolFilter::All
679 }
680}
681
682impl ToolFilter {
683 pub fn includes(&self, tool_name: &str) -> bool {
694 let canon = canonicalize_tool_name(tool_name);
695 match self {
696 ToolFilter::All => true,
697 ToolFilter::Only(allow) => allow.iter().any(|n| {
698 n == &canon
701 }),
702 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
703 }
704 }
705}
706
707pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
712 "Claude Code",
713 "Cursor",
714 "Windsurf",
715 "Cline",
716 "Gemini CLI",
717 "OpenCode",
718 "Codex",
719];
720
721pub fn canonicalize_tool_name(name: &str) -> String {
740 let lowered: String = name
741 .chars()
742 .filter(|c| !c.is_whitespace())
743 .flat_map(|c| c.to_lowercase())
744 .filter(|c| *c != '-' && *c != '_')
745 .collect();
746 match lowered.as_str() {
747 "claude" | "claudecode" => "claudecode".to_string(),
748 "cursor" => "cursor".to_string(),
749 "windsurf" => "windsurf".to_string(),
750 "cline" | "roo" | "roocode" => "cline".to_string(),
754 "gemini" | "geminicli" => "gemini".to_string(),
755 "opencode" => "opencode".to_string(),
756 "codex" => "codex".to_string(),
757 other => other.to_string(),
758 }
759}
760
761pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
771 let mut out = Vec::new();
772 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
773 .iter()
774 .map(|n| canonicalize_tool_name(n))
775 .collect();
776 for part in raw.split(',') {
777 let trimmed = part.trim();
778 if trimmed.is_empty() {
779 continue;
780 }
781 let canon = canonicalize_tool_name(trimmed);
782 if !known.contains(&canon) {
783 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
784 .iter()
785 .map(|n| canonicalize_tool_name(n))
786 .collect();
787 return Err(crate::error::SqzError::Other(format!(
788 "unknown agent name '{}'. Valid options: {}",
789 trimmed,
790 valid.join(", ")
791 )));
792 }
793 if !out.contains(&canon) {
794 out.push(canon);
795 }
796 }
797 Ok(out)
798}
799
800pub fn install_tool_hooks_scoped(
826 project_dir: &Path,
827 sqz_path: &str,
828 scope: InstallScope,
829) -> Vec<String> {
830 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
831}
832
833pub fn install_tool_hooks_scoped_filtered(
847 project_dir: &Path,
848 sqz_path: &str,
849 scope: InstallScope,
850 filter: &ToolFilter,
851) -> Vec<String> {
852 let configs = generate_hook_configs(sqz_path);
853 let mut installed = Vec::new();
854
855 for config in &configs {
856 if !filter.includes(&config.tool_name) {
860 continue;
861 }
862
863 if config.tool_name == "OpenCode" {
872 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
873 Ok((updated, _comments_lost)) => {
874 if updated && !installed.iter().any(|n| n == "OpenCode") {
875 installed.push("OpenCode".to_string());
876 }
877 }
878 Err(_e) => {
879 }
882 }
883 continue;
884 }
885
886 if config.tool_name == "Codex" {
891 let agents_changed = crate::codex_integration::install_agents_md_guidance(
892 project_dir, sqz_path,
893 )
894 .unwrap_or(false);
895 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
896 .unwrap_or(false);
897 if (agents_changed || mcp_changed)
898 && !installed.iter().any(|n| n == "Codex")
899 {
900 installed.push("Codex".to_string());
901 }
902 continue;
903 }
904
905 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
910 match install_claude_global(sqz_path) {
911 Ok(true) => installed.push("Claude Code".to_string()),
912 Ok(false) => { }
913 Err(_e) => {
914 }
916 }
917 continue;
918 }
919
920 let full_path = project_dir.join(&config.config_path);
921
922 if full_path.exists() {
924 continue;
925 }
926
927 if let Some(parent) = full_path.parent() {
929 if std::fs::create_dir_all(parent).is_err() {
930 continue;
931 }
932 }
933
934 if std::fs::write(&full_path, &config.config_content).is_ok() {
935 installed.push(config.tool_name.clone());
936 }
937 }
938
939 if filter.includes("OpenCode") {
950 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
951 if !installed.iter().any(|n| n == "OpenCode") {
952 installed.push("OpenCode".to_string());
953 }
954 }
955 }
956
957 installed
958}
959
960pub fn claude_user_settings_path() -> Option<PathBuf> {
973 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
974}
975
976fn install_claude_global(sqz_path: &str) -> Result<bool> {
991 let path = claude_user_settings_path().ok_or_else(|| {
992 crate::error::SqzError::Other(
993 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
994 )
995 })?;
996
997 let mut root: serde_json::Value = if path.exists() {
999 let content = std::fs::read_to_string(&path).map_err(|e| {
1000 crate::error::SqzError::Other(format!(
1001 "read {}: {e}",
1002 path.display()
1003 ))
1004 })?;
1005 if content.trim().is_empty() {
1006 serde_json::Value::Object(serde_json::Map::new())
1007 } else {
1008 serde_json::from_str(&content).map_err(|e| {
1009 crate::error::SqzError::Other(format!(
1010 "parse {}: {e} — please fix or move the file before re-running sqz init",
1011 path.display()
1012 ))
1013 })?
1014 }
1015 } else {
1016 serde_json::Value::Object(serde_json::Map::new())
1017 };
1018
1019 let root_obj = root.as_object_mut().ok_or_else(|| {
1022 crate::error::SqzError::Other(format!(
1023 "{} is not a JSON object — refusing to overwrite",
1024 path.display()
1025 ))
1026 })?;
1027
1028 let pre_tool_use = serde_json::json!({
1030 "matcher": "Bash",
1031 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1032 });
1033 let pre_compact = serde_json::json!({
1034 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1035 });
1036 let session_start = serde_json::json!({
1037 "matcher": "compact",
1038 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1039 });
1040
1041 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1043
1044 let hooks = root_obj
1046 .entry("hooks".to_string())
1047 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1048 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1049 crate::error::SqzError::Other(format!(
1050 "{}: `hooks` is not an object — refusing to overwrite",
1051 path.display()
1052 ))
1053 })?;
1054
1055 upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "sqz hook claude");
1056 upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "sqz hook precompact");
1057 upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "sqz resume");
1058
1059 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1060 if before == after && path.exists() {
1061 return Ok(false);
1063 }
1064
1065 if let Some(parent) = path.parent() {
1067 std::fs::create_dir_all(parent).map_err(|e| {
1068 crate::error::SqzError::Other(format!(
1069 "create {}: {e}",
1070 parent.display()
1071 ))
1072 })?;
1073 }
1074
1075 let parent = path.parent().ok_or_else(|| {
1079 crate::error::SqzError::Other(format!(
1080 "path {} has no parent directory",
1081 path.display()
1082 ))
1083 })?;
1084 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1085 crate::error::SqzError::Other(format!(
1086 "create temp file in {}: {e}",
1087 parent.display()
1088 ))
1089 })?;
1090 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1091 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1092 std::fs::write(tmp.path(), serialized).map_err(|e| {
1093 crate::error::SqzError::Other(format!(
1094 "write to temp file {}: {e}",
1095 tmp.path().display()
1096 ))
1097 })?;
1098 tmp.persist(&path).map_err(|e| {
1099 crate::error::SqzError::Other(format!(
1100 "rename temp file into place at {}: {e}",
1101 path.display()
1102 ))
1103 })?;
1104
1105 Ok(true)
1106}
1107
1108pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1122 let Some(path) = claude_user_settings_path() else {
1123 return Ok(None);
1124 };
1125 if !path.exists() {
1126 return Ok(None);
1127 }
1128
1129 let content = std::fs::read_to_string(&path).map_err(|e| {
1130 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1131 })?;
1132 if content.trim().is_empty() {
1133 return Ok(Some((path, false)));
1134 }
1135
1136 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1137 crate::error::SqzError::Other(format!(
1138 "parse {}: {e} — refusing to rewrite an unparseable file",
1139 path.display()
1140 ))
1141 })?;
1142 let Some(root_obj) = root.as_object_mut() else {
1143 return Ok(Some((path, false)));
1144 };
1145
1146 let mut changed = false;
1147 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1148 for (event, sentinel) in &[
1149 ("PreToolUse", "sqz hook claude"),
1150 ("PreCompact", "sqz hook precompact"),
1151 ("SessionStart", "sqz resume"),
1152 ] {
1153 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1154 let before = arr.len();
1155 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1156 if arr.len() != before {
1157 changed = true;
1158 }
1159 }
1160 }
1161
1162 hooks.retain(|_, v| match v {
1165 serde_json::Value::Array(a) => !a.is_empty(),
1166 _ => true,
1167 });
1168
1169 let hooks_empty = hooks.is_empty();
1172 if hooks_empty {
1173 root_obj.remove("hooks");
1174 changed = true;
1175 }
1176 }
1177
1178 if !changed {
1179 return Ok(Some((path, false)));
1180 }
1181
1182 if root_obj.is_empty() {
1186 std::fs::remove_file(&path).map_err(|e| {
1187 crate::error::SqzError::Other(format!(
1188 "remove {}: {e}",
1189 path.display()
1190 ))
1191 })?;
1192 return Ok(Some((path, true)));
1193 }
1194
1195 let parent = path.parent().ok_or_else(|| {
1197 crate::error::SqzError::Other(format!(
1198 "path {} has no parent directory",
1199 path.display()
1200 ))
1201 })?;
1202 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1203 crate::error::SqzError::Other(format!(
1204 "create temp file in {}: {e}",
1205 parent.display()
1206 ))
1207 })?;
1208 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1209 .map_err(|e| {
1210 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1211 })?;
1212 std::fs::write(tmp.path(), serialized).map_err(|e| {
1213 crate::error::SqzError::Other(format!(
1214 "write to temp file {}: {e}",
1215 tmp.path().display()
1216 ))
1217 })?;
1218 tmp.persist(&path).map_err(|e| {
1219 crate::error::SqzError::Other(format!(
1220 "rename temp file into place at {}: {e}",
1221 path.display()
1222 ))
1223 })?;
1224
1225 Ok(Some((path, true)))
1226}
1227
1228fn upsert_sqz_hook_entry(
1235 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1236 event_name: &str,
1237 new_entry: serde_json::Value,
1238 sentinel: &str,
1239) {
1240 let arr = hooks_obj
1241 .entry(event_name.to_string())
1242 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1243 let Some(arr) = arr.as_array_mut() else {
1244 hooks_obj.insert(
1248 event_name.to_string(),
1249 serde_json::Value::Array(vec![new_entry]),
1250 );
1251 return;
1252 };
1253
1254 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1256
1257 arr.push(new_entry);
1258}
1259
1260fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1264 entry
1265 .get("hooks")
1266 .and_then(|h| h.as_array())
1267 .map(|hooks_arr| {
1268 hooks_arr.iter().any(|h| {
1269 h.get("command")
1270 .and_then(|c| c.as_str())
1271 .map(|c| c.contains(needle))
1272 .unwrap_or(false)
1273 })
1274 })
1275 .unwrap_or(false)
1276}
1277
1278fn extract_base_command(cmd: &str) -> &str {
1282 cmd.split_whitespace()
1283 .next()
1284 .unwrap_or("unknown")
1285 .rsplit('/')
1286 .next()
1287 .unwrap_or("unknown")
1288}
1289
1290pub(crate) fn json_escape_string_value(s: &str) -> String {
1301 let mut out = String::with_capacity(s.len() + 2);
1302 for ch in s.chars() {
1303 match ch {
1304 '\\' => out.push_str("\\\\"),
1305 '"' => out.push_str("\\\""),
1306 '\n' => out.push_str("\\n"),
1307 '\r' => out.push_str("\\r"),
1308 '\t' => out.push_str("\\t"),
1309 '\x08' => out.push_str("\\b"),
1310 '\x0c' => out.push_str("\\f"),
1311 c if (c as u32) < 0x20 => {
1312 out.push_str(&format!("\\u{:04x}", c as u32));
1314 }
1315 c => out.push(c),
1316 }
1317 }
1318 out
1319}
1320
1321fn shell_escape(s: &str) -> String {
1323 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1324 s.to_string()
1325 } else {
1326 format!("'{}'", s.replace('\'', "'\\''"))
1327 }
1328}
1329
1330fn has_shell_operators(cmd: &str) -> bool {
1334 cmd.contains("&&")
1337 || cmd.contains("||")
1338 || cmd.contains(';')
1339 || cmd.contains('>')
1340 || cmd.contains('<')
1341 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1347
1348fn is_interactive_command(cmd: &str) -> bool {
1350 let base = extract_base_command(cmd);
1351 matches!(
1352 base,
1353 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1354 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1355 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1356 ) || cmd.contains("--watch")
1357 || cmd.contains("-w ")
1358 || cmd.ends_with(" -w")
1359 || cmd.contains("run dev")
1360 || cmd.contains("run start")
1361 || cmd.contains("run serve")
1362}
1363
1364#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 #[test]
1371 fn test_process_hook_rewrites_bash_command() {
1372 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1374 let result = process_hook(input).unwrap();
1375 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1376 let hook_output = &parsed["hookSpecificOutput"];
1378 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1379 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1380 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1382 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1383 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1384 assert!(cmd.contains("--cmd git"), "should pass base command as --cmd: {cmd}");
1387 assert!(
1388 !cmd.contains("SQZ_CMD="),
1389 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1390 );
1391 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1393 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1394 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1395 }
1396
1397 #[test]
1398 fn test_process_hook_passes_through_non_bash() {
1399 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1400 let result = process_hook(input).unwrap();
1401 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1402 }
1403
1404 #[test]
1405 fn test_process_hook_skips_sqz_commands() {
1406 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1407 let result = process_hook(input).unwrap();
1408 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1409 }
1410
1411 #[test]
1412 fn test_process_hook_skips_interactive() {
1413 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1414 let result = process_hook(input).unwrap();
1415 assert_eq!(result, input, "interactive commands should pass through");
1416 }
1417
1418 #[test]
1419 fn test_process_hook_skips_watch_mode() {
1420 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1421 let result = process_hook(input).unwrap();
1422 assert_eq!(result, input, "watch mode should pass through");
1423 }
1424
1425 #[test]
1426 fn test_process_hook_empty_command() {
1427 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1428 let result = process_hook(input).unwrap();
1429 assert_eq!(result, input);
1430 }
1431
1432 #[test]
1433 fn test_process_hook_gemini_format() {
1434 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1436 let result = process_hook_gemini(input).unwrap();
1437 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1438 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1440 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1442 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1443 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1445 "Gemini format should not have updatedInput");
1446 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1447 "Gemini format should not have permissionDecision");
1448 }
1449
1450 #[test]
1451 fn test_process_hook_legacy_format() {
1452 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1454 let result = process_hook(input).unwrap();
1455 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1456 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1457 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1458 }
1459
1460 #[test]
1461 fn test_process_hook_cursor_format() {
1462 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1464 let result = process_hook_cursor(input).unwrap();
1465 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1466 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1468 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1469 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1470 assert!(cmd.contains("git status"));
1471 assert!(parsed.get("hookSpecificOutput").is_none(),
1473 "Cursor format should not have hookSpecificOutput");
1474 }
1475
1476 #[test]
1477 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1478 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1480 let result = process_hook_cursor(input).unwrap();
1481 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1482 }
1483
1484 #[test]
1485 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1486 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1488 let result = process_hook_cursor(input).unwrap();
1489 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1490 }
1491
1492 #[test]
1493 fn test_process_hook_windsurf_format() {
1494 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1496 let result = process_hook_windsurf(input).unwrap();
1497 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1498 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1500 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1501 assert!(cmd.contains("cargo test"));
1502 assert!(cmd.contains("--cmd cargo"), "label must be passed via --cmd flag");
1504 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
1505 }
1506
1507 #[test]
1508 fn test_process_hook_invalid_json() {
1509 let result = process_hook("not json");
1510 assert!(result.is_err());
1511 }
1512
1513 #[test]
1514 fn test_extract_base_command() {
1515 assert_eq!(extract_base_command("git status"), "git");
1516 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1517 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1518 }
1519
1520 #[test]
1521 fn test_is_interactive_command() {
1522 assert!(is_interactive_command("vim file.txt"));
1523 assert!(is_interactive_command("npm run dev --watch"));
1524 assert!(is_interactive_command("python3"));
1525 assert!(!is_interactive_command("git status"));
1526 assert!(!is_interactive_command("cargo test"));
1527 }
1528
1529 #[test]
1544 fn issue_10_rewrite_is_shell_neutral() {
1545 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1546 let result = process_hook(input).unwrap();
1547 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1548 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1549 .as_str()
1550 .unwrap();
1551
1552 assert!(
1554 cmd.contains("--cmd dotnet"),
1555 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
1556 );
1557 assert!(
1559 !cmd.contains("SQZ_CMD="),
1560 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
1561 (broken in PowerShell and cmd.exe), got: {cmd}"
1562 );
1563 assert!(
1565 cmd.contains("dotnet build NewNeonCheckers3.sln"),
1566 "original command must be preserved verbatim: {cmd}"
1567 );
1568 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
1570 }
1571
1572 #[test]
1580 fn issue_10_already_wrapped_command_passes_through() {
1581 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
1582 let result = process_hook(input).unwrap();
1583 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1584 assert_eq!(
1587 result, input,
1588 "already-wrapped command must pass through unchanged; \
1589 otherwise each pass accumulates another `| sqz compress` tail"
1590 );
1591 let _ = parsed; }
1596
1597 #[test]
1598 fn test_generate_hook_configs() {
1599 let configs = generate_hook_configs("sqz");
1600 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1601 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1602 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1603 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1604 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1607 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1608 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1609 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1610 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1611 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1612 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1616 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1617 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1618 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1619 assert!(cursor.config_content.starts_with("---"),
1620 "Cursor rule should start with YAML frontmatter");
1621 assert!(cursor.config_content.contains("alwaysApply: true"),
1622 "Cursor rule should use alwaysApply: true so the guidance loads \
1623 for every agent interaction");
1624 assert!(cursor.config_content.contains("sqz"),
1625 "Cursor rule body should mention sqz");
1626 }
1627
1628 #[test]
1629 fn test_claude_config_includes_precompact_hook() {
1630 let configs = generate_hook_configs("sqz");
1635 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1636 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1637 .expect("Claude Code config must be valid JSON");
1638
1639 let precompact = parsed["hooks"]["PreCompact"]
1640 .as_array()
1641 .expect("PreCompact hook array must be present");
1642 assert!(
1643 !precompact.is_empty(),
1644 "PreCompact must have at least one registered hook"
1645 );
1646
1647 let cmd = precompact[0]["hooks"][0]["command"]
1648 .as_str()
1649 .expect("command field must be a string");
1650 assert!(
1651 cmd.ends_with(" hook precompact"),
1652 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1653 );
1654 }
1655
1656 #[test]
1659 fn test_json_escape_string_value() {
1660 assert_eq!(json_escape_string_value("sqz"), "sqz");
1662 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1663 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1665 r"C:\\Users\\Alice\\sqz.exe");
1666 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1668 r#"path with \"quotes\""#);
1669 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1671 }
1672
1673 #[test]
1674 fn test_windows_path_produces_valid_json_for_claude() {
1675 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1678 let configs = generate_hook_configs(windows_path);
1679
1680 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1681 .expect("Claude config should be generated");
1682 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1683 .expect("Claude hook config must be valid JSON on Windows paths");
1684
1685 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1687 .as_str()
1688 .expect("command field must be a string");
1689 assert!(cmd.contains(windows_path),
1690 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1691 }
1692
1693 #[test]
1694 fn test_windows_path_in_cursor_rules_file() {
1695 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1701 let configs = generate_hook_configs(windows_path);
1702
1703 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1704 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1705 assert!(cursor.config_content.contains(windows_path),
1706 "Cursor rule must contain the raw (unescaped) path so users can \
1707 copy-paste the shown commands — got:\n{}", cursor.config_content);
1708 assert!(!cursor.config_content.contains(r"C:\\Users"),
1709 "Cursor rule must NOT double-escape backslashes in markdown — \
1710 got:\n{}", cursor.config_content);
1711 }
1712
1713 #[test]
1714 fn test_windows_path_produces_valid_json_for_gemini() {
1715 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1716 let configs = generate_hook_configs(windows_path);
1717
1718 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1719 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1720 .expect("Gemini hook config must be valid JSON on Windows paths");
1721 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1722 assert!(cmd.contains(windows_path));
1723 }
1724
1725 #[test]
1726 fn test_rules_files_use_raw_path_for_readability() {
1727 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1731 let configs = generate_hook_configs(windows_path);
1732
1733 for tool in &["Windsurf", "Cline", "Cursor"] {
1734 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1735 assert!(cfg.config_content.contains(windows_path),
1736 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1737 cfg.config_content);
1738 assert!(!cfg.config_content.contains(r"C:\\Users"),
1739 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1740 cfg.config_content);
1741 }
1742 }
1743
1744 #[test]
1745 fn test_unix_path_still_works() {
1746 let unix_path = "/usr/local/bin/sqz";
1749 let configs = generate_hook_configs(unix_path);
1750
1751 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1752 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1753 .expect("Unix path should produce valid JSON");
1754 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1755 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1756 }
1757
1758 #[test]
1759 fn test_shell_escape_simple() {
1760 assert_eq!(shell_escape("git"), "git");
1761 assert_eq!(shell_escape("cargo-test"), "cargo-test");
1762 }
1763
1764 #[test]
1765 fn test_shell_escape_special_chars() {
1766 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1767 }
1768
1769 #[test]
1770 fn test_install_tool_hooks_creates_files() {
1771 let dir = tempfile::tempdir().unwrap();
1772 let installed = install_tool_hooks(dir.path(), "sqz");
1773 assert!(!installed.is_empty(), "should install at least one hook config");
1775 for name in &installed {
1777 let configs = generate_hook_configs("sqz");
1778 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1779 let path = dir.path().join(&config.config_path);
1780 assert!(path.exists(), "hook config should exist: {}", path.display());
1781 }
1782 }
1783
1784 #[test]
1785 fn test_install_tool_hooks_does_not_overwrite() {
1786 let dir = tempfile::tempdir().unwrap();
1787 install_tool_hooks(dir.path(), "sqz");
1789 let custom_path = dir.path().join(".claude/settings.local.json");
1791 std::fs::write(&custom_path, "custom content").unwrap();
1792 install_tool_hooks(dir.path(), "sqz");
1794 let content = std::fs::read_to_string(&custom_path).unwrap();
1795 assert_eq!(content, "custom content", "should not overwrite existing config");
1796 }
1797}
1798
1799#[cfg(test)]
1800mod global_install_tests {
1801 use super::*;
1802
1803 fn with_fake_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
1817 use std::sync::Mutex;
1818 static LOCK: Mutex<()> = Mutex::new(());
1820 let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1821
1822 let prev_home = std::env::var_os("HOME");
1823 let prev_userprofile = std::env::var_os("USERPROFILE");
1824 std::env::set_var("HOME", tmp);
1825 std::env::set_var("USERPROFILE", tmp);
1826 let result = body();
1827 match prev_home {
1828 Some(v) => std::env::set_var("HOME", v),
1829 None => std::env::remove_var("HOME"),
1830 }
1831 match prev_userprofile {
1832 Some(v) => std::env::set_var("USERPROFILE", v),
1833 None => std::env::remove_var("USERPROFILE"),
1834 }
1835 result
1836 }
1837
1838 #[test]
1839 fn global_install_creates_fresh_settings_json() {
1840 let tmp = tempfile::tempdir().unwrap();
1841 with_fake_home(tmp.path(), || {
1842 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1843 assert!(changed, "first install should report a change");
1844
1845 let path = tmp.path().join(".claude").join("settings.json");
1846 assert!(path.exists(), "user settings.json should be created");
1847
1848 let content = std::fs::read_to_string(&path).unwrap();
1849 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1850
1851 let pre = &parsed["hooks"]["PreToolUse"];
1853 assert!(pre.is_array(), "PreToolUse should be an array");
1854 assert_eq!(pre.as_array().unwrap().len(), 1);
1855 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1856 assert!(
1857 cmd.contains("/usr/local/bin/sqz"),
1858 "hook command should use the passed sqz_path, got: {cmd}"
1859 );
1860 assert!(cmd.contains("hook claude"));
1861
1862 let precompact = &parsed["hooks"]["PreCompact"];
1863 assert!(precompact.is_array());
1864 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1865 assert!(precompact_cmd.contains("hook precompact"));
1866
1867 let session = &parsed["hooks"]["SessionStart"];
1868 assert!(session.is_array());
1869 assert_eq!(
1870 session[0]["matcher"].as_str().unwrap(),
1871 "compact",
1872 "SessionStart should only match /compact resume"
1873 );
1874 });
1875 }
1876
1877 #[test]
1878 fn global_install_preserves_existing_user_config() {
1879 let tmp = tempfile::tempdir().unwrap();
1883 let settings = tmp.path().join(".claude").join("settings.json");
1884 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1885
1886 let existing = serde_json::json!({
1887 "permissions": {
1888 "allow": ["Bash(npm test *)"],
1889 "deny": ["Read(./.env)"]
1890 },
1891 "env": { "FOO": "bar" },
1892 "statusLine": {
1893 "type": "command",
1894 "command": "~/.claude/statusline.sh"
1895 },
1896 "hooks": {
1897 "PreToolUse": [
1898 {
1899 "matcher": "Edit",
1900 "hooks": [
1901 {
1902 "type": "command",
1903 "command": "~/.claude/hooks/format-on-edit.sh"
1904 }
1905 ]
1906 }
1907 ]
1908 }
1909 });
1910 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
1911
1912 with_fake_home(tmp.path(), || {
1913 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1914 assert!(changed, "install should report a change on new hook");
1915
1916 let content = std::fs::read_to_string(&settings).unwrap();
1917 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1918
1919 assert_eq!(
1921 parsed["permissions"]["allow"][0].as_str().unwrap(),
1922 "Bash(npm test *)"
1923 );
1924 assert_eq!(
1925 parsed["permissions"]["deny"][0].as_str().unwrap(),
1926 "Read(./.env)"
1927 );
1928 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
1930 assert_eq!(
1932 parsed["statusLine"]["command"].as_str().unwrap(),
1933 "~/.claude/statusline.sh"
1934 );
1935
1936 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1939 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
1940 let matchers: Vec<&str> = pre
1941 .iter()
1942 .map(|e| e["matcher"].as_str().unwrap_or(""))
1943 .collect();
1944 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
1945 assert!(matchers.contains(&"Bash"), "sqz Bash hook must be present");
1946 });
1947 }
1948
1949 #[test]
1950 fn global_install_is_idempotent() {
1951 let tmp = tempfile::tempdir().unwrap();
1955 with_fake_home(tmp.path(), || {
1956 assert!(install_claude_global("sqz").unwrap());
1957 assert!(
1960 !install_claude_global("sqz").unwrap(),
1961 "second install with identical args should report no change"
1962 );
1963
1964 let path = tmp.path().join(".claude").join("settings.json");
1965 let parsed: serde_json::Value =
1966 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1967 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
1969 let arr = parsed["hooks"][event].as_array().unwrap();
1970 assert_eq!(
1971 arr.len(),
1972 1,
1973 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
1974 );
1975 }
1976 });
1977 }
1978
1979 #[test]
1980 fn global_install_upgrades_stale_sqz_hook_in_place() {
1981 let tmp = tempfile::tempdir().unwrap();
1985 with_fake_home(tmp.path(), || {
1986 install_claude_global("/old/path/sqz").unwrap();
1988 let changed = install_claude_global("/new/path/sqz").unwrap();
1990 assert!(changed, "different sqz_path must be seen as a change");
1991
1992 let path = tmp.path().join(".claude").join("settings.json");
1993 let parsed: serde_json::Value =
1994 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1995 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1996 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
1997 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1998 assert!(cmd.contains("/new/path/sqz"));
1999 assert!(!cmd.contains("/old/path/sqz"));
2000 });
2001 }
2002
2003 #[test]
2004 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2005 let tmp = tempfile::tempdir().unwrap();
2006 let settings = tmp.path().join(".claude").join("settings.json");
2007 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2008 std::fs::write(
2009 &settings,
2010 serde_json::json!({
2011 "permissions": { "allow": ["Bash(git status)"] },
2012 "hooks": {
2013 "PreToolUse": [
2014 {
2015 "matcher": "Edit",
2016 "hooks": [
2017 { "type": "command", "command": "~/format.sh" }
2018 ]
2019 }
2020 ]
2021 }
2022 })
2023 .to_string(),
2024 )
2025 .unwrap();
2026
2027 with_fake_home(tmp.path(), || {
2028 install_claude_global("/usr/local/bin/sqz").unwrap();
2030 let result = remove_claude_global_hook().unwrap().unwrap();
2032 assert_eq!(result.0, settings);
2033 assert!(result.1, "should report that the file was modified");
2034
2035 assert!(settings.exists(), "settings.json should be preserved");
2037 let parsed: serde_json::Value =
2038 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2039
2040 assert_eq!(
2042 parsed["permissions"]["allow"][0].as_str().unwrap(),
2043 "Bash(git status)"
2044 );
2045
2046 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2048 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2049 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2050
2051 assert!(parsed["hooks"].get("PreCompact").is_none());
2053 assert!(parsed["hooks"].get("SessionStart").is_none());
2054 });
2055 }
2056
2057 #[test]
2058 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2059 let tmp = tempfile::tempdir().unwrap();
2063 with_fake_home(tmp.path(), || {
2064 install_claude_global("sqz").unwrap();
2065 let path = tmp.path().join(".claude").join("settings.json");
2066 assert!(path.exists(), "precondition: install created the file");
2067
2068 let result = remove_claude_global_hook().unwrap().unwrap();
2069 assert!(result.1);
2070 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2071 });
2072 }
2073
2074 #[test]
2075 fn global_uninstall_on_missing_file_is_noop() {
2076 let tmp = tempfile::tempdir().unwrap();
2077 with_fake_home(tmp.path(), || {
2078 assert!(
2079 remove_claude_global_hook().unwrap().is_none(),
2080 "missing file should return None, not error"
2081 );
2082 });
2083 }
2084
2085 #[test]
2086 fn global_uninstall_refuses_to_touch_unparseable_file() {
2087 let tmp = tempfile::tempdir().unwrap();
2091 let settings = tmp.path().join(".claude").join("settings.json");
2092 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2093 std::fs::write(&settings, "{ invalid json because").unwrap();
2094
2095 with_fake_home(tmp.path(), || {
2096 assert!(
2097 remove_claude_global_hook().is_err(),
2098 "bad JSON must surface as an error"
2099 );
2100 });
2101
2102 let after = std::fs::read_to_string(&settings).unwrap();
2104 assert_eq!(after, "{ invalid json because");
2105 }
2106}
2107
2108#[cfg(test)]
2109mod issue_11_tool_filter_tests {
2110 use super::*;
2119
2120 #[test]
2121 fn canonicalize_collapses_common_aliases() {
2122 for aliases in &[
2125 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2126 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2127 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2128 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2132 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2133 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2134 (vec!["Codex", "codex"], "codex"),
2135 ] {
2136 for alias in &aliases.0 {
2137 assert_eq!(
2138 canonicalize_tool_name(alias),
2139 aliases.1,
2140 "alias '{}' must canonicalise to '{}'",
2141 alias,
2142 aliases.1
2143 );
2144 }
2145 }
2146 }
2147
2148 #[test]
2149 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2150 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2155 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2156 }
2157
2158 #[test]
2159 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2160 let names = parse_tool_list("opencode,codex").unwrap();
2163 assert_eq!(names, vec!["opencode", "codex"]);
2164
2165 let names = parse_tool_list(" opencode , codex ").unwrap();
2166 assert_eq!(names, vec!["opencode", "codex"]);
2167
2168 let names = parse_tool_list("opencode").unwrap();
2170 assert_eq!(names, vec!["opencode"]);
2171
2172 let names = parse_tool_list("claude-code").unwrap();
2174 assert_eq!(names, vec!["claudecode"]);
2175 }
2176
2177 #[test]
2178 fn parse_tool_list_dedupes_repeated_entries() {
2179 let names = parse_tool_list("opencode,opencode").unwrap();
2184 assert_eq!(names, vec!["opencode"]);
2185
2186 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2189 assert_eq!(names, vec!["claudecode"]);
2190 }
2191
2192 #[test]
2193 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2194 let err = parse_tool_list("opncode").unwrap_err();
2201 let msg = err.to_string();
2202 assert!(
2203 msg.contains("unknown agent name 'opncode'"),
2204 "error must quote the bad input: {msg}"
2205 );
2206 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2207 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2208 }
2209
2210 #[test]
2211 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2212 let err = parse_tool_list("opencode,xyz").unwrap_err();
2217 assert!(err.to_string().contains("xyz"));
2218 }
2219
2220 #[test]
2221 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2222 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2228 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2229 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2230 }
2231
2232 #[test]
2233 fn tool_filter_all_includes_every_supported_tool() {
2234 let filter = ToolFilter::All;
2235 for tool in SUPPORTED_TOOL_NAMES {
2236 assert!(
2237 filter.includes(tool),
2238 "default filter must include {tool}"
2239 );
2240 }
2241 }
2242
2243 #[test]
2244 fn tool_filter_only_opencode_excludes_everything_else() {
2245 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2247 assert!(filter.includes("OpenCode"));
2248 for tool in SUPPORTED_TOOL_NAMES {
2250 if *tool == "OpenCode" {
2251 continue;
2252 }
2253 assert!(
2254 !filter.includes(tool),
2255 "--only opencode must not include {tool}"
2256 );
2257 }
2258 }
2259
2260 #[test]
2261 fn tool_filter_only_multi_tool_includes_exactly_those() {
2262 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2263 assert!(filter.includes("OpenCode"));
2264 assert!(filter.includes("Codex"));
2265 assert!(!filter.includes("Claude Code"));
2267 assert!(!filter.includes("Cursor"));
2268 assert!(!filter.includes("Windsurf"));
2269 assert!(!filter.includes("Cline"));
2270 assert!(!filter.includes("Gemini CLI"));
2271 }
2272
2273 #[test]
2274 fn tool_filter_skip_inverts_the_set() {
2275 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2278 assert!(!filter.includes("Cursor"));
2279 assert!(!filter.includes("Windsurf"));
2280 assert!(filter.includes("Claude Code"));
2282 assert!(filter.includes("Cline"));
2283 assert!(filter.includes("Gemini CLI"));
2284 assert!(filter.includes("OpenCode"));
2285 assert!(filter.includes("Codex"));
2286 }
2287
2288 #[test]
2289 fn tool_filter_only_empty_excludes_everything() {
2290 let filter = ToolFilter::Only(vec![]);
2296 for tool in SUPPORTED_TOOL_NAMES {
2297 assert!(
2298 !filter.includes(tool),
2299 "empty --only must exclude every tool, got {tool}"
2300 );
2301 }
2302 }
2303
2304 #[test]
2305 fn tool_filter_only_accepts_display_name_or_canonical() {
2306 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2312 assert!(filter.includes("Claude Code"));
2313 assert!(!filter.includes("Cursor"));
2314
2315 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2316 assert!(filter.includes("Gemini CLI"));
2317 }
2318
2319 #[test]
2320 fn supported_tool_names_matches_generate_hook_configs_exactly() {
2321 let configs = generate_hook_configs("sqz");
2327 let emitted: std::collections::HashSet<&str> =
2328 configs.iter().map(|c| c.tool_name.as_str()).collect();
2329 let declared: std::collections::HashSet<&str> =
2330 SUPPORTED_TOOL_NAMES.iter().copied().collect();
2331 assert_eq!(
2332 emitted, declared,
2333 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2334 from generate_hook_configs. emitted={:?}, declared={:?}",
2335 emitted, declared
2336 );
2337 }
2338
2339 #[test]
2340 fn filtered_install_only_opencode_writes_only_opencode_files() {
2341 let dir = tempfile::tempdir().unwrap();
2347 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2348 let _installed = install_tool_hooks_scoped_filtered(
2349 dir.path(),
2350 "sqz",
2351 InstallScope::Project,
2352 &filter,
2353 );
2354
2355 assert!(
2357 dir.path().join("opencode.json").exists(),
2358 "OpenCode config must be written when --only opencode is used"
2359 );
2360
2361 for (path, tool) in &[
2363 (".claude/settings.local.json", "Claude Code"),
2364 (".cursor/rules/sqz.mdc", "Cursor"),
2365 (".windsurfrules", "Windsurf"),
2366 (".clinerules", "Cline"),
2367 (".gemini/settings.json", "Gemini CLI"),
2368 ("AGENTS.md", "Codex"),
2369 ] {
2370 assert!(
2371 !dir.path().join(path).exists(),
2372 "filter rejected {tool} but the installer still wrote {path}"
2373 );
2374 }
2375 }
2376
2377 #[test]
2378 fn filtered_install_skip_cursor_omits_only_cursor() {
2379 let dir = tempfile::tempdir().unwrap();
2381 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
2382 let _installed = install_tool_hooks_scoped_filtered(
2383 dir.path(),
2384 "sqz",
2385 InstallScope::Project,
2386 &filter,
2387 );
2388
2389 assert!(
2391 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
2392 "skip cursor: .cursor/rules/sqz.mdc must not be written"
2393 );
2394 assert!(
2396 dir.path().join(".windsurfrules").exists(),
2397 "skip cursor should not skip windsurf"
2398 );
2399 assert!(
2400 dir.path().join(".clinerules").exists(),
2401 "skip cursor should not skip cline"
2402 );
2403 }
2404}
2405