1use 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 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 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 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 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 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 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 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 assert!(
667 exec_target.starts_with('/'),
668 "wrapper must use absolute path, got: {exec_target}"
669 );
670
671 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 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 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 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 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 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 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 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 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 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}