From 8034425efb5b9d081a32a1e2132b30864fc881c2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 8 Jul 2026 11:06:54 +0530 Subject: [PATCH] Supporting materials for claude code hooks article --- .../.claude/hooks/format_with_ruff.py | 20 ++++++++ claude-code-hooks/.claude/hooks/guard_pip.py | 17 +++++++ .../.claude/hooks/notify_desktop.py | 31 ++++++++++++ claude-code-hooks/.claude/settings.json | 13 +++++ claude-code-hooks/.gitignore | 10 ++++ claude-code-hooks/README.md | 47 +++++++++++++++++++ claude-code-hooks/main.py | 6 +++ claude-code-hooks/pyproject.toml | 7 +++ 8 files changed, 151 insertions(+) create mode 100644 claude-code-hooks/.claude/hooks/format_with_ruff.py create mode 100644 claude-code-hooks/.claude/hooks/guard_pip.py create mode 100644 claude-code-hooks/.claude/hooks/notify_desktop.py create mode 100644 claude-code-hooks/.claude/settings.json create mode 100644 claude-code-hooks/.gitignore create mode 100644 claude-code-hooks/README.md create mode 100644 claude-code-hooks/main.py create mode 100644 claude-code-hooks/pyproject.toml diff --git a/claude-code-hooks/.claude/hooks/format_with_ruff.py b/claude-code-hooks/.claude/hooks/format_with_ruff.py new file mode 100644 index 0000000000..acb50cd3b0 --- /dev/null +++ b/claude-code-hooks/.claude/hooks/format_with_ruff.py @@ -0,0 +1,20 @@ +import json +import subprocess +import sys +from pathlib import Path + + +def main(): + event = json.load(sys.stdin) + if event.get("tool_name") not in {"Write", "Edit"}: + return 0 + file_path = event.get("tool_input", {}).get("file_path", "") + if not file_path.endswith(".py") or not Path(file_path).exists(): + return 0 + subprocess.run(["ruff", "check", "--fix", file_path], check=False) + subprocess.run(["ruff", "format", file_path], check=False) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-code-hooks/.claude/hooks/guard_pip.py b/claude-code-hooks/.claude/hooks/guard_pip.py new file mode 100644 index 0000000000..4f757c143f --- /dev/null +++ b/claude-code-hooks/.claude/hooks/guard_pip.py @@ -0,0 +1,17 @@ +import json +import sys + + +def main(): + event = json.load(sys.stdin) + if event.get("tool_name") != "Bash": + return 0 + command = event.get("tool_input", {}).get("command", "") + if "pip install" in command or "pip3 install" in command: + print("Use 'uv add' instead of pip install.", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-code-hooks/.claude/hooks/notify_desktop.py b/claude-code-hooks/.claude/hooks/notify_desktop.py new file mode 100644 index 0000000000..227127fa0a --- /dev/null +++ b/claude-code-hooks/.claude/hooks/notify_desktop.py @@ -0,0 +1,31 @@ +import json +import platform +import subprocess +import sys + + +def notify(title, message): + system = platform.system() + if system == "Darwin": + script = f'display notification "{message}" with title "{title}"' + subprocess.run(["osascript", "-e", script], check=False) + elif system == "Linux": + subprocess.run(["notify-send", title, message], check=False) + elif system == "Windows": + toast = f"New-BurntToastNotification -Text '{title}', '{message}'" + subprocess.run( + ["powershell", "-NoProfile", "-Command", toast], check=False + ) + + +def main(): + json.load(sys.stdin) + try: + notify("Claude Code", "Claude just finished responding") + except FileNotFoundError: + print("Claude just finished responding") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-code-hooks/.claude/settings.json b/claude-code-hooks/.claude/settings.json new file mode 100644 index 0000000000..83d179b1b3 --- /dev/null +++ b/claude-code-hooks/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "hooks": { + "Stop": [ + { "hooks": [ { "type": "command", "command": "python .claude/hooks/notify_desktop.py" } ] } + ], + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python .claude/hooks/guard_pip.py" } ] } + ], + "PostToolUse": [ + { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "python .claude/hooks/format_with_ruff.py" } ] } + ] + } +} diff --git a/claude-code-hooks/.gitignore b/claude-code-hooks/.gitignore new file mode 100644 index 0000000000..505a3b1ca2 --- /dev/null +++ b/claude-code-hooks/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/claude-code-hooks/README.md b/claude-code-hooks/README.md new file mode 100644 index 0000000000..920d0586ad --- /dev/null +++ b/claude-code-hooks/README.md @@ -0,0 +1,47 @@ +# Claude Code Hooks Demo + +Supporting materials for the Real Python tutorial **How to Automate Your Workflow With Claude Code Hooks**. + +This is a small [uv](https://docs.astral.sh/uv/)-managed project with three Claude Code hooks wired up in `.claude/`: + +- **`Stop` hook** (`notify_desktop.py`): a cross-platform desktop notification each time Claude finishes responding. +- **`PreToolUse` hook** (`guard_pip.py`): blocks `pip install` and steers Claude toward `uv`. +- **`PostToolUse` hook** (`format_with_ruff.py`): formats the file Claude just edited with ruff. + +Each hook is a small Python script in `.claude/hooks/`, wired up in `settings.json`. + +## Setup + +You need Python 3.12 or later, uv, and ruff. Install ruff as a uv tool so the hook can call it from anywhere: + +```console +$ uv tool install ruff +``` + +Then sync the project: + +```console +$ uv sync +``` + +On Windows, the notification hook shows a toast through PowerShell's BurntToast module. Install it once: + +```console +> Install-Module -Name BurntToast -Scope CurrentUser +``` + +## Try the hooks + +Start Claude Code in this folder: + +```console +$ claude +``` + +Then: + +- Ask Claude to do anything, and you get a desktop notification when it finishes. +- Ask Claude to `pip install requests`. The hook blocks pip, and Claude uses `uv add` instead. +- Ask Claude to edit a Python file. ruff reformats the file on save. + +Run `/hooks` inside Claude Code to list the wired hooks, or start with `claude --debug` to watch them fire. diff --git a/claude-code-hooks/main.py b/claude-code-hooks/main.py new file mode 100644 index 0000000000..8b4277f725 --- /dev/null +++ b/claude-code-hooks/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from hooks-demo!") + + +if __name__ == "__main__": + main() diff --git a/claude-code-hooks/pyproject.toml b/claude-code-hooks/pyproject.toml new file mode 100644 index 0000000000..af008c2e4c --- /dev/null +++ b/claude-code-hooks/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "claude-hooks-demo" +version = "0.1.0" +description = "Sample project for the Real Python tutorial on Claude Code hooks." +readme = "README.md" +requires-python = ">=3.12" +dependencies = []