refactor(cli): Simplify output system and add terminal utilities

Remove colors dependency from output.zig

Add terminal.zig for TTY detection and terminal width

Update flags.zig with color flag support

Simplify colors.zig to basic ANSI codes

Update main.zig and utils.zig exports
This commit is contained in:
Jeremie Fraeys 2026-02-23 14:11:59 -05:00
parent a1988de8b1
commit 2b7319dc2e
No known key found for this signature in database
6 changed files with 198 additions and 278 deletions

View file

@ -6,6 +6,7 @@ pub const CommonFlags = struct {
help: bool = false,
verbose: bool = false,
dry_run: bool = false,
color: ?bool = null, // null = auto, true = force on, false = disable
};
/// Parse common flags from command arguments
@ -27,6 +28,17 @@ pub fn parseCommon(allocator: std.mem.Allocator, args: []const []const u8, flags
flags.verbose = true;
} else if (std.mem.eql(u8, arg, "--dry-run")) {
flags.dry_run = true;
} else if (std.mem.eql(u8, arg, "--no-color") or std.mem.eql(u8, arg, "--no-colour")) {
flags.color = false;
} else if (std.mem.startsWith(u8, arg, "--color=")) {
const val = arg[8..];
if (std.mem.eql(u8, val, "always") or std.mem.eql(u8, val, "yes")) {
flags.color = true;
} else if (std.mem.eql(u8, val, "never") or std.mem.eql(u8, val, "no")) {
flags.color = false;
} else if (std.mem.eql(u8, val, "auto")) {
flags.color = null;
}
} else if (std.mem.eql(u8, arg, "--")) {
// End of flags, rest are positional
i += 1;

View file

@ -1,129 +1,137 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const terminal = @import("../utils/terminal.zig");
/// Output mode for commands
pub const OutputMode = enum {
text,
json,
};
/// Output mode: JSON for structured data, text for TSV
pub const Mode = enum { json, text };
/// Global output mode - set by main based on --json flag
pub var global_mode: OutputMode = .text;
pub var mode: Mode = .text;
/// Initialize output mode from command flags
pub fn init(mode: OutputMode) void {
global_mode = mode;
pub fn setMode(m: Mode) void {
mode = m;
}
/// Print error in appropriate format
pub fn errorMsg(comptime command: []const u8, message: []const u8) void {
switch (global_mode) {
.json => std.debug.print(
"{{\"success\":false,\"command\":\"{s}\",\"error\":\"{s}\"}}\n",
.{ command, message },
),
.text => colors.printError("{s}\n", .{message}),
/// Escape a value for TSV output (replace tabs/newlines with spaces for xargs safety)
fn escapeTSV(val: []const u8) []const u8 {
// For xargs usability, we need single-line output with no tabs/newlines in values
// This returns the same slice if no escaping needed, but we process to ensure safety
// In practice, we just use the value directly since the caller should sanitize
return val;
}
/// Check if value needs TSV escaping
fn needsTSVEscape(val: []const u8) bool {
for (val) |c| {
if (c == '\t' or c == '\n' or c == '\r') return true;
}
return false;
}
/// Print error with additional details in appropriate format
pub fn errorMsgDetailed(comptime command: []const u8, message: []const u8, details: []const u8) void {
switch (global_mode) {
.json => std.debug.print(
"{{\"success\":false,\"command\":\"{s}\",\"error\":\"{s}\",\"details\":\"{s}\"}}\n",
.{ command, message, details },
),
.text => {
colors.printError("{s}\n", .{message});
std.debug.print("Details: {s}\n", .{details});
},
}
/// Print error to stderr
pub fn err(msg: []const u8) void {
std.debug.print("Error: {s}\n", .{msg});
}
/// Print success response in appropriate format (no data)
pub fn success(comptime command: []const u8) void {
switch (global_mode) {
.json => std.debug.print("{{\"success\":true,\"command\":\"{s}\"}}\n", .{command}),
.text => {}, // No output for text mode on simple success
}
}
/// Print success with string data
pub fn successString(comptime command: []const u8, comptime data_key: []const u8, value: []const u8) void {
switch (global_mode) {
.json => std.debug.print(
"{{\"success\":true,\"command\":\"{s}\",\"data\":{{\"{s}\":\"{s}\"}}}}\n",
.{ command, data_key, value },
),
.text => std.debug.print("{s}\n", .{value}),
}
}
/// Print success with formatted string data
pub fn successFmt(comptime command: []const u8, comptime fmt_str: []const u8, args: anytype) void {
switch (global_mode) {
/// Print line in current mode (JSON or TSV)
pub fn line(values: []const []const u8) void {
switch (mode) {
.json => {
// Use stack buffer to avoid allocation
var buf: [4096]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, fmt_str, args) catch {
std.debug.print("{{\"success\":true,\"command\":\"{s}\",\"data\":null}}\n", .{command});
return;
};
std.debug.print("{{\"success\":true,\"command\":\"{s}\",\"data\":{s}}}\n", .{ command, msg });
std.debug.print("{{", .{});
// Assume alternating key-value pairs
var i: usize = 0;
while (i < values.len) : (i += 2) {
if (i > 0) std.debug.print(",", .{});
const key = values[i];
const val = if (i + 1 < values.len) values[i + 1] else "";
std.debug.print("\"{s}\":\"{s}\"", .{ key, val });
}
std.debug.print("}}\n", .{});
},
.text => {
for (values, 0..) |val, i| {
if (i > 0) std.debug.print("\t", .{});
// For TSV/xargs safety: if value contains tabs/newlines, we need to handle it
// Simple approach: print as-is but replace internal tabs with spaces
if (needsTSVEscape(val)) {
for (val) |c| {
if (c == '\t' or c == '\n' or c == '\r') {
std.debug.print(" ", .{});
} else {
std.debug.print("{c}", .{c});
}
}
} else {
std.debug.print("{s}", .{val});
}
}
std.debug.print("\n", .{});
},
.text => std.debug.print(fmt_str ++ "\n", args),
}
}
/// Print informational message (text mode only)
pub fn info(comptime fmt_str: []const u8, args: anytype) void {
if (global_mode == .text) {
std.debug.print(fmt_str ++ "\n", args);
/// Print raw JSON array
pub fn jsonArray(items: []const []const u8) void {
std.debug.print("[", .{});
for (items, 0..) |item, i| {
if (i > 0) std.debug.print(",", .{});
std.debug.print("\"{s}\"", .{item});
}
std.debug.print("]\n", .{});
}
/// Print raw JSON object from key-value pairs
pub fn jsonObject(pairs: []const []const u8) void {
std.debug.print("{{", .{});
var i: usize = 0;
while (i < pairs.len) : (i += 2) {
if (i > 0) std.debug.print(",", .{});
const key = pairs[i];
const val = if (i + 1 < pairs.len) pairs[i + 1] else "";
std.debug.print("\"{s}\":\"{s}\"", .{ key, val });
}
std.debug.print("}}\n", .{});
}
/// Print success response (JSON only)
pub fn success(comptime cmd: []const u8) void {
if (mode == .json) {
std.debug.print("{{\"success\":true,\"command\":\"{s}\"}}\n", .{cmd});
}
}
/// Print success with data
pub fn successData(comptime cmd: []const u8, pairs: []const []const u8) void {
if (mode == .json) {
std.debug.print("{{\"success\":true,\"command\":\"{s}\",\"data\":{{", .{cmd});
var i: usize = 0;
while (i < pairs.len) : (i += 2) {
if (i > 0) std.debug.print(",", .{});
const key = pairs[i];
const val = if (i + 1 < pairs.len) pairs[i + 1] else "";
std.debug.print("\"{s}\":\"{s}\"", .{ key, val });
}
std.debug.print("}}}}\n", .{});
} else {
for (pairs, 0..) |val, i| {
if (i > 0) std.debug.print("\t", .{});
std.debug.print("{s}", .{val});
}
std.debug.print("\n", .{});
}
}
/// Print usage information
pub fn usage(comptime cmd: []const u8, comptime usage_str: []const u8) void {
switch (global_mode) {
.json => std.debug.print(
"{{\"success\":false,\"command\":\"{s}\",\"error\":\"Invalid arguments\",\"usage\":\"{s}\"}}\n",
.{ cmd, usage_str },
),
.text => {
std.debug.print("Usage: {s}\n", .{usage_str});
},
pub fn usage(comptime cmd: []const u8, comptime u: []const u8) void {
std.debug.print("Usage: {s} {s}\n", .{ cmd, u });
}
/// Print plain value (text mode only)
pub fn value(v: []const u8) void {
if (mode == .text) {
std.debug.print("{s}\n", .{v});
}
}
/// Print unknown command error
pub fn unknownCommand(comptime command: []const u8, unknown: []const u8) void {
switch (global_mode) {
.json => std.debug.print(
"{{\"success\":false,\"command\":\"{s}\",\"error\":\"Unknown command: {s}\"}}\n",
.{ command, unknown },
),
.text => colors.printError("Unknown command: {s}\n", .{unknown}),
}
}
/// Print table header (text mode only)
pub fn tableHeader(comptime cols: []const []const u8) void {
if (global_mode == .json) return;
for (cols, 0..) |col, i| {
if (i > 0) std.debug.print("\t", .{});
std.debug.print("{s}", .{col});
}
std.debug.print("\n", .{});
}
/// Print table row (text mode only)
pub fn tableRow(values: []const []const u8) void {
if (global_mode == .json) return;
for (values, 0..) |val, i| {
if (i > 0) std.debug.print("\t", .{});
std.debug.print("{s}", .{val});
}
std.debug.print("\n", .{});
/// Get terminal width for formatting
pub fn getTerminalWidth() ?usize {
return terminal.getWidth();
}

View file

@ -1,17 +1,13 @@
const std = @import("std");
const colors = @import("utils/colors.zig");
// Handle unknown command - prints error and exits
fn handleUnknownCommand(cmd: []const u8) noreturn {
colors.printError("Unknown command: {s}\n", .{cmd});
std.debug.print("Error: Unknown command: {s}\n", .{cmd});
printUsage();
std.process.exit(1);
}
pub fn main() !void {
// Initialize colors based on environment
colors.initColors();
// Use c_allocator for better performance on Linux
const allocator = std.heap.c_allocator;
@ -83,7 +79,7 @@ pub fn main() !void {
try @import("commands/watch.zig").run(allocator, args[2..]);
} else handleUnknownCommand(command),
else => {
colors.printError("Unknown command: {s}\n", .{args[1]});
std.debug.print("Error: Unknown command: {s}\n", .{args[1]});
printUsage();
return error.InvalidCommand;
},
@ -92,17 +88,17 @@ pub fn main() !void {
// Optimized usage printer
fn printUsage() void {
colors.printInfo("ML Experiment Manager\n\n", .{});
std.debug.print("ML Experiment Manager\n\n", .{});
std.debug.print("Usage: ml <command> [options]\n\n", .{});
std.debug.print("Commands:\n", .{});
std.debug.print(" init Initialize project with config (use --local for SQLite)\n", .{});
std.debug.print(" run [args] Execute a run locally (forks, captures, parses metrics)\n", .{});
std.debug.print(" queue <job> Queue job on server (--rerun <id> to re-queue local run)\n", .{});
std.debug.print(" annotate <id> Add metadata annotations (hypothesis/outcome/confidence)\n", .{});
std.debug.print(" experiment Manage experiments (create, list, show)\n", .{});
std.debug.print(" logs <id> Fetch or stream run logs (--follow for live tail)\n", .{});
std.debug.print(" sync [id] Push local runs to server (sync_run + sync_ack protocol)\n", .{});
std.debug.print(" cancel <id> Cancel local run (SIGTERM/SIGKILL by PID)\n", .{});
std.debug.print(" init Initialize project with config\n", .{});
std.debug.print(" run [args] Execute a run locally\n", .{});
std.debug.print(" queue <job> Queue job on server\n", .{});
std.debug.print(" annotate <id> Add metadata annotations\n", .{});
std.debug.print(" experiment Manage experiments (create, list, show)\n", .{});
std.debug.print(" logs <id> Fetch or stream run logs\n", .{});
std.debug.print(" sync [id] Push local runs to server\n", .{});
std.debug.print(" cancel <id> Cancel local run\n", .{});
std.debug.print(" watch [--sync] Watch directory with optional auto-sync\n", .{});
std.debug.print(" status Get system status\n", .{});
std.debug.print(" dataset Manage datasets\n", .{});

View file

@ -12,3 +12,4 @@ pub const rsync = @import("utils/rsync.zig");
pub const rsync_embedded = @import("utils/rsync_embedded.zig");
pub const rsync_embedded_binary = @import("utils/rsync_embedded_binary.zig");
pub const storage = @import("utils/storage.zig");
pub const terminal = @import("utils/terminal.zig");

View file

@ -1,166 +1,34 @@
// Minimal color output utility optimized for size
// Minimal color codes for CLI - no formatting, just basic ANSI
const std = @import("std");
const terminal = @import("terminal.zig");
// Color codes - only essential ones
const colors = struct {
pub const reset = "\x1b[0m";
pub const red = "\x1b[31m";
pub const green = "\x1b[32m";
pub const yellow = "\x1b[33m";
pub const blue = "\x1b[34m";
pub const bold = "\x1b[1m";
};
pub const reset = "\x1b[0m";
pub const red = "\x1b[31m";
pub const green = "\x1b[32m";
pub const yellow = "\x1b[33m";
pub const blue = "\x1b[34m";
pub const bold = "\x1b[1m";
// Check if colors should be disabled
var colors_disabled: bool = false;
/// Check if colors should be used based on: flag > NO_COLOR > CLICOLOR_FORCE > TTY
pub fn shouldUseColor(force_flag: ?bool) bool {
// Flag takes precedence
if (force_flag) |forced| return forced;
pub fn disableColors() void {
colors_disabled = true;
}
pub fn enableColors() void {
colors_disabled = false;
}
// Fast color-aware printing functions
pub fn printError(comptime fmt: anytype, args: anytype) void {
if (!colors_disabled) {
std.debug.print(colors.red ++ colors.bold ++ "Error: " ++ colors.reset, .{});
} else {
std.debug.print("Error: ", .{});
}
std.debug.print(fmt, args);
}
pub fn printSuccess(comptime fmt: anytype, args: anytype) void {
if (!colors_disabled) {
std.debug.print(colors.green ++ colors.bold ++ "" ++ colors.reset, .{});
} else {
std.debug.print("", .{});
}
std.debug.print(fmt, args);
}
pub fn printInfo(comptime fmt: anytype, args: anytype) void {
if (!colors_disabled) {
std.debug.print(colors.blue ++ " " ++ colors.reset, .{});
} else {
std.debug.print(" ", .{});
}
std.debug.print(fmt, args);
}
pub fn printWarning(comptime fmt: anytype, args: anytype) void {
if (!colors_disabled) {
std.debug.print(colors.yellow ++ colors.bold ++ "" ++ colors.reset, .{});
} else {
std.debug.print("", .{});
}
std.debug.print(fmt, args);
}
// Auto-detect if colors should be disabled
pub fn initColors() void {
// Disable colors if NO_COLOR environment variable is set
// Check NO_COLOR (any value disables colors)
if (std.process.getEnvVarOwned(std.heap.page_allocator, "NO_COLOR")) |_| {
disableColors();
} else |_| {
// Default to enabling colors for simplicity
colors_disabled = false;
}
return false;
} else |_| {}
// Check CLICOLOR_FORCE (any value enables colors)
if (std.process.getEnvVarOwned(std.heap.page_allocator, "CLICOLOR_FORCE")) |_| {
return true;
} else |_| {}
// Default: color if TTY
return terminal.isTTY();
}
// Fast string formatting for common cases
pub fn formatDuration(seconds: u64) [16]u8 {
var result: [16]u8 = undefined;
var offset: usize = 0;
if (seconds >= 3600) {
const hours = seconds / 3600;
offset += std.fmt.formatIntBuf(result[offset..], hours, 10, .lower, .{});
result[offset] = 'h';
offset += 1;
const minutes = (seconds % 3600) / 60;
if (minutes > 0) {
offset += std.fmt.formatIntBuf(result[offset..], minutes, 10, .lower, .{});
result[offset] = 'm';
offset += 1;
}
} else if (seconds >= 60) {
const minutes = seconds / 60;
offset += std.fmt.formatIntBuf(result[offset..], minutes, 10, .lower, .{});
result[offset] = 'm';
offset += 1;
const secs = seconds % 60;
if (secs > 0) {
offset += std.fmt.formatIntBuf(result[offset..], secs, 10, .lower, .{});
result[offset] = 's';
offset += 1;
}
} else {
offset += std.fmt.formatIntBuf(result[offset..], seconds, 10, .lower, .{});
result[offset] = 's';
offset += 1;
}
result[offset] = 0;
return result;
// Legacy function - uses auto-detection
pub fn shouldUseColorAuto() bool {
return shouldUseColor(null);
}
// Progress bar for long operations
pub const ProgressBar = struct {
width: usize,
current: usize,
total: usize,
pub fn init(total: usize) ProgressBar {
return ProgressBar{
.width = 50,
.current = 0,
.total = total,
};
}
pub fn update(self: *ProgressBar, current: usize) void {
self.current = current;
self.render();
}
pub fn render(self: ProgressBar) void {
const percentage = if (self.total > 0)
@as(f64, @floatFromInt(self.current)) * 100.0 / @as(f64, @floatFromInt(self.total))
else
0.0;
const filled = @as(usize, @intFromFloat(percentage * @as(f64, @floatFromInt(self.width)) / 100.0));
const empty = self.width - filled;
if (!colors_disabled) {
std.debug.print("\r[" ++ colors.green, .{});
} else {
std.debug.print("\r[", .{});
}
var i: usize = 0;
while (i < filled) : (i += 1) {
std.debug.print("=", .{});
}
if (!colors_disabled) {
std.debug.print(colors.reset, .{});
}
i = 0;
while (i < empty) : (i += 1) {
std.debug.print(" ", .{});
}
std.debug.print("] {d:.1}%\r", .{percentage});
}
pub fn finish(self: ProgressBar) void {
self.current = self.total;
self.render();
std.debug.print("\n", .{});
}
};

View file

@ -0,0 +1,35 @@
const std = @import("std");
/// Check if stdout is a TTY
pub fn isTTY() bool {
return std.posix.isatty(std.posix.STDOUT_FILENO);
}
/// Get terminal width from COLUMNS env var
pub fn getWidth() ?usize {
const allocator = std.heap.page_allocator;
if (std.process.getEnvVarOwned(allocator, "COLUMNS")) |cols| {
defer allocator.free(cols);
return std.fmt.parseInt(usize, cols, 10) catch null;
} else |_| {}
return null;
}
/// Table formatting mode
pub const TableMode = enum { truncate, wrap, auto };
/// Get table formatting mode from env var
pub fn getTableMode() TableMode {
const allocator = std.heap.page_allocator;
const mode_str = std.process.getEnvVarOwned(allocator, "ML_TABLE_MODE") catch return .truncate;
defer allocator.free(mode_str);
if (std.mem.eql(u8, mode_str, "wrap")) return .wrap;
if (std.mem.eql(u8, mode_str, "auto")) return .auto;
return .truncate;
}
/// Get user's preferred pager from PAGER env var
pub fn getPager() ?[]const u8 {
const allocator = std.heap.page_allocator;
return std.process.getEnvVarOwned(allocator, "PAGER") catch null;
}