From 86b4c221e5730b3eaeeb63d421c363bf06b992d9 Mon Sep 17 00:00:00 2001 From: tlkahn Date: Sun, 5 Jul 2026 23:14:06 +0700 Subject: [PATCH 1/4] feat: project scaffold with module graph and CLI stub (#2) - build.zig.zon: package manifest (Zig >= 0.16.0) - build.zig: 7-module graph with compile-enforced dependency direction core (leaf), mlxbridge (leaf), chat->core, model->core+mlxbridge, engine->core+mlxbridge+model (NOT chat), registry->core, http->core+chat+model+engine+registry - src/cli/main.zig: stub subcommand dispatch (serve/run/pull/list) - Per-module test stubs under zig build test - Empty subdirs with .gitkeep for future expansion Co-Authored-By: Claude Opus 4.6 --- build.zig | 130 +++++++++++++++++++++++++++++++++ build.zig.zon | 11 +++ src/chat/root.zig | 5 ++ src/cli/cmd/.gitkeep | 0 src/cli/main.zig | 59 +++++++++++++++ src/core/root.zig | 1 + src/engine/constrain/.gitkeep | 0 src/engine/root.zig | 7 ++ src/engine/specdecode/.gitkeep | 0 src/http/handlers/.gitkeep | 0 src/http/root.zig | 9 +++ src/mlx/root.zig | 1 + src/model/root.zig | 6 ++ src/registry/root.zig | 5 ++ tests/fixtures/.gitkeep | 0 15 files changed, 234 insertions(+) create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/chat/root.zig create mode 100644 src/cli/cmd/.gitkeep create mode 100644 src/cli/main.zig create mode 100644 src/core/root.zig create mode 100644 src/engine/constrain/.gitkeep create mode 100644 src/engine/root.zig create mode 100644 src/engine/specdecode/.gitkeep create mode 100644 src/http/handlers/.gitkeep create mode 100644 src/http/root.zig create mode 100644 src/mlx/root.zig create mode 100644 src/model/root.zig create mode 100644 src/registry/root.zig create mode 100644 tests/fixtures/.gitkeep diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..c770f35 --- /dev/null +++ b/build.zig @@ -0,0 +1,130 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // -- Module graph (compile-enforced) -- + // Zig's module import table makes undeclared @import a compile error, + // so the dependency direction declared here is the only one that compiles. + + // Leaf modules (no dependencies) + const core_mod = b.createModule(.{ + .root_source_file = b.path("src/core/root.zig"), + .target = target, + .optimize = optimize, + }); + + const mlxbridge_mod = b.createModule(.{ + .root_source_file = b.path("src/mlx/root.zig"), + .target = target, + .optimize = optimize, + }); + + // chat -> core + const chat_mod = b.createModule(.{ + .root_source_file = b.path("src/chat/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "core", .module = core_mod }, + }, + }); + + // model -> core, mlxbridge + const model_mod = b.createModule(.{ + .root_source_file = b.path("src/model/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "core", .module = core_mod }, + .{ .name = "mlxbridge", .module = mlxbridge_mod }, + }, + }); + + // engine -> core, mlxbridge, model (NOT chat) + const engine_mod = b.createModule(.{ + .root_source_file = b.path("src/engine/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "core", .module = core_mod }, + .{ .name = "mlxbridge", .module = mlxbridge_mod }, + .{ .name = "model", .module = model_mod }, + }, + }); + + // registry -> core (no mlx) + const registry_mod = b.createModule(.{ + .root_source_file = b.path("src/registry/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "core", .module = core_mod }, + }, + }); + + // http -> core, chat, model, engine, registry + const http_mod = b.createModule(.{ + .root_source_file = b.path("src/http/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "core", .module = core_mod }, + .{ .name = "chat", .module = chat_mod }, + .{ .name = "model", .module = model_mod }, + .{ .name = "engine", .module = engine_mod }, + .{ .name = "registry", .module = registry_mod }, + }, + }); + + // -- Executable -- + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/cli/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "core", .module = core_mod }, + .{ .name = "mlxbridge", .module = mlxbridge_mod }, + .{ .name = "chat", .module = chat_mod }, + .{ .name = "model", .module = model_mod }, + .{ .name = "engine", .module = engine_mod }, + .{ .name = "registry", .module = registry_mod }, + .{ .name = "http", .module = http_mod }, + }, + }); + + const exe = b.addExecutable(.{ + .name = "mlxd", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // -- Run step -- + 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", "Run mlxd"); + run_step.dependOn(&run_cmd.step); + + // -- Test step (per-module) -- + const test_step = b.step("test", "Run all module tests"); + + const test_modules = [_]*std.Build.Module{ + core_mod, + mlxbridge_mod, + chat_mod, + model_mod, + engine_mod, + registry_mod, + http_mod, + }; + + for (test_modules) |mod| { + const t = b.addTest(.{ .root_module = mod }); + const run_t = b.addRunArtifact(t); + test_step.dependOn(&run_t.step); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..16d89db --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,11 @@ +.{ + .name = .mlxd, + .version = "0.1.0", + .minimum_zig_version = "0.16.0", + .fingerprint = 0x4fc94390cee4169, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/src/chat/root.zig b/src/chat/root.zig new file mode 100644 index 0000000..7084984 --- /dev/null +++ b/src/chat/root.zig @@ -0,0 +1,5 @@ +comptime { + _ = @import("core"); +} + +test "chat compiles" {} diff --git a/src/cli/cmd/.gitkeep b/src/cli/cmd/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/cli/main.zig b/src/cli/main.zig new file mode 100644 index 0000000..850f028 --- /dev/null +++ b/src/cli/main.zig @@ -0,0 +1,59 @@ +const std = @import("std"); + +comptime { + _ = @import("core"); + _ = @import("mlxbridge"); + _ = @import("chat"); + _ = @import("model"); + _ = @import("engine"); + _ = @import("registry"); + _ = @import("http"); +} + +const usage = + \\Usage: mlxd + \\ + \\Commands: + \\ serve Start OpenAI-compatible HTTP server + \\ run One-shot or interactive text generation + \\ pull Download model from HuggingFace + \\ list List locally available models + \\ +; + +fn writeAll(fd: std.posix.fd_t, bytes: []const u8) void { + var written: usize = 0; + while (written < bytes.len) { + const rc = std.posix.system.write(fd, bytes[written..].ptr, bytes[written..].len); + const signed: isize = @bitCast(rc); + if (signed < 0) return; + written += @intCast(signed); + } +} + +pub fn main(init: std.process.Init.Minimal) u8 { + var args = std.process.Args.Iterator.init(init.args); + _ = args.next(); + + const cmd = args.next() orelse { + writeAll(std.posix.STDERR_FILENO, usage); + return 1; + }; + + const commands = .{ + .{ "serve", "mlxd serve: not yet implemented\n" }, + .{ "run", "mlxd run: not yet implemented\n" }, + .{ "pull", "mlxd pull: not yet implemented\n" }, + .{ "list", "mlxd list: not yet implemented\n" }, + }; + + inline for (commands) |entry| { + if (std.mem.eql(u8, cmd, entry[0])) { + writeAll(std.posix.STDOUT_FILENO, entry[1]); + return 0; + } + } + + writeAll(std.posix.STDERR_FILENO, usage); + return 1; +} diff --git a/src/core/root.zig b/src/core/root.zig new file mode 100644 index 0000000..685a648 --- /dev/null +++ b/src/core/root.zig @@ -0,0 +1 @@ +test "core compiles" {} diff --git a/src/engine/constrain/.gitkeep b/src/engine/constrain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/engine/root.zig b/src/engine/root.zig new file mode 100644 index 0000000..d71b7db --- /dev/null +++ b/src/engine/root.zig @@ -0,0 +1,7 @@ +comptime { + _ = @import("core"); + _ = @import("mlxbridge"); + _ = @import("model"); +} + +test "engine compiles" {} diff --git a/src/engine/specdecode/.gitkeep b/src/engine/specdecode/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/http/handlers/.gitkeep b/src/http/handlers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/http/root.zig b/src/http/root.zig new file mode 100644 index 0000000..a8988c9 --- /dev/null +++ b/src/http/root.zig @@ -0,0 +1,9 @@ +comptime { + _ = @import("core"); + _ = @import("chat"); + _ = @import("model"); + _ = @import("engine"); + _ = @import("registry"); +} + +test "http compiles" {} diff --git a/src/mlx/root.zig b/src/mlx/root.zig new file mode 100644 index 0000000..8f11058 --- /dev/null +++ b/src/mlx/root.zig @@ -0,0 +1 @@ +test "mlxbridge compiles" {} diff --git a/src/model/root.zig b/src/model/root.zig new file mode 100644 index 0000000..dc5b615 --- /dev/null +++ b/src/model/root.zig @@ -0,0 +1,6 @@ +comptime { + _ = @import("core"); + _ = @import("mlxbridge"); +} + +test "model compiles" {} diff --git a/src/registry/root.zig b/src/registry/root.zig new file mode 100644 index 0000000..4dbf2a9 --- /dev/null +++ b/src/registry/root.zig @@ -0,0 +1,5 @@ +comptime { + _ = @import("core"); +} + +test "registry compiles" {} diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 From 3d353fe40d47863ebca2edf367efbc25be762f6f Mon Sep 17 00:00:00 2001 From: tlkahn Date: Sun, 5 Jul 2026 23:41:32 +0700 Subject: [PATCH 2/4] fix(cli): honest exit codes and write-error handling in main.zig Unimplemented subcommands now write their stub message to stderr and exit 1 instead of claiming success. writeAll surfaces write failures via error.WriteFailed with errno-based handling (EINTR retried), and every caller already returns 1, so a failed write can never mask a non-zero exit. The duplicated command-name/message pairs collapse into a single name list with a runtime loop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016qUaL3Er38EYQCRoExKwzA --- src/cli/main.zig | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/cli/main.zig b/src/cli/main.zig index 850f028..ea40eb2 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -21,13 +21,15 @@ const usage = \\ ; -fn writeAll(fd: std.posix.fd_t, bytes: []const u8) void { +fn writeAll(fd: std.posix.fd_t, bytes: []const u8) error{WriteFailed}!void { var written: usize = 0; while (written < bytes.len) { const rc = std.posix.system.write(fd, bytes[written..].ptr, bytes[written..].len); - const signed: isize = @bitCast(rc); - if (signed < 0) return; - written += @intCast(signed); + switch (std.posix.errno(rc)) { + .SUCCESS => written += @intCast(@as(isize, @bitCast(rc))), + .INTR => continue, + else => return error.WriteFailed, + } } } @@ -36,24 +38,21 @@ pub fn main(init: std.process.Init.Minimal) u8 { _ = args.next(); const cmd = args.next() orelse { - writeAll(std.posix.STDERR_FILENO, usage); + writeAll(std.posix.STDERR_FILENO, usage) catch {}; return 1; }; - const commands = .{ - .{ "serve", "mlxd serve: not yet implemented\n" }, - .{ "run", "mlxd run: not yet implemented\n" }, - .{ "pull", "mlxd pull: not yet implemented\n" }, - .{ "list", "mlxd list: not yet implemented\n" }, - }; + const commands = [_][]const u8{ "serve", "run", "pull", "list" }; - inline for (commands) |entry| { - if (std.mem.eql(u8, cmd, entry[0])) { - writeAll(std.posix.STDOUT_FILENO, entry[1]); - return 0; + for (commands) |name| { + if (std.mem.eql(u8, cmd, name)) { + writeAll(std.posix.STDERR_FILENO, "mlxd ") catch {}; + writeAll(std.posix.STDERR_FILENO, name) catch {}; + writeAll(std.posix.STDERR_FILENO, ": not yet implemented\n") catch {}; + return 1; } } - writeAll(std.posix.STDERR_FILENO, usage); + writeAll(std.posix.STDERR_FILENO, usage) catch {}; return 1; } From 1f207eced00014a5ed550736e77c7c19bfad39a0 Mon Sep 17 00:00:00 2001 From: tlkahn Date: Sun, 5 Jul 2026 23:42:09 +0700 Subject: [PATCH 3/4] build: run cli tests, add -Dgpu-tests option and itest step The cli root module joins the test_modules loop so its inline tests run under 'zig build test'. A shared build_options module carries the new -Dgpu-tests flag so Metal-dependent tests can gate themselves via 'if (!build_options.gpu_tests) return error.SkipZigTest'. The 'itest' step documented in CLAUDE.md now exists, backed by a stub tests/itest.zig that skips without -Dmodel and fails honestly with IntegrationTestsNotYetImplemented when one is given. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016qUaL3Er38EYQCRoExKwzA --- build.zig | 27 +++++++++++++++++++++++++++ tests/itest.zig | 8 ++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/itest.zig diff --git a/build.zig b/build.zig index c770f35..2cf884f 100644 --- a/build.zig +++ b/build.zig @@ -4,6 +4,9 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const gpu_tests = b.option(bool, "gpu-tests", "Include Metal-dependent tests") orelse false; + const model_path = b.option([]const u8, "model", "Model path for integration tests"); + // -- Module graph (compile-enforced) -- // Zig's module import table makes undeclared @import a compile error, // so the dependency direction declared here is the only one that compiles. @@ -112,6 +115,10 @@ pub fn build(b: *std.Build) void { // -- Test step (per-module) -- const test_step = b.step("test", "Run all module tests"); + const build_opts = b.addOptions(); + build_opts.addOption(bool, "gpu_tests", gpu_tests); + const build_opts_mod = build_opts.createModule(); + const test_modules = [_]*std.Build.Module{ core_mod, mlxbridge_mod, @@ -120,11 +127,31 @@ pub fn build(b: *std.Build) void { engine_mod, registry_mod, http_mod, + exe_mod, }; for (test_modules) |mod| { + mod.addImport("build_options", build_opts_mod); const t = b.addTest(.{ .root_module = mod }); const run_t = b.addRunArtifact(t); test_step.dependOn(&run_t.step); } + + // -- Integration test step -- + const itest_opts = b.addOptions(); + itest_opts.addOption(?[]const u8, "model_path", model_path); + + const itest_mod = b.createModule(.{ + .root_source_file = b.path("tests/itest.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "build_options", .module = itest_opts.createModule() }, + }, + }); + + const itest = b.addTest(.{ .root_module = itest_mod }); + const run_itest = b.addRunArtifact(itest); + const itest_step = b.step("itest", "Run integration tests against a real model"); + itest_step.dependOn(&run_itest.step); } diff --git a/tests/itest.zig b/tests/itest.zig new file mode 100644 index 0000000..63e1d22 --- /dev/null +++ b/tests/itest.zig @@ -0,0 +1,8 @@ +const std = @import("std"); +const build_options = @import("build_options"); + +test "integration: serve smoke test" { + if (build_options.model_path == null) return error.SkipZigTest; + // Real itest (spawn `mlxd serve`, drive via std.http.Client) not yet implemented. + return error.IntegrationTestsNotYetImplemented; +} From 26251cb399961cb0f9b3dad7a7bbae13c5135f58 Mon Sep 17 00:00:00 2001 From: tlkahn Date: Sun, 5 Jul 2026 23:42:27 +0700 Subject: [PATCH 4/4] refactor: rename src/mlx to src/mlxbridge to match module name All seven modules now map their @import name to src//root.zig. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016qUaL3Er38EYQCRoExKwzA --- build.zig | 2 +- src/{mlx => mlxbridge}/root.zig | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{mlx => mlxbridge}/root.zig (100%) diff --git a/build.zig b/build.zig index 2cf884f..33f8383 100644 --- a/build.zig +++ b/build.zig @@ -19,7 +19,7 @@ pub fn build(b: *std.Build) void { }); const mlxbridge_mod = b.createModule(.{ - .root_source_file = b.path("src/mlx/root.zig"), + .root_source_file = b.path("src/mlxbridge/root.zig"), .target = target, .optimize = optimize, }); diff --git a/src/mlx/root.zig b/src/mlxbridge/root.zig similarity index 100% rename from src/mlx/root.zig rename to src/mlxbridge/root.zig