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]`. Call the `compress`
124tool directly when you want to compress an already-captured text blob
125rather than piping a fresh shell command.
126
127{end}
128",
129 begin = AGENTS_MD_BEGIN,
130 end = AGENTS_MD_END,
131 )
132}
133
134fn agents_md_has_sqz_block(content: &str) -> bool {
139 content.contains(AGENTS_MD_BEGIN)
140}
141
142pub fn install_agents_md_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
153 let path = agents_md_path(project_dir);
154 let block = agents_md_guidance_block(sqz_path);
155
156 if path.exists() {
157 let existing = std::fs::read_to_string(&path).map_err(|e| {
158 SqzError::Other(format!("failed to read {}: {e}", path.display()))
159 })?;
160 if agents_md_has_sqz_block(&existing) {
161 return Ok(false);
162 }
163 let mut new_content = existing;
166 if !new_content.ends_with('\n') {
167 new_content.push('\n');
168 }
169 if !new_content.ends_with("\n\n") {
170 new_content.push('\n');
171 }
172 new_content.push_str(&block);
173 std::fs::write(&path, new_content).map_err(|e| {
174 SqzError::Other(format!("failed to write {}: {e}", path.display()))
175 })?;
176 return Ok(true);
177 }
178
179 let preamble = "\
183# AGENTS.md
184
185Instructions for AI coding agents (OpenAI Codex, GitHub Copilot, Cursor,
186Windsurf, Amp, Devin) working in this repository. See <https://agentsmd.io>.
187
188";
189 let content = format!("{preamble}{block}");
190 std::fs::write(&path, content)
191 .map_err(|e| SqzError::Other(format!("failed to create {}: {e}", path.display())))?;
192 Ok(true)
193}
194
195pub fn remove_agents_md_guidance(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
210 let path = agents_md_path(project_dir);
211 if !path.exists() {
212 return Ok(None);
213 }
214 let content = std::fs::read_to_string(&path).map_err(|e| {
215 SqzError::Other(format!("failed to read {}: {e}", path.display()))
216 })?;
217 if !agents_md_has_sqz_block(&content) {
218 return Ok(Some((path, false)));
219 }
220
221 let begin_idx = match content.find(AGENTS_MD_BEGIN) {
225 Some(i) => i,
226 None => return Ok(Some((path, false))),
227 };
228 let after_end_idx = match content.find(AGENTS_MD_END) {
229 Some(i) => i + AGENTS_MD_END.len(),
230 None => {
231 return Ok(Some((path, false)));
235 }
236 };
237
238 let mut new_content = String::with_capacity(content.len());
239 new_content.push_str(&content[..begin_idx]);
240 while new_content.ends_with("\n\n\n") {
244 new_content.pop();
245 }
246 let tail = &content[after_end_idx..];
247 let trimmed_tail = tail.trim_start_matches('\n');
250 if !trimmed_tail.is_empty() {
251 if !new_content.ends_with('\n') {
252 new_content.push('\n');
253 }
254 new_content.push_str(trimmed_tail);
255 }
256
257 let essentially_empty = is_essentially_empty_agents_md(&new_content);
261 if essentially_empty {
262 std::fs::remove_file(&path).map_err(|e| {
263 SqzError::Other(format!("failed to remove {}: {e}", path.display()))
264 })?;
265 return Ok(Some((path, true)));
266 }
267
268 std::fs::write(&path, new_content).map_err(|e| {
269 SqzError::Other(format!("failed to write {}: {e}", path.display()))
270 })?;
271 Ok(Some((path, true)))
272}
273
274fn is_essentially_empty_agents_md(s: &str) -> bool {
279 let trimmed = s.trim();
280 if trimmed.is_empty() {
281 return true;
282 }
283 const PREAMBLE_MARKERS: &[&str] = &[
286 "# AGENTS.md",
287 "Instructions for AI coding agents",
288 "See <https://agentsmd.io>.",
289 ];
290 let has_only_preamble = PREAMBLE_MARKERS.iter().all(|m| trimmed.contains(m))
291 && !trimmed
292 .lines()
293 .any(|l| {
294 let l = l.trim();
295 !l.is_empty()
296 && !l.starts_with('#')
297 && !PREAMBLE_MARKERS.iter().any(|m| l.contains(m))
298 });
299 has_only_preamble
300}
301
302pub fn install_codex_mcp_config() -> Result<bool> {
329 let path = codex_config_path();
330
331 let existing = if path.exists() {
334 std::fs::read_to_string(&path).map_err(|e| {
335 SqzError::Other(format!("failed to read {}: {e}", path.display()))
336 })?
337 } else {
338 String::new()
339 };
340
341 let mut doc: toml_edit::DocumentMut = existing
342 .parse()
343 .map_err(|e| SqzError::Other(format!(
344 "failed to parse {} as TOML: {e}",
345 path.display()
346 )))?;
347
348 if let Some(existing_cmd) = doc
353 .get("mcp_servers")
354 .and_then(|v| v.get("sqz"))
355 .and_then(|v| v.get("command"))
356 {
357 if existing_cmd.is_value() {
358 return Ok(false);
359 }
360 }
361
362 let mcp_servers = doc
372 .entry("mcp_servers")
373 .or_insert_with(|| {
374 let mut t = toml_edit::Table::new();
377 t.set_implicit(true);
378 toml_edit::Item::Table(t)
379 })
380 .as_table_mut()
381 .ok_or_else(|| SqzError::Other(format!(
382 "{}: `mcp_servers` is not a table — refusing to overwrite",
383 path.display()
384 )))?;
385
386 let sqz = mcp_servers
387 .entry("sqz")
388 .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
389 .as_table_mut()
390 .ok_or_else(|| SqzError::Other(format!(
391 "{}: `mcp_servers.sqz` is not a table — refusing to overwrite",
392 path.display()
393 )))?;
394
395 if !sqz.contains_key("command") {
398 sqz["command"] = toml_edit::value("sqz-mcp");
399 }
400 if !sqz.contains_key("args") {
401 let mut args = toml_edit::Array::new();
402 args.push("--transport");
403 args.push("stdio");
404 sqz["args"] = toml_edit::Item::Value(toml_edit::Value::Array(args));
405 }
406
407 if let Some(parent) = path.parent() {
409 std::fs::create_dir_all(parent).map_err(|e| {
410 SqzError::Other(format!(
411 "failed to create {}: {e}",
412 parent.display()
413 ))
414 })?;
415 }
416
417 std::fs::write(&path, doc.to_string()).map_err(|e| {
418 SqzError::Other(format!("failed to write {}: {e}", path.display()))
419 })?;
420 Ok(true)
421}
422
423pub fn remove_codex_mcp_config() -> Result<Option<(PathBuf, bool)>> {
441 let path = codex_config_path();
442 if !path.exists() {
443 return Ok(None);
444 }
445 let raw = std::fs::read_to_string(&path)
446 .map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
447
448 let mut doc: toml_edit::DocumentMut = match raw.parse() {
449 Ok(d) => d,
450 Err(_) => {
451 return Ok(Some((path, false)));
453 }
454 };
455
456 let mcp_table = match doc.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
457 Some(t) => t,
458 None => return Ok(Some((path, false))),
459 };
460 if !mcp_table.contains_key("sqz") {
461 return Ok(Some((path, false)));
462 }
463 mcp_table.remove("sqz");
464
465 let mcp_is_empty = mcp_table.iter().count() == 0;
468 if mcp_is_empty {
469 doc.remove("mcp_servers");
470 }
471
472 let doc_is_empty = doc.iter().count() == 0;
474 if doc_is_empty {
475 std::fs::remove_file(&path).map_err(|e| {
476 SqzError::Other(format!("failed to remove {}: {e}", path.display()))
477 })?;
478 return Ok(Some((path, true)));
479 }
480
481 std::fs::write(&path, doc.to_string()).map_err(|e| {
482 SqzError::Other(format!("failed to write {}: {e}", path.display()))
483 })?;
484 Ok(Some((path, true)))
485}
486
487#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn codex_config_path_honours_codex_home() {
495 let dir = tempfile::tempdir().unwrap();
496 let prev = std::env::var_os("CODEX_HOME");
497 std::env::set_var("CODEX_HOME", dir.path());
498 let got = codex_config_path();
499 assert_eq!(got, dir.path().join("config.toml"));
500 match prev {
501 Some(v) => std::env::set_var("CODEX_HOME", v),
502 None => std::env::remove_var("CODEX_HOME"),
503 }
504 }
505
506 #[test]
507 fn codex_config_path_falls_back_to_home_dot_codex() {
508 let prev_codex = std::env::var_os("CODEX_HOME");
509 let prev_home = std::env::var_os("HOME");
510 std::env::remove_var("CODEX_HOME");
511 std::env::set_var("HOME", "/tmp/codex-home-test");
512 let got = codex_config_path();
513 assert_eq!(got, PathBuf::from("/tmp/codex-home-test/.codex/config.toml"));
514 match prev_codex {
515 Some(v) => std::env::set_var("CODEX_HOME", v),
516 None => std::env::remove_var("CODEX_HOME"),
517 }
518 match prev_home {
519 Some(v) => std::env::set_var("HOME", v),
520 None => std::env::remove_var("HOME"),
521 }
522 }
523
524 #[test]
527 fn agents_md_guidance_block_contains_sqz_invocation() {
528 let block = agents_md_guidance_block("/usr/local/bin/sqz");
529 assert!(block.contains(AGENTS_MD_BEGIN));
530 assert!(block.contains(AGENTS_MD_END));
531 assert!(block.contains("| /usr/local/bin/sqz compress"));
532 assert!(block.contains("sqz-mcp"));
533 }
534
535 #[test]
536 fn install_agents_md_creates_file_with_preamble() {
537 let dir = tempfile::tempdir().unwrap();
538 let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
539 assert!(created);
540 let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
541 assert!(content.starts_with("# AGENTS.md"));
542 assert!(content.contains(AGENTS_MD_BEGIN));
543 assert!(content.contains(AGENTS_MD_END));
544 }
545
546 #[test]
547 fn install_agents_md_appends_without_clobbering_user_content() {
548 let dir = tempfile::tempdir().unwrap();
549 let path = dir.path().join("AGENTS.md");
550 std::fs::write(
551 &path,
552 "# My project rules\n\nBe polite. Run tests before committing.\n",
553 ).unwrap();
554
555 let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
556 assert!(created);
557
558 let content = std::fs::read_to_string(&path).unwrap();
559 assert!(content.contains("# My project rules"),
560 "original heading must survive");
561 assert!(content.contains("Be polite. Run tests before committing."),
562 "original body must survive");
563 let user_idx = content.find("Be polite").unwrap();
564 let sqz_idx = content.find(AGENTS_MD_BEGIN).unwrap();
565 assert!(sqz_idx > user_idx,
566 "sqz's block must append after user content, not prepend");
567 }
568
569 #[test]
570 fn install_agents_md_is_idempotent() {
571 let dir = tempfile::tempdir().unwrap();
572 let first = install_agents_md_guidance(dir.path(), "sqz").unwrap();
573 assert!(first);
574 let second = install_agents_md_guidance(dir.path(), "sqz").unwrap();
575 assert!(!second, "second install must be a no-op");
576
577 let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
578 let occurrences = content.matches(AGENTS_MD_BEGIN).count();
579 assert_eq!(occurrences, 1, "must not duplicate the block on re-install");
580 }
581
582 #[test]
583 fn remove_agents_md_preserves_user_content() {
584 let dir = tempfile::tempdir().unwrap();
585 let path = dir.path().join("AGENTS.md");
586 std::fs::write(
587 &path,
588 "# My project rules\n\nBe polite. Run tests before committing.\n",
589 ).unwrap();
590 install_agents_md_guidance(dir.path(), "sqz").unwrap();
591
592 let (returned_path, changed) =
593 remove_agents_md_guidance(dir.path()).unwrap().unwrap();
594 assert_eq!(returned_path, path);
595 assert!(changed);
596 assert!(path.exists(),
597 "file must NOT be deleted — it has user content");
598
599 let content = std::fs::read_to_string(&path).unwrap();
600 assert!(!content.contains(AGENTS_MD_BEGIN));
601 assert!(!content.contains(AGENTS_MD_END));
602 assert!(content.contains("# My project rules"),
603 "user heading must survive the uninstall");
604 assert!(content.contains("Be polite. Run tests before committing."),
605 "user body must survive the uninstall");
606 }
607
608 #[test]
609 fn remove_agents_md_deletes_file_when_only_sqz_preamble_remains() {
610 let dir = tempfile::tempdir().unwrap();
611 install_agents_md_guidance(dir.path(), "sqz").unwrap();
612 let path = dir.path().join("AGENTS.md");
613 assert!(path.exists());
614
615 let (_returned, changed) =
616 remove_agents_md_guidance(dir.path()).unwrap().unwrap();
617 assert!(changed);
618 assert!(
619 !path.exists(),
620 "fresh-install AGENTS.md must be removed when sqz block is stripped"
621 );
622 }
623
624 #[test]
625 fn remove_agents_md_noop_when_block_missing() {
626 let dir = tempfile::tempdir().unwrap();
627 let path = dir.path().join("AGENTS.md");
628 std::fs::write(&path, "# User-authored, sqz never touched this.\n").unwrap();
629
630 let (returned_path, changed) =
631 remove_agents_md_guidance(dir.path()).unwrap().unwrap();
632 assert_eq!(returned_path, path);
633 assert!(!changed, "no sqz block means no change");
634 assert!(path.exists(), "user file must be untouched");
635 }
636
637 #[test]
638 fn remove_agents_md_returns_none_when_file_missing() {
639 let dir = tempfile::tempdir().unwrap();
640 let result = remove_agents_md_guidance(dir.path()).unwrap();
641 assert!(result.is_none());
642 }
643
644 fn with_codex_home<F: FnOnce()>(home: &Path, f: F) {
649 let prev = std::env::var_os("CODEX_HOME");
650 std::env::set_var("CODEX_HOME", home);
651 f();
652 match prev {
653 Some(v) => std::env::set_var("CODEX_HOME", v),
654 None => std::env::remove_var("CODEX_HOME"),
655 }
656 }
657
658 #[test]
659 fn install_codex_mcp_config_creates_file_with_sqz_entry() {
660 let dir = tempfile::tempdir().unwrap();
661 with_codex_home(dir.path(), || {
662 let created = install_codex_mcp_config().unwrap();
663 assert!(created);
664 let content = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
665 assert!(
666 content.contains("[mcp_servers.sqz]"),
667 "config.toml must contain [mcp_servers.sqz] header; got:\n{content}"
668 );
669 assert!(content.contains("command = \"sqz-mcp\""),
670 "command must be sqz-mcp");
671 assert!(content.contains("--transport"));
672 assert!(content.contains("stdio"));
673 });
674 }
675
676 #[test]
677 fn install_codex_mcp_config_preserves_existing_other_servers() {
678 let dir = tempfile::tempdir().unwrap();
679 let cfg = dir.path().join("config.toml");
680 std::fs::write(
681 &cfg,
682 "# User's existing Codex config, with a comment.\n\
683 model = \"gpt-5\"\n\
684 \n\
685 [mcp_servers.other]\n\
686 command = \"other-server\"\n\
687 args = [\"--flag\"]\n",
688 ).unwrap();
689
690 with_codex_home(dir.path(), || {
691 let created = install_codex_mcp_config().unwrap();
692 assert!(created);
693 });
694
695 let after = std::fs::read_to_string(&cfg).unwrap();
696 assert!(after.contains("# User's existing Codex config"),
697 "comment must survive: {after}");
698 assert!(after.contains("model = \"gpt-5\""),
699 "top-level key must survive: {after}");
700 assert!(after.contains("[mcp_servers.other]"),
701 "existing server entry must survive: {after}");
702 assert!(after.contains("command = \"other-server\""),
703 "existing server command must survive: {after}");
704 assert!(after.contains("[mcp_servers.sqz]"),
705 "sqz entry must be added: {after}");
706 }
707
708 #[test]
709 fn install_codex_mcp_config_is_idempotent() {
710 let dir = tempfile::tempdir().unwrap();
711 with_codex_home(dir.path(), || {
712 assert!(install_codex_mcp_config().unwrap());
713 assert!(
714 !install_codex_mcp_config().unwrap(),
715 "second install with complete [mcp_servers.sqz] must be a no-op"
716 );
717 });
718 }
719
720 #[test]
721 fn install_codex_mcp_config_does_not_overwrite_user_tuned_entry() {
722 let dir = tempfile::tempdir().unwrap();
723 let cfg = dir.path().join("config.toml");
724 std::fs::write(
725 &cfg,
726 "[mcp_servers.sqz]\n\
727 command = \"/custom/path/sqz-mcp\"\n\
728 args = [\"--transport\", \"sse\", \"--port\", \"3999\"]\n",
729 ).unwrap();
730
731 with_codex_home(dir.path(), || {
732 let changed = install_codex_mcp_config().unwrap();
733 assert!(!changed, "existing complete entry must be idempotent-skipped");
734 });
735 let after = std::fs::read_to_string(&cfg).unwrap();
736 assert!(after.contains("/custom/path/sqz-mcp"),
737 "user's custom command must survive re-init");
738 assert!(after.contains("\"sse\""),
739 "user's custom transport must survive");
740 }
741
742 #[test]
743 fn remove_codex_mcp_config_removes_only_sqz_entry() {
744 let dir = tempfile::tempdir().unwrap();
745 let cfg = dir.path().join("config.toml");
746 std::fs::write(
747 &cfg,
748 "# keep this comment\n\
749 model = \"gpt-5\"\n\
750 \n\
751 [mcp_servers.other]\n\
752 command = \"other-server\"\n\
753 \n\
754 [mcp_servers.sqz]\n\
755 command = \"sqz-mcp\"\n\
756 args = [\"--transport\", \"stdio\"]\n",
757 ).unwrap();
758
759 with_codex_home(dir.path(), || {
760 let (path, changed) = remove_codex_mcp_config().unwrap().unwrap();
761 assert_eq!(path, cfg);
762 assert!(changed);
763 });
764
765 let after = std::fs::read_to_string(&cfg).unwrap();
766 assert!(after.contains("# keep this comment"),
767 "comment must survive: {after}");
768 assert!(after.contains("model = \"gpt-5\""),
769 "top-level key must survive: {after}");
770 assert!(after.contains("[mcp_servers.other]"),
771 "other server entry must survive: {after}");
772 assert!(!after.contains("[mcp_servers.sqz]"),
773 "sqz entry must be gone: {after}");
774 }
775
776 #[test]
777 fn remove_codex_mcp_config_deletes_file_when_sqz_was_the_only_entry() {
778 let dir = tempfile::tempdir().unwrap();
779 let cfg = dir.path().join("config.toml");
780 with_codex_home(dir.path(), || {
781 install_codex_mcp_config().unwrap();
782 let (path, changed) = remove_codex_mcp_config().unwrap().unwrap();
783 assert_eq!(path, cfg);
784 assert!(changed);
785 });
786 assert!(!cfg.exists(),
787 "config.toml with only sqz must be deleted on uninstall");
788 }
789
790 #[test]
791 fn remove_codex_mcp_config_returns_none_when_file_missing() {
792 let dir = tempfile::tempdir().unwrap();
793 with_codex_home(dir.path(), || {
794 let result = remove_codex_mcp_config().unwrap();
795 assert!(result.is_none());
796 });
797 }
798
799 #[test]
800 fn remove_codex_mcp_config_noop_when_sqz_entry_missing() {
801 let dir = tempfile::tempdir().unwrap();
802 let cfg = dir.path().join("config.toml");
803 std::fs::write(&cfg, "[mcp_servers.other]\ncommand = \"x\"\n").unwrap();
804 with_codex_home(dir.path(), || {
805 let (path, changed) = remove_codex_mcp_config().unwrap().unwrap();
806 assert_eq!(path, cfg);
807 assert!(!changed);
808 });
809 let after = std::fs::read_to_string(&cfg).unwrap();
810 assert!(after.contains("[mcp_servers.other]"));
811 }
812}