1use std::path::{Path, PathBuf};
40
41use crate::error::{Result, SqzError};
42use crate::kiro_integration::SteeringState;
43
44const 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
50pub fn claude_md_path(project_dir: &Path) -> PathBuf {
56 project_dir.join("CLAUDE.md")
57}
58
59pub fn claude_user_json_path() -> Option<PathBuf> {
69 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
76pub 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
179fn claude_md_has_sqz_block(content: &str) -> bool {
182 content.contains(CLAUDE_MD_BEGIN)
183}
184
185pub 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
194pub 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
200fn 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
207fn 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
216pub 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
233pub 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
249fn 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
264pub 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 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 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
318pub 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 return Ok(Some((path, false)));
349 }
350 };
351
352 let new_content = crate::codex_integration::excise_range(&content, begin_idx, after_end_idx);
354
355 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
377const SQZ_MCP_SENTINEL_KEY: &str = "_sqz_managed";
392
393pub fn install_claude_mcp_config() -> Result<bool> {
407 install_claude_mcp_config_at(None)
408}
409
410pub(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 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 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 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 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 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
498pub fn remove_claude_mcp_config() -> Result<Option<(PathBuf, bool)>> {
507 remove_claude_mcp_config_at(None)
508}
509
510pub 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
519pub(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 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#[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 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 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 assert!(content.contains("My project rules"));
640 assert!(content.contains("Be polite"));
641 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 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 #[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 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 assert_eq!(json["mcpServers"]["dart-mcp-server"]["command"], "dart");
839 assert_eq!(json["mcpServers"]["sqz"]["command"], "sqz-mcp");
841 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 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 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 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}