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; 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 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 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 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 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 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 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 assert!(
627 exec_target.starts_with('/'),
628 "wrapper must use absolute path, got: {exec_target}"
629 );
630
631 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 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 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 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 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 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 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 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 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 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}