Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8. Construis un plugin — et laisse Claude écrire tes commits

Pair-programming with Claude to build a haw plugin

Tu apportes l’espace de travail, Claude apporte la prose — du pair-programming pour tes commits et tes PR.

Tu as composé une flotte, tu l’as épinglée, tu as vécu dans le cockpit, tu as livré des changesets et tu les as verrouillés en CI. Maintenant, la partie amusante : tu vas étendre haw toi-même. Et à la fin de ce chapitre, tu auras appris à un assistant IA — Claude — à lire toute ta flotte et à écrire tes commits et le texte de tes pull requests à ta place.

Ce chapitre ne présuppose rien. Si tu n’as jamais écrit de plugin haw, si tu ne sais pas ce qu’est MCP et si tu n’as jamais touché au protocole de plugin, tu es exactement au bon endroit. On définira chaque terme dès sa première apparition, on parcourra chaque commande et on te montrera la sortie que tu dois attendre à chaque étape.

On va construire un vrai plugin — haw-commit-ai — et le faire grandir en deux niveaux :

  • Niveau 1 — les fondations (un seul dépôt). Apprends la mécanique des plugins et la poignée de main MCP un dépôt à la fois. Honnête et simple. À ce niveau, Claude voit déjà tout seul le diff d’un unique dépôt, donc on lui apprend surtout à parler proprement le protocole de haw.
  • Niveau 2 — la puissance inter-dépôts (à l’échelle du changeset). La récompense. Claude, tout seul, ne peut pas voir un changeset couvrant toute la flotte et s’étendant sur plusieurs dépôts. haw le peut. On tend à Claude le diff inter-dépôts combiné et on le laisse écrire une pull request cohérente qui raconte tous les dépôts ensemble. C’est ce que ni Claude ni un outil mono-dépôt comme lazygit ne peuvent faire seuls.

Dans ce chapitre, tu vas apprendre à…

  • Comprendre ce qu’est vraiment un plugin haw, et comment haw le trouve et l’exécute.
  • Lire le contexte haw.plugin/1 que haw remet à chaque plugin, champ par champ.
  • Générer un plugin fonctionnel avec haw plugins new.
  • Émettre les deux formes machine que haw comprend : un haw.plugin.report/1 pour --format json et un panneau haw.plugin.view/1 pour la vue Plugins du cockpit (7).
  • Comprendre ce qu’est MCP, et transformer le même script en serveur MCP pour que Claude puisse appeler de petits outils sûrs.
  • Niveau 2 : remets à Claude le diff inter-dépôts combiné et laisse-le rédiger une seule PR à l’échelle de la flotte — l’histoire inter-dépôts qu’aucun outil mono-dépôt ne peut raconter.

Niveau 1 — les fondations (un seul dépôt)

C’est la base sur laquelle tu construis tout le reste : un dépôt à la fois, et chaque concept dont tu as besoin pour comprendre le protocole de plugin et la poignée de main MCP. C’est volontairement simple. Prends tes marques ici, puis le Niveau 2 débloque la puissance inter-dépôts.

Vois le Niveau 1 comme les petites roues : utile, honnête et volontairement pas encore magique. Reste jusqu’au Niveau 2 — c’est là que ça décolle.

1. Ce qu’est vraiment un plugin haw

Partons de zéro. Un plugin haw n’est rien de plus qu’un programme exécutable nommé haw-<name> qui se trouve quelque part sur ton PATH. C’est toute l’idée. Il n’y a pas de registre de plugins où s’inscrire, pas de bibliothèque dynamique à charger, pas d’API spéciale à lier.

haw suit la même convention que git, cargo et kubectl : quand tu tapes une sous-commande qu’il ne reconnaît pas, il cherche un exécutable correspondant et le lance. Voici le déroulement en mots :

  1. Tu tapes haw commit-ai.
  2. haw vérifie ses sous-commandes intégrées. commit-ai n’en fait pas partie.
  3. haw parcourt chaque répertoire de ton PATH à la recherche d’un exécutable nommé haw-commit-ai.
  4. Il en trouve un, le lance comme un processus séparé et lui transmet tes arguments.
  5. Tout ce que ce programme imprime devient la sortie ; le code de sortie qu’il renvoie devient le code de sortie de haw.

Donc haw commit-ai veut vraiment juste dire « lance le programme haw-commit-ai et donne-lui du contexte sur mon espace de travail ». On appelle ça le dispatch par PATH, et c’est pourquoi tu peux publier haw-jira, haw-sbom ou haw-whatever sans jamais toucher au code source de haw. Un plugin cassé ne peut pas faire planter haw, parce qu’il tourne dans son propre processus.

Tu peux voir exactement quels répertoires haw analyse :

haw plugins path      # affiche les répertoires du PATH où haw cherche les binaires haw-*

