use std::path::{Path, PathBuf};
use crate::error::Result;
#[derive(Debug, Clone)]
pub struct ToolHookConfig {
pub tool_name: String,
pub config_path: PathBuf,
pub config_content: String,
pub scope: HookScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookScope {
Project,
User,
}
pub fn process_hook(input: &str) -> Result<String> {
let parsed: serde_json::Value = serde_json::from_str(input)
.map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
let tool_name = parsed
.get("toolName")
.or_else(|| parsed.get("tool_name"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !matches!(tool_name, "Bash" | "bash" | "shell" | "terminal"
| "run_terminal_command" | "run_shell_command") {
return Ok(input.to_string());
}
let command = parsed
.get("toolCall")
.or_else(|| parsed.get("tool_input"))
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.unwrap_or("");
if command.is_empty() {
return Ok(input.to_string());
}
if command.contains("sqz") || command.contains("SQZ_CMD") {
return Ok(input.to_string());
}
if is_interactive_command(command) {
return Ok(input.to_string());
}
let rewritten = format!(
"SQZ_CMD={} {} 2>&1 | sqz compress",
shell_escape(extract_base_command(command)),
command
);
let output = serde_json::json!({
"decision": "approve",
"reason": "sqz: command output will be compressed for token savings",
"updatedInput": {
"command": rewritten
},
"hookSpecificOutput": {
"tool_input": {
"command": rewritten
}
}
});
serde_json::to_string(&output)
.map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
}
pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
vec![
ToolHookConfig {
tool_name: "Claude Code".to_string(),
config_path: PathBuf::from(".claude/settings.local.json"),
config_content: format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook claude"
}}
]
}}
],
"SessionStart": [
{{
"matcher": "compact",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} resume"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cursor".to_string(),
config_path: PathBuf::from(".cursor/hooks.json"),
config_content: format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook cursor"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Windsurf".to_string(),
config_path: PathBuf::from(".windsurf/hooks.json"),
config_content: format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook windsurf"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cline".to_string(),
config_path: PathBuf::from(".cline/hooks.json"),
config_content: format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook cline"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Gemini CLI".to_string(),
config_path: PathBuf::from(".gemini/settings.json"),
config_content: format!(
r#"{{
"hooks": {{
"BeforeTool": [
{{
"matcher": "run_shell_command",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook gemini"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "OpenCode".to_string(),
config_path: PathBuf::from("opencode.json"),
config_content: format!(
r#"{{
"$schema": "https://opencode.ai/config.json",
"mcp": {{
"sqz": {{
"type": "local",
"command": ["sqz-mcp", "--transport", "stdio"]
}}
}},
"plugin": ["sqz"]
}}"#
),
scope: HookScope::Project,
},
]
}
pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
let configs = generate_hook_configs(sqz_path);
let mut installed = Vec::new();
for config in &configs {
let full_path = project_dir.join(&config.config_path);
if full_path.exists() {
continue;
}
if let Some(parent) = full_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
continue;
}
}
if std::fs::write(&full_path, &config.config_content).is_ok() {
installed.push(config.tool_name.clone());
}
}
if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
if !installed.iter().any(|n| n == "OpenCode") {
installed.push("OpenCode".to_string());
}
}
installed
}
fn extract_base_command(cmd: &str) -> &str {
cmd.split_whitespace()
.next()
.unwrap_or("unknown")
.rsplit('/')
.next()
.unwrap_or("unknown")
}
fn shell_escape(s: &str) -> String {
if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
fn is_interactive_command(cmd: &str) -> bool {
let base = extract_base_command(cmd);
matches!(
base,
"vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
| "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
| "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
) || cmd.contains("--watch")
|| cmd.contains("-w ")
|| cmd.ends_with(" -w")
|| cmd.contains("run dev")
|| cmd.contains("run start")
|| cmd.contains("run serve")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_hook_rewrites_bash_command() {
let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
let cmd = parsed["updatedInput"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
}
#[test]
fn test_process_hook_passes_through_non_bash() {
let input = r#"{"toolName":"Read","toolCall":{"path":"file.txt"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "non-bash tools should pass through unchanged");
}
#[test]
fn test_process_hook_skips_sqz_commands() {
let input = r#"{"toolName":"Bash","toolCall":{"command":"sqz stats"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "sqz commands should not be double-wrapped");
}
#[test]
fn test_process_hook_skips_interactive() {
let input = r#"{"toolName":"Bash","toolCall":{"command":"vim file.txt"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "interactive commands should pass through");
}
#[test]
fn test_process_hook_skips_watch_mode() {
let input = r#"{"toolName":"Bash","toolCall":{"command":"npm run dev --watch"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "watch mode should pass through");
}
#[test]
fn test_process_hook_empty_command() {
let input = r#"{"toolName":"Bash","toolCall":{"command":""}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input);
}
#[test]
fn test_process_hook_gemini_format() {
let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
}
#[test]
fn test_process_hook_invalid_json() {
let result = process_hook("not json");
assert!(result.is_err());
}
#[test]
fn test_extract_base_command() {
assert_eq!(extract_base_command("git status"), "git");
assert_eq!(extract_base_command("/usr/bin/git log"), "git");
assert_eq!(extract_base_command("cargo test --release"), "cargo");
}
#[test]
fn test_is_interactive_command() {
assert!(is_interactive_command("vim file.txt"));
assert!(is_interactive_command("npm run dev --watch"));
assert!(is_interactive_command("python3"));
assert!(!is_interactive_command("git status"));
assert!(!is_interactive_command("cargo test"));
}
#[test]
fn test_generate_hook_configs() {
let configs = generate_hook_configs("sqz");
assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
}
#[test]
fn test_shell_escape_simple() {
assert_eq!(shell_escape("git"), "git");
assert_eq!(shell_escape("cargo-test"), "cargo-test");
}
#[test]
fn test_shell_escape_special_chars() {
assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
}
#[test]
fn test_install_tool_hooks_creates_files() {
let dir = tempfile::tempdir().unwrap();
let installed = install_tool_hooks(dir.path(), "sqz");
assert!(!installed.is_empty(), "should install at least one hook config");
for name in &installed {
let configs = generate_hook_configs("sqz");
let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
let path = dir.path().join(&config.config_path);
assert!(path.exists(), "hook config should exist: {}", path.display());
}
}
#[test]
fn test_install_tool_hooks_does_not_overwrite() {
let dir = tempfile::tempdir().unwrap();
install_tool_hooks(dir.path(), "sqz");
let custom_path = dir.path().join(".claude/settings.local.json");
std::fs::write(&custom_path, "custom content").unwrap();
install_tool_hooks(dir.path(), "sqz");
let content = std::fs::read_to_string(&custom_path).unwrap();
assert_eq!(content, "custom content", "should not overwrite existing config");
}
}