Share Claude and Codex hook scripts (#19755)

## Summary

- add repo-local Codex hooks matching the existing Claude hooks
- move the hook implementations into `scripts/hooks` so both agents
share one implementation
- handle Codex `apply_patch` payloads in the post-edit formatter
- keep other repo-local `.codex` state ignored while tracking
`.codex/hooks.json`

## Validation

- `python3 -m json.tool .claude/settings.json`
- `python3 -m json.tool .codex/hooks.json`
- `bash -n scripts/hooks/session-start.sh`
- `bash -n scripts/hooks/session-start-web.sh`
- exercised `uv run scripts/hooks/post-edit-format.py` with Claude
`Write` and Codex `apply_patch` payloads
This commit is contained in:
Zanie Blue
2026-06-09 12:39:08 -05:00
committed by GitHub
parent 535643a7b1
commit 1c5cab0e58
7 changed files with 142 additions and 80 deletions
-77
View File
@@ -1,77 +0,0 @@
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Post-edit hook to auto-format files after Claude edits."""
import json
import subprocess
import sys
from pathlib import Path
def format_rust(file_path: str, cwd: str) -> None:
"""Format Rust files with cargo fmt."""
try:
subprocess.run(
["cargo", "fmt", "--", file_path],
cwd=cwd,
capture_output=True,
)
except FileNotFoundError:
pass
def format_python(file_path: str, cwd: str) -> None:
"""Format Python files with ruff."""
try:
subprocess.run(
["uvx", "ruff", "format", file_path],
cwd=cwd,
capture_output=True,
)
except FileNotFoundError:
pass
def format_prettier(file_path: str, cwd: str) -> None:
"""Format files with prettier."""
try:
subprocess.run(
["npx", "prettier", "--write", file_path], cwd=cwd, capture_output=True
)
except FileNotFoundError:
pass
def main() -> None:
import os
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path")
# Only process Write, Edit, and MultiEdit tools
if tool_name not in ("Write", "Edit", "MultiEdit"):
return
if not file_path:
return
cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
path = Path(file_path)
ext = path.suffix
if ext == ".rs":
format_rust(file_path, cwd)
elif ext in (".py", ".pyi"):
format_python(file_path, cwd)
elif ext in (".json5", ".yaml", ".yml", ".md"):
format_prettier(file_path, cwd)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-start.sh"
"command": "bash scripts/hooks/session-start.sh"
}
]
}
@@ -17,7 +17,7 @@
"hooks": [
{
"type": "command",
"command": "uv run .claude/hooks/post-edit-format.py"
"command": "uv run scripts/hooks/post-edit-format.py"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "bash scripts/hooks/session-start.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "uv run scripts/hooks/post-edit-format.py"
}
]
}
]
}
}
+4
View File
@@ -51,3 +51,7 @@ profile.json.gz
# IDE
.idea
.vscode
# Codex
.codex/*
!.codex/hooks.json
+109
View File
@@ -0,0 +1,109 @@
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Post-edit hook to auto-format files after agent edits."""
import json
import os
import subprocess
import sys
from pathlib import Path
def format_rust(file_path: str, cwd: str) -> None:
"""Format Rust files with cargo fmt."""
try:
subprocess.run(
["cargo", "fmt", "--", file_path],
cwd=cwd,
capture_output=True,
)
except FileNotFoundError:
pass
def format_python(file_path: str, cwd: str) -> None:
"""Format Python files with ruff."""
try:
subprocess.run(
["uvx", "ruff", "format", file_path],
cwd=cwd,
capture_output=True,
)
except FileNotFoundError:
pass
def format_prettier(file_path: str, cwd: str) -> None:
"""Format files with prettier."""
try:
subprocess.run(
["npx", "prettier", "--write", file_path], cwd=cwd, capture_output=True
)
except FileNotFoundError:
pass
def patch_file_paths(command: str) -> list[str]:
"""Return added or updated file paths from an `apply_patch` payload."""
file_paths: list[str] = []
current_update_index: int | None = None
for line in command.splitlines():
if line.startswith("*** Add File: "):
file_paths.append(line.removeprefix("*** Add File: "))
current_update_index = None
elif line.startswith("*** Update File: "):
file_paths.append(line.removeprefix("*** Update File: "))
current_update_index = len(file_paths) - 1
elif line.startswith("*** Move to: ") and current_update_index is not None:
file_paths[current_update_index] = line.removeprefix("*** Move to: ")
current_update_index = None
elif line.startswith("*** Delete File: "):
current_update_index = None
return list(dict.fromkeys(file_paths))
def edited_file_paths(input_data: dict[str, object]) -> list[str]:
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input")
if not isinstance(tool_input, dict):
return []
if tool_name in ("Write", "Edit", "MultiEdit"):
file_path = tool_input.get("file_path")
return [file_path] if isinstance(file_path, str) and file_path else []
if tool_name == "apply_patch":
command = tool_input.get("command")
return patch_file_paths(command) if isinstance(command, str) else []
return []
def format_file(file_path: str, cwd: str) -> None:
ext = Path(file_path).suffix
if ext == ".rs":
format_rust(file_path, cwd)
elif ext in (".py", ".pyi"):
format_python(file_path, cwd)
elif ext in (".json5", ".yaml", ".yml", ".md"):
format_prettier(file_path, cwd)
def main() -> None:
input_data = json.load(sys.stdin)
cwd = input_data.get("cwd")
if not isinstance(cwd, str) or not cwd:
cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
for file_path in edited_file_paths(input_data):
format_file(file_path, cwd)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -3,5 +3,5 @@ set -euo pipefail
# Dispatch to web hook if running remotely
if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
exec "$(dirname "$0")/session-start-web.sh"
exec bash "$(dirname "$0")/session-start-web.sh"
fi