On va construire un plugin appelé haw-commit-ai. Il a deux visages, tous deux issus d’un seul script :

  • En tant que plugin ordinaire (haw commit-ai), il rédige des messages de commit et du texte de PR à partir de tes diffs.
  • En tant que serveur MCP, il laisse Claude lire ton espace de travail et tes diffs et écrire lui-même le vrai texte de commit et de PR — en toute sécurité.

On fera d’abord le plugin simple, puis on ajoutera le visage MCP.

2. Le contexte que haw tend à chaque plugin : haw.plugin/1

Quand haw lance ton plugin, il ne se contente pas de démarrer un programme aveugle. Il tend au plugin un contexte : un document JSON qui décrit ton espace de travail. Ce document porte un nom de schéma, haw.plugin/1, et c’est le contrat entre haw et chaque plugin.

D’où vient-il ? haw fournit le même JSON à deux endroits, pour que tu lises celui qui t’arrange :

  • la variable d’environnement HAW_JSON, et
  • l’entrée standard (stdin) du plugin.

Le contenu est identique dans les deux. La variable d’environnement est en général plus pratique parce que sa lecture ne bloque jamais.

Qu’y a-t-il dedans ? Voici un exemple réel du contexte à l’intérieur d’un espace de travail :

{
  "schema": "haw.plugin/1",
  "root": "/path/to/workspace",
  "stack": "gateway",
  "repos": [
    { "name": "kernel", "path": "/path/to/workspace/kernel", "rev": "v6.1.2", "groups": ["firmware"] },
    { "name": "hal",    "path": "/path/to/workspace/hal",    "rev": "main",   "groups": ["firmware"] }
  ]
}

Lisons-le champ par champ :

  • schema — toujours "haw.plugin/1". Il t’indique quelle version du contrat tu as sous les yeux.
  • root — le chemin absolu vers la racine de l’espace de travail (le répertoire qui contient ton manifeste). Tout ce que le plugin écrit doit rester à l’intérieur.
  • stack — le nom de la stack active (la sélection nommée de dépôts avec laquelle tu travailles). Ici, c’est "gateway".
  • repos — la liste des dépôts en jeu. Chaque entrée a :
    • name — le nom court du dépôt (kernel, hal).
    • path — son emplacement absolu sur le disque. C’est le champ important : pour lancer git diff ou git commit sur un dépôt, tu appelles un shell dans ce path.
    • rev — la révision épinglée (un tag comme v6.1.2 ou une branche comme main).
    • groups — les groupes auxquels le dépôt appartient (["firmware"]).

Lancé en dehors d’un espace de travail, le contexte se réduit à un simple {"schema": "haw.plugin/1"} — pas de root, pas de repos. Un plugin bien élevé vérifie si root et repos sont présents et fait quelque chose de sensé quand ils sont absents.

Voici comment lire ce contexte en Python, étape par étape. Lis d’abord la variable d’environnement ; si elle est vide, replie-toi sur stdin ; s’il n’y a rien du tout, renvoie le contexte minimal pour que le plugin ne plante jamais :

import json, os, sys

def read_context() -> dict:
    raw = os.environ.get("HAW_JSON", "")       # 1. préférer la variable d'env (ne bloque jamais)
    if not raw and not sys.stdin.isatty():     # 2. se replier sur stdin s'il est passé en pipe
        raw = sys.stdin.read()
    if not raw:                                # 3. rien du tout → contexte minimal
        return {"schema": "haw.plugin/1"}
    try:
        ctx = json.loads(raw)                  # 4. parser le JSON
    except ValueError:
        return {"schema": "haw.plugin/1"}      # 5. mal formé → dégradation gracieuse
    return ctx if isinstance(ctx, dict) else {"schema": "haw.plugin/1"}

Une fois que tu as ctx, tout le reste n’est que du travail shell ordinaire : ctx["repos"] te donne le path sur disque de chaque dépôt, et git diff / git commit ne sont que des appels de sous-processus dans ce chemin.

3. Les trois formes de sortie qu’un plugin peut imprimer

Un plugin peut imprimer trois types de sortie différents, selon la façon dont il est appelé. Tu n’es pas obligé de tous les prendre en charge, mais un bon plugin le fait :

  1. Texte brut — le comportement par défaut. Quand quelqu’un lance haw commit-ai dans un terminal, imprime un texte convivial et lisible par un humain.
  2. Un rapport machine — appelé avec --format json, imprime un document haw.plugin.report/1 : {schema, plugin, ok, summary, findings}. Les outils et la CI parsent ça au lieu de gratter du texte humain.
  3. Un panneau de cockpit — quand haw veut afficher ton plugin dans le cockpit TUI, il définit la variable d’environnement HAW_RENDER=1 et place "intent": "render" dans le contexte. Ton plugin imprime alors un document haw.plugin.view/1 : {schema, title, lines[]}. haw dessine ces lignes dans la vue Plugins du cockpit (appuie sur 7).

