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]`. 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
143If you're using the MCP server, the `passthrough` tool returns raw
144text and the `expand` tool resolves refs — call them when you need
145data sqz hasn't touched.
146
147{end}
148",
149        begin = AGENTS_MD_BEGIN,
150        end = AGENTS_MD_END,
151    )
152}
153
154// ── AGENTS.md install/uninstall ──────────────────────────────────────────
155
156/// Return `true` if the given `AGENTS.md` content already contains sqz's
157/// guidance block (matched by the BEGIN sentinel).
158fn agents_md_has_sqz_block(content: &str) -> bool {
159    content.contains(AGENTS_MD_BEGIN)
160}
161
162/// Install sqz's guidance block into `AGENTS.md` at `project_dir`.
163///
164/// If `AGENTS.md` doesn't exist yet, create it with sqz's block as the
165/// sole content. If it exists, append sqz's block (separated by a blank
166/// line so it renders as a new markdown section). If the block is
167/// already present (detected by the BEGIN sentinel), return `Ok(false)`
168/// without touching the file — `sqz init` stays idempotent.
169///
170/// Returns `true` when the file was created or modified, `false` when
171/// sqz's block was already present.
172pub fn install_agents_md_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
173    let path = agents_md_path(project_dir);
174    let block = agents_md_guidance_block(sqz_path);
175
176    if path.exists() {
177        let existing = std::fs::read_to_string(&path).map_err(|e| {
178            SqzError::Other(format!("failed to read {}: {e}", path.display()))
179        })?;
180        if agents_md_has_sqz_block(&existing) {
181            return Ok(false);
182        }
183        // Append with a guaranteed blank-line separator so sqz's section
184        // doesn't accidentally fuse with a trailing user section.
185        let mut new_content = existing;
186        if !new_content.ends_with('\n') {
187            new_content.push('\n');
188        }
189        if !new_content.ends_with("\n\n") {
190            new_content.push('\n');
191        }
192        new_content.push_str(&block);
193        std::fs::write(&path, new_content).map_err(|e| {
194            SqzError::Other(format!("failed to write {}: {e}", path.display()))
195        })?;
196        return Ok(true);
197    }
198
199    // Fresh AGENTS.md — write just sqz's block. A tiny preamble is added
200    // so `AGENTS.md` is self-explanatory to readers who encounter it for
201    // the first time and don't know the convention.
202    let preamble = "\
203# AGENTS.md
204
205Instructions for AI coding agents (OpenAI Codex, GitHub Copilot, Cursor,
206Windsurf, Amp, Devin) working in this repository. See <https://agentsmd.io>.
207
208";
209    let content = format!("{preamble}{block}");
210    std::fs::write(&path, content)
211        .map_err(|e| SqzError::Other(format!("failed to create {}: {e}", path.display())))?;
212    Ok(true)
213}
214
215/// Remove sqz's guidance block from `AGENTS.md`.
216///
217/// Locates the block by its BEGIN/END sentinels and excises it. If the
218/// resulting `AGENTS.md` is empty (modulo whitespace) or contains only
219/// the stock preamble sqz itself wrote on first install, the file is
220/// deleted entirely — leaving an empty `AGENTS.md` would be noise.
221/// If sentinel markers are missing (user edited them out or the file
222/// was never installed by sqz), this is a no-op.
223///
224/// Returns `Ok((path, removed_or_changed))`:
225///   - `removed_or_changed = true`  — the file was modified or deleted
226///   - `removed_or_changed = false` — no sqz block was found; nothing changed
227///
228/// Returns `Ok(None)` when `AGENTS.md` does not exist at all.
229pub fn remove_agents_md_guidance(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
230    let path = agents_md_path(project_dir);
231    if !path.exists() {
232        return Ok(None);
233    }
234    let content = std::fs::read_to_string(&path).map_err(|e| {
235        SqzError::Other(format!("failed to read {}: {e}", path.display()))
236    })?;
237    if !agents_md_has_sqz_block(&content) {
238        return Ok(Some((path, false)));
239    }
240
241    // Find the slice that contains sqz's block (BEGIN..=END sentinel,
242    // plus any immediate surrounding blank line we contributed on install
243    // so the remaining file reads cleanly).
244    let begin_idx = match content.find(AGENTS_MD_BEGIN) {
245        Some(i) => i,
246        None => return Ok(Some((path, false))),
247    };
248    let after_end_idx = match content.find(AGENTS_MD_END) {
249        Some(i) => i + AGENTS_MD_END.len(),
250        None => {
251            // BEGIN without END — truncated/edited file. Preserve the
252            // user's file and emit a soft no-op rather than destroying
253            // content we can't precisely delimit.
254            return Ok(Some((path, false)));
255        }
256    };
257
258    let mut new_content = String::with_capacity(content.len());
259    new_content.push_str(&content[..begin_idx]);
260    // Trim any trailing blank-line run that was inserted to separate
261    // sqz's block from what preceded it — but keep one trailing newline
262    // if the file had content before the block.
263    while new_content.ends_with("\n\n\n") {
264        new_content.pop();
265    }
266    let tail = &content[after_end_idx..];
267    // Skip the leading newline(s) right after our END sentinel so the
268    // trailing user content starts cleanly.
269    let trimmed_tail = tail.trim_start_matches('\n');
270    if !trimmed_tail.is_empty() {
271        if !new_content.ends_with('\n') {
272            new_content.push('\n');
273        }
274        new_content.push_str(trimmed_tail);
275    }
276
277    // If the remaining content is empty (or only the stock preamble sqz
278    // wrote on first install), remove the file entirely — a near-empty
279    // AGENTS.md is worse than no file.
280    let essentially_empty = is_essentially_empty_agents_md(&new_content);
281    if essentially_empty {
282        std::fs::remove_file(&path).map_err(|e| {
283            SqzError::Other(format!("failed to remove {}: {e}", path.display()))
284        })?;
285        return Ok(Some((path, true)));
286    }
287
288    std::fs::write(&path, new_content).map_err(|e| {
289        SqzError::Other(format!("failed to write {}: {e}", path.display()))
290    })?;
291    Ok(Some((path, true)))
292}
293
294/// Return `true` when the remaining `AGENTS.md` content has no
295/// user-authored material — either empty or just the stock preamble sqz
296/// itself wrote on first install. Used by `remove_agents_md_guidance`
297/// to decide between "write back the trimmed file" and "delete it".
298fn is_essentially_empty_agents_md(s: &str) -> bool {
299    let trimmed = s.trim();
300    if trimmed.is_empty() {
301        return true;
302    }
303    // Match the exact preamble sqz writes on first install (with its
304    // heading, one paragraph of explanation, and no user additions).
305    const PREAMBLE_MARKERS: &[&str] = &[
306        "# AGENTS.md",
307        "Instructions for AI coding agents",
308        "See <https://agentsmd.io>.",
309    ];
310    let has_only_preamble = PREAMBLE_MARKERS.iter().all(|m| trimmed.contains(m))
311        && !trimmed
312            .lines()
313            .any(|l| {
314                let l = l.trim();
315                !l.is_empty()
316                    && !l.starts_with('#')
317                    && !PREAMBLE_MARKERS.iter().any(|m| l.contains(m))
318            });
319    has_only_preamble
320}
321
322// ── ~/.codex/config.toml merger ──────────────────────────────────────────
323
324/// Merge sqz's MCP server entry into Codex's user-level `config.toml`.
325///
326/// Codex reads MCP servers from `~/.codex/config.toml` (or
327/// `$CODEX_HOME/config.toml` when set). The relevant TOML shape is:
328///
329/// ```toml
330/// [mcp_servers.sqz]
331/// command = "sqz-mcp"
332/// args = ["--transport", "stdio"]
333/// ```
334///
335/// Notes:
336///   - Key is `mcp_servers` (snake_case). JSON-tool users who write
337///     `mcpServers` will be quietly ignored by Codex.
338///   - We go through `toml_edit` so the user's existing comments,
339///     key order, and formatting are preserved across the round-trip.
340///     Plain `toml::from_str → toml::to_string` wipes comments.
341///
342/// Idempotent: if `[mcp_servers.sqz]` is already present with a `command`
343/// field, we do not overwrite — the user may have tuned it. Returns
344/// `Ok(false)` in that case.
345///
346/// If the config file doesn't exist yet, it is created with only sqz's
347/// entry (and its parent `~/.codex/` directory is created on demand).
348pub fn install_codex_mcp_config() -> Result<bool> {
349    install_codex_mcp_config_at(None)
350}
351
352/// Internal: home-dir-injectable counterpart used by tests. Avoids
353/// `std::env::set_var` which races with parallel tests that also read
354/// HOME (e.g. the api_proxy property tests that open `~/.sqz/sessions.db`).
355/// See `claude_md_integration::install_claude_mcp_config_at` for the
356/// same pattern.
357pub(crate) fn install_codex_mcp_config_at(home_override: Option<&Path>) -> Result<bool> {
358    let path = match home_override {
359        Some(h) => h.join("config.toml"),
360        None => codex_config_path(),
361    };
362
363    // Read or start from blank. We build on a toml_edit::DocumentMut so
364    // any prior comments/whitespace survive the round-trip.
365    let existing = if path.exists() {
366        std::fs::read_to_string(&path).map_err(|e| {
367            SqzError::Other(format!("failed to read {}: {e}", path.display()))
368        })?
369    } else {
370        String::new()
371    };
372
373    let mut doc: toml_edit::DocumentMut = existing
374        .parse()
375        .map_err(|e| SqzError::Other(format!(
376            "failed to parse {} as TOML: {e}",
377            path.display()
378        )))?;
379
380    // Idempotency: if [mcp_servers.sqz].command is already set, skip.
381    // We only check `command` (not just the table's presence) because a
382    // stub `[mcp_servers.sqz]` with no keys would be a misconfigured
383    // install worth repairing.
384    if let Some(existing_cmd) = doc
385        .get("mcp_servers")
386        .and_then(|v| v.get("sqz"))
387        .and_then(|v| v.get("command"))
388    {
389        if existing_cmd.is_value() {
390            return Ok(false);
391        }
392    }
393
394    // Build the sqz table in place. Using `get_mut(..).or_insert_with(..)`
395    // pattern so we add keys to any existing [mcp_servers] without
396    // replacing the whole table. We deliberately leave the parent
397    // `mcp_servers` implicit (i.e. don't emit an explicit
398    // `[mcp_servers]` header before the sub-tables). The TOML spec
399    // treats `[mcp_servers.sqz]` and `[mcp_servers.jira]` as proper
400    // subtables that implicitly create their parent, and emitting a
401    // bare `[mcp_servers]` header before them is redundant noise in
402    // the file.
403    let mcp_servers = doc
404        .entry("mcp_servers")
405        .or_insert_with(|| {
406            // Start the new table as implicit so toml_edit doesn't
407            // write `[mcp_servers]` before the first subtable header.
408            let mut t = toml_edit::Table::new();
409            t.set_implicit(true);
410            toml_edit::Item::Table(t)
411        })
412        .as_table_mut()
413        .ok_or_else(|| SqzError::Other(format!(
414            "{}: `mcp_servers` is not a table — refusing to overwrite",
415            path.display()
416        )))?;
417
418    let sqz = mcp_servers
419        .entry("sqz")
420        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
421        .as_table_mut()
422        .ok_or_else(|| SqzError::Other(format!(
423            "{}: `mcp_servers.sqz` is not a table — refusing to overwrite",
424            path.display()
425        )))?;
426
427    // Populate the server's required fields. Only write keys the user
428    // hasn't already set so a hand-tuned config survives re-runs.
429    if !sqz.contains_key("command") {
430        sqz["command"] = toml_edit::value("sqz-mcp");
431    }
432    if !sqz.contains_key("args") {
433        let mut args = toml_edit::Array::new();
434        args.push("--transport");
435        args.push("stdio");
436        sqz["args"] = toml_edit::Item::Value(toml_edit::Value::Array(args));
437    }
438
439    // Make sure the parent directory exists.
440    if let Some(parent) = path.parent() {
441        std::fs::create_dir_all(parent).map_err(|e| {
442            SqzError::Other(format!(
443                "failed to create {}: {e}",
444                parent.display()
445            ))
446        })?;
447    }
448
449    std::fs::write(&path, doc.to_string()).map_err(|e| {
450        SqzError::Other(format!("failed to write {}: {e}", path.display()))
451    })?;
452    Ok(true)
453}
454
455/// Remove sqz's MCP entry from Codex's user-level `config.toml`.
456///
457/// Surgical: only removes `[mcp_servers.sqz]`. Other `[mcp_servers.*]`
458/// entries and unrelated keys are preserved. Uses `toml_edit` to keep
459/// the user's comments, key order, and formatting intact.
460///
461/// If removing sqz empties out `[mcp_servers]`, the now-empty table is
462/// dropped so the config file doesn't end up with dangling headers.
463/// If the file would become empty after the surgery (only sqz's entry
464/// existed) we delete it entirely — a zero-byte `config.toml` is just
465/// noise.
466///
467/// Returns `Ok((path, changed))`:
468///   - `changed = true`  — sqz's entry was removed or the file deleted
469///   - `changed = false` — no sqz entry found; nothing changed
470///
471/// Returns `Ok(None)` if the config file does not exist at all.
472pub fn remove_codex_mcp_config() -> Result<Option<(PathBuf, bool)>> {
473    remove_codex_mcp_config_at(None)
474}
475
476/// Internal: home-dir-injectable counterpart used by tests. See
477/// `install_codex_mcp_config_at` for rationale.
478pub(crate) fn remove_codex_mcp_config_at(
479    home_override: Option<&Path>,
480) -> Result<Option<(PathBuf, bool)>> {
481    let path = match home_override {
482        Some(h) => h.join("config.toml"),
483        None => codex_config_path(),
484    };
485    if !path.exists() {
486        return Ok(None);
487    }
488    let raw = std::fs::read_to_string(&path)
489        .map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
490
491    let mut doc: toml_edit::DocumentMut = match raw.parse() {
492        Ok(d) => d,
493        Err(_) => {
494            // Can't parse — leave it alone rather than destroy user data.
495            return Ok(Some((path, false)));
496        }
497    };
498
499    let mcp_table = match doc.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
500        Some(t) => t,
501        None => return Ok(Some((path, false))),
502    };
503    if !mcp_table.contains_key("sqz") {
504        return Ok(Some((path, false)));
505    }
506    mcp_table.remove("sqz");
507
508    // If mcp_servers is now empty, drop the whole table so the file
509    // doesn't carry a dangling `[mcp_servers]` header.
510    let mcp_is_empty = mcp_table.iter().count() == 0;
511    if mcp_is_empty {
512        doc.remove("mcp_servers");
513    }
514
515    // If the whole document is empty now, remove the file.
516    let doc_is_empty = doc.iter().count() == 0;
517    if doc_is_empty {
518        std::fs::remove_file(&path).map_err(|e| {
519            SqzError::Other(format!("failed to remove {}: {e}", path.display()))
520        })?;
521        return Ok(Some((path, true)));
522    }
523
524    std::fs::write(&path, doc.to_string()).map_err(|e| {
525        SqzError::Other(format!("failed to write {}: {e}", path.display()))
526    })?;
527    Ok(Some((path, true)))
528}
529
530// ── Tests ────────────────────────────────────────────────────────────────
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    // codex_config_path() reads CODEX_HOME / HOME env vars. Testing it
537    // with set_var is inherently racy under parallel test execution, so
538    // we only verify the structural invariant: the returned path always
539    // ends with "config.toml".
540    #[test]
541    fn codex_config_path_ends_with_config_toml() {
542        let got = codex_config_path();
543        assert!(
544            got.ends_with("config.toml"),
545            "codex_config_path() must end with config.toml, got: {}",
546            got.display()
547        );
548    }
549
550    // ── AGENTS.md guidance block ─────────────────────────────────────
551
552    #[test]
553    fn agents_md_guidance_block_contains_sqz_invocation() {
554        let block = agents_md_guidance_block("/usr/local/bin/sqz");
555        assert!(block.contains(AGENTS_MD_BEGIN));
556        assert!(block.contains(AGENTS_MD_END));
557        assert!(block.contains("| /usr/local/bin/sqz compress"));
558        assert!(block.contains("sqz-mcp"));
559    }
560
561    #[test]
562    fn install_agents_md_creates_file_with_preamble() {
563        let dir = tempfile::tempdir().unwrap();
564        let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
565        assert!(created);
566        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
567        assert!(content.starts_with("# AGENTS.md"));
568        assert!(content.contains(AGENTS_MD_BEGIN));
569        assert!(content.contains(AGENTS_MD_END));
570    }
571
572    #[test]
573    fn install_agents_md_appends_without_clobbering_user_content() {
574        let dir = tempfile::tempdir().unwrap();
575        let path = dir.path().join("AGENTS.md");
576        std::fs::write(
577            &path,
578            "# My project rules\n\nBe polite. Run tests before committing.\n",
579        ).unwrap();
580
581        let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
582        assert!(created);
583
584        let content = std::fs::read_to_string(&path).unwrap();
585        assert!(content.contains("# My project rules"),
586            "original heading must survive");
587        assert!(content.contains("Be polite. Run tests before committing."),
588            "original body must survive");
589        let user_idx = content.find("Be polite").unwrap();
590        let sqz_idx = content.find(AGENTS_MD_BEGIN).unwrap();
591        assert!(sqz_idx > user_idx,
592            "sqz's block must append after user content, not prepend");
593    }
594
595    #[test]
596    fn install_agents_md_is_idempotent() {
597        let dir = tempfile::tempdir().unwrap();
598        let first = install_agents_md_guidance(dir.path(), "sqz").unwrap();
599        assert!(first);
600        let second = install_agents_md_guidance(dir.path(), "sqz").unwrap();
601        assert!(!second, "second install must be a no-op");
602
603        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
604        let occurrences = content.matches(AGENTS_MD_BEGIN).count();
605        assert_eq!(occurrences, 1, "must not duplicate the block on re-install");
606    }
607
608    #[test]
609    fn remove_agents_md_preserves_user_content() {
610        let dir = tempfile::tempdir().unwrap();
611        let path = dir.path().join("AGENTS.md");
612        std::fs::write(
613            &path,
614            "# My project rules\n\nBe polite. Run tests before committing.\n",
615        ).unwrap();
616        install_agents_md_guidance(dir.path(), "sqz").unwrap();
617
618        let (returned_path, changed) =
619            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
620        assert_eq!(returned_path, path);
621        assert!(changed);
622        assert!(path.exists(),
623            "file must NOT be deleted — it has user content");
624
625        let content = std::fs::read_to_string(&path).unwrap();
626        assert!(!content.contains(AGENTS_MD_BEGIN));
627        assert!(!content.contains(AGENTS_MD_END));
628        assert!(content.contains("# My project rules"),
629            "user heading must survive the uninstall");
630        assert!(content.contains("Be polite. Run tests before committing."),
631            "user body must survive the uninstall");
632    }
633
634    #[test]
635    fn remove_agents_md_deletes_file_when_only_sqz_preamble_remains() {
636        let dir = tempfile::tempdir().unwrap();
637        install_agents_md_guidance(dir.path(), "sqz").unwrap();
638        let path = dir.path().join("AGENTS.md");
639        assert!(path.exists());
640
641        let (_returned, changed) =
642            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
643        assert!(changed);
644        assert!(
645            !path.exists(),
646            "fresh-install AGENTS.md must be removed when sqz block is stripped"
647        );
648    }
649
650    #[test]
651    fn remove_agents_md_noop_when_block_missing() {
652        let dir = tempfile::tempdir().unwrap();
653        let path = dir.path().join("AGENTS.md");
654        std::fs::write(&path, "# User-authored, sqz never touched this.\n").unwrap();
655
656        let (returned_path, changed) =
657            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
658        assert_eq!(returned_path, path);
659        assert!(!changed, "no sqz block means no change");
660        assert!(path.exists(), "user file must be untouched");
661    }
662
663    #[test]
664    fn remove_agents_md_returns_none_when_file_missing() {
665        let dir = tempfile::tempdir().unwrap();
666        let result = remove_agents_md_guidance(dir.path()).unwrap();
667        assert!(result.is_none());
668    }
669
670    // ── ~/.codex/config.toml merger ──────────────────────────────────
671
672    #[test]
673    fn install_codex_mcp_config_creates_file_with_sqz_entry() {
674        let dir = tempfile::tempdir().unwrap();
675        let created = install_codex_mcp_config_at(Some(dir.path())).unwrap();
676        assert!(created);
677        let content = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
678        assert!(
679            content.contains("[mcp_servers.sqz]"),
680            "config.toml must contain [mcp_servers.sqz] header; got:\n{content}"
681        );
682        assert!(content.contains("command = \"sqz-mcp\""),
683            "command must be sqz-mcp");
684        assert!(content.contains("--transport"));
685        assert!(content.contains("stdio"));
686    }
687
688    #[test]
689    fn install_codex_mcp_config_preserves_existing_other_servers() {
690        let dir = tempfile::tempdir().unwrap();
691        let cfg = dir.path().join("config.toml");
692        std::fs::write(
693            &cfg,
694            "# User's existing Codex config, with a comment.\n\
695             model = \"gpt-5\"\n\
696             \n\
697             [mcp_servers.other]\n\
698             command = \"other-server\"\n\
699             args = [\"--flag\"]\n",
700        ).unwrap();
701
702        let created = install_codex_mcp_config_at(Some(dir.path())).unwrap();
703        assert!(created);
704
705        let after = std::fs::read_to_string(&cfg).unwrap();
706        assert!(after.contains("# User's existing Codex config"),
707            "comment must survive: {after}");
708        assert!(after.contains("model = \"gpt-5\""),
709            "top-level key must survive: {after}");
710        assert!(after.contains("[mcp_servers.other]"),
711            "existing server entry must survive: {after}");
712        assert!(after.contains("command = \"other-server\""),
713            "existing server command must survive: {after}");
714        assert!(after.contains("[mcp_servers.sqz]"),
715            "sqz entry must be added: {after}");
716    }
717
718    #[test]
719    fn install_codex_mcp_config_is_idempotent() {
720        let dir = tempfile::tempdir().unwrap();
721        assert!(install_codex_mcp_config_at(Some(dir.path())).unwrap());
722        assert!(
723            !install_codex_mcp_config_at(Some(dir.path())).unwrap(),
724            "second install with complete [mcp_servers.sqz] must be a no-op"
725        );
726    }
727
728    #[test]
729    fn install_codex_mcp_config_does_not_overwrite_user_tuned_entry() {
730        let dir = tempfile::tempdir().unwrap();
731        let cfg = dir.path().join("config.toml");
732        std::fs::write(
733            &cfg,
734            "[mcp_servers.sqz]\n\
735             command = \"/custom/path/sqz-mcp\"\n\
736             args = [\"--transport\", \"sse\", \"--port\", \"3999\"]\n",
737        ).unwrap();
738
739        let changed = install_codex_mcp_config_at(Some(dir.path())).unwrap();
740        assert!(!changed, "existing complete entry must be idempotent-skipped");
741        let after = std::fs::read_to_string(&cfg).unwrap();
742        assert!(after.contains("/custom/path/sqz-mcp"),
743            "user's custom command must survive re-init");
744        assert!(after.contains("\"sse\""),
745            "user's custom transport must survive");
746    }
747
748    #[test]
749    fn remove_codex_mcp_config_removes_only_sqz_entry() {
750        let dir = tempfile::tempdir().unwrap();
751        let cfg = dir.path().join("config.toml");
752        std::fs::write(
753            &cfg,
754            "# keep this comment\n\
755             model = \"gpt-5\"\n\
756             \n\
757             [mcp_servers.other]\n\
758             command = \"other-server\"\n\
759             \n\
760             [mcp_servers.sqz]\n\
761             command = \"sqz-mcp\"\n\
762             args = [\"--transport\", \"stdio\"]\n",
763        ).unwrap();
764
765        let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
766        assert_eq!(path, cfg);
767        assert!(changed);
768
769        let after = std::fs::read_to_string(&cfg).unwrap();
770        assert!(after.contains("# keep this comment"),
771            "comment must survive: {after}");
772        assert!(after.contains("model = \"gpt-5\""),
773            "top-level key must survive: {after}");
774        assert!(after.contains("[mcp_servers.other]"),
775            "other server entry must survive: {after}");
776        assert!(!after.contains("[mcp_servers.sqz]"),
777            "sqz entry must be gone: {after}");
778    }
779
780    #[test]
781    fn remove_codex_mcp_config_deletes_file_when_sqz_was_the_only_entry() {
782        let dir = tempfile::tempdir().unwrap();
783        let cfg = dir.path().join("config.toml");
784        install_codex_mcp_config_at(Some(dir.path())).unwrap();
785        let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
786        assert_eq!(path, cfg);
787        assert!(changed);
788        assert!(!cfg.exists(),
789            "config.toml with only sqz must be deleted on uninstall");
790    }
791
792    #[test]
793    fn remove_codex_mcp_config_returns_none_when_file_missing() {
794        let dir = tempfile::tempdir().unwrap();
795        let result = remove_codex_mcp_config_at(Some(dir.path())).unwrap();
796        assert!(result.is_none());
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        let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
805        assert_eq!(path, cfg);
806        assert!(!changed);
807        let after = std::fs::read_to_string(&cfg).unwrap();
808        assert!(after.contains("[mcp_servers.other]"));
809    }
810}