
Dans le premier article de cette série, on a posé le squelette : un dépôt, un compilateur Zig 0.16 épinglé, un binaire qui affiche hypercode 0.1.0. C'est joli, mais ça ne fait rien d'utile.
Cet article ajoute la couche par laquelle tout passera : la CLI. Quel modèle utiliser, où est la clé API, quel prompt envoyer. Trois informations, trois sources possibles (flag, variable d'environnement, valeur par défaut), une logique de priorité à respecter.
Le code se trouve toujours sur github.com/alexisbchz/hypercode.
Avant de coder, regardons l'interface que d'autres agents exposent.
claude --help
codex --help
Tous suivent le même schéma :
| Élément | Exemple |
|---|---|
| Flags longs | --model, --api-key, --config |
| Flags courts | -h, -v |
| Variables d'environnement | ANTHROPIC_API_KEY, OPENAI_API_KEY |
| Positional | le prompt, ou un chemin vers un fichier |
Pour Hypercode, l'OpenRouter sera notre passerelle par défaut (elle route vers Anthropic, OpenAI, Poolside, etc. derrière une seule clé). Le minimum vital est donc :
--help et -h : afficher l'aide.--version : afficher la version.--model <name> : choisir le modèle (sinon HYPERCODE_MODEL, sinon défaut).--api-key <key> : la clé OpenRouter (sinon OPENROUTER_API_KEY).Pas plus pour cette itération. Si on a besoin d'un --config <fichier> ou d'un --debug plus tard, on les ajoutera quand on en aura vraiment besoin.
ArgsOn commence par déclarer la structure cible. Tous les champs sont optionnels — c'est le rôle de config.zig (plus tard) de remplir les trous.
pub const Args = struct {
help: bool = false,
version: bool = false,
model: ?[:0]const u8 = null,
api_key: ?[:0]const u8 = null,
prompt: ?[:0]const u8 = null,
};
Pourquoi [:0]const u8 plutôt que []const u8 ? Parce que init.minimal.args.toSlice(arena) rend des chaînes terminées par zéro. En gardant la sentinelle dans nos types, on évite les conversions à chaque assignation.
Trois approches possibles pour le parsing :
comptime sur le struct Args — élégant, mais opaque pour un lecteur.argv — verbeux, mais chaque ligne est lisible.On prend la 3. C'est plus long, c'est moins "clever", c'est exactement ce qu'on veut pour la première itération.
Le résultat du parsing est une union Result qui encode soit le succès soit une erreur précise :
pub const Result = union(enum) {
ok: Args,
unknown_flag: [:0]const u8,
missing_value: [:0]const u8,
too_many_positionals,
};
C'est un pattern utile : plutôt qu'un error.UnknownFlag qui perd l'information du flag fautif, on porte le contexte directement dans la variante. Le caller fait un switch et peut afficher un message précis.
La boucle :
pub fn parse(argv: []const [:0]const u8) Result {
var out: Args = .{};
var i: usize = 0;
while (i < argv.len) : (i += 1) {
assert(i < argv.len);
const arg = argv[i];
if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
out.help = true;
} else if (std.mem.eql(u8, arg, "--version")) {
out.version = true;
} else if (std.mem.eql(u8, arg, "--model")) {
const value = take_value(argv, &i) orelse return .{ .missing_value = arg };
out.model = value;
} else if (std.mem.eql(u8, arg, "--api-key")) {
const value = take_value(argv, &i) orelse return .{ .missing_value = arg };
out.api_key = value;
} else if (std.mem.startsWith(u8, arg, "--") or std.mem.startsWith(u8, arg, "-")) {
return .{ .unknown_flag = arg };
} else {
if (out.prompt != null) return .too_many_positionals;
out.prompt = arg;
}
}
assert(i == argv.len);
return .{ .ok = out };
}
Le helper take_value consomme l'argument suivant ou retourne null s'il manque :
fn take_value(argv: []const [:0]const u8, i_inout: *usize) ?[:0]const u8 {
assert(i_inout.* < argv.len);
if (i_inout.* + 1 >= argv.len) return null;
i_inout.* += 1;
return argv[i_inout.*];
}
Deux notes de style :
assert documentent les invariants — i < argv.len à l'entrée de l'itération, i == argv.len à la sortie. Le compilateur ne les fait pas, mais un futur lecteur les lit comme des contrats.std.mem.eql(u8, a, b) plutôt que a == b. En Zig, l'égalité de slices n'est pas définie par défaut — on compare élément par élément explicitement.Le parseur est pure : pas d'I/O, pas d'allocation. Idéal pour une suite de tests exhaustive.
test "--help and -h both set help" {
const long = parse(args_of(&.{"--help"}));
try testing.expect(long.ok.help);
const short = parse(args_of(&.{"-h"}));
try testing.expect(short.ok.help);
}
test "--model with no value reports missing_value" {
const r = parse(args_of(&.{"--model"}));
try testing.expectEqualStrings("--model", r.missing_value);
}
test "flags and prompt can interleave" {
const r = parse(args_of(&.{ "--model", "x/y", "fix it", "--api-key", "sk-abc" }));
try testing.expectEqualStrings("x/y", r.ok.model.?);
try testing.expectEqualStrings("fix it", r.ok.prompt.?);
try testing.expectEqualStrings("sk-abc", r.ok.api_key.?);
}
Le helper args_of est juste pour aider l'inférence de types dans la littérale de tableau. Huit tests couvrent toutes les variantes du Result.
./zig/zig test src/cli.zig
All 8 tests passed.
Le parser ne fait que lire argv. Pour avoir un modèle utilisable, il faut consulter trois sources, dans cet ordre de priorité : CLI > env > défaut.
pub const model_default: [:0]const u8 = "anthropic/claude-sonnet-4.6";
pub const env_api_key = "OPENROUTER_API_KEY";
pub const env_model = "HYPERCODE_MODEL";
pub const Config = struct {
model: [:0]const u8,
api_key: [:0]const u8,
prompt: [:0]const u8,
};
pub const Result = union(enum) {
ok: Config,
no_api_key,
no_prompt,
};
Le même pattern Result que pour cli.zig. Si la clé API ou le prompt manquent, on retourne une variante explicite plutôt qu'une erreur générique.
pub fn resolve(args: cli.Args, environ: std.process.Environ) Result {
const model = pick(args.model, environ.getPosix(env_model), model_default);
const api_key = args.api_key orelse environ.getPosix(env_api_key) orelse return .no_api_key;
const prompt = args.prompt orelse return .no_prompt;
assert(model.len > 0);
assert(api_key.len > 0);
assert(prompt.len > 0);
return .{ .ok = .{ .model = model, .api_key = api_key, .prompt = prompt } };
}
fn pick(
cli_value: ?[:0]const u8,
env_value: ?[:0]const u8,
fallback: [:0]const u8,
) [:0]const u8 {
if (cli_value) |v| return v;
if (env_value) |v| return v;
return fallback;
}
Trois choses méritent un mot.
environ.getPosix. En Zig 0.16, std.process.Init expose init.minimal.environ, qui contient les variables d'environnement sans les copier dans un Map. La méthode getPosix ne fait pas d'allocation — c'est juste un lookup linéaire dans le bloc environ que le noyau a passé au processus.
Le chaînage orelse. Pour api_key, on a une cascade : CLI ? sinon env ? sinon erreur. C'est exactement ce qu'orelse exprime, avec un return final qui sort de la fonction sans bruit.
Les assert après résolution. Une fois model, api_key et prompt choisis, on vérifie qu'ils ne sont pas vides. Une variable d'environnement peut être définie à "", et c'est le genre de truc qui crée des bugs silencieux trois mois plus tard. Fail-fast.
mainLe main reste fin. Sa seule responsabilité est l'orchestration : parser, vérifier les flags spéciaux, résoudre la config, faire le travail (qui viendra dans le prochain article).
pub fn main(init: std.process.Init) !void {
const io = init.io;
const arena = init.arena.allocator();
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout = &stdout_writer.interface;
var stderr_buffer: [1024]u8 = undefined;
var stderr_writer: Io.File.Writer = .init(.stderr(), io, &stderr_buffer);
const stderr = &stderr_writer.interface;
const argv_all = try init.minimal.args.toSlice(arena);
const argv = if (argv_all.len > 0) argv_all[1..] else argv_all;
const parsed = cli.parse(argv);
const args = switch (parsed) {
.ok => |a| a,
.unknown_flag => |f| return fail(stderr, "unknown flag '{s}'", .{f}),
.missing_value => |f| return fail(stderr, "'{s}' requires a value", .{f}),
.too_many_positionals => return fail(stderr, "too many positional arguments", .{}),
};
argv_all[1..] ignore argv[0] (le nom du binaire). Le switch exhaustif sur parsed rend chaque variante d'erreur visible à l'écriture — si on ajoute une variante à Result plus tard, le compilateur nous force à la traiter ici.
if (args.help) {
try print_help(stdout);
try stdout.flush();
return;
}
if (args.version) {
try stdout.print("hypercode {s}\n", .{version});
try stdout.flush();
return;
}
const cfg = switch (config.resolve(args, init.minimal.environ)) {
.ok => |c| c,
.no_api_key => return fail(
stderr,
"no API key. Set {s} or pass --api-key.",
.{config.env_api_key},
),
.no_prompt => return fail(
stderr,
"missing prompt. Usage: hypercode [options] <prompt>",
.{},
),
};
try stdout.print("model: {s}\n", .{cfg.model});
try stdout.print("prompt: {s}\n", .{cfg.prompt});
try stdout.writeAll("(no model call yet — that's Post 03.)\n");
try stdout.flush();
}
Le helper fail centralise la sortie d'erreur :
fn fail(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) !void {
try stderr.print("error: " ++ fmt ++ "\n", args);
try stderr.writeAll("Run `hypercode --help` for usage.\n");
try stderr.flush();
std.process.exit(2);
}
std.process.exit(2) est volontaire — exit code 2 est la convention POSIX pour "mauvais usage", distinct de 1 (erreur générique) et 0 (succès).
Petite subtilité Zig 0.16 : zig build test ne lance les test blocks que des fichiers atteignables depuis le module racine. Comme les test blocks de cli.zig et config.zig ne référencent pas main, ils ne sont pas découverts par défaut.
La solution idiomatique : un test block dans main.zig qui les importe explicitement :
// Pull sibling modules into the test runner so their `test` blocks execute.
test {
_ = @import("cli.zig");
_ = @import("config.zig");
}
Le _ = @import(...) force la résolution du module au moment de la compilation des tests. Sans ça, le compilateur saute les fichiers, et leurs tests aussi.
./zig/zig build test --summary all
Build Summary: 3/3 steps succeeded; 11/11 tests passed
L'aide :
./zig-out/bin/hypercode --help
hypercode — an LLM coding agent
Usage: hypercode [options] <prompt>
Options:
--model <name> Model to use (default: anthropic/claude-sonnet-4.6,
or HYPERCODE_MODEL)
--api-key <key> OpenRouter API key (or OPENROUTER_API_KEY)
--version Print the version and exit
-h, --help Print this help and exit
Le chemin nominal :
OPENROUTER_API_KEY=sk-test ./zig-out/bin/hypercode "fix the bug"
model: anthropic/claude-sonnet-4.6
prompt: fix the bug
(no model call yet — that's Post 03.)
Les chemins d'erreur :
./zig-out/bin/hypercode --bogus
error: unknown flag '--bogus'
Run `hypercode --help` for usage.
unset OPENROUTER_API_KEY
./zig-out/bin/hypercode "fix the bug"
error: no API key. Set OPENROUTER_API_KEY or pass --api-key.
Run `hypercode --help` for usage.
Et exit 2 partout pour les usages incorrects — vérifiable avec echo $?.
Trois commits, chacun avec un seul fichier nouveau ou modifié :
b806c90 feat(main): wire CLI + config; dry-run print resolved model and prompt
7ebc7de feat(config): resolve effective config from CLI > env > default
6e0ca7c feat(cli): hand-written argv parser
Le découpage commit ↔ couche permet à un nouveau lecteur de suivre le code étage par étage : git show 6e0ca7c montre uniquement le parseur, en isolation.
On a maintenant une CLI qui collecte tous les ingrédients d'un appel modèle : quel modèle, quelle clé, quel prompt. Le binaire les imprime au lieu de les utiliser — c'est volontaire. La couche suivante est l'I/O réseau, et elle mérite son propre article.
Dans le prochain article, on construit un client HTTPS minimal en Zig pur (TLS via std.crypto.tls, requête et réponse via std.Io). Pas de curl, pas de libcurl, pas de wrapper — juste la libstd et un socket.
Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.