Skip to main content

mati_core/scaffold/
codex.rs

1//! Install Codex config, hooks, and skill scaffolding into `.codex/`.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use serde_json::Value;
7use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
8
9const HOOKS_JSON: &str = r#"{
10  "hooks": {
11    "SessionStart": [
12      {
13        "hooks": [
14          {
15            "type": "command",
16            "command": "bash .codex/hooks/session-start.sh",
17            "statusMessage": "Loading project knowledge..."
18          }
19        ]
20      }
21    ],
22    "UserPromptSubmit": [
23      {
24        "hooks": [
25          {
26            "type": "command",
27            "command": "bash .codex/hooks/user-prompt-submit.sh"
28          }
29        ]
30      }
31    ],
32    "PreToolUse": [
33      {
34        "matcher": "Bash",
35        "hooks": [
36          {
37            "type": "command",
38            "command": "bash .codex/hooks/pre-bash.sh",
39            "statusMessage": "Checking file knowledge..."
40          }
41        ]
42      },
43      {
44        "matcher": "apply_patch",
45        "hooks": [
46          {
47            "type": "command",
48            "command": "bash .codex/hooks/pre-apply-patch.sh",
49            "statusMessage": "Checking file knowledge before edit..."
50          }
51        ]
52      }
53    ],
54    "PostToolUse": [
55      {
56        "matcher": "Bash",
57        "hooks": [
58          {
59            "type": "command",
60            "command": "bash .codex/hooks/post-bash.sh"
61          }
62        ]
63      }
64    ],
65    "Stop": [
66      {
67        "hooks": [
68          {
69            "type": "command",
70            "command": "bash .codex/hooks/stop.sh"
71          }
72        ]
73      }
74    ]
75  }
76}"#;
77
78const MATI_SKILL: &str = r#"---
79name: mati
80description: Codebase memory layer — gotchas, decisions, and file context that survive developer turnover.
81---
82
83# mati
84
85Use `mati` as the codebase memory layer for this repository.
86
87## Required workflow
88
891. At session start or when entering the repo, call `mem_bootstrap`.
902. Before editing or shell-inspecting an unfamiliar file, call `mem_get("file:<path>")`.
913. Use `mem_query` for broader searches across the knowledge base.
924. When the developer asks to save durable project knowledge, call `mem_set`.
935. Before merge-oriented changes, prefer `mati diff <range>` or the equivalent memory checks.
94
95## mem_set rules
96
97**Gotcha records:**
98- Rule MUST start with an imperative verb (Always/Never/Ensure/Do not).
99- Reason MUST state causality — what breaks and why.
100- Set confirmed=false; run `mati gotcha confirm <key>` after.
101
102**File enrichment:**
103- Value and purpose MUST start with a verb (Handles/Manages/Validates).
104- Preserve existing structural fields from mem_get — only update purpose and gotcha_keys.
105
106**Confirm routing (use MCP, not CLI — CLI is sandboxed in Codex):**
107- Single gotcha: mem_set(action="write") then mem_set(key, action="confirm").
108- Single file enrichment: mem_set then mem_set(action="confirm") for each gotcha.
109- Batch enrichment: mem_set with confirmed=false. End with "Run `mati review` to confirm."
110- To delete a gotcha: mem_set(key, action="delete").
111
112**Quality gate:** records with quality < 0.2 are suppressed. Imperative verb + causality reason = quality >= 0.4.
113
114## Platform semantics
115
116- Codex PreToolUse hooks block unconsulted file reads via exit 2 + stderr.
117- PostToolUse logs compliance for analytics — no context injection.
118- Always call `mem_get("file:<path>")` before shell-inspecting a file.
119
120## /mati-enrich — extraction pipeline (v0.2)
121
122The four-stage pipeline below is the operational instruction set for
123extracting gotcha candidates during `/mati-enrich`. It supersedes the
124brief mem_set rules above for the extraction-specific steps; the
125rules above still apply for everything else (manual capture, confirm
126routing, etc).
127
128### Stage 1 — Setup (before reading)
129
1301. `mem_query mode="text" query="<dirname-of-file>" limit 5`
131   → top 5 confirmed gotchas as POSITIVE EXEMPLARS. If zero exist
132     (cold start), continue with schema-only guidance.
1332. `mem_get("file:<path>")` — mints the consultation receipt, returns
134   existing gotcha_keys, AND returns the `enrichment_depth_hint` field
135   (D2-α: one of "fast", "standard", "deep"). Use it to pick the
136   tier branch below. If absent (older daemon), default to "deep".
1373. **Deep tier only**: call via Bash
138   `mati ls tombstoned --dir <dirname-of-file> --recent 30d --json`
139   to retrieve NEGATIVE EXEMPLARS — rules that were proposed for
140   this directory and then tombstoned. Use them in Stage 2 to
141   calibrate AGAINST proposing similar rules. If `count` is 0,
142   skip the negative block. Record whether the block was used —
143   controls the `with-neg-exemplars` tag in Stage 4.
1444. **SOTA path** (replaces the LLM file scan — preferred): call
145   `mati extract-signals --file <path>` via Bash for deterministic,
146   AST-aware signal extraction across all 12 supported languages.
147   Returns JSON
148   `{ file, language, signal_count, signals: [{ file_line, tier,
149      kind, evidence }, ...] }`. If `signal_count > 0`, use these
150   as the candidate list and SKIP the manual file scan; tag mem_set
151   with `signal-source:ast`. Otherwise fall back to the legacy LLM
152   file scan and tag `signal-source:llm`.
153
154### Tier branches (D2)
155
156| Tier      | Stage 2     | Stage 3 critique | Negative exemplars |
157| --------- | ----------- | ---------------- | ------------------ |
158| fast      | schema only | skip             | no                 |
159| standard  | positive    | Round 1 + 2      | no                 |
160| deep      | positive    | Rounds 1, 2, 3   | yes                |
161
162`fast` for trivial files (LoC < 100, isolated blast, no cluster).
163`standard` is the default. `deep` runs the full pipeline including
164negative exemplars for hotspot / signal-rich files.
165
166### Stage 2 — Enumeration (maximize recall)
167
168Read the file. Output a JSON array of candidates, using the POSITIVE
169EXEMPLARS as calibration for this project's specific bar.
170
171Signal ranking (extract from highest first):
172  HIGH:    WARNING / FIXME / HACK / SAFETY / IMPORTANT comments;
173           panic!/assert!/expect("…") with non-trivial messages;
174           comments explaining "why this looks weird" or "do not".
175  MEDIUM:  Defensive guards (early returns, custom error paths);
176           non-obvious literal arguments (e.g. with_versioning(true, 0));
177           error handling that diverges from the rest of the file.
178  LOW:     Raw API usage with no comment context.
179
180Schema (strict JSON):
181[
182  { "candidate_id": "C1",
183    "signal_tier": "high" | "medium" | "low",
184    "file_line": "L42",
185    "evidence_quote": "exact text from file at that line",
186    "draft_rule": "imperative verb + specific target",
187    "draft_reason": "what breaks and why",
188    "draft_severity": "critical" | "high" | "normal" | "low" } ]
189
190Goal: maximize recall. Weak candidates are OK — filtered next.
191
192### Stage 3 — Critique loop (bounded, 3 rounds)
193
194ROUND 1 — Specificity. Discard candidates failing ANY of:
195  Specific    — names a concrete API, value, or pattern
196  Enforceable — could a hook deny a real mistake based on this rule?
197  Non-obvious — would a reviewer learn something not derivable from
198                type signatures alone?
199  Causal      — does the reason state WHAT breaks with "because"/"since"?
200
201ROUND 2 — Cross-reference verification (DETERMINISTIC, D-α).
202For each Round 1 survivor, call `mati verify-evidence` via Bash:
203  mati verify-evidence \
204    --file <path> \
205    --line <candidate.file_line> \
206    --quote "<candidate.evidence_quote>" \
207    --pattern "<api/literal named in candidate.draft_rule>"
208The CLI returns JSON. Parse it:
209  { "verified": true, ... }  → keep, add "verified": true
210  { "verified": false, ... } → DISCARD (hallucinated citation, or
211                                rule generalizes beyond visible scope)
212Do NOT trust self-critique here. The CLI is the source of truth.
213
214ROUND 3 — Stability check. If Round 2 == Round 1, proceed. If Round 2
215discarded items, re-run Round 2 on the new survivor set. Cap at 3
216iterations total.
217
218### Stage 4 — Refinement and write
219
220For each verified candidate:
221
2221. Tighten rule: imperative verb first; concrete names not pronouns;
223   ≤ 80 chars where possible.
2242. Verify reason uses "because"/"since"/"as" — add if missing.
2253. Assign severity via HYBRID CLASSIFIER (D-β). Two passes:
226
227   3a. KEYWORD pass (deterministic):
228       contains "panic" / "data loss" / "corruption" / "security"
229         → critical
230       contains "regress" / "wrong result" / "silent failure" / "race" /
231                "silently" / "lose" / "lost" / "unbounded" / "indefinite"
232         → high
233       contains "performance" / "warning" / "deprecation" / "slow" /
234                "lock" / "exclusive" / "contention" / "stale state" /
235                "false positive" / "inconsistent"
236         → normal
237       else
238         → low
239
240   3b. SEMANTIC pass (LLM judgment) using rubric:
241       critical — data loss, corruption, security, unbounded growth
242       high     — wrong result, silent failure, race, broken invariant
243       normal   — performance, workflow blocker, non-obvious cleanup
244       low      — informational, stylistic, minor inconvenience
245
246   3c. If 3a and 3b agree → use that severity.
247       If they disagree → use the HIGHER + add tag "severity-disputed".
248
2494. Call `mem_set`:
250     key: `gotcha:<slug>`
251     rule, reason, severity (from step 3)
252     affected_files: [<path>]
253     tags:  ["enriched", "depth:<tier>"]
254          + ["signal-source:ast"] (if Stage 1 step 4 used extract-signals)
255            else ["signal-source:llm"]
256          + ["with-neg-exemplars"] (if Stage 1 step 3 used negatives)
257          + (["severity-disputed"] if step 3c flagged)
258     confirmed: false
259
260     The `depth:<tier>` tag (D3) drives per-tier accuracy in
261     `mati doctor`. The `signal-source:*` and `with-neg-exemplars`
262     tags (SOTA-γ) drive per-config A/B so reviewers can prove the
263     SOTA pipeline outperforms the legacy LLM scan.
264
265### Notes
266
267- Per-file token budget: ~8K tokens for Stages 2-3 combined. If you
268  exceed, truncate Stage 2 candidates to top 10 by signal_tier.
269- Rust-side quality gate still applies at write time. The pipeline
270  maximizes what gets through; the gate enforces the floor.
271"#;
272
273const SKILL_CONFIG_PATH: &str = ".codex/skills/mati/SKILL.md";
274
275pub const CODEX_HOOK_SCRIPTS: &[(&str, &str)] = &[
276    (
277        "session-start.sh",
278        crate::hooks::codex_session_start::SCRIPT,
279    ),
280    (
281        "user-prompt-submit.sh",
282        crate::hooks::codex_user_prompt::SCRIPT,
283    ),
284    ("pre-bash.sh", crate::hooks::codex_pre_bash::SCRIPT),
285    (
286        "pre-apply-patch.sh",
287        crate::hooks::codex_pre_apply_patch::SCRIPT,
288    ),
289    ("post-bash.sh", crate::hooks::codex_post_bash::SCRIPT),
290    ("stop.sh", crate::hooks::codex_stop::SCRIPT),
291];
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum CodexInstallResult {
295    Installed {
296        scripts: usize,
297        missing_deps: Vec<&'static str>,
298    },
299    NoCodex,
300}
301
302pub fn install_codex(project_root: &Path, create_if_missing: bool) -> Result<CodexInstallResult> {
303    let codex_dir = project_root.join(".codex");
304    if !codex_dir.is_dir() && !create_if_missing {
305        return Ok(CodexInstallResult::NoCodex);
306    }
307
308    std::fs::create_dir_all(&codex_dir)
309        .with_context(|| format!("failed to create {}", codex_dir.display()))?;
310
311    let hooks_path = codex_dir.join("hooks.json");
312    merge_hooks_json(&hooks_path)?;
313
314    let config_path = codex_dir.join("config.toml");
315    merge_config_toml(&config_path, SKILL_CONFIG_PATH, project_root)?;
316
317    let hooks_dir = codex_dir.join("hooks");
318    std::fs::create_dir_all(&hooks_dir)
319        .with_context(|| format!("failed to create {}", hooks_dir.display()))?;
320    for (name, content) in CODEX_HOOK_SCRIPTS {
321        let path = hooks_dir.join(name);
322        write_if_changed(&path, content)?;
323        make_executable(&path)?;
324    }
325
326    // Write mati binary wrapper so hooks resolve the same binary as MCP.
327    super::write_mati_wrapper(&hooks_dir)?;
328
329    let skill_dir = codex_dir.join("skills").join("mati");
330    std::fs::create_dir_all(&skill_dir)
331        .with_context(|| format!("failed to create {}", skill_dir.display()))?;
332    write_if_changed(&skill_dir.join("SKILL.md"), MATI_SKILL)?;
333
334    Ok(CodexInstallResult::Installed {
335        scripts: CODEX_HOOK_SCRIPTS.len(),
336        missing_deps: missing_hook_dependencies(),
337    })
338}
339
340fn merge_hooks_json(path: &Path) -> Result<()> {
341    let mati_hooks: Value = serde_json::from_str(HOOKS_JSON)?;
342    let merged = if path.exists() {
343        let existing_str = std::fs::read_to_string(path)?;
344        let mut existing: Value = match serde_json::from_str(&existing_str) {
345            Ok(v) => v,
346            Err(e) => {
347                let bak = path.with_extension("json.bak");
348                match std::fs::write(&bak, &existing_str) {
349                    Ok(()) => tracing::warn!(
350                        "malformed hooks.json, backed up to {} and starting fresh: {e}",
351                        bak.display()
352                    ),
353                    Err(bak_err) => tracing::warn!(
354                        "malformed hooks.json, starting fresh (backup failed: {bak_err}): {e}"
355                    ),
356                }
357                Value::Object(serde_json::Map::new())
358            }
359        };
360        if let Value::Object(ref mut map) = existing {
361            merge_hooks(map, &mati_hooks["hooks"]);
362        } else {
363            anyhow::bail!("hooks.json exists but is not a JSON object — cannot merge safely");
364        }
365        existing
366    } else {
367        mati_hooks
368    };
369
370    let output = serde_json::to_string_pretty(&merged)?;
371    write_if_changed(path, &output)
372}
373
374fn merge_hooks(root: &mut serde_json::Map<String, Value>, mati_hooks: &Value) {
375    let Some(mati_events) = mati_hooks.as_object() else {
376        root.insert("hooks".to_string(), mati_hooks.clone());
377        return;
378    };
379
380    let hooks_value = root
381        .entry("hooks".to_string())
382        .or_insert_with(|| Value::Object(serde_json::Map::new()));
383
384    let Value::Object(existing_events) = hooks_value else {
385        *hooks_value = mati_hooks.clone();
386        return;
387    };
388
389    for (event_name, mati_entries_value) in mati_events {
390        let Some(mati_entries) = mati_entries_value.as_array() else {
391            existing_events.insert(event_name.clone(), mati_entries_value.clone());
392            continue;
393        };
394
395        let owned_commands = mati_hook_commands(mati_entries);
396        let existing_entries = existing_events
397            .entry(event_name.clone())
398            .or_insert_with(|| Value::Array(Vec::new()));
399
400        let Value::Array(existing_entries) = existing_entries else {
401            *existing_entries = Value::Array(mati_entries.clone());
402            continue;
403        };
404
405        existing_entries.retain(|entry| !entry_contains_owned_command(entry, &owned_commands));
406        existing_entries.extend(mati_entries.clone());
407    }
408}
409
410fn mati_hook_commands(entries: &[Value]) -> Vec<String> {
411    entries.iter().flat_map(entry_hook_commands).collect()
412}
413
414fn entry_hook_commands(entry: &Value) -> Vec<String> {
415    entry
416        .get("hooks")
417        .and_then(Value::as_array)
418        .into_iter()
419        .flatten()
420        .filter_map(|hook| hook.get("command").and_then(Value::as_str))
421        .map(ToOwned::to_owned)
422        .collect()
423}
424
425fn entry_contains_owned_command(entry: &Value, owned_commands: &[String]) -> bool {
426    entry_hook_commands(entry)
427        .iter()
428        .any(|command| owned_commands.iter().any(|owned| owned == command))
429}
430
431fn merge_config_toml(path: &Path, skill_path: &str, project_root: &Path) -> Result<()> {
432    let mut doc = if path.exists() {
433        let existing = std::fs::read_to_string(path)?;
434        match existing.parse::<DocumentMut>() {
435            Ok(d) => d,
436            Err(e) => {
437                let bak = path.with_extension("toml.bak");
438                match std::fs::write(&bak, &existing) {
439                    Ok(()) => tracing::warn!(
440                        "malformed config.toml, backed up to {} and starting fresh: {e}",
441                        bak.display()
442                    ),
443                    Err(bak_err) => tracing::warn!(
444                        "malformed config.toml, starting fresh (backup failed: {bak_err}): {e}"
445                    ),
446                }
447                DocumentMut::new()
448            }
449        }
450    } else {
451        DocumentMut::new()
452    };
453
454    if doc.get("features").is_none() || !doc["features"].is_table() {
455        doc["features"] = Item::Table(Table::new());
456    }
457    // Codex 2026-05+ renamed [features].codex_hooks → [features].hooks.
458    // The runtime emits a deprecation warning on the old key. Public docs
459    // still document codex_hooks (likely lagging the runtime); the warning
460    // is the source of truth. If a future Codex re-deprecates `hooks`,
461    // update this line and bump the scaffold installer version.
462    doc["features"]["hooks"] = value(true);
463
464    if doc.get("mcp_servers").is_none() || !doc["mcp_servers"].is_table() {
465        doc["mcp_servers"] = Item::Table(Table::new());
466    }
467    if !doc["mcp_servers"]
468        .as_table()
469        .is_some_and(|t| t.contains_key("mati"))
470        || !doc["mcp_servers"]["mati"].is_table()
471    {
472        doc["mcp_servers"]["mati"] = Item::Table(Table::new());
473    }
474    doc["mcp_servers"]["mati"]["command"] = value("mati");
475    let mut args = Array::new();
476    args.push("serve");
477    doc["mcp_servers"]["mati"]["args"] = value(args);
478    // Codex spawns MCP servers with CWD=/. The cwd field tells Codex to set
479    // the working directory to the project root so `mati serve` derives the
480    // correct store slug from current_dir().
481    let canonical =
482        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
483    doc["mcp_servers"]["mati"]["cwd"] = value(canonical.to_string_lossy().as_ref());
484
485    if doc.get("skills").is_none() || !doc["skills"].is_table() {
486        doc["skills"] = Item::Table(Table::new());
487    }
488    if !doc["skills"]
489        .as_table()
490        .is_some_and(|t| t.contains_key("config"))
491        || !doc["skills"]["config"].is_array_of_tables()
492    {
493        doc["skills"]["config"] = Item::ArrayOfTables(ArrayOfTables::new());
494    }
495    let skills = doc["skills"]["config"]
496        .as_array_of_tables_mut()
497        .expect("skills.config should be an array of tables");
498    let existing_index = {
499        skills
500            .iter()
501            .position(|table| table.get("path").and_then(|i| i.as_str()) == Some(skill_path))
502    };
503    if let Some(index) = existing_index {
504        skills.get_mut(index).expect("index should exist")["enabled"] = value(true);
505    } else {
506        let mut skill = Table::new();
507        skill["path"] = value(skill_path);
508        skill["enabled"] = value(true);
509        skills.push(skill);
510    }
511
512    write_if_changed(path, &doc.to_string())
513}
514
515fn missing_hook_dependencies() -> Vec<&'static str> {
516    // Codex hooks are thin wrappers that exec `mati hook-decide`.
517    // No jq/awk dependency — all JSON parsing is in Rust.
518    Vec::new()
519}
520
521use super::{make_executable, write_if_changed};
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use tempfile::TempDir;
527
528    #[test]
529    fn skips_when_no_codex_dir_in_auto_mode() {
530        let dir = TempDir::new().unwrap();
531        let result = install_codex(dir.path(), false).unwrap();
532        assert_eq!(result, CodexInstallResult::NoCodex);
533    }
534
535    #[test]
536    fn installs_codex_config_hooks_and_skill() {
537        let dir = TempDir::new().unwrap();
538        let result = install_codex(dir.path(), true).unwrap();
539        match result {
540            CodexInstallResult::Installed { scripts, .. } => {
541                assert_eq!(scripts, CODEX_HOOK_SCRIPTS.len())
542            }
543            other => panic!("expected Installed, got {other:?}"),
544        }
545
546        let hooks: serde_json::Value = serde_json::from_str(
547            &std::fs::read_to_string(dir.path().join(".codex/hooks.json")).unwrap(),
548        )
549        .unwrap();
550        assert!(hooks["hooks"]["SessionStart"].is_array());
551        assert!(hooks["hooks"]["PreToolUse"].is_array());
552
553        let config = std::fs::read_to_string(dir.path().join(".codex/config.toml")).unwrap();
554        let doc = config.parse::<DocumentMut>().unwrap();
555        assert_eq!(doc["features"]["hooks"].as_bool(), Some(true));
556        assert_eq!(
557            doc["mcp_servers"]["mati"]["args"][0].as_str(),
558            Some("serve")
559        );
560        assert_eq!(
561            doc["skills"]["config"][0]["path"].as_str(),
562            Some(SKILL_CONFIG_PATH)
563        );
564        assert!(dir.path().join(".codex/skills/mati/SKILL.md").exists());
565    }
566
567    #[test]
568    fn merge_preserves_existing_codex_config_and_hooks() {
569        let dir = TempDir::new().unwrap();
570        let codex_dir = dir.path().join(".codex");
571        std::fs::create_dir_all(&codex_dir).unwrap();
572        std::fs::write(
573            codex_dir.join("hooks.json"),
574            r#"{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"custom-pre-write.sh"}]}]}}"#,
575        )
576        .unwrap();
577        std::fs::write(
578            codex_dir.join("config.toml"),
579            "[profiles]\ntrusted = true\n",
580        )
581        .unwrap();
582
583        install_codex(dir.path(), false).unwrap();
584
585        let hooks: serde_json::Value =
586            serde_json::from_str(&std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
587                .unwrap();
588        let pre = hooks["hooks"]["PreToolUse"].as_array().unwrap();
589        assert!(pre.iter().any(|entry| {
590            entry["hooks"]
591                .as_array()
592                .into_iter()
593                .flatten()
594                .any(|hook| hook["command"] == "custom-pre-write.sh")
595        }));
596
597        let config = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
598        let doc = config.parse::<DocumentMut>().unwrap();
599        assert_eq!(doc["profiles"]["trusted"].as_bool(), Some(true));
600        assert_eq!(doc["features"]["hooks"].as_bool(), Some(true));
601    }
602
603    #[test]
604    fn codex_wrapper_contains_absolute_binary_path_matching_mcp_config() {
605        let dir = TempDir::new().unwrap();
606        install_codex(dir.path(), true).unwrap();
607
608        // Wrapper must exist and be executable
609        let wrapper_path = dir.path().join(".codex/hooks/mati");
610        assert!(
611            wrapper_path.exists(),
612            ".codex/hooks/mati wrapper must exist"
613        );
614
615        let wrapper = std::fs::read_to_string(&wrapper_path).unwrap();
616        assert!(wrapper.contains("exec"), "wrapper must use exec");
617
618        // Extract the exec target from the wrapper
619        let exec_line = wrapper.lines().find(|l| l.contains("exec")).unwrap();
620        let exec_target = exec_line
621            .strip_prefix("exec \"")
622            .and_then(|s| s.strip_suffix("\" \"$@\""))
623            .expect("exec line must follow format: exec \"<path>\" \"$@\"");
624
625        // Wrapper uses absolute path (hooks run in restricted shell).
626        assert!(
627            exec_target.starts_with('/'),
628            "wrapper must use absolute path, got: {exec_target}"
629        );
630
631        // MCP config uses portable bare command.
632        let config = std::fs::read_to_string(dir.path().join(".codex/config.toml")).unwrap();
633        let doc = config.parse::<DocumentMut>().unwrap();
634        assert_eq!(
635            doc["mcp_servers"]["mati"]["command"].as_str().unwrap(),
636            "mati",
637            "MCP config must use bare 'mati' for portability"
638        );
639
640        // MCP args must include "serve"
641        let args = doc["mcp_servers"]["mati"]["args"]
642            .as_array()
643            .expect("mcp_servers.mati.args must be an array");
644        let args_str: Vec<&str> = args.iter().filter_map(|v| v.as_str()).collect();
645        assert!(
646            args_str.contains(&"serve"),
647            "args must contain 'serve', got: {args_str:?}"
648        );
649
650        // cwd must be set to an absolute project path (Codex spawns with CWD=/)
651        let cwd = doc["mcp_servers"]["mati"]["cwd"]
652            .as_str()
653            .expect("mcp_servers.mati.cwd must be set");
654        assert!(
655            cwd.starts_with('/'),
656            "cwd must be an absolute path, got: {cwd}"
657        );
658    }
659
660    #[test]
661    fn codex_hook_scripts_prepend_hooks_dir_to_path() {
662        let dir = TempDir::new().unwrap();
663        install_codex(dir.path(), true).unwrap();
664
665        for (name, content_template) in CODEX_HOOK_SCRIPTS {
666            let path = dir.path().join(".codex/hooks").join(name);
667            let content = std::fs::read_to_string(&path)
668                .unwrap_or_else(|_| panic!("hook script {name} must exist"));
669            // No-op hooks (e.g. user-prompt-submit) don't need HOOKS_DIR.
670            if content_template.contains("HOOKS_DIR=") {
671                assert!(
672                    content.contains("HOOKS_DIR=") && content.contains("export PATH="),
673                    "hook script {name} must prepend HOOKS_DIR to PATH"
674                );
675            }
676        }
677    }
678
679    #[test]
680    fn codex_reinit_updates_wrapper_path() {
681        let dir = TempDir::new().unwrap();
682        install_codex(dir.path(), true).unwrap();
683
684        // Tamper with the wrapper to simulate a stale binary path
685        let wrapper_path = dir.path().join(".codex/hooks/mati");
686        std::fs::write(
687            &wrapper_path,
688            "#!/usr/bin/env bash\nexec \"/old/path/mati\" \"$@\"\n",
689        )
690        .unwrap();
691
692        // Re-install should overwrite
693        install_codex(dir.path(), false).unwrap();
694        let wrapper = std::fs::read_to_string(&wrapper_path).unwrap();
695        assert!(
696            !wrapper.contains("/old/path/mati"),
697            "re-init must update the wrapper binary path"
698        );
699    }
700
701    #[test]
702    fn malformed_hooks_json_backed_up_and_replaced() {
703        let dir = TempDir::new().unwrap();
704        let codex_dir = dir.path().join(".codex");
705        std::fs::create_dir_all(&codex_dir).unwrap();
706
707        let malformed = "{not valid json";
708        std::fs::write(codex_dir.join("hooks.json"), malformed).unwrap();
709
710        install_codex(dir.path(), false).unwrap();
711
712        // Original malformed content should be backed up
713        let bak_path = codex_dir.join("hooks.json.bak");
714        assert!(bak_path.exists(), "backup file must exist");
715        assert_eq!(std::fs::read_to_string(&bak_path).unwrap(), malformed);
716
717        // Replaced hooks.json must be valid JSON with mati's hooks
718        let hooks: serde_json::Value =
719            serde_json::from_str(&std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
720                .expect("hooks.json must be valid JSON after recovery");
721        assert!(hooks["hooks"]["SessionStart"].is_array());
722        assert!(hooks["hooks"]["PreToolUse"].is_array());
723    }
724
725    #[test]
726    fn non_object_hooks_json_causes_error() {
727        let dir = TempDir::new().unwrap();
728        let codex_dir = dir.path().join(".codex");
729        std::fs::create_dir_all(&codex_dir).unwrap();
730
731        std::fs::write(codex_dir.join("hooks.json"), "[1, 2, 3]").unwrap();
732
733        let err = install_codex(dir.path(), false).unwrap_err();
734        let msg = format!("{err}");
735        assert!(
736            msg.contains("not a JSON object"),
737            "error must mention 'not a JSON object', got: {msg}"
738        );
739    }
740
741    #[test]
742    fn malformed_config_toml_backed_up_and_replaced() {
743        let dir = TempDir::new().unwrap();
744        let codex_dir = dir.path().join(".codex");
745        std::fs::create_dir_all(&codex_dir).unwrap();
746
747        let malformed = "[broken toml";
748        std::fs::write(codex_dir.join("config.toml"), malformed).unwrap();
749
750        install_codex(dir.path(), false).unwrap();
751
752        // Original malformed content should be backed up
753        let bak_path = codex_dir.join("config.toml.bak");
754        assert!(bak_path.exists(), "backup file must exist");
755        assert_eq!(std::fs::read_to_string(&bak_path).unwrap(), malformed);
756
757        // Replaced config.toml must be valid TOML with mati's config
758        let config = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
759        let doc = config
760            .parse::<DocumentMut>()
761            .expect("config.toml must be valid TOML after recovery");
762        assert_eq!(
763            doc["features"]["hooks"].as_bool(),
764            Some(true),
765            "features.hooks must be true"
766        );
767    }
768}