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 on write; confirm via mem_set(action="confirm") — see Confirm routing below.
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="dir_gotchas" query="<dirname-of-file>" limit 5`
131   → top 5 confirmed gotchas for that directory as POSITIVE
132     EXEMPLARS. If the array is empty (cold start), continue with
133     schema-only guidance. Do not use `mode="text"` here: the search
134     index carries no `affected_files`, so a directory query returns
135     file records instead of gotchas.
1362. `mem_get("file:<path>")` — mints the consultation receipt, returns
137   existing gotcha_keys, AND returns the `enrichment_depth_hint` field
138   (D2-α: one of "fast", "standard", "deep"). Use it to pick the
139   tier branch below. If absent (older daemon), default to "deep".
1403. **Deep tier only**: call via Bash
141   `mati ls tombstoned --dir <dirname-of-file> --recent 30d --json`
142   to retrieve NEGATIVE EXEMPLARS — rules that were proposed for
143   this directory and then tombstoned. Use them in Stage 2 to
144   calibrate AGAINST proposing similar rules. If `count` is 0,
145   skip the negative block. Record whether the block was used —
146   controls the `with-neg-exemplars` tag in Stage 4.
1474. **AST seeding**: call `mati extract-signals --file <path>` via Bash
148   for deterministic, AST-aware signal extraction across all 12
149   supported languages. Returns JSON
150   `{ file, language, signal_count, signals: [{ file_line, tier,
151      kind, evidence }, ...] }`. Treat each `file_line` as a SEED that
152   Stage 2 must examine. Seeds never replace the file scan — read the
153   file in Stage 2 at every tier, whatever `signal_count` says.
154   For `panic`, `assert`, and `unwrap_like` signals, `evidence` is a
155   bare identifier (`bail`, `expect`), not source text. A candidate
156   drafted from that alone passes Stage 3 by construction — the quote
157   is the token the extractor matched — and carries an invented
158   reason. Take `evidence_quote` from the file, not from `evidence`.
159   The extractor also misses signals inside macro bodies, which
160   tree-sitter parses as token trees.
161
162### Tier branches (D2)
163
164| Tier      | Positive exemplars | Negative exemplars | Stage 2 file scan |
165| --------- | ------------------ | ------------------ | ----------------- |
166| fast      | no (schema only)   | no                 | yes               |
167| standard  | yes                | no                 | yes               |
168| deep      | yes                | yes                | yes               |
169
170`fast` for trivial files (LoC < 100, isolated blast, no cluster).
171`standard` is the default. `deep` adds negative exemplars for
172hotspot / signal-rich files. Tier gates exemplars only — it never
173gates the file scan.
174
175Stage 3 runs at every tier. It is one CLI call per candidate, not a
176reasoning pass, so tier does not gate it.
177
178### Stage 2 — Enumeration (maximize recall)
179
180Read the file. Output a JSON array of candidates, using the POSITIVE
181EXEMPLARS as calibration for this project's specific bar.
182
183Signal ranking (extract from highest first):
184  HIGH:    WARNING / FIXME / HACK / SAFETY / IMPORTANT comments;
185           panic!/assert!/expect("…") with non-trivial messages;
186           comments explaining "why this looks weird" or "do not".
187  MEDIUM:  Defensive guards (early returns, custom error paths);
188           non-obvious literal arguments (e.g. with_versioning(true, 0));
189           error handling that diverges from the rest of the file.
190  LOW:     Raw API usage with no comment context.
191
192Schema (strict JSON):
193[
194  { "candidate_id": "C1",
195    "signal_tier": "high" | "medium" | "low",
196    "file_line": "L42",
197    "evidence_quote": "exact text from file at that line",
198    "draft_rule": "imperative verb + specific target",
199    "draft_reason": "what breaks and why",
200    "draft_severity": "critical" | "high" | "normal" | "low" } ]
201
202Write each candidate to be Specific (names a concrete API, value, or
203pattern — never "be careful" or "review carefully"), Enforceable (a
204hook could deny a real mistake on it), Non-obvious (not derivable
205from type signatures alone), and Causal (the reason says WHAT breaks,
206with "because"/"since"). These shape how you draft a candidate. They
207are not a filter — do not drop a candidate for failing them here.
208
209Goal: maximize recall. Weak candidates are OK — filtered next.
210
211### Stage 3 — Evidence verification (deterministic, D-α)
212
213One pass, no rounds. For each candidate, call `mati verify-evidence`
214via Bash:
215  mati verify-evidence \
216    --file <path> \
217    --line <candidate.file_line> \
218    --quote "<candidate.evidence_quote>" \
219    --pattern "<api/literal named in candidate.draft_rule>"
220The CLI returns JSON. Parse it:
221  { "verified": true, ... }  → keep, add "verified": true
222  { "verified": false, ... } → DISCARD (hallucinated citation, or
223                                rule generalizes beyond visible scope)
224The CLI is the source of truth. Do not second-guess a verdict, and do
225not re-run a candidate that already returned one — the check is
226deterministic, so a repeat call returns the same answer.
227
228Known limit: the check reads a ±5-line window. It proves the citation
229is real, not that the rule follows from it. Keep `draft_rule` anchored
230to what is visible at `file_line` — a rule whose claim spans more of
231the file than that window passes unverified.
232
233### Stage 4 — Refinement and write
234
235For each verified candidate:
236
2371. Tighten rule: imperative verb first; concrete names not pronouns;
238   ≤ 80 chars where possible. If a candidate is still vague after
239   tightening — no concrete API, value, or pattern to enforce on —
240   drop it here.
2412. Verify reason uses "because"/"since"/"as" — add if missing.
2423. Assign severity (D-β). One judgment, one deterministic floor:
243
244   3a. SEMANTIC pass — the severity. Judge rule + reason against:
245       critical — data loss, corruption, security, unbounded growth
246       high     — wrong result, silent failure, race, broken invariant
247       normal   — performance, workflow blocker, non-obvious cleanup
248       low      — informational, stylistic, minor inconvenience
249
250   3b. KEYWORD FLOOR — deterministic, raises only, never lowers.
251       Scan rule + reason case-insensitively for these stems:
252         "data loss" / "corrupt" / "security" / "unbounded"  → critical
253         "silent" / "race" / "wrong result" / "lost"         → high
254       No stem present → no floor.
255
256   3c. severity = the higher of 3a and the floor.
257       Tag "severity-disputed" ONLY when the floor RAISED 3a — the
258       text names a failure the judgment underrated. No stem matched,
259       or 3a already at or above the floor → no tag. The tag flags a
260       real conflict for the reviewer; it is not a routine annotation.
261
2624. Call `mem_set`:
263     key: `gotcha:<slug>`
264     rule, reason, severity (from step 3)
265     affected_files: [<path>]
266     tags:  ["enriched", "depth:<tier>"]
267          + ["signal-source:ast"] (if this candidate's file_line was
268            an extract-signals seed) else ["signal-source:llm"]
269          + ["with-neg-exemplars"] (if Stage 1 step 3 used negatives)
270          + (["severity-disputed"] if step 3c flagged)
271     confirmed: false
272
273     `signal-source:*` is per candidate, not per file — it records
274     which channel first surfaced the line. The `depth:<tier>` tag
275     (D3) drives per-tier accuracy in `mati doctor`.
276
277     This is attribution, not an experiment: Stage 2 sees the seeds
278     while scanning, so `signal-source:llm` candidates are not an
279     uncontaminated control.
280
281### Notes
282
283- Per-file token budget: ~8K tokens for Stage 2. Stage 3 is CLI
284  calls, near-zero tokens. If you exceed the budget, truncate Stage 2
285  candidates to top 10 by signal_tier.
286- Rust-side quality gate still applies at write time. The pipeline
287  maximizes what gets through; the gate enforces the floor.
288- Do not add verification passes of your own, and do not spawn a
289  subagent to double-check candidates. Stage 3 and the write-time
290  quality gate are the only filters. Extra self-review costs tokens
291  and suppresses recall without improving precision.
292"#;
293
294const SKILL_CONFIG_PATH: &str = ".codex/skills/mati/SKILL.md";
295
296pub const CODEX_HOOK_SCRIPTS: &[(&str, &str)] = &[
297    (
298        "session-start.sh",
299        crate::hooks::codex_session_start::SCRIPT,
300    ),
301    (
302        "user-prompt-submit.sh",
303        crate::hooks::codex_user_prompt::SCRIPT,
304    ),
305    ("pre-bash.sh", crate::hooks::codex_pre_bash::SCRIPT),
306    (
307        "pre-apply-patch.sh",
308        crate::hooks::codex_pre_apply_patch::SCRIPT,
309    ),
310    ("post-bash.sh", crate::hooks::codex_post_bash::SCRIPT),
311    ("stop.sh", crate::hooks::codex_stop::SCRIPT),
312];
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum CodexInstallResult {
316    Installed {
317        scripts: usize,
318        missing_deps: Vec<&'static str>,
319    },
320    NoCodex,
321}
322
323pub fn install_codex(project_root: &Path, create_if_missing: bool) -> Result<CodexInstallResult> {
324    let codex_dir = project_root.join(".codex");
325    if !codex_dir.is_dir() && !create_if_missing {
326        return Ok(CodexInstallResult::NoCodex);
327    }
328
329    std::fs::create_dir_all(&codex_dir)
330        .with_context(|| format!("failed to create {}", codex_dir.display()))?;
331
332    let hooks_path = codex_dir.join("hooks.json");
333    merge_hooks_json(&hooks_path)?;
334
335    let config_path = codex_dir.join("config.toml");
336    merge_config_toml(&config_path, SKILL_CONFIG_PATH, project_root)?;
337
338    let hooks_dir = codex_dir.join("hooks");
339    std::fs::create_dir_all(&hooks_dir)
340        .with_context(|| format!("failed to create {}", hooks_dir.display()))?;
341    for (name, content) in CODEX_HOOK_SCRIPTS {
342        let path = hooks_dir.join(name);
343        write_if_changed(&path, content)?;
344        make_executable(&path)?;
345    }
346
347    // Write mati binary wrapper so hooks resolve the same binary as MCP.
348    super::write_mati_wrapper(&hooks_dir)?;
349
350    let skill_dir = codex_dir.join("skills").join("mati");
351    std::fs::create_dir_all(&skill_dir)
352        .with_context(|| format!("failed to create {}", skill_dir.display()))?;
353    write_if_changed(&skill_dir.join("SKILL.md"), MATI_SKILL)?;
354
355    Ok(CodexInstallResult::Installed {
356        scripts: CODEX_HOOK_SCRIPTS.len(),
357        missing_deps: missing_hook_dependencies(),
358    })
359}
360
361fn merge_hooks_json(path: &Path) -> Result<()> {
362    let mati_hooks: Value = serde_json::from_str(HOOKS_JSON)?;
363    let merged = if path.exists() {
364        let existing_str = std::fs::read_to_string(path)?;
365        let mut existing: Value = match serde_json::from_str(&existing_str) {
366            Ok(v) => v,
367            Err(e) => {
368                let bak = path.with_extension("json.bak");
369                match std::fs::write(&bak, &existing_str) {
370                    Ok(()) => tracing::warn!(
371                        "malformed hooks.json, backed up to {} and starting fresh: {e}",
372                        bak.display()
373                    ),
374                    Err(bak_err) => tracing::warn!(
375                        "malformed hooks.json, starting fresh (backup failed: {bak_err}): {e}"
376                    ),
377                }
378                Value::Object(serde_json::Map::new())
379            }
380        };
381        if let Value::Object(ref mut map) = existing {
382            merge_hooks(map, &mati_hooks["hooks"]);
383        } else {
384            anyhow::bail!("hooks.json exists but is not a JSON object — cannot merge safely");
385        }
386        existing
387    } else {
388        mati_hooks
389    };
390
391    let output = serde_json::to_string_pretty(&merged)?;
392    write_if_changed(path, &output)
393}
394
395fn merge_hooks(root: &mut serde_json::Map<String, Value>, mati_hooks: &Value) {
396    let Some(mati_events) = mati_hooks.as_object() else {
397        root.insert("hooks".to_string(), mati_hooks.clone());
398        return;
399    };
400
401    let hooks_value = root
402        .entry("hooks".to_string())
403        .or_insert_with(|| Value::Object(serde_json::Map::new()));
404
405    let Value::Object(existing_events) = hooks_value else {
406        *hooks_value = mati_hooks.clone();
407        return;
408    };
409
410    for (event_name, mati_entries_value) in mati_events {
411        let Some(mati_entries) = mati_entries_value.as_array() else {
412            existing_events.insert(event_name.clone(), mati_entries_value.clone());
413            continue;
414        };
415
416        let owned_commands = mati_hook_commands(mati_entries);
417        let existing_entries = existing_events
418            .entry(event_name.clone())
419            .or_insert_with(|| Value::Array(Vec::new()));
420
421        let Value::Array(existing_entries) = existing_entries else {
422            *existing_entries = Value::Array(mati_entries.clone());
423            continue;
424        };
425
426        existing_entries.retain(|entry| !entry_contains_owned_command(entry, &owned_commands));
427        existing_entries.extend(mati_entries.clone());
428    }
429}
430
431fn mati_hook_commands(entries: &[Value]) -> Vec<String> {
432    entries.iter().flat_map(entry_hook_commands).collect()
433}
434
435fn entry_hook_commands(entry: &Value) -> Vec<String> {
436    entry
437        .get("hooks")
438        .and_then(Value::as_array)
439        .into_iter()
440        .flatten()
441        .filter_map(|hook| hook.get("command").and_then(Value::as_str))
442        .map(ToOwned::to_owned)
443        .collect()
444}
445
446fn entry_contains_owned_command(entry: &Value, owned_commands: &[String]) -> bool {
447    entry_hook_commands(entry)
448        .iter()
449        .any(|command| owned_commands.iter().any(|owned| owned == command))
450}
451
452fn merge_config_toml(path: &Path, skill_path: &str, project_root: &Path) -> Result<()> {
453    let mut doc = if path.exists() {
454        let existing = std::fs::read_to_string(path)?;
455        match existing.parse::<DocumentMut>() {
456            Ok(d) => d,
457            Err(e) => {
458                let bak = path.with_extension("toml.bak");
459                match std::fs::write(&bak, &existing) {
460                    Ok(()) => tracing::warn!(
461                        "malformed config.toml, backed up to {} and starting fresh: {e}",
462                        bak.display()
463                    ),
464                    Err(bak_err) => tracing::warn!(
465                        "malformed config.toml, starting fresh (backup failed: {bak_err}): {e}"
466                    ),
467                }
468                DocumentMut::new()
469            }
470        }
471    } else {
472        DocumentMut::new()
473    };
474
475    if doc.get("features").is_none() || !doc["features"].is_table() {
476        doc["features"] = Item::Table(Table::new());
477    }
478    // Codex 2026-05+ renamed [features].codex_hooks → [features].hooks.
479    // The runtime emits a deprecation warning on the old key. Public docs
480    // still document codex_hooks (likely lagging the runtime); the warning
481    // is the source of truth. If a future Codex re-deprecates `hooks`,
482    // update this line and bump the scaffold installer version.
483    doc["features"]["hooks"] = value(true);
484
485    if doc.get("mcp_servers").is_none() || !doc["mcp_servers"].is_table() {
486        doc["mcp_servers"] = Item::Table(Table::new());
487    }
488    if !doc["mcp_servers"]
489        .as_table()
490        .is_some_and(|t| t.contains_key("mati"))
491        || !doc["mcp_servers"]["mati"].is_table()
492    {
493        doc["mcp_servers"]["mati"] = Item::Table(Table::new());
494    }
495    doc["mcp_servers"]["mati"]["command"] = value("mati");
496    let mut args = Array::new();
497    args.push("serve");
498    doc["mcp_servers"]["mati"]["args"] = value(args);
499    // Codex spawns MCP servers with CWD=/. The cwd field tells Codex to set
500    // the working directory to the project root so `mati serve` derives the
501    // correct store slug from current_dir().
502    let canonical =
503        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
504    doc["mcp_servers"]["mati"]["cwd"] = value(canonical.to_string_lossy().as_ref());
505
506    // Codex spawns MCP servers with a clean environment (see the CWD=/ note
507    // above), so a non-default MATI_HOME set at init time is NOT inherited by
508    // `mati serve`. Capture it into the server's env so the MCP server opens the
509    // same store the hooks do. Without this, serve falls back to ~/.mati while
510    // hooks (which inherit the session env) use MATI_HOME, and every mem_get
511    // reads an empty store — the deny it was meant to clear can never satisfy.
512    // Only written when MATI_HOME is actually set, so a default install stays
513    // on ~/.mati; an existing env table's other keys are preserved.
514    if let Some(mati_home) = std::env::var_os("MATI_HOME") {
515        if !doc["mcp_servers"]["mati"]
516            .get("env")
517            .is_some_and(|e| e.is_table())
518        {
519            doc["mcp_servers"]["mati"]["env"] = Item::Table(Table::new());
520        }
521        doc["mcp_servers"]["mati"]["env"]["MATI_HOME"] =
522            value(mati_home.to_string_lossy().as_ref());
523    }
524
525    if doc.get("skills").is_none() || !doc["skills"].is_table() {
526        doc["skills"] = Item::Table(Table::new());
527    }
528    if !doc["skills"]
529        .as_table()
530        .is_some_and(|t| t.contains_key("config"))
531        || !doc["skills"]["config"].is_array_of_tables()
532    {
533        doc["skills"]["config"] = Item::ArrayOfTables(ArrayOfTables::new());
534    }
535    let skills = doc["skills"]["config"]
536        .as_array_of_tables_mut()
537        .expect("skills.config should be an array of tables");
538    let existing_index = {
539        skills
540            .iter()
541            .position(|table| table.get("path").and_then(|i| i.as_str()) == Some(skill_path))
542    };
543    if let Some(index) = existing_index {
544        skills.get_mut(index).expect("index should exist")["enabled"] = value(true);
545    } else {
546        let mut skill = Table::new();
547        skill["path"] = value(skill_path);
548        skill["enabled"] = value(true);
549        skills.push(skill);
550    }
551
552    write_if_changed(path, &doc.to_string())
553}
554
555fn missing_hook_dependencies() -> Vec<&'static str> {
556    // Codex hooks are thin wrappers that exec `mati hook-decide`.
557    // No jq/awk dependency — all JSON parsing is in Rust.
558    Vec::new()
559}
560
561use super::{make_executable, write_if_changed};
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use tempfile::TempDir;
567
568    #[test]
569    fn skips_when_no_codex_dir_in_auto_mode() {
570        let dir = TempDir::new().unwrap();
571        let result = install_codex(dir.path(), false).unwrap();
572        assert_eq!(result, CodexInstallResult::NoCodex);
573    }
574
575    #[test]
576    fn installs_codex_config_hooks_and_skill() {
577        let dir = TempDir::new().unwrap();
578        let result = install_codex(dir.path(), true).unwrap();
579        match result {
580            CodexInstallResult::Installed { scripts, .. } => {
581                assert_eq!(scripts, CODEX_HOOK_SCRIPTS.len())
582            }
583            other => panic!("expected Installed, got {other:?}"),
584        }
585
586        let hooks: serde_json::Value = serde_json::from_str(
587            &std::fs::read_to_string(dir.path().join(".codex/hooks.json")).unwrap(),
588        )
589        .unwrap();
590        assert!(hooks["hooks"]["SessionStart"].is_array());
591        assert!(hooks["hooks"]["PreToolUse"].is_array());
592
593        let config = std::fs::read_to_string(dir.path().join(".codex/config.toml")).unwrap();
594        let doc = config.parse::<DocumentMut>().unwrap();
595        assert_eq!(doc["features"]["hooks"].as_bool(), Some(true));
596        assert_eq!(
597            doc["mcp_servers"]["mati"]["args"][0].as_str(),
598            Some("serve")
599        );
600        assert_eq!(
601            doc["skills"]["config"][0]["path"].as_str(),
602            Some(SKILL_CONFIG_PATH)
603        );
604        assert!(dir.path().join(".codex/skills/mati/SKILL.md").exists());
605    }
606
607    #[test]
608    fn merge_preserves_existing_codex_config_and_hooks() {
609        let dir = TempDir::new().unwrap();
610        let codex_dir = dir.path().join(".codex");
611        std::fs::create_dir_all(&codex_dir).unwrap();
612        std::fs::write(
613            codex_dir.join("hooks.json"),
614            r#"{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"custom-pre-write.sh"}]}]}}"#,
615        )
616        .unwrap();
617        std::fs::write(
618            codex_dir.join("config.toml"),
619            "[profiles]\ntrusted = true\n",
620        )
621        .unwrap();
622
623        install_codex(dir.path(), false).unwrap();
624
625        let hooks: serde_json::Value =
626            serde_json::from_str(&std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
627                .unwrap();
628        let pre = hooks["hooks"]["PreToolUse"].as_array().unwrap();
629        assert!(pre.iter().any(|entry| {
630            entry["hooks"]
631                .as_array()
632                .into_iter()
633                .flatten()
634                .any(|hook| hook["command"] == "custom-pre-write.sh")
635        }));
636
637        let config = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
638        let doc = config.parse::<DocumentMut>().unwrap();
639        assert_eq!(doc["profiles"]["trusted"].as_bool(), Some(true));
640        assert_eq!(doc["features"]["hooks"].as_bool(), Some(true));
641    }
642
643    #[test]
644    fn codex_wrapper_contains_absolute_binary_path_matching_mcp_config() {
645        let dir = TempDir::new().unwrap();
646        install_codex(dir.path(), true).unwrap();
647
648        // Wrapper must exist and be executable
649        let wrapper_path = dir.path().join(".codex/hooks/mati");
650        assert!(
651            wrapper_path.exists(),
652            ".codex/hooks/mati wrapper must exist"
653        );
654
655        let wrapper = std::fs::read_to_string(&wrapper_path).unwrap();
656        assert!(wrapper.contains("exec"), "wrapper must use exec");
657
658        // Extract the exec target from the wrapper
659        let exec_line = wrapper.lines().find(|l| l.contains("exec")).unwrap();
660        let exec_target = exec_line
661            .strip_prefix("exec \"")
662            .and_then(|s| s.strip_suffix("\" \"$@\""))
663            .expect("exec line must follow format: exec \"<path>\" \"$@\"");
664
665        // Wrapper uses absolute path (hooks run in restricted shell).
666        assert!(
667            exec_target.starts_with('/'),
668            "wrapper must use absolute path, got: {exec_target}"
669        );
670
671        // MCP config uses portable bare command.
672        let config = std::fs::read_to_string(dir.path().join(".codex/config.toml")).unwrap();
673        let doc = config.parse::<DocumentMut>().unwrap();
674        assert_eq!(
675            doc["mcp_servers"]["mati"]["command"].as_str().unwrap(),
676            "mati",
677            "MCP config must use bare 'mati' for portability"
678        );
679
680        // MCP args must include "serve"
681        let args = doc["mcp_servers"]["mati"]["args"]
682            .as_array()
683            .expect("mcp_servers.mati.args must be an array");
684        let args_str: Vec<&str> = args.iter().filter_map(|v| v.as_str()).collect();
685        assert!(
686            args_str.contains(&"serve"),
687            "args must contain 'serve', got: {args_str:?}"
688        );
689
690        // cwd must be set to an absolute project path (Codex spawns with CWD=/)
691        let cwd = doc["mcp_servers"]["mati"]["cwd"]
692            .as_str()
693            .expect("mcp_servers.mati.cwd must be set");
694        assert!(
695            cwd.starts_with('/'),
696            "cwd must be an absolute path, got: {cwd}"
697        );
698    }
699
700    #[test]
701    fn codex_hook_scripts_prepend_hooks_dir_to_path() {
702        let dir = TempDir::new().unwrap();
703        install_codex(dir.path(), true).unwrap();
704
705        for (name, content_template) in CODEX_HOOK_SCRIPTS {
706            let path = dir.path().join(".codex/hooks").join(name);
707            let content = std::fs::read_to_string(&path)
708                .unwrap_or_else(|_| panic!("hook script {name} must exist"));
709            // No-op hooks (e.g. user-prompt-submit) don't need HOOKS_DIR.
710            if content_template.contains("HOOKS_DIR=") {
711                assert!(
712                    content.contains("HOOKS_DIR=") && content.contains("export PATH="),
713                    "hook script {name} must prepend HOOKS_DIR to PATH"
714                );
715            }
716        }
717    }
718
719    #[test]
720    fn codex_reinit_updates_wrapper_path() {
721        let dir = TempDir::new().unwrap();
722        install_codex(dir.path(), true).unwrap();
723
724        // Tamper with the wrapper to simulate a stale binary path
725        let wrapper_path = dir.path().join(".codex/hooks/mati");
726        std::fs::write(
727            &wrapper_path,
728            "#!/usr/bin/env bash\nexec \"/old/path/mati\" \"$@\"\n",
729        )
730        .unwrap();
731
732        // Re-install should overwrite
733        install_codex(dir.path(), false).unwrap();
734        let wrapper = std::fs::read_to_string(&wrapper_path).unwrap();
735        assert!(
736            !wrapper.contains("/old/path/mati"),
737            "re-init must update the wrapper binary path"
738        );
739    }
740
741    #[test]
742    fn malformed_hooks_json_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 = "{not valid json";
748        std::fs::write(codex_dir.join("hooks.json"), 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("hooks.json.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 hooks.json must be valid JSON with mati's hooks
758        let hooks: serde_json::Value =
759            serde_json::from_str(&std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
760                .expect("hooks.json must be valid JSON after recovery");
761        assert!(hooks["hooks"]["SessionStart"].is_array());
762        assert!(hooks["hooks"]["PreToolUse"].is_array());
763    }
764
765    #[test]
766    fn non_object_hooks_json_causes_error() {
767        let dir = TempDir::new().unwrap();
768        let codex_dir = dir.path().join(".codex");
769        std::fs::create_dir_all(&codex_dir).unwrap();
770
771        std::fs::write(codex_dir.join("hooks.json"), "[1, 2, 3]").unwrap();
772
773        let err = install_codex(dir.path(), false).unwrap_err();
774        let msg = format!("{err}");
775        assert!(
776            msg.contains("not a JSON object"),
777            "error must mention 'not a JSON object', got: {msg}"
778        );
779    }
780
781    #[test]
782    fn malformed_config_toml_backed_up_and_replaced() {
783        let dir = TempDir::new().unwrap();
784        let codex_dir = dir.path().join(".codex");
785        std::fs::create_dir_all(&codex_dir).unwrap();
786
787        let malformed = "[broken toml";
788        std::fs::write(codex_dir.join("config.toml"), malformed).unwrap();
789
790        install_codex(dir.path(), false).unwrap();
791
792        // Original malformed content should be backed up
793        let bak_path = codex_dir.join("config.toml.bak");
794        assert!(bak_path.exists(), "backup file must exist");
795        assert_eq!(std::fs::read_to_string(&bak_path).unwrap(), malformed);
796
797        // Replaced config.toml must be valid TOML with mati's config
798        let config = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
799        let doc = config
800            .parse::<DocumentMut>()
801            .expect("config.toml must be valid TOML after recovery");
802        assert_eq!(
803            doc["features"]["hooks"].as_bool(),
804            Some(true),
805            "features.hooks must be true"
806        );
807    }
808}