fr2026-05-13

Construire un agent de coding en Zig : mise en route

Hypercode setup illustration

"What I cannot create, I do not understand."

Richard Feynman

Les agents de coding sont devenus le compagnon quotidien de beaucoup d'entre nous. Claude Code, Codex, Cursor, Aider — chacun pilote un modèle, appelle des outils, édite des fichiers. Ce sont des programmes étonnamment puissants, et étonnamment simples une fois qu'on regarde sous le capot.

Quoi de tel pour les comprendre, que de les réimplémenter ?

Dans cette série d'articles, on va construire Hypercode, un agent de coding open source, écrit en Zig. Pas de framework, pas de dépendance — juste le compilateur Zig 0.16 et la librairie standard. Comme pour la série Git.ts, on procède commande par commande, outil par outil, un commit à la fois.

Tout le code se trouve sur GitHub : github.com/alexisbchz/hypercode.

Pourquoi Zig ?

Zig n'est pas le choix le plus évident pour un agent de coding. La plupart des agents existants sont en TypeScript, en Rust ou en Go. Alors pourquoi Zig ?

RaisonDétail
Un seul binaireUne distribution sans runtime. L'utilisateur télécharge un exécutable de quelques mégaoctets, l'exécute, et c'est tout.
Allocation statiqueToute la mémoire est allouée au démarrage. Plus d'allocation, plus de free une fois le programme lancé. Cela élimine les fuites mémoire et donne des latences prévisibles.
Zéro dépendanceUn agent de coding lit votre code source, manipule vos fichiers, et accède à vos clés API. La surface d'attaque doit être minimale.
comptimePermet de générer du code spécialisé sans macros ni génériques opaques. Idéal pour un registre d'outils, par exemple.
ApprentissageZig est un DSL pour le code machine. On voit exactement ce que la machine va faire.

L'inspiration principale vient de TigerBeetle, la base de données financière. Leur TigerStyle est notre référence : safety, performance, developer experience — dans cet ordre.

Le style avant le code

Avant d'écrire la moindre ligne, on pose les règles. Tout le projet sera construit selon une discipline stricte :

  • Pas de récursion. Toute boucle a une borne explicite.
  • Allocation mémoire statique : tout est alloué au démarrage, plus rien après.
  • Au moins deux assertions par fonction, en moyenne.
  • Maximum 70 lignes par fonction.
  • 100 colonnes par ligne, jamais plus.
  • Zéro dépendance externe.

Ces règles sont consignées dans un fichier CLAUDE.md à la racine du projet. C'est une adaptation directe du TigerStyle, traduite pour un agent de coding. Pas besoin de la lire dans le détail pour suivre la série, mais c'est la boussole qu'on consultera quand un choix d'implémentation est ambigu.

Verrouiller la version du compilateur

Premier piège classique : "ça compile chez moi". Les nightlies de Zig évoluent vite. Pour éviter ce piège, on vend la version exacte du compilateur dans le dépôt.

On crée un script zig/download.sh qui télécharge Zig 0.16.0 et vérifie son SHA-256 :

zig/download.sh
#!/usr/bin/env sh
set -eu

ZIG_MIRROR="https://ziglang.org/download"
ZIG_RELEASE="0.16.0"
ZIG_CHECKSUMS=$(cat<<EOF
${ZIG_MIRROR}/0.16.0/zig-aarch64-linux-0.16.0.tar.xz ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17
${ZIG_MIRROR}/0.16.0/zig-aarch64-macos-0.16.0.tar.xz b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489
${ZIG_MIRROR}/0.16.0/zig-x86_64-linux-0.16.0.tar.xz 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00
${ZIG_MIRROR}/0.16.0/zig-x86_64-macos-0.16.0.tar.xz 0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7
EOF
)

Le script détecte l'architecture et l'OS, télécharge l'archive correspondante, vérifie le checksum, puis l'extrait dans ./zig/. Le binaire devient ./zig/zig.

