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). The guidance-file approach for (2)
31//! is the standard answer to Codex's "no programmatic hook" limitation.
32
33use std::path::{Path, PathBuf};
34
35use crate::error::{Result, SqzError};
36
37// ── Paths ─────────────────────────────────────────────────────────────────
38
39/// Return the location of Codex's user-level `config.toml`.
40///
41/// Honors `$CODEX_HOME` when set (Codex supports it for isolated installs),
42/// falling back to `~/.codex/config.toml`. Matches the behaviour of the
43/// Codex CLI itself — see the `sqlite_home` discussion in Codex's
44/// `docs/config.md`, which describes the same resolution order.
45pub fn codex_config_path() -> PathBuf {
46    if let Ok(home) = std::env::var("CODEX_HOME") {
47        return PathBuf::from(home).join("config.toml");
48    }
49    let home = std::env::var("HOME")
50        .or_else(|_| std::env::var("USERPROFILE"))
51        .map(PathBuf::from)
52        .unwrap_or_else(|_| PathBuf::from("."));
53    home.join(".codex").join("config.toml")
54}
55
56/// Return the path where Codex reads project-level instructions.
57///
58/// `AGENTS.md` lives in the project root and is the cross-tool standard
59/// (Codex, Copilot, Cursor). We do NOT fall back to the deprecated
60/// `codex.md` / `.codexrules` names — they are pre-consolidation legacy
61/// and new installs should target `AGENTS.md`.
62pub fn agents_md_path(project_dir: &Path) -> PathBuf {
63    project_dir.join("AGENTS.md")
64}
65
66// ── Sentinel-bracketed guidance block ────────────────────────────────────
67//
68// `AGENTS.md` is a free-form markdown file the user may edit freely. sqz
69// writes its guidance between BEGIN/END sentinels so we can later locate
70// and remove exactly what we added, without clobbering unrelated user
71// content. Matches the pattern we already use for shell RC hooks in
72// sqz/src/shell_hook.rs.
73
74const AGENTS_MD_BEGIN: &str = "<!-- BEGIN sqz-agents-guidance (auto-installed by sqz init; remove this block to disable) -->";
75const AGENTS_MD_END: &str = "<!-- END sqz-agents-guidance -->";
76
77/// Build the markdown block that sqz appends to `AGENTS.md`.
78///
79/// The block is wrapped in HTML comment sentinels so `remove_agents_md_guidance`
80/// can locate and excise it byte-exact on uninstall. `sqz_path` is the
81/// absolute path of the sqz binary; we use it verbatim (no JSON escaping)
82/// because this is a markdown file the user reads — raw backslashes on
83/// Windows render correctly as-is, and the user can copy-paste the
84/// commands straight into their shell.
85pub fn agents_md_guidance_block(sqz_path: &str) -> String {
86    format!(
87        "\
88{begin}
89
90## sqz — Token-Optimized CLI Output
91
92When running shell commands whose output may be long (directory listings,
93git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
94pipe the output through `sqz compress` to reduce token consumption.
95
96`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
97usage is to pipe the command's output into `sqz compress`:
98
99```bash
100# Instead of:     Use:
101git status        git status 2>&1 | {sqz_path} compress
102cargo test        cargo test 2>&1 | {sqz_path} compress
103git log -10       git log -10 2>&1 | {sqz_path} compress
104docker ps         docker ps 2>&1 | {sqz_path} compress
105ls -la            ls -la 2>&1 | {sqz_path} compress
106```
107
108The `2>&1` captures stderr too, which is useful for commands like `cargo
109test` where diagnostics go to stderr. `sqz compress` filters and compresses
110the combined output while preserving filenames, paths, and identifiers.
111It typically saves 60-90% tokens on verbose commands.
112
113Do NOT pipe output for:
114- Interactive commands (`vim`, `ssh`, `python`, REPLs)
115- Compound commands with shell operators (`cmd && other`, `cmd > file.txt`,
116  `cmd; other`) — run those directly
117- Short commands whose output is already a few lines
118
119If `sqz` is not on PATH, run commands normally.
120
121The `sqz-mcp` MCP server is also available — Codex reads it from
122`~/.codex/config.toml` under `[mcp_servers.sqz]`. It exposes
123`compress` (the default pipeline), `passthrough` (return text
124unchanged — the escape hatch below), `expand` (resolve a
125`§ref:HASH§` token back to the original bytes), `sqz_recall`
126(full-text search over everything sqz has compressed — recover
127context lost to compaction), and `sqz_read_file` / `sqz_grep` /
128`sqz_list_dir` (file access with a dedup cache: a repeat read of an
129unchanged file costs ~13 tokens, and a ranged re-read via `offset` /
130`limit` of a file you already have in full returns a
131`§ref:HASH:L40-80§` line-range reference). Prefer those three over
132`cat`, `grep` and `ls` for anything you might read more than once.
133
134## Escape hatch — when sqz output confuses you
135
136If you see a `§ref:HASH§` token and can't parse it, or compressed
137output is leading you to make lots of small retries instead of one
138big request, use one of these:
139
140- **`{sqz_path} expand <prefix>`** — resolve a dedup ref back to the
141  original bytes. Accepts bare hex (`sqz expand a1b2c3d4`) or the full
142  token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
143- **`SQZ_NO_DEDUP=1`** — set this env var for one command to disable
144  dedup: `SQZ_NO_DEDUP=1 git status 2>&1 | sqz compress`. You'll get
145  the full compressed output with no `§ref:…§` tokens.
146- **`--no-cache`** — same opt-out as a CLI flag:
147  `git status 2>&1 | sqz compress --no-cache`.
148- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
149  abbreviation, which rewrites repeated phrases to `«A1»` symbols and
150  keeps only the first occurrence. Use it when output is full of
151  SHAs/paths/URLs you'll copy-paste verbatim:
152  `SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
153
154If you're using the MCP server, the `passthrough` tool returns raw
155text and the `expand` tool resolves refs — call them when you need
156data sqz hasn't touched.
157
158{end}
159",
160        begin = AGENTS_MD_BEGIN,
161        end = AGENTS_MD_END,
162    )
163}
164
165// ── AGENTS.md install/uninstall ──────────────────────────────────────────
166
167/// Return `true` if the given `AGENTS.md` content already contains sqz's
168/// guidance block (matched by the BEGIN sentinel).
169fn agents_md_has_sqz_block(content: &str) -> bool {
170    content.contains(AGENTS_MD_BEGIN)
171}
172
173/// Install sqz's guidance block into `AGENTS.md` at `project_dir`.
174///
175/// If `AGENTS.md` doesn't exist yet, create it with sqz's block as the
176/// sole content. If it exists, append sqz's block (separated by a blank
177/// line so it renders as a new markdown section). If the block is
178/// already present (detected by the BEGIN sentinel), return `Ok(false)`
179/// without touching the file — `sqz init` stays idempotent.
180///
181/// Returns `true` when the file was created or modified, `false` when
182/// sqz's block was already present.
183pub fn install_agents_md_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
184    let path = agents_md_path(project_dir);
185    let block = agents_md_guidance_block(sqz_path);
186
187    if path.exists() {
188        let existing = std::fs::read_to_string(&path).map_err(|e| {
189            SqzError::Other(format!("failed to read {}: {e}", path.display()))
190        })?;
191        if agents_md_has_sqz_block(&existing) {
192            return Ok(false);
193        }
194        // Append with a guaranteed blank-line separator so sqz's section
195        // doesn't accidentally fuse with a trailing user section.
196        let mut new_content = existing;
197        if !new_content.ends_with('\n') {
198            new_content.push('\n');
199        }
200        if !new_content.ends_with("\n\n") {
201            new_content.push('\n');
202        }
203        new_content.push_str(&block);
204        std::fs::write(&path, new_content).map_err(|e| {
205            SqzError::Other(format!("failed to write {}: {e}", path.display()))
206        })?;
207        return Ok(true);
208    }
209
210    // Fresh AGENTS.md — write just sqz's block. A tiny preamble is added
211    // so `AGENTS.md` is self-explanatory to readers who encounter it for
212    // the first time and don't know the convention.
213    let preamble = "\
214# AGENTS.md
215
216Instructions for AI coding agents (OpenAI Codex, GitHub Copilot, Cursor,
217Windsurf, Amp, Devin) working in this repository. See <https://agentsmd.io>.
218
219";
220    let content = format!("{preamble}{block}");
221    std::fs::write(&path, content)
222        .map_err(|e| SqzError::Other(format!("failed to create {}: {e}", path.display())))?;
223    Ok(true)
224}
225
226/// Remove sqz's guidance block from `AGENTS.md`.
227///
228/// Locates the block by its BEGIN/END sentinels and excises it. If the
229/// resulting `AGENTS.md` is empty (modulo whitespace) or contains only
230/// the stock preamble sqz itself wrote on first install, the file is
231/// deleted entirely — leaving an empty `AGENTS.md` would be noise.
232/// If sentinel markers are missing (user edited them out or the file
233/// was never installed by sqz), this is a no-op.
234///
235/// Returns `Ok((path, removed_or_changed))`:
236///   - `removed_or_changed = true`  — the file was modified or deleted
237///   - `removed_or_changed = false` — no sqz block was found; nothing changed
238///
239/// Returns `Ok(None)` when `AGENTS.md` does not exist at all.
240pub fn remove_agents_md_guidance(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
241    let path = agents_md_path(project_dir);
242    if !path.exists() {
243        return Ok(None);
244    }
245    let content = std::fs::read_to_string(&path).map_err(|e| {
246        SqzError::Other(format!("failed to read {}: {e}", path.display()))
247    })?;
248    if !agents_md_has_sqz_block(&content) {
249        return Ok(Some((path, false)));
250    }
251
252    // Find the slice that contains sqz's block (BEGIN..=END sentinel,
253    // plus any immediate surrounding blank line we contributed on install
254    // so the remaining file reads cleanly).
255    let begin_idx = match content.find(AGENTS_MD_BEGIN) {
256        Some(i) => i,
257        None => return Ok(Some((path, false))),
258    };
259    let after_end_idx = match content.find(AGENTS_MD_END) {
260        Some(i) => i + AGENTS_MD_END.len(),
261        None => {
262            // BEGIN without END — truncated/edited file. Preserve the
263            // user's file and emit a soft no-op rather than destroying
264            // content we can't precisely delimit.
265            return Ok(Some((path, false)));
266        }
267    };
268
269    let mut new_content = String::with_capacity(content.len());
270    new_content.push_str(&content[..begin_idx]);
271    // Trim any trailing blank-line run that was inserted to separate
272    // sqz's block from what preceded it — but keep one trailing newline
273    // if the file had content before the block.
274    while new_content.ends_with("\n\n\n") {
275        new_content.pop();
276    }
277    let tail = &content[after_end_idx..];
278    // Skip the leading newline(s) right after our END sentinel so the
279    // trailing user content starts cleanly.
280    let trimmed_tail = tail.trim_start_matches('\n');
281    if !trimmed_tail.is_empty() {
282        if !new_content.ends_with('\n') {
283            new_content.push('\n');
284        }
285        new_content.push_str(trimmed_tail);
286    }
287
288    // If the remaining content is empty (or only the stock preamble sqz
289    // wrote on first install), remove the file entirely — a near-empty
290    // AGENTS.md is worse than no file.
291    let essentially_empty = is_essentially_empty_agents_md(&new_content);
292    if essentially_empty {
293        std::fs::remove_file(&path).map_err(|e| {
294            SqzError::Other(format!("failed to remove {}: {e}", path.display()))
295        })?;
296        return Ok(Some((path, true)));
297    }
298
299    std::fs::write(&path, new_content).map_err(|e| {
300        SqzError::Other(format!("failed to write {}: {e}", path.display()))
301    })?;
302    Ok(Some((path, true)))
303}
304
305/// Return `true` when the remaining `AGENTS.md` content has no
306/// user-authored material — either empty or just the stock preamble sqz
307/// itself wrote on first install. Used by `remove_agents_md_guidance`
308/// to decide between "write back the trimmed file" and "delete it".
309fn is_essentially_empty_agents_md(s: &str) -> bool {
310    let trimmed = s.trim();
311    if trimmed.is_empty() {
312        return true;
313    }
314    // Match the exact preamble sqz writes on first install (with its
315    // heading, one paragraph of explanation, and no user additions).
316    const PREAMBLE_MARKERS: &[&str] = &[
317        "# AGENTS.md",
318        "Instructions for AI coding agents",
319        "See <https://agentsmd.io>.",
320    ];
321    let has_only_preamble = PREAMBLE_MARKERS.iter().all(|m| trimmed.contains(m))
322        && !trimmed
323            .lines()
324            .any(|l| {
325                let l = l.trim();
326                !l.is_empty()
327                    && !l.starts_with('#')
328                    && !PREAMBLE_MARKERS.iter().any(|m| l.contains(m))
329            });
330    has_only_preamble
331}
332
333// ── ~/.codex/config.toml merger ──────────────────────────────────────────
334
335/// Merge sqz's MCP server entry into Codex's user-level `config.toml`.
336///
337/// Codex reads MCP servers from `~/.codex/config.toml` (or
338/// `$CODEX_HOME/config.toml` when set). The relevant TOML shape is:
339///
340/// ```toml
341/// [mcp_servers.sqz]
342/// command = "sqz-mcp"
343/// args = ["--transport", "stdio"]
344/// ```
345///
346/// Notes:
347///   - Key is `mcp_servers` (snake_case). JSON-tool users who write
348///     `mcpServers` will be quietly ignored by Codex.
349///   - We go through `toml_edit` so the user's existing comments,
350///     key order, and formatting are preserved across the round-trip.
351///     Plain `toml::from_str → toml::to_string` wipes comments.
352///
353/// Idempotent: if `[mcp_servers.sqz]` is already present with a `command`
354/// field, we do not overwrite — the user may have tuned it. Returns
355/// `Ok(false)` in that case.
356///
357/// If the config file doesn't exist yet, it is created with only sqz's
358/// entry (and its parent `~/.codex/` directory is created on demand).
359pub fn install_codex_mcp_config() -> Result<bool> {
360    install_codex_mcp_config_at(None)
361}
362
363/// Internal: home-dir-injectable counterpart used by tests. Avoids
364/// `std::env::set_var` which races with parallel tests that also read
365/// HOME (e.g. the api_proxy property tests that open `~/.sqz/sessions.db`).
366/// See `claude_md_integration::install_claude_mcp_config_at` for the
367/// same pattern.
368pub(crate) fn install_codex_mcp_config_at(home_override: Option<&Path>) -> Result<bool> {
369    let path = match home_override {
370        Some(h) => h.join("config.toml"),
371        None => codex_config_path(),
372    };
373
374    // Read or start from blank. We build on a toml_edit::DocumentMut so
375    // any prior comments/whitespace survive the round-trip.
376    let existing = if path.exists() {
377        std::fs::read_to_string(&path).map_err(|e| {
378            SqzError::Other(format!("failed to read {}: {e}", path.display()))
379        })?
380    } else {
381        String::new()
382    };
383
384    let mut doc: toml_edit::DocumentMut = existing
385        .parse()
386        .map_err(|e| SqzError::Other(format!(
387            "failed to parse {} as TOML: {e}",
388            path.display()
389        )))?;
390
391    // Idempotency: if [mcp_servers.sqz].command is already set, skip.
392    // We only check `command` (not just the table's presence) because a
393    // stub `[mcp_servers.sqz]` with no keys would be a misconfigured
394    // install worth repairing.
395    if let Some(existing_cmd) = doc
396        .get("mcp_servers")
397        .and_then(|v| v.get("sqz"))
398        .and_then(|v| v.get("command"))
399    {
400        if existing_cmd.is_value() {
401            return Ok(false);
402        }
403    }
404
405    // Build the sqz table in place. Using `get_mut(..).or_insert_with(..)`
406    // pattern so we add keys to any existing [mcp_servers] without
407    // replacing the whole table. We deliberately leave the parent
408    // `mcp_servers` implicit (i.e. don't emit an explicit
409    // `[mcp_servers]` header before the sub-tables). The TOML spec
410    // treats `[mcp_servers.sqz]` and `[mcp_servers.jira]` as proper
411    // subtables that implicitly create their parent, and emitting a
412    // bare `[mcp_servers]` header before them is redundant noise in
413    // the file.
414    let mcp_servers = doc
415        .entry("mcp_servers")
416        .or_insert_with(|| {
417            // Start the new table as implicit so toml_edit doesn't
418            // write `[mcp_servers]` before the first subtable header.
419            let mut t = toml_edit::Table::new();
420            t.set_implicit(true);
421            toml_edit::Item::Table(t)
422        })
423        .as_table_mut()
424        .ok_or_else(|| SqzError::Other(format!(
425            "{}: `mcp_servers` is not a table — refusing to overwrite",
426            path.display()
427        )))?;
428
429    let sqz = mcp_servers
430        .entry("sqz")
431        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
432        .as_table_mut()
433        .ok_or_else(|| SqzError::Other(format!(
434            "{}: `mcp_servers.sqz` is not a table — refusing to overwrite",
435            path.display()
436        )))?;
437
438    // Populate the server's required fields. Only write keys the user
439    // hasn't already set so a hand-tuned config survives re-runs.
440    if !sqz.contains_key("command") {
441        sqz["command"] = toml_edit::value("sqz-mcp");
442    }
443    if !sqz.contains_key("args") {
444        let mut args = toml_edit::Array::new();
445        args.push("--transport");
446        args.push("stdio");
447        sqz["args"] = toml_edit::Item::Value(toml_edit::Value::Array(args));
448    }
449
450    // Make sure the parent directory exists.
451    if let Some(parent) = path.parent() {
452        std::fs::create_dir_all(parent).map_err(|e| {
453            SqzError::Other(format!(
454                "failed to create {}: {e}",
455                parent.display()
456            ))
457        })?;
458    }
459
460    std::fs::write(&path, doc.to_string()).map_err(|e| {
461        SqzError::Other(format!("failed to write {}: {e}", path.display()))
462    })?;
463    Ok(true)
464}
465
466/// Remove sqz's MCP entry from Codex's user-level `config.toml`.
467///
468/// Surgical: only removes `[mcp_servers.sqz]`. Other `[mcp_servers.*]`
469/// entries and unrelated keys are preserved. Uses `toml_edit` to keep
470/// the user's comments, key order, and formatting intact.
471///
472/// If removing sqz empties out `[mcp_servers]`, the now-empty table is
473/// dropped so the config file doesn't end up with dangling headers.
474/// If the file would become empty after the surgery (only sqz's entry
475/// existed) we delete it entirely — a zero-byte `config.toml` is just
476/// noise.
477///
478/// Returns `Ok((path, changed))`:
479///   - `changed = true`  — sqz's entry was removed or the file deleted
480///   - `changed = false` — no sqz entry found; nothing changed
481///
482/// Returns `Ok(None)` if the config file does not exist at all.
483pub fn remove_codex_mcp_config() -> Result<Option<(PathBuf, bool)>> {
484    remove_codex_mcp_config_at(None)
485}
486
487/// Internal: home-dir-injectable counterpart used by tests. See
488/// `install_codex_mcp_config_at` for rationale.
489pub(crate) fn remove_codex_mcp_config_at(
490    home_override: Option<&Path>,
491) -> Result<Option<(PathBuf, bool)>> {
492    let path = match home_override {
493        Some(h) => h.join("config.toml"),
494        None => codex_config_path(),
495    };
496    if !path.exists() {
497        return Ok(None);
498    }
499    let raw = std::fs::read_to_string(&path)
500        .map_err(|e| SqzError::Other(format!("failed to read {}: {e}", path.display())))?;
501
502    let mut doc: toml_edit::DocumentMut = match raw.parse() {
503        Ok(d) => d,
504        Err(_) => {
505            // Can't parse — leave it alone rather than destroy user data.
506            return Ok(Some((path, false)));
507        }
508    };
509
510    let mcp_table = match doc.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
511        Some(t) => t,
512        None => return Ok(Some((path, false))),
513    };
514    if !mcp_table.contains_key("sqz") {
515        return Ok(Some((path, false)));
516    }
517    mcp_table.remove("sqz");
518
519    // If mcp_servers is now empty, drop the whole table so the file
520    // doesn't carry a dangling `[mcp_servers]` header.
521    let mcp_is_empty = mcp_table.iter().count() == 0;
522    if mcp_is_empty {
523        doc.remove("mcp_servers");
524    }
525
526    // If the whole document is empty now, remove the file.
527    let doc_is_empty = doc.iter().count() == 0;
528    if doc_is_empty {
529        std::fs::remove_file(&path).map_err(|e| {
530            SqzError::Other(format!("failed to remove {}: {e}", path.display()))
531        })?;
532        return Ok(Some((path, true)));
533    }
534
535    std::fs::write(&path, doc.to_string()).map_err(|e| {
536        SqzError::Other(format!("failed to write {}: {e}", path.display()))
537    })?;
538    Ok(Some((path, true)))
539}
540
541// ── Tests ────────────────────────────────────────────────────────────────
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    // codex_config_path() reads CODEX_HOME / HOME env vars. Testing it
548    // with set_var is inherently racy under parallel test execution, so
549    // we only verify the structural invariant: the returned path always
550    // ends with "config.toml".
551    #[test]
552    fn codex_config_path_ends_with_config_toml() {
553        let got = codex_config_path();
554        assert!(
555            got.ends_with("config.toml"),
556            "codex_config_path() must end with config.toml, got: {}",
557            got.display()
558        );
559    }
560
561    // ── AGENTS.md guidance block ─────────────────────────────────────
562
563    #[test]
564    fn agents_md_guidance_block_contains_sqz_invocation() {
565        let block = agents_md_guidance_block("/usr/local/bin/sqz");
566        assert!(block.contains(AGENTS_MD_BEGIN));
567        assert!(block.contains(AGENTS_MD_END));
568        assert!(block.contains("| /usr/local/bin/sqz compress"));
569        assert!(block.contains("sqz-mcp"));
570    }
571
572    #[test]
573    fn install_agents_md_creates_file_with_preamble() {
574        let dir = tempfile::tempdir().unwrap();
575        let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
576        assert!(created);
577        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
578        assert!(content.starts_with("# AGENTS.md"));
579        assert!(content.contains(AGENTS_MD_BEGIN));
580        assert!(content.contains(AGENTS_MD_END));
581    }
582
583    #[test]
584    fn install_agents_md_appends_without_clobbering_user_content() {
585        let dir = tempfile::tempdir().unwrap();
586        let path = dir.path().join("AGENTS.md");
587        std::fs::write(
588            &path,
589            "# My project rules\n\nBe polite. Run tests before committing.\n",
590        ).unwrap();
591
592        let created = install_agents_md_guidance(dir.path(), "sqz").unwrap();
593        assert!(created);
594
595        let content = std::fs::read_to_string(&path).unwrap();
596        assert!(content.contains("# My project rules"),
597            "original heading must survive");
598        assert!(content.contains("Be polite. Run tests before committing."),
599            "original body must survive");
600        let user_idx = content.find("Be polite").unwrap();
601        let sqz_idx = content.find(AGENTS_MD_BEGIN).unwrap();
602        assert!(sqz_idx > user_idx,
603            "sqz's block must append after user content, not prepend");
604    }
605
606    #[test]
607    fn install_agents_md_is_idempotent() {
608        let dir = tempfile::tempdir().unwrap();
609        let first = install_agents_md_guidance(dir.path(), "sqz").unwrap();
610        assert!(first);
611        let second = install_agents_md_guidance(dir.path(), "sqz").unwrap();
612        assert!(!second, "second install must be a no-op");
613
614        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
615        let occurrences = content.matches(AGENTS_MD_BEGIN).count();
616        assert_eq!(occurrences, 1, "must not duplicate the block on re-install");
617    }
618
619    #[test]
620    fn remove_agents_md_preserves_user_content() {
621        let dir = tempfile::tempdir().unwrap();
622        let path = dir.path().join("AGENTS.md");
623        std::fs::write(
624            &path,
625            "# My project rules\n\nBe polite. Run tests before committing.\n",
626        ).unwrap();
627        install_agents_md_guidance(dir.path(), "sqz").unwrap();
628
629        let (returned_path, changed) =
630            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
631        assert_eq!(returned_path, path);
632        assert!(changed);
633        assert!(path.exists(),
634            "file must NOT be deleted — it has user content");
635
636        let content = std::fs::read_to_string(&path).unwrap();
637        assert!(!content.contains(AGENTS_MD_BEGIN));
638        assert!(!content.contains(AGENTS_MD_END));
639        assert!(content.contains("# My project rules"),
640            "user heading must survive the uninstall");
641        assert!(content.contains("Be polite. Run tests before committing."),
642            "user body must survive the uninstall");
643    }
644
645    #[test]
646    fn remove_agents_md_deletes_file_when_only_sqz_preamble_remains() {
647        let dir = tempfile::tempdir().unwrap();
648        install_agents_md_guidance(dir.path(), "sqz").unwrap();
649        let path = dir.path().join("AGENTS.md");
650        assert!(path.exists());
651
652        let (_returned, changed) =
653            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
654        assert!(changed);
655        assert!(
656            !path.exists(),
657            "fresh-install AGENTS.md must be removed when sqz block is stripped"
658        );
659    }
660
661    #[test]
662    fn remove_agents_md_noop_when_block_missing() {
663        let dir = tempfile::tempdir().unwrap();
664        let path = dir.path().join("AGENTS.md");
665        std::fs::write(&path, "# User-authored, sqz never touched this.\n").unwrap();
666
667        let (returned_path, changed) =
668            remove_agents_md_guidance(dir.path()).unwrap().unwrap();
669        assert_eq!(returned_path, path);
670        assert!(!changed, "no sqz block means no change");
671        assert!(path.exists(), "user file must be untouched");
672    }
673
674    #[test]
675    fn remove_agents_md_returns_none_when_file_missing() {
676        let dir = tempfile::tempdir().unwrap();
677        let result = remove_agents_md_guidance(dir.path()).unwrap();
678        assert!(result.is_none());
679    }
680
681    // ── ~/.codex/config.toml merger ──────────────────────────────────
682
683    #[test]
684    fn install_codex_mcp_config_creates_file_with_sqz_entry() {
685        let dir = tempfile::tempdir().unwrap();
686        let created = install_codex_mcp_config_at(Some(dir.path())).unwrap();
687        assert!(created);
688        let content = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
689        assert!(
690            content.contains("[mcp_servers.sqz]"),
691            "config.toml must contain [mcp_servers.sqz] header; got:\n{content}"
692        );
693        assert!(content.contains("command = \"sqz-mcp\""),
694            "command must be sqz-mcp");
695        assert!(content.contains("--transport"));
696        assert!(content.contains("stdio"));
697    }
698
699    #[test]
700    fn install_codex_mcp_config_preserves_existing_other_servers() {
701        let dir = tempfile::tempdir().unwrap();
702        let cfg = dir.path().join("config.toml");
703        std::fs::write(
704            &cfg,
705            "# User's existing Codex config, with a comment.\n\
706             model = \"gpt-5\"\n\
707             \n\
708             [mcp_servers.other]\n\
709             command = \"other-server\"\n\
710             args = [\"--flag\"]\n",
711        ).unwrap();
712
713        let created = install_codex_mcp_config_at(Some(dir.path())).unwrap();
714        assert!(created);
715
716        let after = std::fs::read_to_string(&cfg).unwrap();
717        assert!(after.contains("# User's existing Codex config"),
718            "comment must survive: {after}");
719        assert!(after.contains("model = \"gpt-5\""),
720            "top-level key must survive: {after}");
721        assert!(after.contains("[mcp_servers.other]"),
722            "existing server entry must survive: {after}");
723        assert!(after.contains("command = \"other-server\""),
724            "existing server command must survive: {after}");
725        assert!(after.contains("[mcp_servers.sqz]"),
726            "sqz entry must be added: {after}");
727    }
728
729    #[test]
730    fn install_codex_mcp_config_is_idempotent() {
731        let dir = tempfile::tempdir().unwrap();
732        assert!(install_codex_mcp_config_at(Some(dir.path())).unwrap());
733        assert!(
734            !install_codex_mcp_config_at(Some(dir.path())).unwrap(),
735            "second install with complete [mcp_servers.sqz] must be a no-op"
736        );
737    }
738
739    #[test]
740    fn install_codex_mcp_config_does_not_overwrite_user_tuned_entry() {
741        let dir = tempfile::tempdir().unwrap();
742        let cfg = dir.path().join("config.toml");
743        std::fs::write(
744            &cfg,
745            "[mcp_servers.sqz]\n\
746             command = \"/custom/path/sqz-mcp\"\n\
747             args = [\"--transport\", \"sse\", \"--port\", \"3999\"]\n",
748        ).unwrap();
749
750        let changed = install_codex_mcp_config_at(Some(dir.path())).unwrap();
751        assert!(!changed, "existing complete entry must be idempotent-skipped");
752        let after = std::fs::read_to_string(&cfg).unwrap();
753        assert!(after.contains("/custom/path/sqz-mcp"),
754            "user's custom command must survive re-init");
755        assert!(after.contains("\"sse\""),
756            "user's custom transport must survive");
757    }
758
759    #[test]
760    fn remove_codex_mcp_config_removes_only_sqz_entry() {
761        let dir = tempfile::tempdir().unwrap();
762        let cfg = dir.path().join("config.toml");
763        std::fs::write(
764            &cfg,
765            "# keep this comment\n\
766             model = \"gpt-5\"\n\
767             \n\
768             [mcp_servers.other]\n\
769             command = \"other-server\"\n\
770             \n\
771             [mcp_servers.sqz]\n\
772             command = \"sqz-mcp\"\n\
773             args = [\"--transport\", \"stdio\"]\n",
774        ).unwrap();
775
776        let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
777        assert_eq!(path, cfg);
778        assert!(changed);
779
780        let after = std::fs::read_to_string(&cfg).unwrap();
781        assert!(after.contains("# keep this comment"),
782            "comment must survive: {after}");
783        assert!(after.contains("model = \"gpt-5\""),
784            "top-level key must survive: {after}");
785        assert!(after.contains("[mcp_servers.other]"),
786            "other server entry must survive: {after}");
787        assert!(!after.contains("[mcp_servers.sqz]"),
788            "sqz entry must be gone: {after}");
789    }
790
791    #[test]
792    fn remove_codex_mcp_config_deletes_file_when_sqz_was_the_only_entry() {
793        let dir = tempfile::tempdir().unwrap();
794        let cfg = dir.path().join("config.toml");
795        install_codex_mcp_config_at(Some(dir.path())).unwrap();
796        let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
797        assert_eq!(path, cfg);
798        assert!(changed);
799        assert!(!cfg.exists(),
800            "config.toml with only sqz must be deleted on uninstall");
801    }
802
803    #[test]
804    fn remove_codex_mcp_config_returns_none_when_file_missing() {
805        let dir = tempfile::tempdir().unwrap();
806        let result = remove_codex_mcp_config_at(Some(dir.path())).unwrap();
807        assert!(result.is_none());
808    }
809
810    #[test]
811    fn remove_codex_mcp_config_noop_when_sqz_entry_missing() {
812        let dir = tempfile::tempdir().unwrap();
813        let cfg = dir.path().join("config.toml");
814        std::fs::write(&cfg, "[mcp_servers.other]\ncommand = \"x\"\n").unwrap();
815        let (path, changed) = remove_codex_mcp_config_at(Some(dir.path())).unwrap().unwrap();
816        assert_eq!(path, cfg);
817        assert!(!changed);
818        let after = std::fs::read_to_string(&cfg).unwrap();
819        assert!(after.contains("[mcp_servers.other]"));
820    }
821}