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,
}
pub fn process_hook(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::ClaudeCode)
}
pub fn process_hook_cursor(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::Cursor)
}
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)
}
fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
let parsed: serde_json::Value = serde_json::from_str(input)
.map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
let tool_name = parsed
.get("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")
|| matches!(hook_event, "beforeShellExecution" | "pre_run_command");
if !is_shell {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
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())
})
.unwrap_or("");
if command.is_empty() {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
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(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
if is_interactive_command(command) {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
if has_shell_operators(command) {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
let rewritten = format!(
"{} 2>&1 | sqz compress --cmd {}",
command,
shell_escape(extract_base_command(command)),
);
let output = match platform {
HookPlatform::ClaudeCode => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "sqz: command output will be compressed for token savings",
"updatedInput": {
"command": rewritten
}
}
}),
HookPlatform::Cursor => serde_json::json!({
"permission": "allow",
"updated_input": {
"command": rewritten
}
}),
HookPlatform::GeminiCli => serde_json::json!({
"decision": "allow",
"hookSpecificOutput": {
"tool_input": {
"command": rewritten
}
}
}),
HookPlatform::Windsurf => {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "sqz: command output will be compressed for token savings",
"updatedInput": {
"command": rewritten
}
}
})
}
};
serde_json::to_string(&output)
.map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
}
pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
let sqz_path_raw = sqz_path;
let sqz_path_json = json_escape_string_value(sqz_path);
let sqz_path = &sqz_path_json;
let escape_hatch = format!(
r#"## Escape hatch — when sqz output confuses the agent
If you see a `§ref:HASH§` token in tool output and can't parse it, or if
compressed output is making it harder to work (loops, repeated tiny
requests), you have three ways out:
- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
status 2>&1 | sqz compress`) to disable dedup for that one command.
Strictly more tokens, strictly less ambiguous.
- **`--no-cache`** — the same opt-out as a CLI flag:
`git status 2>&1 | sqz compress --no-cache`.
Use these when you genuinely need raw data. The default compressed
path is still what you want for most output."#,
);
let escape_hatch = &escape_hatch;
vec![
ToolHookConfig {
tool_name: "Claude Code".to_string(),
config_path: PathBuf::from(".claude/settings.local.json"),
config_content: format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook claude"
}}
]
}}
],
"PreCompact": [
{{
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook precompact"
}}
]
}}
],
"SessionStart": [
{{
"matcher": "compact",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} resume"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cursor".to_string(),
config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
config_content: format!(
r#"---
description: sqz — Token-Optimized CLI Output
alwaysApply: true
---
# sqz — Token-Optimized CLI Output
When running shell commands whose output may be long (directory listings,
git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
pipe the output through `sqz compress` to reduce token consumption.
`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
usage is to pipe the command's output into `sqz compress`:
```bash
# Instead of: Use:
git status git status 2>&1 | {sqz_path_raw} compress
cargo test cargo test 2>&1 | {sqz_path_raw} compress
git log -10 git log -10 2>&1 | {sqz_path_raw} compress
docker ps docker ps 2>&1 | {sqz_path_raw} compress
ls -la ls -la 2>&1 | {sqz_path_raw} compress
```
The `2>&1` captures stderr too, which is useful for commands like `cargo
test` where diagnostics go to stderr. `sqz compress` filters and compresses
the combined output while preserving filenames, paths, and identifiers.
It typically saves 60-90% tokens on verbose commands.
Do NOT pipe output for:
- Interactive commands (`vim`, `ssh`, `python`, REPLs)
- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
`cmd; other`) — run those directly
- Short commands whose output is already a few lines
If `sqz` is not on PATH, run commands normally.
{escape_hatch}
"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Windsurf".to_string(),
config_path: PathBuf::from(".windsurfrules"),
config_content: format!(
r#"# sqz — Token-Optimized CLI Output
Pipe verbose shell command output through `sqz compress` to save tokens.
`sqz` reads from stdin and writes the compressed output to stdout — it is
NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
```bash
# Instead of: Use:
git status git status 2>&1 | {sqz_path_raw} compress
cargo test cargo test 2>&1 | {sqz_path_raw} compress
git log -10 git log -10 2>&1 | {sqz_path_raw} compress
docker ps docker ps 2>&1 | {sqz_path_raw} compress
```
sqz filters and compresses command outputs while preserving filenames,
paths, and identifiers (typically 60-90% token reduction on verbose
commands). Skip short commands, interactive commands (vim, ssh, python),
and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
not on PATH, run commands normally.
{escape_hatch}
"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cline".to_string(),
config_path: PathBuf::from(".clinerules"),
config_content: format!(
r#"# sqz — Token-Optimized CLI Output
Pipe verbose shell command output through `sqz compress` to save tokens.
`sqz` reads from stdin and writes the compressed output to stdout — it is
NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
```bash
# Instead of: Use:
git status git status 2>&1 | {sqz_path_raw} compress
cargo test cargo test 2>&1 | {sqz_path_raw} compress
git log -10 git log -10 2>&1 | {sqz_path_raw} compress
docker ps docker ps 2>&1 | {sqz_path_raw} compress
```
sqz filters and compresses command outputs while preserving filenames,
paths, and identifiers (typically 60-90% token reduction on verbose
commands). Skip short commands, interactive commands (vim, ssh, python),
and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
not on PATH, run commands normally.
{escape_hatch}
"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Gemini CLI".to_string(),
config_path: PathBuf::from(".gemini/settings.json"),
config_content: format!(
r#"{{
"hooks": {{
"BeforeTool": [
{{
"matcher": "run_shell_command",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook gemini"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "OpenCode".to_string(),
config_path: PathBuf::from("opencode.json"),
config_content: format!(
r#"{{
"$schema": "https://opencode.ai/config.json",
"mcp": {{
"sqz": {{
"type": "local",
"command": ["sqz-mcp", "--transport", "stdio"]
}}
}},
"plugin": ["sqz"]
}}"#
),
scope: HookScope::Project,
},
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,
},
]
}
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",
"OpenCode",
"Codex",
];
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(),
"opencode" => "opencode".to_string(),
"codex" => "codex".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> {
let configs = generate_hook_configs(sqz_path);
let mut installed = Vec::new();
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()
.unwrap_or(false);
if (agents_changed || mcp_changed)
&& !installed.iter().any(|n| n == "Codex")
{
installed.push("Codex".to_string());
}
continue;
}
if config.tool_name == "Claude Code" && scope == InstallScope::Global {
let hook_installed = match install_claude_global(sqz_path) {
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()
.unwrap_or(false);
if (hook_installed || 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 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()
.unwrap_or(false);
if (md_changed || mcp_changed)
&& !installed.iter().any(|n| n == "Claude Code")
{
installed.push("Claude Code".to_string());
}
}
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();
}
}
}
if filter.includes("OpenCode") {
if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
if !installed.iter().any(|n| n == "OpenCode") {
installed.push("OpenCode".to_string());
}
}
}
installed
}
pub fn claude_user_settings_path() -> Option<PathBuf> {
dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
}
fn install_claude_global(sqz_path: &str) -> Result<bool> {
let path = claude_user_settings_path().ok_or_else(|| {
crate::error::SqzError::Other(
"Could not resolve home directory for ~/.claude/settings.json".to_string(),
)
})?;
let mut root: serde_json::Value = if path.exists() {
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() {
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 pre_tool_use = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
});
let pre_compact = serde_json::json!({
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
});
let session_start = serde_json::json!({
"matcher": "compact",
"hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
});
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()
))
})?;
upsert_sqz_hook_entry(hooks_obj, "PreToolUse", pre_tool_use, "sqz hook claude");
upsert_sqz_hook_entry(hooks_obj, "PreCompact", pre_compact, "sqz hook precompact");
upsert_sqz_hook_entry(hooks_obj, "SessionStart", session_start, "sqz resume");
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()
))
})?;
}
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 serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
.map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
std::fs::write(tmp.path(), serialized).map_err(|e| {
crate::error::SqzError::Other(format!(
"write to temp file {}: {e}",
tmp.path().display()
))
})?;
tmp.persist(&path).map_err(|e| {
crate::error::SqzError::Other(format!(
"rename temp file into place at {}: {e}",
path.display()
))
})?;
Ok(true)
}
pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
let Some(path) = claude_user_settings_path() else {
return Ok(None);
};
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 mut changed = false;
if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
for (event, sentinel) in &[
("PreToolUse", "sqz hook claude"),
("PreCompact", "sqz hook precompact"),
("SessionStart", "sqz resume"),
] {
if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
let before = arr.len();
arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
if arr.len() != before {
changed = true;
}
}
}
hooks.retain(|_, v| match v {
serde_json::Value::Array(a) => !a.is_empty(),
_ => true,
});
let hooks_empty = hooks.is_empty();
if hooks_empty {
root_obj.remove("hooks");
changed = true;
}
}
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)));
}
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 serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
.map_err(|e| {
crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
})?;
std::fs::write(tmp.path(), serialized).map_err(|e| {
crate::error::SqzError::Other(format!(
"write to temp file {}: {e}",
tmp.path().display()
))
})?;
tmp.persist(&path).map_err(|e| {
crate::error::SqzError::Other(format!(
"rename temp file into place at {}: {e}",
path.display()
))
})?;
Ok(Some((path, true)))
}
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;
};
arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
arr.push(new_entry);
}
fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
entry
.get("hooks")
.and_then(|h| h.as_array())
.map(|hooks_arr| {
hooks_arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.map(|c| c.contains(needle))
.unwrap_or(false)
})
})
.unwrap_or(false)
}
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 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_process_hook_rewrites_bash_command() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"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_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
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"), "should pass base command as --cmd: {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_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_eq!(parsed["decision"].as_str().unwrap(), "allow");
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 test_process_hook_legacy_format() {
let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
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() {
let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
let result = process_hook_cursor(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
let cmd = parsed["updated_input"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
assert!(cmd.contains("git status"));
assert!(parsed.get("hookSpecificOutput").is_none(),
"Cursor format should not have hookSpecificOutput");
}
#[test]
fn test_process_hook_cursor_passthrough_returns_empty_json() {
let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
let result = process_hook_cursor(input).unwrap();
assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
}
#[test]
fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
let result = process_hook_cursor(input).unwrap();
assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
}
#[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"), "label must be passed via --cmd flag");
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 = r#"{"tool_name":"Bash","tool_input":{"command":"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"),
"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(".windsurfrules"),
"Windsurf should use .windsurfrules, not .windsurf/hooks.json");
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'");
}
#[test]
fn test_install_tool_hooks_creates_files() {
let dir = tempfile::tempdir().unwrap();
let installed = install_tool_hooks(dir.path(), "sqz");
assert!(!installed.is_empty(), "should install at least one hook config");
for name in &installed {
let configs = generate_hook_configs("sqz");
let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
let path = dir.path().join(&config.config_path);
assert!(path.exists(), "hook config should exist: {}", path.display());
}
}
#[test]
fn test_install_tool_hooks_does_not_overwrite() {
let dir = tempfile::tempdir().unwrap();
install_tool_hooks(dir.path(), "sqz");
let custom_path = dir.path().join(".claude/settings.local.json");
std::fs::write(&custom_path, "custom content").unwrap();
install_tool_hooks(dir.path(), "sqz");
let content = std::fs::read_to_string(&custom_path).unwrap();
assert_eq!(content, "custom content", "should not overwrite existing config");
}
}
#[cfg(test)]
mod global_install_tests {
use super::*;
fn with_fake_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
std::env::set_var("HOME", tmp);
std::env::set_var("USERPROFILE", tmp);
let result = body();
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_userprofile {
Some(v) => std::env::set_var("USERPROFILE", v),
None => std::env::remove_var("USERPROFILE"),
}
result
}
#[test]
fn global_install_creates_fresh_settings_json() {
let tmp = tempfile::tempdir().unwrap();
with_fake_home(tmp.path(), || {
let changed = install_claude_global("/usr/local/bin/sqz").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();
with_fake_home(tmp.path(), || {
let changed = install_claude_global("/usr/local/bin/sqz").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"), "sqz Bash hook must be present");
});
}
#[test]
fn global_install_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
with_fake_home(tmp.path(), || {
assert!(install_claude_global("sqz").unwrap());
assert!(
!install_claude_global("sqz").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_upgrades_stale_sqz_hook_in_place() {
let tmp = tempfile::tempdir().unwrap();
with_fake_home(tmp.path(), || {
install_claude_global("/old/path/sqz").unwrap();
let changed = install_claude_global("/new/path/sqz").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();
with_fake_home(tmp.path(), || {
install_claude_global("/usr/local/bin/sqz").unwrap();
let result = remove_claude_global_hook().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();
with_fake_home(tmp.path(), || {
install_claude_global("sqz").unwrap();
let path = tmp.path().join(".claude").join("settings.json");
assert!(path.exists(), "precondition: install created the file");
let result = remove_claude_global_hook().unwrap().unwrap();
assert!(result.1);
assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
});
}
#[test]
fn global_uninstall_on_missing_file_is_noop() {
let tmp = tempfile::tempdir().unwrap();
with_fake_home(tmp.path(), || {
assert!(
remove_claude_global_hook().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();
with_fake_home(tmp.path(), || {
assert!(
remove_claude_global_hook().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 filter = ToolFilter::Only(vec!["opencode".to_string()]);
let _installed = install_tool_hooks_scoped_filtered(
dir.path(),
"sqz",
InstallScope::Project,
&filter,
);
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 filter = ToolFilter::Skip(vec!["cursor".to_string()]);
let _installed = install_tool_hooks_scoped_filtered(
dir.path(),
"sqz",
InstallScope::Project,
&filter,
);
assert!(
!dir.path().join(".cursor/rules/sqz.mdc").exists(),
"skip cursor: .cursor/rules/sqz.mdc must not be written"
);
assert!(
dir.path().join(".windsurfrules").exists(),
"skip cursor should not skip windsurf"
);
assert!(
dir.path().join(".clinerules").exists(),
"skip cursor should not skip cline"
);
}
}