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> {
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)
}
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> {
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" | "execute_bash"
| "PowerShell" | "powershell" | "pwsh")
|| matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
if !is_shell {
return Ok(match platform {
HookPlatform::Cursor | HookPlatform::Copilot => "{}".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())
})
.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(match platform {
HookPlatform::Cursor | HookPlatform::Copilot => "{}".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 | HookPlatform::Copilot => "{}".to_string(),
_ => input.to_string(),
});
}
if is_interactive_command(command) {
return Ok(match platform {
HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
_ => input.to_string(),
});
}
if has_shell_operators(command) {
return Ok(match platform {
HookPlatform::Cursor | HookPlatform::Copilot => "{}".to_string(),
_ => input.to_string(),
});
}
let is_powershell = matches!(tool_name, "PowerShell" | "powershell" | "pwsh");
let rewritten = if is_powershell {
crate::exit_marker::powershell_rewrite(command, &shell_escape(command))
} else {
crate::exit_marker::posix_rewrite(command, &shell_escape(command))
};
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",
"permissionDecision": "allow",
"permissionDecisionReason": "sqz: command output will be compressed for token savings",
"updatedInput": updated_input
}
}),
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": updated_input
}
})
}
HookPlatform::Copilot => serde_json::json!({
"permissionDecision": "allow",
"permissionDecisionReason": "sqz: command output will be compressed for token savings",
"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}")))
}
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), or if a `«A1»` abbreviation symbol has replaced a value you
need verbatim (a SHA, path, or URL), you have these 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`.
- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
abbreviation, which replaces repeated phrases with `«A1»` symbols.
Use this for output full of SHAs/paths/URLs you'll copy-paste, since
abbreviation keeps only the first occurrence of a repeated phrase:
`SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
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|PowerShell",
"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(".windsurf/rules/sqz.md"),
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: "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",
];
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>> {
let home = copilot_home();
if !home.exists() {
return Ok(None);
}
let path = copilot_hooks_path();
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> {
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 == "Windsurf" {
let legacy = project_dir.join(".windsurfrules");
let target = project_dir.join(&config.config_path);
let legacy_is_sqz = legacy.exists()
&& std::fs::read_to_string(&legacy)
.map(|s| s.starts_with("# sqz — Token-Optimized CLI Output"))
.unwrap_or(false);
let mut changed = false;
if !target.exists() {
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&target, &config.config_content).is_ok() {
changed = true;
}
}
if legacy_is_sqz && target.exists() {
let _ = std::fs::remove_file(&legacy);
changed = true;
}
if changed && !installed.iter().any(|n| n == "Windsurf") {
installed.push("Windsurf".to_string());
}
continue;
}
if config.tool_name == "Cline" {
let base = project_dir.join(".clinerules");
let target = if base.is_dir() { base.join("sqz.md") } else { base };
if !target.exists() {
if std::fs::write(&target, &config.config_content).is_ok()
&& !installed.iter().any(|n| n == "Cline")
{
installed.push("Cline".to_string());
}
}
continue;
}
if config.tool_name == "Copilot CLI" {
if let Ok(Some(changed)) = install_copilot_hook(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 (steering_changed, mcp_changed) = if scope == InstallScope::Global {
let steering =
crate::kiro_integration::install_kiro_user_steering(sqz_path)
.unwrap_or(false);
let mcp = matches!(
crate::kiro_integration::install_kiro_user_mcp_config(),
Ok(crate::kiro_integration::KiroMcpInstall::Created)
| Ok(crate::kiro_integration::KiroMcpInstall::Merged)
);
(steering, mcp)
} else {
let steering =
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 mcp_changed = matches!(
crate::zed_integration::install_zed_mcp_config(),
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(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 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()
.unwrap_or(false);
if (hook_changed || 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> {
install_claude_global_at(sqz_path, None)
}
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);
}
let after = serde_json::to_string(&root_obj).unwrap_or_default();
Ok(before != after)
}
fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
[
(
"PreToolUse",
serde_json::json!({
"matcher": "Bash|PowerShell",
"hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
}),
"hook claude",
),
(
"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 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 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);
}
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)>> {
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),
},
};
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", "hook claude"),
("PreCompact", "hook precompact"),
("SessionStart", "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('\'', "'\\''"))
}
}
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_eq!(parsed["permissionDecision"], "allow");
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_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() {
let input = r#"{"toolName":"bash","toolArgs":{"command":"make && make install"}}"#;
let result = process_hook_copilot(input).unwrap();
assert_eq!(result, "{}", "compound commands must not be rewritten");
}
#[test]
fn test_claude_updated_input_preserves_sibling_fields() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"cargo test","run_in_background":true,"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"], true);
assert_eq!(updated["timeout"], 120000);
assert_eq!(updated["description"], "Run tests");
}
#[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 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","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","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":"Bash","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_eq!(
cmd,
"{ cargo test 2>&1; printf '\\n__SQZ_EXIT_%d__\\n' \"$?\"; } | sqz compress --cmd 'cargo test'",
"POSIX rewrite must send the command's exit status through the pipe"
);
}
#[test]
fn powershell_rewrite_uses_lastexitcode_not_printf() {
let input = r#"{"tool_name":"PowerShell","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(
r#"{"tool_name":"Bash","tool_input":{"command":"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_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 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 = 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 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'");
}
#[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 {
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();
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");
}
#[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();
install_tool_hooks(dir.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();
install_tool_hooks(dir.path(), "sqz");
assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
assert!(!dir.path().join(".windsurfrules").exists());
}
#[test]
fn test_windsurf_migrates_legacy_sqz_rules_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".windsurfrules"),
"# sqz — Token-Optimized CLI Output\n\nold content\n",
)
.unwrap();
install_tool_hooks(dir.path(), "sqz");
assert!(dir.path().join(".windsurf/rules/sqz.md").exists());
assert!(!dir.path().join(".windsurfrules").exists(), "legacy file migrated");
}
#[test]
fn test_windsurf_leaves_user_legacy_rules_alone() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".windsurfrules"), "my own rules\n").unwrap();
install_tool_hooks(dir.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();
std::fs::create_dir_all(dir.path().join(".clinerules")).unwrap();
std::fs::write(dir.path().join(".clinerules/mine.md"), "user rule\n").unwrap();
install_tool_hooks(dir.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();
std::fs::write(dir.path().join(".clinerules"), "user rules\n").unwrap();
install_tool_hooks(dir.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();
install_tool_hooks(dir.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_tool_hooks(dir.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);
}
}
#[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 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 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(".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());
}
}