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- **`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
159// ── AGENTS.md install/uninstall ──────────────────────────────────────────
160
161/// Return `true` if the given `AGENTS.md` content already contains sqz's
162/// guidance block (matched by the BEGIN sentinel).
163fn agents_md_has_sqz_block(content: &str) -> bool {
164    content.contains(AGENTS_MD_BEGIN)
165}
166
167/// Install sqz's guidance block into `AGENTS.md` at `project_dir`.
168///
169/// If `AGENTS.md` doesn't exist yet, create it with sqz's block as the
170/// sole content. If it exists, append sqz's block (separated by a blank
171/// line so it renders as a new markdown section). If the block is
172/// already present (detected by the BEGIN sentinel), return `Ok(false)`
173/// without touching the file — `sqz init` stays idempotent.
174///
175/// Returns `true` when the file was created or modified, `false` when
176/// sqz's block was already present.
177pub 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        // Append with a guaranteed blank-line separator so sqz's section
189        // doesn't accidentally fuse with a trailing user section.
190        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    // Fresh AGENTS.md — write just sqz's block. A tiny preamble is added
205    // so `AGENTS.md` is self-explanatory to readers who encounter it for
206    // the first time and don't know the convention.
207    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
220/// Remove sqz's guidance block from `AGENTS.md`.
221///
222/// Locates the block by its BEGIN/END sentinels and excises it. If the
223/// resulting `AGENTS.md` is empty (modulo whitespace) or contains only
224/// the stock preamble sqz itself wrote on first install, the file is
225/// deleted entirely — leaving an empty `AGENTS.md` would be noise.
226/// If sentinel markers are missing (user edited them out or the file
227/// was never installed by sqz), this is a no-op.
228///
229/// Returns `Ok((path, removed_or_changed))`:
230///   - `removed_or_changed = true`  — the file was modified or deleted
231///   - `removed_or_changed = false` — no sqz block was found; nothing changed
232///
233/// Returns `Ok(None)` when `AGENTS.md` does not exist at all.
234pub 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    // Find the slice that contains sqz's block (BEGIN..=END sentinel,
247    // plus any immediate surrounding blank line we contributed on install
248    // so the remaining file reads cleanly).
249    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            // BEGIN without END — truncated/edited file. Preserve the
257            // user's file and emit a soft no-op rather than destroying
258            // content we can't precisely delimit.
259            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    // Trim any trailing blank-line run that was inserted to separate
266    // sqz's block from what preceded it — but keep one trailing newline
267    // if the file had content before the block.
268    while new_content.ends_with("\n\n\n") {
269        new_content.pop();
270    }
271    let tail = &content[after_end_idx..];
272    // Skip the leading newline(s) right after our END sentinel so the
273    // trailing user content starts cleanly.
274    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    // If the remaining content is empty (or only the stock preamble sqz
283    // wrote on first install), remove the file entirely — a near-empty
284    // AGENTS.md is worse than no file.
285    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
299/// Return `true` when the remaining `AGENTS.md` content has no
300/// user-authored material — either empty or just the stock preamble sqz
301/// itself wrote on first install. Used by `remove_agents_md_guidance`
302/// to decide between "write back the trimmed file" and "delete it".
303fn is_essentially_empty_agents_md(s: &str) -> bool {
304    let trimmed = s.trim();
305    if trimmed.is_empty() {
306        return true;
307    }
308    // Match the exact preamble sqz writes on first install (with its
309    // heading, one paragraph of explanation, and no user additions).
310    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
327// ── ~/.codex/config.toml merger ──────────────────────────────────────────
328
329/// Merge sqz's MCP server entry into Codex's user-level `config.toml`.
330///
331/// Codex reads MCP servers from `~/.codex/config.toml` (or
332/// `$CODEX_HOME/config.toml` when set). The relevant TOML shape is:
333///
334/// ```toml
335/// [mcp_servers.sqz]
336/// command = "sqz-mcp"
337/// args = ["--transport", "stdio"]
338/// ```
339///
340/// Notes:
341///   - Key is `mcp_servers` (snake_case). JSON-tool users who write
342///     `mcpServers` will be quietly ignored by Codex.
343///   - We go through `toml_edit` so the user's existing comments,
344///     key order, and formatting are preserved across the round-trip.
345///     Plain `toml::from_str → toml::to_string` wipes comments.
346///
347/// Idempotent: if `[mcp_servers.sqz]` is already present with a `command`
348/// field, we do not overwrite — the user may have tuned it. Returns
349/// `Ok(false)` in that case.
350///
351/// If the config file doesn't exist yet, it is created with only sqz's
352/// entry (and its parent `~/.codex/` directory is created on demand).
353pub fn install_codex_mcp_config() -> Result<bool> {
354    install_codex_mcp_config_at(None)
355}
356
357/// Internal: home-dir-injectable counterpart used by tests. Avoids
358/// `std::env::set_var` which races with parallel tests that also read
359/// HOME (e.g. the api_proxy property tests that open `~/.sqz/sessions.db`).
360/// See `claude_md_integration::install_claude_mcp_config_at` for the
361/// same pattern.
362pub(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    // Read or start from blank. We build on a toml_edit::DocumentMut so
369    // any prior comments/whitespace survive the round-trip.
370    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    // Idempotency: if [mcp_servers.sqz].command is already set, skip.
386    // We only check `command` (not just the table's presence) because a
387    // stub `[mcp_servers.sqz]` with no keys would be a misconfigured
388    // install worth repairing.
389    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    // Build the sqz table in place. Using `get_mut(..).or_insert_with(..)`
400    // pattern so we add keys to any existing [mcp_servers] without
401    // replacing the whole table. We deliberately leave the parent
402    // `mcp_servers` implicit (i.e. don't emit an explicit
403    // `[mcp_servers]` header before the sub-tables). The TOML spec
404    // treats `[mcp_servers.sqz]` and `[mcp_servers.jira]` as proper
405    // subtables that implicitly create their parent, and emitting a
406    // bare `[mcp_servers]` header before them is redundant noise in
407    // the file.
408    let mcp_servers = doc
409        .entry("mcp_servers")
410        .or_insert_with(|| {
411            // Start the new table as implicit so toml_edit doesn't
412            // write `[mcp_servers]` before the first subtable header.
413            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    // Populate the server's required fields. Only write keys the user
433    // hasn't already set so a hand-tuned config survives re-runs.
434    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    // Make sure the parent directory exists.
445    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
460/// Remove sqz's MCP entry from Codex's user-level `config.toml`.
461///
462/// Surgical: only removes `[mcp_servers.sqz]`. Other `[mcp_servers.*]`
463/// entries and unrelated keys are preserved. Uses `toml_edit` to keep
464/// the user's comments, key order, and formatting intact.
465///
466/// If removing sqz empties out `[mcp_servers]`, the now-empty table is
467/// dropped so the config file doesn't end up with dangling headers.
468/// If the file would become empty after the surgery (only sqz's entry
469/// existed) we delete it entirely — a zero-byte `config.toml` is just
470/// noise.
471///
472/// Returns `Ok((path, changed))`:
473///   - `changed = true`  — sqz's entry was removed or the file deleted
474///   - `changed = false` — no sqz entry found; nothing changed
475///
476/// Returns `Ok(None)` if the config file does not exist at all.
477pub fn remove_codex_mcp_config() -> Result<Option<(PathBuf, bool)>> {
478    remove_codex_mcp_config_at(None)
479}
480
481/// Internal: home-dir-injectable counterpart used by tests. See
482/// `install_codex_mcp_config_at` for rationale.
483pub(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            // Can't parse — leave it alone rather than destroy user data.
500            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    // If mcp_servers is now empty, drop the whole table so the file
514    // doesn't carry a dangling `[mcp_servers]` header.
515    let mcp_is_empty = mcp_table.iter().count() == 0;
516    if mcp_is_empty {
517        doc.remove("mcp_servers");
518    }
519
520    // If the whole document is empty now, remove the file.
521    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// ── Tests ────────────────────────────────────────────────────────────────
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    // codex_config_path() reads CODEX_HOME / HOME env vars. Testing it
542    // with set_var is inherently racy under parallel test execution, so
543    // we only verify the structural invariant: the returned path always
544    // ends with "config.toml".
545    #[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    // ── AGENTS.md guidance block ─────────────────────────────────────
556
557    #[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    // ── ~/.codex/config.toml merger ──────────────────────────────────
676
677    #[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}