
"What I cannot create, I do not understand."
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.
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 ?
| Raison | Détail |
|---|---|
| Un seul binaire | Une distribution sans runtime. L'utilisateur télécharge un exécutable de quelques mégaoctets, l'exécute, et c'est tout. |
| Allocation statique | Toute 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épendance | Un agent de coding lit votre code source, manipule vos fichiers, et accède à vos clés API. La surface d'attaque doit être minimale. |
comptime | Permet de générer du code spécialisé sans macros ni génériques opaques. Idéal pour un registre d'outils, par exemple. |
| Apprentissage | Zig 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.
Avant d'écrire la moindre ligne, on pose les règles. Tout le projet sera construit selon une discipline stricte :
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.
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 :
#!/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ément | Rôle |
|---|---|
ZIG_MIRROR | URL de base des releases officielles. |
ZIG_RELEASE | Version exacte. Un seul endroit à modifier pour faire un bump. |
ZIG_CHECKSUMS | SHA-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.
.gitignoreOn a maintenant un compilateur de plusieurs centaines de mégaoctets dans ./zig/. Il ne faut surtout pas le commiter. On crée un .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.
build.zigC'est le fichier qui décrit comment construire le projet. Zig n'a pas de Makefile ni de Cargo.toml — build.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 :
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ément | Rôle |
|---|---|
standardTargetOptions | Permet à l'utilisateur de choisir la cible (-Dtarget=x86_64-linux, etc.). |
standardOptimizeOption | Idem pour le mode d'optimisation (-Doptimize=ReleaseFast). |
addExecutable | Définit le binaire hypercode. |
b.path("src/main.zig") | Le point d'entrée. Le chemin est relatif à la racine du projet. |
installArtifact | Indique 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.
build.zig.zonLe pendant déclaratif de build.zig. Il contient les métadonnées du package :
.{
.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",
},
}
| Champ | Rôle |
|---|---|
name | Préfixé par un . — c'est un littéral d'énumération, pas une chaîne. |
fingerprint | Identifiant unique généré par Zig. Permet de détecter les forks. |
minimum_zig_version | Documente la version minimale (en plus de l'assertion comptime). |
dependencies | Vide — c'est notre politique zéro dépendance. |
paths | Liste 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.
main.zigC'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.
//! 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.
./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.)
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.
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.