C'est quoi les hooks de Claude Code ?

Découvrez ce que sont les hooks de Claude Code et comment les utiliser.

Publié le par Gabriel Trouvé (mis à jour le )

37 minutes

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
SHELL

--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)
PYTHON

À 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
          }
        ]
      }
    ]
  }
}
JSON

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

Liste des évènements

Notre hook

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.
SHELL
Résultat du prompt

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
SHELL

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"
SHELL

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,
        )
    )
PYTHON

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
          }
        ]
      }
    ]
  }
}
JSON

Mettons ça en pratique :

Ajoute des tests pytest pour inventaire.py
SHELL

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"
SHELL

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)
PYTHON

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"]
          }
        ]
      }
    ]
  }
}
JSON

J'ai voulu le fâcher un peu :

install requests via pip install requests 
SHELL
Le hook en action

Le hook en action

Où placer les hooks ?

  • ~/.claude/settings.json pour tous vos projets

  • .claude/settings.json uniquement pour un projet, ce qui permet de le commiter

  • .claude/settings.local.json toujours 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.

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.