Les trois schémas JSON vivent dans schemas/ — ce sont la source de vérité pour chaque champ.

4. Générer le squelette du plugin

Tu n’as pas à écrire tout ça depuis un fichier vide. haw génère un squelette exécutable qui implémente déjà le contrat pour toi.

Prérequis : tu as besoin de Python 3 installé. Vérifie-le :

python3 --version      # n'importe quel Python 3.x récent convient

Génère maintenant le squelette :

haw plugins new commit-ai --lang python
created ./haw-commit-ai/haw-commit-ai   (executable, python3)
created ./haw-commit-ai/README.md
next:
  chmod is already set — drop it on PATH:
    PATH="$PWD/haw-commit-ai:$PATH" haw commit-ai

Deux fichiers apparaissent dans un nouveau répertoire ./haw-commit-ai/ :

  • haw-commit-ai — l’exécutable du plugin lui-même (un script Python avec un shebang #!/usr/bin/env python3, déjà marqué exécutable).
  • README.md — des notes pour le plugin.

Le squelette lit déjà $HAW_JSON, gère --help et --format json, et émet un haw.plugin.report/1. C’est un plugin correct et fonctionnel tel quel. Dans les sections suivantes, on remplacera son corps par notre version capable de MCP.

Pourquoi Python ? Parce que le SDK MCP qu’on utilisera côté Claude est pensé d’abord pour Python. Le visage plugin reste sans dépendance (bibliothèque standard uniquement) ; seul le visage --mcp a besoin d’un paquet supplémentaire, qu’on installera plus tard avec pip install mcp.

5. Les visages du plugin : texte humain, rapport JSON et panneau de cockpit

Construisons le plugin. On va l’introduire morceau par morceau pour que rien ne reste mystérieux, puis on te montrera le script complet.

D’abord, quelques petits utilitaires. context_repos extrait la liste des dépôts du contexte en toute sécurité, et _run est un mince wrapper autour de l’exécution d’une commande shell et de la capture de sa sortie :

import json, os, subprocess, sys

def context_repos(ctx):
    r = ctx.get("repos")
    return [x for x in r if isinstance(x, dict)] if isinstance(r, list) else []

def _run(cmd, cwd=None):
    p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
    return p.returncode, p.stdout, p.stderr

def repo_diff_text(path):                        # changements indexés + non indexés par rapport à HEAD
    rc, out, _ = _run(["git", "-C", path, "diff", "HEAD"], cwd=path)
    return out

Ensuite, changeset_repos détermine sur quels dépôts agir. Il demande à haw quels dépôts le changeset courant a touchés ; si ça revient vide, il se replie sur tout dépôt ayant des changements sales (non committés) :

def changeset_repos(ctx):                        # dépôts touchés, sinon dépôts sales
    root, repos = ctx.get("root"), context_repos(ctx)
    if root:
        rc, out, _ = _run(["haw", "change", "status", "--format", "json"], cwd=root)
        if rc == 0 and out.strip():
            try: data = json.loads(out)
            except ValueError: data = {}
            names = {r.get("name") for r in data.get("repos", []) if isinstance(r, dict)}
            touched = [r for r in repos if r.get("name") in names]
            if touched: return touched
    dirty = []
    for r in repos:
        rc, out, _ = _run(["git", "-C", r["path"], "status", "--porcelain"], cwd=r["path"])
        if out.strip(): dirty.append(r)
    return dirty

Maintenant, les deux sorties machine. emit_report imprime le document haw.plugin.report/1 pour --format json, et emit_view imprime le panneau haw.plugin.view/1 pour le cockpit :

def emit_report(ctx):
    repos = changeset_repos(ctx) or context_repos(ctx)
    findings = [{"level": "info", "message": f"{r['name']} : rédiger un commit"} for r in repos]
    print(json.dumps({"schema": "haw.plugin.report/1", "plugin": "commit-ai",
                       "ok": True, "summary": f"{len(repos)} dépôt(s)", "findings": findings}, indent=2))

def emit_view(ctx):
    repos = changeset_repos(ctx) or context_repos(ctx)
    lines = [f"{r['name']:<16} rédiger un commit" for r in repos] or ["rien à committer"]
    print(json.dumps({"schema": "haw.plugin.view/1",
                      "title": "commit-ai — commits proposés", "lines": lines}))

Voilà tout le visage plugin — la partie qui n’a besoin d’aucun paquet externe. Le visage MCP arrive ensuite.

6. Ce qu’est MCP, et pourquoi c’est important ici

Avant d’écrire le visage MCP, définissons MCP, parce que tu ne peux pas brancher ce que tu ne comprends pas.

MCP (Model Context Protocol) est une façon standard pour un assistant IA d’appeler les outils que tu exposes. En trois phrases :

  1. C’est un protocole simple parlé via stdio (entrée/sortie standard) à l’aide de messages JSON-RPC — ton programme lit les requêtes sur stdin et écrit les réponses sur stdout.
  2. Ton programme annonce un ensemble d’outils (des fonctions nommées avec des arguments typés), et un assistant IA comme Claude peut les appeler et lire les résultats.
  3. C’est tout : MCP est le pont qui laisse Claude faire des choses dans ton monde — lire un diff, committer un dépôt — au lieu de seulement en discuter.

Pourquoi c’est important ici : haw connaît toute ta flotte — chaque dépôt, chaque chemin, chaque changeset. Si on expose ce savoir sous forme d’outils MCP, Claude peut les appeler pour lire tes diffs et écrire des messages de commit et du texte de PR précis. Claude arrête de deviner et se met à travailler à partir du vrai diff.

La bonne nouvelle : tu n’implémentes pas toi-même le protocole filaire JSON-RPC. Le SDK MCP officiel embarque un utilitaire appelé FastMCP qui transforme une simple fonction Python en outil avec un seul décorateur. Tu écris des fonctions normales ; FastMCP gère le protocole.

7. Le visage MCP : les outils que Claude appellera

Voici la fonction run_mcp(). Elle importe FastMCP (en échouant gracieusement si le paquet n’est pas installé), crée un serveur et enregistre chaque outil avec le décorateur @mcp.tool(). La docstring de chaque fonction est ce que Claude voit comme description de l’outil, donc on les écrit clairement.

Deux petits garde-fous sont définis d’abord : _repo_path recherche par nom le chemin sur disque d’un dépôt, et _within_root garantit que toute écriture reste à l’intérieur du root de l’espace de travail :

def run_mcp():
    try:
        from mcp.server.fastmcp import FastMCP
    except ImportError:
        sys.stderr.write("haw-commit-ai --mcp needs the MCP SDK: pip install mcp\n")
        return 1
    mcp = FastMCP("haw-commit-ai")

    def _repo_path(ctx, repo):
        return next((r.get("path") for r in context_repos(ctx) if r.get("name") == repo), None)

    def _within_root(root, path):                 # garde-chemin : les écritures restent à l'intérieur de root
        if not root or not path: return False
        root_abs, path_abs = os.path.realpath(root), os.path.realpath(path)
        return path_abs == root_abs or path_abs.startswith(root_abs + os.sep)

Maintenant les cinq outils du Niveau 1. Lis les docstrings — c’est ce que Claude lit aussi :

    @mcp.tool()
    def haw_context() -> dict:
        """Racine de l'espace de travail, stack courante et dépôts (name, path, rev, groups)."""
        ctx = read_context()
        return {"root": ctx.get("root"), "stack": ctx.get("stack"), "repos": context_repos(ctx)}

    @mcp.tool()
    def repo_diff(repo: str) -> str:
        """Le diff git indexé+non indexé d'un dépôt — vois ce qui a changé."""
        path = _repo_path(read_context(), repo)
        return repo_diff_text(path) if path else f"aucun dépôt nommé {repo!r}"

    @mcp.tool()
    def changeset_repos_tool() -> list:
        """Dépôts touchés par le changeset courant, sinon les dépôts sales."""
        return changeset_repos(read_context())

    @mcp.tool()
    def write_commit(repo: str, message: str) -> str:
        """git commit -m dans un dépôt. Garde-chemin sur la racine de l'espace de travail."""
        ctx = read_context(); path = _repo_path(ctx, repo)
        if not _within_root(ctx.get("root"), path):
            return f"refusé : {repo!r} est en dehors de la racine de l'espace de travail."
        rc, out, err = _run(["git", "-C", path, "commit", "-m", message], cwd=path)
        return f"committé {repo} :\n{out}" if rc == 0 else f"échec du commit :\n{err or out}"

    @mcp.tool()
    def draft_pr(repo: str, title: str, body: str, submit: bool = False) -> str:
        """Renvoie le texte de la PR. À blanc par défaut — ne pousse jamais sauf si submit=True."""
        text = f"# {title}\n\n{body}"
        if not submit:
            return text + "\n\n(exécution à blanc — passe submit=True pour lancer `haw change request`)"
        ctx = read_context()
        rc, out, err = _run(["haw", "change", "request", "--title", title, "--body", body],
                            cwd=ctx.get("root"))
        return f"{text}\n\n[request: {'ok' if rc == 0 else 'failed'}]\n{out or err}"

    mcp.run()
    return 0

Voici ce que fait chaque outil et pourquoi il existe :

  • haw_context() — tend à Claude la forme de l’espace de travail : root, stack et dépôts. C’est ainsi que Claude apprend que ta flotte existe.
  • repo_diff(repo) — renvoie le diff d’un dépôt pour que Claude lise exactement ce qui a changé avant d’écrire à ce sujet.
  • changeset_repos_tool() — indique à Claude quels dépôts sont en jeu en ce moment.
  • write_commit(repo, message) — committe réellement, mais uniquement à l’intérieur de la racine de l’espace de travail (le garde-chemin refuse tout ce qui est en dehors).
  • draft_pr(repo, title, body) — renvoie le texte de la PR ; à blanc par défaut, donc il ne pousse jamais rien sauf si tu passes explicitement submit=True.

Enfin, main() relie tous les visages ensemble — aide, MCP, rapport JSON, rendu cockpit et le texte humain par défaut :

def main():
    args = sys.argv[1:]
    if "-h" in args or "--help" in args:
        print("haw-commit-ai — rédige des commits/PR ; --mcp pour servir Claude"); return 0
    if "--mcp" in args: return run_mcp()
    ctx = read_context()
    if "--format" in args and "json" in args: emit_report(ctx); return 0
    if os.environ.get("HAW_RENDER") == "1" or ctx.get("intent") == "render":
        emit_view(ctx); return 0
    repos = changeset_repos(ctx) or context_repos(ctx)
    print(f"haw-commit-ai — {len(repos)} dépôt(s). Lance avec --mcp pour laisser Claude écrire.")
    return 0

if __name__ == "__main__":
    sys.exit(main())

Le listing ci-dessus est la version Niveau 1. Le fichier livré examples/plugins/haw-commit-ai/haw-commit-ai est la version entièrement commentée (avec un vrai squelette de commit conventionnel et un modèle de corps de PR) et porte aussi les outils inter-dépôts du Niveau 2 qu’on ajoute plus bas. Les deux passent python3 -m py_compile et tournent avec zéro dépendance en mode plugin.

8. Lance le plugin — pas encore de MCP, pas encore de Claude

Prouvons d’abord qu’il fonctionne comme un plugin simple. Rends le fichier exécutable et place son répertoire sur ton PATH pour la commande :

chmod +x haw-commit-ai
PATH="$PWD:$PATH" haw commit-ai               # brouillon humain
PATH="$PWD:$PATH" haw commit-ai --format json # un haw.plugin.report/1

La première commande imprime une ligne conviviale. La seconde imprime un rapport JSON que tu peux parser — quelque chose comme :

{
  "schema": "haw.plugin.report/1",
  "plugin": "commit-ai",
  "ok": true,
  "summary": "2 dépôt(s)",
  "findings": [
    { "level": "info", "message": "kernel : rédiger un commit" },
    { "level": "info", "message": "hal : rédiger un commit" }
  ]
}

Pas de recompilation, aucun changement du cœur : dépose haw-commit-ai sur ton PATH et haw dispatche vers lui comme vers n’importe quelle commande intégrée.

Et parce qu’il émet aussi un haw.plugin.view/1, ton plugin trouve sa place dans le cockpit — ouvre haw dash, appuie sur 7, et le voilà dans le panneau Plugins :

9. Brancher le serveur MCP dans Claude Code

Maintenant, le côté Claude. Installe d’abord le SDK MCP — c’est la seule dépendance, et seul le visage --mcp en a besoin :

pip install mcp

Enregistre le serveur auprès de Claude Code — une seule commande. Utilise un chemin absolu vers ton fichier de plugin :

claude mcp add haw-commit-ai -- python3 /abs/path/to/haw-commit-ai --mcp

Ou, par projet, dépose-le dans un fichier .mcp.json à la racine de ton projet :

{
  "mcpServers": {
    "haw-commit-ai": {
      "command": "python3",
      "args": ["/abs/path/to/haw-commit-ai", "--mcp"]
    }
  }
}

Vérifie que Claude voit le serveur :

claude mcp list            # haw-commit-ai devrait apparaître dans la liste

Dans une session Claude, /mcp affiche le serveur connecté et ses outils. Au Niveau 1, ce sont haw_context, repo_diff, changeset_repos_tool, write_commit et draft_pr. Une fois que tu ajoutes le Niveau 2 ci-dessous, changeset_diff et draft_changeset_pr les rejoignent.

10. Exemple concret — Claude écrit ton texte de commit et de PR

Fais un changement sur deux dépôts de ton espace de travail (disons kernel et hal), indexe-les, puis demande à Claude — depuis le répertoire de l’espace de travail :

« Lis les diffs des dépôts touchés par mon changeset courant et écris un message de commit conventionnel pour chacun. Puis rédige un unique corps de PR inter-dépôts. Committe chaque dépôt avec son message ; laisse la PR en brouillon à blanc. »

Claude va :

  1. appeler changeset_repos_tool() → voit kernel, hal,
  2. appeler repo_diff("kernel") et repo_diff("hal") → lit exactement ce qui a changé,
  3. écrire des messages de commit conventionnels (ex. fix(kernel): guard against null irq handler),
  4. appeler write_commit("kernel", …) et write_commit("hal", …) — chacun protégé par garde-chemin vers ton espace de travail,
  5. appeler draft_pr("kernel", "…", "…") → renvoie un corps de PR à blanc (rien n’est poussé).

Tu relis les brouillons, et quand tu es satisfait, tu lances haw change request toi-même (ou tu laisses Claude appeler draft_pr(..., submit=True)).

Soyons honnêtes à propos du Niveau 1. À ce stade, Claude Code voit déjà nativement le diff d’un unique dépôt — tu ne lui as pas encore donné de super-pouvoir, tu lui as juste appris à parler proprement le protocole de haw. La vraie puissance arrive au Niveau 2 : lui montrer un changeset entier, réparti sur plusieurs dépôts, dans une seule vue.

Jusqu’ici, on a surtout réinventé ce que Claude fait gratuitement pour un seul dépôt. Garde la foi : le niveau suivant est la partie qu’il ne peut pas faire tout seul.

This is fine — a cartoon dog sipping coffee as the room burns

« On a construit tout un plugin pour faire ce que Claude faisait déjà. » C’est très bien — le Niveau 2 corrige ça.

À toi de jouer (Niveau 1)

  • Génère le tien : haw plugins new commit-ai –lang python, puis lance la face sans dépendance avec haw commit-ai –format json et confirme que tu obtiens un document haw.plugin.report/1.
  • Dépose le plugin sur le PATH, ouvre haw dash, appuie sur 7, et sélectionne commit-ai — ton panneau haw.plugin.view/1 s’affiche directement dans le cockpit.
  • pip install mcp, enregistre-le avec claude mcp add …, et demande à Claude de lire le diff d’un dépôt et de proposer un commit — sans committer. Puis laisse-le appeler write_commit, et observe le garde-fou de chemin en action en lui demandant de committer un chemin hors de l’espace de travail (il devrait refuser).

Niveau 2 — la puissance inter-dépôts (à l’échelle du changeset)

Voici l’argument, tranchant : Claude seul ne peut pas voir un changeset couvrant toute la flotte. Il peut lire le diff d’un dépôt — mais un changeset haw s’étend sur plusieurs dépôts à la fois (kernel, hal, app…), et cette histoire combinée vit entre les dépôts. Un outil mono-dépôt comme lazygit ne peut pas la montrer non plus. haw connaît tout le changeset, donc haw peut tendre à Claude l’image complète.

On ajoute deux outils au même plugin — pas de nouveau script, pas de nouveau serveur. Ils transforment haw-commit-ai de « un sympathique assistant de commit » en « la chose qui donne à un LLM une vision de toute la flotte ».

11. Pourquoi l’inter-dépôts est la valeur unique

Imagine que tu ajoutes une fonctionnalité — disons un flag irq_mask — et qu’elle doive atterrir dans trois dépôts à la fois : le pilote kernel qui possède le registre, la couche hal qui le fait circuler, et l’app qui l’expose sur la CLI. Le diff de chaque dépôt, lu seul, n’est qu’un fragment. Le sens — « ces trois mouvements ne sont qu’une seule fonctionnalité et doivent atterrir ensemble » — n’existe que lorsque tu vois les trois diffs côte à côte.

  • Claude, tout seul, lit un dépôt à la fois. Il ne peut pas voir les fragments comme une seule histoire.
  • Un outil mono-dépôt voit un seul dépôt, point final.
  • haw connaît le changeset, donc il peut concaténer le diff de chaque dépôt en un seul document et le tendre à Claude. Désormais, Claude écrit une PR cohérente qui raconte toute la fonctionnalité.

Cette vue combinée est la valeur unique. Tout ce qui compose le Niveau 2 existe pour la livrer.

12. Deux outils inter-dépôts

Dépose-les à côté des outils du Niveau 1 (le fichier livré examples/plugins/haw-commit-ai/haw-commit-ai les possède déjà). D’abord, l’utilitaire qui construit le diff combiné — il concatène le diff de chaque dépôt sous un en-tête clair === <repo> === :

def changeset_diff_text(ctx):
    """Le diff git COMBINÉ à travers chaque dépôt du changeset courant."""
    repos = changeset_repos(ctx) or context_repos(ctx)
    if not repos:
        return "pas de changeset et pas de dépôts sales — rien à comparer."
    chunks = []
    for r in repos:                                  # en-têtes clairs par dépôt
        diff = repo_diff_text(r["path"]) if r.get("path") else ""
        body = diff.rstrip() if diff.strip() else "(aucun changement)"
        chunks.append(f"=== {r.get('name','?')} ===\n{body}")
    return "\n\n".join(chunks)                        # toute l'histoire, de haut en bas

C’est la fonction phare : un seul appel, et Claude voit les diffs de kernel, hal et app concaténés sous des en-têtes === <repo> === — le changeset couvrant toute la flotte comme un unique document lisible.

Ensuite, l’utilitaire qui construit un unique squelette de PR inter-dépôts — un emplacement de résumé combiné plus une section par dépôt, que Claude remplit ensuite avec de la prose :

def draft_changeset_pr_body(ctx, title):
    """UN squelette de PR inter-dépôts cohérent racontant tous les dépôts ensemble."""
    repos = changeset_repos(ctx) or context_repos(ctx)
    lines = [f"# {title}", "", "## Résumé combiné", "",
             "<!-- un récit couvrant tous les dépôts et pourquoi ils évoluent ensemble -->", "",
             "## Changements par dépôt", ""]
    for r in repos:
        files, changed = diff_stat(r.get("path", "")) if r.get("path") else (0, 0)
        stat = f" ({files} fichier(s), {changed} ligne(s))" if files or changed else ""
        lines += [f"### {r.get('name','?')}{stat}", "", "<!-- ce qui a changé ici et pourquoi -->", ""]
    lines += ["## Tests", "", "- [ ] `haw build`", "- [ ] `haw test`", ""]
    return "\n".join(lines)

Les deux sont enveloppés en outils MCP avec le décorateur FastMCP, exactement comme ceux du Niveau 1 — ajoute-les à l’intérieur de run_mcp() :

    @mcp.tool()
    def changeset_diff() -> str:
        """Le diff git COMBINÉ à travers TOUS les dépôts du changeset, avec des en-têtes = par dépôt.
        La vue couvrant toute la flotte qu'un outil mono-dépôt ne peut pas te donner."""
        return changeset_diff_text(read_context())

    @mcp.tool()
    def draft_changeset_pr(title: str) -> str:
        """UN squelette de PR inter-dépôts cohérent racontant tous les dépôts ensemble.
        À blanc — assemble l'ossature ; tu remplis la prose, puis `haw change request`."""
        return draft_changeset_pr_body(read_context(), title)

draft_changeset_pr(title) renvoie un corps de PR : le plugin assemble le squelette (sections par dépôt plus un emplacement de résumé combiné), et Claude remplit la prose à partir des diffs. À blanc par défaut ; passe le résultat à haw change request pour ouvrir les PR liées à travers la flotte.

write_commit reste protégé par garde-chemin. Il n’y a pas d’outil inter-dépôts « tout écrire » par conception — pour un changeset, tu committes par dépôt (Claude appelle write_commit pour chacun, chacun gardé sur root), puis tu lances haw change request pour ouvrir les PR liées à travers la flotte. Les écritures restent petites, relisables et à l’intérieur de ton espace de travail.

13. Exemple concret — une seule PR pour un changeset de trois dépôts

Touche deux ou trois dépôts dans un changeset — disons kernel, hal et app — indexe-les, puis demande à Claude, depuis l’espace de travail :

« Appelle changeset_diff() pour lire tout mon changeset, puis draft_changeset_pr() et écris une seule PR qui raconte l’histoire combinée — une section par dépôt plus un résumé combiné. »

Claude va :

  1. appeler changeset_diff() → un document avec === kernel ===, === hal ===, === app ===, le diff de chaque dépôt en dessous,
  2. appeler draft_changeset_pr("…") → obtient le squelette avec une section par dépôt,
  3. remplir la prose pour en faire un unique récit cohérent.

Voici un résultat illustratif (étiqueté comme illustratif — ta prose correspondra à tes diffs réels) :

# feat: propagate the new irq-mask flag end to end

## Combined summary
A new `irq_mask` flag flows from the kernel driver up through the HAL and into
the app's config surface. The three repos move together so the feature lands atomically.

## Per-repo changes
### kernel
Add `irq_mask` to the driver's register write and guard the null-handler path.
### hal
Thread `irq_mask` through the HAL's `configure()` and expose it in the C header.
### app
Surface `--irq-mask` on the CLI and wire it to the HAL call.

C’est ce que ni Claude ni un outil mono-dépôt ne peuvent faire seuls. Un outil mono-dépôt voit trois diffs déconnectés ; haw plus ce plugin tendent à Claude le changeset, alors il écrit l’unique histoire qui les traverse. Quand tu es satisfait, committe chaque dépôt (write_commit, par dépôt) et lance haw change request pour ouvrir les PR liées à travers la flotte.

Jubilant celebration reaction

Un seul prompt. Trois dépôts. Un récit de PR cohérent. C’est la récompense — va fêter ça.

14. Notes de sécurité — voici la partie importante

Des outils d’écriture plus un LLM, ça veut dire que les garde-fous comptent. C’est la section que tu ne survoles pas. Le plugin intègre les garde-fous d’office :

  • Écritures protégées par garde-chemin. write_commit et draft_pr(submit=True) refusent tout chemin de dépôt qui n’est pas à l’intérieur du root de l’espace de travail — Claude ne peut pas committer en dehors de ta flotte.
  • À blanc par défaut. draft_pr ne renvoie que du texte ; il ne pousse jamais ni ne force-pousse sauf si tu passes explicitement submit=True.
  • Aucun secret dans le plugin. L’authentification à la forge vient de ton environnement — la résolution de token normale de haw (GITHUB_TOKEN, et ainsi de suite). Le plugin ne stocke rien.
  • Processus séparé, codes de sortie honnêtes. Le plugin tourne hors processus ; un bug ne peut pas faire planter haw, et un code de sortie non nul se propage pour que la CI verrouille toujours.

À toi de jouer (Niveau 2)

  • Touche à trois dépôts dans un changeset, puis demande à Claude d’appeler changeset_diff() et draft_changeset_pr() et de rédiger un seul récit de PR couvrant les trois (kernel / hal / app plus un résumé combiné). Compare-le à ce que tu obtiendrais en interrogeant Claude dépôt par dépôt — l’histoire inter-dépôts n’apparaît que lorsqu’il voit tout le changeset d’un coup.
  • Étends le plugin : ajoute un outil repo_log(repo, n) pour que Claude puisse voir l’historique récent et écrire de meilleurs messages. Garde-le en lecture seule.
  • Ajoute un décompte de lignes par dépôt à la sortie du diff combiné, pour que Claude sache quel dépôt porte l’essentiel du changement avant de commencer à écrire.

Glossaire

  • plugin — un exécutable nommé haw-<name> sur ton PATH ; haw le lance comme haw <name>.
  • dispatch par PATH — la convention (partagée avec git/cargo/kubectl) où haw lance une sous-commande inconnue en trouvant un exécutable correspondant sur PATH.
  • haw.plugin/1 — le contexte JSON que haw tend à chaque plugin (via HAW_JSON ou stdin), décrivant root, stack et repos.
  • haw.plugin.report/1 — le rapport machine qu’un plugin imprime pour --format json.
  • haw.plugin.view/1 — le panneau de cockpit qu’un plugin imprime quand haw lui demande de s’afficher (HAW_RENDER=1, "intent": "render").
  • MCP (Model Context Protocol) — un protocole JSON-RPC standard sur stdio qui laisse un assistant IA appeler les outils que tu exposes.
  • stdio — les flux d’entrée et de sortie standard d’un programme ; les messages MCP y transitent.
  • outil — une fonction nommée (avec des arguments typés) que ton serveur MCP annonce pour que Claude l’appelle.
  • FastMCP — l’utilitaire du SDK MCP officiel qui transforme une fonction Python en outil MCP avec un décorateur.

Ce que tu as appris

  • Un plugin est n’importe quel exécutable nommé haw-<name> sur PATH ; haw lui tend le contexte haw.plugin/1 via $HAW_JSON / stdin et propage son code de sortie.
  • Il peut imprimer un haw.plugin.report/1 (--format json) et un panneau haw.plugin.view/1 (intention de rendu, HAW_RENDER=1) pour la vue Plugins du cockpit (7).
  • MCP est un protocole standard sur stdio qui laisse Claude appeler les outils que tu exposes ; le même script devient un serveur MCP avec --mcp, en utilisant FastMCP.
  • Le Niveau 1 (un seul dépôt) enseigne le protocole : haw_context, repo_diff, changeset_repos_tool, write_commit, draft_pr — mais Claude voit déjà un dépôt nativement.
  • Le Niveau 2 (inter-dépôts) est la vraie puissance : changeset_diff tend à Claude le diff combiné à travers tout le changeset, et draft_changeset_pr le pousse à écrire un récit de PR couvrant toute la flotte — l’histoire inter-dépôts que ni Claude ni un outil mono-dépôt ne peuvent raconter seuls.
  • Garde-fous : garde-chemin sur les écritures à l’intérieur de root (committe par dépôt, puis haw change request), garde la rédaction de PR à blanc par défaut, et ne stocke jamais de secrets — l’authentification reste dans ton environnement.

Et maintenant, où aller ?

Tu peux maintenant étendre haw dans n’importe quel langage et donner à un LLM des outils sûrs et riches en contexte. À partir d’ici :

Voilà tout l’outil — maintenant, va construire ta propre poutre. Bienvenue à bord.