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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPlatform {
ClaudeCode,
Cursor,
GeminiCli,
Windsurf,
Kiro,
Copilot,
}
pub fn process_hook(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::ClaudeCode)
}
pub fn process_hook_cursor(_input: &str) -> Result<String> {
Err(crate::error::SqzError::Other(
"can't rewrite without approving the command; Cursor's own approval applies".into(),
))
}
pub fn process_hook_gemini(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::GeminiCli)
}
pub fn process_hook_windsurf(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::Windsurf)
}
pub fn process_hook_kiro(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::Kiro)
}
pub fn process_hook_copilot(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::Copilot)
}
fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
if platform == HookPlatform::Cursor {
return process_hook_cursor(input);
}
let mut parsed: serde_json::Value = serde_json::from_str(input)
.map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
if let Some(args) = parsed
.get("toolArgs")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.filter(serde_json::Value::is_object)
{
parsed["toolArgs"] = args;
}
let tool_name = parsed
.get("tool_name")
.or_else(|| parsed.get("toolName"))
.and_then(|v| v.as_str())
.unwrap_or("");
let hook_event = parsed
.get("hook_event_name")
.or_else(|| parsed.get("agent_action_name"))
.and_then(|v| v.as_str())
.unwrap_or("");
let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
| "run_terminal_command" | "run_shell_command" | "execute_bash"
| "PowerShell" | "powershell" | "pwsh")
|| matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
if !is_shell {
return Ok(passthrough(platform, input));
}
let command = parsed
.get("tool_input")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.or_else(|| parsed.get("command").and_then(|v| v.as_str()))
.or_else(|| {
parsed
.get("tool_info")
.and_then(|v| v.get("command_line"))
.and_then(|v| v.as_str())
})
.or_else(|| {
parsed
.get("toolCall")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
})
.or_else(|| {
parsed
.get("toolArgs")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
})
.unwrap_or("");
if command.is_empty() {
return Ok(passthrough(platform, input));
}
let base_cmd = extract_base_command(command);
if base_cmd == "sqz"
|| command.starts_with("SQZ_CMD=")
|| command.contains("sqz compress --cmd ")
|| command.contains("sqz.exe compress --cmd ")
{
return Ok(passthrough(platform, input));
}
if runs_in_background(&parsed) {
return Ok(passthrough(platform, input));
}
let is_powershell = matches!(tool_name, "PowerShell" | "powershell" | "pwsh");
let rewritten = if platform == HookPlatform::ClaudeCode {
let settings = claude_settings_files(&parsed);
let shell = crate::shell_scan::Shell::from_env();
match claude_rewrite(&parsed, command, is_powershell, &settings, shell) {
Some(rewritten) => rewritten,
None => return Ok(passthrough(platform, input)),
}
} else if is_powershell {
if is_interactive_command(command) || has_shell_operators(command) {
return Ok(passthrough(platform, input));
}
crate::exit_marker::powershell_rewrite(command, &shell_escape(command))
} else {
match crate::shell_scan::plan(command) {
Ok(plan) => posix_rewrite_plan(&plan),
Err(_) => return Ok(passthrough(platform, input)),
}
};
let mut updated_input = parsed
.get("tool_input")
.or_else(|| parsed.get("toolArgs"))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
updated_input.insert(
"command".to_string(),
serde_json::Value::String(rewritten.clone()),
);
let updated_input = serde_json::Value::Object(updated_input);
let output = match platform {
HookPlatform::ClaudeCode => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated_input
}
}),
HookPlatform::Cursor => return process_hook_cursor(input),
HookPlatform::GeminiCli => serde_json::json!({
"hookSpecificOutput": {
"tool_input": updated_input
}
}),
HookPlatform::Windsurf => {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated_input
}
})
}
HookPlatform::Copilot => serde_json::json!({
"modifiedArgs": updated_input
}),
HookPlatform::Kiro => {
serde_json::json!({
"tool_input": {
"command": rewritten
}
})
}
};
serde_json::to_string(&output)
.map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
}
const SQZ_RULES_SENTINEL: &str = "<!-- sqz-rules (auto-installed by sqz init) -->";
const LEGACY_RULES_HEADING: &str = "# sqz \u{2014} Token-Optimized CLI Output";
const LEGACY_CURSOR_FRONTMATTER: &str = "---\ndescription: sqz \u{2014} Token-Optimized CLI Output\n";
const LEGACY_RULES_LAST_LINES: [&str; 3] = [
"path is still what you want for most output.",
"not on PATH, run commands normally.",
"If sqz is not available, run commands normally.",
];
pub fn sqz_rules_content(tool_name: &str, sqz_path: &str) -> String {
if tool_name == "Cline" {
return crate::codex_integration::agents_md_guidance_block(sqz_path);
}
let body = format!(
"{SQZ_RULES_SENTINEL}\n\n{}",
crate::codex_integration::sqz_guidance(sqz_path)
);
if tool_name == "Cursor" {
format!("---\ndescription: sqz output compression\nalwaysApply: true\n---\n{body}")
} else {
body
}
}
fn is_sqz_rules(content: &str) -> bool {
content.contains(SQZ_RULES_SENTINEL)
|| content.starts_with(LEGACY_RULES_HEADING)
|| content.replace("\r\n", "\n").starts_with(LEGACY_CURSOR_FRONTMATTER)
}
fn legacy_rules_end(content: &str) -> Option<usize> {
if !content.starts_with(LEGACY_RULES_HEADING) {
return None;
}
LEGACY_RULES_LAST_LINES.iter().find_map(|last| {
let mut start = 0;
content.split_inclusive('\n').find_map(|line| {
let text = line.trim_end();
let end = start + text.len();
start += line.len();
text.ends_with(last).then_some(end)
})
})
}
fn without_sqz_rules(content: &str) -> Option<String> {
match legacy_rules_end(content) {
Some(end) => Some(content[end..].trim_start_matches(['\r', '\n']).to_string()),
None => crate::codex_integration::without_guidance(content),
}
}
pub fn has_sqz_rules(path: &Path) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|content| without_sqz_rules(&content))
.is_some()
}
pub fn remove_sqz_rules(path: &Path) -> Result<bool> {
let Ok(content) = std::fs::read_to_string(path) else {
return Ok(false);
};
let Some(rest) = without_sqz_rules(&content) else {
return Ok(false);
};
if rest.trim().is_empty() {
std::fs::remove_file(path).map_err(|e| {
crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
})?;
} else {
std::fs::write(path, rest)
.map_err(|e| crate::error::SqzError::Other(format!("write {}: {e}", path.display())))?;
}
Ok(true)
}
pub fn sqz_rules_path(project_dir: &Path, tool_name: &str) -> Option<PathBuf> {
match tool_name {
"Cursor" => Some(project_dir.join(".cursor").join("rules").join("sqz.mdc")),
"Windsurf" => Some(project_dir.join(".windsurf").join("rules").join("sqz.md")),
"Cline" => {
let base = project_dir.join(".clinerules");
Some(if base.is_dir() { base.join("sqz.md") } else { base })
}
_ => None,
}
}
pub fn sqz_rules_state(path: &Path, tool_name: &str) -> crate::kiro_integration::SteeringState {
use crate::kiro_integration::SteeringState;
let Ok(content) = std::fs::read_to_string(path) else {
return SteeringState::Missing;
};
if tool_name == "Cline" {
return if legacy_rules_end(&content).is_some() {
SteeringState::Stale
} else {
crate::codex_integration::guidance_state(&content)
};
}
if !is_sqz_rules(&content) {
return SteeringState::UserAuthored;
}
match crate::kiro_integration::embedded_sqz_path(&content) {
Some(p) if content.replace("\r\n", "\n") == sqz_rules_content(tool_name, p) => SteeringState::Current,
_ => SteeringState::Stale,
}
}
pub fn refresh_sqz_rules(path: &Path, tool_name: &str, fallback_sqz_path: &str) -> Result<bool> {
if sqz_rules_state(path, tool_name) != crate::kiro_integration::SteeringState::Stale {
return Ok(false);
}
let existing = std::fs::read_to_string(path)
.map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
if tool_name == "Cline" && legacy_rules_end(&existing).is_none() {
return crate::codex_integration::refresh_agents_md_guidance(path, fallback_sqz_path);
}
let sqz_path = crate::kiro_integration::embedded_sqz_path(&existing)
.filter(|p| Path::new(p).is_file())
.unwrap_or(fallback_sqz_path)
.to_string();
install_sqz_rules(path, tool_name, &sqz_path)
}
pub fn sqz_rules_change(
path: &Path,
tool_name: &str,
sqz_path: &str,
) -> crate::codex_integration::GuidanceChange {
use crate::codex_integration::GuidanceChange;
match rules_update(path, tool_name, sqz_path) {
None => GuidanceChange::Unchanged,
Some(_) if path.exists() => GuidanceChange::Replace,
Some(_) => GuidanceChange::Create,
}
}
fn rules_update(path: &Path, tool_name: &str, sqz_path: &str) -> Option<String> {
let content = sqz_rules_content(tool_name, sqz_path);
let Ok(existing) = std::fs::read_to_string(path) else {
return (!path.exists()).then_some(content);
};
let updated = if tool_name != "Cline" {
if !is_sqz_rules(&existing) {
return None;
}
content
} else if let Some(end) = legacy_rules_end(&existing) {
format!("{}{}", content.trim_end_matches('\n'), &existing[end..])
} else {
crate::codex_integration::replaced_guidance(&existing, sqz_path)?
};
(updated != existing).then_some(updated)
}
fn install_sqz_rules(path: &Path, tool_name: &str, sqz_path: &str) -> Result<bool> {
let Some(content) = rules_update(path, tool_name, sqz_path) else {
return Ok(false);
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
})?;
}
std::fs::write(path, content)
.map_err(|e| crate::error::SqzError::Other(format!("write {}: {e}", path.display())))?;
Ok(true)
}
pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
let sqz_path_raw = sqz_path;
let sqz_path_json = json_escape_string_value(sqz_path);
let sqz_path = &sqz_path_json;
vec![
ToolHookConfig {
tool_name: "Claude Code".to_string(),
config_path: PathBuf::from(".claude/settings.local.json"),
config_content: claude_settings_content(sqz_path_raw),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cursor".to_string(),
config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
config_content: sqz_rules_content("Cursor", sqz_path_raw),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Windsurf".to_string(),
config_path: PathBuf::from(".windsurf/rules/sqz.md"),
config_content: sqz_rules_content("Windsurf", sqz_path_raw),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cline".to_string(),
config_path: PathBuf::from(".clinerules"),
config_content: sqz_rules_content("Cline", sqz_path_raw),
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: "Kiro".to_string(),
config_path: PathBuf::from(".kiro/steering/sqz.md"),
config_content: crate::kiro_integration::kiro_steering_content(sqz_path_raw),
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,
},
ToolHookConfig {
tool_name: "Codex".to_string(),
config_path: PathBuf::from("AGENTS.md"),
config_content: crate::codex_integration::agents_md_guidance_block(
sqz_path_raw,
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Zed".to_string(),
config_path: PathBuf::from("AGENTS.md"),
config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Copilot CLI".to_string(),
config_path: PathBuf::from(".copilot/hooks/sqz.json"),
config_content: copilot_hook_content(sqz_path_raw),
scope: HookScope::User,
},
]
}
pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallScope {
Project,
Global,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolFilter {
All,
Only(Vec<String>),
Skip(Vec<String>),
}
impl Default for ToolFilter {
fn default() -> Self {
ToolFilter::All
}
}
impl ToolFilter {
pub fn includes(&self, tool_name: &str) -> bool {
let canon = canonicalize_tool_name(tool_name);
match self {
ToolFilter::All => true,
ToolFilter::Only(allow) => allow.iter().any(|n| {
n == &canon
}),
ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
}
}
}
pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
"Claude Code",
"Cursor",
"Windsurf",
"Cline",
"Gemini CLI",
"Kiro",
"OpenCode",
"Codex",
"Zed",
"Copilot CLI",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntegrationTier {
Transparent,
Advisory,
}
pub fn integration_tier(tool_name: &str) -> IntegrationTier {
match canonicalize_tool_name(tool_name).as_str() {
"claudecode" | "copilot" | "gemini" | "opencode" => IntegrationTier::Transparent,
_ => IntegrationTier::Advisory,
}
}
pub fn copilot_hooks_path() -> PathBuf {
copilot_home().join("hooks").join("sqz.json")
}
fn copilot_home() -> PathBuf {
if let Ok(home) = std::env::var("COPILOT_HOME") {
if !home.trim().is_empty() {
return PathBuf::from(home);
}
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
home.join(".copilot")
}
pub fn ci_hooks_path(project_dir: &Path) -> PathBuf {
project_dir.join(".github").join("hooks").join("sqz.json")
}
pub fn ci_hook_content() -> String {
r#"{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"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",
"timeoutSec": 120
}
],
"preToolUse": [
{
"type": "command",
"matcher": "bash|powershell",
"bash": "SQZ_BIN=$(command -v sqz || echo $HOME/.local/bin/sqz); [ -x \"$SQZ_BIN\" ] && \"$SQZ_BIN\" hook copilot 2>/dev/null || echo '{}'",
"timeoutSec": 10
}
]
}
}
"#
.to_string()
}
pub fn install_ci_hook(project_dir: &Path) -> Result<bool> {
let path = ci_hooks_path(project_dir);
let content = ci_hook_content();
if path.exists() {
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing == content || !existing.contains("hook copilot") {
return Ok(false);
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
})?;
}
std::fs::write(&path, content).map_err(|e| {
crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
})?;
Ok(true)
}
pub fn remove_ci_hook(project_dir: &Path) -> Result<bool> {
let path = ci_hooks_path(project_dir);
if !path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
if !content.contains("hook copilot") {
return Ok(false);
}
std::fs::remove_file(&path).map_err(|e| {
crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
})?;
Ok(true)
}
pub fn copilot_hook_content(sqz_path: &str) -> String {
let escaped = json_escape_string_value(sqz_path);
format!(
r#"{{
"version": 1,
"hooks": {{
"preToolUse": [
{{
"type": "command",
"matcher": "bash|powershell",
"bash": "{escaped} hook copilot",
"powershell": "{escaped} hook copilot",
"timeoutSec": 10
}}
]
}}
}}
"#
)
}
pub fn install_copilot_hook(sqz_path: &str) -> Result<Option<bool>> {
install_copilot_hook_at(&copilot_home(), sqz_path)
}
fn install_copilot_hook_at(home: &Path, sqz_path: &str) -> Result<Option<bool>> {
if !home.exists() {
return Ok(None);
}
let path = home.join("hooks").join("sqz.json");
let content = copilot_hook_content(sqz_path);
if path.exists() {
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing == content {
return Ok(Some(false));
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::error::SqzError::Other(format!("create {}: {e}", parent.display()))
})?;
}
std::fs::write(&path, content).map_err(|e| {
crate::error::SqzError::Other(format!("write {}: {e}", path.display()))
})?;
Ok(Some(true))
}
pub fn remove_copilot_hook() -> Result<bool> {
let path = copilot_hooks_path();
if !path.exists() {
return Ok(false);
}
std::fs::remove_file(&path).map_err(|e| {
crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
})?;
Ok(true)
}
pub fn canonicalize_tool_name(name: &str) -> String {
let lowered: String = name
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(|c| c.to_lowercase())
.filter(|c| *c != '-' && *c != '_')
.collect();
match lowered.as_str() {
"claude" | "claudecode" => "claudecode".to_string(),
"cursor" => "cursor".to_string(),
"windsurf" => "windsurf".to_string(),
"cline" | "roo" | "roocode" => "cline".to_string(),
"gemini" | "geminicli" => "gemini".to_string(),
"kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
"opencode" => "opencode".to_string(),
"codex" => "codex".to_string(),
"zed" | "zededitor" | "zedagent" => "zed".to_string(),
"copilot" | "copilotcli" | "githubcopilot" | "ghcopilot" => "copilot".to_string(),
other => other.to_string(),
}
}
pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
let mut out = Vec::new();
let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
.iter()
.map(|n| canonicalize_tool_name(n))
.collect();
for part in raw.split(',') {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
let canon = canonicalize_tool_name(trimmed);
if !known.contains(&canon) {
let valid: Vec<String> = SUPPORTED_TOOL_NAMES
.iter()
.map(|n| canonicalize_tool_name(n))
.collect();
return Err(crate::error::SqzError::Other(format!(
"unknown agent name '{}'. Valid options: {}",
trimmed,
valid.join(", ")
)));
}
if !out.contains(&canon) {
out.push(canon);
}
}
Ok(out)
}
pub fn install_tool_hooks_scoped(
project_dir: &Path,
sqz_path: &str,
scope: InstallScope,
) -> Vec<String> {
install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
}
pub fn install_tool_hooks_scoped_filtered(
project_dir: &Path,
sqz_path: &str,
scope: InstallScope,
filter: &ToolFilter,
) -> Vec<String> {
install_tool_hooks_at(project_dir, sqz_path, scope, filter, None)
}
fn install_tool_hooks_at(
project_dir: &Path,
sqz_path: &str,
scope: InstallScope,
filter: &ToolFilter,
home_override: Option<&Path>,
) -> Vec<String> {
let configs = generate_hook_configs(sqz_path);
let mut installed = Vec::new();
let kiro_root =
home_override.map_or_else(crate::kiro_integration::kiro_user_root, |h| h.join(".kiro"));
for config in &configs {
if !filter.includes(&config.tool_name) {
continue;
}
if config.tool_name == "OpenCode" {
match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
Ok((updated, _comments_lost)) => {
if updated && !installed.iter().any(|n| n == "OpenCode") {
installed.push("OpenCode".to_string());
}
}
Err(_e) => {
}
}
continue;
}
if config.tool_name == "Codex" {
let agents_changed = crate::codex_integration::install_agents_md_guidance(
project_dir, sqz_path,
)
.unwrap_or(false);
let mcp_changed = crate::codex_integration::install_codex_mcp_config_at(
home_override.map(|h| h.join(".codex")).as_deref(),
)
.unwrap_or(false);
if (agents_changed || mcp_changed)
&& !installed.iter().any(|n| n == "Codex")
{
installed.push("Codex".to_string());
}
continue;
}
if config.tool_name == "Windsurf" {
let legacy = project_dir.join(".windsurfrules");
let target = project_dir.join(&config.config_path);
let mut changed = install_sqz_rules(&target, "Windsurf", sqz_path).unwrap_or(false);
if target.exists() && remove_sqz_rules(&legacy).unwrap_or(false) {
changed = true;
}
if changed && !installed.iter().any(|n| n == "Windsurf") {
installed.push("Windsurf".to_string());
}
continue;
}
if config.tool_name == "Cursor" {
let target = project_dir.join(&config.config_path);
if install_sqz_rules(&target, "Cursor", sqz_path).unwrap_or(false)
&& !installed.iter().any(|n| n == "Cursor")
{
installed.push("Cursor".to_string());
}
continue;
}
if config.tool_name == "Cline" {
let Some(target) = sqz_rules_path(project_dir, "Cline") else {
continue;
};
if install_sqz_rules(&target, "Cline", sqz_path).unwrap_or(false)
&& !installed.iter().any(|n| n == "Cline")
{
installed.push("Cline".to_string());
}
continue;
}
if config.tool_name == "Copilot CLI" {
let copilot_root = home_override.map_or_else(copilot_home, |h| h.join(".copilot"));
if let Ok(Some(changed)) = install_copilot_hook_at(&copilot_root, sqz_path) {
if changed && !installed.iter().any(|n| n == "Copilot CLI") {
installed.push("Copilot CLI".to_string());
}
}
continue;
}
if config.tool_name == "Kiro" {
let _ = crate::kiro_integration::remove_kiro_legacy_hook(project_dir);
let user_steering = kiro_root.join("steering").join("sqz.md");
let (steering_changed, mcp_changed) = if scope == InstallScope::Global {
let steering = crate::kiro_integration::install_kiro_steering_at(
&user_steering,
&crate::kiro_integration::kiro_user_steering_content(sqz_path),
)
.unwrap_or(false);
let mcp = matches!(
crate::kiro_integration::install_kiro_mcp_config_at(
&kiro_root.join("settings").join("mcp.json")
),
Ok(crate::kiro_integration::KiroMcpInstall::Created)
| Ok(crate::kiro_integration::KiroMcpInstall::Merged)
);
(steering, mcp)
} else {
let user_installed = matches!(
crate::kiro_integration::kiro_steering_state(&user_steering, true),
crate::kiro_integration::SteeringState::Current
| crate::kiro_integration::SteeringState::Stale
);
let steering = !user_installed
&& crate::kiro_integration::install_kiro_steering(project_dir, sqz_path)
.unwrap_or(false);
let mcp = matches!(
crate::kiro_integration::install_kiro_mcp_config(project_dir),
Ok(crate::kiro_integration::KiroMcpInstall::Created)
| Ok(crate::kiro_integration::KiroMcpInstall::Merged)
);
(steering, mcp)
};
if (steering_changed || mcp_changed) && !installed.iter().any(|n| n == "Kiro") {
installed.push("Kiro".to_string());
}
continue;
}
if config.tool_name == "Zed" {
let guidance_changed =
crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
.unwrap_or(false);
let zed_settings = home_override.map_or_else(
crate::zed_integration::zed_settings_path,
|h| h.join(".config").join("zed").join("settings.json"),
);
let mcp_changed = matches!(
crate::zed_integration::install_zed_mcp_config_at(&zed_settings),
Ok(crate::zed_integration::ZedMcpInstall::Created)
| Ok(crate::zed_integration::ZedMcpInstall::Merged)
);
if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
installed.push("Zed".to_string());
}
continue;
}
if config.tool_name == "Claude Code" && scope == InstallScope::Global {
let hook_installed = match install_claude_global_at(sqz_path, home_override) {
Ok(v) => v,
Err(_) => false,
};
let md_changed = crate::claude_md_integration::install_claude_md_guidance(
project_dir, sqz_path,
)
.unwrap_or(false);
let mcp_changed =
crate::claude_md_integration::install_claude_mcp_config_at(home_override)
.unwrap_or(false);
if (hook_installed || md_changed || mcp_changed)
&& !installed.iter().any(|n| n == "Claude Code")
{
installed.push("Claude Code".to_string());
}
continue;
}
if config.tool_name == "Claude Code"
&& home_override
.map(|h| h.join(".claude").join("settings.json"))
.or_else(claude_user_settings_path)
.is_some_and(|p| claude_settings_have_sqz_hooks(&p))
{
let md_changed = crate::claude_md_integration::install_claude_md_guidance(
project_dir, sqz_path,
)
.unwrap_or(false);
let mcp_changed =
crate::claude_md_integration::install_claude_mcp_config_at(home_override)
.unwrap_or(false);
if (md_changed || mcp_changed) && !installed.iter().any(|n| n == "Claude Code") {
installed.push("Claude Code".to_string());
}
continue;
}
let full_path = project_dir.join(&config.config_path);
if full_path.exists() {
if config.tool_name == "Claude Code" {
let hook_changed =
merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
let md_changed =
crate::claude_md_integration::install_claude_md_guidance(
project_dir, sqz_path,
)
.unwrap_or(false);
let mcp_changed =
crate::claude_md_integration::install_claude_mcp_config_at(home_override)
.unwrap_or(false);
if (hook_changed || md_changed || mcp_changed)
&& !installed.iter().any(|n| n == "Claude Code")
{
installed.push("Claude Code".to_string());
}
}
if config.tool_name == "Gemini CLI"
&& merge_sqz_gemini_hook(&full_path, sqz_path).unwrap_or(false)
{
installed.push(config.tool_name.clone());
}
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 config.tool_name == "Claude Code" {
let _ = crate::claude_md_integration::install_claude_md_guidance(
project_dir, sqz_path,
);
let _ = crate::claude_md_integration::install_claude_mcp_config_at(home_override);
}
}
}
if filter.includes("OpenCode") {
let plugin = home_override.map_or_else(
crate::opencode_plugin::opencode_plugin_path,
|h| h.join(".config").join("opencode").join("plugins").join("sqz.ts"),
);
if let Ok(true) = crate::opencode_plugin::install_opencode_plugin_at(&plugin, sqz_path) {
if !installed.iter().any(|n| n == "OpenCode") {
installed.push("OpenCode".to_string());
}
}
}
installed
}
pub fn claude_user_settings_path() -> Option<PathBuf> {
dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
}
fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
let path = match home_override {
Some(h) => h.join(".claude").join("settings.json"),
None => claude_user_settings_path().ok_or_else(|| {
crate::error::SqzError::Other(
"Could not resolve home directory for ~/.claude/settings.json".to_string(),
)
})?,
};
merge_sqz_hooks_into_settings(&path, sqz_path)
}
pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
if !path.exists() {
return Ok(true);
}
let content = std::fs::read_to_string(path).map_err(|e| {
crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
})?;
let mut root: serde_json::Value = if content.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&content).map_err(|e| {
crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
})?
};
let Some(root_obj) = root.as_object_mut() else {
return Err(crate::error::SqzError::Other(format!(
"{} is not a JSON object",
path.display()
)));
};
let before = serde_json::to_string(&root_obj).unwrap_or_default();
let hooks = root_obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let Some(hooks_obj) = hooks.as_object_mut() else {
return Ok(true);
};
for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
}
upsert_claude_allow_rules(root_obj);
let after = serde_json::to_string(&root_obj).unwrap_or_default();
Ok(before != after)
}
fn claude_settings_content(sqz_path: &str) -> String {
let mut hooks = serde_json::Map::new();
for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
upsert_sqz_hook_entry(&mut hooks, event, entry, sentinel);
}
let mut root = serde_json::Map::new();
root.insert("hooks".to_string(), serde_json::Value::Object(hooks));
upsert_claude_allow_rules(&mut root);
serde_json::to_string_pretty(&serde_json::Value::Object(root))
.expect("a map of strings always serializes")
}
fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 4] {
[
(
"PreToolUse",
serde_json::json!({
"matcher": "Bash|PowerShell",
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
}),
"hook claude",
),
(
"PostToolUse",
serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude-post") }]
}),
"hook claude-post",
),
(
"PreCompact",
serde_json::json!({
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
}),
"hook precompact",
),
(
"SessionStart",
serde_json::json!({
"matcher": "compact",
"hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
}),
"resume",
),
]
}
fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
let mut content = String::new();
let mut root: serde_json::Value = if path.exists() {
content = std::fs::read_to_string(&path).map_err(|e| {
crate::error::SqzError::Other(format!(
"read {}: {e}",
path.display()
))
})?;
if content.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&content).map_err(|e| {
crate::error::SqzError::Other(format!(
"parse {}: {e} — please fix or move the file before re-running sqz init",
path.display()
))
})?
}
} else {
serde_json::Value::Object(serde_json::Map::new())
};
let root_obj = root.as_object_mut().ok_or_else(|| {
crate::error::SqzError::Other(format!(
"{} is not a JSON object — refusing to overwrite",
path.display()
))
})?;
let before = serde_json::to_string(&root_obj).unwrap_or_default();
let hooks = root_obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
crate::error::SqzError::Other(format!(
"{}: `hooks` is not an object — refusing to overwrite",
path.display()
))
})?;
for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
}
upsert_claude_allow_rules(root_obj);
let after = serde_json::to_string(&root_obj).unwrap_or_default();
if before == after && path.exists() {
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::error::SqzError::Other(format!(
"create {}: {e}",
parent.display()
))
})?;
}
write_json_config(path, &serde_json::Value::Object(root_obj.clone()), &content)?;
Ok(true)
}
pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
remove_claude_global_hook_at(None)
}
fn remove_claude_global_hook_at(
home_override: Option<&Path>,
) -> Result<Option<(PathBuf, bool)>> {
let path = match home_override {
Some(h) => h.join(".claude").join("settings.json"),
None => match claude_user_settings_path() {
Some(p) => p,
None => return Ok(None),
},
};
remove_sqz_from_claude_settings(path, true)
}
pub fn remove_claude_project_hook(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
remove_sqz_from_claude_settings(project_dir.join(".claude").join("settings.local.json"), true)
}
pub fn remove_claude_project_hooks_only(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
remove_sqz_from_claude_settings(project_dir.join(".claude").join("settings.local.json"), false)
}
pub fn claude_settings_have_sqz_hooks(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
sqz_hook_entries("").iter().any(|(event, _, args)| {
root.pointer(&format!("/hooks/{event}"))
.and_then(|v| v.as_array())
.is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, args)))
})
}
pub fn claude_settings_have_sqz(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(mut root) => root.as_object_mut().is_some_and(|o| strip_sqz_claude_entries(o, true)),
Err(_) => text.contains("sqz"),
}
}
pub fn claude_user_hooks_installed() -> bool {
claude_user_settings_path().is_some_and(|p| claude_settings_have_sqz_hooks(&p))
}
fn remove_sqz_from_claude_settings(path: PathBuf, rules: bool) -> Result<Option<(PathBuf, bool)>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path).map_err(|e| {
crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
})?;
if content.trim().is_empty() {
return Ok(Some((path, false)));
}
let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
crate::error::SqzError::Other(format!(
"parse {}: {e} — refusing to rewrite an unparseable file",
path.display()
))
})?;
let Some(root_obj) = root.as_object_mut() else {
return Ok(Some((path, false)));
};
let changed = strip_sqz_claude_entries(root_obj, rules);
if !changed {
return Ok(Some((path, false)));
}
if root_obj.is_empty() {
std::fs::remove_file(&path).map_err(|e| {
crate::error::SqzError::Other(format!(
"remove {}: {e}",
path.display()
))
})?;
return Ok(Some((path, true)));
}
write_json_config(&path, &root, &content)?;
Ok(Some((path, true)))
}
fn strip_sqz_claude_entries(
root: &mut serde_json::Map<String, serde_json::Value>,
rules: bool,
) -> bool {
let mut changed = rules && remove_claude_allow_rules(root);
let Some(hooks) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
return changed;
};
let mut emptied = false;
for (event, _, sentinel) in &sqz_hook_entries("") {
let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) else {
continue;
};
if !remove_sqz_hook_commands(arr, sentinel) {
continue;
}
changed = true;
if arr.is_empty() {
hooks.remove(*event);
emptied = true;
}
}
if emptied && hooks.is_empty() {
root.remove("hooks");
}
changed
}
fn sqz_command_binary<'a>(command: &'a str, args: &str) -> Option<&'a str> {
let bin = command.strip_suffix(args)?.strip_suffix(' ')?;
let path = unquoted(bin);
let absolute = path.starts_with(['/', '\\']) || path.as_bytes().get(1) == Some(&b':');
if path.contains(['"', '\'', '$', '`', '\n'])
|| (path == bin
&& (bin.contains([';', '&', '|', '<', '>', '(', ')'])
|| (bin.contains(char::is_whitespace) && !absolute)
|| bin
.split_whitespace()
.skip(1)
.any(|w| w.starts_with(['/', '-', '~']) || w.contains('='))))
{
return None;
}
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let stem = match name.len().checked_sub(4) {
Some(i) if name.is_char_boundary(i) && name[i..].eq_ignore_ascii_case(".exe") => &name[..i],
_ => name,
};
(stem == "sqz").then_some(bin)
}
fn unquoted(bin: &str) -> &str {
['"', '\'']
.into_iter()
.find_map(|q| bin.strip_prefix(q)?.strip_suffix(q))
.unwrap_or(bin)
}
pub fn resolve_sqz_binary(bin: &str) -> Option<PathBuf> {
let path = unquoted(bin);
if !path.contains(['/', '\\']) {
let dirs = std::env::var_os("PATH")?;
return std::env::split_paths(&dirs).find_map(|dir| {
let exe = dir.join(format!("{path}.exe"));
[dir.join(path)]
.into_iter()
.chain(cfg!(windows).then_some(exe))
.find(|p| p.is_file())
});
}
let file = match path.strip_prefix("~/").filter(|_| path == bin) {
Some(rest) => PathBuf::from(std::env::var_os("HOME")?).join(rest),
None => PathBuf::from(path),
};
file.is_file().then_some(file)
}
const 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";
fn legacy_hook_files(
project_dir: &Path,
home_override: Option<&Path>,
) -> Vec<(PathBuf, Option<&'static str>)> {
let mut files = vec![
(project_dir.join(".cursor").join("hooks.json"), Some("hook cursor")),
(project_dir.join(".windsurf").join("hooks.json"), Some("hook windsurf")),
(project_dir.join(".cline").join("hooks.json"), Some("hook cline")),
(project_dir.join(".clinerules").join("hooks").join("PreToolUse"), None),
];
let home = home_override.map(Path::to_path_buf).or_else(|| {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
});
if let Some(home) = home {
files.push((home.join(".cursor").join("hooks.json"), Some("hook cursor")));
}
files
}
fn legacy_hooks_cut(text: &str, args: Option<&str>) -> Option<Option<serde_json::Value>> {
let Some(args) = args else {
return (text.replace("\r\n", "\n") == LEGACY_CLINE_SCRIPT).then_some(None);
};
let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
let obj = root.as_object_mut()?;
let hooks = obj.get_mut("hooks")?.as_object_mut()?;
let mut changed = false;
let mut emptied = Vec::new();
for (event, entries) in hooks.iter_mut() {
let Some(arr) = entries.as_array_mut() else { continue };
let before = arr.len();
arr.retain(|e| !hook_runs_sqz(e, args));
if remove_sqz_hook_commands(arr, args) || arr.len() != before {
changed = true;
if arr.is_empty() {
emptied.push(event.clone());
}
}
}
if !changed {
return None;
}
for event in &emptied {
hooks.remove(event);
}
if hooks.is_empty() {
obj.remove("hooks");
}
let only_version = obj.len() == 1 && obj.get("version") == Some(&serde_json::json!(1));
Some((!obj.is_empty() && !only_version).then_some(root))
}
pub fn legacy_sqz_hook_files(project_dir: &Path) -> Vec<PathBuf> {
legacy_sqz_hook_files_at(project_dir, None)
}
fn legacy_sqz_hook_files_at(project_dir: &Path, home_override: Option<&Path>) -> Vec<PathBuf> {
legacy_hook_files(project_dir, home_override)
.into_iter()
.filter(|(path, args)| {
std::fs::read_to_string(path).is_ok_and(|text| legacy_hooks_cut(&text, *args).is_some())
})
.map(|(path, _)| path)
.collect()
}
pub fn remove_legacy_sqz_hooks(project_dir: &Path) -> Result<Vec<(PathBuf, bool)>> {
remove_legacy_sqz_hooks_at(project_dir, None)
}
fn remove_legacy_sqz_hooks_at(
project_dir: &Path,
home_override: Option<&Path>,
) -> Result<Vec<(PathBuf, bool)>> {
let mut changed = Vec::new();
for (path, args) in legacy_hook_files(project_dir, home_override) {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
match legacy_hooks_cut(&text, args) {
None => continue,
Some(None) => std::fs::remove_file(&path).map_err(|e| {
crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
})?,
Some(Some(rest)) => write_json_config(&path, &rest, &text)?,
}
let deleted = !path.exists();
changed.push((path, deleted));
}
Ok(changed)
}
const SQZ_HOOK_ARGS: [&str; 9] = [
"hook claude",
"hook claude-post",
"hook precompact",
"resume",
"hook gemini",
"hook copilot",
"hook cursor",
"hook windsurf",
"hook cline",
];
pub fn sqz_hook_binaries(path: &Path) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
let mut found = Vec::new();
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(root) => collect_sqz_hook_binaries(&root, &mut found),
Err(_) => found.extend(text.lines().find_map(|line| {
let literal = line.trim().strip_prefix("const SQZ_PATH = ")?.strip_suffix(';')?;
serde_json::from_str::<String>(literal).ok()
})),
}
found
}
fn collect_sqz_hook_binaries(value: &serde_json::Value, found: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, v) in map {
let command = v.as_str().filter(|_| matches!(key.as_str(), "command" | "bash" | "powershell"));
match command.and_then(|c| SQZ_HOOK_ARGS.iter().find_map(|args| sqz_command_binary(c, args))) {
Some(bin) if !found.iter().any(|b| b == bin) => found.push(bin.to_string()),
Some(_) => {}
None => collect_sqz_hook_binaries(v, found),
}
}
}
serde_json::Value::Array(items) => {
for v in items {
collect_sqz_hook_binaries(v, found);
}
}
_ => {}
}
}
fn hook_entry_runs_sqz(entry: &serde_json::Value, args: &str) -> bool {
entry
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|hooks| hooks.iter().any(|h| hook_runs_sqz(h, args)))
}
fn hook_runs_sqz(hook: &serde_json::Value, args: &str) -> bool {
hook.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| sqz_command_binary(c, args).is_some())
}
fn remove_sqz_hook_commands(arr: &mut Vec<serde_json::Value>, args: &str) -> bool {
let mut removed = false;
arr.retain_mut(|entry| {
let Some(hooks) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
return true;
};
let before = hooks.len();
hooks.retain(|h| !hook_runs_sqz(h, args));
if hooks.len() == before {
return true;
}
removed = true;
!hooks.is_empty()
});
removed
}
fn upsert_sqz_hook_entry(
hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
event_name: &str,
new_entry: serde_json::Value,
sentinel: &str,
) {
let arr = hooks_obj
.entry(event_name.to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let Some(arr) = arr.as_array_mut() else {
hooks_obj.insert(
event_name.to_string(),
serde_json::Value::Array(vec![new_entry]),
);
return;
};
remove_sqz_hook_commands(arr, sentinel);
arr.push(new_entry);
}
fn upsert_claude_allow_rules(root: &mut serde_json::Map<String, serde_json::Value>) {
let perms = root
.entry("permissions".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let Some(perms) = perms.as_object_mut() else { return };
let allow = perms
.entry("allow".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let Some(allow) = allow.as_array_mut() else { return };
for rule in CLAUDE_ALLOW_RULES {
if !allow.iter().any(|r| r.as_str() == Some(rule)) {
allow.push(serde_json::Value::String(rule.to_string()));
}
}
}
fn remove_claude_allow_rules(root: &mut serde_json::Map<String, serde_json::Value>) -> bool {
let Some(perms) = root.get_mut("permissions").and_then(|p| p.as_object_mut()) else {
return false;
};
let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) else {
return false;
};
let before = allow.len();
allow.retain(|r| !r.as_str().is_some_and(|s| CLAUDE_ALLOW_RULES.contains(&s)));
if allow.len() == before {
return false;
}
if allow.is_empty() {
perms.remove("allow");
}
if perms.is_empty() {
root.remove("permissions");
}
true
}
fn claude_settings_sqz_path(root: &serde_json::Value) -> Option<String> {
root.pointer("/hooks/PreToolUse")?.as_array()?.iter().find_map(|entry| {
entry.get("hooks")?.as_array()?.iter().find_map(|h| {
let command = h.get("command")?.as_str()?;
sqz_command_binary(command, "hook claude").map(str::to_string)
})
})
}
pub fn claude_settings_stale(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
if claude_settings_sqz_path(&root).is_none() {
return false;
}
let post_hook = root
.pointer("/hooks/PostToolUse")
.and_then(|v| v.as_array())
.is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, "hook claude-post")));
!post_hook || !claude_rules_installed(&[path.to_path_buf()])
}
pub fn refresh_claude_settings(path: &Path, fallback_sqz: &str) -> Result<bool> {
let text = std::fs::read_to_string(path)
.map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
let root: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| crate::error::SqzError::Other(format!("parse {}: {e}", path.display())))?;
let sqz_path = claude_settings_sqz_path(&root)
.filter(|p| resolve_sqz_binary(p).is_some())
.unwrap_or_else(|| fallback_sqz.to_string());
if resolve_sqz_binary(&sqz_path).is_none() {
return Err(crate::error::SqzError::Other(format!(
"{sqz_path} does not exist, so the hooks were left as they are"
)));
}
merge_sqz_hooks_into_settings(path, &sqz_path)
}
fn gemini_hook_entry(sqz_path: &str) -> serde_json::Value {
serde_json::json!({
"matcher": "run_shell_command",
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook gemini") }]
})
}
fn gemini_settings_merged(
path: &Path,
sqz_path: &str,
) -> Result<Option<(String, serde_json::Value)>> {
let text = std::fs::read_to_string(path)
.map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
let mut root: serde_json::Value = if text.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&text).map_err(|e| {
crate::error::SqzError::Other(format!("{} is not plain JSON: {e}", path.display()))
})?
};
let root_obj = root.as_object_mut().ok_or_else(|| {
crate::error::SqzError::Other(format!("{} is not a JSON object", path.display()))
})?;
let before = serde_json::to_string(&root_obj).unwrap_or_default();
let hooks = root_obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
crate::error::SqzError::Other(format!("{}: `hooks` is not an object", path.display()))
})?;
upsert_sqz_hook_entry(hooks_obj, "BeforeTool", gemini_hook_entry(sqz_path), "hook gemini");
let after = serde_json::to_string(&root_obj).unwrap_or_default();
Ok((before != after).then_some((text, root)))
}
pub fn gemini_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
if !path.exists() {
return Ok(true);
}
Ok(gemini_settings_merged(path, sqz_path)?.is_some())
}
pub fn merge_sqz_gemini_hook(path: &Path, sqz_path: &str) -> Result<bool> {
let Some((text, root)) = gemini_settings_merged(path, sqz_path)? else {
return Ok(false);
};
write_json_config(path, &root, &text)?;
Ok(true)
}
pub fn gemini_settings_has_sqz(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(root) => root
.pointer("/hooks/BeforeTool")
.and_then(|v| v.as_array())
.is_some_and(|a| a.iter().any(|e| hook_entry_runs_sqz(e, "hook gemini"))),
Err(_) => text.contains(" hook gemini"),
}
}
pub fn remove_sqz_from_gemini_settings(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
let path = project_dir.join(".gemini").join("settings.json");
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)
.map_err(|e| crate::error::SqzError::Other(format!("read {}: {e}", path.display())))?;
if text.trim().is_empty() {
return Ok(Some((path, false)));
}
let mut root: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
crate::error::SqzError::Other(format!(
"{} is not plain JSON ({e}); remove the sqz hook by hand",
path.display()
))
})?;
let Some(root_obj) = root.as_object_mut() else {
return Ok(Some((path, false)));
};
let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
return Ok(Some((path, false)));
};
let Some(arr) = hooks.get_mut("BeforeTool").and_then(|v| v.as_array_mut()) else {
return Ok(Some((path, false)));
};
if !remove_sqz_hook_commands(arr, "hook gemini") {
return Ok(Some((path, false)));
}
if arr.is_empty() {
hooks.remove("BeforeTool");
if hooks.is_empty() {
root_obj.remove("hooks");
}
}
if root_obj.is_empty() {
std::fs::remove_file(&path).map_err(|e| {
crate::error::SqzError::Other(format!("remove {}: {e}", path.display()))
})?;
return Ok(Some((path, true)));
}
write_json_config(&path, &root, &text)?;
Ok(Some((path, true)))
}
fn write_json_config(path: &Path, value: &serde_json::Value, original: &str) -> Result<()> {
let parent = path.parent().ok_or_else(|| {
crate::error::SqzError::Other(format!("path {} has no parent directory", path.display()))
})?;
let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
crate::error::SqzError::Other(format!("create temp file in {}: {e}", parent.display()))
})?;
let mut serialized = serde_json::to_string_pretty(value)
.map_err(|e| crate::error::SqzError::Other(format!("serialize {}: {e}", path.display())))?;
if original.ends_with('\n') {
serialized.push('\n');
}
std::fs::write(tmp.path(), serialized).map_err(|e| {
crate::error::SqzError::Other(format!("write to temp file {}: {e}", tmp.path().display()))
})?;
if let Ok(meta) = std::fs::metadata(path) {
let _ = std::fs::set_permissions(tmp.path(), meta.permissions());
}
tmp.persist(path).map_err(|e| {
crate::error::SqzError::Other(format!(
"rename temp file into place at {}: {e}",
path.display()
))
})?;
Ok(())
}
fn extract_base_command(cmd: &str) -> &str {
cmd.split_whitespace()
.next()
.unwrap_or("unknown")
.rsplit('/')
.next()
.unwrap_or("unknown")
}
pub(crate) fn json_escape_string_value(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\x08' => out.push_str("\\b"),
'\x0c' => out.push_str("\\f"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
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 passthrough(platform: HookPlatform, input: &str) -> String {
match platform {
HookPlatform::Copilot => "{}".to_string(),
_ => input.to_string(),
}
}
fn runs_in_background(parsed: &serde_json::Value) -> bool {
let Some(args) = parsed
.get("tool_input")
.or_else(|| parsed.get("toolArgs"))
.or_else(|| parsed.get("args"))
else {
return false;
};
["run_in_background", "is_background", "background", "async", "detach"]
.iter()
.any(|k| args.get(k).and_then(|v| v.as_bool()) == Some(true))
|| args.get("mode").and_then(|v| v.as_str()) == Some("async")
}
pub const CLAUDE_ALLOW_RULES: [&str; 2] = ["Bash(set -o pipefail)", "Bash(sqz compress --cmd *)"];
fn claude_rewrite(
parsed: &serde_json::Value,
command: &str,
is_powershell: bool,
settings: &[PathBuf],
shell: crate::shell_scan::Shell,
) -> Option<String> {
let bypass = parsed.get("permission_mode").and_then(|v| v.as_str()) == Some("bypassPermissions");
if is_powershell {
if !bypass || is_interactive_command(command) || has_shell_operators(command) {
return None;
}
return Some(crate::exit_marker::powershell_rewrite(command, &shell_escape(command)));
}
if crate::shell_scan::flat(command).is_some() {
let cmds = crate::shell_scan::flat_for(command, shell)?;
return (bypass || claude_rules_installed(settings))
.then(|| crate::exit_marker::posix_flat_rewrite(command, &cmds));
}
if bypass {
return crate::shell_scan::plan(command).ok().map(|plan| posix_rewrite_plan(&plan));
}
None
}
#[derive(Debug, Clone, PartialEq)]
pub struct PostToolUseTarget {
pub response: serde_json::Map<String, serde_json::Value>,
pub label: String,
}
pub fn claude_post_tool_use_target(input: &str) -> Option<PostToolUseTarget> {
let parsed: serde_json::Value = serde_json::from_str(input).ok()?;
if parsed.get("hook_event_name").and_then(|v| v.as_str()) != Some("PostToolUse")
|| parsed.get("tool_name").and_then(|v| v.as_str()) != Some("Bash")
|| runs_in_background(&parsed)
{
return None;
}
let command = parsed.pointer("/tool_input/command")?.as_str()?;
if command.contains("sqz compress")
|| crate::shell_scan::plan(command) == Err(crate::shell_scan::Skip::AlreadySqz)
{
return None;
}
let response = parsed.get("tool_response")?.as_object()?.clone();
let flag = |k: &str| response.get(k).and_then(|v| v.as_bool()) == Some(true);
if flag("isImage") || flag("interrupted") {
return None;
}
let limit = std::env::var("BASH_MAX_OUTPUT_LENGTH")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(30_000);
let long = |k: &str| {
response
.get(k)
.and_then(|v| v.as_str())
.is_some_and(|s| s.chars().count() >= limit)
};
if response.get("stdout").and_then(|v| v.as_str()).is_none() || long("stdout") || long("stderr") {
return None;
}
Some(PostToolUseTarget { response, label: post_tool_use_label(command) })
}
pub fn claude_hooks_compress(command: &str, failed: bool) -> bool {
use crate::shell_scan::{flat_for, plan, Shell, Skip};
if command.contains("sqz compress") {
return false;
}
match plan(command) {
Err(Skip::AlreadySqz) => false,
_ if flat_for(command, Shell::from_env()).is_some() => true,
Err(Skip::LongRunning | Skip::Background) => false,
_ => !failed,
}
}
fn post_tool_use_label(command: &str) -> String {
if let Some(cmds) = crate::shell_scan::flat(command) {
let mut labels = cmds.into_iter().filter_map(|c| c.label);
if let (Some(only), None) = (labels.next(), labels.next()) {
return only;
}
}
list_label(command)
}
fn claude_settings_files(parsed: &serde_json::Value) -> Vec<PathBuf> {
let mut files = Vec::new();
let user = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.or_else(|| dirs_next::home_dir().map(|h| h.join(".claude")));
if let Some(dir) = user {
files.push(dir.join("settings.json"));
}
let project = std::env::var_os("CLAUDE_PROJECT_DIR")
.map(PathBuf::from)
.or_else(|| parsed.get("cwd").and_then(|v| v.as_str()).map(PathBuf::from));
if let Some(dir) = project {
files.push(dir.join(".claude").join("settings.json"));
files.push(dir.join(".claude").join("settings.local.json"));
}
files
}
fn claude_rules_installed(files: &[PathBuf]) -> bool {
let (mut pipefail, mut compress) = (false, false);
for file in files {
let Ok(text) = std::fs::read_to_string(file) else { continue };
let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
let Some(rules) = root.pointer("/permissions/allow").and_then(|a| a.as_array()) else {
continue;
};
for rule in rules.iter().filter_map(|r| r.as_str()) {
pipefail |= bash_rule_allows(rule, "set -o pipefail");
compress |= bash_rule_allows(rule, "sqz compress --cmd ls");
}
}
pipefail && compress
}
fn bash_rule_allows(rule: &str, command: &str) -> bool {
let rule = rule.trim();
if rule == "Bash" {
return true;
}
let Some(spec) = rule.strip_prefix("Bash(").and_then(|r| r.strip_suffix(')')) else {
return false;
};
if let Some(prefix) = spec.strip_suffix(":*").or_else(|| spec.strip_suffix(" *")) {
if prefix.contains('*') {
return false;
}
return command == prefix
|| command.strip_prefix(prefix).is_some_and(|rest| rest.starts_with(' '));
}
match spec.strip_suffix('*') {
Some(prefix) if !prefix.contains('*') => command.starts_with(prefix),
Some(_) => false,
None => spec == command,
}
}
fn posix_rewrite_plan(plan: &crate::shell_scan::Plan<'_>) -> String {
let grouped = if plan.simple {
let cmd = plan.body.trim();
let label = cmd.strip_suffix(" 2>&1").map_or(cmd, str::trim_end);
crate::exit_marker::posix_rewrite(cmd, &shell_escape(label))
} else {
crate::exit_marker::posix_rewrite_list(plan.body, &shell_escape(&list_label(plan.body)))
};
if plan.prefix.is_empty() {
grouped
} else {
format!("{} {grouped}", plan.prefix)
}
}
fn list_label(body: &str) -> String {
let first = body.trim().lines().next().unwrap_or("");
let label = format!("sh: {}", first.split_whitespace().collect::<Vec<_>>().join(" "));
if label.chars().count() > 100 {
format!("{}…", label.chars().take(99).collect::<String>())
} else {
label
}
}
pub(crate) fn has_shell_operators(cmd: &str) -> bool {
cmd.contains("&&")
|| cmd.contains("||")
|| cmd.contains(';')
|| cmd.contains('>')
|| cmd.contains('<')
|| cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
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_copilot_hook_rewrites_bash_with_modified_args() {
let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"bash","toolArgs":{"command":"git log -20","timeoutSec":30}}"#;
let result = process_hook_copilot(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed.get("permissionDecision").is_none(), "{parsed}");
let args = &parsed["modifiedArgs"];
assert!(args["command"].as_str().unwrap().contains("sqz compress"));
assert_eq!(args["timeoutSec"], 30, "sibling toolArgs fields carried over");
}
#[test]
fn test_copilot_hook_reads_tool_args_given_as_a_string() {
let input = serde_json::json!({
"toolName": "bash",
"toolArgs": r#"{"command":"git log -20","timeoutSec":30}"#,
})
.to_string();
let out = process_hook_copilot(&input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let args = &parsed["modifiedArgs"];
assert!(args["command"].as_str().unwrap().contains("sqz compress"), "{parsed}");
assert_eq!(args["timeoutSec"], 30, "{parsed}");
let background = serde_json::json!({
"toolName": "bash",
"toolArgs": r#"{"command":"cargo build","mode":"async"}"#,
})
.to_string();
assert_eq!(process_hook_copilot(&background).unwrap(), "{}");
}
#[test]
fn test_copilot_hook_passes_non_shell_through() {
let input = r#"{"sessionId":"s","timestamp":1,"cwd":"/x","toolName":"view","toolArgs":{"path":"a.rs"}}"#;
let result = process_hook_copilot(input).unwrap();
assert_eq!(result, "{}", "non-shell tools fall through with empty object");
}
#[test]
fn test_copilot_hook_skips_risky_shell_operators() {
for command in [
"source .venv/bin/activate && pytest",
"npm run build &",
"for f in a b; do last=$f; done",
] {
let input = serde_json::json!({"toolName": "bash", "toolArgs": {"command": command}}).to_string();
assert_eq!(process_hook_copilot(&input).unwrap(), "{}", "{command}");
}
}
#[test]
fn test_claude_updated_input_preserves_sibling_fields() {
let input = r#"{"tool_name":"Bash","permission_mode":"bypassPermissions","tool_input":{"command":"cargo test","run_in_background":false,"timeout":120000,"description":"Run tests"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let updated = &parsed["hookSpecificOutput"]["updatedInput"];
assert!(updated["command"].as_str().unwrap().contains("sqz compress"));
assert_eq!(updated["run_in_background"], false);
assert_eq!(updated["timeout"], 120000);
assert_eq!(updated["description"], "Run tests");
}
fn rewritten(input: &str) -> Option<String> {
let out: serde_json::Value = serde_json::from_str(&process_hook(input).unwrap()).unwrap();
out.pointer("/hookSpecificOutput/updatedInput/command")
.and_then(|c| c.as_str())
.map(str::to_string)
}
fn bash(command: &str) -> String {
serde_json::json!({
"tool_name": "Bash",
"permission_mode": "bypassPermissions",
"tool_input": {"command": command}
})
.to_string()
}
fn claude_default(command: &str, settings: &[PathBuf]) -> Option<String> {
claude_default_in(command, settings, crate::shell_scan::Shell::Zsh)
}
fn claude_default_in(
command: &str,
settings: &[PathBuf],
shell: crate::shell_scan::Shell,
) -> Option<String> {
let parsed = serde_json::json!({
"tool_name": "Bash",
"permission_mode": "default",
"tool_input": {"command": command}
});
claude_rewrite(&parsed, command, false, settings, shell)
}
fn settings_with_rules(dir: &Path, rules: &[&str]) -> PathBuf {
let path = dir.join("settings.json");
std::fs::write(&path, serde_json::json!({"permissions": {"allow": rules}}).to_string())
.unwrap();
path
}
#[test]
fn claude_default_mode_rewrites_only_with_the_allow_rules() {
let dir = tempfile::tempdir().unwrap();
let none = dir.path().join("missing.json");
assert_eq!(claude_default("git status", &[none.clone()]), None);
let partial = settings_with_rules(dir.path(), &["Bash(sqz compress --cmd *)"]);
assert_eq!(claude_default("git status", &[partial]), None, "pipefail rule missing");
let full = settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES);
assert_eq!(
claude_default("git status", &[none, full.clone()]).as_deref(),
Some("set -o pipefail; git status 2>&1 | sqz compress --cmd 'git status'")
);
assert_eq!(claude_default("git log | head -5", &[full.clone()]), None);
assert_eq!(claude_default("for f in *; do wc -l $f; done", &[full]), None);
}
#[test]
fn claude_default_mode_keeps_builtins_in_the_shell() {
let dir = tempfile::tempdir().unwrap();
let rules = settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES);
assert_eq!(
claude_default("cd sub && export CI=1 && cargo test", &[rules]).as_deref(),
Some("set -o pipefail; cd sub && export CI=1 && cargo test 2>&1 | sqz compress --cmd 'cargo test'")
);
}
#[test]
fn claude_flat_form_stops_where_the_shell_would() {
use crate::shell_scan::Shell;
let dir = tempfile::tempdir().unwrap();
let rules = [settings_with_rules(dir.path(), &CLAUDE_ALLOW_RULES)];
for shell in [Shell::Bash, Shell::Zsh] {
assert_eq!(claude_default_in("ls ${X:?unset}; echo next", &rules, shell), None);
assert_eq!(claude_default_in("ls $((1/0)) || echo next", &rules, shell), None);
assert_eq!(
claude_default_in("ls ${X:?unset} && echo next", &rules, shell).as_deref(),
Some("set -o pipefail; ls ${X:?unset} 2>&1 | sqz compress --cmd ls && echo next")
);
}
assert_eq!(claude_default_in("ls *.rs; echo next", &rules, Shell::Zsh), None);
assert!(claude_default_in("ls *.rs; echo next", &rules, Shell::Bash).is_some());
let parsed = serde_json::json!({"permission_mode": "bypassPermissions"});
for shell in [Shell::Bash, Shell::Zsh] {
for command in ["ls ${X:?unset}; echo next", "ls $((1/0))\necho next"] {
assert_eq!(claude_rewrite(&parsed, command, false, &[], shell), None, "{command:?}");
}
}
}
#[test]
fn claude_powershell_is_rewritten_only_in_bypass_mode() {
let zsh = crate::shell_scan::Shell::Zsh;
let parsed = serde_json::json!({"tool_name": "PowerShell", "permission_mode": "default"});
assert_eq!(claude_rewrite(&parsed, "Get-ChildItem", true, &[], zsh), None);
let parsed = serde_json::json!({"tool_name": "PowerShell", "permission_mode": "bypassPermissions"});
assert!(claude_rewrite(&parsed, "Get-ChildItem", true, &[], zsh).is_some());
}
#[test]
fn bash_rule_matching() {
for (rule, command, expected) in [
("Bash", "set -o pipefail", true),
("Bash(*)", "set -o pipefail", true),
("Bash(set -o pipefail)", "set -o pipefail", true),
("Bash(set -o pipefail)", "set -o pipefail -x", false),
("Bash(set *)", "set -o pipefail", true),
("Bash(set:*)", "set -o pipefail", true),
("Bash(sqz compress --cmd *)", "sqz compress --cmd ls", true),
("Bash(sqz *)", "sqz compress --cmd ls", true),
("Bash(sqz*)", "sqz compress --cmd ls", true),
("Bash(sq *)", "sqz compress --cmd ls", false),
("Bash(* --cmd ls)", "sqz compress --cmd ls", false),
("Read(./x)", "sqz compress --cmd ls", false),
] {
assert_eq!(bash_rule_allows(rule, command), expected, "{rule} vs {command}");
}
}
#[test]
fn crlf_copies_of_current_files_are_current() {
use crate::kiro_integration::SteeringState;
let dir = tempfile::tempdir().unwrap();
let crlf = |s: String| s.replace('\n', "\r\n");
let write = |name: &str, text: String| {
let path = dir.path().join(name);
std::fs::write(&path, text).unwrap();
path
};
let cursor = write("sqz.mdc", crlf(sqz_rules_content("Cursor", "/opt/sqz")));
assert_eq!(sqz_rules_state(&cursor, "Cursor"), SteeringState::Current);
let legacy = write("old.mdc", crlf(format!("{LEGACY_CURSOR_FRONTMATTER}alwaysApply: true\n---\nold\n")));
assert_eq!(sqz_rules_state(&legacy, "Cursor"), SteeringState::Stale);
let kiro = write("kiro.md", crlf(crate::kiro_integration::kiro_steering_content("/opt/sqz")));
assert_eq!(crate::kiro_integration::kiro_steering_state(&kiro, false), SteeringState::Current);
let block = crate::codex_integration::agents_md_guidance_block("/opt/sqz");
let agents = write("AGENTS.md", crlf(format!("# Mine\n\n{block}")));
assert_eq!(crate::codex_integration::agents_md_guidance_state(&agents), SteeringState::Current);
let claude_md = write("CLAUDE.md", crlf(crate::claude_md_integration::claude_md_guidance_block("/opt/sqz")));
assert_eq!(crate::claude_md_integration::claude_md_guidance_state(dir.path()), SteeringState::Current);
assert!(!crate::claude_md_integration::claude_md_block_outdated(dir.path(), "/opt/sqz"));
let before = std::fs::read(&claude_md).unwrap();
assert!(!crate::claude_md_integration::install_claude_md_guidance(dir.path(), "/opt/sqz").unwrap());
assert_eq!(std::fs::read(&claude_md).unwrap(), before);
}
#[test]
fn stale_claude_settings_are_detected_and_refreshed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let bin = dir.path().join("sqz");
std::fs::write(&bin, "").unwrap();
let bin = bin.to_string_lossy().to_string();
let old = serde_json::json!({"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": format!("{bin} hook claude")}]}],
"PostToolUse": [{"matcher": "Edit", "hooks": [{"type": "command", "command": "prettier --write"}]}],
}});
std::fs::write(&path, old.to_string()).unwrap();
assert!(claude_settings_stale(&path));
assert!(refresh_claude_settings(&path, "/fallback/sqz").unwrap());
assert!(!claude_settings_stale(&path));
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let post = v["hooks"]["PostToolUse"].as_array().unwrap();
assert_eq!(post.len(), 2, "the user's own PostToolUse hook stays: {post:?}");
assert_eq!(
v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
format!("{bin} hook claude"),
"keeps the binary the hook already used"
);
std::fs::write(&path, r#"{"permissions":{}}"#).unwrap();
assert!(!claude_settings_stale(&path), "no sqz hook, nothing to refresh");
}
#[test]
fn background_calls_pass_through() {
for input in [
r#"{"tool_name":"Bash","tool_input":{"command":"cargo build","run_in_background":true}}"#,
r#"{"tool_name":"run_shell_command","tool_input":{"command":"cargo build","is_background":true}}"#,
r#"{"toolName":"bash","toolArgs":{"command":"cargo build","mode":"async"}}"#,
] {
assert_eq!(rewritten(input), None, "{input}");
}
}
#[test]
fn lists_and_pipelines_are_grouped_with_a_generic_label() {
let cmd = rewritten(&bash("cargo test 2>&1 | tail -30")).unwrap();
assert_eq!(
cmd,
"{ ( 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'"
);
let cmd = rewritten(&bash("ls | head # list")).unwrap();
assert!(cmd.starts_with("{ ( ls | head # list\n); printf"), "{cmd}");
let cmd = rewritten(&bash("ls # list")).unwrap();
assert_eq!(cmd, "set -o pipefail; ls 2>&1 | sqz compress --cmd ls # list");
}
#[test]
fn leading_cd_runs_outside_the_group() {
let cmd = rewritten(&bash("cd sqz_engine && cargo test 2>&1 | tail -5")).unwrap();
assert_eq!(
cmd,
"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'"
);
let cmd = rewritten(&bash("cd sqz_engine && cargo test 2>&1")).unwrap();
assert_eq!(
cmd,
"set -o pipefail; cd sqz_engine && cargo test 2>&1 | sqz compress --cmd 'cargo test'"
);
}
#[test]
fn state_changes_pipe_only_the_commands_around_them() {
assert_eq!(
rewritten(&bash("export X=1 && make")).as_deref(),
Some("set -o pipefail; export X=1 && make 2>&1 | sqz compress --cmd make")
);
assert_eq!(
rewritten(&bash("make && cd build")).as_deref(),
Some("set -o pipefail; make 2>&1 | sqz compress --cmd make && cd build")
);
}
#[test]
fn followers_background_jobs_and_sqz_pass_through() {
for command in ["tail -f app.log", "make &", "git log | sqz compress", "cd x && npm run dev"] {
assert_eq!(rewritten(&bash(command)), None, "{command}");
}
}
#[test]
fn test_process_hook_rewrites_bash_command() {
let input = bash("git status");
let result = process_hook(&input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let hook_output = &parsed["hookSpecificOutput"];
assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
assert!(hook_output.get("permissionDecision").is_none(), "{hook_output}");
assert!(hook_output.get("permissionDecisionReason").is_none(), "{hook_output}");
let cmd = hook_output["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("--cmd 'git status'"),
"should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
);
assert!(
!cmd.contains("SQZ_CMD="),
"new rewrites must not emit the legacy sh-style env prefix: {cmd}"
);
assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
}
#[test]
fn test_process_hook_passes_through_non_bash() {
let input = r#"{"tool_name":"Read","tool_input":{"file_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_rewrites_powershell_command() {
let input = r#"{"tool_name":"PowerShell","permission_mode":"bypassPermissions","tool_input":{"command":"Get-Content big.log"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.expect("PowerShell tool calls should be rewritten");
assert!(
cmd.contains("Get-Content big.log"),
"should preserve original PowerShell command: {cmd}"
);
assert!(
cmd.contains("sqz compress"),
"should pipe through sqz compress: {cmd}"
);
assert!(
cmd.contains("--cmd 'Get-Content big.log'"),
"should pass full command via --cmd: {cmd}"
);
}
#[test]
fn test_process_hook_rewrites_pwsh_command() {
let input = r#"{"tool_name":"pwsh","permission_mode":"bypassPermissions","tool_input":{"command":"ls"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
"pwsh tool calls should be rewritten"
);
}
#[test]
fn bash_rewrite_carries_exit_status_trailer() {
let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"cargo test"}}"#;
let result = process_hook_gemini(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"]
.as_str()
.unwrap();
assert_eq!(
cmd,
"{ ( cargo test\n); printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } 2>&1 | sqz compress --cmd 'cargo test'",
"POSIX rewrite must send the command's exit status through the pipe"
);
let cmd = rewritten(&bash("cargo test")).unwrap();
assert_eq!(cmd, "set -o pipefail; cargo test 2>&1 | sqz compress --cmd 'cargo test'");
}
#[test]
fn powershell_rewrite_uses_lastexitcode_not_printf() {
let input = r#"{"tool_name":"PowerShell","permission_mode":"bypassPermissions","tool_input":{"command":"cargo test"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.unwrap();
assert!(cmd.contains("$LASTEXITCODE"), "PowerShell form uses $LASTEXITCODE: {cmd}");
assert!(cmd.contains("__SQZ_EXIT_"), "PowerShell form emits the trailer: {cmd}");
assert!(!cmd.contains("printf"), "printf is not valid PowerShell: {cmd}");
}
#[test]
fn exit_trailer_rewrite_is_not_rewrapped() {
let first = process_hook(&bash("git log")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&first).unwrap();
let rewritten = parsed["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.unwrap();
let second_input = serde_json::json!({
"tool_name": "Bash",
"tool_input": { "command": rewritten }
})
.to_string();
let second = process_hook(&second_input).unwrap();
assert_eq!(
second, second_input,
"already-rewritten commands must pass through unchanged"
);
}
#[test]
fn test_process_hook_skips_sqz_commands() {
let input = r#"{"tool_name":"Bash","tool_input":{"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#"{"tool_name":"Bash","tool_input":{"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#"{"tool_name":"Bash","tool_input":{"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#"{"tool_name":"Bash","tool_input":{"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_gemini(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed.get("decision").is_none(), "the policy engine decides: {parsed}");
let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
"Gemini format should not have updatedInput");
assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
"Gemini format should not have permissionDecision");
}
#[test]
fn gemini_tool_input_keeps_every_argument() {
let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log","dir_path":"sub","description":"log"}}"#;
let parsed: serde_json::Value =
serde_json::from_str(&process_hook_gemini(input).unwrap()).unwrap();
let args = &parsed["hookSpecificOutput"]["tool_input"];
assert_eq!(args["dir_path"], "sub");
assert_eq!(args["description"], "log");
assert!(args["command"].as_str().unwrap().contains("sqz compress"));
}
#[test]
fn test_process_hook_legacy_format() {
let input = r#"{"toolName":"Bash","permission_mode":"bypassPermissions","toolCall":{"command":"git status"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
}
#[test]
fn test_process_hook_cursor_format() {
for input in [
r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#,
r#"{"hook_event_name":"beforeShellExecution","command":"git status","cwd":"/p"}"#,
r#"{"hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"git status"}}"#,
r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#,
r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#,
"not json",
] {
assert!(process_hook_cursor(input).is_err(), "{input}");
assert!(process_hook_for_platform(input, HookPlatform::Cursor).is_err(), "{input}");
}
}
#[test]
fn test_process_hook_windsurf_format() {
let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
let result = process_hook_windsurf(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
assert!(cmd.contains("cargo test"));
assert!(
cmd.contains("--cmd 'cargo test'"),
"full command must be passed via --cmd flag: {cmd}"
);
assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {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 issue_10_rewrite_is_shell_neutral() {
let input = bash("dotnet build NewNeonCheckers3.sln");
let result = process_hook(&input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.unwrap();
assert!(
cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
"issue #10: rewrite must pass label via --cmd, got: {cmd}"
);
assert!(
!cmd.contains("SQZ_CMD="),
"issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
(broken in PowerShell and cmd.exe), got: {cmd}"
);
assert!(
cmd.contains("dotnet build NewNeonCheckers3.sln"),
"original command must be preserved verbatim: {cmd}"
);
assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
}
#[test]
fn issue_10_already_wrapped_command_passes_through() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(
result, input,
"already-wrapped command must pass through unchanged; \
otherwise each pass accumulates another `| sqz compress` tail"
);
let _ = parsed; }
#[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"));
let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
assert_eq!(windsurf.config_path, PathBuf::from(".windsurf/rules/sqz.md"),
"Windsurf should use the .windsurf/rules/ directory (legacy .windsurfrules is deprecated)");
let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
"Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
"Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
.cursor/hooks.json (non-functional) or .cursorrules (legacy)");
assert!(cursor.config_content.starts_with("---"),
"Cursor rule should start with YAML frontmatter");
assert!(cursor.config_content.contains("alwaysApply: true"),
"Cursor rule should use alwaysApply: true so the guidance loads \
for every agent interaction");
assert!(cursor.config_content.contains("sqz"),
"Cursor rule body should mention sqz");
}
#[test]
fn test_claude_config_includes_precompact_hook() {
let configs = generate_hook_configs("sqz");
let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
.expect("Claude Code config must be valid JSON");
let precompact = parsed["hooks"]["PreCompact"]
.as_array()
.expect("PreCompact hook array must be present");
assert!(
!precompact.is_empty(),
"PreCompact must have at least one registered hook"
);
let cmd = precompact[0]["hooks"][0]["command"]
.as_str()
.expect("command field must be a string");
assert!(
cmd.ends_with(" hook precompact"),
"PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
);
}
#[test]
fn test_json_escape_string_value() {
assert_eq!(json_escape_string_value("sqz"), "sqz");
assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
r"C:\\Users\\Alice\\sqz.exe");
assert_eq!(json_escape_string_value(r#"path with "quotes""#),
r#"path with \"quotes\""#);
assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
}
#[test]
fn test_windows_path_produces_valid_json_for_claude() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
.expect("Claude config should be generated");
let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
.expect("Claude hook config must be valid JSON on Windows paths");
let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.expect("command field must be a string");
assert!(cmd.contains(windows_path),
"command '{cmd}' must contain the original Windows path '{windows_path}'");
}
#[test]
fn test_windows_path_in_cursor_rules_file() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
assert!(cursor.config_content.contains(windows_path),
"Cursor rule must contain the raw (unescaped) path so users can \
copy-paste the shown commands — got:\n{}", cursor.config_content);
assert!(!cursor.config_content.contains(r"C:\\Users"),
"Cursor rule must NOT double-escape backslashes in markdown — \
got:\n{}", cursor.config_content);
}
#[test]
fn test_windows_path_produces_valid_json_for_gemini() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
.expect("Gemini hook config must be valid JSON on Windows paths");
let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
assert!(cmd.contains(windows_path));
}
#[test]
fn test_rules_files_use_raw_path_for_readability() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
for tool in &["Windsurf", "Cline", "Cursor"] {
let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
assert!(cfg.config_content.contains(windows_path),
"{tool} rules file must contain the raw (unescaped) path — got:\n{}",
cfg.config_content);
assert!(!cfg.config_content.contains(r"C:\\Users"),
"{tool} rules file must NOT double-escape backslashes — got:\n{}",
cfg.config_content);
}
}
#[test]
fn test_unix_path_still_works() {
let unix_path = "/usr/local/bin/sqz";
let configs = generate_hook_configs(unix_path);
let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
.expect("Unix path should produce valid JSON");
let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
}
#[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'");
}
fn install_in(project: &Path, home: &Path, sqz: &str) -> Vec<String> {
install_tool_hooks_at(project, sqz, InstallScope::Project, &ToolFilter::All, Some(home))
}
#[test]
fn user_level_files_go_to_the_given_home() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".copilot")).unwrap();
install_in(dir.path(), home.path(), "/opt/sqz");
for rel in [
".claude.json",
".codex/config.toml",
".config/zed/settings.json",
".config/opencode/plugins/sqz.ts",
".copilot/hooks/sqz.json",
] {
assert!(home.path().join(rel).exists(), "{rel}");
}
let copilot = std::fs::read_to_string(home.path().join(".copilot/hooks/sqz.json")).unwrap();
assert!(copilot.contains("/opt/sqz hook copilot"));
let global = tempfile::tempdir().unwrap();
let filter = ToolFilter::All;
install_tool_hooks_at(dir.path(), "/opt/sqz", InstallScope::Global, &filter, Some(global.path()));
for rel in [".claude/settings.json", ".kiro/steering/sqz.md", ".kiro/settings/mcp.json"] {
assert!(global.path().join(rel).exists(), "{rel}");
}
}
#[test]
fn test_install_tool_hooks_creates_files() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let installed = install_in(dir.path(), home.path(), "sqz");
assert!(!installed.is_empty(), "should install at least one hook config");
for name in &installed {
if name == "Copilot CLI" {
continue;
}
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();
let home = tempfile::tempdir().unwrap();
install_in(dir.path(), home.path(), "sqz");
let custom_path = dir.path().join(".claude/settings.local.json");
std::fs::write(&custom_path, "custom content").unwrap();
install_in(dir.path(), home.path(), "sqz");
let content = std::fs::read_to_string(&custom_path).unwrap();
assert_eq!(content, "custom content", "should not overwrite existing config");
}
#[test]
fn test_reinit_upgrades_stale_project_scope_matcher() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join(".claude").join("settings.local.json");
std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
let stale = serde_json::json!({
"permissions": { "allow": ["Bash(ls:*)"] },
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
},
{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "my-linter --check" }]
}
]
}
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
let home = tempfile::tempdir().unwrap();
install_in(dir.path(), home.path(), "/new/path/sqz");
let content = std::fs::read_to_string(&settings_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
let sqz_entries: Vec<_> = pre
.iter()
.filter(|e| {
e["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.contains("hook claude"))
})
.collect();
assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
assert_eq!(
sqz_entries[0]["matcher"].as_str().unwrap(),
"Bash|PowerShell",
"stale Bash-only matcher must be upgraded"
);
assert!(
sqz_entries[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("/new/path/sqz"),
"binary path must be refreshed"
);
assert!(
pre.iter().any(|e| e["matcher"] == "Edit"),
"user's non-sqz PreToolUse entry must be preserved"
);
assert_eq!(
parsed["permissions"]["allow"][0].as_str().unwrap(),
"Bash(ls:*)",
"user's permissions must be preserved"
);
assert!(parsed["hooks"]["PreCompact"].is_array());
assert!(parsed["hooks"]["SessionStart"].is_array());
}
#[test]
fn test_windsurf_installs_to_rules_directory() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
install_in(dir.path(), home.path(), "sqz");
assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
assert!(!dir.path().join(".windsurfrules").exists());
}
fn legacy_rules(sqz: &str) -> String {
format!(
"# 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"
)
}
#[test]
fn test_windsurf_migrates_legacy_sqz_rules_file() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".windsurfrules"), legacy_rules("sqz")).unwrap();
install_in(dir.path(), home.path(), "sqz");
assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
}
#[test]
fn test_windsurf_migration_keeps_user_rules_after_the_sqz_text() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let legacy = dir.path().join(".windsurfrules");
std::fs::write(&legacy, format!("{}\n## Team rules\n\nUse tabs.\n", legacy_rules("sqz"))).unwrap();
install_in(dir.path(), home.path(), "sqz");
assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
assert_eq!(std::fs::read_to_string(&legacy).unwrap(), "## Team rules\n\nUse tabs.\n");
}
#[test]
fn test_windsurf_leaves_user_legacy_rules_alone() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
install_in(dir.path(), home.path(), "sqz");
assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
assert_eq!(
std::fs::read_to_string(dir.path().join(".windsurfrules")).unwrap(),
"my own rules\n"
);
}
#[test]
fn test_cline_installs_into_rules_directory_when_present() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
install_in(dir.path(), home.path(), "sqz");
assert!(dir.path().join(".clinerules/sqz.md").exists());
assert!(dir.path().join(".clinerules/mine.md").exists());
}
#[test]
fn test_cline_single_file_untouched_when_user_authored() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
install_in(dir.path(), home.path(), "sqz");
assert_eq!(
std::fs::read_to_string(dir.path().join(".clinerules")).unwrap(),
"user rules\n"
);
}
#[test]
fn test_reinit_is_idempotent_at_project_scope() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
install_in(dir.path(), home.path(), "/usr/bin/sqz");
let settings_path = dir.path().join(".claude").join("settings.local.json");
let first = std::fs::read_to_string(&settings_path).unwrap();
install_in(dir.path(), home.path(), "/usr/bin/sqz");
let second = std::fs::read_to_string(&settings_path).unwrap();
let a: serde_json::Value = serde_json::from_str(&first).unwrap();
let b: serde_json::Value = serde_json::from_str(&second).unwrap();
assert_eq!(a, b, "second init must not change project settings");
assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
}
#[test]
fn init_refreshes_old_sqz_rules_in_place() {
let dir = tempfile::tempdir().unwrap();
let filter = ToolFilter::Only(vec!["cursor".into(), "windsurf".into(), "cline".into()]);
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";
std::fs::create_dir_all(dir.path().join(".cursor/rules")).unwrap();
std::fs::write(dir.path().join(".cursor/rules/sqz.mdc"), old_cursor).unwrap();
std::fs::write(dir.path().join(".clinerules"), legacy_rules("/old/sqz")).unwrap();
let installed =
install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter);
assert_eq!(installed, vec!["Cursor", "Windsurf", "Cline"]);
for (tool, rel) in [
("Cursor", ".cursor/rules/sqz.mdc"),
("Windsurf", ".windsurf/rules/sqz.md"),
("Cline", ".clinerules"),
] {
let content = std::fs::read_to_string(dir.path().join(rel)).unwrap();
assert_eq!(content, sqz_rules_content(tool, "/new/sqz"), "{tool}");
assert!(!content.contains('%') && !content.contains('\u{2014}'), "{tool}");
}
assert!(
install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
.is_empty()
);
}
#[test]
fn init_leaves_user_written_rules_alone() {
let dir = tempfile::tempdir().unwrap();
let cursor = dir.path().join(".cursor/rules/sqz.mdc");
std::fs::create_dir_all(cursor.parent().unwrap()).unwrap();
std::fs::write(&cursor, "my own rule\n").unwrap();
let filter = ToolFilter::Only(vec!["cursor".into()]);
assert!(
install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
.is_empty()
);
assert_eq!(std::fs::read_to_string(&cursor).unwrap(), "my own rule\n");
}
#[test]
fn rules_refresh_keeps_the_binary_the_old_text_points_at() {
use crate::kiro_integration::SteeringState;
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("sqz");
std::fs::write(&bin, "").unwrap();
let bin = bin.to_string_lossy().to_string();
let path = dir.path().join("sqz.md");
std::fs::write(
&path,
format!("# sqz \u{2014} Token-Optimized CLI Output\n\ngit status git status 2>&1 | {bin} compress\n"),
)
.unwrap();
assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::Stale);
assert!(refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), sqz_rules_content("Windsurf", &bin));
assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::Current);
assert!(!refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
std::fs::write(&path, "my own rules\n").unwrap();
assert_eq!(sqz_rules_state(&path, "Windsurf"), SteeringState::UserAuthored);
assert!(!refresh_sqz_rules(&path, "Windsurf", "/fallback/sqz").unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "my own rules\n");
}
#[test]
fn cline_rules_keep_the_user_rules_after_them() {
use crate::kiro_integration::SteeringState;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".clinerules");
let filter = ToolFilter::Only(vec!["cline".into()]);
let install = |sqz: &str| {
install_tool_hooks_scoped_filtered(dir.path(), sqz, InstallScope::Project, &filter)
};
let mine = "\n## Team rules\n\nUse tabs.\n";
std::fs::write(&path, format!("{}{mine}", legacy_rules("/old/sqz"))).unwrap();
assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::Stale);
assert_eq!(install("/new/sqz"), vec!["Cline"]);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!("{}{mine}", sqz_rules_content("Cline", "/new/sqz"))
);
assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::Current);
assert!(install("/new/sqz").is_empty());
assert_eq!(install("/other/sqz"), vec!["Cline"]);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!("{}{mine}", sqz_rules_content("Cline", "/other/sqz"))
);
assert!(remove_sqz_rules(&path).unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "## Team rules\n\nUse tabs.\n");
assert!(!has_sqz_rules(&path));
}
#[test]
fn cline_refresh_keeps_the_old_binary_and_the_user_rules() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("sqz");
std::fs::write(&bin, "").unwrap();
let bin = bin.to_string_lossy().to_string();
let path = dir.path().join(".clinerules");
let v2_ending = "and commands with shell operators. If sqz is\nnot on PATH, run commands normally.\n";
let old = legacy_rules(&bin).replace(
"Use these when you genuinely need raw data. The default compressed\npath is still what you want for most output.\n",
v2_ending,
);
std::fs::write(&path, format!("{old}\nUse tabs.\n")).unwrap();
assert!(refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
let block = sqz_rules_content("Cline", &bin);
assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{block}\nUse tabs.\n"));
assert!(!refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
std::fs::write(&path, format!("{}\nUse tabs.\n", block.replace("sqz_recall", "recall"))).unwrap();
assert!(refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{block}\nUse tabs.\n"));
}
#[test]
fn cline_rules_without_a_known_end_are_left_alone() {
use crate::kiro_integration::SteeringState;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".clinerules");
let edited = "# sqz \u{2014} Token-Optimized CLI Output\n\nPipe output through sqz.\n\n## Team rules\n";
std::fs::write(&path, edited).unwrap();
let filter = ToolFilter::Only(vec!["cline".into()]);
assert_eq!(sqz_rules_state(&path, "Cline"), SteeringState::UserAuthored);
assert!(
install_tool_hooks_scoped_filtered(dir.path(), "/new/sqz", InstallScope::Project, &filter)
.is_empty()
);
assert!(!refresh_sqz_rules(&path, "Cline", "/fallback/sqz").unwrap());
assert!(!remove_sqz_rules(&path).unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), edited);
}
}
#[cfg(test)]
mod global_install_tests {
use super::*;
#[test]
fn global_install_creates_fresh_settings_json() {
let tmp = tempfile::tempdir().unwrap();
let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
assert!(changed, "first install should report a change");
let path = tmp.path().join(".claude").join("settings.json");
assert!(path.exists(), "user settings.json should be created");
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
let pre = &parsed["hooks"]["PreToolUse"];
assert!(pre.is_array(), "PreToolUse should be an array");
assert_eq!(pre.as_array().unwrap().len(), 1);
let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
assert!(
cmd.contains("/usr/local/bin/sqz"),
"hook command should use the passed sqz_path, got: {cmd}"
);
assert!(cmd.contains("hook claude"));
let precompact = &parsed["hooks"]["PreCompact"];
assert!(precompact.is_array());
let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
assert!(precompact_cmd.contains("hook precompact"));
let session = &parsed["hooks"]["SessionStart"];
assert!(session.is_array());
assert_eq!(
session[0]["matcher"].as_str().unwrap(),
"compact",
"SessionStart should only match /compact resume"
);
}
#[test]
fn global_install_preserves_existing_user_config() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
let existing = serde_json::json!({
"permissions": {
"allow": ["Bash(npm test *)"],
"deny": ["Read(./.env)"]
},
"env": { "FOO": "bar" },
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/format-on-edit.sh"
}
]
}
]
}
});
std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
assert!(changed, "install should report a change on new hook");
let content = std::fs::read_to_string(&settings).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed["permissions"]["allow"][0].as_str().unwrap(),
"Bash(npm test *)"
);
assert_eq!(
parsed["permissions"]["deny"][0].as_str().unwrap(),
"Read(./.env)"
);
assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
assert_eq!(
parsed["statusLine"]["command"].as_str().unwrap(),
"~/.claude/statusline.sh"
);
let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
let matchers: Vec<&str> = pre
.iter()
.map(|e| e["matcher"].as_str().unwrap_or(""))
.collect();
assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
assert!(
matchers.contains(&"Bash|PowerShell"),
"sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
);
}
#[test]
fn global_install_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
assert!(
!install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
"second install with identical args should report no change"
);
let path = tmp.path().join(".claude").join("settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for event in &["PreToolUse", "PreCompact", "SessionStart"] {
let arr = parsed["hooks"][event].as_array().unwrap();
assert_eq!(
arr.len(),
1,
"{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
);
}
}
#[test]
fn global_install_is_idempotent_with_exe_path() {
let tmp = tempfile::tempdir().unwrap();
let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
assert!(
!install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
"second install with .exe path should report no change (issue #21)"
);
let path = tmp.path().join(".claude").join("settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for event in &["PreToolUse", "PreCompact", "SessionStart"] {
let arr = parsed["hooks"][event].as_array().unwrap();
assert_eq!(
arr.len(),
1,
"{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
);
}
}
#[test]
fn global_install_upgrades_stale_sqz_hook_in_place() {
let tmp = tempfile::tempdir().unwrap();
install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
assert!(changed, "different sqz_path must be seen as a change");
let path = tmp.path().join(".claude").join("settings.json");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
assert!(cmd.contains("/new/path/sqz"));
assert!(!cmd.contains("/old/path/sqz"));
}
#[test]
fn global_uninstall_removes_sqz_and_preserves_the_rest() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(
&settings,
serde_json::json!({
"permissions": { "allow": ["Bash(git status)"] },
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [
{ "type": "command", "command": "~/format.sh" }
]
}
]
}
})
.to_string(),
)
.unwrap();
install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
assert_eq!(result.0, settings);
assert!(result.1, "should report that the file was modified");
assert!(settings.exists(), "settings.json should be preserved");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
assert_eq!(
parsed["permissions"]["allow"][0].as_str().unwrap(),
"Bash(git status)"
);
let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
assert!(parsed["hooks"].get("PreCompact").is_none());
assert!(parsed["hooks"].get("SessionStart").is_none());
}
#[test]
fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
let tmp = tempfile::tempdir().unwrap();
install_claude_global_at("sqz", Some(tmp.path())).unwrap();
let path = tmp.path().join(".claude").join("settings.json");
assert!(path.exists(), "precondition: install created the file");
let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
assert!(result.1);
assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
}
#[test]
fn only_commands_that_run_sqz_count_as_sqz_hooks() {
for (command, args) in [
("sqz hook claude", "hook claude"),
("/usr/local/bin/sqz hook claude-post", "hook claude-post"),
(r"C:\Users\me\.cargo\bin\sqz.exe hook precompact", "hook precompact"),
("C:/Users/Jane Doe/.cargo/bin/sqz.EXE resume", "resume"),
(r"C:\Users\Jane Doe\.cargo\bin\sqz.exe hook claude", "hook claude"),
("/Users/Jane Doe/bin/sqz resume", "resume"),
("~/.cargo/bin/sqz hook claude", "hook claude"),
("\"/opt/sqz\" hook claude", "hook claude"),
("'/opt/my tools (x86)/sqz' hook claude-post", "hook claude-post"),
] {
assert!(sqz_command_binary(command, args).is_some(), "{command}");
}
for (command, args) in [
("/usr/local/bin/on-resume.sh", "resume"),
("~/bin/save-notes --hook precompact", "hook precompact"),
("~/bin/audit-hook claude", "hook claude"),
("echo hi && /usr/bin/sqz hook claude", "hook claude"),
("nice -n 5 sqz hook claude", "hook claude"),
("/usr/bin/time /opt/sqz hook claude", "hook claude"),
("/usr/bin/env SQZ_NO_DEDUP=1 /opt/sqz hook claude", "hook claude"),
("/usr/bin/nice -n 5 /opt/sqz hook claude", "hook claude"),
("/usr/bin/time ~/bin/sqz hook claude", "hook claude"),
("sqz hook claude --verbose", "hook claude"),
("notsqz hook claude", "hook claude"),
("sqz hook claude-post", "hook claude"),
("\"/opt/sqz hook claude", "hook claude"),
("\"$HOME/bin/sqz\" hook claude", "hook claude"),
("\"/opt/notsqz\" hook claude", "hook claude"),
] {
assert!(sqz_command_binary(command, args).is_none(), "{command}");
}
}
#[test]
fn legacy_hook_files_lose_only_sqz_hooks() {
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let write = |root: &Path, rel: &str, text: &str| {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
};
let p = project.path();
let cursor = write(
p,
".cursor/hooks.json",
r#"{"version":1,"hooks":{"beforeShellExecution":[{"command":"/opt/sqz hook cursor"},{"command":"./audit.sh"}]}}"#,
);
let windsurf = write(p, ".windsurf/hooks.json", r#"{"hooks":{"pre_run_command":[{"command":"/opt/sqz hook windsurf"}]}}"#);
let cline = write(
p,
".cline/hooks.json",
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"sqz hook cline"}]}]}}"#,
);
let script = write(p, ".clinerules/hooks/PreToolUse", &LEGACY_CLINE_SCRIPT.replace('\n', "\r\n"));
let user_cursor = write(
home.path(),
".cursor/hooks.json",
r#"{"version":1,"hooks":{"preToolUse":[{"command":"C:/tools/sqz.exe hook cursor","matcher":"Shell"}]}}"#,
);
let all = vec![cursor.clone(), windsurf.clone(), cline.clone(), script.clone(), user_cursor.clone()];
assert_eq!(legacy_sqz_hook_files_at(p, Some(home.path())), all);
assert_eq!(
remove_legacy_sqz_hooks_at(p, Some(home.path())).unwrap(),
vec![(cursor.clone(), false), (windsurf, true), (cline, true), (script.clone(), true), (user_cursor, true)]
);
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cursor).unwrap()).unwrap();
assert_eq!(v, serde_json::json!({"version": 1, "hooks": {"beforeShellExecution": [{"command": "./audit.sh"}]}}));
assert!(legacy_sqz_hook_files_at(p, Some(home.path())).is_empty());
write(p, ".clinerules/hooks/PreToolUse", &LEGACY_CLINE_SCRIPT.replace("exit 0", "exit 1"));
assert!(legacy_sqz_hook_files_at(p, Some(home.path())).is_empty(), "an edited script is the user's");
}
#[test]
fn hook_binaries_are_read_from_every_hook_file_kind() {
let dir = tempfile::tempdir().unwrap();
let settings = dir.path().join("settings.json");
std::fs::write(&settings, claude_settings_content("/opt/sqz")).unwrap();
assert_eq!(sqz_hook_binaries(&settings), vec!["/opt/sqz"]);
let copilot = dir.path().join("sqz.json");
std::fs::write(&copilot, copilot_hook_content("C:\\tools\\sqz.exe")).unwrap();
assert_eq!(sqz_hook_binaries(&copilot), vec!["C:\\tools\\sqz.exe"]);
let plugin = dir.path().join("sqz.ts");
std::fs::write(&plugin, crate::opencode_plugin::generate_opencode_plugin("/gone/sqz")).unwrap();
assert_eq!(sqz_hook_binaries(&plugin), vec!["/gone/sqz"]);
let user = dir.path().join("user.json");
std::fs::write(
&user,
r#"{"hooks":{"PreCompact":[{"hooks":[{"command":"~/bin/save-notes --hook precompact"}]}],"PreToolUse":[{"matcher":"Bash","hooks":[{"command":"/usr/bin/time /opt/sqz hook claude"}]}]}}"#,
)
.unwrap();
assert!(sqz_hook_binaries(&user).is_empty());
}
#[test]
fn refresh_never_points_a_hook_at_a_missing_binary() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let old = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/gone/sqz hook claude"}]}]}}"#;
std::fs::write(&path, old).unwrap();
assert!(refresh_claude_settings(&path, "/also-gone/sqz").is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), old);
}
#[test]
fn hook_binaries_resolve_to_files_without_running_them() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("my tools").join("sqz");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, "").unwrap();
let shown = bin.to_string_lossy().to_string();
assert_eq!(resolve_sqz_binary(&shown), Some(bin.clone()));
assert_eq!(resolve_sqz_binary(&format!("\"{shown}\"")), Some(bin.clone()));
assert_eq!(resolve_sqz_binary(&format!("{shown}-gone")), None);
assert_eq!(resolve_sqz_binary("sqz-not-on-any-path-xyz"), None);
}
#[test]
fn user_hooks_that_look_like_sqz_ones_are_kept() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
let cmd = |c: &str| serde_json::json!({"type": "command", "command": c});
let user = serde_json::json!({
"hooks": {
"SessionStart": [
{"matcher": "resume", "hooks": [cmd("/usr/local/bin/on-resume.sh")]},
{"matcher": "startup", "hooks": [cmd("echo user-startup")]}
],
"PreCompact": [{"hooks": [cmd("~/bin/save-notes --hook precompact")]}],
"PreToolUse": [{"matcher": "Bash", "hooks": [
cmd("~/bin/audit-hook claude"),
cmd("/usr/bin/env SQZ_NO_DEDUP=1 /opt/sqz hook claude")
]}]
}
});
let text = serde_json::to_string_pretty(&user).unwrap();
std::fs::write(&settings, &text).unwrap();
let read = || -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap()
};
assert_eq!(remove_claude_global_hook_at(Some(tmp.path())).unwrap(), Some((settings.clone(), false)));
assert_eq!(std::fs::read_to_string(&settings).unwrap(), text);
assert!(install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
assert!(!install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
let installed = read();
for (event, user_count) in [("SessionStart", 2), ("PreCompact", 1), ("PreToolUse", 1)] {
let arr = installed["hooks"][event].as_array().unwrap();
assert_eq!(arr.len(), user_count + 1, "{event}: {arr:?}");
assert_eq!(arr[..user_count], user["hooks"][event].as_array().unwrap()[..], "{event}");
}
let mut shared = installed.clone();
shared["hooks"]["PreToolUse"][1]["hooks"]
.as_array_mut()
.unwrap()
.push(cmd("echo also-mine"));
std::fs::write(&settings, serde_json::to_string_pretty(&shared).unwrap()).unwrap();
assert!(remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap().1);
let mut expected = user.clone();
expected["hooks"]["PreToolUse"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"matcher": "Bash|PowerShell", "hooks": [cmd("echo also-mine")]}));
assert_eq!(read(), expected);
}
#[test]
fn project_install_leaves_claude_hooks_to_the_user_level_ones() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
install_claude_global_at("/opt/sqz", Some(home.path())).unwrap();
let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
let installed =
install_tool_hooks_at(project.path(), "/opt/sqz", InstallScope::Project, &filter, Some(home.path()));
assert_eq!(installed, vec!["Claude Code"]);
assert!(!project.path().join(".claude").join("settings.local.json").exists());
assert!(project.path().join("CLAUDE.md").exists());
}
#[test]
fn duplicate_project_hooks_go_and_permissions_stay() {
let project = tempfile::tempdir().unwrap();
let local = project.path().join(".claude").join("settings.local.json");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(&local, claude_settings_content("/opt/sqz")).unwrap();
assert!(claude_settings_have_sqz_hooks(&local));
assert!(remove_claude_project_hooks_only(project.path()).unwrap().unwrap().1);
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&local).unwrap()).unwrap();
assert_eq!(v, serde_json::json!({"permissions": {"allow": CLAUDE_ALLOW_RULES}}));
assert!(!claude_settings_have_sqz_hooks(&local));
let hook = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"sqz hook claude"}]}]}}"#;
std::fs::write(&local, hook).unwrap();
assert!(remove_claude_project_hooks_only(project.path()).unwrap().unwrap().1);
assert!(!local.exists());
}
#[test]
fn settings_rewrites_keep_the_final_newline_and_mode() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
let text = "{\n \"theme\": \"dark\"\n}\n";
std::fs::write(&settings, text).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&settings, std::fs::Permissions::from_mode(0o644)).unwrap();
}
assert!(install_claude_global_at("/opt/sqz", Some(tmp.path())).unwrap());
assert!(std::fs::read_to_string(&settings).unwrap().ends_with("}\n"));
assert!(remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap().1);
assert_eq!(std::fs::read_to_string(&settings).unwrap(), text);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&settings).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644);
}
}
#[test]
fn global_uninstall_on_missing_file_is_noop() {
let tmp = tempfile::tempdir().unwrap();
assert!(
remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
"missing file should return None, not error"
);
}
#[test]
fn global_uninstall_refuses_to_touch_unparseable_file() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, "{ invalid json because").unwrap();
assert!(
remove_claude_global_hook_at(Some(tmp.path())).is_err(),
"bad JSON must surface as an error"
);
let after = std::fs::read_to_string(&settings).unwrap();
assert_eq!(after, "{ invalid json because");
}
}
#[cfg(test)]
mod issue_11_tool_filter_tests {
use super::*;
#[test]
fn canonicalize_collapses_common_aliases() {
for aliases in &[
(vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
(vec!["Cursor", "cursor", "CURSOR"], "cursor"),
(vec!["Windsurf", "WINDSURF"], "windsurf"),
(vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
(vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
(vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
(vec!["Codex", "codex"], "codex"),
] {
for alias in &aliases.0 {
assert_eq!(
canonicalize_tool_name(alias),
aliases.1,
"alias '{}' must canonicalise to '{}'",
alias,
aliases.1
);
}
}
}
#[test]
fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
assert_eq!(canonicalize_tool_name("Some Thing"), "something");
}
#[test]
fn parse_tool_list_accepts_comma_separated_with_whitespace() {
let names = parse_tool_list("opencode,codex").unwrap();
assert_eq!(names, vec!["opencode", "codex"]);
let names = parse_tool_list(" opencode , codex ").unwrap();
assert_eq!(names, vec!["opencode", "codex"]);
let names = parse_tool_list("opencode").unwrap();
assert_eq!(names, vec!["opencode"]);
let names = parse_tool_list("claude-code").unwrap();
assert_eq!(names, vec!["claudecode"]);
}
#[test]
fn parse_tool_list_dedupes_repeated_entries() {
let names = parse_tool_list("opencode,opencode").unwrap();
assert_eq!(names, vec!["opencode"]);
let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
assert_eq!(names, vec!["claudecode"]);
}
#[test]
fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
let err = parse_tool_list("opncode").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown agent name 'opncode'"),
"error must quote the bad input: {msg}"
);
assert!(msg.contains("opencode"), "error must list valid options: {msg}");
assert!(msg.contains("cursor"), "error must list valid options: {msg}");
}
#[test]
fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
let err = parse_tool_list("opencode,xyz").unwrap_err();
assert!(err.to_string().contains("xyz"));
}
#[test]
fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
}
#[test]
fn tool_filter_all_includes_every_supported_tool() {
let filter = ToolFilter::All;
for tool in SUPPORTED_TOOL_NAMES {
assert!(
filter.includes(tool),
"default filter must include {tool}"
);
}
}
#[test]
fn tool_filter_only_opencode_excludes_everything_else() {
let filter = ToolFilter::Only(vec!["opencode".to_string()]);
assert!(filter.includes("OpenCode"));
for tool in SUPPORTED_TOOL_NAMES {
if *tool == "OpenCode" {
continue;
}
assert!(
!filter.includes(tool),
"--only opencode must not include {tool}"
);
}
}
#[test]
fn tool_filter_only_multi_tool_includes_exactly_those() {
let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
assert!(filter.includes("OpenCode"));
assert!(filter.includes("Codex"));
assert!(!filter.includes("Claude Code"));
assert!(!filter.includes("Cursor"));
assert!(!filter.includes("Windsurf"));
assert!(!filter.includes("Cline"));
assert!(!filter.includes("Gemini CLI"));
}
#[test]
fn tool_filter_skip_inverts_the_set() {
let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
assert!(!filter.includes("Cursor"));
assert!(!filter.includes("Windsurf"));
assert!(filter.includes("Claude Code"));
assert!(filter.includes("Cline"));
assert!(filter.includes("Gemini CLI"));
assert!(filter.includes("OpenCode"));
assert!(filter.includes("Codex"));
}
#[test]
fn tool_filter_only_empty_excludes_everything() {
let filter = ToolFilter::Only(vec![]);
for tool in SUPPORTED_TOOL_NAMES {
assert!(
!filter.includes(tool),
"empty --only must exclude every tool, got {tool}"
);
}
}
#[test]
fn tool_filter_only_accepts_display_name_or_canonical() {
let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
assert!(filter.includes("Claude Code"));
assert!(!filter.includes("Cursor"));
let filter = ToolFilter::Only(vec!["gemini".to_string()]);
assert!(filter.includes("Gemini CLI"));
}
#[test]
fn supported_tool_names_matches_generate_hook_configs_exactly() {
let configs = generate_hook_configs("sqz");
let emitted: std::collections::HashSet<&str> =
configs.iter().map(|c| c.tool_name.as_str()).collect();
let declared: std::collections::HashSet<&str> =
SUPPORTED_TOOL_NAMES.iter().copied().collect();
assert_eq!(
emitted, declared,
"SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
from generate_hook_configs. emitted={:?}, declared={:?}",
emitted, declared
);
}
#[test]
fn filtered_install_only_opencode_writes_only_opencode_files() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let filter = ToolFilter::Only(vec!["opencode".to_string()]);
let _installed = install_tool_hooks_at(
dir.path(),
"sqz",
InstallScope::Project,
&filter,
Some(home.path()),
);
assert!(
dir.path().join("opencode.json").exists(),
"OpenCode config must be written when --only opencode is used"
);
for (path, tool) in &[
(".claude/settings.local.json", "Claude Code"),
(".cursor/rules/sqz.mdc", "Cursor"),
(".windsurfrules", "Windsurf"),
(".clinerules", "Cline"),
(".gemini/settings.json", "Gemini CLI"),
("AGENTS.md", "Codex"),
] {
assert!(
!dir.path().join(path).exists(),
"filter rejected {tool} but the installer still wrote {path}"
);
}
}
#[test]
fn filtered_install_skip_cursor_omits_only_cursor() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
let _installed = install_tool_hooks_at(
dir.path(),
"sqz",
InstallScope::Project,
&filter,
Some(home.path()),
);
assert!(
!dir.path().join(".cursor/rules/sqz.mdc").exists(),
"skip cursor: .cursor/rules/sqz.mdc must not be written"
);
assert!(
dir.path().join(".windsurf/rules/sqz.md").exists(),
"skip cursor should not skip windsurf"
);
assert!(
dir.path().join(".clinerules").exists(),
"skip cursor should not skip cline"
);
}
}
#[cfg(test)]
mod ci_hook_tests {
use super::*;
#[test]
fn ci_hook_content_is_valid_v1_config() {
let parsed: serde_json::Value =
serde_json::from_str(&ci_hook_content()).expect("CI hook must be valid JSON");
assert_eq!(parsed["version"], 1);
let session_start = parsed["hooks"]["sessionStart"].as_array().unwrap();
assert!(session_start[0]["bash"]
.as_str()
.unwrap()
.contains("install.sh"));
let pre = parsed["hooks"]["preToolUse"].as_array().unwrap();
let bash = pre[0]["bash"].as_str().unwrap();
assert!(bash.contains("hook copilot"));
assert!(bash.ends_with("echo '{}'"));
assert_eq!(pre[0]["matcher"], "bash|powershell");
}
#[test]
fn install_ci_hook_creates_then_noops() {
let dir = tempfile::tempdir().unwrap();
assert!(install_ci_hook(dir.path()).unwrap());
let path = ci_hooks_path(dir.path());
assert!(path.exists());
assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
assert!(!install_ci_hook(dir.path()).unwrap());
}
#[test]
fn install_ci_hook_refreshes_stale_sqz_file() {
let dir = tempfile::tempdir().unwrap();
let path = ci_hooks_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"version":1,"hooks":{"preToolUse":[{"bash":"sqz hook copilot"}]}}"#).unwrap();
assert!(install_ci_hook(dir.path()).unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), ci_hook_content());
}
#[test]
fn install_ci_hook_leaves_user_file_alone() {
let dir = tempfile::tempdir().unwrap();
let path = ci_hooks_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"version":1,"hooks":{}}"#).unwrap();
assert!(!install_ci_hook(dir.path()).unwrap());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
r#"{"version":1,"hooks":{}}"#
);
}
#[test]
fn remove_ci_hook_only_removes_sqz_authored() {
let dir = tempfile::tempdir().unwrap();
assert!(!remove_ci_hook(dir.path()).unwrap());
install_ci_hook(dir.path()).unwrap();
assert!(remove_ci_hook(dir.path()).unwrap());
assert!(!ci_hooks_path(dir.path()).exists());
let path = ci_hooks_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{}").unwrap();
assert!(!remove_ci_hook(dir.path()).unwrap());
assert!(path.exists());
}
fn read_json(path: &Path) -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
#[test]
fn claude_install_adds_the_post_hook_and_allow_rules_once() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".claude").join("settings.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"permissions":{"allow":["Bash(npm test)"],"deny":["Bash(rm *)"]}}"#)
.unwrap();
assert!(install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap());
let v = read_json(&path);
assert_eq!(
v["permissions"]["allow"],
serde_json::json!(["Bash(npm test)", "Bash(set -o pipefail)", "Bash(sqz compress --cmd *)"])
);
assert_eq!(v["permissions"]["deny"], serde_json::json!(["Bash(rm *)"]));
assert_eq!(v["hooks"]["PostToolUse"][0]["matcher"], "Bash");
assert_eq!(
v["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
"/usr/local/bin/sqz hook claude-post"
);
assert!(claude_rules_installed(&[path.clone()]));
assert!(!install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap());
let (_, changed) = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
assert!(changed);
assert_eq!(
read_json(&path),
serde_json::json!({"permissions": {"allow": ["Bash(npm test)"], "deny": ["Bash(rm *)"]}})
);
}
#[test]
fn gemini_merge_and_removal_touch_only_sqz_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".gemini").join("settings.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mine = serde_json::json!({"matcher": "write_file", "hooks": [{"type": "command", "command": "lint"}]});
let settings = serde_json::json!({"theme": "x", "hooks": {"BeforeTool": [mine, gemini_hook_entry("/old/sqz")]}});
std::fs::write(&path, settings.to_string()).unwrap();
assert!(gemini_settings_has_sqz(&path));
assert!(gemini_settings_needs_update(&path, "/new/sqz").unwrap());
assert!(merge_sqz_gemini_hook(&path, "/new/sqz").unwrap());
assert!(!gemini_settings_needs_update(&path, "/new/sqz").unwrap());
assert!(!merge_sqz_gemini_hook(&path, "/new/sqz").unwrap());
assert_eq!(
read_json(&path)["hooks"]["BeforeTool"],
serde_json::json!([mine, gemini_hook_entry("/new/sqz")])
);
let (_, changed) = remove_sqz_from_gemini_settings(dir.path()).unwrap().unwrap();
assert!(changed);
assert_eq!(
read_json(&path),
serde_json::json!({"theme": "x", "hooks": {"BeforeTool": [mine]}})
);
assert!(!gemini_settings_has_sqz(&path));
assert_eq!(remove_sqz_from_gemini_settings(dir.path()).unwrap(), Some((path.clone(), false)));
std::fs::write(&path, "{\n // comment\n \"theme\": \"x\"\n}\n").unwrap();
assert!(gemini_settings_needs_update(&path, "/new/sqz").is_err());
assert!(!merge_sqz_gemini_hook(&path, "/new/sqz").unwrap_or(false));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\n // comment\n \"theme\": \"x\"\n}\n");
}
#[test]
fn fresh_project_settings_carry_the_rules_and_post_hook() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.local.json");
std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
assert!(claude_rules_installed(&[path.clone()]));
assert_eq!(
read_json(&path)["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
"/usr/bin/sqz hook claude-post"
);
assert!(!claude_project_settings_needs_update(&path, "/usr/bin/sqz").unwrap());
let mut old = read_json(&path);
old.as_object_mut().unwrap().remove("permissions");
std::fs::write(&path, old.to_string()).unwrap();
assert!(claude_settings_stale(&path));
assert!(claude_project_settings_needs_update(&path, "/usr/bin/sqz").unwrap());
assert!(merge_sqz_hooks_into_settings(&path, "/usr/bin/sqz").unwrap());
assert!(!claude_settings_stale(&path));
}
#[test]
fn project_uninstall_keeps_permissions_claude_saved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".claude").join("settings.local.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
let mut v = read_json(&path);
v["permissions"]["allow"].as_array_mut().unwrap().push(serde_json::json!("Bash(make)"));
std::fs::write(&path, v.to_string()).unwrap();
let (_, changed) = remove_claude_project_hook(dir.path()).unwrap().unwrap();
assert!(changed);
assert_eq!(read_json(&path), serde_json::json!({"permissions": {"allow": ["Bash(make)"]}}));
std::fs::write(&path, claude_settings_content("/usr/bin/sqz")).unwrap();
remove_claude_project_hook(dir.path()).unwrap();
assert!(!path.exists());
assert!(remove_claude_project_hook(dir.path()).unwrap().is_none());
}
fn post(command: &str, response: serde_json::Value) -> String {
serde_json::json!({
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": {"command": command},
"tool_response": response,
})
.to_string()
}
#[test]
fn post_tool_use_picks_successful_bash_output_sqz_never_saw() {
let ok = serde_json::json!({"stdout": "out", "stderr": "", "interrupted": false, "isImage": false});
let t = claude_post_tool_use_target(&post("cargo test | tail -5", ok.clone())).unwrap();
assert_eq!(t.label, "sh: cargo test | tail -5");
assert_eq!(serde_json::Value::Object(t.response), ok);
let t = claude_post_tool_use_target(&post("cd a && cargo test", ok.clone())).unwrap();
assert_eq!(t.label, "cargo test");
let skipped = [
post("set -o pipefail; ls 2>&1 | sqz compress --cmd ls", ok.clone()),
post("sqz stats", ok.clone()),
post("ls", serde_json::json!({"stdout": "x", "isImage": true})),
post("ls", serde_json::json!({"stdout": "x", "interrupted": true})),
post("ls", serde_json::json!({"content": "x"})),
post("ls", serde_json::json!({"stdout": "a".repeat(30_000), "stderr": ""})),
serde_json::json!({"hook_event_name": "PreToolUse", "tool_name": "Bash",
"tool_input": {"command": "ls"}, "tool_response": ok}).to_string(),
serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Read",
"tool_input": {"file_path": "/x"}, "tool_response": ok}).to_string(),
serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Bash",
"tool_input": {"command": "make", "run_in_background": true}, "tool_response": ok}).to_string(),
"not json".to_string(),
];
for input in skipped {
assert_eq!(claude_post_tool_use_target(&input), None, "{input}");
}
}
}