1use std::path::{Path, PathBuf};
35
36use crate::error::{Result, SqzError};
37
38pub fn codex_config_path() -> PathBuf {
47 if let Ok(home) = std::env::var("CODEX_HOME") {
48 return PathBuf::from(home).join("config.toml");
49 }
50 let home = std::env::var("HOME")
51 .or_else(|_| std::env::var("USERPROFILE"))
52 .map(PathBuf::from)
53 .unwrap_or_else(|_| PathBuf::from("."));
54 home.join(".codex").join("config.toml")
55}
56
57pub fn agents_md_path(project_dir: &Path) -> PathBuf {
64 project_dir.join("AGENTS.md")
65}
66
67const AGENTS_MD_BEGIN: &str = "<!-- BEGIN sqz-agents-guidance (auto-installed by sqz init; remove this block to disable) -->";
76const AGENTS_MD_END: &str = "<!-- END sqz-agents-guidance -->";
77
78pub fn agents_md_guidance_block(sqz_path: &str) -> String {
87 format!(
88 "\
89{begin}
90
91## sqz — Token-Optimized CLI Output
92
93When running shell commands whose output may be long (directory listings,
94git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
95pipe the output through `sqz compress` to reduce token consumption.
96
97`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
98usage is to pipe the command's output into `sqz compress`:
99
100```bash
101# Instead of: Use:
102git status git status 2>&1 | {sqz_path} compress
103cargo test cargo test 2>&1 | {sqz_path} compress
104git log -10 git log -10 2>&1 | {sqz_path} compress
105docker ps docker ps 2>&1 | {sqz_path} compress
106ls -la ls -la 2>&1 | {sqz_path} compress
107```
108
109The `2>&1` captures stderr too, which is useful for commands like `cargo
110test` where diagnostics go to stderr. `sqz compress` filters and compresses
111the combined output while preserving filenames, paths, and identifiers.
112It typically saves 60-90% tokens on verbose commands.
113
114Do NOT pipe output for:
115- Interactive commands (`vim`, `ssh`, `python`, REPLs)
116- Compound commands with shell operators (`cmd && other`, `cmd > file.txt`,
117 `cmd; other`) — run those directly
118- Short commands whose output is already a few lines
119
120If `sqz` is not on PATH, run commands normally.
121
122The `sqz-mcp` MCP server is also available — Codex reads it from
123`~/.codex/config.toml` under `[mcp_servers.sqz]`. It exposes three
124tools: `compress` (the default pipeline), `passthrough` (return text
125unchanged — the escape hatch below), and `expand` (resolve a
126`§ref:HASH§` token back to the original bytes).
127
128## Escape hatch — when sqz output confuses you
129
130If you see a `§ref:HASH§` token and can't parse it, or compressed
131output is leading you to make lots of small retries instead of one
132big request, use one of these:
133
134- **`{sqz_path} expand <prefix>`** — resolve a dedup ref back to the
135 original bytes. Accepts bare hex (`sqz expand a1b2c3d4`) or the full
136 token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
137- **`SQZ_NO_DEDUP=1`** — set this env var for one command to disable
138 dedup: `SQZ_NO_DEDUP=1 git status 2>&1 | sqz compress`. You'll get
139 the full compressed output with no `§ref:…§` tokens.
140- **`--no-cache`** — same opt-out as a CLI flag:
141 `git status 2>&1 | sqz compress --no-cache`.
142- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
143 abbreviation, which rewrites repeated phrases to `«A1»` symbols and
144 keeps only the first occurrence. Use it when output is full of
145 SHAs/paths/URLs you'll copy-paste verbatim:
146 `SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
147
148If you're using the MCP server, the `passthrough` tool returns raw
149text and the `expand` tool resolves refs — call them when you need
150data sqz hasn't touched.
151
152{end}
153",
154 begin = AGENTS_MD_BEGIN,
155 end = AGENTS_MD_END,
156 )
157}
158
159fn agents_md_has_sqz_block(content: &str) -> bool {
164 content.contains(AGENTS_MD_BEGIN)
165}
166
167pub fn install_agents_md_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
178 let path = agents_md_path(project_dir);
179 let block = agents_md_guidance_block(sqz_path);
180
181 if path.exists() {
182 let existing = std::fs::read_to_string(&path).map_err(|e| {
183 SqzError::Other(format!("failed to read {}: {e}", path.display()))
184 })?;
185 if agents_md_has_sqz_block(&existing) {
186 return Ok(false);
187 }
188 let mut new_content = existing;
191 if !new_content.ends_with('\n') {
192 new_content.push('\n');
193 }
194 if !new_content.ends_with("\n\n") {
195 new_content.push('\n');
196 }
197 new_content.push_str(&block);
198 std::fs::write(&path, new_content).map_err(|e| {
199 SqzError::Other(format!("failed to write {}: {e}", path.display()))
200 })?;
201 return Ok(true);
202 }
203
204 let preamble = "\
208# AGENTS.md
209
210Instructions for AI coding agents (OpenAI Codex, GitHub Copilot, Cursor,
211Windsurf, Amp, Devin) working in this repository. See <https://agentsmd.io>.
212
213";
214 let content = format!("{preamble}{block}");
215 std::fs::write(&path, content)
216 .map_err(|e| SqzError::Other(format!("failed to create {}: {e}", path.display())))?;
217 Ok(true)
218}
219
220pub fn remove_agents_md_guidance(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
235 let path = agents_md_path(project_dir);
236 if !path.exists() {
237 return Ok(None);
238 }
239 let content = std::fs::read_to_string(&path).map_err(|e| {
240 SqzError::Other(format!("failed to read {}: {e}", path.display()))
241 })?;
242 if !agents_md_has_sqz_block(&content) {
243 return Ok(Some((path, false)));
244 }
245
246 let begin_idx = match content.find(AGENTS_MD_BEGIN) {
250 Some(i) => i,
251 None => return Ok(Some((path, false))),
252 };
253 let after_end_idx = match content.find(AGENTS_MD_END) {
254 Some(i) => i + AGENTS_MD_END.len(),
255 None => {
256 return Ok(Some((path, false)));
260 }
261 };
262
263 let mut new_content = String::with_capacity(content.len());
264 new_content.push_str(&content[..begin_idx]);
265 while new_content.ends_with("\n\n\n") {
269 new_content.pop();
270 }
271 let tail = &content[after_end_idx..];
272 let trimmed_tail = tail.trim_start_matches('\n');
275 if !trimmed_tail.is_empty() {
276 if !new_content.ends_with('\n') {
277 new_content.push('\n');
278 }
279 new_content.push_str(trimmed_tail);
280 }
281
282 let essentially_empty = is_essentially_empty_agents_md(&new_content);
286 if essentially_empty {
287 std::fs::remove_file(&path).map_err(|e| {
288 SqzError::Other(format!("failed to remove {}: {e}", path.display()))
289 })?;
290 return Ok(Some((path, true)));
291 }
292
293 std::fs::write(&path, new_content).map_err(|e| {
294 SqzError::Other(format!("failed to write {}: {e}", path.display()))
295 })?;
296 Ok(Some((path, true)))
297}
298
299fn is_essentially_empty_agents_md(s: &str) -> bool {
304 let trimmed = s.trim();
305 if trimmed.is_empty() {
306 return true;
307 }
308 const PREAMBLE_MARKERS: &[&str] = &[
311 "# AGENTS.md",
312 "Instructions for AI coding agents",
313 "See <https://agentsmd.io>.",
314 ];
315 let has_only_preamble = PREAMBLE_MARKERS.iter().all(|m| trimmed.contains(m))
316 && !trimmed
317 .lines()
318 .any(|l| {
319 let l = l.trim();
320 !l.is_empty()
321 && !l.starts_with('#')
322 && !PREAMBLE_MARKERS.iter().any(|m| l.contains(m))
323 });
324 has_only_preamble
325}
326
327pub fn install_codex_mcp_config() -> Result<bool> {
354 install_codex_mcp_config_at(None)
355}
356
357pub(crate) fn install_codex_mcp_config_at(home_override: Option<&Path>) -> Result<bool> {
363 let path = match home_override {
364 Some(h) => h.join("config.toml"),
365 None => codex_config_path(),
366 };
367
368 let existing = if path.exists() {
371 std::fs::read_to_string(&path).map_err(|e| {
372 SqzError::Other(format!("failed to read {}: {e}", path.display()))
373 })?
374 } else {
375 String::new()
376 };
377
378 let mut doc: toml_edit::DocumentMut = existing
379 .parse()
380 .map_err(|e| SqzError::Other(format!(
381 "failed to parse {} as TOML: {e}",
382 path.display()
383 )))?;
384
385 if let Some(existing_cmd) = doc
390 .get("mcp_servers")
391 .and_then(|v| v.get("sqz"))
392 .and_then(|v| v.get("command"))
393 {
394 if existing_cmd.is_value() {
395 return Ok(false);
396 }
397 }
398
399 let mcp_servers = doc
409 .entry("mcp_servers")
410 .or_insert_with(|| {
411 let mut t = toml_edit::Table::new();
414 t.set_implicit(true);
415 toml_edit::Item::Table(t)
416 })
417 .as_table_mut()
418 .ok_or_else(|| SqzError::Other(format!(
419 "{}: `mcp_servers` is not a table — refusing to overwrite",
420 path.display()
421 )))?;
422
423 let sqz = mcp_servers
424 .entry("sqz")
425 .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
426 .as_table_mut()
427 .ok_or_else(|| SqzError::Other(format!(
428 "{}: `mcp_servers.sqz` is not a table — refusing to overwrite",
429 path.display()
430 )))?;
431
432 if !sqz.contains_key("command") {
435 sqz["command"] = toml_edit::value("sqz-mcp");
436 }
437 if !sqz.contains_key("args") {
438 let mut args = toml_edit::Array::new();
439 args.push("--transport");
440 args.push("stdio");
441 sqz["args"] = toml_edit::Item::Value(toml_edit::Value::Array(args));
442 }
443
444 if let Some(parent) = path.parent() {
446 std::fs::create_dir_all(parent).map_err(|e| {
447 SqzError::Other(format!(
448 "failed to create {}: {e}",
449 parent.display()
450 ))
451 })?;
452 }
453
454 std::fs::write(&path, doc.to_string()).map_err(|e| {
455 SqzError::Other(format!("failed to write {}: {e}", path.display()))
456 })?;
457 Ok(true)
458}
459
460pub fn remove_codex_mcp_config() -> Result<Option<(PathBuf, bool)>> {
478 remove_codex_mcp_config_at(None)
479}
480
481pub(crate) fn remove_codex_mcp_config_at(
484 home_override: Option<&Path>,
485) -> Result<Option<(PathBuf, bool)>> {
486 let path = match home_override {
487 Some(h) => h.join("config.toml"),
488 None => codex_config_path(),
489 };
490 if !path.exists() {
491 return Ok(None);
492 }
493 let raw = std::fs::read_to_string(&path)
494 .map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
495
496 let mut doc: toml_edit::DocumentMut = match raw.parse() {
497 Ok(d) => d,
498 Err(_) => {
499 return Ok(Some((path, false)));
501 }
502 };
503
504 let mcp_table = match doc.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
505 Some(t) => t,
506 None => return Ok(Some((path, false))),
507 };
508 if !mcp_table.contains_key("sqz") {
509 return Ok(Some((path, false)));
510 }
511 mcp_table.remove("sqz");
512
513 let mcp_is_empty = mcp_table.iter().count() == 0;
516 if mcp_is_empty {
517 doc.remove("mcp_servers");
518 }
519
520 let doc_is_empty = doc.iter().count() == 0;
522 if doc_is_empty {
523 std::fs::remove_file(&path).map_err(|e| {
524 SqzError::Other(format!("failed to remove {}: {e}", path.display()))
525 })?;
526 return Ok(Some((path, true)));
527 }
528
529 std::fs::write(&path, doc.to_string()).map_err(|e| {
530 SqzError::Other(format!("failed to write {}: {e}", path.display()))
531 })?;
532 Ok(Some((path, true)))
533}
534
535#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
546 fn codex_config_path_ends_with_config_toml() {
547 let got = codex_config_path();
548 assert!(
549 got.ends_with("config.toml"),
550 "codex_config_path() must end with config.toml, got: {}",
551 got.display()
552 );
553 }
554
555 #[test]
558 fn agents_md_guidance_block_contains_sqz_invocation() {
559 let block = agents_md_guidance_block("/usr/local/bin/sqz");
560 assert!(block.contains(AGENTS_MD_BEGIN));
561 assert!(block.contains(AGENTS_MD_END));
562 assert!(block.contains("| /usr/local/bin/sqz compress"));
563 assert!(block.contains("sqz-mcp"));
564 }
565
566 #[test]
567 fn install_agents_md_creates_file_with_preamble() {
568 let dir = tempfile::tempdir().unwrap();
569 let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
570 assert!(created);
571 let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
572 assert!(content.starts_with("# AGENTS.md"));
573 assert!(content.contains(AGENTS_MD_BEGIN));
574 assert!(content.contains(AGENTS_MD_END));
575 }
576
577 #[test]
578 fn install_agents_md_appends_without_clobbering_user_content() {
579 let dir = tempfile::tempdir().unwrap();
580 let path = dir.path().join("AGENTS.md");
581 std::fs::write(
582 &path,
583 "# My project rules\n\nBe polite. Run tests before committing.\n",
584 ).unwrap();
585
586 let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
587 assert!(created);
588
589 let content = std::fs::read_to_string(&path).unwrap();
590 assert!(content.contains("# My project rules"),
591 "original heading must survive");
592 assert!(content.contains("Be polite. Run tests before committing."),
593 "original body must survive");
594 let user_idx = content.find("Be polite").unwrap();
595 let sqz_idx = content.find(AGENTS_MD_BEGIN).unwrap();
596 assert!(sqz_idx > user_idx,
597 "sqz's block must append after user content, not prepend");
598 }
599
600 #[test]
601 fn install_agents_md_is_idempotent() {
602 let dir = tempfile::tempdir().unwrap();
603 let first = install_agents_md_guidance(dir.path(), "sqz").unwrap();
604 assert!(first);
605 let second = install_agents_md_guidance(dir.path(), "sqz").unwrap();
606 assert!(!second, "second install must be a no-op");
607
608 let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
609 let occurrences = content.matches(AGENTS_MD_BEGIN).count();
610 assert_eq!(occurrences, 1, "must not duplicate the block on re-install");
611 }
612
613 #[test]
614 fn remove_agents_md_preserves_user_content() {
615 let dir = tempfile::tempdir().unwrap();
616 let path = dir.path().join("AGENTS.md");
617 std::fs::write(
618 &path,
619 "# My project rules\n\nBe polite. Run tests before committing.\n",
620 ).unwrap();
621 install_agents_md_guidance(dir.path(), "sqz").unwrap();
622
623 let (returned_path, changed) =
624 remove_agents_md_guidance(dir.path()).unwrap().unwrap();
625 assert_eq!(returned_path, path);
626 assert!(changed);
627 assert!(path.exists(),
628 "file must NOT be deleted — it has user content");
629
630 let content = std::fs::read_to_string(&path).unwrap();
631 assert!(!content.contains(AGENTS_MD_BEGIN));
632 assert!(!content.contains(AGENTS_MD_END));
633 assert!(content.contains("# My project rules"),
634 "user heading must survive the uninstall");
635 assert!(content.contains("Be polite. Run tests before committing."),
636 "user body must survive the uninstall");
637 }
638
639 #[test]
640 fn remove_agents_md_deletes_file_when_only_sqz_preamble_remains() {
641 let dir = tempfile::tempdir().unwrap();
642 install_agents_md_guidance(dir.path(), "sqz").unwrap();
643 let path = dir.path().join("AGENTS.md");
644 assert!(path.exists());
645
646 let (_returned, changed) =
647 remove_agents_md_guidance(dir.path()).unwrap().unwrap();
648 assert!(changed);
649 assert!(
650 !path.exists(),
651 "fresh-install AGENTS.md must be removed when sqz block is stripped"
652 );
653 }
654
655 #[test]
656 fn remove_agents_md_noop_when_block_missing() {
657 let dir = tempfile::tempdir().unwrap();
658 let path = dir.path().join("AGENTS.md");
659 std::fs::write(&path, "# User-authored, sqz never touched this.\n").unwrap();
660
661 let (returned_path, changed) =
662 remove_agents_md_guidance(dir.path()).unwrap().unwrap();
663 assert_eq!(returned_path, path);
664 assert!(!changed, "no sqz block means no change");
665 assert!(path.exists(), "user file must be untouched");
666 }
667
668 #[test]
669 fn remove_agents_md_returns_none_when_file_missing() {
670 let dir = tempfile::tempdir().unwrap();
671 let result = remove_agents_md_guidance(dir.path()).unwrap();
672 assert!(result.is_none());
673 }
674
675 #[test]
678 fn install_codex_mcp_config_creates_file_with_sqz_entry() {
679 let dir = tempfile::tempdir().unwrap();
680 let created = install_codex_mcp_config_at(Some(dir.path())).unwrap();
681 assert!(created);
682 let content = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
683 assert!(
684 content.contains("[mcp_servers.sqz]"),
685 "config.toml must contain [mcp_servers.sqz] header; got:\n{content}"
686 );
687 assert!(content.contains("command = \"sqz-mcp\""),
688 "command must be sqz-mcp");
689 assert!(content.contains("--transport"));
690 assert!(content.contains("stdio"));
691 }
692
693 #[test]
694 fn install_codex_mcp_config_preserves_existing_other_servers() {
695 let dir = tempfile::tempdir().unwrap();
696 let cfg = dir.path().join("config.toml");
697 std::fs::write(
698 &cfg,
699 "# User's existing Codex config, with a comment.\n\
700 model = \"gpt-5\"\n\
701 \n\
702 [mcp_servers.other]\n\
703 command = \"other-server\"\n\
704 args = [\"--flag\"]\n",
705 ).unwrap();
706
707 let created = install_codex_mcp_config_at(Some(dir.path())).unwrap();
708 assert!(created);
709
710 let after = std::fs::read_to_string(&cfg).unwrap();
711 assert!(after.contains("# User's existing Codex config"),
712 "comment must survive: {after}");
713 assert!(after.contains("model = \"gpt-5\""),
714 "top-level key must survive: {after}");
715 assert!(after.contains("[mcp_servers.other]"),
716 "existing server entry must survive: {after}");
717 assert!(after.contains("command = \"other-server\""),
718 "existing server command must survive: {after}");
719 assert!(after.contains("[mcp_servers.sqz]"),
720 "sqz entry must be added: {after}");
721 }
722
723 #[test]
724 fn install_codex_mcp_config_is_idempotent() {
725 let dir = tempfile::tempdir().unwrap();
726 assert!(install_codex_mcp_config_at(Some(dir.path())).unwrap());
727 assert!(
728 !install_codex_mcp_config_at(Some(dir.path())).unwrap(),
729 "second install with complete [mcp_servers.sqz] must be a no-op"
730 );
731 }
732
733 #[test]
734 fn install_codex_mcp_config_does_not_overwrite_user_tuned_entry() {
735 let dir = tempfile::tempdir().unwrap();
736 let cfg = dir.path().join("config.toml");
737 std::fs::write(
738 &cfg,
739 "[mcp_servers.sqz]\n\
740 command = \"/custom/path/sqz-mcp\"\n\
741 args = [\"--transport\", \"sse\", \"--port\", \"3999\"]\n",
742 ).unwrap();
743
744 let changed = install_codex_mcp_config_at(Some(dir.path())).unwrap();
745 assert!(!changed, "existing complete entry must be idempotent-skipped");
746 let after = std::fs::read_to_string(&cfg).unwrap();
747 assert!(after.contains("/custom/path/sqz-mcp"),
748 "user's custom command must survive re-init");
749 assert!(after.contains("\"sse\""),
750 "user's custom transport must survive");
751 }
752
753 #[test]
754 fn remove_codex_mcp_config_removes_only_sqz_entry() {
755 let dir = tempfile::tempdir().unwrap();
756 let cfg = dir.path().join("config.toml");
757 std::fs::write(
758 &cfg,
759 "# keep this comment\n\
760 model = \"gpt-5\"\n\
761 \n\
762 [mcp_servers.other]\n\
763 command = \"other-server\"\n\
764 \n\
765 [mcp_servers.sqz]\n\
766 command = \"sqz-mcp\"\n\
767 args = [\"--transport\", \"stdio\"]\n",
768 ).unwrap();
769
770 let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
771 assert_eq!(path, cfg);
772 assert!(changed);
773
774 let after = std::fs::read_to_string(&cfg).unwrap();
775 assert!(after.contains("# keep this comment"),
776 "comment must survive: {after}");
777 assert!(after.contains("model = \"gpt-5\""),
778 "top-level key must survive: {after}");
779 assert!(after.contains("[mcp_servers.other]"),
780 "other server entry must survive: {after}");
781 assert!(!after.contains("[mcp_servers.sqz]"),
782 "sqz entry must be gone: {after}");
783 }
784
785 #[test]
786 fn remove_codex_mcp_config_deletes_file_when_sqz_was_the_only_entry() {
787 let dir = tempfile::tempdir().unwrap();
788 let cfg = dir.path().join("config.toml");
789 install_codex_mcp_config_at(Some(dir.path())).unwrap();
790 let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
791 assert_eq!(path, cfg);
792 assert!(changed);
793 assert!(!cfg.exists(),
794 "config.toml with only sqz must be deleted on uninstall");
795 }
796
797 #[test]
798 fn remove_codex_mcp_config_returns_none_when_file_missing() {
799 let dir = tempfile::tempdir().unwrap();
800 let result = remove_codex_mcp_config_at(Some(dir.path())).unwrap();
801 assert!(result.is_none());
802 }
803
804 #[test]
805 fn remove_codex_mcp_config_noop_when_sqz_entry_missing() {
806 let dir = tempfile::tempdir().unwrap();
807 let cfg = dir.path().join("config.toml");
808 std::fs::write(&cfg, "[mcp_servers.other]\ncommand = \"x\"\n").unwrap();
809 let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
810 assert_eq!(path, cfg);
811 assert!(!changed);
812 let after = std::fs::read_to_string(&cfg).unwrap();
813 assert!(after.contains("[mcp_servers.other]"));
814 }
815}