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    let path = codex_config_path();
350
351    // Read or start from blank. We build on a toml_edit::DocumentMut so
352    // any prior comments/whitespace survive the round-trip.
353    let existing = if path.exists() {
354        std::fs::read_to_string(&path).map_err(|e| {
355            SqzError::Other(format!("failed to read {}: {e}", path.display()))
356        })?
357    } else {
358        String::new()
359    };
360
361    let mut doc: toml_edit::DocumentMut = existing
362        .parse()
363        .map_err(|e| SqzError::Other(format!(
364            "failed to parse {} as TOML: {e}",
365            path.display()
366        )))?;
367
368    // Idempotency: if [mcp_servers.sqz].command is already set, skip.
369    // We only check `command` (not just the table's presence) because a
370    // stub `[mcp_servers.sqz]` with no keys would be a misconfigured
371    // install worth repairing.
372    if let Some(existing_cmd) = doc
373        .get("mcp_servers")
374        .and_then(|v| v.get("sqz"))
375        .and_then(|v| v.get("command"))
376    {
377        if existing_cmd.is_value() {
378            return Ok(false);
379        }
380    }
381
382    // Build the sqz table in place. Using `get_mut(..).or_insert_with(..)`
383    // pattern so we add keys to any existing [mcp_servers] without
384    // replacing the whole table. We deliberately leave the parent
385    // `mcp_servers` implicit (i.e. don't emit an explicit
386    // `[mcp_servers]` header before the sub-tables). The TOML spec
387    // treats `[mcp_servers.sqz]` and `[mcp_servers.jira]` as proper
388    // subtables that implicitly create their parent, and emitting a
389    // bare `[mcp_servers]` header before them is redundant noise in
390    // the file.
391    let mcp_servers = doc
392        .entry("mcp_servers")
393        .or_insert_with(|| {
394            // Start the new table as implicit so toml_edit doesn't
395            // write `[mcp_servers]` before the first subtable header.
396            let mut t = toml_edit::Table::new();
397            t.set_implicit(true);
398            toml_edit::Item::Table(t)
399        })
400        .as_table_mut()
401        .ok_or_else(|| SqzError::Other(format!(
402            "{}: `mcp_servers` is not a table — refusing to overwrite",
403            path.display()
404        )))?;
405
406    let sqz = mcp_servers
407        .entry("sqz")
408        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
409        .as_table_mut()
410        .ok_or_else(|| SqzError::Other(format!(
411            "{}: `mcp_servers.sqz` is not a table — refusing to overwrite",
412            path.display()
413        )))?;
414
415    // Populate the server's required fields. Only write keys the user
416    // hasn't already set so a hand-tuned config survives re-runs.
417    if !sqz.contains_key("command") {
418        sqz["command"] = toml_edit::value("sqz-mcp");
419    }
420    if !sqz.contains_key("args") {
421        let mut args = toml_edit::Array::new();
422        args.push("--transport");
423        args.push("stdio");
424        sqz["args"] = toml_edit::Item::Value(toml_edit::Value::Array(args));
425    }
426
427    // Make sure the parent directory exists.
428    if let Some(parent) = path.parent() {
429        std::fs::create_dir_all(parent).map_err(|e| {
430            SqzError::Other(format!(
431                "failed to create {}: {e}",
432                parent.display()
433            ))
434        })?;
435    }
436
437    std::fs::write(&path, doc.to_string()).map_err(|e| {
438        SqzError::Other(format!("failed to write {}: {e}", path.display()))
439    })?;
440    Ok(true)
441}
442
443/// Remove sqz's MCP entry from Codex's user-level `config.toml`.
444///
445/// Surgical: only removes `[mcp_servers.sqz]`. Other `[mcp_servers.*]`
446/// entries and unrelated keys are preserved. Uses `toml_edit` to keep
447/// the user's comments, key order, and formatting intact.
448///
449/// If removing sqz empties out `[mcp_servers]`, the now-empty table is
450/// dropped so the config file doesn't end up with dangling headers.
451/// If the file would become empty after the surgery (only sqz's entry
452/// existed) we delete it entirely — a zero-byte `config.toml` is just
453/// noise.
454///
455/// Returns `Ok((path, changed))`:
456///   - `changed = true`  — sqz's entry was removed or the file deleted
457///   - `changed = false` — no sqz entry found; nothing changed
458///
459/// Returns `Ok(None)` if the config file does not exist at all.
460pub fn remove_codex_mcp_config() -> Result<Option<(PathBuf, bool)>> {
461    let path = codex_config_path();
462    if !path.exists() {
463        return Ok(None);
464    }
465    let raw = std::fs::read_to_string(&path)
466        .map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
467
468    let mut doc: toml_edit::DocumentMut = match raw.parse() {
469        Ok(d) => d,
470        Err(_) => {
471            // Can't parse — leave it alone rather than destroy user data.
472            return Ok(Some((path, false)));
473        }
474    };
475
476    let mcp_table = match doc.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
477        Some(t) => t,
478        None => return Ok(Some((path, false))),
479    };
480    if !mcp_table.contains_key("sqz") {
481        return Ok(Some((path, false)));
482    }
483    mcp_table.remove("sqz");
484
485    // If mcp_servers is now empty, drop the whole table so the file
486    // doesn't carry a dangling `[mcp_servers]` header.
487    let mcp_is_empty = mcp_table.iter().count() == 0;
488    if mcp_is_empty {
489        doc.remove("mcp_servers");
490    }
491
492    // If the whole document is empty now, remove the file.
493    let doc_is_empty = doc.iter().count() == 0;
494    if doc_is_empty {
495        std::fs::remove_file(&path).map_err(|e| {
496            SqzError::Other(format!("failed to remove {}: {e}", path.display()))
497        })?;
498        return Ok(Some((path, true)));
499    }
500
501    std::fs::write(&path, doc.to_string()).map_err(|e| {
502        SqzError::Other(format!("failed to write {}: {e}", path.display()))
503    })?;
504    Ok(Some((path, true)))
505}
506
507// ── Tests ────────────────────────────────────────────────────────────────
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn codex_config_path_honours_codex_home() {
515        let dir = tempfile::tempdir().unwrap();
516        let prev = std::env::var_os("CODEX_HOME");
517        std::env::set_var("CODEX_HOME", dir.path());
518        let got = codex_config_path();
519        assert_eq!(got, dir.path().join("config.toml"));
520        match prev {
521            Some(v) => std::env::set_var("CODEX_HOME", v),
522            None => std::env::remove_var("CODEX_HOME"),
523        }
524    }
525
526    #[test]
527    fn codex_config_path_falls_back_to_home_dot_codex() {
528        let prev_codex = std::env::var_os("CODEX_HOME");
529        let prev_home = std::env::var_os("HOME");
530        std::env::remove_var("CODEX_HOME");
531        std::env::set_var("HOME", "/tmp/codex-home-test");
532        let got = codex_config_path();
533        assert_eq!(got, PathBuf::from("/tmp/codex-home-test/.codex/config.toml"));
534        match prev_codex {
535            Some(v) => std::env::set_var("CODEX_HOME", v),
536            None => std::env::remove_var("CODEX_HOME"),
537        }
538        match prev_home {
539            Some(v) => std::env::set_var("HOME", v),
540            None => std::env::remove_var("HOME"),
541        }
542    }
543
544    // ── AGENTS.md guidance block ─────────────────────────────────────
545
546    #[test]
547    fn agents_md_guidance_block_contains_sqz_invocation() {
548        let block = agents_md_guidance_block("/usr/local/bin/sqz");
549        assert!(block.contains(AGENTS_MD_BEGIN));
550        assert!(block.contains(AGENTS_MD_END));
551        assert!(block.contains("| /usr/local/bin/sqz compress"));
552        assert!(block.contains("sqz-mcp"));
553    }
554
555    #[test]
556    fn install_agents_md_creates_file_with_preamble() {
557        let dir = tempfile::tempdir().unwrap();
558        let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
559        assert!(created);
560        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
561        assert!(content.starts_with("# AGENTS.md"));
562        assert!(content.contains(AGENTS_MD_BEGIN));
563        assert!(content.contains(AGENTS_MD_END));
564    }
565
566    #[test]
567    fn install_agents_md_appends_without_clobbering_user_content() {
568        let dir = tempfile::tempdir().unwrap();
569        let path = dir.path().join("AGENTS.md");
570        std::fs::write(
571            &path,
572            "# My project rules\n\nBe polite. Run tests before committing.\n",
573        ).unwrap();
574
575        let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
576        assert!(created);
577
578        let content = std::fs::read_to_string(&path).unwrap();
579        assert!(content.contains("# My project rules"),
580            "original heading must survive");
581        assert!(content.contains("Be polite. Run tests before committing."),
582            "original body must survive");
583        let user_idx = content.find("Be polite").unwrap();
584        let sqz_idx = content.find(AGENTS_MD_BEGIN).unwrap();
585        assert!(sqz_idx > user_idx,
586            "sqz's block must append after user content, not prepend");
587    }
588
589    #[test]
590    fn install_agents_md_is_idempotent() {
591        let dir = tempfile::tempdir().unwrap();
592        let first = install_agents_md_guidance(dir.path(), "sqz").unwrap();
593        assert!(first);
594        let second = install_agents_md_guidance(dir.path(), "sqz").unwrap();
595        assert!(!second, "second install must be a no-op");
596
597        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
598        let occurrences = content.matches(AGENTS_MD_BEGIN).count();
599        assert_eq!(occurrences, 1, "must not duplicate the block on re-install");
600    }
601
602    #[test]
603    fn remove_agents_md_preserves_user_content() {
604        let dir = tempfile::tempdir().unwrap();
605        let path = dir.path().join("AGENTS.md");
606        std::fs::write(
607            &path,
608            "# My project rules\n\nBe polite. Run tests before committing.\n",
609        ).unwrap();
610        install_agents_md_guidance(dir.path(), "sqz").unwrap();
611
612        let (returned_path, changed) =
613            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
614        assert_eq!(returned_path, path);
615        assert!(changed);
616        assert!(path.exists(),
617            "file must NOT be deleted — it has user content");
618
619        let content = std::fs::read_to_string(&path).unwrap();
620        assert!(!content.contains(AGENTS_MD_BEGIN));
621        assert!(!content.contains(AGENTS_MD_END));
622        assert!(content.contains("# My project rules"),
623            "user heading must survive the uninstall");
624        assert!(content.contains("Be polite. Run tests before committing."),
625            "user body must survive the uninstall");
626    }
627
628    #[test]
629    fn remove_agents_md_deletes_file_when_only_sqz_preamble_remains() {
630        let dir = tempfile::tempdir().unwrap();
631        install_agents_md_guidance(dir.path(), "sqz").unwrap();
632        let path = dir.path().join("AGENTS.md");
633        assert!(path.exists());
634
635        let (_returned, changed) =
636            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
637        assert!(changed);
638        assert!(
639            !path.exists(),
640            "fresh-install AGENTS.md must be removed when sqz block is stripped"
641        );
642    }
643
644    #[test]
645    fn remove_agents_md_noop_when_block_missing() {
646        let dir = tempfile::tempdir().unwrap();
647        let path = dir.path().join("AGENTS.md");
648        std::fs::write(&path, "# User-authored, sqz never touched this.\n").unwrap();
649
650        let (returned_path, changed) =
651            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
652        assert_eq!(returned_path, path);
653        assert!(!changed, "no sqz block means no change");
654        assert!(path.exists(), "user file must be untouched");
655    }
656
657    #[test]
658    fn remove_agents_md_returns_none_when_file_missing() {
659        let dir = tempfile::tempdir().unwrap();
660        let result = remove_agents_md_guidance(dir.path()).unwrap();
661        assert!(result.is_none());
662    }
663
664    // ── ~/.codex/config.toml merger ──────────────────────────────────
665
666    /// Helper: run `f` with CODEX_HOME pointing at `home` so install/uninstall
667    /// touch only the tempdir.
668    fn with_codex_home<F: FnOnce()>(home: &Path, f: F) {
669        let prev = std::env::var_os("CODEX_HOME");
670        std::env::set_var("CODEX_HOME", home);
671        f();
672        match prev {
673            Some(v) => std::env::set_var("CODEX_HOME", v),
674            None => std::env::remove_var("CODEX_HOME"),
675        }
676    }
677
678    #[test]
679    fn install_codex_mcp_config_creates_file_with_sqz_entry() {
680        let dir = tempfile::tempdir().unwrap();
681        with_codex_home(dir.path(), || {
682            let created = install_codex_mcp_config().unwrap();
683            assert!(created);
684            let content = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
685            assert!(
686                content.contains("[mcp_servers.sqz]"),
687                "config.toml must contain [mcp_servers.sqz] header; got:\n{content}"
688            );
689            assert!(content.contains("command = \"sqz-mcp\""),
690                "command must be sqz-mcp");
691            assert!(content.contains("--transport"));
692            assert!(content.contains("stdio"));
693        });
694    }
695
696    #[test]
697    fn install_codex_mcp_config_preserves_existing_other_servers() {
698        let dir = tempfile::tempdir().unwrap();
699        let cfg = dir.path().join("config.toml");
700        std::fs::write(
701            &cfg,
702            "# User's existing Codex config, with a comment.\n\
703             model = \"gpt-5\"\n\
704             \n\
705             [mcp_servers.other]\n\
706             command = \"other-server\"\n\
707             args = [\"--flag\"]\n",
708        ).unwrap();
709
710        with_codex_home(dir.path(), || {
711            let created = install_codex_mcp_config().unwrap();
712            assert!(created);
713        });
714
715        let after = std::fs::read_to_string(&cfg).unwrap();
716        assert!(after.contains("# User's existing Codex config"),
717            "comment must survive: {after}");
718        assert!(after.contains("model = \"gpt-5\""),
719            "top-level key must survive: {after}");
720        assert!(after.contains("[mcp_servers.other]"),
721            "existing server entry must survive: {after}");
722        assert!(after.contains("command = \"other-server\""),
723            "existing server command must survive: {after}");
724        assert!(after.contains("[mcp_servers.sqz]"),
725            "sqz entry must be added: {after}");
726    }
727
728    #[test]
729    fn install_codex_mcp_config_is_idempotent() {
730        let dir = tempfile::tempdir().unwrap();
731        with_codex_home(dir.path(), || {
732            assert!(install_codex_mcp_config().unwrap());
733            assert!(
734                !install_codex_mcp_config().unwrap(),
735                "second install with complete [mcp_servers.sqz] must be a no-op"
736            );
737        });
738    }
739
740    #[test]
741    fn install_codex_mcp_config_does_not_overwrite_user_tuned_entry() {
742        let dir = tempfile::tempdir().unwrap();
743        let cfg = dir.path().join("config.toml");
744        std::fs::write(
745            &cfg,
746            "[mcp_servers.sqz]\n\
747             command = \"/custom/path/sqz-mcp\"\n\
748             args = [\"--transport\", \"sse\", \"--port\", \"3999\"]\n",
749        ).unwrap();
750
751        with_codex_home(dir.path(), || {
752            let changed = install_codex_mcp_config().unwrap();
753            assert!(!changed, "existing complete entry must be idempotent-skipped");
754        });
755        let after = std::fs::read_to_string(&cfg).unwrap();
756        assert!(after.contains("/custom/path/sqz-mcp"),
757            "user's custom command must survive re-init");
758        assert!(after.contains("\"sse\""),
759            "user's custom transport must survive");
760    }
761
762    #[test]
763    fn remove_codex_mcp_config_removes_only_sqz_entry() {
764        let dir = tempfile::tempdir().unwrap();
765        let cfg = dir.path().join("config.toml");
766        std::fs::write(
767            &cfg,
768            "# keep this comment\n\
769             model = \"gpt-5\"\n\
770             \n\
771             [mcp_servers.other]\n\
772             command = \"other-server\"\n\
773             \n\
774             [mcp_servers.sqz]\n\
775             command = \"sqz-mcp\"\n\
776             args = [\"--transport\", \"stdio\"]\n",
777        ).unwrap();
778
779        with_codex_home(dir.path(), || {
780            let (path, changed) = remove_codex_mcp_config().unwrap().unwrap();
781            assert_eq!(path, cfg);
782            assert!(changed);
783        });
784
785        let after = std::fs::read_to_string(&cfg).unwrap();
786        assert!(after.contains("# keep this comment"),
787            "comment must survive: {after}");
788        assert!(after.contains("model = \"gpt-5\""),
789            "top-level key must survive: {after}");
790        assert!(after.contains("[mcp_servers.other]"),
791            "other server entry must survive: {after}");
792        assert!(!after.contains("[mcp_servers.sqz]"),
793            "sqz entry must be gone: {after}");
794    }
795
796    #[test]
797    fn remove_codex_mcp_config_deletes_file_when_sqz_was_the_only_entry() {
798        let dir = tempfile::tempdir().unwrap();
799        let cfg = dir.path().join("config.toml");
800        with_codex_home(dir.path(), || {
801            install_codex_mcp_config().unwrap();
802            let (path, changed) = remove_codex_mcp_config().unwrap().unwrap();
803            assert_eq!(path, cfg);
804            assert!(changed);
805        });
806        assert!(!cfg.exists(),
807            "config.toml with only sqz must be deleted on uninstall");
808    }
809
810    #[test]
811    fn remove_codex_mcp_config_returns_none_when_file_missing() {
812        let dir = tempfile::tempdir().unwrap();
813        with_codex_home(dir.path(), || {
814            let result = remove_codex_mcp_config().unwrap();
815            assert!(result.is_none());
816        });
817    }
818
819    #[test]
820    fn remove_codex_mcp_config_noop_when_sqz_entry_missing() {
821        let dir = tempfile::tempdir().unwrap();
822        let cfg = dir.path().join("config.toml");
823        std::fs::write(&cfg, "[mcp_servers.other]\ncommand = \"x\"\n").unwrap();
824        with_codex_home(dir.path(), || {
825            let (path, changed) = remove_codex_mcp_config().unwrap().unwrap();
826            assert_eq!(path, cfg);
827            assert!(!changed);
828        });
829        let after = std::fs::read_to_string(&cfg).unwrap();
830        assert!(after.contains("[mcp_servers.other]"));
831    }
832}