Skip to main content

sqz_engine/
claude_md_integration.rs

1//! Claude Code `CLAUDE.md` prompt-level guidance installer.
2//!
3//! The PreToolUse hook (`.claude/settings.local.json`) only intercepts
4//! `Bash`-tool commands. Claude Code's built-in `Read`/`Grep`/`Glob`
5//! tools bypass shell hooks entirely — confirmed in
6//! github.com/anthropics/claude-code/issues/4544. That means a heavy
7//! session (file reads, searches, directory listings) can pass through
8//! Claude Code without sqz ever seeing a single byte.
9//!
10//! Reported in issue #12 by @JCKodel:
11//!
12//! > Running claude on a long session don't show anything in the
13//! > dashboard […] In practice, both Cursor and claude cli shows 0.
14//!
15//! This module installs prompt-level guidance into the project's
16//! `CLAUDE.md` file. Claude Code reads `CLAUDE.md` at session start,
17//! so the agent sees the instructions on every turn.
18//!
19//! The guidance block:
20//!   * recommends `sqz_read_file` / `sqz_grep` / `sqz_list_dir` MCP
21//!     tools (registered via `sqz init`'s MCP config merge) for any
22//!     file I/O larger than ~2KB or that might be repeated;
23//!   * keeps the built-in `Read` available for tiny configs and
24//!     byte-exact needs (lockfiles, signatures);
25//!   * documents the `§ref:HASH§` escape hatch so the agent doesn't
26//!     thrash on a token it can't parse.
27//!
28//! Same install/uninstall semantics as
29//! `codex_integration::install_agents_md_guidance`:
30//!   * If `CLAUDE.md` doesn't exist → create it with sqz's block.
31//!   * If it exists without the sqz block → append with a blank-line
32//!     separator.
33//!   * If it exists with an outdated sqz block → replace the block in
34//!     place; with the current one → no-op (idempotent).
35//!
36//! The block is wrapped in HTML comment sentinels so `uninstall` can
37//! excise it byte-exact.
38
39use std::path::{Path, PathBuf};
40
41use crate::error::{Result, SqzError};
42use crate::kiro_integration::SteeringState;
43
44// ── Sentinels (must match `remove_claude_md_guidance` byte-exact) ────────
45
46const CLAUDE_MD_BEGIN: &str =
47    "<!-- BEGIN sqz-claude-guidance (auto-installed by sqz init; remove this block to disable) -->";
48const CLAUDE_MD_END: &str = "<!-- END sqz-claude-guidance -->";
49
50/// Compute the path where `CLAUDE.md` lives for the given project.
51///
52/// Claude Code reads `CLAUDE.md` from the project root (not from
53/// `.claude/`). This matches Anthropic's documented convention
54/// (docs.anthropic.com/en/docs/claude-code/memory).
55pub fn claude_md_path(project_dir: &Path) -> PathBuf {
56    project_dir.join("CLAUDE.md")
57}
58
59/// Compute the path of the user-level `~/.claude.json` config.
60///
61/// This is where Claude Code stores its `mcpServers` map. Registering
62/// sqz-mcp here makes it available to every Claude Code session on
63/// this machine. Matches what @JCKodel had to configure manually in
64/// issue #12 — sqz init should do this for them.
65///
66/// Returns `None` if the home directory cannot be resolved (very
67/// unusual — typically only in sandboxes or broken environments).
68pub fn claude_user_json_path() -> Option<PathBuf> {
69    // Honour $HOME (standard on Unix, set explicitly on Windows when
70    // Claude Code is installed in the user-scope convention).
71    let home = std::env::var_os("HOME")
72        .or_else(|| std::env::var_os("USERPROFILE"))?;
73    Some(PathBuf::from(home).join(".claude.json"))
74}
75
76/// Build the markdown block that sqz appends to `CLAUDE.md`.
77///
78/// `sqz_path` is the absolute path of the sqz binary; we use it
79/// verbatim in the markdown fence blocks so Windows backslash paths
80/// render correctly and the user can copy-paste commands unchanged.
81pub fn claude_md_guidance_block(sqz_path: &str) -> String {
82    format!(
83        "\
84{begin}
85
86## sqz — Context Compression (READ FIRST)
87
88sqz is installed in this project. It compresses tool output so large
89files, long logs, and verbose command output cost far fewer tokens.
90There are **two ways** sqz is wired in, and you should prefer each
91one in the situations below.
92
93### Preferred tools (MCP)
94
95The `sqz-mcp` server is registered in this project's MCP config. It
96exposes three read-only tools that compress their output through the
97sqz pipeline:
98
99- **`sqz_read_file`** — read a file from disk and return a compressed
100  view. **PREFER this over the built-in `Read` tool** for any file
101  larger than ~2KB or any file you might read more than once in the
102  same session, **including ranged reads** (`offset` / `limit`, same
103  meaning as the built-in Read). Repeat reads return a 13-token
104  `§ref:HASH§` reference instead of the full content; a ranged re-read
105  of a file you already have in full returns `§ref:HASH:L40-80§`,
106  meaning lines 40-80 of that content. Call `expand` on either token
107  if you need the bytes again.
108
109- **`sqz_grep`** — search files for a literal string or regex.
110  **PREFER this over the built-in `Grep`** for anything that might
111  match more than a handful of lines. Caps at 200 matches by default;
112  raise with `max_matches` if needed.
113
114- **`sqz_list_dir`** — list a directory. Skips `.git`, `node_modules`,
115  `target`, `dist`, `build`, `vendor`, `__pycache__` so the output
116  stays focused. **PREFER this over `ls -la` via Bash** when you want
117  to see a project layout.
118
119- **`sqz_recall`** — full-text search over everything sqz has
120  compressed in past sessions. Use it to recover context lost to
121  compaction (an error message, a file you read yesterday).
122
123The built-in `Read`, `Grep`, `Glob` tools remain available. Use them for:
124- Tiny config files (<1KB) where compression can't help.
125- Byte-exact reads you'll hash or diff (lockfiles, signatures).
126- Globbing (sqz has no glob tool; `Glob` is still the right choice).
127
128### Bash commands (hooked automatically)
129
130When you run a shell command through the `Bash` tool, a PreToolUse hook
131gives each command in it a pipe through `sqz compress`, under
132`set -o pipefail` so exit status and `&&`/`||` behave as before. This is
133transparent: you don't need to add anything. Builtins like `cd` and
134`export` stay outside the pipes, and the permission rules you already
135have still apply, since sqz never approves a command for you.
136
137A pipeline, loop, subshell or heredoc is left as written (outside bypass
138mode its grouped form couldn't match your allow rules); when it exits 0,
139a PostToolUse hook compresses its output instead. Background jobs and
140`run_in_background` calls, followers and servers (`tail -f`,
141`npm run dev`), editors and REPLs, and anything already going through
142sqz are never rewritten.
143
144### Escape hatch — when you see a `§ref:HASH§` token
145
146If tool output contains a `§ref:a1b2c3d4§` token and you need the full
147content it points at, resolve it. Three equivalent ways:
148
149- Shell: `{sqz_path} expand a1b2c3d4` (or paste the whole token
150  `{sqz_path} expand §ref:a1b2c3d4§`).
151- MCP tool: call `expand` with `{{ \"prefix\": \"a1b2c3d4\" }}`.
152- To get uncompressed output for one command: put `SQZ_NO_DEDUP=1`
153  right before `sqz compress` (e.g. `git log | SQZ_NO_DEDUP=1 sqz compress`).
154
155If you see a `«A1»` abbreviation symbol where a SHA, path, or URL
156should be, n-gram phrase abbreviation collapsed a repeated phrase and
157kept only its first occurrence. Disable it for that command with
158`SQZ_NO_ABBREV=1` (or the `--no-abbrev` flag):
159`git log | SQZ_NO_ABBREV=1 sqz compress`.
160
161If the compressed output is actively making the task harder (looping
162on refs, small retries replacing one big read), call the `passthrough`
163MCP tool to get raw text.
164
165### When NOT to use sqz tools
166
167- Writing or editing files — use the built-in `Write`/`Edit` tools.
168  sqz has no write tools (by design; see issue #5 follow-up).
169- Running commands interactively or in watch mode.
170- Reading very small files (<1KB) where compression can't help.
171
172{end}
173",
174        begin = CLAUDE_MD_BEGIN,
175        end = CLAUDE_MD_END,
176    )
177}
178
179/// Return `true` if the given `CLAUDE.md` content already contains sqz's
180/// guidance block (matched by the BEGIN sentinel).
181fn claude_md_has_sqz_block(content: &str) -> bool {
182    content.contains(CLAUDE_MD_BEGIN)
183}
184
185/// Whether `sqz init` would replace the sqz block in `CLAUDE.md` for `sqz_path`.
186pub fn claude_md_block_outdated(project_dir: &Path, sqz_path: &str) -> bool {
187    std::fs::read_to_string(claude_md_path(project_dir)).is_ok_and(|content| {
188        claude_md_block_range(&content).is_some_and(|(start, end)| {
189            content[start..end].replace("\r\n", "\n") != claude_md_guidance_block(sqz_path).trim_end_matches('\n')
190        })
191    })
192}
193
194/// Whether the project's `CLAUDE.md` holds a sqz block uninstall can remove.
195pub fn claude_md_has_guidance(project_dir: &Path) -> bool {
196    std::fs::read_to_string(claude_md_path(project_dir))
197        .is_ok_and(|s| claude_md_block_range(&s).is_some())
198}
199
200/// The byte range of the sqz block in `CLAUDE.md` content, END marker included.
201fn claude_md_block_range(content: &str) -> Option<(usize, usize)> {
202    let start = content.find(CLAUDE_MD_BEGIN)?;
203    let end = start + content[start..].find(CLAUDE_MD_END)? + CLAUDE_MD_END.len();
204    Some((start, end))
205}
206
207/// The sqz binary a block was written for, read back from its `expand` example.
208fn claude_md_block_sqz_path(block: &str) -> Option<&str> {
209    block.lines().find_map(|line| {
210        line.strip_prefix("- Shell: `")?
211            .split_once(" expand a1b2c3d4`")
212            .map(|(path, _)| path)
213    })
214}
215
216/// Compare the `CLAUDE.md` block against what this version writes for the same binary.
217pub fn claude_md_guidance_state(project_dir: &Path) -> SteeringState {
218    let Ok(content) = std::fs::read_to_string(claude_md_path(project_dir)) else {
219        return SteeringState::Missing;
220    };
221    let Some((start, end)) = claude_md_block_range(&content) else {
222        return SteeringState::UserAuthored;
223    };
224    let block = &content[start..end];
225    match claude_md_block_sqz_path(block) {
226        Some(p) if block.replace("\r\n", "\n") == claude_md_guidance_block(p).trim_end_matches('\n') => {
227            SteeringState::Current
228        }
229        _ => SteeringState::Stale,
230    }
231}
232
233/// Rewrite a stale `CLAUDE.md` block in place, keeping its sqz binary while that resolves.
234pub fn refresh_claude_md_guidance(project_dir: &Path, fallback_sqz_path: &str) -> Result<bool> {
235    if claude_md_guidance_state(project_dir) != SteeringState::Stale {
236        return Ok(false);
237    }
238    let path = claude_md_path(project_dir);
239    let content = std::fs::read_to_string(&path)
240        .map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
241    let sqz_path = claude_md_block_range(&content)
242        .and_then(|(start, end)| claude_md_block_sqz_path(&content[start..end]))
243        .filter(|p| crate::tool_hooks::resolve_sqz_binary(p).is_some())
244        .unwrap_or(fallback_sqz_path)
245        .to_string();
246    write_claude_md_block(&path, &content, &sqz_path)
247}
248
249/// Swap the sqz block in `content` for this version's; the text around it keeps its bytes.
250fn write_claude_md_block(path: &Path, content: &str, sqz_path: &str) -> Result<bool> {
251    let Some((start, end)) = claude_md_block_range(content) else {
252        return Ok(false);
253    };
254    let block = claude_md_guidance_block(sqz_path);
255    if content[start..end].replace("\r\n", "\n") == block.trim_end_matches('\n') {
256        return Ok(false);
257    }
258    let updated = format!("{}{}{}", &content[..start], block.trim_end_matches('\n'), &content[end..]);
259    std::fs::write(path, updated)
260        .map_err(|e| SqzError::Other(format!("failed to write {}: {e}", path.display())))?;
261    Ok(true)
262}
263
264/// Install sqz's guidance block into `CLAUDE.md` at `project_dir`.
265///
266/// If `CLAUDE.md` doesn't exist yet, create it with a minimal preamble
267/// and sqz's block. If it exists, append sqz's block (separated by a
268/// blank line so it renders as a new markdown section). If the block
269/// is already present (detected by the BEGIN sentinel), it is replaced
270/// in place when it differs from this version's block for `sqz_path`,
271/// and the file is left alone otherwise, so `sqz init` stays idempotent.
272///
273/// Returns `true` when the file was created or modified, `false` when
274/// sqz's current block was already present.
275pub fn install_claude_md_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
276    let path = claude_md_path(project_dir);
277    let block = claude_md_guidance_block(sqz_path);
278
279    if path.exists() {
280        let existing = std::fs::read_to_string(&path).map_err(|e| {
281            SqzError::Other(format!("failed to read {}: {e}", path.display()))
282        })?;
283        if claude_md_has_sqz_block(&existing) {
284            return write_claude_md_block(&path, &existing, sqz_path);
285        }
286        // Append with a guaranteed blank-line separator so sqz's
287        // section doesn't accidentally fuse with a trailing user
288        // section.
289        let mut new_content = existing;
290        if !new_content.ends_with('\n') {
291            new_content.push('\n');
292        }
293        if !new_content.ends_with("\n\n") {
294            new_content.push('\n');
295        }
296        new_content.push_str(&block);
297        std::fs::write(&path, new_content).map_err(|e| {
298            SqzError::Other(format!("failed to write {}: {e}", path.display()))
299        })?;
300        return Ok(true);
301    }
302
303    // Fresh CLAUDE.md — add a tiny preamble so the file is
304    // self-explanatory to users who encounter it for the first time.
305    let mut content = String::from(
306        "# CLAUDE.md\n\
307         \n\
308         Project-level instructions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).\n\
309         \n",
310    );
311    content.push_str(&block);
312    std::fs::write(&path, content).map_err(|e| {
313        SqzError::Other(format!("failed to write {}: {e}", path.display()))
314    })?;
315    Ok(true)
316}
317
318/// Remove sqz's guidance block from `CLAUDE.md` if present.
319///
320/// Locates the block by its BEGIN/END sentinels and excises the
321/// entire range including one preceding blank line (if any) so the
322/// remaining file reads cleanly. If `CLAUDE.md` becomes empty (or
323/// contains only the sqz preamble) after removal, deletes the file.
324///
325/// Returns the path plus a flag indicating whether anything changed.
326/// Missing file or missing block → `(path, false)`.
327pub fn remove_claude_md_guidance(
328    project_dir: &Path,
329) -> Result<Option<(PathBuf, bool)>> {
330    let path = claude_md_path(project_dir);
331    if !path.exists() {
332        return Ok(None);
333    }
334
335    let content = std::fs::read_to_string(&path).map_err(|e| {
336        SqzError::Other(format!("failed to read {}: {e}", path.display()))
337    })?;
338
339    let begin_idx = match content.find(CLAUDE_MD_BEGIN) {
340        Some(i) => i,
341        None => return Ok(Some((path, false))),
342    };
343    let after_end_idx = match content[begin_idx..].find(CLAUDE_MD_END) {
344        Some(i) => begin_idx + i + CLAUDE_MD_END.len(),
345        None => {
346            // BEGIN without END — file was edited by hand. Leave it
347            // alone rather than risk mutilating user content.
348            return Ok(Some((path, false)));
349        }
350    };
351
352    // Same cut as the AGENTS.md block: the separator install added goes too.
353    let new_content = crate::codex_integration::excise_range(&content, begin_idx, after_end_idx);
354
355    // If nothing meaningful remains (just our preamble or whitespace),
356    // delete the file — don't leave an empty marker behind.
357    let remaining_trimmed = new_content.trim();
358    let is_only_preamble = remaining_trimmed
359        == "# CLAUDE.md\n\n\
360            Project-level instructions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code)."
361            .trim()
362        || remaining_trimmed.is_empty();
363
364    if is_only_preamble {
365        std::fs::remove_file(&path).map_err(|e| {
366            SqzError::Other(format!("failed to delete {}: {e}", path.display()))
367        })?;
368    } else {
369        std::fs::write(&path, new_content).map_err(|e| {
370            SqzError::Other(format!("failed to write {}: {e}", path.display()))
371        })?;
372    }
373
374    Ok(Some((path, true)))
375}
376
377// ── MCP server registration in ~/.claude.json ────────────────────────────
378//
379// Claude Code reads its `mcpServers` map from `~/.claude.json`. Adding
380// sqz-mcp here makes all three new tools (`sqz_read_file`, `sqz_grep`,
381// `sqz_list_dir`) plus `compress`/`passthrough`/`expand` available in
382// every Claude Code session on this machine.
383//
384// Reported in issue #12: @JCKodel had to add this entry by hand. `sqz
385// init` should do it automatically so the average user doesn't have to
386// know the config path or the exact JSON shape.
387
388/// JSON sentinel we stamp into our `mcpServers.sqz` entry so we can
389/// distinguish sqz-installed entries from user-edited ones on upgrade
390/// or uninstall.
391const SQZ_MCP_SENTINEL_KEY: &str = "_sqz_managed";
392
393/// Register sqz-mcp as an MCP server in `~/.claude.json`.
394///
395/// Idempotent: if an `sqz` entry already exists and points at
396/// `sqz-mcp --transport stdio`, we leave it alone and return
397/// `Ok(false)`. If the entry exists but diverges (user edited the
398/// command args), we also leave it alone — the user's customisation
399/// wins. Only fresh installs write a new entry.
400///
401/// Returns:
402///   * `Ok(true)` when a new entry was added.
403///   * `Ok(false)` when an entry already existed (either sqz-managed
404///     or user-customised).
405///   * `Err(_)` on JSON parse error or write failure.
406pub fn install_claude_mcp_config() -> Result<bool> {
407    install_claude_mcp_config_at(None)
408}
409
410/// Internal: accept a home-dir override so tests can point at a tempdir
411/// without touching `env::set_var("HOME")` — that mutates process-wide
412/// state and races with parallel tests that also read HOME (e.g. the
413/// api_proxy property tests that open `~/.sqz/sessions.db`).
414pub(crate) fn install_claude_mcp_config_at(home_override: Option<&Path>) -> Result<bool> {
415    let path = match home_override {
416        Some(h) => h.join(".claude.json"),
417        None => claude_user_json_path().ok_or_else(|| {
418            SqzError::Other(
419                "cannot resolve $HOME — ~/.claude.json location unknown".to_string(),
420            )
421        })?,
422    };
423
424    // Parent directory for new installs. On most systems $HOME already
425    // exists; this is a safety net for sandboxed test environments.
426    if let Some(parent) = path.parent() {
427        if !parent.exists() {
428            let _ = std::fs::create_dir_all(parent);
429        }
430    }
431
432    let text = if path.exists() {
433        std::fs::read_to_string(&path).map_err(|e| {
434            SqzError::Other(format!("failed to read {}: {e}", path.display()))
435        })?
436    } else {
437        String::new()
438    };
439    let mut root: serde_json::Value = if text.trim().is_empty() {
440        serde_json::json!({})
441    } else {
442        serde_json::from_str(&text).map_err(|e| {
443            SqzError::Other(format!(
444                "~/.claude.json exists but is not valid JSON: {e}"
445            ))
446        })?
447    };
448
449    // Ensure root is an object (Claude Code's config is always an
450    // object; anything else is user corruption we shouldn't rewrite).
451    let root_obj = root
452        .as_object_mut()
453        .ok_or_else(|| SqzError::Other(
454            "~/.claude.json root must be a JSON object".to_string(),
455        ))?;
456
457    // Ensure mcpServers is an object.
458    let mcp = root_obj
459        .entry("mcpServers".to_string())
460        .or_insert_with(|| serde_json::json!({}));
461    if !mcp.is_object() {
462        *mcp = serde_json::json!({});
463    }
464    let mcp_obj = mcp
465        .as_object_mut()
466        .expect("just ensured mcp is an object");
467
468    // Already present? Don't overwrite — the user may have customised
469    // the command or args. Respect that by leaving the entry alone
470    // regardless of whether it matches our defaults.
471    if mcp_obj.contains_key("sqz") {
472        return Ok(false);
473    }
474
475    mcp_obj.insert(
476        "sqz".to_string(),
477        serde_json::json!({
478            "command": "sqz-mcp",
479            "args": ["--transport", "stdio"],
480            SQZ_MCP_SENTINEL_KEY: true
481        }),
482    );
483
484    // Write back with a two-space indent (matches Claude Code's own
485    // formatting so diffs stay clean).
486    let mut out = serde_json::to_string_pretty(&root).map_err(|e| {
487        SqzError::Other(format!("failed to serialize ~/.claude.json: {e}"))
488    })?;
489    if text.ends_with('\n') {
490        out.push('\n');
491    }
492    std::fs::write(&path, out).map_err(|e| {
493        SqzError::Other(format!("failed to write {}: {e}", path.display()))
494    })?;
495    Ok(true)
496}
497
498/// Remove sqz's `mcpServers.sqz` entry from `~/.claude.json` if we
499/// installed it (detected by the `_sqz_managed` sentinel). Leaves
500/// user-customised entries alone.
501///
502/// Returns `Ok(Some((path, true)))` when the entry was removed,
503/// `Ok(Some((path, false)))` when present but not managed by sqz,
504/// `Ok(None)` when the file doesn't exist, `Err(_)` on read/write
505/// failure.
506pub fn remove_claude_mcp_config() -> Result<Option<(PathBuf, bool)>> {
507    remove_claude_mcp_config_at(None)
508}
509
510/// Whether `~/.claude.json` holds the `mcpServers.sqz` entry sqz installed.
511pub fn claude_mcp_config_managed() -> bool {
512    claude_user_json_path()
513        .and_then(|p| std::fs::read_to_string(p).ok())
514        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
515        .and_then(|v| v.pointer(&format!("/mcpServers/sqz/{SQZ_MCP_SENTINEL_KEY}"))?.as_bool())
516        .unwrap_or(false)
517}
518
519/// Internal: home-dir-injectable counterpart used by tests. See
520/// `install_claude_mcp_config_at` for rationale.
521pub(crate) fn remove_claude_mcp_config_at(
522    home_override: Option<&Path>,
523) -> Result<Option<(PathBuf, bool)>> {
524    let path = match home_override {
525        Some(h) => h.join(".claude.json"),
526        None => match claude_user_json_path() {
527            Some(p) => p,
528            None => return Ok(None),
529        },
530    };
531    if !path.exists() {
532        return Ok(None);
533    }
534
535    let text = std::fs::read_to_string(&path).map_err(|e| {
536        SqzError::Other(format!("failed to read {}: {e}", path.display()))
537    })?;
538    let mut root: serde_json::Value = match serde_json::from_str(&text) {
539        Ok(v) => v,
540        Err(_) => {
541            // Corrupted/non-JSON file — don't touch it.
542            return Ok(Some((path, false)));
543        }
544    };
545
546    let changed = {
547        let Some(root_obj) = root.as_object_mut() else {
548            return Ok(Some((path, false)));
549        };
550        let Some(mcp) = root_obj.get_mut("mcpServers").and_then(|v| v.as_object_mut())
551        else {
552            return Ok(Some((path, false)));
553        };
554        let is_managed = mcp
555            .get("sqz")
556            .and_then(|v| v.get(SQZ_MCP_SENTINEL_KEY))
557            .and_then(|v| v.as_bool())
558            .unwrap_or(false);
559        if !is_managed {
560            return Ok(Some((path, false)));
561        }
562        let removed = mcp.remove("sqz").is_some();
563        if mcp.is_empty() {
564            root_obj.remove("mcpServers");
565        }
566        removed
567    };
568
569    if changed {
570        let mut out = serde_json::to_string_pretty(&root).map_err(|e| {
571            SqzError::Other(format!("failed to serialize ~/.claude.json: {e}"))
572        })?;
573        if text.ends_with('\n') {
574            out.push('\n');
575        }
576        std::fs::write(&path, out).map_err(|e| {
577            SqzError::Other(format!("failed to write {}: {e}", path.display()))
578        })?;
579    }
580
581    Ok(Some((path, changed)))
582}
583
584// ── Tests ─────────────────────────────────────────────────────────────────
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use tempfile::TempDir;
590
591    #[test]
592    fn guidance_block_contains_tool_recommendations() {
593        let block = claude_md_guidance_block("/usr/local/bin/sqz");
594        assert!(block.contains(CLAUDE_MD_BEGIN));
595        assert!(block.contains(CLAUDE_MD_END));
596        // The agent must see explicit "PREFER this over Read" instructions
597        // for each of the three new MCP tools.
598        assert!(
599            block.contains("PREFER this over the built-in `Read` tool"),
600            "guidance must tell the agent when to prefer sqz_read_file"
601        );
602        assert!(block.contains("sqz_read_file"));
603        assert!(block.contains("sqz_grep"));
604        assert!(block.contains("sqz_list_dir"));
605        // Escape hatch must be documented so the agent doesn't thrash
606        // on §ref§ tokens.
607        assert!(block.contains("§ref:"));
608        assert!(block.contains("/usr/local/bin/sqz expand"));
609    }
610
611    #[test]
612    fn install_creates_new_claude_md() {
613        let dir = TempDir::new().unwrap();
614        let changed =
615            install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
616        assert!(changed);
617        let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
618        assert!(content.starts_with("# CLAUDE.md"));
619        assert!(content.contains(CLAUDE_MD_BEGIN));
620        assert!(content.contains(CLAUDE_MD_END));
621    }
622
623    #[test]
624    fn install_appends_to_existing_claude_md() {
625        let dir = TempDir::new().unwrap();
626        let path = dir.path().join("CLAUDE.md");
627        std::fs::write(
628            &path,
629            "# My project rules\n\n- Always use 2-space indent\n- Be polite\n",
630        )
631        .unwrap();
632
633        let changed =
634            install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
635        assert!(changed);
636
637        let content = std::fs::read_to_string(&path).unwrap();
638        // User's original content must survive unchanged.
639        assert!(content.contains("My project rules"));
640        assert!(content.contains("Be polite"));
641        // sqz's block must come AFTER the user's content.
642        let user_idx = content.find("Be polite").unwrap();
643        let sqz_idx = content.find(CLAUDE_MD_BEGIN).unwrap();
644        assert!(
645            sqz_idx > user_idx,
646            "sqz block must append after existing content, not prepend"
647        );
648    }
649
650    #[test]
651    fn install_is_idempotent() {
652        let dir = TempDir::new().unwrap();
653        install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
654        let second = install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz")
655            .unwrap();
656        assert!(!second, "second install must be a no-op");
657
658        let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
659        let occurrences = content.matches(CLAUDE_MD_BEGIN).count();
660        assert_eq!(
661            occurrences, 1,
662            "re-running sqz init must not duplicate the block"
663        );
664    }
665
666    #[test]
667    fn remove_returns_none_when_file_missing() {
668        let dir = TempDir::new().unwrap();
669        assert!(remove_claude_md_guidance(dir.path()).unwrap().is_none());
670    }
671
672    #[test]
673    fn remove_excises_block_and_preserves_user_content() {
674        let dir = TempDir::new().unwrap();
675        let path = dir.path().join("CLAUDE.md");
676        std::fs::write(
677            &path,
678            "# My project rules\n\n- Always use 2-space indent\n- Be polite\n",
679        )
680        .unwrap();
681
682        install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
683        let (_returned_path, changed) =
684            remove_claude_md_guidance(dir.path()).unwrap().unwrap();
685        assert!(changed);
686
687        let content = std::fs::read_to_string(&path).unwrap();
688        assert!(!content.contains(CLAUDE_MD_BEGIN));
689        assert!(!content.contains(CLAUDE_MD_END));
690        assert!(
691            content.contains("My project rules"),
692            "user's rules must survive uninstall"
693        );
694        assert!(content.contains("Be polite"));
695    }
696
697    #[test]
698    fn remove_gives_back_the_user_text_byte_for_byte() {
699        let dir = TempDir::new().unwrap();
700        let path = dir.path().join("CLAUDE.md");
701        let mine = "# My project rules\n\n- Always use 2-space indent\n- Be polite\n";
702        std::fs::write(&path, mine).unwrap();
703        install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
704        assert!(claude_md_has_guidance(dir.path()));
705        remove_claude_md_guidance(dir.path()).unwrap();
706        assert_eq!(std::fs::read_to_string(&path).unwrap(), mine);
707        assert!(!claude_md_has_guidance(dir.path()));
708
709        install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
710        let after = "\n## After sqz\n\nKeep commits small.\n";
711        let mut content = std::fs::read_to_string(&path).unwrap();
712        content.push_str(after);
713        std::fs::write(&path, content).unwrap();
714        remove_claude_md_guidance(dir.path()).unwrap();
715        assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{mine}{after}"));
716    }
717
718    fn old_block(sqz: &str) -> String {
719        format!(
720            "{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}"
721        )
722    }
723
724    #[test]
725    fn an_old_block_is_stale_and_refreshed_in_place() {
726        let dir = TempDir::new().unwrap();
727        let path = dir.path().join("CLAUDE.md");
728        let bin = dir.path().join("sqz");
729        std::fs::write(&bin, "").unwrap();
730        let bin = bin.to_string_lossy().to_string();
731        let (head, tail) = ("# Team\n\nPrefer small diffs.\n\n", "\n\n## After\n\nKeep me.\n");
732        std::fs::write(&path, format!("{head}{}{tail}", old_block(&bin))).unwrap();
733
734        assert_eq!(claude_md_guidance_state(dir.path()), SteeringState::Stale);
735        assert!(refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
736        let block = claude_md_guidance_block(&bin);
737        assert_eq!(
738            std::fs::read_to_string(&path).unwrap(),
739            format!("{head}{}{tail}", block.trim_end_matches('\n'))
740        );
741        assert_eq!(claude_md_guidance_state(dir.path()), SteeringState::Current);
742        assert!(!refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
743
744        std::fs::write(&path, format!("{head}{}{tail}", old_block("/gone/sqz"))).unwrap();
745        assert!(refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
746        assert!(std::fs::read_to_string(&path).unwrap().contains("`/fallback/sqz expand a1b2c3d4`"));
747
748        assert!(claude_md_block_outdated(dir.path(), "/other/sqz"));
749        assert!(install_claude_md_guidance(dir.path(), "/other/sqz").unwrap());
750        assert!(!claude_md_block_outdated(dir.path(), "/other/sqz"));
751        assert!(!install_claude_md_guidance(dir.path(), "/other/sqz").unwrap());
752        assert_eq!(std::fs::read_to_string(&path).unwrap().matches(CLAUDE_MD_BEGIN).count(), 1);
753
754        std::fs::write(&path, "# Only mine\n").unwrap();
755        assert_eq!(claude_md_guidance_state(dir.path()), SteeringState::UserAuthored);
756        assert!(!refresh_claude_md_guidance(dir.path(), "/fallback/sqz").unwrap());
757        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Only mine\n");
758    }
759
760    #[test]
761    fn remove_deletes_file_if_only_preamble_remains() {
762        // Pure sqz install (no user content): uninstall should delete
763        // the file rather than leave an empty preamble behind.
764        let dir = TempDir::new().unwrap();
765        install_claude_md_guidance(dir.path(), "/usr/local/bin/sqz").unwrap();
766        let path = dir.path().join("CLAUDE.md");
767        assert!(path.exists());
768
769        remove_claude_md_guidance(dir.path()).unwrap();
770        assert!(!path.exists(), "pure-sqz CLAUDE.md should be deleted on uninstall");
771    }
772
773    #[test]
774    fn remove_is_noop_when_block_absent() {
775        let dir = TempDir::new().unwrap();
776        let path = dir.path().join("CLAUDE.md");
777        std::fs::write(&path, "# User-only file\n").unwrap();
778
779        let (_, changed) = remove_claude_md_guidance(dir.path()).unwrap().unwrap();
780        assert!(!changed);
781
782        let content = std::fs::read_to_string(&path).unwrap();
783        assert_eq!(content, "# User-only file\n", "file must be untouched");
784    }
785
786    // ── MCP registration tests ─────────────────────────────────────────
787    //
788    // These tests use the `_at` variants to inject a tempdir without
789    // touching $HOME. The public functions read $HOME directly, but we
790    // avoid that in tests because env::set_var is process-wide and
791    // races with parallel tests that also read HOME (e.g. the api_proxy
792    // property tests open `~/.sqz/sessions.db`).
793
794    #[test]
795    fn install_mcp_creates_new_config() {
796        let dir = TempDir::new().unwrap();
797        let changed = install_claude_mcp_config_at(Some(dir.path())).unwrap();
798        assert!(changed);
799
800        let path = dir.path().join(".claude.json");
801        let content = std::fs::read_to_string(&path).unwrap();
802        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
803        assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
804        assert_eq!(
805            json["mcpServers"]["sqz"]["args"],
806            serde_json::json!(["--transport", "stdio"])
807        );
808        // Sentinel so uninstall can distinguish sqz-managed from
809        // user-edited entries.
810        assert_eq!(json["mcpServers"]["sqz"][SQZ_MCP_SENTINEL_KEY], true);
811    }
812
813    #[test]
814    fn install_mcp_preserves_existing_servers() {
815        let dir = TempDir::new().unwrap();
816        let path = dir.path().join(".claude.json");
817        let existing = serde_json::json!({
818            "someOtherKey": "preserved",
819            "mcpServers": {
820                "dart-mcp-server": {
821                    "command": "dart",
822                    "args": ["mcp-server"],
823                    "env": {}
824                }
825            }
826        });
827        std::fs::write(
828            &path,
829            serde_json::to_string_pretty(&existing).unwrap(),
830        )
831        .unwrap();
832
833        install_claude_mcp_config_at(Some(dir.path())).unwrap();
834
835        let content = std::fs::read_to_string(&path).unwrap();
836        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
837        // User's existing MCP server survives.
838        assert_eq!(json["mcpServers"]["dart-mcp-server"]["command"], "dart");
839        // sqz was added alongside it.
840        assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
841        // Top-level keys unrelated to MCP also survive.
842        assert_eq!(json["someOtherKey"], "preserved");
843    }
844
845    #[test]
846    fn install_mcp_is_idempotent() {
847        let dir = TempDir::new().unwrap();
848        install_claude_mcp_config_at(Some(dir.path())).unwrap();
849        let second = install_claude_mcp_config_at(Some(dir.path())).unwrap();
850        assert!(!second, "second install must be a no-op");
851    }
852
853    #[test]
854    fn install_mcp_preserves_user_customised_entry() {
855        // If the user has already configured sqz with their own
856        // command/args, we MUST NOT overwrite them. Respecting that
857        // is the difference between a helpful install and one that
858        // silently breaks user config.
859        let dir = TempDir::new().unwrap();
860        let path = dir.path().join(".claude.json");
861        let user_config = serde_json::json!({
862            "mcpServers": {
863                "sqz": {
864                    "command": "/custom/path/sqz-mcp",
865                    "args": ["--verbose"],
866                    "env": { "SQZ_PRESET": "aggressive" }
867                }
868            }
869        });
870        std::fs::write(
871            &path,
872            serde_json::to_string_pretty(&user_config).unwrap(),
873        )
874        .unwrap();
875
876        let changed = install_claude_mcp_config_at(Some(dir.path())).unwrap();
877        assert!(!changed, "must not overwrite user-customised entry");
878
879        let content = std::fs::read_to_string(&path).unwrap();
880        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
881        // User's custom command survives.
882        assert_eq!(
883            json["mcpServers"]["sqz"]["command"],
884            "/custom/path/sqz-mcp"
885        );
886        assert_eq!(
887            json["mcpServers"]["sqz"]["args"],
888            serde_json::json!(["--verbose"])
889        );
890    }
891
892    #[test]
893    fn remove_mcp_only_removes_sqz_managed_entry() {
894        // Complement to the test above: uninstall must leave
895        // user-customised entries alone too.
896        let dir = TempDir::new().unwrap();
897        let path = dir.path().join(".claude.json");
898        let user_config = serde_json::json!({
899            "mcpServers": {
900                "sqz": {
901                    "command": "/custom/path/sqz-mcp",
902                    "args": ["--verbose"]
903                }
904            }
905        });
906        std::fs::write(
907            &path,
908            serde_json::to_string_pretty(&user_config).unwrap(),
909        )
910        .unwrap();
911
912        let (_, changed) =
913            remove_claude_mcp_config_at(Some(dir.path())).unwrap().unwrap();
914        assert!(!changed, "must not remove user-customised entry");
915
916        let content = std::fs::read_to_string(&path).unwrap();
917        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
918        assert_eq!(
919            json["mcpServers"]["sqz"]["command"],
920            "/custom/path/sqz-mcp",
921            "user's custom entry must survive uninstall"
922        );
923    }
924
925    #[test]
926    fn remove_mcp_removes_managed_entry_and_preserves_others() {
927        let dir = TempDir::new().unwrap();
928        let path = dir.path().join(".claude.json");
929        let existing = serde_json::json!({
930            "mcpServers": {
931                "dart-mcp-server": {
932                    "command": "dart",
933                    "args": ["mcp-server"]
934                }
935            }
936        });
937        std::fs::write(
938            &path,
939            serde_json::to_string_pretty(&existing).unwrap(),
940        )
941        .unwrap();
942
943        install_claude_mcp_config_at(Some(dir.path())).unwrap();
944        let (_, changed) =
945            remove_claude_mcp_config_at(Some(dir.path())).unwrap().unwrap();
946        assert!(changed);
947
948        let content = std::fs::read_to_string(&path).unwrap();
949        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
950        assert!(
951            json["mcpServers"].get("sqz").is_none(),
952            "sqz entry must be removed"
953        );
954        assert_eq!(
955            json["mcpServers"]["dart-mcp-server"]["command"], "dart",
956            "other MCP servers must survive"
957        );
958    }
959
960    #[test]
961    fn install_mcp_handles_empty_file() {
962        let dir = TempDir::new().unwrap();
963        let path = dir.path().join(".claude.json");
964        std::fs::write(&path, "").unwrap();
965
966        let changed = install_claude_mcp_config_at(Some(dir.path())).unwrap();
967        assert!(changed);
968
969        let content = std::fs::read_to_string(&path).unwrap();
970        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
971        assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
972    }
973
974    #[test]
975    fn install_mcp_rejects_non_object_root() {
976        let dir = TempDir::new().unwrap();
977        let path = dir.path().join(".claude.json");
978        std::fs::write(&path, r#"["not", "an", "object"]"#).unwrap();
979
980        let result = install_claude_mcp_config_at(Some(dir.path()));
981        assert!(result.is_err(), "array root must be rejected — corrupted config");
982    }
983}