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 {
915 let hook_installed = match install_claude_global(sqz_path) {
916 Ok(v) => v,
917 Err(_) => false,
918 };
919 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
920 project_dir, sqz_path,
921 )
922 .unwrap_or(false);
923 let mcp_changed =
924 crate::claude_md_integration::install_claude_mcp_config()
925 .unwrap_or(false);
926 if (hook_installed || md_changed || mcp_changed)
927 && !installed.iter().any(|n| n == "Claude Code")
928 {
929 installed.push("Claude Code".to_string());
930 }
931 continue;
932 }
933
934 let full_path = project_dir.join(&config.config_path);
935
936 if full_path.exists() {
938 if config.tool_name == "Claude Code" {
943 let md_changed =
944 crate::claude_md_integration::install_claude_md_guidance(
945 project_dir, sqz_path,
946 )
947 .unwrap_or(false);
948 let mcp_changed =
949 crate::claude_md_integration::install_claude_mcp_config()
950 .unwrap_or(false);
951 if (md_changed || mcp_changed)
952 && !installed.iter().any(|n| n == "Claude Code")
953 {
954 installed.push("Claude Code".to_string());
955 }
956 }
957 continue;
958 }
959
960 if let Some(parent) = full_path.parent() {
962 if std::fs::create_dir_all(parent).is_err() {
963 continue;
964 }
965 }
966
967 if std::fs::write(&full_path, &config.config_content).is_ok() {
968 installed.push(config.tool_name.clone());
969 if config.tool_name == "Claude Code" {
976 let _ = crate::claude_md_integration::install_claude_md_guidance(
977 project_dir, sqz_path,
978 );
979 let _ = crate::claude_md_integration::install_claude_mcp_config();
980 }
981 }
982 }
983
984 if filter.includes("OpenCode") {
995 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
996 if !installed.iter().any(|n| n == "OpenCode") {
997 installed.push("OpenCode".to_string());
998 }
999 }
1000 }
1001
1002 installed
1003}
1004
1005pub fn claude_user_settings_path() -> Option<PathBuf> {
1018 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1019}
1020
1021fn install_claude_global(sqz_path: &str) -> Result<bool> {
1036 let path = claude_user_settings_path().ok_or_else(|| {
1037 crate::error::SqzError::Other(
1038 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1039 )
1040 })?;
1041
1042 let mut root: serde_json::Value = if path.exists() {
1044 let content = std::fs::read_to_string(&path).map_err(|e| {
1045 crate::error::SqzError::Other(format!(
1046 "read {}: {e}",
1047 path.display()
1048 ))
1049 })?;
1050 if content.trim().is_empty() {
1051 serde_json::Value::Object(serde_json::Map::new())
1052 } else {
1053 serde_json::from_str(&content).map_err(|e| {
1054 crate::error::SqzError::Other(format!(
1055 "parse {}: {e} — please fix or move the file before re-running sqz init",
1056 path.display()
1057 ))
1058 })?
1059 }
1060 } else {
1061 serde_json::Value::Object(serde_json::Map::new())
1062 };
1063
1064 let root_obj = root.as_object_mut().ok_or_else(|| {
1067 crate::error::SqzError::Other(format!(
1068 "{} is not a JSON object — refusing to overwrite",
1069 path.display()
1070 ))
1071 })?;
1072
1073 let pre_tool_use = serde_json::json!({
1075 "matcher": "Bash",
1076 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1077 });
1078 let pre_compact = serde_json::json!({
1079 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1080 });
1081 let session_start = serde_json::json!({
1082 "matcher": "compact",
1083 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1084 });
1085
1086 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1088
1089 let hooks = root_obj
1091 .entry("hooks".to_string())
1092 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1093 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1094 crate::error::SqzError::Other(format!(
1095 "{}: `hooks` is not an object — refusing to overwrite",
1096 path.display()
1097 ))
1098 })?;
1099
1100 upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "sqz hook claude");
1101 upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "sqz hook precompact");
1102 upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "sqz resume");
1103
1104 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1105 if before == after && path.exists() {
1106 return Ok(false);
1108 }
1109
1110 if let Some(parent) = path.parent() {
1112 std::fs::create_dir_all(parent).map_err(|e| {
1113 crate::error::SqzError::Other(format!(
1114 "create {}: {e}",
1115 parent.display()
1116 ))
1117 })?;
1118 }
1119
1120 let parent = path.parent().ok_or_else(|| {
1124 crate::error::SqzError::Other(format!(
1125 "path {} has no parent directory",
1126 path.display()
1127 ))
1128 })?;
1129 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1130 crate::error::SqzError::Other(format!(
1131 "create temp file in {}: {e}",
1132 parent.display()
1133 ))
1134 })?;
1135 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1136 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1137 std::fs::write(tmp.path(), serialized).map_err(|e| {
1138 crate::error::SqzError::Other(format!(
1139 "write to temp file {}: {e}",
1140 tmp.path().display()
1141 ))
1142 })?;
1143 tmp.persist(&path).map_err(|e| {
1144 crate::error::SqzError::Other(format!(
1145 "rename temp file into place at {}: {e}",
1146 path.display()
1147 ))
1148 })?;
1149
1150 Ok(true)
1151}
1152
1153pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1167 let Some(path) = claude_user_settings_path() else {
1168 return Ok(None);
1169 };
1170 if !path.exists() {
1171 return Ok(None);
1172 }
1173
1174 let content = std::fs::read_to_string(&path).map_err(|e| {
1175 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1176 })?;
1177 if content.trim().is_empty() {
1178 return Ok(Some((path, false)));
1179 }
1180
1181 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1182 crate::error::SqzError::Other(format!(
1183 "parse {}: {e} — refusing to rewrite an unparseable file",
1184 path.display()
1185 ))
1186 })?;
1187 let Some(root_obj) = root.as_object_mut() else {
1188 return Ok(Some((path, false)));
1189 };
1190
1191 let mut changed = false;
1192 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1193 for (event, sentinel) in &[
1194 ("PreToolUse", "sqz hook claude"),
1195 ("PreCompact", "sqz hook precompact"),
1196 ("SessionStart", "sqz resume"),
1197 ] {
1198 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1199 let before = arr.len();
1200 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1201 if arr.len() != before {
1202 changed = true;
1203 }
1204 }
1205 }
1206
1207 hooks.retain(|_, v| match v {
1210 serde_json::Value::Array(a) => !a.is_empty(),
1211 _ => true,
1212 });
1213
1214 let hooks_empty = hooks.is_empty();
1217 if hooks_empty {
1218 root_obj.remove("hooks");
1219 changed = true;
1220 }
1221 }
1222
1223 if !changed {
1224 return Ok(Some((path, false)));
1225 }
1226
1227 if root_obj.is_empty() {
1231 std::fs::remove_file(&path).map_err(|e| {
1232 crate::error::SqzError::Other(format!(
1233 "remove {}: {e}",
1234 path.display()
1235 ))
1236 })?;
1237 return Ok(Some((path, true)));
1238 }
1239
1240 let parent = path.parent().ok_or_else(|| {
1242 crate::error::SqzError::Other(format!(
1243 "path {} has no parent directory",
1244 path.display()
1245 ))
1246 })?;
1247 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1248 crate::error::SqzError::Other(format!(
1249 "create temp file in {}: {e}",
1250 parent.display()
1251 ))
1252 })?;
1253 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1254 .map_err(|e| {
1255 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1256 })?;
1257 std::fs::write(tmp.path(), serialized).map_err(|e| {
1258 crate::error::SqzError::Other(format!(
1259 "write to temp file {}: {e}",
1260 tmp.path().display()
1261 ))
1262 })?;
1263 tmp.persist(&path).map_err(|e| {
1264 crate::error::SqzError::Other(format!(
1265 "rename temp file into place at {}: {e}",
1266 path.display()
1267 ))
1268 })?;
1269
1270 Ok(Some((path, true)))
1271}
1272
1273fn upsert_sqz_hook_entry(
1280 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1281 event_name: &str,
1282 new_entry: serde_json::Value,
1283 sentinel: &str,
1284) {
1285 let arr = hooks_obj
1286 .entry(event_name.to_string())
1287 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1288 let Some(arr) = arr.as_array_mut() else {
1289 hooks_obj.insert(
1293 event_name.to_string(),
1294 serde_json::Value::Array(vec![new_entry]),
1295 );
1296 return;
1297 };
1298
1299 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1301
1302 arr.push(new_entry);
1303}
1304
1305fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1309 entry
1310 .get("hooks")
1311 .and_then(|h| h.as_array())
1312 .map(|hooks_arr| {
1313 hooks_arr.iter().any(|h| {
1314 h.get("command")
1315 .and_then(|c| c.as_str())
1316 .map(|c| c.contains(needle))
1317 .unwrap_or(false)
1318 })
1319 })
1320 .unwrap_or(false)
1321}
1322
1323fn extract_base_command(cmd: &str) -> &str {
1327 cmd.split_whitespace()
1328 .next()
1329 .unwrap_or("unknown")
1330 .rsplit('/')
1331 .next()
1332 .unwrap_or("unknown")
1333}
1334
1335pub(crate) fn json_escape_string_value(s: &str) -> String {
1346 let mut out = String::with_capacity(s.len() + 2);
1347 for ch in s.chars() {
1348 match ch {
1349 '\\' => out.push_str("\\\\"),
1350 '"' => out.push_str("\\\""),
1351 '\n' => out.push_str("\\n"),
1352 '\r' => out.push_str("\\r"),
1353 '\t' => out.push_str("\\t"),
1354 '\x08' => out.push_str("\\b"),
1355 '\x0c' => out.push_str("\\f"),
1356 c if (c as u32) < 0x20 => {
1357 out.push_str(&format!("\\u{:04x}", c as u32));
1359 }
1360 c => out.push(c),
1361 }
1362 }
1363 out
1364}
1365
1366fn shell_escape(s: &str) -> String {
1368 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1369 s.to_string()
1370 } else {
1371 format!("'{}'", s.replace('\'', "'\\''"))
1372 }
1373}
1374
1375fn has_shell_operators(cmd: &str) -> bool {
1379 cmd.contains("&&")
1382 || cmd.contains("||")
1383 || cmd.contains(';')
1384 || cmd.contains('>')
1385 || cmd.contains('<')
1386 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1392
1393fn is_interactive_command(cmd: &str) -> bool {
1395 let base = extract_base_command(cmd);
1396 matches!(
1397 base,
1398 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1399 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1400 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1401 ) || cmd.contains("--watch")
1402 || cmd.contains("-w ")
1403 || cmd.ends_with(" -w")
1404 || cmd.contains("run dev")
1405 || cmd.contains("run start")
1406 || cmd.contains("run serve")
1407}
1408
1409#[cfg(test)]
1412mod tests {
1413 use super::*;
1414
1415 #[test]
1416 fn test_process_hook_rewrites_bash_command() {
1417 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1419 let result = process_hook(input).unwrap();
1420 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1421 let hook_output = &parsed["hookSpecificOutput"];
1423 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1424 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1425 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1427 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1428 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1429 assert!(cmd.contains("--cmd git"), "should pass base command as --cmd: {cmd}");
1432 assert!(
1433 !cmd.contains("SQZ_CMD="),
1434 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1435 );
1436 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1438 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1439 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1440 }
1441
1442 #[test]
1443 fn test_process_hook_passes_through_non_bash() {
1444 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1445 let result = process_hook(input).unwrap();
1446 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1447 }
1448
1449 #[test]
1450 fn test_process_hook_skips_sqz_commands() {
1451 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1452 let result = process_hook(input).unwrap();
1453 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1454 }
1455
1456 #[test]
1457 fn test_process_hook_skips_interactive() {
1458 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1459 let result = process_hook(input).unwrap();
1460 assert_eq!(result, input, "interactive commands should pass through");
1461 }
1462
1463 #[test]
1464 fn test_process_hook_skips_watch_mode() {
1465 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1466 let result = process_hook(input).unwrap();
1467 assert_eq!(result, input, "watch mode should pass through");
1468 }
1469
1470 #[test]
1471 fn test_process_hook_empty_command() {
1472 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1473 let result = process_hook(input).unwrap();
1474 assert_eq!(result, input);
1475 }
1476
1477 #[test]
1478 fn test_process_hook_gemini_format() {
1479 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1481 let result = process_hook_gemini(input).unwrap();
1482 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1483 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1485 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1487 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1488 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1490 "Gemini format should not have updatedInput");
1491 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1492 "Gemini format should not have permissionDecision");
1493 }
1494
1495 #[test]
1496 fn test_process_hook_legacy_format() {
1497 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1499 let result = process_hook(input).unwrap();
1500 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1501 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1502 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1503 }
1504
1505 #[test]
1506 fn test_process_hook_cursor_format() {
1507 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1509 let result = process_hook_cursor(input).unwrap();
1510 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1511 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1513 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1514 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1515 assert!(cmd.contains("git status"));
1516 assert!(parsed.get("hookSpecificOutput").is_none(),
1518 "Cursor format should not have hookSpecificOutput");
1519 }
1520
1521 #[test]
1522 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1523 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1525 let result = process_hook_cursor(input).unwrap();
1526 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1527 }
1528
1529 #[test]
1530 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1531 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1533 let result = process_hook_cursor(input).unwrap();
1534 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1535 }
1536
1537 #[test]
1538 fn test_process_hook_windsurf_format() {
1539 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1541 let result = process_hook_windsurf(input).unwrap();
1542 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1543 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1545 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1546 assert!(cmd.contains("cargo test"));
1547 assert!(cmd.contains("--cmd cargo"), "label must be passed via --cmd flag");
1549 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
1550 }
1551
1552 #[test]
1553 fn test_process_hook_invalid_json() {
1554 let result = process_hook("not json");
1555 assert!(result.is_err());
1556 }
1557
1558 #[test]
1559 fn test_extract_base_command() {
1560 assert_eq!(extract_base_command("git status"), "git");
1561 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1562 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1563 }
1564
1565 #[test]
1566 fn test_is_interactive_command() {
1567 assert!(is_interactive_command("vim file.txt"));
1568 assert!(is_interactive_command("npm run dev --watch"));
1569 assert!(is_interactive_command("python3"));
1570 assert!(!is_interactive_command("git status"));
1571 assert!(!is_interactive_command("cargo test"));
1572 }
1573
1574 #[test]
1589 fn issue_10_rewrite_is_shell_neutral() {
1590 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1591 let result = process_hook(input).unwrap();
1592 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1593 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1594 .as_str()
1595 .unwrap();
1596
1597 assert!(
1599 cmd.contains("--cmd dotnet"),
1600 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
1601 );
1602 assert!(
1604 !cmd.contains("SQZ_CMD="),
1605 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
1606 (broken in PowerShell and cmd.exe), got: {cmd}"
1607 );
1608 assert!(
1610 cmd.contains("dotnet build NewNeonCheckers3.sln"),
1611 "original command must be preserved verbatim: {cmd}"
1612 );
1613 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
1615 }
1616
1617 #[test]
1625 fn issue_10_already_wrapped_command_passes_through() {
1626 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
1627 let result = process_hook(input).unwrap();
1628 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1629 assert_eq!(
1632 result, input,
1633 "already-wrapped command must pass through unchanged; \
1634 otherwise each pass accumulates another `| sqz compress` tail"
1635 );
1636 let _ = parsed; }
1641
1642 #[test]
1643 fn test_generate_hook_configs() {
1644 let configs = generate_hook_configs("sqz");
1645 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1646 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1647 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1648 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1649 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1652 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1653 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1654 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1655 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1656 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1657 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1661 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1662 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1663 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1664 assert!(cursor.config_content.starts_with("---"),
1665 "Cursor rule should start with YAML frontmatter");
1666 assert!(cursor.config_content.contains("alwaysApply: true"),
1667 "Cursor rule should use alwaysApply: true so the guidance loads \
1668 for every agent interaction");
1669 assert!(cursor.config_content.contains("sqz"),
1670 "Cursor rule body should mention sqz");
1671 }
1672
1673 #[test]
1674 fn test_claude_config_includes_precompact_hook() {
1675 let configs = generate_hook_configs("sqz");
1680 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1681 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1682 .expect("Claude Code config must be valid JSON");
1683
1684 let precompact = parsed["hooks"]["PreCompact"]
1685 .as_array()
1686 .expect("PreCompact hook array must be present");
1687 assert!(
1688 !precompact.is_empty(),
1689 "PreCompact must have at least one registered hook"
1690 );
1691
1692 let cmd = precompact[0]["hooks"][0]["command"]
1693 .as_str()
1694 .expect("command field must be a string");
1695 assert!(
1696 cmd.ends_with(" hook precompact"),
1697 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1698 );
1699 }
1700
1701 #[test]
1704 fn test_json_escape_string_value() {
1705 assert_eq!(json_escape_string_value("sqz"), "sqz");
1707 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1708 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1710 r"C:\\Users\\Alice\\sqz.exe");
1711 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1713 r#"path with \"quotes\""#);
1714 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1716 }
1717
1718 #[test]
1719 fn test_windows_path_produces_valid_json_for_claude() {
1720 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1723 let configs = generate_hook_configs(windows_path);
1724
1725 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1726 .expect("Claude config should be generated");
1727 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1728 .expect("Claude hook config must be valid JSON on Windows paths");
1729
1730 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1732 .as_str()
1733 .expect("command field must be a string");
1734 assert!(cmd.contains(windows_path),
1735 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1736 }
1737
1738 #[test]
1739 fn test_windows_path_in_cursor_rules_file() {
1740 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1746 let configs = generate_hook_configs(windows_path);
1747
1748 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1749 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
1750 assert!(cursor.config_content.contains(windows_path),
1751 "Cursor rule must contain the raw (unescaped) path so users can \
1752 copy-paste the shown commands — got:\n{}", cursor.config_content);
1753 assert!(!cursor.config_content.contains(r"C:\\Users"),
1754 "Cursor rule must NOT double-escape backslashes in markdown — \
1755 got:\n{}", cursor.config_content);
1756 }
1757
1758 #[test]
1759 fn test_windows_path_produces_valid_json_for_gemini() {
1760 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1761 let configs = generate_hook_configs(windows_path);
1762
1763 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
1764 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
1765 .expect("Gemini hook config must be valid JSON on Windows paths");
1766 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
1767 assert!(cmd.contains(windows_path));
1768 }
1769
1770 #[test]
1771 fn test_rules_files_use_raw_path_for_readability() {
1772 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1776 let configs = generate_hook_configs(windows_path);
1777
1778 for tool in &["Windsurf", "Cline", "Cursor"] {
1779 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
1780 assert!(cfg.config_content.contains(windows_path),
1781 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
1782 cfg.config_content);
1783 assert!(!cfg.config_content.contains(r"C:\\Users"),
1784 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
1785 cfg.config_content);
1786 }
1787 }
1788
1789 #[test]
1790 fn test_unix_path_still_works() {
1791 let unix_path = "/usr/local/bin/sqz";
1794 let configs = generate_hook_configs(unix_path);
1795
1796 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1797 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1798 .expect("Unix path should produce valid JSON");
1799 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
1800 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
1801 }
1802
1803 #[test]
1804 fn test_shell_escape_simple() {
1805 assert_eq!(shell_escape("git"), "git");
1806 assert_eq!(shell_escape("cargo-test"), "cargo-test");
1807 }
1808
1809 #[test]
1810 fn test_shell_escape_special_chars() {
1811 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
1812 }
1813
1814 #[test]
1815 fn test_install_tool_hooks_creates_files() {
1816 let dir = tempfile::tempdir().unwrap();
1817 let installed = install_tool_hooks(dir.path(), "sqz");
1818 assert!(!installed.is_empty(), "should install at least one hook config");
1820 for name in &installed {
1822 let configs = generate_hook_configs("sqz");
1823 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
1824 let path = dir.path().join(&config.config_path);
1825 assert!(path.exists(), "hook config should exist: {}", path.display());
1826 }
1827 }
1828
1829 #[test]
1830 fn test_install_tool_hooks_does_not_overwrite() {
1831 let dir = tempfile::tempdir().unwrap();
1832 install_tool_hooks(dir.path(), "sqz");
1834 let custom_path = dir.path().join(".claude/settings.local.json");
1836 std::fs::write(&custom_path, "custom content").unwrap();
1837 install_tool_hooks(dir.path(), "sqz");
1839 let content = std::fs::read_to_string(&custom_path).unwrap();
1840 assert_eq!(content, "custom content", "should not overwrite existing config");
1841 }
1842}
1843
1844#[cfg(test)]
1845mod global_install_tests {
1846 use super::*;
1847
1848 fn with_fake_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
1862 use std::sync::Mutex;
1863 static LOCK: Mutex<()> = Mutex::new(());
1865 let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1866
1867 let prev_home = std::env::var_os("HOME");
1868 let prev_userprofile = std::env::var_os("USERPROFILE");
1869 std::env::set_var("HOME", tmp);
1870 std::env::set_var("USERPROFILE", tmp);
1871 let result = body();
1872 match prev_home {
1873 Some(v) => std::env::set_var("HOME", v),
1874 None => std::env::remove_var("HOME"),
1875 }
1876 match prev_userprofile {
1877 Some(v) => std::env::set_var("USERPROFILE", v),
1878 None => std::env::remove_var("USERPROFILE"),
1879 }
1880 result
1881 }
1882
1883 #[test]
1884 fn global_install_creates_fresh_settings_json() {
1885 let tmp = tempfile::tempdir().unwrap();
1886 with_fake_home(tmp.path(), || {
1887 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1888 assert!(changed, "first install should report a change");
1889
1890 let path = tmp.path().join(".claude").join("settings.json");
1891 assert!(path.exists(), "user settings.json should be created");
1892
1893 let content = std::fs::read_to_string(&path).unwrap();
1894 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1895
1896 let pre = &parsed["hooks"]["PreToolUse"];
1898 assert!(pre.is_array(), "PreToolUse should be an array");
1899 assert_eq!(pre.as_array().unwrap().len(), 1);
1900 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
1901 assert!(
1902 cmd.contains("/usr/local/bin/sqz"),
1903 "hook command should use the passed sqz_path, got: {cmd}"
1904 );
1905 assert!(cmd.contains("hook claude"));
1906
1907 let precompact = &parsed["hooks"]["PreCompact"];
1908 assert!(precompact.is_array());
1909 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
1910 assert!(precompact_cmd.contains("hook precompact"));
1911
1912 let session = &parsed["hooks"]["SessionStart"];
1913 assert!(session.is_array());
1914 assert_eq!(
1915 session[0]["matcher"].as_str().unwrap(),
1916 "compact",
1917 "SessionStart should only match /compact resume"
1918 );
1919 });
1920 }
1921
1922 #[test]
1923 fn global_install_preserves_existing_user_config() {
1924 let tmp = tempfile::tempdir().unwrap();
1928 let settings = tmp.path().join(".claude").join("settings.json");
1929 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
1930
1931 let existing = serde_json::json!({
1932 "permissions": {
1933 "allow": ["Bash(npm test *)"],
1934 "deny": ["Read(./.env)"]
1935 },
1936 "env": { "FOO": "bar" },
1937 "statusLine": {
1938 "type": "command",
1939 "command": "~/.claude/statusline.sh"
1940 },
1941 "hooks": {
1942 "PreToolUse": [
1943 {
1944 "matcher": "Edit",
1945 "hooks": [
1946 {
1947 "type": "command",
1948 "command": "~/.claude/hooks/format-on-edit.sh"
1949 }
1950 ]
1951 }
1952 ]
1953 }
1954 });
1955 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
1956
1957 with_fake_home(tmp.path(), || {
1958 let changed = install_claude_global("/usr/local/bin/sqz").unwrap();
1959 assert!(changed, "install should report a change on new hook");
1960
1961 let content = std::fs::read_to_string(&settings).unwrap();
1962 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1963
1964 assert_eq!(
1966 parsed["permissions"]["allow"][0].as_str().unwrap(),
1967 "Bash(npm test *)"
1968 );
1969 assert_eq!(
1970 parsed["permissions"]["deny"][0].as_str().unwrap(),
1971 "Read(./.env)"
1972 );
1973 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
1975 assert_eq!(
1977 parsed["statusLine"]["command"].as_str().unwrap(),
1978 "~/.claude/statusline.sh"
1979 );
1980
1981 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1984 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
1985 let matchers: Vec<&str> = pre
1986 .iter()
1987 .map(|e| e["matcher"].as_str().unwrap_or(""))
1988 .collect();
1989 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
1990 assert!(matchers.contains(&"Bash"), "sqz Bash hook must be present");
1991 });
1992 }
1993
1994 #[test]
1995 fn global_install_is_idempotent() {
1996 let tmp = tempfile::tempdir().unwrap();
2000 with_fake_home(tmp.path(), || {
2001 assert!(install_claude_global("sqz").unwrap());
2002 assert!(
2005 !install_claude_global("sqz").unwrap(),
2006 "second install with identical args should report no change"
2007 );
2008
2009 let path = tmp.path().join(".claude").join("settings.json");
2010 let parsed: serde_json::Value =
2011 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2012 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2014 let arr = parsed["hooks"][event].as_array().unwrap();
2015 assert_eq!(
2016 arr.len(),
2017 1,
2018 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2019 );
2020 }
2021 });
2022 }
2023
2024 #[test]
2025 fn global_install_upgrades_stale_sqz_hook_in_place() {
2026 let tmp = tempfile::tempdir().unwrap();
2030 with_fake_home(tmp.path(), || {
2031 install_claude_global("/old/path/sqz").unwrap();
2033 let changed = install_claude_global("/new/path/sqz").unwrap();
2035 assert!(changed, "different sqz_path must be seen as a change");
2036
2037 let path = tmp.path().join(".claude").join("settings.json");
2038 let parsed: serde_json::Value =
2039 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2040 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2041 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2042 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2043 assert!(cmd.contains("/new/path/sqz"));
2044 assert!(!cmd.contains("/old/path/sqz"));
2045 });
2046 }
2047
2048 #[test]
2049 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2050 let tmp = tempfile::tempdir().unwrap();
2051 let settings = tmp.path().join(".claude").join("settings.json");
2052 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2053 std::fs::write(
2054 &settings,
2055 serde_json::json!({
2056 "permissions": { "allow": ["Bash(git status)"] },
2057 "hooks": {
2058 "PreToolUse": [
2059 {
2060 "matcher": "Edit",
2061 "hooks": [
2062 { "type": "command", "command": "~/format.sh" }
2063 ]
2064 }
2065 ]
2066 }
2067 })
2068 .to_string(),
2069 )
2070 .unwrap();
2071
2072 with_fake_home(tmp.path(), || {
2073 install_claude_global("/usr/local/bin/sqz").unwrap();
2075 let result = remove_claude_global_hook().unwrap().unwrap();
2077 assert_eq!(result.0, settings);
2078 assert!(result.1, "should report that the file was modified");
2079
2080 assert!(settings.exists(), "settings.json should be preserved");
2082 let parsed: serde_json::Value =
2083 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2084
2085 assert_eq!(
2087 parsed["permissions"]["allow"][0].as_str().unwrap(),
2088 "Bash(git status)"
2089 );
2090
2091 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2093 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2094 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2095
2096 assert!(parsed["hooks"].get("PreCompact").is_none());
2098 assert!(parsed["hooks"].get("SessionStart").is_none());
2099 });
2100 }
2101
2102 #[test]
2103 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2104 let tmp = tempfile::tempdir().unwrap();
2108 with_fake_home(tmp.path(), || {
2109 install_claude_global("sqz").unwrap();
2110 let path = tmp.path().join(".claude").join("settings.json");
2111 assert!(path.exists(), "precondition: install created the file");
2112
2113 let result = remove_claude_global_hook().unwrap().unwrap();
2114 assert!(result.1);
2115 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2116 });
2117 }
2118
2119 #[test]
2120 fn global_uninstall_on_missing_file_is_noop() {
2121 let tmp = tempfile::tempdir().unwrap();
2122 with_fake_home(tmp.path(), || {
2123 assert!(
2124 remove_claude_global_hook().unwrap().is_none(),
2125 "missing file should return None, not error"
2126 );
2127 });
2128 }
2129
2130 #[test]
2131 fn global_uninstall_refuses_to_touch_unparseable_file() {
2132 let tmp = tempfile::tempdir().unwrap();
2136 let settings = tmp.path().join(".claude").join("settings.json");
2137 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2138 std::fs::write(&settings, "{ invalid json because").unwrap();
2139
2140 with_fake_home(tmp.path(), || {
2141 assert!(
2142 remove_claude_global_hook().is_err(),
2143 "bad JSON must surface as an error"
2144 );
2145 });
2146
2147 let after = std::fs::read_to_string(&settings).unwrap();
2149 assert_eq!(after, "{ invalid json because");
2150 }
2151}
2152
2153#[cfg(test)]
2154mod issue_11_tool_filter_tests {
2155 use super::*;
2164
2165 #[test]
2166 fn canonicalize_collapses_common_aliases() {
2167 for aliases in &[
2170 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2171 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2172 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2173 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2177 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2178 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2179 (vec!["Codex", "codex"], "codex"),
2180 ] {
2181 for alias in &aliases.0 {
2182 assert_eq!(
2183 canonicalize_tool_name(alias),
2184 aliases.1,
2185 "alias '{}' must canonicalise to '{}'",
2186 alias,
2187 aliases.1
2188 );
2189 }
2190 }
2191 }
2192
2193 #[test]
2194 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2195 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2200 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2201 }
2202
2203 #[test]
2204 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2205 let names = parse_tool_list("opencode,codex").unwrap();
2208 assert_eq!(names, vec!["opencode", "codex"]);
2209
2210 let names = parse_tool_list(" opencode , codex ").unwrap();
2211 assert_eq!(names, vec!["opencode", "codex"]);
2212
2213 let names = parse_tool_list("opencode").unwrap();
2215 assert_eq!(names, vec!["opencode"]);
2216
2217 let names = parse_tool_list("claude-code").unwrap();
2219 assert_eq!(names, vec!["claudecode"]);
2220 }
2221
2222 #[test]
2223 fn parse_tool_list_dedupes_repeated_entries() {
2224 let names = parse_tool_list("opencode,opencode").unwrap();
2229 assert_eq!(names, vec!["opencode"]);
2230
2231 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2234 assert_eq!(names, vec!["claudecode"]);
2235 }
2236
2237 #[test]
2238 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2239 let err = parse_tool_list("opncode").unwrap_err();
2246 let msg = err.to_string();
2247 assert!(
2248 msg.contains("unknown agent name 'opncode'"),
2249 "error must quote the bad input: {msg}"
2250 );
2251 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2252 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2253 }
2254
2255 #[test]
2256 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2257 let err = parse_tool_list("opencode,xyz").unwrap_err();
2262 assert!(err.to_string().contains("xyz"));
2263 }
2264
2265 #[test]
2266 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2267 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2273 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2274 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2275 }
2276
2277 #[test]
2278 fn tool_filter_all_includes_every_supported_tool() {
2279 let filter = ToolFilter::All;
2280 for tool in SUPPORTED_TOOL_NAMES {
2281 assert!(
2282 filter.includes(tool),
2283 "default filter must include {tool}"
2284 );
2285 }
2286 }
2287
2288 #[test]
2289 fn tool_filter_only_opencode_excludes_everything_else() {
2290 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2292 assert!(filter.includes("OpenCode"));
2293 for tool in SUPPORTED_TOOL_NAMES {
2295 if *tool == "OpenCode" {
2296 continue;
2297 }
2298 assert!(
2299 !filter.includes(tool),
2300 "--only opencode must not include {tool}"
2301 );
2302 }
2303 }
2304
2305 #[test]
2306 fn tool_filter_only_multi_tool_includes_exactly_those() {
2307 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2308 assert!(filter.includes("OpenCode"));
2309 assert!(filter.includes("Codex"));
2310 assert!(!filter.includes("Claude Code"));
2312 assert!(!filter.includes("Cursor"));
2313 assert!(!filter.includes("Windsurf"));
2314 assert!(!filter.includes("Cline"));
2315 assert!(!filter.includes("Gemini CLI"));
2316 }
2317
2318 #[test]
2319 fn tool_filter_skip_inverts_the_set() {
2320 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2323 assert!(!filter.includes("Cursor"));
2324 assert!(!filter.includes("Windsurf"));
2325 assert!(filter.includes("Claude Code"));
2327 assert!(filter.includes("Cline"));
2328 assert!(filter.includes("Gemini CLI"));
2329 assert!(filter.includes("OpenCode"));
2330 assert!(filter.includes("Codex"));
2331 }
2332
2333 #[test]
2334 fn tool_filter_only_empty_excludes_everything() {
2335 let filter = ToolFilter::Only(vec![]);
2341 for tool in SUPPORTED_TOOL_NAMES {
2342 assert!(
2343 !filter.includes(tool),
2344 "empty --only must exclude every tool, got {tool}"
2345 );
2346 }
2347 }
2348
2349 #[test]
2350 fn tool_filter_only_accepts_display_name_or_canonical() {
2351 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2357 assert!(filter.includes("Claude Code"));
2358 assert!(!filter.includes("Cursor"));
2359
2360 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2361 assert!(filter.includes("Gemini CLI"));
2362 }
2363
2364 #[test]
2365 fn supported_tool_names_matches_generate_hook_configs_exactly() {
2366 let configs = generate_hook_configs("sqz");
2372 let emitted: std::collections::HashSet<&str> =
2373 configs.iter().map(|c| c.tool_name.as_str()).collect();
2374 let declared: std::collections::HashSet<&str> =
2375 SUPPORTED_TOOL_NAMES.iter().copied().collect();
2376 assert_eq!(
2377 emitted, declared,
2378 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2379 from generate_hook_configs. emitted={:?}, declared={:?}",
2380 emitted, declared
2381 );
2382 }
2383
2384 #[test]
2385 fn filtered_install_only_opencode_writes_only_opencode_files() {
2386 let dir = tempfile::tempdir().unwrap();
2392 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2393 let _installed = install_tool_hooks_scoped_filtered(
2394 dir.path(),
2395 "sqz",
2396 InstallScope::Project,
2397 &filter,
2398 );
2399
2400 assert!(
2402 dir.path().join("opencode.json").exists(),
2403 "OpenCode config must be written when --only opencode is used"
2404 );
2405
2406 for (path, tool) in &[
2408 (".claude/settings.local.json", "Claude Code"),
2409 (".cursor/rules/sqz.mdc", "Cursor"),
2410 (".windsurfrules", "Windsurf"),
2411 (".clinerules", "Cline"),
2412 (".gemini/settings.json", "Gemini CLI"),
2413 ("AGENTS.md", "Codex"),
2414 ] {
2415 assert!(
2416 !dir.path().join(path).exists(),
2417 "filter rejected {tool} but the installer still wrote {path}"
2418 );
2419 }
2420 }
2421
2422 #[test]
2423 fn filtered_install_skip_cursor_omits_only_cursor() {
2424 let dir = tempfile::tempdir().unwrap();
2426 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
2427 let _installed = install_tool_hooks_scoped_filtered(
2428 dir.path(),
2429 "sqz",
2430 InstallScope::Project,
2431 &filter,
2432 );
2433
2434 assert!(
2436 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
2437 "skip cursor: .cursor/rules/sqz.mdc must not be written"
2438 );
2439 assert!(
2441 dir.path().join(".windsurfrules").exists(),
2442 "skip cursor should not skip windsurf"
2443 );
2444 assert!(
2445 dir.path().join(".clinerules").exists(),
2446 "skip cursor should not skip cline"
2447 );
2448 }
2449}
2450