use std::path::{Path, PathBuf};
use crate::error::{Result, SqzError};
use crate::kiro_integration::SteeringState;
const CLAUDE_MD_BEGIN: &str =
"<!-- BEGIN sqz-claude-guidance (auto-installed by sqz init; remove this block to disable) -->";
const CLAUDE_MD_END: &str = "<!-- END sqz-claude-guidance -->";
pub fn claude_md_path(project_dir: &Path) -> PathBuf {
project_dir.join("CLAUDE.md")
}
pub fn claude_user_json_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".claude.json"))
}
pub fn claude_md_guidance_block(sqz_path: &str) -> String {
format!(
"\
{begin}
## sqz — Context Compression (READ FIRST)
sqz is installed in this project. It compresses tool output so large
files, long logs, and verbose command output cost far fewer tokens.
There are **two ways** sqz is wired in, and you should prefer each
one in the situations below.
### Preferred tools (MCP)
The `sqz-mcp` server is registered in this project's MCP config. It
exposes three read-only tools that compress their output through the
sqz pipeline:
- **`sqz_read_file`** — read a file from disk and return a compressed
view. **PREFER this over the built-in `Read` tool** for any file
larger than ~2KB or any file you might read more than once in the
same session, **including ranged reads** (`offset` / `limit`, same
meaning as the built-in Read). Repeat reads return a 13-token
`§ref:HASH§` reference instead of the full content; a ranged re-read
of a file you already have in full returns `§ref:HASH:L40-80§`,
meaning lines 40-80 of that content. Call `expand` on either token
if you need the bytes again.
- **`sqz_grep`** — search files for a literal string or regex.
**PREFER this over the built-in `Grep`** for anything that might
match more than a handful of lines. Caps at 200 matches by default;
raise with `max_matches` if needed.
- **`sqz_list_dir`** — list a directory. Skips `.git`, `node_modules`,
`target`, `dist`, `build`, `vendor`, `__pycache__` so the output
stays focused. **PREFER this over `ls -la` via Bash** when you want
to see a project layout.
- **`sqz_recall`** — full-text search over everything sqz has
compressed in past sessions. Use it to recover context lost to
compaction (an error message, a file you read yesterday).
The built-in `Read`, `Grep`, `Glob` tools remain available. Use them for:
- Tiny config files (<1KB) where compression can't help.
- Byte-exact reads you'll hash or diff (lockfiles, signatures).
- Globbing (sqz has no glob tool; `Glob` is still the right choice).
### Bash commands (hooked automatically)
When you run a shell command through the `Bash` tool, a PreToolUse hook
gives each command in it a pipe through `sqz compress`, under
`set -o pipefail` so exit status and `&&`/`||` behave as before. This is
transparent: you don't need to add anything. Builtins like `cd` and
`export` stay outside the pipes, and the permission rules you already
have still apply, since sqz never approves a command for you.
A pipeline, loop, subshell or heredoc is left as written (outside bypass
mode its grouped form couldn't match your allow rules); when it exits 0,
a PostToolUse hook compresses its output instead. Background jobs and
`run_in_background` calls, followers and servers (`tail -f`,
`npm run dev`), editors and REPLs, and anything already going through
sqz are never rewritten.
### Escape hatch — when you see a `§ref:HASH§` token
If tool output contains a `§ref:a1b2c3d4§` token and you need the full
content it points at, resolve it. Three equivalent ways:
- Shell: `{sqz_path} expand a1b2c3d4` (or paste the whole token
`{sqz_path} expand §ref:a1b2c3d4§`).
- MCP tool: call `expand` with `{{ \"prefix\": \"a1b2c3d4\" }}`.
- To get uncompressed output for one command: put `SQZ_NO_DEDUP=1`
right before `sqz compress` (e.g. `git log | SQZ_NO_DEDUP=1 sqz compress`).
If you see a `«A1»` abbreviation symbol where a SHA, path, or URL
should be, n-gram phrase abbreviation collapsed a repeated phrase and
kept only its first occurrence. Disable it for that command with
`SQZ_NO_ABBREV=1` (or the `--no-abbrev` flag):
`git log | SQZ_NO_ABBREV=1 sqz compress`.
If the compressed output is actively making the task harder (looping
on refs, small retries replacing one big read), call the `passthrough`
MCP tool to get raw text.
### When NOT to use sqz tools
- Writing or editing files — use the built-in `Write`/`Edit` tools.
sqz has no write tools (by design; see issue #5 follow-up).
- Running commands interactively or in watch mode.
- Reading very small files (<1KB) where compression can't help.
{end}
",
begin = CLAUDE_MD_BEGIN,
end = CLAUDE_MD_END,
)
}
fn claude_md_has_sqz_block(content: &str) -> bool {
content.contains(CLAUDE_MD_BEGIN)
}
pub fn claude_md_block_outdated(project_dir: &Path, sqz_path: &str) -> bool {
std::fs::read_to_string(claude_md_path(project_dir)).is_ok_and(|content| {
claude_md_block_range(&content).is_some_and(|(start, end)| {
content[start..end].replace("\r\n", "\n") != claude_md_guidance_block(sqz_path).trim_end_matches('\n')
})
})
}
pub fn claude_md_has_guidance(project_dir: &Path) -> bool {
std::fs::read_to_string(claude_md_path(project_dir))
.is_ok_and(|s| claude_md_block_range(&s).is_some())
}
fn claude_md_block_range(content: &str) -> Option<(usize, usize)> {
let start = content.find(CLAUDE_MD_BEGIN)?;
let end = start + content[start..].find(CLAUDE_MD_END)? + CLAUDE_MD_END.len();
Some((start, end))
}
fn claude_md_block_sqz_path(block: &str) -> Option<&str> {
block.lines().find_map(|line| {
line.strip_prefix("- Shell: `")?
.split_once(" expand a1b2c3d4`")
.map(|(path, _)| path)
})
}
pub fn claude_md_guidance_state(project_dir: &Path) -> SteeringState {
let Ok(content) = std::fs::read_to_string(claude_md_path(project_dir)) else {
return SteeringState::Missing;
};
let Some((start, end)) = claude_md_block_range(&content) else {
return SteeringState::UserAuthored;
};
let block = &content[start..end];
match claude_md_block_sqz_path(block) {
Some(p) if block.replace("\r\n", "\n") == claude_md_guidance_block(p).trim_end_matches('\n') => {
SteeringState::Current
}
_ => SteeringState::Stale,
}
}
pub fn refresh_claude_md_guidance(project_dir: &Path, fallback_sqz_path: &str) -> Result<bool> {
if claude_md_guidance_state(project_dir) != SteeringState::Stale {
return Ok(false);
}
let path = claude_md_path(project_dir);
let content = std::fs::read_to_string(&path)
.map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
let sqz_path = claude_md_block_range(&content)
.and_then(|(start, end)| claude_md_block_sqz_path(&content[start..end]))
.filter(|p| crate::tool_hooks::resolve_sqz_binary(p).is_some())
.unwrap_or(fallback_sqz_path)
.to_string();
write_claude_md_block(&path, &content, &sqz_path)
}
fn write_claude_md_block(path: &Path, content: &str, sqz_path: &str) -> Result<bool> {
let Some((start, end)) = claude_md_block_range(content) else {
return Ok(false);
};
let block = claude_md_guidance_block(sqz_path);
if content[start..end].replace("\r\n", "\n") == block.trim_end_matches('\n') {
return Ok(false);
}
let updated = format!("{}{}{}", &content[..start], block.trim_end_matches('\n'), &content[end..]);
std::fs::write(path, updated)
.map_err(|e| SqzError::Other(format!("failed to write {}: {e}", path.display())))?;
Ok(true)
}
pub fn install_claude_md_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
let path = claude_md_path(project_dir);
let block = claude_md_guidance_block(sqz_path);
if path.exists() {
let existing = std::fs::read_to_string(&path).map_err(|e| {
SqzError::Other(format!("failed to read {}: {e}", path.display()))
})?;
if claude_md_has_sqz_block(&existing) {
return write_claude_md_block(&path, &existing, sqz_path);
}
let mut new_content = existing;
if !new_content.ends_with('\n') {
new_content.push('\n');
}
if !new_content.ends_with("\n\n") {
new_content.push('\n');
}
new_content.push_str(&block);
std::fs::write(&path, new_content).map_err(|e| {
SqzError::Other(format!("failed to write {}: {e}", path.display()))
})?;
return Ok(true);
}
let mut content = String::from(
"# CLAUDE.md\n\
\n\
Project-level instructions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).\n\
\n",
);
content.push_str(&block);
std::fs::write(&path, content).map_err(|e| {
SqzError::Other(format!("failed to write {}: {e}", path.display()))
})?;
Ok(true)
}
pub fn remove_claude_md_guidance(
project_dir: &Path,
) -> Result<Option<(PathBuf, bool)>> {
let path = claude_md_path(project_dir);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path).map_err(|e| {
SqzError::Other(format!("failed to read {}: {e}", path.display()))
})?;
let begin_idx = match content.find(CLAUDE_MD_BEGIN) {
Some(i) => i,
None => return Ok(Some((path, false))),
};
let after_end_idx = match content[begin_idx..].find(CLAUDE_MD_END) {
Some(i) => begin_idx + i + CLAUDE_MD_END.len(),
None => {
return Ok(Some((path, false)));
}
};
let new_content = crate::codex_integration::excise_range(&content, begin_idx, after_end_idx);
let remaining_trimmed = new_content.trim();
let is_only_preamble = remaining_trimmed
== "# CLAUDE.md\n\n\
Project-level instructions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code)."
.trim()
|| remaining_trimmed.is_empty();
if is_only_preamble {
std::fs::remove_file(&path).map_err(|e| {
SqzError::Other(format!("failed to delete {}: {e}", path.display()))
})?;
} else {
std::fs::write(&path, new_content).map_err(|e| {
SqzError::Other(format!("failed to write {}: {e}", path.display()))
})?;
}
Ok(Some((path, true)))
}
const SQZ_MCP_SENTINEL_KEY: &str = "_sqz_managed";
pub fn install_claude_mcp_config() -> Result<bool> {
install_claude_mcp_config_at(None)
}
pub(crate) fn install_claude_mcp_config_at(home_override: Option<&Path>) -> Result<bool> {
let path = match home_override {
Some(h) => h.join(".claude.json"),
None => claude_user_json_path().ok_or_else(|| {
SqzError::Other(
"cannot resolve $HOME — ~/.claude.json location unknown".to_string(),
)
})?,
};
if let Some(parent) = path.parent() {
if !parent.exists() {
let _ = std::fs::create_dir_all(parent);
}
}
let text = if path.exists() {
std::fs::read_to_string(&path).map_err(|e| {
SqzError::Other(format!("failed to read {}: {e}", path.display()))
})?
} else {
String::new()
};
let mut root: serde_json::Value = if text.trim().is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&text).map_err(|e| {
SqzError::Other(format!(
"~/.claude.json exists but is not valid JSON: {e}"
))
})?
};
let root_obj = root
.as_object_mut()
.ok_or_else(|| SqzError::Other(
"~/.claude.json root must be a JSON object".to_string(),
))?;
let mcp = root_obj
.entry("mcpServers".to_string())
.or_insert_with(|| serde_json::json!({}));
if !mcp.is_object() {
*mcp = serde_json::json!({});
}
let mcp_obj = mcp
.as_object_mut()
.expect("just ensured mcp is an object");
if mcp_obj.contains_key("sqz") {
return Ok(false);
}
mcp_obj.insert(
"sqz".to_string(),
serde_json::json!({
"command": "sqz-mcp",
"args": ["--transport", "stdio"],
SQZ_MCP_SENTINEL_KEY: true
}),
);
let mut out = serde_json::to_string_pretty(&root).map_err(|e| {
SqzError::Other(format!("failed to serialize ~/.claude.json: {e}"))
})?;
if text.ends_with('\n') {
out.push('\n');
}
std::fs::write(&path, out).map_err(|e| {
SqzError::Other(format!("failed to write {}: {e}", path.display()))
})?;
Ok(true)
}
pub fn remove_claude_mcp_config() -> Result<Option<(PathBuf, bool)>> {
remove_claude_mcp_config_at(None)
}
pub fn claude_mcp_config_managed() -> bool {
claude_user_json_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.pointer(&format!("/mcpServers/sqz/{SQZ_MCP_SENTINEL_KEY}"))?.as_bool())
.unwrap_or(false)
}
pub(crate) fn remove_claude_mcp_config_at(
home_override: Option<&Path>,
) -> Result<Option<(PathBuf, bool)>> {
let path = match home_override {
Some(h) => h.join(".claude.json"),
None => match claude_user_json_path() {
Some(p) => p,
None => return Ok(None),
},
};
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&path).map_err(|e| {
SqzError::Other(format!("failed to read {}: {e}", path.display()))
})?;
let mut root: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => {
return Ok(Some((path, false)));
}
};
let changed = {
let Some(root_obj) = root.as_object_mut() else {
return Ok(Some((path, false)));
};
let Some(mcp) = root_obj.get_mut("mcpServers").and_then(|v| v.as_object_mut())
else {
return Ok(Some((path, false)));
};
let is_managed = mcp
.get("sqz")
.and_then(|v| v.get(SQZ_MCP_SENTINEL_KEY))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_managed {
return Ok(Some((path, false)));
}
let removed = mcp.remove("sqz").is_some();
if mcp.is_empty() {
root_obj.remove("mcpServers");
}
removed
};
if changed {
let mut out = serde_json::to_string_pretty(&root).map_err(|e| {
SqzError::Other(format!("failed to serialize ~/.claude.json: {e}"))
})?;
if text.ends_with('\n') {
out.push('\n');
}
std::fs::write(&path, out).map_err(|e| {
SqzError::Other(format!("failed to write {}: {e}", path.display()))
})?;
}
Ok(Some((path, changed)))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn guidance_block_contains_tool_recommendations() {
let block = claude_md_guidance_block("/usr/local/bin/sqz");
assert!(block.contains(CLAUDE_MD_BEGIN));
assert!(block.contains(CLAUDE_MD_END));
assert!(
block.contains("PREFER this over the built-in `Read` tool"),
"guidance must tell the agent when to prefer sqz_read_file"
);
assert!(block.contains("sqz_read_file"));
assert!(block.contains("sqz_grep"));
assert!(block.contains("sqz_list_dir"));
assert!(block.contains("§ref:"));
assert!(block.contains("/usr/local/bin/sqz expand"));
}
#[test]
fn install_creates_new_claude_md() {
let dir = TempDir::new().unwrap();
let changed =
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
assert!(changed);
let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(content.starts_with("# CLAUDE.md"));
assert!(content.contains(CLAUDE_MD_BEGIN));
assert!(content.contains(CLAUDE_MD_END));
}
#[test]
fn install_appends_to_existing_claude_md() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
std::fs::write(
&path,
"# My project rules\n\n- Always use 2-space indent\n- Be polite\n",
)
.unwrap();
let changed =
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
assert!(changed);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("My project rules"));
assert!(content.contains("Be polite"));
let user_idx = content.find("Be polite").unwrap();
let sqz_idx = content.find(CLAUDE_MD_BEGIN).unwrap();
assert!(
sqz_idx > user_idx,
"sqz block must append after existing content, not prepend"
);
}
#[test]
fn install_is_idempotent() {
let dir = TempDir::new().unwrap();
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
let second = install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz")
.unwrap();
assert!(!second, "second install must be a no-op");
let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
let occurrences = content.matches(CLAUDE_MD_BEGIN).count();
assert_eq!(
occurrences, 1,
"re-running sqz init must not duplicate the block"
);
}
#[test]
fn remove_returns_none_when_file_missing() {
let dir = TempDir::new().unwrap();
assert!(remove_claude_md_guidance(dir.path()).unwrap().is_none());
}
#[test]
fn remove_excises_block_and_preserves_user_content() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
std::fs::write(
&path,
"# My project rules\n\n- Always use 2-space indent\n- Be polite\n",
)
.unwrap();
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
let (_returned_path, changed) =
remove_claude_md_guidance(dir.path()).unwrap().unwrap();
assert!(changed);
let content = std::fs::read_to_string(&path).unwrap();
assert!(!content.contains(CLAUDE_MD_BEGIN));
assert!(!content.contains(CLAUDE_MD_END));
assert!(
content.contains("My project rules"),
"user's rules must survive uninstall"
);
assert!(content.contains("Be polite"));
}
#[test]
fn remove_gives_back_the_user_text_byte_for_byte() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
let mine = "# My project rules\n\n- Always use 2-space indent\n- Be polite\n";
std::fs::write(&path, mine).unwrap();
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
assert!(claude_md_has_guidance(dir.path()));
remove_claude_md_guidance(dir.path()).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), mine);
assert!(!claude_md_has_guidance(dir.path()));
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
let after = "\n## After sqz\n\nKeep commits small.\n";
let mut content = std::fs::read_to_string(&path).unwrap();
content.push_str(after);
std::fs::write(&path, content).unwrap();
remove_claude_md_guidance(dir.path()).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{mine}{after}"));
}
fn old_block(sqz: &str) -> String {
format!(
"{CLAUDE_MD_BEGIN}\n\n## sqz\n\n- Shell: `{sqz} expand a1b2c3d4` (or paste the whole token\n `{sqz} expand §ref:a1b2c3d4§`).\n- To get uncompressed output for one command: prefix it with\n `SQZ_NO_DEDUP=1` (e.g. `SQZ_NO_DEDUP=1 git log | sqz compress`).\n\n{CLAUDE_MD_END}"
)
}
#[test]
fn an_old_block_is_stale_and_refreshed_in_place() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
let bin = dir.path().join("sqz");
std::fs::write(&bin, "").unwrap();
let bin = bin.to_string_lossy().to_string();
let (head, tail) = ("# Team\n\nPrefer small diffs.\n\n", "\n\n## After\n\nKeep me.\n");
std::fs::write(&path, format!("{head}{}{tail}", old_block(&bin))).unwrap();
assert_eq!(claude_md_guidance_state(dir.path()), SteeringState::Stale);
assert!(refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
let block = claude_md_guidance_block(&bin);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!("{head}{}{tail}", block.trim_end_matches('\n'))
);
assert_eq!(claude_md_guidance_state(dir.path()), SteeringState::Current);
assert!(!refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
std::fs::write(&path, format!("{head}{}{tail}", old_block("/gone/sqz"))).unwrap();
assert!(refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
assert!(std::fs::read_to_string(&path).unwrap().contains("`/fallback/sqz expand a1b2c3d4`"));
assert!(claude_md_block_outdated(dir.path(), "/other/sqz"));
assert!(install_claude_md_guidance(dir.path(), "/other/sqz").unwrap());
assert!(!claude_md_block_outdated(dir.path(), "/other/sqz"));
assert!(!install_claude_md_guidance(dir.path(), "/other/sqz").unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap().matches(CLAUDE_MD_BEGIN).count(), 1);
std::fs::write(&path, "# Only mine\n").unwrap();
assert_eq!(claude_md_guidance_state(dir.path()), SteeringState::UserAuthored);
assert!(!refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Only mine\n");
}
#[test]
fn remove_deletes_file_if_only_preamble_remains() {
let dir = TempDir::new().unwrap();
install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
let path = dir.path().join("CLAUDE.md");
assert!(path.exists());
remove_claude_md_guidance(dir.path()).unwrap();
assert!(!path.exists(), "pure-sqz CLAUDE.md should be deleted on uninstall");
}
#[test]
fn remove_is_noop_when_block_absent() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
std::fs::write(&path, "# User-only file\n").unwrap();
let (_, changed) = remove_claude_md_guidance(dir.path()).unwrap().unwrap();
assert!(!changed);
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "# User-only file\n", "file must be untouched");
}
#[test]
fn install_mcp_creates_new_config() {
let dir = TempDir::new().unwrap();
let changed = install_claude_mcp_config_at(Some(dir.path())).unwrap();
assert!(changed);
let path = dir.path().join(".claude.json");
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
assert_eq!(
json["mcpServers"]["sqz"]["args"],
serde_json::json!(["--transport", "stdio"])
);
assert_eq!(json["mcpServers"]["sqz"][SQZ_MCP_SENTINEL_KEY], true);
}
#[test]
fn install_mcp_preserves_existing_servers() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".claude.json");
let existing = serde_json::json!({
"someOtherKey": "preserved",
"mcpServers": {
"dart-mcp-server": {
"command": "dart",
"args": ["mcp-server"],
"env": {}
}
}
});
std::fs::write(
&path,
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
install_claude_mcp_config_at(Some(dir.path())).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["mcpServers"]["dart-mcp-server"]["command"], "dart");
assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
assert_eq!(json["someOtherKey"], "preserved");
}
#[test]
fn install_mcp_is_idempotent() {
let dir = TempDir::new().unwrap();
install_claude_mcp_config_at(Some(dir.path())).unwrap();
let second = install_claude_mcp_config_at(Some(dir.path())).unwrap();
assert!(!second, "second install must be a no-op");
}
#[test]
fn install_mcp_preserves_user_customised_entry() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".claude.json");
let user_config = serde_json::json!({
"mcpServers": {
"sqz": {
"command": "/custom/path/sqz-mcp",
"args": ["--verbose"],
"env": { "SQZ_PRESET": "aggressive" }
}
}
});
std::fs::write(
&path,
serde_json::to_string_pretty(&user_config).unwrap(),
)
.unwrap();
let changed = install_claude_mcp_config_at(Some(dir.path())).unwrap();
assert!(!changed, "must not overwrite user-customised entry");
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
json["mcpServers"]["sqz"]["command"],
"/custom/path/sqz-mcp"
);
assert_eq!(
json["mcpServers"]["sqz"]["args"],
serde_json::json!(["--verbose"])
);
}
#[test]
fn remove_mcp_only_removes_sqz_managed_entry() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".claude.json");
let user_config = serde_json::json!({
"mcpServers": {
"sqz": {
"command": "/custom/path/sqz-mcp",
"args": ["--verbose"]
}
}
});
std::fs::write(
&path,
serde_json::to_string_pretty(&user_config).unwrap(),
)
.unwrap();
let (_, changed) =
remove_claude_mcp_config_at(Some(dir.path())).unwrap().unwrap();
assert!(!changed, "must not remove user-customised entry");
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
json["mcpServers"]["sqz"]["command"],
"/custom/path/sqz-mcp",
"user's custom entry must survive uninstall"
);
}
#[test]
fn remove_mcp_removes_managed_entry_and_preserves_others() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".claude.json");
let existing = serde_json::json!({
"mcpServers": {
"dart-mcp-server": {
"command": "dart",
"args": ["mcp-server"]
}
}
});
std::fs::write(
&path,
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
install_claude_mcp_config_at(Some(dir.path())).unwrap();
let (_, changed) =
remove_claude_mcp_config_at(Some(dir.path())).unwrap().unwrap();
assert!(changed);
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
json["mcpServers"].get("sqz").is_none(),
"sqz entry must be removed"
);
assert_eq!(
json["mcpServers"]["dart-mcp-server"]["command"], "dart",
"other MCP servers must survive"
);
}
#[test]
fn install_mcp_handles_empty_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".claude.json");
std::fs::write(&path, "").unwrap();
let changed = install_claude_mcp_config_at(Some(dir.path())).unwrap();
assert!(changed);
let content = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
}
#[test]
fn install_mcp_rejects_non_object_root() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(".claude.json");
std::fs::write(&path, r#"["not", "an", "object"]"#).unwrap();
let result = install_claude_mcp_config_at(Some(dir.path()));
assert!(result.is_err(), "array root must be rejected — corrupted config");
}
}