1use std::path::{Path, PathBuf};
27
28use crate::error::Result;
29
30#[derive(Debug, Clone)]
32pub struct ToolHookConfig {
33 pub tool_name: String,
35 pub config_path: PathBuf,
37 pub config_content: String,
39 pub scope: HookScope,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HookScope {
45 Project,
47 User,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum HookPlatform {
55 ClaudeCode,
57 Cursor,
59 GeminiCli,
61 Windsurf,
63 Kiro,
65 Copilot,
68}
69
70pub fn process_hook(input: &str) -> Result<String> {
89 process_hook_for_platform(input, HookPlatform::ClaudeCode)
90}
91
92pub fn process_hook_cursor(_input: &str) -> Result<String> {
95 Err(crate::error::SqzError::Other(
96 "can't rewrite without approving the command; Cursor's own approval applies".into(),
97 ))
98}
99
100pub fn process_hook_gemini(input: &str) -> Result<String> {
104 process_hook_for_platform(input, HookPlatform::GeminiCli)
105}
106
107pub fn process_hook_windsurf(input: &str) -> Result<String> {
112 process_hook_for_platform(input, HookPlatform::Windsurf)
113}
114
115pub fn process_hook_kiro(input: &str) -> Result<String> {
125 process_hook_for_platform(input, HookPlatform::Kiro)
126}
127
128pub fn process_hook_copilot(input: &str) -> Result<String> {
135 process_hook_for_platform(input, HookPlatform::Copilot)
136}
137
138fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
142 if platform == HookPlatform::Cursor {
143 return process_hook_cursor(input);
144 }
145 let mut parsed: serde_json::Value = serde_json::from_str(input)
146 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
147 if let Some(args) = parsed
149 .get("toolArgs")
150 .and_then(|v| v.as_str())
151 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
152 .filter(serde_json::Value::is_object)
153 {
154 parsed["toolArgs"] = args;
155 }
156
157 let tool_name = parsed
161 .get("tool_name")
162 .or_else(|| parsed.get("toolName"))
163 .and_then(|v| v.as_str())
164 .unwrap_or("");
165
166 let hook_event = parsed
167 .get("hook_event_name")
168 .or_else(|| parsed.get("agent_action_name"))
169 .and_then(|v| v.as_str())
170 .unwrap_or("");
171
172 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
181 | "run_terminal_command" | "run_shell_command" | "execute_bash"
182 | "PowerShell" | "powershell" | "pwsh")
183 || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
184
185 if !is_shell {
186 return Ok(passthrough(platform, input));
188 }
189
190 let command = parsed
195 .get("tool_input")
196 .and_then(|v| v.get("command"))
197 .and_then(|v| v.as_str())
198 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
199 .or_else(|| {
200 parsed
201 .get("tool_info")
202 .and_then(|v| v.get("command_line"))
203 .and_then(|v| v.as_str())
204 })
205 .or_else(|| {
206 parsed
207 .get("toolCall")
208 .and_then(|v| v.get("command"))
209 .and_then(|v| v.as_str())
210 })
211 .or_else(|| {
212 parsed
214 .get("toolArgs")
215 .and_then(|v| v.get("command"))
216 .and_then(|v| v.as_str())
217 })
218 .unwrap_or("");
219
220 if command.is_empty() {
221 return Ok(passthrough(platform, input));
222 }
223
224 let base_cmd = extract_base_command(command);
236 if base_cmd == "sqz"
237 || command.starts_with("SQZ_CMD=")
238 || command.contains("sqz compress --cmd ")
239 || command.contains("sqz.exe compress --cmd ")
240 {
241 return Ok(passthrough(platform, input));
242 }
243
244 if runs_in_background(&parsed) {
247 return Ok(passthrough(platform, input));
248 }
249
250 let is_powershell = matches!(tool_name, "PowerShell" | "powershell" | "pwsh");
259 let rewritten = if platform == HookPlatform::ClaudeCode {
260 let settings = claude_settings_files(&parsed);
261 let shell = crate::shell_scan::Shell::from_env();
262 match claude_rewrite(&parsed, command, is_powershell, &settings, shell) {
263 Some(rewritten) => rewritten,
264 None => return Ok(passthrough(platform, input)),
265 }
266 } else if is_powershell {
267 if is_interactive_command(command) || has_shell_operators(command) {
269 return Ok(passthrough(platform, input));
270 }
271 crate::exit_marker::powershell_rewrite(command, &shell_escape(command))
272 } else {
273 match crate::shell_scan::plan(command) {
274 Ok(plan) => posix_rewrite_plan(&plan),
275 Err(_) => return Ok(passthrough(platform, input)),
276 }
277 };
278
279 let mut updated_input = parsed
284 .get("tool_input")
285 .or_else(|| parsed.get("toolArgs"))
286 .and_then(|v| v.as_object())
287 .cloned()
288 .unwrap_or_default();
289 updated_input.insert(
290 "command".to_string(),
291 serde_json::Value::String(rewritten.clone()),
292 );
293 let updated_input = serde_json::Value::Object(updated_input);
294
295 let output = match platform {
319 HookPlatform::ClaudeCode => serde_json::json!({
320 "hookSpecificOutput": {
321 "hookEventName": "PreToolUse",
322 "updatedInput": updated_input
323 }
324 }),
325 HookPlatform::Cursor => return process_hook_cursor(input),
326 HookPlatform::GeminiCli => serde_json::json!({
327 "hookSpecificOutput": {
328 "tool_input": updated_input
329 }
330 }),
331 HookPlatform::Windsurf => {
332 serde_json::json!({
336 "hookSpecificOutput": {
337 "hookEventName": "PreToolUse",
338 "updatedInput": updated_input
339 }
340 })
341 }
342 HookPlatform::Copilot => serde_json::json!({
343 "modifiedArgs": updated_input
344 }),
345 HookPlatform::Kiro => {
346 serde_json::json!({
350 "tool_input": {
351 "command": rewritten
352 }
353 })
354 }
355 };
356
357 serde_json::to_string(&output)
358 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
359}
360
361const SQZ_RULES_SENTINEL: &str = "<!-- sqz-rules (auto-installed by sqz init) -->";
362const LEGACY_RULES_HEADING: &str = "# sqz \u{2014} Token-Optimized CLI Output";
363const LEGACY_CURSOR_FRONTMATTER: &str = "---\ndescription: sqz \u{2014} Token-Optimized CLI Output\n";
364const LEGACY_RULES_LAST_LINES: [&str; 3] = [
365 "path is still what you want for most output.",
366 "not on PATH, run commands normally.",
367 "If sqz is not available, run commands normally.",
368];
369
370pub fn sqz_rules_content(tool_name: &str, sqz_path: &str) -> String {
371 if tool_name == "Cline" {
372 return crate::codex_integration::agents_md_guidance_block(sqz_path);
373 }
374 let body = format!(
375 "{SQZ_RULES_SENTINEL}\n\n{}",
376 crate::codex_integration::sqz_guidance(sqz_path)
377 );
378 if tool_name == "Cursor" {
379 format!("---\ndescription: sqz output compression\nalwaysApply: true\n---\n{body}")
380 } else {
381 body
382 }
383}
384
385fn is_sqz_rules(content: &str) -> bool {
386 content.contains(SQZ_RULES_SENTINEL)
387 || content.starts_with(LEGACY_RULES_HEADING)
388 || content.replace("\r\n", "\n").starts_with(LEGACY_CURSOR_FRONTMATTER)
389}
390
391fn legacy_rules_end(content: &str) -> Option<usize> {
392 if !content.starts_with(LEGACY_RULES_HEADING) {
393 return None;
394 }
395 LEGACY_RULES_LAST_LINES.iter().find_map(|last| {
396 let mut start = 0;
397 content.split_inclusive('\n').find_map(|line| {
398 let text = line.trim_end();
399 let end = start + text.len();
400 start += line.len();
401 text.ends_with(last).then_some(end)
402 })
403 })
404}
405
406fn without_sqz_rules(content: &str) -> Option<String> {
407 match legacy_rules_end(content) {
408 Some(end) => Some(content[end..].trim_start_matches(['\r', '\n']).to_string()),
409 None => crate::codex_integration::without_guidance(content),
410 }
411}
412
413pub fn has_sqz_rules(path: &Path) -> bool {
414 std::fs::read_to_string(path)
415 .ok()
416 .and_then(|content| without_sqz_rules(&content))
417 .is_some()
418}
419
420pub fn remove_sqz_rules(path: &Path) -> Result<bool> {
421 let Ok(content) = std::fs::read_to_string(path) else {
422 return Ok(false);
423 };
424 let Some(rest) = without_sqz_rules(&content) else {
425 return Ok(false);
426 };
427 if rest.trim().is_empty() {
428 std::fs::remove_file(path).map_err(|e| {
429 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
430 })?;
431 } else {
432 std::fs::write(path, rest)
433 .map_err(|e| crate::error::SqzError::Other(format!("write {}: {e}", path.display())))?;
434 }
435 Ok(true)
436}
437
438pub fn sqz_rules_path(project_dir: &Path, tool_name: &str) -> Option<PathBuf> {
439 match tool_name {
440 "Cursor" => Some(project_dir.join(".cursor").join("rules").join("sqz.mdc")),
441 "Windsurf" => Some(project_dir.join(".windsurf").join("rules").join("sqz.md")),
442 "Cline" => {
443 let base = project_dir.join(".clinerules");
444 Some(if base.is_dir() { base.join("sqz.md") } else { base })
445 }
446 _ => None,
447 }
448}
449
450pub fn sqz_rules_state(path: &Path, tool_name: &str) -> crate::kiro_integration::SteeringState {
451 use crate::kiro_integration::SteeringState;
452 let Ok(content) = std::fs::read_to_string(path) else {
453 return SteeringState::Missing;
454 };
455 if tool_name == "Cline" {
456 return if legacy_rules_end(&content).is_some() {
457 SteeringState::Stale
458 } else {
459 crate::codex_integration::guidance_state(&content)
460 };
461 }
462 if !is_sqz_rules(&content) {
463 return SteeringState::UserAuthored;
464 }
465 match crate::kiro_integration::embedded_sqz_path(&content) {
466 Some(p) if content.replace("\r\n", "\n") == sqz_rules_content(tool_name, p) => SteeringState::Current,
467 _ => SteeringState::Stale,
468 }
469}
470
471pub fn refresh_sqz_rules(path: &Path, tool_name: &str, fallback_sqz_path: &str) -> Result<bool> {
472 if sqz_rules_state(path, tool_name) != crate::kiro_integration::SteeringState::Stale {
473 return Ok(false);
474 }
475 let existing = std::fs::read_to_string(path)
476 .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
477 if tool_name == "Cline" && legacy_rules_end(&existing).is_none() {
478 return crate::codex_integration::refresh_agents_md_guidance(path, fallback_sqz_path);
479 }
480 let sqz_path = crate::kiro_integration::embedded_sqz_path(&existing)
481 .filter(|p| Path::new(p).is_file())
482 .unwrap_or(fallback_sqz_path)
483 .to_string();
484 install_sqz_rules(path, tool_name, &sqz_path)
485}
486
487pub fn sqz_rules_change(
488 path: &Path,
489 tool_name: &str,
490 sqz_path: &str,
491) -> crate::codex_integration::GuidanceChange {
492 use crate::codex_integration::GuidanceChange;
493 match rules_update(path, tool_name, sqz_path) {
494 None => GuidanceChange::Unchanged,
495 Some(_) if path.exists() => GuidanceChange::Replace,
496 Some(_) => GuidanceChange::Create,
497 }
498}
499
500fn rules_update(path: &Path, tool_name: &str, sqz_path: &str) -> Option<String> {
501 let content = sqz_rules_content(tool_name, sqz_path);
502 let Ok(existing) = std::fs::read_to_string(path) else {
503 return (!path.exists()).then_some(content);
504 };
505 let updated = if tool_name != "Cline" {
506 if !is_sqz_rules(&existing) {
507 return None;
508 }
509 content
510 } else if let Some(end) = legacy_rules_end(&existing) {
511 format!("{}{}", content.trim_end_matches('\n'), &existing[end..])
512 } else {
513 crate::codex_integration::replaced_guidance(&existing, sqz_path)?
514 };
515 (updated != existing).then_some(updated)
516}
517
518fn install_sqz_rules(path: &Path, tool_name: &str, sqz_path: &str) -> Result<bool> {
519 let Some(content) = rules_update(path, tool_name, sqz_path) else {
520 return Ok(false);
521 };
522 if let Some(parent) = path.parent() {
523 std::fs::create_dir_all(parent).map_err(|e| {
524 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
525 })?;
526 }
527 std::fs::write(path, content)
528 .map_err(|e| crate::error::SqzError::Other(format!("write {}: {e}", path.display())))?;
529 Ok(true)
530}
531
532pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
534 let sqz_path_raw = sqz_path;
545 let sqz_path_json = json_escape_string_value(sqz_path);
546 let sqz_path = &sqz_path_json;
547
548 vec![
549 ToolHookConfig {
567 tool_name: "Claude Code".to_string(),
568 config_path: PathBuf::from(".claude/settings.local.json"),
569 config_content: claude_settings_content(sqz_path_raw),
570 scope: HookScope::Project,
571 },
572 ToolHookConfig {
581 tool_name: "Cursor".to_string(),
582 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
583 config_content: sqz_rules_content("Cursor", sqz_path_raw),
584 scope: HookScope::Project,
585 },
586 ToolHookConfig {
589 tool_name: "Windsurf".to_string(),
590 config_path: PathBuf::from(".windsurf/rules/sqz.md"),
591 config_content: sqz_rules_content("Windsurf", sqz_path_raw),
592 scope: HookScope::Project,
593 },
594 ToolHookConfig {
597 tool_name: "Cline".to_string(),
598 config_path: PathBuf::from(".clinerules"),
599 config_content: sqz_rules_content("Cline", sqz_path_raw),
600 scope: HookScope::Project,
601 },
602 ToolHookConfig {
604 tool_name: "Gemini CLI".to_string(),
605 config_path: PathBuf::from(".gemini/settings.json"),
606 config_content: format!(
607 r#"{{
608 "hooks": {{
609 "BeforeTool": [
610 {{
611 "matcher": "run_shell_command",
612 "hooks": [
613 {{
614 "type": "command",
615 "command": "{sqz_path} hook gemini"
616 }}
617 ]
618 }}
619 ]
620 }}
621}}"#
622 ),
623 scope: HookScope::Project,
624 },
625 ToolHookConfig {
630 tool_name: "Kiro".to_string(),
631 config_path: PathBuf::from(".kiro/steering/sqz.md"),
632 config_content: crate::kiro_integration::kiro_steering_content(sqz_path_raw),
633 scope: HookScope::Project,
634 },
635 ToolHookConfig {
644 tool_name: "OpenCode".to_string(),
645 config_path: PathBuf::from("opencode.json"),
646 config_content: format!(
647 r#"{{
648 "$schema": "https://opencode.ai/config.json",
649 "mcp": {{
650 "sqz": {{
651 "type": "local",
652 "command": ["sqz-mcp", "--transport", "stdio"]
653 }}
654 }},
655 "plugin": ["sqz"]
656}}"#
657 ),
658 scope: HookScope::Project,
659 },
660 ToolHookConfig {
678 tool_name: "Codex".to_string(),
679 config_path: PathBuf::from("AGENTS.md"),
680 config_content: crate::codex_integration::agents_md_guidance_block(
681 sqz_path_raw,
682 ),
683 scope: HookScope::Project,
684 },
685 ToolHookConfig {
700 tool_name: "Zed".to_string(),
701 config_path: PathBuf::from("AGENTS.md"),
702 config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
703 scope: HookScope::Project,
704 },
705 ToolHookConfig {
710 tool_name: "Copilot CLI".to_string(),
711 config_path: PathBuf::from(".copilot/hooks/sqz.json"),
712 config_content: copilot_hook_content(sqz_path_raw),
713 scope: HookScope::User,
714 },
715 ]
716}
717
718pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
724 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
750pub enum InstallScope {
751 Project,
754 Global,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
772pub enum ToolFilter {
773 All,
776 Only(Vec<String>),
780 Skip(Vec<String>),
785}
786
787impl Default for ToolFilter {
788 fn default() -> Self {
789 ToolFilter::All
790 }
791}
792
793impl ToolFilter {
794 pub fn includes(&self, tool_name: &str) -> bool {
805 let canon = canonicalize_tool_name(tool_name);
806 match self {
807 ToolFilter::All => true,
808 ToolFilter::Only(allow) => allow.iter().any(|n| {
809 n == &canon
812 }),
813 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
814 }
815 }
816}
817
818pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
823 "Claude Code",
824 "Cursor",
825 "Windsurf",
826 "Cline",
827 "Gemini CLI",
828 "Kiro",
829 "OpenCode",
830 "Codex",
831 "Zed",
832 "Copilot CLI",
833];
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum IntegrationTier {
838 Transparent,
841 Advisory,
844}
845
846pub fn integration_tier(tool_name: &str) -> IntegrationTier {
849 match canonicalize_tool_name(tool_name).as_str() {
850 "claudecode" | "copilot" | "gemini" | "opencode" => IntegrationTier::Transparent,
851 _ => IntegrationTier::Advisory,
852 }
853}
854
855pub fn copilot_hooks_path() -> PathBuf {
858 copilot_home().join("hooks").join("sqz.json")
859}
860
861fn copilot_home() -> PathBuf {
862 if let Ok(home) = std::env::var("COPILOT_HOME") {
863 if !home.trim().is_empty() {
864 return PathBuf::from(home);
865 }
866 }
867 let home = std::env::var("HOME")
868 .or_else(|_| std::env::var("USERPROFILE"))
869 .map(PathBuf::from)
870 .unwrap_or_else(|_| PathBuf::from("."));
871 home.join(".copilot")
872}
873
874pub fn ci_hooks_path(project_dir: &Path) -> PathBuf {
878 project_dir.join(".github").join("hooks").join("sqz.json")
879}
880
881pub fn ci_hook_content() -> String {
890 r#"{
891 "version": 1,
892 "hooks": {
893 "sessionStart": [
894 {
895 "type": "command",
896 "bash": "command -v sqz >/dev/null 2>&1 || curl -fsSL https://raw.githubusercontent.com/ojuschugh1/sqz/main/install.sh | sh >/dev/null 2>&1 || true",
897 "timeoutSec": 120
898 }
899 ],
900 "preToolUse": [
901 {
902 "type": "command",
903 "matcher": "bash|powershell",
904 "bash": "SQZ_BIN=$(command -v sqz || echo $HOME/.local/bin/sqz); [ -x \"$SQZ_BIN\" ] && \"$SQZ_BIN\" hook copilot 2>/dev/null || echo '{}'",
905 "timeoutSec": 10
906 }
907 ]
908 }
909}
910"#
911 .to_string()
912}
913
914pub fn install_ci_hook(project_dir: &Path) -> Result<bool> {
917 let path = ci_hooks_path(project_dir);
918 let content = ci_hook_content();
919 if path.exists() {
920 let existing = std::fs::read_to_string(&path).unwrap_or_default();
921 if existing == content || !existing.contains("hook copilot") {
922 return Ok(false);
923 }
924 }
925 if let Some(parent) = path.parent() {
926 std::fs::create_dir_all(parent).map_err(|e| {
927 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
928 })?;
929 }
930 std::fs::write(&path, content).map_err(|e| {
931 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
932 })?;
933 Ok(true)
934}
935
936pub fn remove_ci_hook(project_dir: &Path) -> Result<bool> {
938 let path = ci_hooks_path(project_dir);
939 if !path.exists() {
940 return Ok(false);
941 }
942 let content = std::fs::read_to_string(&path).unwrap_or_default();
943 if !content.contains("hook copilot") {
944 return Ok(false);
945 }
946 std::fs::remove_file(&path).map_err(|e| {
947 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
948 })?;
949 Ok(true)
950}
951
952pub fn copilot_hook_content(sqz_path: &str) -> String {
954 let escaped = json_escape_string_value(sqz_path);
955 format!(
956 r#"{{
957 "version": 1,
958 "hooks": {{
959 "preToolUse": [
960 {{
961 "type": "command",
962 "matcher": "bash|powershell",
963 "bash": "{escaped} hook copilot",
964 "powershell": "{escaped} hook copilot",
965 "timeoutSec": 10
966 }}
967 ]
968 }}
969}}
970"#
971 )
972}
973
974pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
978 install_copilot_hook_at(&copilot_home(), sqz_path)
979}
980
981fn install_copilot_hook_at(home: &Path, sqz_path: &str) -> Result<Option<bool>> {
982 if !home.exists() {
983 return Ok(None);
984 }
985 let path = home.join("hooks").join("sqz.json");
986 let content = copilot_hook_content(sqz_path);
987 if path.exists() {
988 let existing = std::fs::read_to_string(&path).unwrap_or_default();
989 if existing == content {
990 return Ok(Some(false));
991 }
992 }
993 if let Some(parent) = path.parent() {
994 std::fs::create_dir_all(parent).map_err(|e| {
995 crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
996 })?;
997 }
998 std::fs::write(&path, content).map_err(|e| {
999 crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
1000 })?;
1001 Ok(Some(true))
1002}
1003
1004pub fn remove_copilot_hook() -> Result<bool> {
1006 let path = copilot_hooks_path();
1007 if !path.exists() {
1008 return Ok(false);
1009 }
1010 std::fs::remove_file(&path).map_err(|e| {
1011 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
1012 })?;
1013 Ok(true)
1014}
1015
1016pub fn canonicalize_tool_name(name: &str) -> String {
1035 let lowered: String = name
1036 .chars()
1037 .filter(|c| !c.is_whitespace())
1038 .flat_map(|c| c.to_lowercase())
1039 .filter(|c| *c != '-' && *c != '_')
1040 .collect();
1041 match lowered.as_str() {
1042 "claude" | "claudecode" => "claudecode".to_string(),
1043 "cursor" => "cursor".to_string(),
1044 "windsurf" => "windsurf".to_string(),
1045 "cline" | "roo" | "roocode" => "cline".to_string(),
1049 "gemini" | "geminicli" => "gemini".to_string(),
1050 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
1051 "opencode" => "opencode".to_string(),
1052 "codex" => "codex".to_string(),
1053 "zed" | "zededitor" | "zedagent" => "zed".to_string(),
1054 "copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
1055 other => other.to_string(),
1056 }
1057}
1058
1059pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
1069 let mut out = Vec::new();
1070 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
1071 .iter()
1072 .map(|n| canonicalize_tool_name(n))
1073 .collect();
1074 for part in raw.split(',') {
1075 let trimmed = part.trim();
1076 if trimmed.is_empty() {
1077 continue;
1078 }
1079 let canon = canonicalize_tool_name(trimmed);
1080 if !known.contains(&canon) {
1081 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
1082 .iter()
1083 .map(|n| canonicalize_tool_name(n))
1084 .collect();
1085 return Err(crate::error::SqzError::Other(format!(
1086 "unknown agent name '{}'. Valid options: {}",
1087 trimmed,
1088 valid.join(", ")
1089 )));
1090 }
1091 if !out.contains(&canon) {
1092 out.push(canon);
1093 }
1094 }
1095 Ok(out)
1096}
1097
1098pub fn install_tool_hooks_scoped(
1124 project_dir: &Path,
1125 sqz_path: &str,
1126 scope: InstallScope,
1127) -> Vec<String> {
1128 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
1129}
1130
1131pub fn install_tool_hooks_scoped_filtered(
1145 project_dir: &Path,
1146 sqz_path: &str,
1147 scope: InstallScope,
1148 filter: &ToolFilter,
1149) -> Vec<String> {
1150 install_tool_hooks_at(project_dir, sqz_path, scope, filter, None)
1151}
1152
1153fn install_tool_hooks_at(
1155 project_dir: &Path,
1156 sqz_path: &str,
1157 scope: InstallScope,
1158 filter: &ToolFilter,
1159 home_override: Option<&Path>,
1160) -> Vec<String> {
1161 let configs = generate_hook_configs(sqz_path);
1162 let mut installed = Vec::new();
1163 let kiro_root =
1164 home_override.map_or_else(crate::kiro_integration::kiro_user_root, |h| h.join(".kiro"));
1165
1166 for config in &configs {
1167 if !filter.includes(&config.tool_name) {
1171 continue;
1172 }
1173
1174 if config.tool_name == "OpenCode" {
1183 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
1184 Ok((updated, _comments_lost)) => {
1185 if updated && !installed.iter().any(|n| n == "OpenCode") {
1186 installed.push("OpenCode".to_string());
1187 }
1188 }
1189 Err(_e) => {
1190 }
1193 }
1194 continue;
1195 }
1196
1197 if config.tool_name == "Codex" {
1202 let agents_changed = crate::codex_integration::install_agents_md_guidance(
1203 project_dir, sqz_path,
1204 )
1205 .unwrap_or(false);
1206 let mcp_changed = crate::codex_integration::install_codex_mcp_config_at(
1207 home_override.map(|h| h.join(".codex")).as_deref(),
1208 )
1209 .unwrap_or(false);
1210 if (agents_changed || mcp_changed)
1211 && !installed.iter().any(|n| n == "Codex")
1212 {
1213 installed.push("Codex".to_string());
1214 }
1215 continue;
1216 }
1217
1218 if config.tool_name == "Windsurf" {
1221 let legacy = project_dir.join(".windsurfrules");
1222 let target = project_dir.join(&config.config_path);
1223 let mut changed = install_sqz_rules(&target, "Windsurf", sqz_path).unwrap_or(false);
1224 if target.exists() && remove_sqz_rules(&legacy).unwrap_or(false) {
1225 changed = true;
1226 }
1227 if changed && !installed.iter().any(|n| n == "Windsurf") {
1228 installed.push("Windsurf".to_string());
1229 }
1230 continue;
1231 }
1232
1233 if config.tool_name == "Cursor" {
1234 let target = project_dir.join(&config.config_path);
1235 if install_sqz_rules(&target, "Cursor", sqz_path).unwrap_or(false)
1236 && !installed.iter().any(|n| n == "Cursor")
1237 {
1238 installed.push("Cursor".to_string());
1239 }
1240 continue;
1241 }
1242
1243 if config.tool_name == "Cline" {
1246 let Some(target) = sqz_rules_path(project_dir, "Cline") else {
1247 continue;
1248 };
1249 if install_sqz_rules(&target, "Cline", sqz_path).unwrap_or(false)
1250 && !installed.iter().any(|n| n == "Cline")
1251 {
1252 installed.push("Cline".to_string());
1253 }
1254 continue;
1255 }
1256
1257 if config.tool_name == "Copilot CLI" {
1259 let copilot_root = home_override.map_or_else(copilot_home, |h| h.join(".copilot"));
1260 if let Ok(Some(changed)) = install_copilot_hook_at(&copilot_root, sqz_path) {
1261 if changed && !installed.iter().any(|n| n == "Copilot CLI") {
1262 installed.push("Copilot CLI".to_string());
1263 }
1264 }
1265 continue;
1266 }
1267
1268 if config.tool_name == "Kiro" {
1273 let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
1274 let user_steering = kiro_root.join("steering").join("sqz.md");
1275 let (steering_changed, mcp_changed) = if scope == InstallScope::Global {
1276 let steering = crate::kiro_integration::install_kiro_steering_at(
1277 &user_steering,
1278 &crate::kiro_integration::kiro_user_steering_content(sqz_path),
1279 )
1280 .unwrap_or(false);
1281 let mcp = matches!(
1282 crate::kiro_integration::install_kiro_mcp_config_at(
1283 &kiro_root.join("settings").join("mcp.json")
1284 ),
1285 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1286 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1287 );
1288 (steering, mcp)
1289 } else {
1290 let user_installed = matches!(
1292 crate::kiro_integration::kiro_steering_state(&user_steering, true),
1293 crate::kiro_integration::SteeringState::Current
1294 | crate::kiro_integration::SteeringState::Stale
1295 );
1296 let steering = !user_installed
1297 && crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
1298 .unwrap_or(false);
1299 let mcp = matches!(
1300 crate::kiro_integration::install_kiro_mcp_config(project_dir),
1301 Ok(crate::kiro_integration::KiroMcpInstall::Created)
1302 | Ok(crate::kiro_integration::KiroMcpInstall::Merged)
1303 );
1304 (steering, mcp)
1305 };
1306 if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
1307 installed.push("Kiro".to_string());
1308 }
1309 continue;
1310 }
1311
1312 if config.tool_name == "Zed" {
1317 let guidance_changed =
1318 crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
1319 .unwrap_or(false);
1320 let zed_settings = home_override.map_or_else(
1321 crate::zed_integration::zed_settings_path,
1322 |h| h.join(".config").join("zed").join("settings.json"),
1323 );
1324 let mcp_changed = matches!(
1325 crate::zed_integration::install_zed_mcp_config_at(&zed_settings),
1326 Ok(crate::zed_integration::ZedMcpInstall::Created)
1327 | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1328 );
1329 if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1330 installed.push("Zed".to_string());
1331 }
1332 continue;
1333 }
1334
1335 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1345 let hook_installed = match install_claude_global_at(sqz_path, home_override) {
1346 Ok(v) => v,
1347 Err(_) => false,
1348 };
1349 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1350 project_dir, sqz_path,
1351 )
1352 .unwrap_or(false);
1353 let mcp_changed =
1354 crate::claude_md_integration::install_claude_mcp_config_at(home_override)
1355 .unwrap_or(false);
1356 if (hook_installed || md_changed || mcp_changed)
1357 && !installed.iter().any(|n| n == "Claude Code")
1358 {
1359 installed.push("Claude Code".to_string());
1360 }
1361 continue;
1362 }
1363
1364 if config.tool_name == "Claude Code"
1366 && home_override
1367 .map(|h| h.join(".claude").join("settings.json"))
1368 .or_else(claude_user_settings_path)
1369 .is_some_and(|p| claude_settings_have_sqz_hooks(&p))
1370 {
1371 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1372 project_dir, sqz_path,
1373 )
1374 .unwrap_or(false);
1375 let mcp_changed =
1376 crate::claude_md_integration::install_claude_mcp_config_at(home_override)
1377 .unwrap_or(false);
1378 if (md_changed || mcp_changed) && !installed.iter().any(|n| n == "Claude Code") {
1379 installed.push("Claude Code".to_string());
1380 }
1381 continue;
1382 }
1383
1384 let full_path = project_dir.join(&config.config_path);
1385
1386 if full_path.exists() {
1388 if config.tool_name == "Claude Code" {
1397 let hook_changed =
1398 merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1399 let md_changed =
1400 crate::claude_md_integration::install_claude_md_guidance(
1401 project_dir, sqz_path,
1402 )
1403 .unwrap_or(false);
1404 let mcp_changed =
1405 crate::claude_md_integration::install_claude_mcp_config_at(home_override)
1406 .unwrap_or(false);
1407 if (hook_changed || md_changed || mcp_changed)
1408 && !installed.iter().any(|n| n == "Claude Code")
1409 {
1410 installed.push("Claude Code".to_string());
1411 }
1412 }
1413 if config.tool_name == "Gemini CLI"
1414 && merge_sqz_gemini_hook(&full_path, sqz_path).unwrap_or(false)
1415 {
1416 installed.push(config.tool_name.clone());
1417 }
1418 continue;
1419 }
1420
1421 if let Some(parent) = full_path.parent() {
1423 if std::fs::create_dir_all(parent).is_err() {
1424 continue;
1425 }
1426 }
1427
1428 if std::fs::write(&full_path, &config.config_content).is_ok() {
1429 installed.push(config.tool_name.clone());
1430 if config.tool_name == "Claude Code" {
1437 let _ = crate::claude_md_integration::install_claude_md_guidance(
1438 project_dir, sqz_path,
1439 );
1440 let _ = crate::claude_md_integration::install_claude_mcp_config_at(home_override);
1441 }
1442 }
1443 }
1444
1445 if filter.includes("OpenCode") {
1456 let plugin = home_override.map_or_else(
1457 crate::opencode_plugin::opencode_plugin_path,
1458 |h| h.join(".config").join("opencode").join("plugins").join("sqz.ts"),
1459 );
1460 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin_at(&plugin, sqz_path) {
1461 if !installed.iter().any(|n| n == "OpenCode") {
1462 installed.push("OpenCode".to_string());
1463 }
1464 }
1465 }
1466
1467 installed
1468}
1469
1470pub fn claude_user_settings_path() -> Option<PathBuf> {
1483 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1484}
1485
1486fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1506 let path = match home_override {
1507 Some(h) => h.join(".claude").join("settings.json"),
1508 None => claude_user_settings_path().ok_or_else(|| {
1509 crate::error::SqzError::Other(
1510 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1511 )
1512 })?,
1513 };
1514 merge_sqz_hooks_into_settings(&path, sqz_path)
1515}
1516
1517pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1530 if !path.exists() {
1531 return Ok(true);
1532 }
1533 let content = std::fs::read_to_string(path).map_err(|e| {
1534 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1535 })?;
1536 let mut root: serde_json::Value = if content.trim().is_empty() {
1537 serde_json::Value::Object(serde_json::Map::new())
1538 } else {
1539 serde_json::from_str(&content).map_err(|e| {
1540 crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1541 })?
1542 };
1543 let Some(root_obj) = root.as_object_mut() else {
1544 return Err(crate::error::SqzError::Other(format!(
1545 "{} is not a JSON object",
1546 path.display()
1547 )));
1548 };
1549
1550 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1551 let hooks = root_obj
1552 .entry("hooks".to_string())
1553 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1554 let Some(hooks_obj) = hooks.as_object_mut() else {
1555 return Ok(true);
1556 };
1557 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1558 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1559 }
1560 upsert_claude_allow_rules(root_obj);
1561 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1562 Ok(before != after)
1563}
1564
1565fn claude_settings_content(sqz_path: &str) -> String {
1567 let mut hooks = serde_json::Map::new();
1568 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1569 upsert_sqz_hook_entry(&mut hooks, event, entry, sentinel);
1570 }
1571 let mut root = serde_json::Map::new();
1572 root.insert("hooks".to_string(), serde_json::Value::Object(hooks));
1573 upsert_claude_allow_rules(&mut root);
1574 serde_json::to_string_pretty(&serde_json::Value::Object(root))
1575 .expect("a map of strings always serializes")
1576}
1577
1578fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 4] {
1582 [
1583 (
1584 "PreToolUse",
1585 serde_json::json!({
1586 "matcher": "Bash|PowerShell",
1587 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1588 }),
1589 "hook claude",
1590 ),
1591 (
1592 "PostToolUse",
1593 serde_json::json!({
1594 "matcher": "Bash",
1595 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude-post") }]
1596 }),
1597 "hook claude-post",
1598 ),
1599 (
1600 "PreCompact",
1601 serde_json::json!({
1602 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1603 }),
1604 "hook precompact",
1605 ),
1606 (
1607 "SessionStart",
1608 serde_json::json!({
1609 "matcher": "compact",
1610 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1611 }),
1612 "resume",
1613 ),
1614 ]
1615}
1616
1617fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1626 let mut content = String::new();
1628 let mut root: serde_json::Value = if path.exists() {
1629 content = std::fs::read_to_string(&path).map_err(|e| {
1630 crate::error::SqzError::Other(format!(
1631 "read {}: {e}",
1632 path.display()
1633 ))
1634 })?;
1635 if content.trim().is_empty() {
1636 serde_json::Value::Object(serde_json::Map::new())
1637 } else {
1638 serde_json::from_str(&content).map_err(|e| {
1639 crate::error::SqzError::Other(format!(
1640 "parse {}: {e} — please fix or move the file before re-running sqz init",
1641 path.display()
1642 ))
1643 })?
1644 }
1645 } else {
1646 serde_json::Value::Object(serde_json::Map::new())
1647 };
1648
1649 let root_obj = root.as_object_mut().ok_or_else(|| {
1652 crate::error::SqzError::Other(format!(
1653 "{} is not a JSON object — refusing to overwrite",
1654 path.display()
1655 ))
1656 })?;
1657
1658 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1660
1661 let hooks = root_obj
1663 .entry("hooks".to_string())
1664 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1665 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1666 crate::error::SqzError::Other(format!(
1667 "{}: `hooks` is not an object — refusing to overwrite",
1668 path.display()
1669 ))
1670 })?;
1671
1672 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1673 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1674 }
1675 upsert_claude_allow_rules(root_obj);
1676
1677 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1678 if before == after && path.exists() {
1679 return Ok(false);
1681 }
1682
1683 if let Some(parent) = path.parent() {
1685 std::fs::create_dir_all(parent).map_err(|e| {
1686 crate::error::SqzError::Other(format!(
1687 "create {}: {e}",
1688 parent.display()
1689 ))
1690 })?;
1691 }
1692
1693 write_json_config(path, &serde_json::Value::Object(root_obj.clone()), &content)?;
1696
1697 Ok(true)
1698}
1699
1700pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1714 remove_claude_global_hook_at(None)
1715}
1716
1717fn remove_claude_global_hook_at(
1720 home_override: Option<&Path>,
1721) -> Result<Option<(PathBuf, bool)>> {
1722 let path = match home_override {
1723 Some(h) => h.join(".claude").join("settings.json"),
1724 None => match claude_user_settings_path() {
1725 Some(p) => p,
1726 None => return Ok(None),
1727 },
1728 };
1729 remove_sqz_from_claude_settings(path, true)
1730}
1731
1732pub fn remove_claude_project_hook(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
1737 remove_sqz_from_claude_settings(project_dir.join(".claude").join("settings.local.json"), true)
1738}
1739
1740pub fn remove_claude_project_hooks_only(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
1742 remove_sqz_from_claude_settings(project_dir.join(".claude").join("settings.local.json"), false)
1743}
1744
1745pub fn claude_settings_have_sqz_hooks(path: &Path) -> bool {
1747 let Ok(text) = std::fs::read_to_string(path) else { return false };
1748 let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
1749 sqz_hook_entries("").iter().any(|(event, _, args)| {
1750 root.pointer(&format!("/hooks/{event}"))
1751 .and_then(|v| v.as_array())
1752 .is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, args)))
1753 })
1754}
1755
1756pub fn claude_settings_have_sqz(path: &Path) -> bool {
1758 let Ok(text) = std::fs::read_to_string(path) else { return false };
1759 match serde_json::from_str::<serde_json::Value>(&text) {
1760 Ok(mut root) => root.as_object_mut().is_some_and(|o| strip_sqz_claude_entries(o, true)),
1761 Err(_) => text.contains("sqz"),
1762 }
1763}
1764
1765pub fn claude_user_hooks_installed() -> bool {
1767 claude_user_settings_path().is_some_and(|p| claude_settings_have_sqz_hooks(&p))
1768}
1769
1770fn remove_sqz_from_claude_settings(path: PathBuf, rules: bool) -> Result<Option<(PathBuf, bool)>> {
1773 if !path.exists() {
1774 return Ok(None);
1775 }
1776
1777 let content = std::fs::read_to_string(&path).map_err(|e| {
1778 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1779 })?;
1780 if content.trim().is_empty() {
1781 return Ok(Some((path, false)));
1782 }
1783
1784 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1785 crate::error::SqzError::Other(format!(
1786 "parse {}: {e} — refusing to rewrite an unparseable file",
1787 path.display()
1788 ))
1789 })?;
1790 let Some(root_obj) = root.as_object_mut() else {
1791 return Ok(Some((path, false)));
1792 };
1793
1794 let changed = strip_sqz_claude_entries(root_obj, rules);
1795
1796 if !changed {
1797 return Ok(Some((path, false)));
1798 }
1799
1800 if root_obj.is_empty() {
1804 std::fs::remove_file(&path).map_err(|e| {
1805 crate::error::SqzError::Other(format!(
1806 "remove {}: {e}",
1807 path.display()
1808 ))
1809 })?;
1810 return Ok(Some((path, true)));
1811 }
1812
1813 write_json_config(&path, &root, &content)?;
1815
1816 Ok(Some((path, true)))
1817}
1818
1819fn strip_sqz_claude_entries(
1821 root: &mut serde_json::Map<String, serde_json::Value>,
1822 rules: bool,
1823) -> bool {
1824 let mut changed = rules && remove_claude_allow_rules(root);
1825 let Some(hooks) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
1826 return changed;
1827 };
1828 let mut emptied = false;
1829 for (event, _, sentinel) in &sqz_hook_entries("") {
1830 let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) else {
1831 continue;
1832 };
1833 if !remove_sqz_hook_commands(arr, sentinel) {
1834 continue;
1835 }
1836 changed = true;
1837 if arr.is_empty() {
1838 hooks.remove(*event);
1839 emptied = true;
1840 }
1841 }
1842 if emptied && hooks.is_empty() {
1843 root.remove("hooks");
1844 }
1845 changed
1846}
1847
1848fn sqz_command_binary<'a>(command: &'a str, args: &str) -> Option<&'a str> {
1850 let bin = command.strip_suffix(args)?.strip_suffix(' ')?;
1851 let path = unquoted(bin);
1852 let absolute = path.starts_with(['/', '\\']) || path.as_bytes().get(1) == Some(&b':');
1853 if path.contains(['"', '\'', '$', '`', '\n'])
1854 || (path == bin
1855 && (bin.contains([';', '&', '|', '<', '>', '(', ')'])
1856 || (bin.contains(char::is_whitespace) && !absolute)
1857 || bin
1858 .split_whitespace()
1859 .skip(1)
1860 .any(|w| w.starts_with(['/', '-', '~']) || w.contains('='))))
1861 {
1862 return None;
1863 }
1864 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
1865 let stem = match name.len().checked_sub(4) {
1866 Some(i) if name.is_char_boundary(i) && name[i..].eq_ignore_ascii_case(".exe") => &name[..i],
1867 _ => name,
1868 };
1869 (stem == "sqz").then_some(bin)
1870}
1871
1872fn unquoted(bin: &str) -> &str {
1874 ['"', '\'']
1875 .into_iter()
1876 .find_map(|q| bin.strip_prefix(q)?.strip_suffix(q))
1877 .unwrap_or(bin)
1878}
1879
1880pub fn resolve_sqz_binary(bin: &str) -> Option<PathBuf> {
1882 let path = unquoted(bin);
1883 if !path.contains(['/', '\\']) {
1884 let dirs = std::env::var_os("PATH")?;
1885 return std::env::split_paths(&dirs).find_map(|dir| {
1886 let exe = dir.join(format!("{path}.exe"));
1887 [dir.join(path)]
1888 .into_iter()
1889 .chain(cfg!(windows).then_some(exe))
1890 .find(|p| p.is_file())
1891 });
1892 }
1893 let file = match path.strip_prefix("~/").filter(|_| path == bin) {
1894 Some(rest) => PathBuf::from(std::env::var_os("HOME")?).join(rest),
1895 None => PathBuf::from(path),
1896 };
1897 file.is_file().then_some(file)
1898}
1899
1900const LEGACY_CLINE_SCRIPT: &str = "#!/usr/bin/env bash\n# sqz hook for Cline \u{2014} passes through all tool calls.\n# Cline's PreToolUse cannot rewrite commands, so compression\n# is handled by the sqz-mcp server instead.\necho '{\"cancel\": false}'\nexit 0\n";
1902
1903fn legacy_hook_files(
1905 project_dir: &Path,
1906 home_override: Option<&Path>,
1907) -> Vec<(PathBuf, Option<&'static str>)> {
1908 let mut files = vec![
1909 (project_dir.join(".cursor").join("hooks.json"), Some("hook cursor")),
1910 (project_dir.join(".windsurf").join("hooks.json"), Some("hook windsurf")),
1911 (project_dir.join(".cline").join("hooks.json"), Some("hook cline")),
1912 (project_dir.join(".clinerules").join("hooks").join("PreToolUse"), None),
1913 ];
1914 let home = home_override.map(Path::to_path_buf).or_else(|| {
1915 std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
1916 });
1917 if let Some(home) = home {
1918 files.push((home.join(".cursor").join("hooks.json"), Some("hook cursor")));
1919 }
1920 files
1921}
1922
1923fn legacy_hooks_cut(text: &str, args: Option<&str>) -> Option<Option<serde_json::Value>> {
1925 let Some(args) = args else {
1926 return (text.replace("\r\n", "\n") == LEGACY_CLINE_SCRIPT).then_some(None);
1927 };
1928 let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
1929 let obj = root.as_object_mut()?;
1930 let hooks = obj.get_mut("hooks")?.as_object_mut()?;
1931 let mut changed = false;
1932 let mut emptied = Vec::new();
1933 for (event, entries) in hooks.iter_mut() {
1934 let Some(arr) = entries.as_array_mut() else { continue };
1935 let before = arr.len();
1936 arr.retain(|e| !hook_runs_sqz(e, args));
1937 if remove_sqz_hook_commands(arr, args) || arr.len() != before {
1938 changed = true;
1939 if arr.is_empty() {
1940 emptied.push(event.clone());
1941 }
1942 }
1943 }
1944 if !changed {
1945 return None;
1946 }
1947 for event in &emptied {
1948 hooks.remove(event);
1949 }
1950 if hooks.is_empty() {
1951 obj.remove("hooks");
1952 }
1953 let only_version = obj.len() == 1 && obj.get("version") == Some(&serde_json::json!(1));
1954 Some((!obj.is_empty() && !only_version).then_some(root))
1955}
1956
1957pub fn legacy_sqz_hook_files(project_dir: &Path) -> Vec<PathBuf> {
1959 legacy_sqz_hook_files_at(project_dir, None)
1960}
1961
1962fn legacy_sqz_hook_files_at(project_dir: &Path, home_override: Option<&Path>) -> Vec<PathBuf> {
1963 legacy_hook_files(project_dir, home_override)
1964 .into_iter()
1965 .filter(|(path, args)| {
1966 std::fs::read_to_string(path).is_ok_and(|text| legacy_hooks_cut(&text, *args).is_some())
1967 })
1968 .map(|(path, _)| path)
1969 .collect()
1970}
1971
1972pub fn remove_legacy_sqz_hooks(project_dir: &Path) -> Result<Vec<(PathBuf, bool)>> {
1974 remove_legacy_sqz_hooks_at(project_dir, None)
1975}
1976
1977fn remove_legacy_sqz_hooks_at(
1978 project_dir: &Path,
1979 home_override: Option<&Path>,
1980) -> Result<Vec<(PathBuf, bool)>> {
1981 let mut changed = Vec::new();
1982 for (path, args) in legacy_hook_files(project_dir, home_override) {
1983 let Ok(text) = std::fs::read_to_string(&path) else { continue };
1984 match legacy_hooks_cut(&text, args) {
1985 None => continue,
1986 Some(None) => std::fs::remove_file(&path).map_err(|e| {
1987 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
1988 })?,
1989 Some(Some(rest)) => write_json_config(&path, &rest, &text)?,
1990 }
1991 let deleted = !path.exists();
1992 changed.push((path, deleted));
1993 }
1994 Ok(changed)
1995}
1996
1997const SQZ_HOOK_ARGS: [&str; 9] = [
1999 "hook claude",
2000 "hook claude-post",
2001 "hook precompact",
2002 "resume",
2003 "hook gemini",
2004 "hook copilot",
2005 "hook cursor",
2006 "hook windsurf",
2007 "hook cline",
2008];
2009
2010pub fn sqz_hook_binaries(path: &Path) -> Vec<String> {
2012 let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
2013 let mut found = Vec::new();
2014 match serde_json::from_str::<serde_json::Value>(&text) {
2015 Ok(root) => collect_sqz_hook_binaries(&root, &mut found),
2016 Err(_) => found.extend(text.lines().find_map(|line| {
2017 let literal = line.trim().strip_prefix("const SQZ_PATH = ")?.strip_suffix(';')?;
2018 serde_json::from_str::<String>(literal).ok()
2019 })),
2020 }
2021 found
2022}
2023
2024fn collect_sqz_hook_binaries(value: &serde_json::Value, found: &mut Vec<String>) {
2025 match value {
2026 serde_json::Value::Object(map) => {
2027 for (key, v) in map {
2028 let command = v.as_str().filter(|_| matches!(key.as_str(), "command" | "bash" | "powershell"));
2029 match command.and_then(|c| SQZ_HOOK_ARGS.iter().find_map(|args| sqz_command_binary(c, args))) {
2030 Some(bin) if !found.iter().any(|b| b == bin) => found.push(bin.to_string()),
2031 Some(_) => {}
2032 None => collect_sqz_hook_binaries(v, found),
2033 }
2034 }
2035 }
2036 serde_json::Value::Array(items) => {
2037 for v in items {
2038 collect_sqz_hook_binaries(v, found);
2039 }
2040 }
2041 _ => {}
2042 }
2043}
2044
2045fn hook_entry_runs_sqz(entry: &serde_json::Value, args: &str) -> bool {
2047 entry
2048 .get("hooks")
2049 .and_then(|h| h.as_array())
2050 .is_some_and(|hooks| hooks.iter().any(|h| hook_runs_sqz(h, args)))
2051}
2052
2053fn hook_runs_sqz(hook: &serde_json::Value, args: &str) -> bool {
2055 hook.get("command")
2056 .and_then(|c| c.as_str())
2057 .is_some_and(|c| sqz_command_binary(c, args).is_some())
2058}
2059
2060fn remove_sqz_hook_commands(arr: &mut Vec<serde_json::Value>, args: &str) -> bool {
2062 let mut removed = false;
2063 arr.retain_mut(|entry| {
2064 let Some(hooks) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
2065 return true;
2066 };
2067 let before = hooks.len();
2068 hooks.retain(|h| !hook_runs_sqz(h, args));
2069 if hooks.len() == before {
2070 return true;
2071 }
2072 removed = true;
2073 !hooks.is_empty()
2074 });
2075 removed
2076}
2077
2078fn upsert_sqz_hook_entry(
2085 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
2086 event_name: &str,
2087 new_entry: serde_json::Value,
2088 sentinel: &str,
2089) {
2090 let arr = hooks_obj
2091 .entry(event_name.to_string())
2092 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
2093 let Some(arr) = arr.as_array_mut() else {
2094 hooks_obj.insert(
2098 event_name.to_string(),
2099 serde_json::Value::Array(vec![new_entry]),
2100 );
2101 return;
2102 };
2103
2104 remove_sqz_hook_commands(arr, sentinel);
2106
2107 arr.push(new_entry);
2108}
2109
2110fn upsert_claude_allow_rules(root: &mut serde_json::Map<String, serde_json::Value>) {
2113 let perms = root
2114 .entry("permissions".to_string())
2115 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2116 let Some(perms) = perms.as_object_mut() else { return };
2117 let allow = perms
2118 .entry("allow".to_string())
2119 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
2120 let Some(allow) = allow.as_array_mut() else { return };
2121 for rule in CLAUDE_ALLOW_RULES {
2122 if !allow.iter().any(|r| r.as_str() == Some(rule)) {
2123 allow.push(serde_json::Value::String(rule.to_string()));
2124 }
2125 }
2126}
2127
2128fn remove_claude_allow_rules(root: &mut serde_json::Map<String, serde_json::Value>) -> bool {
2131 let Some(perms) = root.get_mut("permissions").and_then(|p| p.as_object_mut()) else {
2132 return false;
2133 };
2134 let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) else {
2135 return false;
2136 };
2137 let before = allow.len();
2138 allow.retain(|r| !r.as_str().is_some_and(|s| CLAUDE_ALLOW_RULES.contains(&s)));
2139 if allow.len() == before {
2140 return false;
2141 }
2142 if allow.is_empty() {
2143 perms.remove("allow");
2144 }
2145 if perms.is_empty() {
2146 root.remove("permissions");
2147 }
2148 true
2149}
2150
2151fn claude_settings_sqz_path(root: &serde_json::Value) -> Option<String> {
2153 root.pointer("/hooks/PreToolUse")?.as_array()?.iter().find_map(|entry| {
2154 entry.get("hooks")?.as_array()?.iter().find_map(|h| {
2155 let command = h.get("command")?.as_str()?;
2156 sqz_command_binary(command, "hook claude").map(str::to_string)
2157 })
2158 })
2159}
2160
2161pub fn claude_settings_stale(path: &Path) -> bool {
2166 let Ok(text) = std::fs::read_to_string(path) else { return false };
2167 let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
2168 if claude_settings_sqz_path(&root).is_none() {
2169 return false;
2170 }
2171 let post_hook = root
2172 .pointer("/hooks/PostToolUse")
2173 .and_then(|v| v.as_array())
2174 .is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, "hook claude-post")));
2175 !post_hook || !claude_rules_installed(&[path.to_path_buf()])
2176}
2177
2178pub fn refresh_claude_settings(path: &Path, fallback_sqz: &str) -> Result<bool> {
2181 let text = std::fs::read_to_string(path)
2182 .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
2183 let root: serde_json::Value = serde_json::from_str(&text)
2184 .map_err(|e| crate::error::SqzError::Other(format!("parse {}: {e}", path.display())))?;
2185 let sqz_path = claude_settings_sqz_path(&root)
2186 .filter(|p| resolve_sqz_binary(p).is_some())
2187 .unwrap_or_else(|| fallback_sqz.to_string());
2188 if resolve_sqz_binary(&sqz_path).is_none() {
2189 return Err(crate::error::SqzError::Other(format!(
2190 "{sqz_path} does not exist, so the hooks were left as they are"
2191 )));
2192 }
2193 merge_sqz_hooks_into_settings(path, &sqz_path)
2194}
2195
2196fn gemini_hook_entry(sqz_path: &str) -> serde_json::Value {
2200 serde_json::json!({
2201 "matcher": "run_shell_command",
2202 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook gemini") }]
2203 })
2204}
2205
2206fn gemini_settings_merged(
2208 path: &Path,
2209 sqz_path: &str,
2210) -> Result<Option<(String, serde_json::Value)>> {
2211 let text = std::fs::read_to_string(path)
2212 .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
2213 let mut root: serde_json::Value = if text.trim().is_empty() {
2214 serde_json::Value::Object(serde_json::Map::new())
2215 } else {
2216 serde_json::from_str(&text).map_err(|e| {
2217 crate::error::SqzError::Other(format!("{} is not plain JSON: {e}", path.display()))
2218 })?
2219 };
2220 let root_obj = root.as_object_mut().ok_or_else(|| {
2221 crate::error::SqzError::Other(format!("{} is not a JSON object", path.display()))
2222 })?;
2223 let before = serde_json::to_string(&root_obj).unwrap_or_default();
2224 let hooks = root_obj
2225 .entry("hooks".to_string())
2226 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2227 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
2228 crate::error::SqzError::Other(format!("{}: `hooks` is not an object", path.display()))
2229 })?;
2230 upsert_sqz_hook_entry(hooks_obj, "BeforeTool", gemini_hook_entry(sqz_path), "hook gemini");
2231 let after = serde_json::to_string(&root_obj).unwrap_or_default();
2232 Ok((before != after).then_some((text, root)))
2233}
2234
2235pub fn gemini_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
2237 if !path.exists() {
2238 return Ok(true);
2239 }
2240 Ok(gemini_settings_merged(path, sqz_path)?.is_some())
2241}
2242
2243pub fn merge_sqz_gemini_hook(path: &Path, sqz_path: &str) -> Result<bool> {
2245 let Some((text, root)) = gemini_settings_merged(path, sqz_path)? else {
2246 return Ok(false);
2247 };
2248 write_json_config(path, &root, &text)?;
2249 Ok(true)
2250}
2251
2252pub fn gemini_settings_has_sqz(path: &Path) -> bool {
2254 let Ok(text) = std::fs::read_to_string(path) else { return false };
2255 match serde_json::from_str::<serde_json::Value>(&text) {
2256 Ok(root) => root
2257 .pointer("/hooks/BeforeTool")
2258 .and_then(|v| v.as_array())
2259 .is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, "hook gemini"))),
2260 Err(_) => text.contains(" hook gemini"),
2261 }
2262}
2263
2264pub fn remove_sqz_from_gemini_settings(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
2266 let path = project_dir.join(".gemini").join("settings.json");
2267 if !path.exists() {
2268 return Ok(None);
2269 }
2270 let text = std::fs::read_to_string(&path)
2271 .map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
2272 if text.trim().is_empty() {
2273 return Ok(Some((path, false)));
2274 }
2275 let mut root: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
2276 crate::error::SqzError::Other(format!(
2277 "{} is not plain JSON ({e}); remove the sqz hook by hand",
2278 path.display()
2279 ))
2280 })?;
2281 let Some(root_obj) = root.as_object_mut() else {
2282 return Ok(Some((path, false)));
2283 };
2284 let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
2285 return Ok(Some((path, false)));
2286 };
2287 let Some(arr) = hooks.get_mut("BeforeTool").and_then(|v| v.as_array_mut()) else {
2288 return Ok(Some((path, false)));
2289 };
2290 if !remove_sqz_hook_commands(arr, "hook gemini") {
2291 return Ok(Some((path, false)));
2292 }
2293 if arr.is_empty() {
2294 hooks.remove("BeforeTool");
2295 if hooks.is_empty() {
2296 root_obj.remove("hooks");
2297 }
2298 }
2299 if root_obj.is_empty() {
2300 std::fs::remove_file(&path).map_err(|e| {
2301 crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
2302 })?;
2303 return Ok(Some((path, true)));
2304 }
2305 write_json_config(&path, &root, &text)?;
2306 Ok(Some((path, true)))
2307}
2308
2309fn write_json_config(path: &Path, value: &serde_json::Value, original: &str) -> Result<()> {
2311 let parent = path.parent().ok_or_else(|| {
2312 crate::error::SqzError::Other(format!("path {} has no parent directory", path.display()))
2313 })?;
2314 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
2315 crate::error::SqzError::Other(format!("create temp file in {}: {e}", parent.display()))
2316 })?;
2317 let mut serialized = serde_json::to_string_pretty(value)
2318 .map_err(|e| crate::error::SqzError::Other(format!("serialize {}: {e}", path.display())))?;
2319 if original.ends_with('\n') {
2320 serialized.push('\n');
2321 }
2322 std::fs::write(tmp.path(), serialized).map_err(|e| {
2323 crate::error::SqzError::Other(format!("write to temp file {}: {e}", tmp.path().display()))
2324 })?;
2325 if let Ok(meta) = std::fs::metadata(path) {
2326 let _ = std::fs::set_permissions(tmp.path(), meta.permissions());
2327 }
2328 tmp.persist(path).map_err(|e| {
2329 crate::error::SqzError::Other(format!(
2330 "rename temp file into place at {}: {e}",
2331 path.display()
2332 ))
2333 })?;
2334 Ok(())
2335}
2336
2337fn extract_base_command(cmd: &str) -> &str {
2341 cmd.split_whitespace()
2342 .next()
2343 .unwrap_or("unknown")
2344 .rsplit('/')
2345 .next()
2346 .unwrap_or("unknown")
2347}
2348
2349pub(crate) fn json_escape_string_value(s: &str) -> String {
2360 let mut out = String::with_capacity(s.len() + 2);
2361 for ch in s.chars() {
2362 match ch {
2363 '\\' => out.push_str("\\\\"),
2364 '"' => out.push_str("\\\""),
2365 '\n' => out.push_str("\\n"),
2366 '\r' => out.push_str("\\r"),
2367 '\t' => out.push_str("\\t"),
2368 '\x08' => out.push_str("\\b"),
2369 '\x0c' => out.push_str("\\f"),
2370 c if (c as u32) < 0x20 => {
2371 out.push_str(&format!("\\u{:04x}", c as u32));
2373 }
2374 c => out.push(c),
2375 }
2376 }
2377 out
2378}
2379
2380fn shell_escape(s: &str) -> String {
2382 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
2383 s.to_string()
2384 } else {
2385 format!("'{}'", s.replace('\'', "'\\''"))
2386 }
2387}
2388
2389fn passthrough(platform: HookPlatform, input: &str) -> String {
2392 match platform {
2393 HookPlatform::Copilot => "{}".to_string(),
2394 _ => input.to_string(),
2395 }
2396}
2397
2398fn runs_in_background(parsed: &serde_json::Value) -> bool {
2401 let Some(args) = parsed
2402 .get("tool_input")
2403 .or_else(|| parsed.get("toolArgs"))
2404 .or_else(|| parsed.get("args"))
2405 else {
2406 return false;
2407 };
2408 ["run_in_background", "is_background", "background", "async", "detach"]
2409 .iter()
2410 .any(|k| args.get(k).and_then(|v| v.as_bool()) == Some(true))
2411 || args.get("mode").and_then(|v| v.as_str()) == Some("async")
2412}
2413
2414pub const CLAUDE_ALLOW_RULES: [&str; 2] = ["Bash(set -o pipefail)", "Bash(sqz compress --cmd *)"];
2418
2419fn claude_rewrite(
2425 parsed: &serde_json::Value,
2426 command: &str,
2427 is_powershell: bool,
2428 settings: &[PathBuf],
2429 shell: crate::shell_scan::Shell,
2430) -> Option<String> {
2431 let bypass = parsed.get("permission_mode").and_then(|v| v.as_str()) == Some("bypassPermissions");
2432 if is_powershell {
2433 if !bypass || is_interactive_command(command) || has_shell_operators(command) {
2434 return None;
2435 }
2436 return Some(crate::exit_marker::powershell_rewrite(command, &shell_escape(command)));
2437 }
2438 if crate::shell_scan::flat(command).is_some() {
2439 let cmds = crate::shell_scan::flat_for(command, shell)?;
2440 return (bypass || claude_rules_installed(settings))
2441 .then(|| crate::exit_marker::posix_flat_rewrite(command, &cmds));
2442 }
2443 if bypass {
2444 return crate::shell_scan::plan(command).ok().map(|plan| posix_rewrite_plan(&plan));
2445 }
2446 None
2447}
2448
2449#[derive(Debug, Clone, PartialEq)]
2451pub struct PostToolUseTarget {
2452 pub response: serde_json::Map<String, serde_json::Value>,
2455 pub label: String,
2457}
2458
2459pub fn claude_post_tool_use_target(input: &str) -> Option<PostToolUseTarget> {
2465 let parsed: serde_json::Value = serde_json::from_str(input).ok()?;
2466 if parsed.get("hook_event_name").and_then(|v| v.as_str()) != Some("PostToolUse")
2467 || parsed.get("tool_name").and_then(|v| v.as_str()) != Some("Bash")
2468 || runs_in_background(&parsed)
2469 {
2470 return None;
2471 }
2472 let command = parsed.pointer("/tool_input/command")?.as_str()?;
2473 if command.contains("sqz compress")
2474 || crate::shell_scan::plan(command) == Err(crate::shell_scan::Skip::AlreadySqz)
2475 {
2476 return None;
2477 }
2478 let response = parsed.get("tool_response")?.as_object()?.clone();
2479 let flag = |k: &str| response.get(k).and_then(|v| v.as_bool()) == Some(true);
2480 if flag("isImage") || flag("interrupted") {
2481 return None;
2482 }
2483 let limit = std::env::var("BASH_MAX_OUTPUT_LENGTH")
2486 .ok()
2487 .and_then(|v| v.parse::<usize>().ok())
2488 .unwrap_or(30_000);
2489 let long = |k: &str| {
2490 response
2491 .get(k)
2492 .and_then(|v| v.as_str())
2493 .is_some_and(|s| s.chars().count() >= limit)
2494 };
2495 if response.get("stdout").and_then(|v| v.as_str()).is_none() || long("stdout") || long("stderr") {
2496 return None;
2497 }
2498 Some(PostToolUseTarget { response, label: post_tool_use_label(command) })
2499}
2500
2501pub fn claude_hooks_compress(command: &str, failed: bool) -> bool {
2506 use crate::shell_scan::{flat_for, plan, Shell, Skip};
2507 if command.contains("sqz compress") {
2508 return false;
2509 }
2510 match plan(command) {
2511 Err(Skip::AlreadySqz) => false,
2512 _ if flat_for(command, Shell::from_env()).is_some() => true,
2513 Err(Skip::LongRunning | Skip::Background) => false,
2514 _ => !failed,
2515 }
2516}
2517
2518fn post_tool_use_label(command: &str) -> String {
2521 if let Some(cmds) = crate::shell_scan::flat(command) {
2522 let mut labels = cmds.into_iter().filter_map(|c| c.label);
2523 if let (Some(only), None) = (labels.next(), labels.next()) {
2524 return only;
2525 }
2526 }
2527 list_label(command)
2528}
2529
2530fn claude_settings_files(parsed: &serde_json::Value) -> Vec<PathBuf> {
2533 let mut files = Vec::new();
2534 let user = std::env::var_os("CLAUDE_CONFIG_DIR")
2535 .map(PathBuf::from)
2536 .or_else(|| dirs_next::home_dir().map(|h| h.join(".claude")));
2537 if let Some(dir) = user {
2538 files.push(dir.join("settings.json"));
2539 }
2540 let project = std::env::var_os("CLAUDE_PROJECT_DIR")
2541 .map(PathBuf::from)
2542 .or_else(|| parsed.get("cwd").and_then(|v| v.as_str()).map(PathBuf::from));
2543 if let Some(dir) = project {
2544 files.push(dir.join(".claude").join("settings.json"));
2545 files.push(dir.join(".claude").join("settings.local.json"));
2546 }
2547 files
2548}
2549
2550fn claude_rules_installed(files: &[PathBuf]) -> bool {
2553 let (mut pipefail, mut compress) = (false, false);
2554 for file in files {
2555 let Ok(text) = std::fs::read_to_string(file) else { continue };
2556 let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
2557 let Some(rules) = root.pointer("/permissions/allow").and_then(|a| a.as_array()) else {
2558 continue;
2559 };
2560 for rule in rules.iter().filter_map(|r| r.as_str()) {
2561 pipefail |= bash_rule_allows(rule, "set -o pipefail");
2562 compress |= bash_rule_allows(rule, "sqz compress --cmd ls");
2563 }
2564 }
2565 pipefail && compress
2566}
2567
2568fn bash_rule_allows(rule: &str, command: &str) -> bool {
2572 let rule = rule.trim();
2573 if rule == "Bash" {
2574 return true;
2575 }
2576 let Some(spec) = rule.strip_prefix("Bash(").and_then(|r| r.strip_suffix(')')) else {
2577 return false;
2578 };
2579 if let Some(prefix) = spec.strip_suffix(":*").or_else(|| spec.strip_suffix(" *")) {
2580 if prefix.contains('*') {
2581 return false;
2582 }
2583 return command == prefix
2584 || command.strip_prefix(prefix).is_some_and(|rest| rest.starts_with(' '));
2585 }
2586 match spec.strip_suffix('*') {
2587 Some(prefix) if !prefix.contains('*') => command.starts_with(prefix),
2588 Some(_) => false,
2589 None => spec == command,
2590 }
2591}
2592
2593fn posix_rewrite_plan(plan: &crate::shell_scan::Plan<'_>) -> String {
2595 let grouped = if plan.simple {
2596 let cmd = plan.body.trim();
2599 let label = cmd.strip_suffix(" 2>&1").map_or(cmd, str::trim_end);
2600 crate::exit_marker::posix_rewrite(cmd, &shell_escape(label))
2601 } else {
2602 crate::exit_marker::posix_rewrite_list(plan.body, &shell_escape(&list_label(plan.body)))
2603 };
2604 if plan.prefix.is_empty() {
2605 grouped
2606 } else {
2607 format!("{} {grouped}", plan.prefix)
2608 }
2609}
2610
2611fn list_label(body: &str) -> String {
2614 let first = body.trim().lines().next().unwrap_or("");
2615 let label = format!("sh: {}", first.split_whitespace().collect::<Vec<_>>().join(" "));
2616 if label.chars().count() > 100 {
2617 format!("{}…", label.chars().take(99).collect::<String>())
2618 } else {
2619 label
2620 }
2621}
2622
2623pub(crate) fn has_shell_operators(cmd: &str) -> bool {
2627 cmd.contains("&&")
2630 || cmd.contains("||")
2631 || cmd.contains(';')
2632 || cmd.contains('>')
2633 || cmd.contains('<')
2634 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
2640
2641fn is_interactive_command(cmd: &str) -> bool {
2643 let base = extract_base_command(cmd);
2644 matches!(
2645 base,
2646 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
2647 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
2648 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
2649 ) || cmd.contains("--watch")
2650 || cmd.contains("-w ")
2651 || cmd.ends_with(" -w")
2652 || cmd.contains("run dev")
2653 || cmd.contains("run start")
2654 || cmd.contains("run serve")
2655}
2656
2657#[cfg(test)]
2660mod tests {
2661 use super::*;
2662
2663 #[test]
2664 fn test_copilot_hook_rewrites_bash_with_modified_args() {
2665 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
2666 let result = process_hook_copilot(input).unwrap();
2667 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2668 assert!(parsed.get("permissionDecision").is_none(), "{parsed}");
2670 let args = &parsed["modifiedArgs"];
2671 assert!(args["command"].as_str().unwrap().contains("sqz compress"));
2672 assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
2673 }
2674
2675 #[test]
2676 fn test_copilot_hook_reads_tool_args_given_as_a_string() {
2677 let input = serde_json::json!({
2678 "toolName": "bash",
2679 "toolArgs": r#"{"command":"git log -20","timeoutSec":30}"#,
2680 })
2681 .to_string();
2682 let out = process_hook_copilot(&input).unwrap();
2683 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
2684 let args = &parsed["modifiedArgs"];
2685 assert!(args["command"].as_str().unwrap().contains("sqz compress"), "{parsed}");
2686 assert_eq!(args["timeoutSec"], 30, "{parsed}");
2687 let background = serde_json::json!({
2688 "toolName": "bash",
2689 "toolArgs": r#"{"command":"cargo build","mode":"async"}"#,
2690 })
2691 .to_string();
2692 assert_eq!(process_hook_copilot(&background).unwrap(), "{}");
2693 }
2694
2695 #[test]
2696 fn test_copilot_hook_passes_non_shell_through() {
2697 let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
2698 let result = process_hook_copilot(input).unwrap();
2699 assert_eq!(result, "{}", "non-shell tools fall through with empty object");
2700 }
2701
2702 #[test]
2703 fn test_copilot_hook_skips_risky_shell_operators() {
2704 for command in [
2707 "source .venv/bin/activate && pytest",
2708 "npm run build &",
2709 "for f in a b; do last=$f; done",
2710 ] {
2711 let input = serde_json::json!({"toolName": "bash", "toolArgs": {"command": command}}).to_string();
2712 assert_eq!(process_hook_copilot(&input).unwrap(), "{}", "{command}");
2713 }
2714 }
2715
2716 #[test]
2717 fn test_claude_updated_input_preserves_sibling_fields() {
2718 let input = r#"{"tool_name":"Bash","permission_mode":"bypassPermissions","tool_input":{"command":"cargo test","run_in_background":false,"timeout":120000,"description":"Run tests"}}"#;
2721 let result = process_hook(input).unwrap();
2722 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2723 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
2724 assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
2725 assert_eq!(updated["run_in_background"], false);
2726 assert_eq!(updated["timeout"], 120000);
2727 assert_eq!(updated["description"], "Run tests");
2728 }
2729
2730 fn rewritten(input: &str) -> Option<String> {
2731 let out: serde_json::Value = serde_json::from_str(&process_hook(input).unwrap()).unwrap();
2732 out.pointer("/hookSpecificOutput/updatedInput/command")
2733 .and_then(|c| c.as_str())
2734 .map(str::to_string)
2735 }
2736
2737 fn bash(command: &str) -> String {
2740 serde_json::json!({
2741 "tool_name": "Bash",
2742 "permission_mode": "bypassPermissions",
2743 "tool_input": {"command": command}
2744 })
2745 .to_string()
2746 }
2747
2748 fn claude_default(command: &str, settings: &[PathBuf]) -> Option<String> {
2749 claude_default_in(command, settings, crate::shell_scan::Shell::Zsh)
2750 }
2751
2752 fn claude_default_in(
2753 command: &str,
2754 settings: &[PathBuf],
2755 shell: crate::shell_scan::Shell,
2756 ) -> Option<String> {
2757 let parsed = serde_json::json!({
2758 "tool_name": "Bash",
2759 "permission_mode": "default",
2760 "tool_input": {"command": command}
2761 });
2762 claude_rewrite(&parsed, command, false, settings, shell)
2763 }
2764
2765 fn settings_with_rules(dir: &Path, rules: &[&str]) -> PathBuf {
2766 let path = dir.join("settings.json");
2767 std::fs::write(&path, serde_json::json!({"permissions": {"allow": rules}}).to_string())
2768 .unwrap();
2769 path
2770 }
2771
2772 #[test]
2773 fn claude_default_mode_rewrites_only_with_the_allow_rules() {
2774 let dir = tempfile::tempdir().unwrap();
2775 let none = dir.path().join("missing.json");
2776 assert_eq!(claude_default("git status", &[none.clone()]), None);
2777
2778 let partial = settings_with_rules(dir.path(), &["Bash(sqz compress --cmd *)"]);
2779 assert_eq!(claude_default("git status", &[partial]), None, "pipefail rule missing");
2780
2781 let full = settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES);
2782 assert_eq!(
2783 claude_default("git status", &[none, full.clone()]).as_deref(),
2784 Some("set -o pipefail; git status 2>&1 | sqz compress --cmd 'git status'")
2785 );
2786 assert_eq!(claude_default("git log | head -5", &[full.clone()]), None);
2789 assert_eq!(claude_default("for f in *; do wc -l $f; done", &[full]), None);
2790 }
2791
2792 #[test]
2793 fn claude_default_mode_keeps_builtins_in_the_shell() {
2794 let dir = tempfile::tempdir().unwrap();
2795 let rules = settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES);
2796 assert_eq!(
2797 claude_default("cd sub && export CI=1 && cargo test", &[rules]).as_deref(),
2798 Some("set -o pipefail; cd sub && export CI=1 && cargo test 2>&1 | sqz compress --cmd 'cargo test'")
2799 );
2800 }
2801
2802 #[test]
2803 fn claude_flat_form_stops_where_the_shell_would() {
2804 use crate::shell_scan::Shell;
2805 let dir = tempfile::tempdir().unwrap();
2806 let rules = [settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES)];
2807 for shell in [Shell::Bash, Shell::Zsh] {
2808 assert_eq!(claude_default_in("ls ${X:?unset}; echo next", &rules, shell), None);
2809 assert_eq!(claude_default_in("ls $((1/0)) || echo next", &rules, shell), None);
2810 assert_eq!(
2811 claude_default_in("ls ${X:?unset} && echo next", &rules, shell).as_deref(),
2812 Some("set -o pipefail; ls ${X:?unset} 2>&1 | sqz compress --cmd ls && echo next")
2813 );
2814 }
2815 assert_eq!(claude_default_in("ls *.rs; echo next", &rules, Shell::Zsh), None);
2816 assert!(claude_default_in("ls *.rs; echo next", &rules, Shell::Bash).is_some());
2817 let parsed = serde_json::json!({"permission_mode": "bypassPermissions"});
2818 for shell in [Shell::Bash, Shell::Zsh] {
2819 for command in ["ls ${X:?unset}; echo next", "ls $((1/0))\necho next"] {
2820 assert_eq!(claude_rewrite(&parsed, command, false, &[], shell), None, "{command:?}");
2821 }
2822 }
2823 }
2824
2825 #[test]
2826 fn claude_powershell_is_rewritten_only_in_bypass_mode() {
2827 let zsh = crate::shell_scan::Shell::Zsh;
2828 let parsed = serde_json::json!({"tool_name": "PowerShell", "permission_mode": "default"});
2829 assert_eq!(claude_rewrite(&parsed, "Get-ChildItem", true, &[], zsh), None);
2830 let parsed = serde_json::json!({"tool_name": "PowerShell", "permission_mode": "bypassPermissions"});
2831 assert!(claude_rewrite(&parsed, "Get-ChildItem", true, &[], zsh).is_some());
2832 }
2833
2834 #[test]
2835 fn bash_rule_matching() {
2836 for (rule, command, expected) in [
2837 ("Bash", "set -o pipefail", true),
2838 ("Bash(*)", "set -o pipefail", true),
2839 ("Bash(set -o pipefail)", "set -o pipefail", true),
2840 ("Bash(set -o pipefail)", "set -o pipefail -x", false),
2841 ("Bash(set *)", "set -o pipefail", true),
2842 ("Bash(set:*)", "set -o pipefail", true),
2843 ("Bash(sqz compress --cmd *)", "sqz compress --cmd ls", true),
2844 ("Bash(sqz *)", "sqz compress --cmd ls", true),
2845 ("Bash(sqz*)", "sqz compress --cmd ls", true),
2846 ("Bash(sq *)", "sqz compress --cmd ls", false),
2847 ("Bash(* --cmd ls)", "sqz compress --cmd ls", false),
2848 ("Read(./x)", "sqz compress --cmd ls", false),
2849 ] {
2850 assert_eq!(bash_rule_allows(rule, command), expected, "{rule} vs {command}");
2851 }
2852 }
2853
2854 #[test]
2855 fn crlf_copies_of_current_files_are_current() {
2856 use crate::kiro_integration::SteeringState;
2857 let dir = tempfile::tempdir().unwrap();
2858 let crlf = |s: String| s.replace('\n', "\r\n");
2859 let write = |name: &str, text: String| {
2860 let path = dir.path().join(name);
2861 std::fs::write(&path, text).unwrap();
2862 path
2863 };
2864
2865 let cursor = write("sqz.mdc", crlf(sqz_rules_content("Cursor", "/opt/sqz")));
2866 assert_eq!(sqz_rules_state(&cursor, "Cursor"), SteeringState::Current);
2867 let legacy = write("old.mdc", crlf(format!("{LEGACY_CURSOR_FRONTMATTER}alwaysApply: true\n---\nold\n")));
2868 assert_eq!(sqz_rules_state(&legacy, "Cursor"), SteeringState::Stale);
2869 let kiro = write("kiro.md", crlf(crate::kiro_integration::kiro_steering_content("/opt/sqz")));
2870 assert_eq!(crate::kiro_integration::kiro_steering_state(&kiro, false), SteeringState::Current);
2871 let block = crate::codex_integration::agents_md_guidance_block("/opt/sqz");
2872 let agents = write("AGENTS.md", crlf(format!("# Mine\n\n{block}")));
2873 assert_eq!(crate::codex_integration::agents_md_guidance_state(&agents), SteeringState::Current);
2874 let claude_md = write("CLAUDE.md", crlf(crate::claude_md_integration::claude_md_guidance_block("/opt/sqz")));
2875 assert_eq!(crate::claude_md_integration::claude_md_guidance_state(dir.path()), SteeringState::Current);
2876 assert!(!crate::claude_md_integration::claude_md_block_outdated(dir.path(), "/opt/sqz"));
2877 let before = std::fs::read(&claude_md).unwrap();
2878 assert!(!crate::claude_md_integration::install_claude_md_guidance(dir.path(), "/opt/sqz").unwrap());
2879 assert_eq!(std::fs::read(&claude_md).unwrap(), before);
2880 }
2881
2882 #[test]
2883 fn stale_claude_settings_are_detected_and_refreshed() {
2884 let dir = tempfile::tempdir().unwrap();
2885 let path = dir.path().join("settings.json");
2886 let bin = dir.path().join("sqz");
2887 std::fs::write(&bin, "").unwrap();
2888 let bin = bin.to_string_lossy().to_string();
2889 let old = serde_json::json!({"hooks": {
2891 "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": format!("{bin} hook claude")}]}],
2892 "PostToolUse": [{"matcher": "Edit", "hooks": [{"type": "command", "command": "prettier --write"}]}],
2893 }});
2894 std::fs::write(&path, old.to_string()).unwrap();
2895 assert!(claude_settings_stale(&path));
2896
2897 assert!(refresh_claude_settings(&path, "/fallback/sqz").unwrap());
2898 assert!(!claude_settings_stale(&path));
2899 let v: serde_json::Value =
2900 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2901 let post = v["hooks"]["PostToolUse"].as_array().unwrap();
2902 assert_eq!(post.len(), 2, "the user's own PostToolUse hook stays: {post:?}");
2903 assert_eq!(
2904 v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
2905 format!("{bin} hook claude"),
2906 "keeps the binary the hook already used"
2907 );
2908
2909 std::fs::write(&path, r#"{"permissions":{}}"#).unwrap();
2910 assert!(!claude_settings_stale(&path), "no sqz hook, nothing to refresh");
2911 }
2912
2913 #[test]
2914 fn background_calls_pass_through() {
2915 for input in [
2916 r#"{"tool_name":"Bash","tool_input":{"command":"cargo build","run_in_background":true}}"#,
2917 r#"{"tool_name":"run_shell_command","tool_input":{"command":"cargo build","is_background":true}}"#,
2918 r#"{"toolName":"bash","toolArgs":{"command":"cargo build","mode":"async"}}"#,
2919 ] {
2920 assert_eq!(rewritten(input), None, "{input}");
2921 }
2922 }
2923
2924 #[test]
2925 fn lists_and_pipelines_are_grouped_with_a_generic_label() {
2926 let cmd = rewritten(&bash("cargo test 2>&1 | tail -30")).unwrap();
2927 assert_eq!(
2928 cmd,
2929 "{ ( cargo test 2>&1 | tail -30\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'sh: cargo test 2>&1 | tail -30'"
2930 );
2931 let cmd = rewritten(&bash("ls | head # list")).unwrap();
2933 assert!(cmd.starts_with("{ ( ls | head # list\n); printf"), "{cmd}");
2934 let cmd = rewritten(&bash("ls # list")).unwrap();
2936 assert_eq!(cmd, "set -o pipefail; ls 2>&1 | sqz compress --cmd ls # list");
2937 }
2938
2939 #[test]
2940 fn leading_cd_runs_outside_the_group() {
2941 let cmd = rewritten(&bash("cd sqz_engine && cargo test 2>&1 | tail -5")).unwrap();
2942 assert_eq!(
2943 cmd,
2944 "cd sqz_engine && { ( cargo test 2>&1 | tail -5\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'sh: cargo test 2>&1 | tail -5'"
2945 );
2946 let cmd = rewritten(&bash("cd sqz_engine && cargo test 2>&1")).unwrap();
2947 assert_eq!(
2948 cmd,
2949 "set -o pipefail; cd sqz_engine && cargo test 2>&1 | sqz compress --cmd 'cargo test'"
2950 );
2951 }
2952
2953 #[test]
2954 fn state_changes_pipe_only_the_commands_around_them() {
2955 assert_eq!(
2956 rewritten(&bash("export X=1 && make")).as_deref(),
2957 Some("set -o pipefail; export X=1 && make 2>&1 | sqz compress --cmd make")
2958 );
2959 assert_eq!(
2960 rewritten(&bash("make && cd build")).as_deref(),
2961 Some("set -o pipefail; make 2>&1 | sqz compress --cmd make && cd build")
2962 );
2963 }
2964
2965 #[test]
2966 fn followers_background_jobs_and_sqz_pass_through() {
2967 for command in ["tail -f app.log", "make &", "git log | sqz compress", "cd x && npm run dev"] {
2968 assert_eq!(rewritten(&bash(command)), None, "{command}");
2969 }
2970 }
2971
2972 #[test]
2973 fn test_process_hook_rewrites_bash_command() {
2974 let input = bash("git status");
2976 let result = process_hook(&input).unwrap();
2977 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2978 let hook_output = &parsed["hookSpecificOutput"];
2980 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
2981 assert!(hook_output.get("permissionDecision").is_none(), "{hook_output}");
2983 assert!(hook_output.get("permissionDecisionReason").is_none(), "{hook_output}");
2984 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
2986 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
2987 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
2988 assert!(
2991 cmd.contains("--cmd 'git status'"),
2992 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
2993 );
2994 assert!(
2995 !cmd.contains("SQZ_CMD="),
2996 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
2997 );
2998 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
3000 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
3001 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
3002 }
3003
3004 #[test]
3005 fn test_process_hook_passes_through_non_bash() {
3006 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
3007 let result = process_hook(input).unwrap();
3008 assert_eq!(result, input, "non-bash tools should pass through unchanged");
3009 }
3010
3011 #[test]
3012 fn test_process_hook_rewrites_powershell_command() {
3013 let input = r#"{"tool_name":"PowerShell","permission_mode":"bypassPermissions","tool_input":{"command":"Get-Content big.log"}}"#;
3014 let result = process_hook(input).unwrap();
3015 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3016 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3017 .as_str()
3018 .expect("PowerShell tool calls should be rewritten");
3019 assert!(
3020 cmd.contains("Get-Content big.log"),
3021 "should preserve original PowerShell command: {cmd}"
3022 );
3023 assert!(
3024 cmd.contains("sqz compress"),
3025 "should pipe through sqz compress: {cmd}"
3026 );
3027 assert!(
3028 cmd.contains("--cmd 'Get-Content big.log'"),
3029 "should pass full command via --cmd: {cmd}"
3030 );
3031 }
3032
3033 #[test]
3034 fn test_process_hook_rewrites_pwsh_command() {
3035 let input = r#"{"tool_name":"pwsh","permission_mode":"bypassPermissions","tool_input":{"command":"ls"}}"#;
3036 let result = process_hook(input).unwrap();
3037 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3038 assert!(
3039 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
3040 "pwsh tool calls should be rewritten"
3041 );
3042 }
3043
3044 #[test]
3045 fn bash_rewrite_carries_exit_status_trailer() {
3046 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"cargo test"}}"#;
3047 let result = process_hook_gemini(input).unwrap();
3048 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3049 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"]
3050 .as_str()
3051 .unwrap();
3052 assert_eq!(
3053 cmd,
3054 "{ ( cargo test\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'cargo test'",
3055 "POSIX rewrite must send the command's exit status through the pipe"
3056 );
3057 let cmd = rewritten(&bash("cargo test")).unwrap();
3059 assert_eq!(cmd, "set -o pipefail; cargo test 2>&1 | sqz compress --cmd 'cargo test'");
3060 }
3061
3062 #[test]
3063 fn powershell_rewrite_uses_lastexitcode_not_printf() {
3064 let input = r#"{"tool_name":"PowerShell","permission_mode":"bypassPermissions","tool_input":{"command":"cargo test"}}"#;
3065 let result = process_hook(input).unwrap();
3066 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3067 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3068 .as_str()
3069 .unwrap();
3070 assert!(cmd.contains("$LASTEXITCODE"), "PowerShell form uses $LASTEXITCODE: {cmd}");
3071 assert!(cmd.contains("__SQZ_EXIT_"), "PowerShell form emits the trailer: {cmd}");
3072 assert!(!cmd.contains("printf"), "printf is not valid PowerShell: {cmd}");
3073 }
3074
3075 #[test]
3076 fn exit_trailer_rewrite_is_not_rewrapped() {
3077 let first = process_hook(&bash("git log")).unwrap();
3080 let parsed: serde_json::Value = serde_json::from_str(&first).unwrap();
3081 let rewritten = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3082 .as_str()
3083 .unwrap();
3084 let second_input = serde_json::json!({
3085 "tool_name": "Bash",
3086 "tool_input": { "command": rewritten }
3087 })
3088 .to_string();
3089 let second = process_hook(&second_input).unwrap();
3090 assert_eq!(
3091 second, second_input,
3092 "already-rewritten commands must pass through unchanged"
3093 );
3094 }
3095
3096 #[test]
3097 fn test_process_hook_skips_sqz_commands() {
3098 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
3099 let result = process_hook(input).unwrap();
3100 assert_eq!(result, input, "sqz commands should not be double-wrapped");
3101 }
3102
3103 #[test]
3104 fn test_process_hook_skips_interactive() {
3105 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
3106 let result = process_hook(input).unwrap();
3107 assert_eq!(result, input, "interactive commands should pass through");
3108 }
3109
3110 #[test]
3111 fn test_process_hook_skips_watch_mode() {
3112 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
3113 let result = process_hook(input).unwrap();
3114 assert_eq!(result, input, "watch mode should pass through");
3115 }
3116
3117 #[test]
3118 fn test_process_hook_empty_command() {
3119 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
3120 let result = process_hook(input).unwrap();
3121 assert_eq!(result, input);
3122 }
3123
3124 #[test]
3125 fn test_process_hook_gemini_format() {
3126 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
3128 let result = process_hook_gemini(input).unwrap();
3129 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3130 assert!(parsed.get("decision").is_none(), "the policy engine decides: {parsed}");
3131 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
3133 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
3134 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
3136 "Gemini format should not have updatedInput");
3137 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
3138 "Gemini format should not have permissionDecision");
3139 }
3140
3141 #[test]
3142 fn gemini_tool_input_keeps_every_argument() {
3143 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log","dir_path":"sub","description":"log"}}"#;
3145 let parsed: serde_json::Value =
3146 serde_json::from_str(&process_hook_gemini(input).unwrap()).unwrap();
3147 let args = &parsed["hookSpecificOutput"]["tool_input"];
3148 assert_eq!(args["dir_path"], "sub");
3149 assert_eq!(args["description"], "log");
3150 assert!(args["command"].as_str().unwrap().contains("sqz compress"));
3151 }
3152
3153 #[test]
3154 fn test_process_hook_legacy_format() {
3155 let input = r#"{"toolName":"Bash","permission_mode":"bypassPermissions","toolCall":{"command":"git status"}}"#;
3157 let result = process_hook(input).unwrap();
3158 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3159 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
3160 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
3161 }
3162
3163 #[test]
3164 fn test_process_hook_cursor_format() {
3165 for input in [
3167 r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#,
3168 r#"{"hook_event_name":"beforeShellExecution","command":"git status","cwd":"/p"}"#,
3169 r#"{"hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"git status"}}"#,
3170 r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#,
3171 r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#,
3172 "not json",
3173 ] {
3174 assert!(process_hook_cursor(input).is_err(), "{input}");
3175 assert!(process_hook_for_platform(input, HookPlatform::Cursor).is_err(), "{input}");
3176 }
3177 }
3178
3179 #[test]
3180 fn test_process_hook_windsurf_format() {
3181 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
3183 let result = process_hook_windsurf(input).unwrap();
3184 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3185 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
3187 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
3188 assert!(cmd.contains("cargo test"));
3189 assert!(
3191 cmd.contains("--cmd 'cargo test'"),
3192 "full command must be passed via --cmd flag: {cmd}"
3193 );
3194 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
3195 }
3196
3197 #[test]
3198 fn test_process_hook_invalid_json() {
3199 let result = process_hook("not json");
3200 assert!(result.is_err());
3201 }
3202
3203 #[test]
3204 fn test_extract_base_command() {
3205 assert_eq!(extract_base_command("git status"), "git");
3206 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
3207 assert_eq!(extract_base_command("cargo test --release"), "cargo");
3208 }
3209
3210 #[test]
3211 fn test_is_interactive_command() {
3212 assert!(is_interactive_command("vim file.txt"));
3213 assert!(is_interactive_command("npm run dev --watch"));
3214 assert!(is_interactive_command("python3"));
3215 assert!(!is_interactive_command("git status"));
3216 assert!(!is_interactive_command("cargo test"));
3217 }
3218
3219 #[test]
3234 fn issue_10_rewrite_is_shell_neutral() {
3235 let input = bash("dotnet build NewNeonCheckers3.sln");
3236 let result = process_hook(&input).unwrap();
3237 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3238 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
3239 .as_str()
3240 .unwrap();
3241
3242 assert!(
3244 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
3245 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
3246 );
3247 assert!(
3249 !cmd.contains("SQZ_CMD="),
3250 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
3251 (broken in PowerShell and cmd.exe), got: {cmd}"
3252 );
3253 assert!(
3255 cmd.contains("dotnet build NewNeonCheckers3.sln"),
3256 "original command must be preserved verbatim: {cmd}"
3257 );
3258 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
3260 }
3261
3262 #[test]
3270 fn issue_10_already_wrapped_command_passes_through() {
3271 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
3272 let result = process_hook(input).unwrap();
3273 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3274 assert_eq!(
3277 result, input,
3278 "already-wrapped command must pass through unchanged; \
3279 otherwise each pass accumulates another `| sqz compress` tail"
3280 );
3281 let _ = parsed; }
3286
3287 #[test]
3288 fn test_generate_hook_configs() {
3289 let configs = generate_hook_configs("sqz");
3290 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
3291 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
3292 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
3293 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
3294 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
3297 assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
3298 "Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
3299 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
3300 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
3301 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
3302 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
3306 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
3307 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
3308 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
3309 assert!(cursor.config_content.starts_with("---"),
3310 "Cursor rule should start with YAML frontmatter");
3311 assert!(cursor.config_content.contains("alwaysApply: true"),
3312 "Cursor rule should use alwaysApply: true so the guidance loads \
3313 for every agent interaction");
3314 assert!(cursor.config_content.contains("sqz"),
3315 "Cursor rule body should mention sqz");
3316 }
3317
3318 #[test]
3319 fn test_claude_config_includes_precompact_hook() {
3320 let configs = generate_hook_configs("sqz");
3325 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
3326 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
3327 .expect("Claude Code config must be valid JSON");
3328
3329 let precompact = parsed["hooks"]["PreCompact"]
3330 .as_array()
3331 .expect("PreCompact hook array must be present");
3332 assert!(
3333 !precompact.is_empty(),
3334 "PreCompact must have at least one registered hook"
3335 );
3336
3337 let cmd = precompact[0]["hooks"][0]["command"]
3338 .as_str()
3339 .expect("command field must be a string");
3340 assert!(
3341 cmd.ends_with(" hook precompact"),
3342 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
3343 );
3344 }
3345
3346 #[test]
3349 fn test_json_escape_string_value() {
3350 assert_eq!(json_escape_string_value("sqz"), "sqz");
3352 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
3353 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
3355 r"C:\\Users\\Alice\\sqz.exe");
3356 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
3358 r#"path with \"quotes\""#);
3359 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
3361 }
3362
3363 #[test]
3364 fn test_windows_path_produces_valid_json_for_claude() {
3365 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3368 let configs = generate_hook_configs(windows_path);
3369
3370 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
3371 .expect("Claude config should be generated");
3372 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
3373 .expect("Claude hook config must be valid JSON on Windows paths");
3374
3375 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
3377 .as_str()
3378 .expect("command field must be a string");
3379 assert!(cmd.contains(windows_path),
3380 "command '{cmd}' must contain the original Windows path '{windows_path}'");
3381 }
3382
3383 #[test]
3384 fn test_windows_path_in_cursor_rules_file() {
3385 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3391 let configs = generate_hook_configs(windows_path);
3392
3393 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
3394 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
3395 assert!(cursor.config_content.contains(windows_path),
3396 "Cursor rule must contain the raw (unescaped) path so users can \
3397 copy-paste the shown commands — got:\n{}", cursor.config_content);
3398 assert!(!cursor.config_content.contains(r"C:\\Users"),
3399 "Cursor rule must NOT double-escape backslashes in markdown — \
3400 got:\n{}", cursor.config_content);
3401 }
3402
3403 #[test]
3404 fn test_windows_path_produces_valid_json_for_gemini() {
3405 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3406 let configs = generate_hook_configs(windows_path);
3407
3408 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
3409 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
3410 .expect("Gemini hook config must be valid JSON on Windows paths");
3411 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
3412 assert!(cmd.contains(windows_path));
3413 }
3414
3415 #[test]
3416 fn test_rules_files_use_raw_path_for_readability() {
3417 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
3421 let configs = generate_hook_configs(windows_path);
3422
3423 for tool in &["Windsurf", "Cline", "Cursor"] {
3424 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
3425 assert!(cfg.config_content.contains(windows_path),
3426 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
3427 cfg.config_content);
3428 assert!(!cfg.config_content.contains(r"C:\\Users"),
3429 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
3430 cfg.config_content);
3431 }
3432 }
3433
3434 #[test]
3435 fn test_unix_path_still_works() {
3436 let unix_path = "/usr/local/bin/sqz";
3439 let configs = generate_hook_configs(unix_path);
3440
3441 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
3442 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
3443 .expect("Unix path should produce valid JSON");
3444 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
3445 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
3446 }
3447
3448 #[test]
3449 fn test_shell_escape_simple() {
3450 assert_eq!(shell_escape("git"), "git");
3451 assert_eq!(shell_escape("cargo-test"), "cargo-test");
3452 }
3453
3454 #[test]
3455 fn test_shell_escape_special_chars() {
3456 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
3457 }
3458
3459 fn install_in(project: &Path, home: &Path, sqz: &str) -> Vec<String> {
3460 install_tool_hooks_at(project, sqz, InstallScope::Project, &ToolFilter::All, Some(home))
3461 }
3462
3463 #[test]
3464 fn user_level_files_go_to_the_given_home() {
3465 let dir = tempfile::tempdir().unwrap();
3466 let home = tempfile::tempdir().unwrap();
3467 std::fs::create_dir_all(home.path().join(".copilot")).unwrap();
3468 install_in(dir.path(), home.path(), "/opt/sqz");
3469 for rel in [
3470 ".claude.json",
3471 ".codex/config.toml",
3472 ".config/zed/settings.json",
3473 ".config/opencode/plugins/sqz.ts",
3474 ".copilot/hooks/sqz.json",
3475 ] {
3476 assert!(home.path().join(rel).exists(), "{rel}");
3477 }
3478 let copilot = std::fs::read_to_string(home.path().join(".copilot/hooks/sqz.json")).unwrap();
3479 assert!(copilot.contains("/opt/sqz hook copilot"));
3480
3481 let global = tempfile::tempdir().unwrap();
3482 let filter = ToolFilter::All;
3483 install_tool_hooks_at(dir.path(), "/opt/sqz", InstallScope::Global, &filter, Some(global.path()));
3484 for rel in [".claude/settings.json", ".kiro/steering/sqz.md", ".kiro/settings/mcp.json"] {
3485 assert!(global.path().join(rel).exists(), "{rel}");
3486 }
3487 }
3488
3489 #[test]
3490 fn test_install_tool_hooks_creates_files() {
3491 let dir = tempfile::tempdir().unwrap();
3492 let home = tempfile::tempdir().unwrap();
3493 let installed = install_in(dir.path(), home.path(), "sqz");
3494 assert!(!installed.is_empty(), "should install at least one hook config");
3496 for name in &installed {
3500 if name == "Copilot CLI" {
3501 continue;
3502 }
3503 let configs = generate_hook_configs("sqz");
3504 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
3505 let path = dir.path().join(&config.config_path);
3506 assert!(path.exists(), "hook config should exist: {}", path.display());
3507 }
3508 }
3509
3510 #[test]
3511 fn test_install_tool_hooks_does_not_overwrite() {
3512 let dir = tempfile::tempdir().unwrap();
3513 let home = tempfile::tempdir().unwrap();
3514 install_in(dir.path(), home.path(), "sqz");
3516 let custom_path = dir.path().join(".claude/settings.local.json");
3520 std::fs::write(&custom_path, "custom content").unwrap();
3521 install_in(dir.path(), home.path(), "sqz");
3523 let content = std::fs::read_to_string(&custom_path).unwrap();
3524 assert_eq!(content, "custom content", "should not overwrite existing config");
3525 }
3526
3527 #[test]
3534 fn test_reinit_upgrades_stale_project_scope_matcher() {
3535 let dir = tempfile::tempdir().unwrap();
3536 let settings_path = dir.path().join(".claude").join("settings.local.json");
3537 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
3538
3539 let stale = serde_json::json!({
3542 "permissions": { "allow": ["Bash(ls:*)"] },
3543 "hooks": {
3544 "PreToolUse": [
3545 {
3546 "matcher": "Bash",
3547 "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
3548 },
3549 {
3550 "matcher": "Edit",
3551 "hooks": [{ "type": "command", "command": "my-linter --check" }]
3552 }
3553 ]
3554 }
3555 });
3556 std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
3557
3558 let home = tempfile::tempdir().unwrap();
3559 install_in(dir.path(), home.path(), "/new/path/sqz");
3560
3561 let content = std::fs::read_to_string(&settings_path).unwrap();
3562 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
3563
3564 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3566 let sqz_entries: Vec<_> = pre
3567 .iter()
3568 .filter(|e| {
3569 e["hooks"][0]["command"]
3570 .as_str()
3571 .is_some_and(|c| c.contains("hook claude"))
3572 })
3573 .collect();
3574 assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
3575 assert_eq!(
3576 sqz_entries[0]["matcher"].as_str().unwrap(),
3577 "Bash|PowerShell",
3578 "stale Bash-only matcher must be upgraded"
3579 );
3580 assert!(
3581 sqz_entries[0]["hooks"][0]["command"]
3582 .as_str()
3583 .unwrap()
3584 .contains("/new/path/sqz"),
3585 "binary path must be refreshed"
3586 );
3587
3588 assert!(
3590 pre.iter().any(|e| e["matcher"] == "Edit"),
3591 "user's non-sqz PreToolUse entry must be preserved"
3592 );
3593 assert_eq!(
3594 parsed["permissions"]["allow"][0].as_str().unwrap(),
3595 "Bash(ls:*)",
3596 "user's permissions must be preserved"
3597 );
3598
3599 assert!(parsed["hooks"]["PreCompact"].is_array());
3601 assert!(parsed["hooks"]["SessionStart"].is_array());
3602 }
3603
3604 #[test]
3605 fn test_windsurf_installs_to_rules_directory() {
3606 let dir = tempfile::tempdir().unwrap();
3607 let home = tempfile::tempdir().unwrap();
3608 install_in(dir.path(), home.path(), "sqz");
3609 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3610 assert!(!dir.path().join(".windsurfrules").exists());
3611 }
3612
3613 fn legacy_rules(sqz: &str) -> String {
3614 format!(
3615 "# sqz \u{2014} Token-Optimized CLI Output\n\n```bash\ngit status git status 2>&1 | {sqz} compress\n```\n\nUse these when you genuinely need raw data. The default compressed\npath is still what you want for most output.\n"
3616 )
3617 }
3618
3619 #[test]
3620 fn test_windsurf_migrates_legacy_sqz_rules_file() {
3621 let dir = tempfile::tempdir().unwrap();
3622 let home = tempfile::tempdir().unwrap();
3623 std::fs::write(dir.path().join(".windsurfrules"), legacy_rules("sqz")).unwrap();
3624 install_in(dir.path(), home.path(), "sqz");
3625 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3626 assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
3627 }
3628
3629 #[test]
3630 fn test_windsurf_migration_keeps_user_rules_after_the_sqz_text() {
3631 let dir = tempfile::tempdir().unwrap();
3632 let home = tempfile::tempdir().unwrap();
3633 let legacy = dir.path().join(".windsurfrules");
3634 std::fs::write(&legacy, format!("{}\n## Team rules\n\nUse tabs.\n", legacy_rules("sqz"))).unwrap();
3635 install_in(dir.path(), home.path(), "sqz");
3636 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3637 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), "## Team rules\n\nUse tabs.\n");
3638 }
3639
3640 #[test]
3641 fn test_windsurf_leaves_user_legacy_rules_alone() {
3642 let dir = tempfile::tempdir().unwrap();
3643 let home = tempfile::tempdir().unwrap();
3644 std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
3645 install_in(dir.path(), home.path(), "sqz");
3646 assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
3647 assert_eq!(
3648 std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
3649 "my own rules\n"
3650 );
3651 }
3652
3653 #[test]
3654 fn test_cline_installs_into_rules_directory_when_present() {
3655 let dir = tempfile::tempdir().unwrap();
3656 let home = tempfile::tempdir().unwrap();
3657 std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
3658 std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
3659 install_in(dir.path(), home.path(), "sqz");
3660 assert!(dir.path().join(".clinerules/sqz.md").exists());
3661 assert!(dir.path().join(".clinerules/mine.md").exists());
3662 }
3663
3664 #[test]
3665 fn test_cline_single_file_untouched_when_user_authored() {
3666 let dir = tempfile::tempdir().unwrap();
3667 let home = tempfile::tempdir().unwrap();
3668 std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
3669 install_in(dir.path(), home.path(), "sqz");
3670 assert_eq!(
3671 std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
3672 "user rules\n"
3673 );
3674 }
3675
3676 #[test]
3678 fn test_reinit_is_idempotent_at_project_scope() {
3679 let dir = tempfile::tempdir().unwrap();
3680 let home = tempfile::tempdir().unwrap();
3681 install_in(dir.path(), home.path(), "/usr/bin/sqz");
3682 let settings_path = dir.path().join(".claude").join("settings.local.json");
3683 let first = std::fs::read_to_string(&settings_path).unwrap();
3684
3685 install_in(dir.path(), home.path(), "/usr/bin/sqz");
3686 let second = std::fs::read_to_string(&settings_path).unwrap();
3687
3688 let a: serde_json::Value = serde_json::from_str(&first).unwrap();
3689 let b: serde_json::Value = serde_json::from_str(&second).unwrap();
3690 assert_eq!(a, b, "second init must not change project settings");
3691 assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
3693 }
3694
3695 #[test]
3696 fn init_refreshes_old_sqz_rules_in_place() {
3697 let dir = tempfile::tempdir().unwrap();
3698 let filter = ToolFilter::Only(vec!["cursor".into(), "windsurf".into(), "cline".into()]);
3699 let old_cursor = "---\ndescription: sqz \u{2014} Token-Optimized CLI Output\nalwaysApply: true\n---\n\n# sqz \u{2014} Token-Optimized CLI Output\n\ngit status git status 2>&1 | /old/sqz compress\n";
3700 std::fs::create_dir_all(dir.path().join(".cursor/rules")).unwrap();
3701 std::fs::write(dir.path().join(".cursor/rules/sqz.mdc"), old_cursor).unwrap();
3702 std::fs::write(dir.path().join(".clinerules"), legacy_rules("/old/sqz")).unwrap();
3703
3704 let installed =
3705 install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter);
3706 assert_eq!(installed, vec!["Cursor", "Windsurf", "Cline"]);
3707 for (tool, rel) in [
3708 ("Cursor", ".cursor/rules/sqz.mdc"),
3709 ("Windsurf", ".windsurf/rules/sqz.md"),
3710 ("Cline", ".clinerules"),
3711 ] {
3712 let content = std::fs::read_to_string(dir.path().join(rel)).unwrap();
3713 assert_eq!(content, sqz_rules_content(tool, "/new/sqz"), "{tool}");
3714 assert!(!content.contains('%') && !content.contains('\u{2014}'), "{tool}");
3715 }
3716 assert!(
3717 install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
3718 .is_empty()
3719 );
3720 }
3721
3722 #[test]
3723 fn init_leaves_user_written_rules_alone() {
3724 let dir = tempfile::tempdir().unwrap();
3725 let cursor = dir.path().join(".cursor/rules/sqz.mdc");
3726 std::fs::create_dir_all(cursor.parent().unwrap()).unwrap();
3727 std::fs::write(&cursor, "my own rule\n").unwrap();
3728 let filter = ToolFilter::Only(vec!["cursor".into()]);
3729 assert!(
3730 install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
3731 .is_empty()
3732 );
3733 assert_eq!(std::fs::read_to_string(&cursor).unwrap(), "my own rule\n");
3734 }
3735
3736 #[test]
3737 fn rules_refresh_keeps_the_binary_the_old_text_points_at() {
3738 use crate::kiro_integration::SteeringState;
3739 let dir = tempfile::tempdir().unwrap();
3740 let bin = dir.path().join("sqz");
3741 std::fs::write(&bin, "").unwrap();
3742 let bin = bin.to_string_lossy().to_string();
3743 let path = dir.path().join("sqz.md");
3744 std::fs::write(
3745 &path,
3746 format!("# sqz \u{2014} Token-Optimized CLI Output\n\ngit status git status 2>&1 | {bin} compress\n"),
3747 )
3748 .unwrap();
3749
3750 assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::Stale);
3751 assert!(refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
3752 assert_eq!(std::fs::read_to_string(&path).unwrap(), sqz_rules_content("Windsurf", &bin));
3753 assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::Current);
3754 assert!(!refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
3755
3756 std::fs::write(&path, "my own rules\n").unwrap();
3757 assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::UserAuthored);
3758 assert!(!refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
3759 assert_eq!(std::fs::read_to_string(&path).unwrap(), "my own rules\n");
3760 }
3761
3762 #[test]
3763 fn cline_rules_keep_the_user_rules_after_them() {
3764 use crate::kiro_integration::SteeringState;
3765 let dir = tempfile::tempdir().unwrap();
3766 let path = dir.path().join(".clinerules");
3767 let filter = ToolFilter::Only(vec!["cline".into()]);
3768 let install = |sqz: &str| {
3769 install_tool_hooks_scoped_filtered(dir.path(), sqz, InstallScope::Project, &filter)
3770 };
3771 let mine = "\n## Team rules\n\nUse tabs.\n";
3772 std::fs::write(&path, format!("{}{mine}", legacy_rules("/old/sqz"))).unwrap();
3773
3774 assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::Stale);
3775 assert_eq!(install("/new/sqz"), vec!["Cline"]);
3776 assert_eq!(
3777 std::fs::read_to_string(&path).unwrap(),
3778 format!("{}{mine}", sqz_rules_content("Cline", "/new/sqz"))
3779 );
3780 assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::Current);
3781 assert!(install("/new/sqz").is_empty());
3782
3783 assert_eq!(install("/other/sqz"), vec!["Cline"]);
3784 assert_eq!(
3785 std::fs::read_to_string(&path).unwrap(),
3786 format!("{}{mine}", sqz_rules_content("Cline", "/other/sqz"))
3787 );
3788
3789 assert!(remove_sqz_rules(&path).unwrap());
3790 assert_eq!(std::fs::read_to_string(&path).unwrap(), "## Team rules\n\nUse tabs.\n");
3791 assert!(!has_sqz_rules(&path));
3792 }
3793
3794 #[test]
3795 fn cline_refresh_keeps_the_old_binary_and_the_user_rules() {
3796 let dir = tempfile::tempdir().unwrap();
3797 let bin = dir.path().join("sqz");
3798 std::fs::write(&bin, "").unwrap();
3799 let bin = bin.to_string_lossy().to_string();
3800 let path = dir.path().join(".clinerules");
3801 let v2_ending = "and commands with shell operators. If sqz is\nnot on PATH, run commands normally.\n";
3802 let old = legacy_rules(&bin).replace(
3803 "Use these when you genuinely need raw data. The default compressed\npath is still what you want for most output.\n",
3804 v2_ending,
3805 );
3806 std::fs::write(&path, format!("{old}\nUse tabs.\n")).unwrap();
3807
3808 assert!(refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3809 let block = sqz_rules_content("Cline", &bin);
3810 assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{block}\nUse tabs.\n"));
3811 assert!(!refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3812
3813 std::fs::write(&path, format!("{}\nUse tabs.\n", block.replace("sqz_recall", "recall"))).unwrap();
3814 assert!(refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3815 assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{block}\nUse tabs.\n"));
3816 }
3817
3818 #[test]
3819 fn cline_rules_without_a_known_end_are_left_alone() {
3820 use crate::kiro_integration::SteeringState;
3821 let dir = tempfile::tempdir().unwrap();
3822 let path = dir.path().join(".clinerules");
3823 let edited = "# sqz \u{2014} Token-Optimized CLI Output\n\nPipe output through sqz.\n\n## Team rules\n";
3824 std::fs::write(&path, edited).unwrap();
3825 let filter = ToolFilter::Only(vec!["cline".into()]);
3826
3827 assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::UserAuthored);
3828 assert!(
3829 install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
3830 .is_empty()
3831 );
3832 assert!(!refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
3833 assert!(!remove_sqz_rules(&path).unwrap());
3834 assert_eq!(std::fs::read_to_string(&path).unwrap(), edited);
3835 }
3836}
3837
3838#[cfg(test)]
3839mod global_install_tests {
3840 use super::*;
3841
3842 #[test]
3843 fn global_install_creates_fresh_settings_json() {
3844 let tmp = tempfile::tempdir().unwrap();
3845 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
3846 assert!(changed, "first install should report a change");
3847
3848 let path = tmp.path().join(".claude").join("settings.json");
3849 assert!(path.exists(), "user settings.json should be created");
3850
3851 let content = std::fs::read_to_string(&path).unwrap();
3852 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
3853
3854 let pre = &parsed["hooks"]["PreToolUse"];
3856 assert!(pre.is_array(), "PreToolUse should be an array");
3857 assert_eq!(pre.as_array().unwrap().len(), 1);
3858 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
3859 assert!(
3860 cmd.contains("/usr/local/bin/sqz"),
3861 "hook command should use the passed sqz_path, got: {cmd}"
3862 );
3863 assert!(cmd.contains("hook claude"));
3864
3865 let precompact = &parsed["hooks"]["PreCompact"];
3866 assert!(precompact.is_array());
3867 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
3868 assert!(precompact_cmd.contains("hook precompact"));
3869
3870 let session = &parsed["hooks"]["SessionStart"];
3871 assert!(session.is_array());
3872 assert_eq!(
3873 session[0]["matcher"].as_str().unwrap(),
3874 "compact",
3875 "SessionStart should only match /compact resume"
3876 );
3877 }
3878
3879 #[test]
3880 fn global_install_preserves_existing_user_config() {
3881 let tmp = tempfile::tempdir().unwrap();
3882 let settings = tmp.path().join(".claude").join("settings.json");
3883 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
3884
3885 let existing = serde_json::json!({
3886 "permissions": {
3887 "allow": ["Bash(npm test *)"],
3888 "deny": ["Read(./.env)"]
3889 },
3890 "env": { "FOO": "bar" },
3891 "statusLine": {
3892 "type": "command",
3893 "command": "~/.claude/statusline.sh"
3894 },
3895 "hooks": {
3896 "PreToolUse": [
3897 {
3898 "matcher": "Edit",
3899 "hooks": [
3900 {
3901 "type": "command",
3902 "command": "~/.claude/hooks/format-on-edit.sh"
3903 }
3904 ]
3905 }
3906 ]
3907 }
3908 });
3909 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
3910
3911 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
3912 assert!(changed, "install should report a change on new hook");
3913
3914 let content = std::fs::read_to_string(&settings).unwrap();
3915 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
3916
3917 assert_eq!(
3919 parsed["permissions"]["allow"][0].as_str().unwrap(),
3920 "Bash(npm test *)"
3921 );
3922 assert_eq!(
3923 parsed["permissions"]["deny"][0].as_str().unwrap(),
3924 "Read(./.env)"
3925 );
3926 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
3928 assert_eq!(
3930 parsed["statusLine"]["command"].as_str().unwrap(),
3931 "~/.claude/statusline.sh"
3932 );
3933
3934 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
3937 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
3938 let matchers: Vec<&str> = pre
3939 .iter()
3940 .map(|e| e["matcher"].as_str().unwrap_or(""))
3941 .collect();
3942 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
3943 assert!(
3944 matchers.contains(&"Bash|PowerShell"),
3945 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
3946 );
3947 }
3948
3949 #[test]
3950 fn global_install_is_idempotent() {
3951 let tmp = tempfile::tempdir().unwrap();
3952 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
3953 assert!(
3954 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
3955 "second install with identical args should report no change"
3956 );
3957
3958 let path = tmp.path().join(".claude").join("settings.json");
3959 let parsed: serde_json::Value =
3960 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
3961 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
3962 let arr = parsed["hooks"][event].as_array().unwrap();
3963 assert_eq!(
3964 arr.len(),
3965 1,
3966 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
3967 );
3968 }
3969 }
3970
3971 #[test]
3978 fn global_install_is_idempotent_with_exe_path() {
3979 let tmp = tempfile::tempdir().unwrap();
3980 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
3981 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
3982 assert!(
3983 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
3984 "second install with .exe path should report no change (issue #21)"
3985 );
3986
3987 let path = tmp.path().join(".claude").join("settings.json");
3988 let parsed: serde_json::Value =
3989 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
3990 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
3991 let arr = parsed["hooks"][event].as_array().unwrap();
3992 assert_eq!(
3993 arr.len(),
3994 1,
3995 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
3996 );
3997 }
3998 }
3999
4000 #[test]
4001 fn global_install_upgrades_stale_sqz_hook_in_place() {
4002 let tmp = tempfile::tempdir().unwrap();
4003 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
4004 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
4005 assert!(changed, "different sqz_path must be seen as a change");
4006
4007 let path = tmp.path().join(".claude").join("settings.json");
4008 let parsed: serde_json::Value =
4009 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4010 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
4011 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
4012 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
4013 assert!(cmd.contains("/new/path/sqz"));
4014 assert!(!cmd.contains("/old/path/sqz"));
4015 }
4016
4017 #[test]
4018 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
4019 let tmp = tempfile::tempdir().unwrap();
4020 let settings = tmp.path().join(".claude").join("settings.json");
4021 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4022 std::fs::write(
4023 &settings,
4024 serde_json::json!({
4025 "permissions": { "allow": ["Bash(git status)"] },
4026 "hooks": {
4027 "PreToolUse": [
4028 {
4029 "matcher": "Edit",
4030 "hooks": [
4031 { "type": "command", "command": "~/format.sh" }
4032 ]
4033 }
4034 ]
4035 }
4036 })
4037 .to_string(),
4038 )
4039 .unwrap();
4040
4041 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
4042 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
4043 assert_eq!(result.0, settings);
4044 assert!(result.1, "should report that the file was modified");
4045
4046 assert!(settings.exists(), "settings.json should be preserved");
4047 let parsed: serde_json::Value =
4048 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
4049
4050 assert_eq!(
4051 parsed["permissions"]["allow"][0].as_str().unwrap(),
4052 "Bash(git status)"
4053 );
4054
4055 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
4056 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
4057 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
4058
4059 assert!(parsed["hooks"].get("PreCompact").is_none());
4060 assert!(parsed["hooks"].get("SessionStart").is_none());
4061 }
4062
4063 #[test]
4064 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
4065 let tmp = tempfile::tempdir().unwrap();
4066 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
4067 let path = tmp.path().join(".claude").join("settings.json");
4068 assert!(path.exists(), "precondition: install created the file");
4069
4070 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
4071 assert!(result.1);
4072 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
4073 }
4074
4075 #[test]
4076 fn only_commands_that_run_sqz_count_as_sqz_hooks() {
4077 for (command, args) in [
4078 ("sqz hook claude", "hook claude"),
4079 ("/usr/local/bin/sqz hook claude-post", "hook claude-post"),
4080 (r"C:\Users\me\.cargo\bin\sqz.exe hook precompact", "hook precompact"),
4081 ("C:/Users/Jane Doe/.cargo/bin/sqz.EXE resume", "resume"),
4082 (r"C:\Users\Jane Doe\.cargo\bin\sqz.exe hook claude", "hook claude"),
4083 ("/Users/Jane Doe/bin/sqz resume", "resume"),
4084 ("~/.cargo/bin/sqz hook claude", "hook claude"),
4085 ("\"/opt/sqz\" hook claude", "hook claude"),
4086 ("'/opt/my tools (x86)/sqz' hook claude-post", "hook claude-post"),
4087 ] {
4088 assert!(sqz_command_binary(command, args).is_some(), "{command}");
4089 }
4090 for (command, args) in [
4091 ("/usr/local/bin/on-resume.sh", "resume"),
4092 ("~/bin/save-notes --hook precompact", "hook precompact"),
4093 ("~/bin/audit-hook claude", "hook claude"),
4094 ("echo hi && /usr/bin/sqz hook claude", "hook claude"),
4095 ("nice -n 5 sqz hook claude", "hook claude"),
4096 ("/usr/bin/time /opt/sqz hook claude", "hook claude"),
4097 ("/usr/bin/env SQZ_NO_DEDUP=1 /opt/sqz hook claude", "hook claude"),
4098 ("/usr/bin/nice -n 5 /opt/sqz hook claude", "hook claude"),
4099 ("/usr/bin/time ~/bin/sqz hook claude", "hook claude"),
4100 ("sqz hook claude --verbose", "hook claude"),
4101 ("notsqz hook claude", "hook claude"),
4102 ("sqz hook claude-post", "hook claude"),
4103 ("\"/opt/sqz hook claude", "hook claude"),
4104 ("\"$HOME/bin/sqz\" hook claude", "hook claude"),
4105 ("\"/opt/notsqz\" hook claude", "hook claude"),
4106 ] {
4107 assert!(sqz_command_binary(command, args).is_none(), "{command}");
4108 }
4109 }
4110
4111 #[test]
4112 fn legacy_hook_files_lose_only_sqz_hooks() {
4113 let project = tempfile::tempdir().unwrap();
4114 let home = tempfile::tempdir().unwrap();
4115 let write = |root: &Path, rel: &str, text: &str| {
4116 let path = root.join(rel);
4117 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4118 std::fs::write(&path, text).unwrap();
4119 path
4120 };
4121 let p = project.path();
4122 let cursor = write(
4123 p,
4124 ".cursor/hooks.json",
4125 r#"{"version":1,"hooks":{"beforeShellExecution":[{"command":"/opt/sqz hook cursor"},{"command":"./audit.sh"}]}}"#,
4126 );
4127 let windsurf = write(p, ".windsurf/hooks.json", r#"{"hooks":{"pre_run_command":[{"command":"/opt/sqz hook windsurf"}]}}"#);
4128 let cline = write(
4129 p,
4130 ".cline/hooks.json",
4131 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"sqz hook cline"}]}]}}"#,
4132 );
4133 let script = write(p, ".clinerules/hooks/PreToolUse", &LEGACY_CLINE_SCRIPT.replace('\n', "\r\n"));
4134 let user_cursor = write(
4135 home.path(),
4136 ".cursor/hooks.json",
4137 r#"{"version":1,"hooks":{"preToolUse":[{"command":"C:/tools/sqz.exe hook cursor","matcher":"Shell"}]}}"#,
4138 );
4139
4140 let all = vec![cursor.clone(), windsurf.clone(), cline.clone(), script.clone(), user_cursor.clone()];
4141 assert_eq!(legacy_sqz_hook_files_at(p, Some(home.path())), all);
4142 assert_eq!(
4143 remove_legacy_sqz_hooks_at(p, Some(home.path())).unwrap(),
4144 vec![(cursor.clone(), false), (windsurf, true), (cline, true), (script.clone(), true), (user_cursor, true)]
4145 );
4146 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cursor).unwrap()).unwrap();
4147 assert_eq!(v, serde_json::json!({"version": 1, "hooks": {"beforeShellExecution": [{"command": "./audit.sh"}]}}));
4148 assert!(legacy_sqz_hook_files_at(p, Some(home.path())).is_empty());
4149
4150 write(p, ".clinerules/hooks/PreToolUse", &LEGACY_CLINE_SCRIPT.replace("exit 0", "exit 1"));
4151 assert!(legacy_sqz_hook_files_at(p, Some(home.path())).is_empty(), "an edited script is the user's");
4152 }
4153
4154 #[test]
4155 fn hook_binaries_are_read_from_every_hook_file_kind() {
4156 let dir = tempfile::tempdir().unwrap();
4157 let settings = dir.path().join("settings.json");
4158 std::fs::write(&settings, claude_settings_content("/opt/sqz")).unwrap();
4159 assert_eq!(sqz_hook_binaries(&settings), vec!["/opt/sqz"]);
4160 let copilot = dir.path().join("sqz.json");
4161 std::fs::write(&copilot, copilot_hook_content("C:\\tools\\sqz.exe")).unwrap();
4162 assert_eq!(sqz_hook_binaries(&copilot), vec!["C:\\tools\\sqz.exe"]);
4163 let plugin = dir.path().join("sqz.ts");
4164 std::fs::write(&plugin, crate::opencode_plugin::generate_opencode_plugin("/gone/sqz")).unwrap();
4165 assert_eq!(sqz_hook_binaries(&plugin), vec!["/gone/sqz"]);
4166 let user = dir.path().join("user.json");
4167 std::fs::write(
4168 &user,
4169 r#"{"hooks":{"PreCompact":[{"hooks":[{"command":"~/bin/save-notes --hook precompact"}]}],"PreToolUse":[{"matcher":"Bash","hooks":[{"command":"/usr/bin/time /opt/sqz hook claude"}]}]}}"#,
4170 )
4171 .unwrap();
4172 assert!(sqz_hook_binaries(&user).is_empty());
4173 }
4174
4175 #[test]
4176 fn refresh_never_points_a_hook_at_a_missing_binary() {
4177 let dir = tempfile::tempdir().unwrap();
4178 let path = dir.path().join("settings.json");
4179 let old = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/gone/sqz hook claude"}]}]}}"#;
4180 std::fs::write(&path, old).unwrap();
4181 assert!(refresh_claude_settings(&path, "/also-gone/sqz").is_err());
4182 assert_eq!(std::fs::read_to_string(&path).unwrap(), old);
4183 }
4184
4185 #[test]
4186 fn hook_binaries_resolve_to_files_without_running_them() {
4187 let dir = tempfile::tempdir().unwrap();
4188 let bin = dir.path().join("my tools").join("sqz");
4189 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
4190 std::fs::write(&bin, "").unwrap();
4191 let shown = bin.to_string_lossy().to_string();
4192 assert_eq!(resolve_sqz_binary(&shown), Some(bin.clone()));
4193 assert_eq!(resolve_sqz_binary(&format!("\"{shown}\"")), Some(bin.clone()));
4194 assert_eq!(resolve_sqz_binary(&format!("{shown}-gone")), None);
4195 assert_eq!(resolve_sqz_binary("sqz-not-on-any-path-xyz"), None);
4196 }
4197
4198 #[test]
4199 fn user_hooks_that_look_like_sqz_ones_are_kept() {
4200 let tmp = tempfile::tempdir().unwrap();
4201 let settings = tmp.path().join(".claude").join("settings.json");
4202 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4203 let cmd = |c: &str| serde_json::json!({"type": "command", "command": c});
4204 let user = serde_json::json!({
4205 "hooks": {
4206 "SessionStart": [
4207 {"matcher": "resume", "hooks": [cmd("/usr/local/bin/on-resume.sh")]},
4208 {"matcher": "startup", "hooks": [cmd("echo user-startup")]}
4209 ],
4210 "PreCompact": [{"hooks": [cmd("~/bin/save-notes --hook precompact")]}],
4211 "PreToolUse": [{"matcher": "Bash", "hooks": [
4212 cmd("~/bin/audit-hook claude"),
4213 cmd("/usr/bin/env SQZ_NO_DEDUP=1 /opt/sqz hook claude")
4214 ]}]
4215 }
4216 });
4217 let text = serde_json::to_string_pretty(&user).unwrap();
4218 std::fs::write(&settings, &text).unwrap();
4219 let read = || -> serde_json::Value {
4220 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap()
4221 };
4222
4223 assert_eq!(remove_claude_global_hook_at(Some(tmp.path())).unwrap(), Some((settings.clone(), false)));
4224 assert_eq!(std::fs::read_to_string(&settings).unwrap(), text);
4225
4226 assert!(install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
4227 assert!(!install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
4228 let installed = read();
4229 for (event, user_count) in [("SessionStart", 2), ("PreCompact", 1), ("PreToolUse", 1)] {
4230 let arr = installed["hooks"][event].as_array().unwrap();
4231 assert_eq!(arr.len(), user_count + 1, "{event}: {arr:?}");
4232 assert_eq!(arr[..user_count], user["hooks"][event].as_array().unwrap()[..], "{event}");
4233 }
4234
4235 let mut shared = installed.clone();
4236 shared["hooks"]["PreToolUse"][1]["hooks"]
4237 .as_array_mut()
4238 .unwrap()
4239 .push(cmd("echo also-mine"));
4240 std::fs::write(&settings, serde_json::to_string_pretty(&shared).unwrap()).unwrap();
4241 assert!(remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap().1);
4242 let mut expected = user.clone();
4243 expected["hooks"]["PreToolUse"]
4244 .as_array_mut()
4245 .unwrap()
4246 .push(serde_json::json!({"matcher": "Bash|PowerShell", "hooks": [cmd("echo also-mine")]}));
4247 assert_eq!(read(), expected);
4248 }
4249
4250 #[test]
4251 fn project_install_leaves_claude_hooks_to_the_user_level_ones() {
4252 let home = tempfile::tempdir().unwrap();
4253 let project = tempfile::tempdir().unwrap();
4254 install_claude_global_at("/opt/sqz", Some(home.path())).unwrap();
4255 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
4256 let installed =
4257 install_tool_hooks_at(project.path(), "/opt/sqz", InstallScope::Project, &filter, Some(home.path()));
4258 assert_eq!(installed, vec!["Claude Code"]);
4259 assert!(!project.path().join(".claude").join("settings.local.json").exists());
4260 assert!(project.path().join("CLAUDE.md").exists());
4261 }
4262
4263 #[test]
4264 fn duplicate_project_hooks_go_and_permissions_stay() {
4265 let project = tempfile::tempdir().unwrap();
4266 let local = project.path().join(".claude").join("settings.local.json");
4267 std::fs::create_dir_all(local.parent().unwrap()).unwrap();
4268 std::fs::write(&local, claude_settings_content("/opt/sqz")).unwrap();
4269 assert!(claude_settings_have_sqz_hooks(&local));
4270 assert!(remove_claude_project_hooks_only(project.path()).unwrap().unwrap().1);
4271 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&local).unwrap()).unwrap();
4272 assert_eq!(v, serde_json::json!({"permissions": {"allow": CLAUDE_ALLOW_RULES}}));
4273 assert!(!claude_settings_have_sqz_hooks(&local));
4274
4275 let hook = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"sqz hook claude"}]}]}}"#;
4276 std::fs::write(&local, hook).unwrap();
4277 assert!(remove_claude_project_hooks_only(project.path()).unwrap().unwrap().1);
4278 assert!(!local.exists());
4279 }
4280
4281 #[test]
4282 fn settings_rewrites_keep_the_final_newline_and_mode() {
4283 let tmp = tempfile::tempdir().unwrap();
4284 let settings = tmp.path().join(".claude").join("settings.json");
4285 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4286 let text = "{\n \"theme\": \"dark\"\n}\n";
4287 std::fs::write(&settings, text).unwrap();
4288 #[cfg(unix)]
4289 {
4290 use std::os::unix::fs::PermissionsExt;
4291 std::fs::set_permissions(&settings, std::fs::Permissions::from_mode(0o644)).unwrap();
4292 }
4293
4294 assert!(install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
4295 assert!(std::fs::read_to_string(&settings).unwrap().ends_with("}\n"));
4296 assert!(remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap().1);
4297 assert_eq!(std::fs::read_to_string(&settings).unwrap(), text);
4298 #[cfg(unix)]
4299 {
4300 use std::os::unix::fs::PermissionsExt;
4301 let mode = std::fs::metadata(&settings).unwrap().permissions().mode() & 0o777;
4302 assert_eq!(mode, 0o644);
4303 }
4304 }
4305
4306 #[test]
4307 fn global_uninstall_on_missing_file_is_noop() {
4308 let tmp = tempfile::tempdir().unwrap();
4309 assert!(
4310 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
4311 "missing file should return None, not error"
4312 );
4313 }
4314
4315 #[test]
4316 fn global_uninstall_refuses_to_touch_unparseable_file() {
4317 let tmp = tempfile::tempdir().unwrap();
4318 let settings = tmp.path().join(".claude").join("settings.json");
4319 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
4320 std::fs::write(&settings, "{ invalid json because").unwrap();
4321
4322 assert!(
4323 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
4324 "bad JSON must surface as an error"
4325 );
4326
4327 let after = std::fs::read_to_string(&settings).unwrap();
4328 assert_eq!(after, "{ invalid json because");
4329 }
4330}
4331
4332#[cfg(test)]
4333mod issue_11_tool_filter_tests {
4334 use super::*;
4343
4344 #[test]
4345 fn canonicalize_collapses_common_aliases() {
4346 for aliases in &[
4349 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
4350 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
4351 (vec!["Windsurf", "WINDSURF"], "windsurf"),
4352 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
4356 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
4357 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
4358 (vec!["Codex", "codex"], "codex"),
4359 ] {
4360 for alias in &aliases.0 {
4361 assert_eq!(
4362 canonicalize_tool_name(alias),
4363 aliases.1,
4364 "alias '{}' must canonicalise to '{}'",
4365 alias,
4366 aliases.1
4367 );
4368 }
4369 }
4370 }
4371
4372 #[test]
4373 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
4374 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
4379 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
4380 }
4381
4382 #[test]
4383 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
4384 let names = parse_tool_list("opencode,codex").unwrap();
4387 assert_eq!(names, vec!["opencode", "codex"]);
4388
4389 let names = parse_tool_list(" opencode , codex ").unwrap();
4390 assert_eq!(names, vec!["opencode", "codex"]);
4391
4392 let names = parse_tool_list("opencode").unwrap();
4394 assert_eq!(names, vec!["opencode"]);
4395
4396 let names = parse_tool_list("claude-code").unwrap();
4398 assert_eq!(names, vec!["claudecode"]);
4399 }
4400
4401 #[test]
4402 fn parse_tool_list_dedupes_repeated_entries() {
4403 let names = parse_tool_list("opencode,opencode").unwrap();
4408 assert_eq!(names, vec!["opencode"]);
4409
4410 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
4413 assert_eq!(names, vec!["claudecode"]);
4414 }
4415
4416 #[test]
4417 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
4418 let err = parse_tool_list("opncode").unwrap_err();
4425 let msg = err.to_string();
4426 assert!(
4427 msg.contains("unknown agent name 'opncode'"),
4428 "error must quote the bad input: {msg}"
4429 );
4430 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
4431 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
4432 }
4433
4434 #[test]
4435 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
4436 let err = parse_tool_list("opencode,xyz").unwrap_err();
4441 assert!(err.to_string().contains("xyz"));
4442 }
4443
4444 #[test]
4445 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
4446 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
4452 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
4453 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
4454 }
4455
4456 #[test]
4457 fn tool_filter_all_includes_every_supported_tool() {
4458 let filter = ToolFilter::All;
4459 for tool in SUPPORTED_TOOL_NAMES {
4460 assert!(
4461 filter.includes(tool),
4462 "default filter must include {tool}"
4463 );
4464 }
4465 }
4466
4467 #[test]
4468 fn tool_filter_only_opencode_excludes_everything_else() {
4469 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
4471 assert!(filter.includes("OpenCode"));
4472 for tool in SUPPORTED_TOOL_NAMES {
4474 if *tool == "OpenCode" {
4475 continue;
4476 }
4477 assert!(
4478 !filter.includes(tool),
4479 "--only opencode must not include {tool}"
4480 );
4481 }
4482 }
4483
4484 #[test]
4485 fn tool_filter_only_multi_tool_includes_exactly_those() {
4486 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
4487 assert!(filter.includes("OpenCode"));
4488 assert!(filter.includes("Codex"));
4489 assert!(!filter.includes("Claude Code"));
4491 assert!(!filter.includes("Cursor"));
4492 assert!(!filter.includes("Windsurf"));
4493 assert!(!filter.includes("Cline"));
4494 assert!(!filter.includes("Gemini CLI"));
4495 }
4496
4497 #[test]
4498 fn tool_filter_skip_inverts_the_set() {
4499 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
4502 assert!(!filter.includes("Cursor"));
4503 assert!(!filter.includes("Windsurf"));
4504 assert!(filter.includes("Claude Code"));
4506 assert!(filter.includes("Cline"));
4507 assert!(filter.includes("Gemini CLI"));
4508 assert!(filter.includes("OpenCode"));
4509 assert!(filter.includes("Codex"));
4510 }
4511
4512 #[test]
4513 fn tool_filter_only_empty_excludes_everything() {
4514 let filter = ToolFilter::Only(vec![]);
4520 for tool in SUPPORTED_TOOL_NAMES {
4521 assert!(
4522 !filter.includes(tool),
4523 "empty --only must exclude every tool, got {tool}"
4524 );
4525 }
4526 }
4527
4528 #[test]
4529 fn tool_filter_only_accepts_display_name_or_canonical() {
4530 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
4536 assert!(filter.includes("Claude Code"));
4537 assert!(!filter.includes("Cursor"));
4538
4539 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
4540 assert!(filter.includes("Gemini CLI"));
4541 }
4542
4543 #[test]
4544 fn supported_tool_names_matches_generate_hook_configs_exactly() {
4545 let configs = generate_hook_configs("sqz");
4551 let emitted: std::collections::HashSet<&str> =
4552 configs.iter().map(|c| c.tool_name.as_str()).collect();
4553 let declared: std::collections::HashSet<&str> =
4554 SUPPORTED_TOOL_NAMES.iter().copied().collect();
4555 assert_eq!(
4556 emitted, declared,
4557 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
4558 from generate_hook_configs. emitted={:?}, declared={:?}",
4559 emitted, declared
4560 );
4561 }
4562
4563 #[test]
4564 fn filtered_install_only_opencode_writes_only_opencode_files() {
4565 let dir = tempfile::tempdir().unwrap();
4571 let home = tempfile::tempdir().unwrap();
4572 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
4573 let _installed = install_tool_hooks_at(
4574 dir.path(),
4575 "sqz",
4576 InstallScope::Project,
4577 &filter,
4578 Some(home.path()),
4579 );
4580
4581 assert!(
4583 dir.path().join("opencode.json").exists(),
4584 "OpenCode config must be written when --only opencode is used"
4585 );
4586
4587 for (path, tool) in &[
4589 (".claude/settings.local.json", "Claude Code"),
4590 (".cursor/rules/sqz.mdc", "Cursor"),
4591 (".windsurfrules", "Windsurf"),
4592 (".clinerules", "Cline"),
4593 (".gemini/settings.json", "Gemini CLI"),
4594 ("AGENTS.md", "Codex"),
4595 ] {
4596 assert!(
4597 !dir.path().join(path).exists(),
4598 "filter rejected {tool} but the installer still wrote {path}"
4599 );
4600 }
4601 }
4602
4603 #[test]
4604 fn filtered_install_skip_cursor_omits_only_cursor() {
4605 let dir = tempfile::tempdir().unwrap();
4607 let home = tempfile::tempdir().unwrap();
4608 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
4609 let _installed = install_tool_hooks_at(
4610 dir.path(),
4611 "sqz",
4612 InstallScope::Project,
4613 &filter,
4614 Some(home.path()),
4615 );
4616
4617 assert!(
4619 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
4620 "skip cursor: .cursor/rules/sqz.mdc must not be written"
4621 );
4622 assert!(
4624 dir.path().join(".windsurf/rules/sqz.md").exists(),
4625 "skip cursor should not skip windsurf"
4626 );
4627 assert!(
4628 dir.path().join(".clinerules").exists(),
4629 "skip cursor should not skip cline"
4630 );
4631 }
4632}
4633
4634#[cfg(test)]
4635mod ci_hook_tests {
4636 use super::*;
4637
4638 #[test]
4639 fn ci_hook_content_is_valid_v1_config() {
4640 let parsed: serde_json::Value =
4641 serde_json::from_str(&ci_hook_content()).expect("CI hook must be valid JSON");
4642 assert_eq!(parsed["version"], 1);
4643 let session_start = parsed["hooks"]["sessionStart"].as_array().unwrap();
4644 assert!(session_start[0]["bash"]
4645 .as_str()
4646 .unwrap()
4647 .contains("install.sh"));
4648 let pre = parsed["hooks"]["preToolUse"].as_array().unwrap();
4649 let bash = pre[0]["bash"].as_str().unwrap();
4650 assert!(bash.contains("hook copilot"));
4651 assert!(bash.ends_with("echo '{}'"));
4654 assert_eq!(pre[0]["matcher"], "bash|powershell");
4655 }
4656
4657 #[test]
4658 fn install_ci_hook_creates_then_noops() {
4659 let dir = tempfile::tempdir().unwrap();
4660 assert!(install_ci_hook(dir.path()).unwrap());
4661 let path = ci_hooks_path(dir.path());
4662 assert!(path.exists());
4663 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
4664 assert!(!install_ci_hook(dir.path()).unwrap());
4666 }
4667
4668 #[test]
4669 fn install_ci_hook_refreshes_stale_sqz_file() {
4670 let dir = tempfile::tempdir().unwrap();
4671 let path = ci_hooks_path(dir.path());
4672 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4673 std::fs::write(&path, r#"{"version":1,"hooks":{"preToolUse":[{"bash":"sqz hook copilot"}]}}"#).unwrap();
4674 assert!(install_ci_hook(dir.path()).unwrap());
4675 assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
4676 }
4677
4678 #[test]
4679 fn install_ci_hook_leaves_user_file_alone() {
4680 let dir = tempfile::tempdir().unwrap();
4681 let path = ci_hooks_path(dir.path());
4682 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4683 std::fs::write(&path, r#"{"version":1,"hooks":{}}"#).unwrap();
4684 assert!(!install_ci_hook(dir.path()).unwrap());
4685 assert_eq!(
4686 std::fs::read_to_string(&path).unwrap(),
4687 r#"{"version":1,"hooks":{}}"#
4688 );
4689 }
4690
4691 #[test]
4692 fn remove_ci_hook_only_removes_sqz_authored() {
4693 let dir = tempfile::tempdir().unwrap();
4694 assert!(!remove_ci_hook(dir.path()).unwrap());
4696 install_ci_hook(dir.path()).unwrap();
4698 assert!(remove_ci_hook(dir.path()).unwrap());
4699 assert!(!ci_hooks_path(dir.path()).exists());
4700 let path = ci_hooks_path(dir.path());
4702 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4703 std::fs::write(&path, "{}").unwrap();
4704 assert!(!remove_ci_hook(dir.path()).unwrap());
4705 assert!(path.exists());
4706 }
4707
4708 fn read_json(path: &Path) -> serde_json::Value {
4709 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
4710 }
4711
4712 #[test]
4713 fn claude_install_adds_the_post_hook_and_allow_rules_once() {
4714 let tmp = tempfile::tempdir().unwrap();
4715 let path = tmp.path().join(".claude").join("settings.json");
4716 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4717 std::fs::write(&path, r#"{"permissions":{"allow":["Bash(npm test)"],"deny":["Bash(rm *)"]}}"#)
4718 .unwrap();
4719 assert!(install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap());
4720 let v = read_json(&path);
4721 assert_eq!(
4722 v["permissions"]["allow"],
4723 serde_json::json!(["Bash(npm test)", "Bash(set -o pipefail)", "Bash(sqz compress --cmd *)"])
4724 );
4725 assert_eq!(v["permissions"]["deny"], serde_json::json!(["Bash(rm *)"]));
4726 assert_eq!(v["hooks"]["PostToolUse"][0]["matcher"], "Bash");
4727 assert_eq!(
4728 v["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
4729 "/usr/local/bin/sqz hook claude-post"
4730 );
4731 assert!(claude_rules_installed(&[path.clone()]));
4732 assert!(!install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap());
4733
4734 let (_, changed) = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
4735 assert!(changed);
4736 assert_eq!(
4737 read_json(&path),
4738 serde_json::json!({"permissions": {"allow": ["Bash(npm test)"], "deny": ["Bash(rm *)"]}})
4739 );
4740 }
4741
4742 #[test]
4743 fn gemini_merge_and_removal_touch_only_sqz_entries() {
4744 let dir = tempfile::tempdir().unwrap();
4745 let path = dir.path().join(".gemini").join("settings.json");
4746 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4747 let mine = serde_json::json!({"matcher": "write_file", "hooks": [{"type": "command", "command": "lint"}]});
4748 let settings = serde_json::json!({"theme": "x", "hooks": {"BeforeTool": [mine, gemini_hook_entry("/old/sqz")]}});
4749 std::fs::write(&path, settings.to_string()).unwrap();
4750
4751 assert!(gemini_settings_has_sqz(&path));
4752 assert!(gemini_settings_needs_update(&path, "/new/sqz").unwrap());
4753 assert!(merge_sqz_gemini_hook(&path, "/new/sqz").unwrap());
4754 assert!(!gemini_settings_needs_update(&path, "/new/sqz").unwrap());
4755 assert!(!merge_sqz_gemini_hook(&path, "/new/sqz").unwrap());
4756 assert_eq!(
4757 read_json(&path)["hooks"]["BeforeTool"],
4758 serde_json::json!([mine, gemini_hook_entry("/new/sqz")])
4759 );
4760
4761 let (_, changed) = remove_sqz_from_gemini_settings(dir.path()).unwrap().unwrap();
4762 assert!(changed);
4763 assert_eq!(
4764 read_json(&path),
4765 serde_json::json!({"theme": "x", "hooks": {"BeforeTool": [mine]}})
4766 );
4767 assert!(!gemini_settings_has_sqz(&path));
4768 assert_eq!(remove_sqz_from_gemini_settings(dir.path()).unwrap(), Some((path.clone(), false)));
4769
4770 std::fs::write(&path, "{\n // comment\n \"theme\": \"x\"\n}\n").unwrap();
4771 assert!(gemini_settings_needs_update(&path, "/new/sqz").is_err());
4772 assert!(!merge_sqz_gemini_hook(&path, "/new/sqz").unwrap_or(false));
4773 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\n // comment\n \"theme\": \"x\"\n}\n");
4774 }
4775
4776 #[test]
4777 fn fresh_project_settings_carry_the_rules_and_post_hook() {
4778 let dir = tempfile::tempdir().unwrap();
4779 let path = dir.path().join("settings.local.json");
4780 std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
4781 assert!(claude_rules_installed(&[path.clone()]));
4782 assert_eq!(
4783 read_json(&path)["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
4784 "/usr/bin/sqz hook claude-post"
4785 );
4786 assert!(!claude_project_settings_needs_update(&path, "/usr/bin/sqz").unwrap());
4787
4788 let mut old = read_json(&path);
4790 old.as_object_mut().unwrap().remove("permissions");
4791 std::fs::write(&path, old.to_string()).unwrap();
4792 assert!(claude_settings_stale(&path));
4793 assert!(claude_project_settings_needs_update(&path, "/usr/bin/sqz").unwrap());
4794 assert!(merge_sqz_hooks_into_settings(&path, "/usr/bin/sqz").unwrap());
4795 assert!(!claude_settings_stale(&path));
4796 }
4797
4798 #[test]
4799 fn project_uninstall_keeps_permissions_claude_saved() {
4800 let dir = tempfile::tempdir().unwrap();
4801 let path = dir.path().join(".claude").join("settings.local.json");
4802 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4803 std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
4804 let mut v = read_json(&path);
4806 v["permissions"]["allow"].as_array_mut().unwrap().push(serde_json::json!("Bash(make)"));
4807 std::fs::write(&path, v.to_string()).unwrap();
4808
4809 let (_, changed) = remove_claude_project_hook(dir.path()).unwrap().unwrap();
4810 assert!(changed);
4811 assert_eq!(read_json(&path), serde_json::json!({"permissions": {"allow": ["Bash(make)"]}}));
4812
4813 std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
4815 remove_claude_project_hook(dir.path()).unwrap();
4816 assert!(!path.exists());
4817 assert!(remove_claude_project_hook(dir.path()).unwrap().is_none());
4818 }
4819
4820 fn post(command: &str, response: serde_json::Value) -> String {
4821 serde_json::json!({
4822 "hook_event_name": "PostToolUse",
4823 "tool_name": "Bash",
4824 "tool_input": {"command": command},
4825 "tool_response": response,
4826 })
4827 .to_string()
4828 }
4829
4830 #[test]
4831 fn post_tool_use_picks_successful_bash_output_sqz_never_saw() {
4832 let ok = serde_json::json!({"stdout": "out", "stderr": "", "interrupted": false, "isImage": false});
4833 let t = claude_post_tool_use_target(&post("cargo test | tail -5", ok.clone())).unwrap();
4834 assert_eq!(t.label, "sh: cargo test | tail -5");
4835 assert_eq!(serde_json::Value::Object(t.response), ok);
4836 let t = claude_post_tool_use_target(&post("cd a && cargo test", ok.clone())).unwrap();
4838 assert_eq!(t.label, "cargo test");
4839
4840 let skipped = [
4841 post("set -o pipefail; ls 2>&1 | sqz compress --cmd ls", ok.clone()),
4842 post("sqz stats", ok.clone()),
4843 post("ls", serde_json::json!({"stdout": "x", "isImage": true})),
4844 post("ls", serde_json::json!({"stdout": "x", "interrupted": true})),
4845 post("ls", serde_json::json!({"content": "x"})),
4846 post("ls", serde_json::json!({"stdout": "a".repeat(30_000), "stderr": ""})),
4847 serde_json::json!({"hook_event_name": "PreToolUse", "tool_name": "Bash",
4848 "tool_input": {"command": "ls"}, "tool_response": ok}).to_string(),
4849 serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Read",
4850 "tool_input": {"file_path": "/x"}, "tool_response": ok}).to_string(),
4851 serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Bash",
4852 "tool_input": {"command": "make", "run_in_background": true}, "tool_response": ok}).to_string(),
4853 "not json".to_string(),
4854 ];
4855 for input in skipped {
4856 assert_eq!(claude_post_tool_use_target(&input), None, "{input}");
4857 }
4858 }
4859}
4860