Dans mon harnais, j'ai un fichier CLAUDE.md avec des règles. Je demande notamment à mon agent de lancer ruff et mes tests à chaque nouvelle fonctionnalité ou modification. Mais attention, rien ne garantit que Claude respecte les règles de ce fichier. Il essaie de les suivre du mieux qu'il peut 😅.
Parlons hooks ! Si ce qui est dans le CLAUDE.md est une instruction, un hook est un script qui s'exécute quoi qu'il arrive.
Pourquoi CLAUDE.md ne suffit pas ?
Il est important de savoir que CLAUDE.md est du contexte, pas une configuration « appliquée ». Imaginez si, en plus, vous avez des instructions vagues ou contradictoires : les risques grandissent. Aussi, il est important de savoir que Claude a davantage de chances de zapper une partie quand le fichier fait plus de 200 lignes.
Ce qui peut devenir embêtant pour le lint, les tests ou l'accès à des fichiers contenant des secrets.
À noter
La documentation le dit elle-même : si une instruction doit s'exécuter à un moment bien précis, il faut utiliser un hook.
C'est quoi un hook ?
Nous avons commencé à donner la réponse avant : un hook est un script exécuté par Claude Code automatiquement à un moment précis. Le hook n'est pas appelé par le modèle lui-même, mais par Claude Code (le programme), qui lance le script dès que l'événement survient, tout en lui transmettant le contexte de l'événement en JSON. Le script répond par un code de sortie.
On compte une trentaine d'événements, voici ceux que l'on retrouve dans la majorité des cas :
-
SessionStart: au démarrage d'une session -
UserPromptSubmit: quand vous envoyez un prompt, avant qu'il ne soit traité -
PreToolUse: avant qu'un outil s'exécute -
PostToolUse: après qu'un outil a réussi -
Stop: quand Claude termine de répondre
Quand je parle d'outil, c'est ce qui permet d'agir sur un projet : Read, Edit, Write, Bash, etc. Vous retrouverez la liste sur cette page.
Exemple concret d'un hook
Pour notre exemple, on va utiliser Ruff. Si vous ne connaissez pas encore Ruff, n'hésitez pas à aller lire notre guide sur le sujet. Pour la faire courte, Ruff est à la fois un linter et un formateur.
Après chaque Edit ou Write, on va lancer Ruff pour le lint et le formatage.
Les deux outils n'agissent pas de la même façon : Write crée un fichier et son contenu, et Edit sert à éditer un fichier.
Préparation du projet
On va partir de zéro pour notre exemple, mais vous pouvez faire ce que l'on va voir sur un projet existant. Pour ma part, je pars de zéro :
uv init --no-package demo-hooks cd demo-hooks uv add --dev ruff pytest
--no-package permet de créer un projet plat avec nos fichiers Python à la racine, car depuis la version 0.12, uv crée un projet avec un dossier src/. D'ailleurs, supprimez le main.py généré, il ne nous servira pas.
Le hook
Deux fichiers : le script et le branchement.
D'abord, le script dans .claude/hooks/ruff.py :
"""Hook PostToolUse : lint et formatage Ruff après chaque écriture de fichier Python.""" import json import os import subprocess import sys # Claude Code envoie le contexte de l'événement en JSON sur l'entrée standard evenement = json.load(sys.stdin) fichier = evenement["tool_input"].get("file_path", "") # On ne s'occupe que des fichiers Python if not fichier.endswith(".py"): sys.exit(0) # uv remonte depuis ce dossier pour trouver le pyproject.toml dossier = os.path.dirname(fichier) # On corrige ce qui peut l'être, puis on formate # (uv run, car Ruff est une dépendance de développement du projet) lint = subprocess.run( ["uv", "run", "ruff", "check", "--fix", "--quiet", fichier], capture_output=True, text=True, cwd=dossier, ) subprocess.run(["uv", "run", "ruff", "format", "--quiet", fichier], cwd=dossier) # S'il reste des erreurs non corrigeables, on les remonte à Claude if lint.returncode != 0: print(lint.stdout, file=sys.stderr) sys.exit(2)
À noter
Vous verrez dans la documentation des exemples en shell, mais la documentation parle aussi de Python et de Node.js comme alternatives.
Le script est plus compréhensible si on voit ce qu'il reçoit. Imaginons que Claude vient d'écrire dans le fichier inventaire.py : Claude Code envoie sur l'entrée standard du script un JSON décrivant l'événement.
N'hésitez pas à aller voir ce que renvoient les événements sur cette page.
Ensuite, le branchement dans .claude/settings.json :
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff.py"], "timeout": 30 } ] } ] } }
Trois niveaux :
-
l'événement
-
les matchers
-
les hooks
On pourrait aussi appeler un service HTTP ou encore un MCP. La liste des hooks est disponible ici.
Attention
Le code 1 ne fait rien de particulier. Dans un hook Claude Code, seul le code 2 est pris en compte : vous retrouverez la liste de ses effets sur cette page.
Pour vérifier que le hook est bien chargé, tapez /hooks : vous verrez combien de hooks sont disponibles par événement :
Liste des évènements
Notre hook
On va essayer notre hook avec une simple demande :
Crée inventaire.py pour un jeu d'exploration, avec une fonction ramasser(inventaire, objet) qui renvoie un nouvel inventaire contenant l'objet, et une fonction peut_ouvrir(inventaire, porte) qui dit si on a la clé correspondante.
Résultat du prompt
Claude me dit que le hook s'est déclenché. Mais on ne va pas lui faire confiance : on va lancer le mode debug avec claude --debug-file /tmp/claude.log, puis suivre le journal avec tail -f /tmp/claude.log dans un autre terminal (je recommence la manipulation) :
[Stall] tool_dispatch_end tool=Write outcome=ok durationMs=24 [DEBUG] Hook output does not start with {, treating as plain text
C'est notre hook : comme tout va bien, il ne renvoie pas de JSON. À la fin du hook, j'ai ajouté print(f"ruff est passé sur {fichier}", file=sys.stderr), et j'ai recommencé encore une fois la manipulation. J'ai bien :
[Stall] tool_dispatch_end tool=Write toolUseId=toolu_015di2hLW6nMwzFAWMyt2nwT outcome=ok durationMs=25 [DEBUG] Hook output does not start with {, treating as plain text [DEBUG] "Hook PostToolUse:Write (PostToolUse) success:\nruff est passé sur /Users/gabrieltrouve/Pro/demo-hooks/inventaire.py"
Empêcher que Claude termine tant que les tests échouent
Maintenant, je veux que Claude ne puisse pas dire qu'il a terminé sa tâche tant que la suite ne passe pas. Nous utilisons l'événement Stop.
Dans .claude/hooks/pytest.py :
"""Hook Stop : Claude ne peut pas terminer son tour tant que les tests échouent.""" import json import os import subprocess tests = subprocess.run( ["uv", "run", "pytest", "-q"], capture_output=True, text=True, cwd=os.environ["CLAUDE_PROJECT_DIR"], check=False, ) if tests.returncode != 0: # On garde les 30 dernières lignes : la sortie d'un hook est plafonnée rapport = "\n".join(tests.stdout.splitlines()[-30:]) print( json.dumps( { "decision": "block", "reason": f"Les tests échouent. Corrige-les avant de terminer :\n{rapport}", }, ensure_ascii=False, ) )
reason est envoyé à Claude, et on tronque, car la sortie d'un hook est plafonnée à 10 000 caractères.
Maintenant, le branchement dans le fichier .claude/settings.json (existant) :
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff.py"], "timeout": 30 } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/pytest.py"], "timeout": 120 } ] } ] } }
Mettons ça en pratique :
Ajoute des tests pytest pour inventaire.py
Pour le debug, j'ajoute cette ligne à la fin du hook : print(f"pytest : {tests.returncode}", file=sys.stderr). Je lance ensuite Claude avec claude --debug-file /tmp/claude.log, et tail -f /tmp/claude.log dans un autre terminal :
[DEBUG] Hook output does not start with {, treating as plain text [DEBUG] "Hook Stop (Stop) success:\npytest : 0"
Bloquer une commande avant qu'elle s'exécute ?
Il est possible d'utiliser PreToolUse, qui se déclenche avant l'exécution d'un outil et peut l'annuler.
Vous pourriez très bien vous assurer que Claude n'utilise jamais pip install, puisque vous utilisez uv :
"""Hook PreToolUse : on impose uv, pas pip.""" import json import sys evenement = json.load(sys.stdin) commande = evenement["tool_input"].get("command", "") if "pip install" in commande: print("pip install est interdit sur ce projet, utilise uv add", file=sys.stderr) sys.exit(2)
Le branchement qui va avec :
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff.py"], "timeout": 30 } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/pytest.py"], "timeout": 120 } ] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/pas-de-pip.py"] } ] } ] } }
J'ai voulu le fâcher un peu :
install requests via pip install requests
Le hook en action
Où placer les hooks ?
-
~/.claude/settings.jsonpour tous vos projets -
.claude/settings.jsonuniquement pour un projet, ce qui permet de le commiter -
.claude/settings.local.jsontoujours pour le projet, mais ignoré par git
À savoir que, d'un niveau à l'autre, les hooks se cumulent.
CLAUDE.md ou hook ?
Si le fichier CLAUDE.md explique à Claude comment on travaille, le hook permet de s'assurer qu'une action est faite. Dans un fichier CLAUDE.md, on retrouvera l'architecture du projet, les conventions, les commandes du projet, etc. Dans un hook, le formatage, le lint, les tests, les commandes interdites, etc.
Cet ensemble bien construit permet d'avoir un bon harnais.