diff --git a/.claude/hooks/post-edit-format.py b/.claude/hooks/post-edit-format.py deleted file mode 100644 index e033634c09..0000000000 --- a/.claude/hooks/post-edit-format.py +++ /dev/null @@ -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() diff --git a/.claude/settings.json b/.claude/settings.json index 88b7e3d72b..c026b5f2bd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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" } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000000..c026b5f2bd --- /dev/null +++ b/.codex/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 3a5797885d..5131a4604d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ profile.json.gz # IDE .idea .vscode + +# Codex +.codex/* +!.codex/hooks.json diff --git a/scripts/hooks/post-edit-format.py b/scripts/hooks/post-edit-format.py new file mode 100644 index 0000000000..2c502ab713 --- /dev/null +++ b/scripts/hooks/post-edit-format.py @@ -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() diff --git a/.claude/hooks/session-start-web.sh b/scripts/hooks/session-start-web.sh old mode 100755 new mode 100644 similarity index 100% rename from .claude/hooks/session-start-web.sh rename to scripts/hooks/session-start-web.sh diff --git a/.claude/hooks/session-start.sh b/scripts/hooks/session-start.sh old mode 100755 new mode 100644 similarity index 71% rename from .claude/hooks/session-start.sh rename to scripts/hooks/session-start.sh index 009af0ddba..ca87dcfe84 --- a/.claude/hooks/session-start.sh +++ b/scripts/hooks/session-start.sh @@ -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