Skip to main content

sqz_engine/
codex_integration.rs

1//! OpenAI Codex integration for sqz.
2//!
3//! Codex is OpenAI's terminal coding agent (openai/codex). It does not
4//! expose a stable per-tool-call hook like Claude Code's PreToolUse — the
5//! only event hook documented at the time of writing is `notify`, which
6//! fires at the END of a turn (once per user message) and so is useless
7//! for compressing tool output before the model sees it. An experimental
8//! `features.codex_hooks` flag is mentioned in the config reference but
9//! described as "under development; off by default" and the public `hooks.json`
10//! schema is not documented enough to target safely. See
11//! <https://developers.openai.com/codex/config-reference>.
12//!
13//! What Codex DOES expose reliably today:
14//!
15//!   1. **MCP servers** via `~/.codex/config.toml` under
16//!      `[mcp_servers.<id>]`. The TOML key is `mcp_servers` (snake_case),
17//!      NOT `mcpServers` (camelCase) as in JSON-based tools. See
18//!      <https://github.com/openai/codex/blob/main/docs/config.md>.
19//!
20//!   2. **`AGENTS.md`** — project-level markdown instructions Codex reads
21//!      at session start. It's the Codex analogue of `CLAUDE.md` and
22//!      `.cursor/rules/*.mdc`. A single `AGENTS.md` is the cross-tool
23//!      convention (Codex, GitHub Copilot, Cursor, Windsurf, Amp, Devin
24//!      all read it). See <https://agentsmd.io>.
25//!
26//!   3. **The shell hook** installed by `sqz init` — works transparently
27//!      because Codex runs bash via its sandboxed exec tool and sees the
28//!      compressed stdout automatically. No Codex-specific wiring needed.
29//!
30//! This module implements (1) and (2). Guidance-file approach for (2)
31//! mirrors how RTK handles the same "no programmatic hook" problem with
32//! Codex — see <https://github.com/rtk-ai/rtk/blob/master/hooks/codex/README.md>.
33
34use std::path::{Path, PathBuf};
35
36use crate::error::{Result, SqzError};
37
38// ── Paths ─────────────────────────────────────────────────────────────────
39
40/// Return the location of Codex's user-level `config.toml`.
41///
42/// Honors `$CODEX_HOME` when set (Codex supports it for isolated installs),
43/// falling back to `~/.codex/config.toml`. Matches the behaviour of the
44/// Codex CLI itself — see the `sqlite_home` discussion in Codex's
45/// `docs/config.md`, which describes the same resolution order.
46pub 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
57/// Return the path where Codex reads project-level instructions.
58///
59/// `AGENTS.md` lives in the project root and is the cross-tool standard
60/// (Codex, Copilot, Cursor). We do NOT fall back to the deprecated
61/// `codex.md` / `.codexrules` names — they are pre-consolidation legacy
62/// and new installs should target `AGENTS.md`.
63pub fn agents_md_path(project_dir: &Path) -> PathBuf {
64    project_dir.join("AGENTS.md")
65}
66
67// ── Sentinel-bracketed guidance block ────────────────────────────────────
68//
69// `AGENTS.md` is a free-form markdown file the user may edit freely. sqz
70// writes its guidance between BEGIN/END sentinels so we can later locate
71// and remove exactly what we added, without clobbering unrelated user
72// content. Matches the pattern we already use for shell RC hooks in
73// sqz/src/shell_hook.rs.
74
75const 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
78/// Build the markdown block that sqz appends to `AGENTS.md`.
79///
80/// The block is wrapped in HTML comment sentinels so `remove_agents_md_guidance`
81/// can locate and excise it byte-exact on uninstall. `sqz_path` is the
82/// absolute path of the sqz binary; we use it verbatim (no JSON escaping)
83/// because this is a markdown file the user reads — raw backslashes on
84/// Windows render correctly as-is, and the user can copy-paste the
85/// commands straight into their shell.
86pub 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
134// ── AGENTS.md install/uninstall ──────────────────────────────────────────
135
136/// Return `true` if the given `AGENTS.md` content already contains sqz's
137/// guidance block (matched by the BEGIN sentinel).
138fn agents_md_has_sqz_block(content: &str) -> bool {
139    content.contains(AGENTS_MD_BEGIN)
140}
141
142/// Install sqz's guidance block into `AGENTS.md` at `project_dir`.
143///
144/// If `AGENTS.md` doesn't exist yet, create it with sqz's block as the
145/// sole content. If it exists, append sqz's block (separated by a blank
146/// line so it renders as a new markdown section). If the block is
147/// already present (detected by the BEGIN sentinel), return `Ok(false)`
148/// without touching the file — `sqz init` stays idempotent.
149///
150/// Returns `true` when the file was created or modified, `false` when
151/// sqz's block was already present.
152pub 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        // Append with a guaranteed blank-line separator so sqz's section
164        // doesn't accidentally fuse with a trailing user section.
165        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    // Fresh AGENTS.md — write just sqz's block. A tiny preamble is added
180    // so `AGENTS.md` is self-explanatory to readers who encounter it for
181    // the first time and don't know the convention.
182    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
195/// Remove sqz's guidance block from `AGENTS.md`.
196///
197/// Locates the block by its BEGIN/END sentinels and excises it. If the
198/// resulting `AGENTS.md` is empty (modulo whitespace) or contains only
199/// the stock preamble sqz itself wrote on first install, the file is
200/// deleted entirely — leaving an empty `AGENTS.md` would be noise.
201/// If sentinel markers are missing (user edited them out or the file
202/// was never installed by sqz), this is a no-op.
203///
204/// Returns `Ok((path, removed_or_changed))`:
205///   - `removed_or_changed = true`  — the file was modified or deleted
206///   - `removed_or_changed = false` — no sqz block was found; nothing changed
207///
208/// Returns `Ok(None)` when `AGENTS.md` does not exist at all.
209pub 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    // Find the slice that contains sqz's block (BEGIN..=END sentinel,
222    // plus any immediate surrounding blank line we contributed on install
223    // so the remaining file reads cleanly).
224    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            // BEGIN without END — truncated/edited file. Preserve the
232            // user's file and emit a soft no-op rather than destroying
233            // content we can't precisely delimit.
234            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    // Trim any trailing blank-line run that was inserted to separate
241    // sqz's block from what preceded it — but keep one trailing newline
242    // if the file had content before the block.
243    while new_content.ends_with("\n\n\n") {
244        new_content.pop();
245    }
246    let tail = &content[after_end_idx..];
247    // Skip the leading newline(s) right after our END sentinel so the
248    // trailing user content starts cleanly.
249    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    // If the remaining content is empty (or only the stock preamble sqz
258    // wrote on first install), remove the file entirely — a near-empty
259    // AGENTS.md is worse than no file.
260    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
274/// Return `true` when the remaining `AGENTS.md` content has no
275/// user-authored material — either empty or just the stock preamble sqz
276/// itself wrote on first install. Used by `remove_agents_md_guidance`
277/// to decide between "write back the trimmed file" and "delete it".
278fn is_essentially_empty_agents_md(s: &str) -> bool {
279    let trimmed = s.trim();
280    if trimmed.is_empty() {
281        return true;
282    }
283    // Match the exact preamble sqz writes on first install (with its
284    // heading, one paragraph of explanation, and no user additions).
285    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
302// ── ~/.codex/config.toml merger ──────────────────────────────────────────
303
304/// Merge sqz's MCP server entry into Codex's user-level `config.toml`.
305///
306/// Codex reads MCP servers from `~/.codex/config.toml` (or
307/// `$CODEX_HOME/config.toml` when set). The relevant TOML shape is:
308///
309/// ```toml
310/// [mcp_servers.sqz]
311/// command = "sqz-mcp"
312/// args = ["--transport", "stdio"]
313/// ```
314///
315/// Notes:
316///   - Key is `mcp_servers` (snake_case). JSON-tool users who write
317///     `mcpServers` will be quietly ignored by Codex.
318///   - We go through `toml_edit` so the user's existing comments,
319///     key order, and formatting are preserved across the round-trip.
320///     Plain `toml::from_str → toml::to_string` wipes comments.
321///
322/// Idempotent: if `[mcp_servers.sqz]` is already present with a `command`
323/// field, we do not overwrite — the user may have tuned it. Returns
324/// `Ok(false)` in that case.
325///
326/// If the config file doesn't exist yet, it is created with only sqz's
327/// entry (and its parent `~/.codex/` directory is created on demand).
328pub fn install_codex_mcp_config() -> Result<bool> {
329    let path = codex_config_path();
330
331    // Read or start from blank. We build on a toml_edit::DocumentMut so
332    // any prior comments/whitespace survive the round-trip.
333    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    // Idempotency: if [mcp_servers.sqz].command is already set, skip.
349    // We only check `command` (not just the table's presence) because a
350    // stub `[mcp_servers.sqz]` with no keys would be a misconfigured
351    // install worth repairing.
352    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    // Build the sqz table in place. Using `get_mut(..).or_insert_with(..)`
363    // pattern so we add keys to any existing [mcp_servers] without
364    // replacing the whole table. We deliberately leave the parent
365    // `mcp_servers` implicit (i.e. don't emit an explicit
366    // `[mcp_servers]` header before the sub-tables). The TOML spec
367    // treats `[mcp_servers.sqz]` and `[mcp_servers.jira]` as proper
368    // subtables that implicitly create their parent, and emitting a
369    // bare `[mcp_servers]` header before them is redundant noise in
370    // the file.
371    let mcp_servers = doc
372        .entry("mcp_servers")
373        .or_insert_with(|| {
374            // Start the new table as implicit so toml_edit doesn't
375            // write `[mcp_servers]` before the first subtable header.
376            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    // Populate the server's required fields. Only write keys the user
396    // hasn't already set so a hand-tuned config survives re-runs.
397    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    // Make sure the parent directory exists.
408    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
423/// Remove sqz's MCP entry from Codex's user-level `config.toml`.
424///
425/// Surgical: only removes `[mcp_servers.sqz]`. Other `[mcp_servers.*]`
426/// entries and unrelated keys are preserved. Uses `toml_edit` to keep
427/// the user's comments, key order, and formatting intact.
428///
429/// If removing sqz empties out `[mcp_servers]`, the now-empty table is
430/// dropped so the config file doesn't end up with dangling headers.
431/// If the file would become empty after the surgery (only sqz's entry
432/// existed) we delete it entirely — a zero-byte `config.toml` is just
433/// noise.
434///
435/// Returns `Ok((path, changed))`:
436///   - `changed = true`  — sqz's entry was removed or the file deleted
437///   - `changed = false` — no sqz entry found; nothing changed
438///
439/// Returns `Ok(None)` if the config file does not exist at all.
440pub 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            // Can't parse — leave it alone rather than destroy user data.
452            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    // If mcp_servers is now empty, drop the whole table so the file
466    // doesn't carry a dangling `[mcp_servers]` header.
467    let mcp_is_empty = mcp_table.iter().count() == 0;
468    if mcp_is_empty {
469        doc.remove("mcp_servers");
470    }
471
472    // If the whole document is empty now, remove the file.
473    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// ── Tests ────────────────────────────────────────────────────────────────
488
489#[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    // ── AGENTS.md guidance block ─────────────────────────────────────
525
526    #[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    // ── ~/.codex/config.toml merger ──────────────────────────────────
645
646    /// Helper: run `f` with CODEX_HOME pointing at `home` so install/uninstall
647    /// touch only the tempdir.
648    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}