ÉlémentRôle
ZIG_MIRRORURL de base des releases officielles.
ZIG_RELEASEVersion exacte. Un seul endroit à modifier pour faire un bump.
ZIG_CHECKSUMSSHA-256 par couple (arch, OS). Empêche un téléchargement compromis.

Le script complet est adapté de TigerBeetle — ils ont déjà résolu le problème, on hérite directement. Il y a aussi une version PowerShell pour Windows, dans zig/download.ps1.

On le rend exécutable, puis on le lance :

chmod +x zig/download.sh
./zig/download.sh
Downloading Zig 0.16.0 ...
Extracting ./zig/cache/zig-aarch64-macos-0.16.0.tar.xz ...
Downloaded Zig 0.16.0 to /Users/alex/hypercode/zig/zig

Et on vérifie :

./zig/zig version
0.16.0

Tout le reste du projet utilisera ./zig/zig plutôt que le zig du système.

Préparer le .gitignore

On a maintenant un compilateur de plusieurs centaines de mégaoctets dans ./zig/. Il ne faut surtout pas le commiter. On crée un .gitignore :

.gitignore
# Zig build artefacts.
zig-out/
.zig-cache/

# Vendored Zig toolchain — only the download scripts are tracked.
/zig/*
!/zig/download.sh
!/zig/download.ps1

# Local environment.
.env
.env.local

# Editor.
.idea/
.DS_Store

L'astuce ici, c'est le !/zig/download.sh : on ignore tout ./zig/, sauf les scripts de téléchargement. Comme ça, un nouveau contributeur clone le repo, lance le script, et obtient le même compilateur que tout le monde.

Le build.zig

C'est le fichier qui décrit comment construire le projet. Zig n'a pas de Makefile ni de Cargo.tomlbuild.zig est un programme Zig qui définit le graphe de compilation.

On commence par épingler la version du compilateur au niveau comptime. Si quelqu'un essaie de compiler avec une autre version, on échoue à la compilation avec un message clair :

build.zig
const std = @import("std");
const builtin = @import("builtin");

const zig_version_required = std.SemanticVersion{ .major = 0, .minor = 16, .patch = 0 };

comptime {
    const v = builtin.zig_version;
    if (v.major != zig_version_required.major or
        v.minor != zig_version_required.minor or
        v.patch != zig_version_required.patch)
    {
        @compileError(std.fmt.comptimePrint(
            "unsupported zig version: expected {d}.{d}.{d}, found {d}.{d}.{d}",
            .{
                zig_version_required.major, zig_version_required.minor, zig_version_required.patch,
                v.major,                    v.minor,                    v.patch,
            },
        ));
    }
}

Le bloc comptime { ... } est évalué à la compilation. C'est notre première assertion : on ne peut pas se tromper de compilateur sans le savoir.

Ensuite on définit l'exécutable :

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "hypercode",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    b.installArtifact(exe);
ÉlémentRôle
standardTargetOptionsPermet à l'utilisateur de choisir la cible (-Dtarget=x86_64-linux, etc.).
standardOptimizeOptionIdem pour le mode d'optimisation (-Doptimize=ReleaseFast).
addExecutableDéfinit le binaire hypercode.
b.path("src/main.zig")Le point d'entrée. Le chemin est relatif à la racine du projet.
installArtifactIndique que zig build doit copier le binaire dans zig-out/bin/.

On expose quatre étapes : build, run, check, test.

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);
    const run_step = b.step("run", "Build and run hypercode");
    run_step.dependOn(&run_cmd.step);

    const check_step = b.step("check", "Typecheck without installing");
    check_step.dependOn(&exe.step);

    const exe_tests = b.addTest(.{ .root_module = exe.root_module });
    const run_exe_tests = b.addRunArtifact(exe_tests);
    const test_step = b.step("test", "Run all tests");
    test_step.dependOn(&run_exe_tests.step);
}

L'étape check mérite un mot. Elle force le type-checker à passer sans produire de binaire — la boucle d'itération la plus rapide pendant le développement. À chaque fois qu'on modifie un fichier, on lance ./zig/zig build check, et on a le retour du compilateur en quelques centaines de millisecondes.

Le manifeste : build.zig.zon

Le pendant déclaratif de build.zig. Il contient les métadonnées du package :

build.zig.zon
.{
    .name = .hypercode,
    .version = "0.1.0",
    .fingerprint = 0xd3bfaf40e914a053,
    .minimum_zig_version = "0.16.0",
    .dependencies = .{},
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
        "LICENSE",
        "README.md",
    },
}
ChampRôle
namePréfixé par un . — c'est un littéral d'énumération, pas une chaîne.
fingerprintIdentifiant unique généré par Zig. Permet de détecter les forks.
minimum_zig_versionDocumente la version minimale (en plus de l'assertion comptime).
dependenciesVide — c'est notre politique zéro dépendance.
pathsListe des fichiers inclus dans le package.

Le fingerprint est généré la première fois qu'on lance zig init. On le garde tel quel : si on le change, on ment sur l'identité du package.

Le premier main.zig

C'est notre "Hello, World!". L'objectif est minimal : afficher la version pour vérifier que la chaîne de compilation fonctionne de bout en bout.

src/main.zig
//! Hypercode — entry point.
//!
//! For now this just prints a banner so we can verify the toolchain end-to-end.
//! CLI parsing arrives in Post 02.

const std = @import("std");
const Io = std.Io;

const version = "0.1.0";

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var stdout_buffer: [256]u8 = undefined;
    var stdout_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
    const stdout = &stdout_writer.interface;

    try stdout.print("hypercode {s}\n", .{version});
    try stdout.flush();
}

test "version is non-empty" {
    try std.testing.expect(version.len > 0);
}

Trois choses méritent une explication.

La signature de main. En Zig 0.16, main reçoit un std.process.Init — une structure qui contient un allocator d'arène pour la durée du processus, les arguments de la ligne de commande, et l'instance std.Io pour les entrées/sorties. C'est différent des versions précédentes qui n'avaient pas de paramètre.

Le buffer explicite pour stdout. Plutôt que d'écrire directement vers le descripteur de fichier, Zig 0.16 demande de fournir un buffer. C'est intentionnel : le programme contrôle exactement combien de mémoire est utilisée pour l'I/O. Pas de surprise, pas d'allocation cachée. On bufferise sur 256 octets — largement suffisant pour une bannière.

Le flush(). Sans lui, le buffer n'est jamais écrit. Le commentaire du template zig init le rappelle : Don't forget to flush! C'est une de ces petites disciplines auxquelles on s'habitue.

Le bloc test à la fin est notre première assertion testable. C'est trivial — on vérifie que la version n'est pas vide — mais ça pose la convention : chaque fichier porte ses propres tests.

Vérifier que tout marche

./zig/zig build
./zig-out/bin/hypercode
hypercode 0.1.0

Le check rapide :

./zig/zig build check

(Aucune sortie = succès.)

Les tests :

./zig/zig build test

(Aucune sortie = succès.)

Le formatage :

./zig/zig fmt --check .

(Aucune sortie = succès.)

Les commits

J'aime découper les commits en unités lisibles. Pour cet article, on a cinq commits :

600bf8d feat: print hypercode 0.1.0 banner
88b84a5 build: add build.zig pinned to Zig 0.16
18f300a build: vendor Zig 0.16.0 via download scripts
c76e831 chore: replace Go .gitignore with Zig conventions
8bc7837 chore: add CLAUDE.md style guide for Hypercode

Chacun raconte une histoire. C'est une discipline qui paie : six mois plus tard, quand on cherche pourquoi telle décision a été prise, git log est plus utile que n'importe quel wiki.

Conclusion

On a la fondation : un dépôt, un compilateur épinglé, un build qui marche, un binaire qui s'exécute. Ce n'est pas encore un agent de coding, mais c'est la base sur laquelle tout le reste se posera.

Dans le prochain article, on attaque la CLI : parser les arguments, lire les variables d'environnement, choisir le modèle. On commencera à donner un visage à Hypercode.

Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.