Write native Codex hooks config to ~/.codex/hooks.json. Merges code-review-graph hook entries into any existing hooks.json, preserving user-defined hook entries and other top-level settings. A backup of the original file is created before modifications.
(repo_root: Path)
| 759 | |
| 760 | |
| 761 | def install_codex_hooks(repo_root: Path) -> Path: |
| 762 | """Write native Codex hooks config to ~/.codex/hooks.json. |
| 763 | |
| 764 | Merges code-review-graph hook entries into any existing hooks.json, |
| 765 | preserving user-defined hook entries and other top-level settings. |
| 766 | A backup of the original file is created before modifications. |
| 767 | """ |
| 768 | codex_dir = Path.home() / ".codex" |
| 769 | codex_dir.mkdir(parents=True, exist_ok=True) |
| 770 | hooks_path = codex_dir / "hooks.json" |
| 771 | |
| 772 | existing: dict[str, Any] = {} |
| 773 | if hooks_path.exists(): |
| 774 | try: |
| 775 | existing = json.loads(hooks_path.read_text(encoding="utf-8", errors="replace")) |
| 776 | backup_path = codex_dir / "hooks.json.bak" |
| 777 | shutil.copy2(hooks_path, backup_path) |
| 778 | logger.info("Backed up existing Codex hooks to %s", backup_path) |
| 779 | except (json.JSONDecodeError, OSError) as exc: |
| 780 | logger.warning("Could not read existing %s: %s", hooks_path, exc) |
| 781 | |
| 782 | hooks_config = generate_codex_hooks_config(repo_root) |
| 783 | existing_hooks = existing.get("hooks", {}) |
| 784 | if not isinstance(existing_hooks, dict): |
| 785 | logger.warning("Existing Codex hooks config is not a dict; replacing with defaults") |
| 786 | existing_hooks = {} |
| 787 | |
| 788 | merged_hooks = dict(existing_hooks) |
| 789 | for hook_name, hook_entries in hooks_config.get("hooks", {}).items(): |
| 790 | if isinstance(merged_hooks.get(hook_name), list): |
| 791 | merged_list = list(merged_hooks[hook_name]) |
| 792 | existing_commands = { |
| 793 | hook.get("command", "") |
| 794 | for entry in merged_list |
| 795 | if isinstance(entry, dict) |
| 796 | for hook in entry.get("hooks", []) |
| 797 | if isinstance(hook, dict) |
| 798 | } |
| 799 | for entry in hook_entries: |
| 800 | entry_commands = [ |
| 801 | hook.get("command", "") |
| 802 | for hook in entry.get("hooks", []) |
| 803 | if isinstance(hook, dict) |
| 804 | ] |
| 805 | if not any(command in existing_commands for command in entry_commands): |
| 806 | merged_list.append(entry) |
| 807 | merged_hooks[hook_name] = merged_list |
| 808 | else: |
| 809 | merged_hooks[hook_name] = hook_entries |
| 810 | |
| 811 | existing["hooks"] = merged_hooks |
| 812 | hooks_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") |
| 813 | logger.info("Wrote Codex hooks config: %s", hooks_path) |
| 814 | return hooks_path |
| 815 | |
| 816 | |
| 817 | _CLAUDE_MD_SECTION_MARKER = "<!-- code-review-graph MCP tools -->" |