diff --git a/cli/src/core/flags.zig b/cli/src/core/flags.zig index b0ddaf1..e7d8f71 100644 --- a/cli/src/core/flags.zig +++ b/cli/src/core/flags.zig @@ -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; diff --git a/cli/src/core/output.zig b/cli/src/core/output.zig index 3c76cee..35a8084 100644 --- a/cli/src/core/output.zig +++ b/cli/src/core/output.zig @@ -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(); } diff --git a/cli/src/main.zig b/cli/src/main.zig index 45d7d92..993a45f 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -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 [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 Queue job on server (--rerun to re-queue local run)\n", .{}); - std.debug.print(" annotate Add metadata annotations (hypothesis/outcome/confidence)\n", .{}); - std.debug.print(" experiment Manage experiments (create, list, show)\n", .{}); - std.debug.print(" logs 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 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 Queue job on server\n", .{}); + std.debug.print(" annotate Add metadata annotations\n", .{}); + std.debug.print(" experiment Manage experiments (create, list, show)\n", .{}); + std.debug.print(" logs Fetch or stream run logs\n", .{}); + std.debug.print(" sync [id] Push local runs to server\n", .{}); + std.debug.print(" cancel 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", .{}); diff --git a/cli/src/utils.zig b/cli/src/utils.zig index 384e7e0..274dd2b 100644 --- a/cli/src/utils.zig +++ b/cli/src/utils.zig @@ -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"); diff --git a/cli/src/utils/colors.zig b/cli/src/utils/colors.zig index fe88e28..612af3c 100644 --- a/cli/src/utils/colors.zig +++ b/cli/src/utils/colors.zig @@ -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", .{}); - } -}; diff --git a/cli/src/utils/terminal.zig b/cli/src/utils/terminal.zig new file mode 100644 index 0000000..0e77a17 --- /dev/null +++ b/cli/src/utils/terminal.zig @@ -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; +}