From 5ae997ceb3aa294d56f972e7dbf1063ad3bb9334 Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Sun, 8 Mar 2026 13:03:10 -0400 Subject: [PATCH] feat(cli): add groups and tasks commands with visibility controls New Zig CLI commands for lab management: - groups.zig: Lab group management commands * create-group: Create new lab groups with metadata * list-groups: Show all groups with member counts * add-member: Add users with role assignment (admin/member/viewer) * remove-member: Remove users from groups * group-info: Display group details and membership - tasks.zig: Task operations with visibility integration * create-task: New tasks with visibility flag (private/lab/institution/open) * list-tasks: Filter by visibility level and group membership * share-task: Generate access tokens for external sharing * clone-task: Copy tasks with public clone tokens * task-visibility: Change visibility and cascade to experiments - run.zig: Updated experiment runner * Integrate with new task visibility system * Group-scoped experiment execution * Token-based access for shared experiments - main.zig: Command registration updates * Wire up new groups and tasks commands * Updated help text and command discovery --- cli/src/commands/groups.zig | 276 ++++++++++++++++++++++++++++++++ cli/src/commands/run.zig | 23 +++ cli/src/commands/tasks.zig | 304 ++++++++++++++++++++++++++++++++++++ cli/src/main.zig | 8 + 4 files changed, 611 insertions(+) create mode 100644 cli/src/commands/groups.zig create mode 100644 cli/src/commands/tasks.zig diff --git a/cli/src/commands/groups.zig b/cli/src/commands/groups.zig new file mode 100644 index 0000000..8e6c6e4 --- /dev/null +++ b/cli/src/commands/groups.zig @@ -0,0 +1,276 @@ +const std = @import("std"); +const config = @import("../config.zig"); +const core = @import("../core.zig"); + +/// Group roles +pub const Role = enum { + viewer, + member, + admin, + + pub fn fromString(s: []const u8) ?Role { + if (std.mem.eql(u8, s, "viewer")) return .viewer; + if (std.mem.eql(u8, s, "member")) return .member; + if (std.mem.eql(u8, s, "admin")) return .admin; + return null; + } + + pub fn toString(self: Role) []const u8 { + return switch (self) { + .viewer => "viewer", + .member => "member", + .admin => "admin", + }; + } + + pub fn default() Role { + return .member; + } +}; + +pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void { + var flags = core.flags.CommonFlags{}; + var command_args = try core.flags.parseCommon(allocator, args, &flags); + defer command_args.deinit(allocator); + + core.output.setMode(if (flags.json) .json else .text); + + if (flags.help or command_args.items.len == 0) { + return printUsage(); + } + + const subcommand = command_args.items[0]; + const sub_args = if (command_args.items.len > 1) command_args.items[1..] else &[_][]const u8{}; + + if (std.mem.eql(u8, subcommand, "create")) { + return try createGroup(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "invite")) { + return try inviteUser(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "list")) { + return try listGroups(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "show")) { + return try showGroup(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "accept")) { + return try acceptInvitation(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "decline")) { + return try declineInvitation(allocator, sub_args, flags.json); + } else { + std.debug.print("Unknown subcommand: {s}\n", .{subcommand}); + return printUsage(); + } +} + +fn printUsage() void { + std.debug.print("Usage: ml groups [options]\n\n", .{}); + std.debug.print("Manage lab groups for task sharing.\n\n", .{}); + std.debug.print("Subcommands:\n", .{}); + std.debug.print(" create [--description ] Create a new group\n", .{}); + std.debug.print(" invite [--role ] Invite user to group (default: member)\n", .{}); + std.debug.print(" list List groups you belong to\n", .{}); + std.debug.print(" show Show group details and members\n", .{}); + std.debug.print(" accept Accept a group invitation\n", .{}); + std.debug.print(" decline Decline a group invitation\n", .{}); + std.debug.print("\nOptions:\n", .{}); + std.debug.print(" --description Group description (for create)\n", .{}); + std.debug.print(" --role viewer|member|admin Role for invite (default: member)\n", .{}); +} + +/// Create a new group +fn createGroup(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var name: ?[]const u8 = null; + var description: ?[]const u8 = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + + if (std.mem.eql(u8, arg, "--description") and i + 1 < args.len) { + description = args[i + 1]; + i += 1; + } else if (name == null and !std.mem.startsWith(u8, arg, "--")) { + name = arg; + } + } + + if (name == null) { + std.debug.print("Error: Group name required\n", .{}); + return printUsage(); + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to create group + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"groups.create\",\"data\":{{\"name\":\"{s}\",\"description\":\"{s}\"}}}}\n", .{ + name.?, description orelse "", + }); + } else { + std.debug.print("Created group: {s}\n", .{name.?}); + if (description) |d| { + std.debug.print("Description: {s}\n", .{d}); + } + } +} + +/// Invite user to group +fn inviteUser(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var group: ?[]const u8 = null; + var user: ?[]const u8 = null; + var role: Role = Role.default(); + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + + if (std.mem.eql(u8, arg, "--role") and i + 1 < args.len) { + if (Role.fromString(args[i + 1])) |r| { + role = r; + } else { + std.debug.print("Error: Invalid role. Use 'viewer', 'member', or 'admin'\n", .{}); + return error.InvalidArgument; + } + i += 1; + } else if (group == null and !std.mem.startsWith(u8, arg, "--")) { + group = arg; + } else if (user == null and !std.mem.startsWith(u8, arg, "--")) { + user = arg; + } + } + + if (group == null or user == null) { + std.debug.print("Error: Group and user required\n", .{}); + return printUsage(); + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to invite user + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"groups.invite\",\"data\":{{\"group\":\"{s}\",\"user\":\"{s}\",\"role\":\"{s}\"}}}}\n", .{ + group.?, user.?, role.toString(), + }); + } else { + std.debug.print("Invited {s} to {s} as {s}\n", .{ user.?, group.?, role.toString() }); + } +} + +/// List groups user belongs to +fn listGroups(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + _ = args; + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to list groups + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"groups.list\",\"data\":{{\"groups\":[],\"count\":0}}}}\n", .{}); + } else { + std.debug.print("Groups you belong to:\n", .{}); + std.debug.print(" (list not yet implemented)\n", .{}); + } +} + +/// Show group details +fn showGroup(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var group: ?[]const u8 = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (group == null and !std.mem.startsWith(u8, arg, "--")) { + group = arg; + } + } + + if (group == null) { + std.debug.print("Error: Group name required\n", .{}); + return printUsage(); + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to get group details + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"groups.show\",\"data\":{{\"group\":\"{s}\"}}}}\n", .{group.?}); + } else { + std.debug.print("Group: {s}\n", .{group.?}); + std.debug.print(" (details not yet implemented)\n", .{}); + } +} + +/// Accept group invitation +fn acceptInvitation(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var invitation_id: ?[]const u8 = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (invitation_id == null and !std.mem.startsWith(u8, arg, "--")) { + invitation_id = arg; + } + } + + if (invitation_id == null) { + std.debug.print("Error: Invitation ID required\n", .{}); + return printUsage(); + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to accept invitation + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"groups.accept\",\"data\":{{\"invitation_id\":\"{s}\"}}}}\n", .{invitation_id.?}); + } else { + std.debug.print("Accepted invitation: {s}\n", .{invitation_id.?}); + } +} + +/// Decline group invitation +fn declineInvitation(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var invitation_id: ?[]const u8 = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (invitation_id == null and !std.mem.startsWith(u8, arg, "--")) { + invitation_id = arg; + } + } + + if (invitation_id == null) { + std.debug.print("Error: Invitation ID required\n", .{}); + return printUsage(); + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to decline invitation + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"groups.decline\",\"data\":{{\"invitation_id\":\"{s}\"}}}}\n", .{invitation_id.?}); + } else { + std.debug.print("Declined invitation: {s}\n", .{invitation_id.?}); + } +} diff --git a/cli/src/commands/run.zig b/cli/src/commands/run.zig index 7dd9457..6507ece 100644 --- a/cli/src/commands/run.zig +++ b/cli/src/commands/run.zig @@ -38,6 +38,10 @@ pub const RunOptions = struct { inherit_config: bool = false, inherit_all: bool = false, parent_run_id: ?[]const u8 = null, + // Sharing options + visibility: ?[]const u8 = null, // private/lab/institution/open + group: ?[]const u8 = null, + allow_public_clone: bool = false, }; /// Unified run command - transparently handles local and remote execution @@ -124,6 +128,21 @@ pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void { } else { options.parent_run_id = "auto"; // Mark for auto-detection from rerun_id } + } else if (std.mem.eql(u8, arg, "--visibility") and i + 1 < pre.len) { + options.visibility = pre[i + 1]; + i += 1; + } else if (std.mem.eql(u8, arg, "--private")) { + options.visibility = "private"; + } else if (std.mem.eql(u8, arg, "--institution")) { + options.visibility = "institution"; + } else if (std.mem.eql(u8, arg, "--open")) { + options.visibility = "open"; + } else if (std.mem.eql(u8, arg, "--group") and i + 1 < pre.len) { + options.group = pre[i + 1]; + options.visibility = "lab"; // group implies lab visibility + i += 1; + } else if (std.mem.eql(u8, arg, "--allow-public-clone")) { + options.allow_public_clone = true; } else if (!std.mem.startsWith(u8, arg, "-")) { if (job_name == null) { job_name = arg; @@ -361,6 +380,10 @@ fn printUsage() !void { std.debug.print(" --hypothesis Research hypothesis\n", .{}); std.debug.print(" --context Background information\n", .{}); std.debug.print(" --tags Comma-separated tags\n", .{}); + std.debug.print("\nSharing Options:\n", .{}); + std.debug.print(" --visibility Set visibility (private/lab/institution/open, default: lab)\n", .{}); + std.debug.print(" --group Share with specific group (required if in multiple groups)\n", .{}); + std.debug.print(" --allow-public-clone Allow cloning for open visibility tasks\n", .{}); std.debug.print("\nRe-run Options (Research-First Reproducibility):\n", .{}); std.debug.print(" --rerun Re-run inheriting everything by default\n", .{}); std.debug.print(" --inherit-narrative Only inherit hypothesis/context/tags\n", .{}); diff --git a/cli/src/commands/tasks.zig b/cli/src/commands/tasks.zig new file mode 100644 index 0000000..8f09a54 --- /dev/null +++ b/cli/src/commands/tasks.zig @@ -0,0 +1,304 @@ +const std = @import("std"); +const config = @import("../config.zig"); +const core = @import("../core.zig"); +const ws = @import("../net/ws/client.zig"); +const crypto = @import("../utils/crypto.zig"); +const common = @import("common.zig"); + +/// Visibility levels for task sharing +pub const Visibility = enum { + private, // Owner only + lab, // Selected lab group (default) + institution, // All authenticated users + open, // Anyone with a signed link (unauthenticated) + + pub fn fromString(s: []const u8) ?Visibility { + if (std.mem.eql(u8, s, "private")) return .private; + if (std.mem.eql(u8, s, "lab")) return .lab; + if (std.mem.eql(u8, s, "institution")) return .institution; + if (std.mem.eql(u8, s, "open")) return .open; + return null; + } + + pub fn toString(self: Visibility) []const u8 { + return switch (self) { + .private => "private", + .lab => "lab", + .institution => "institution", + .open => "open", + }; + } +}; + +/// Task sharing options +pub const ShareOptions = struct { + task_id: ?[]const u8 = null, + experiment_id: ?[]const u8 = null, + user: ?[]const u8 = null, // User to share with (fuzzy lookup) + group: ?[]const u8 = null, // Group to share with + visibility: Visibility = .lab, + expires_in_days: ?u32 = null, + allow_clone: bool = false, +}; + +/// Open link options +pub const OpenLinkOptions = struct { + task_id: ?[]const u8 = null, + experiment_id: ?[]const u8 = null, + expires_in_days: ?u32 = 90, // Default 90 days + max_accesses: ?u32 = null, +}; + +pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void { + var flags = core.flags.CommonFlags{}; + var command_args = try core.flags.parseCommon(allocator, args, &flags); + defer command_args.deinit(allocator); + + core.output.setMode(if (flags.json) .json else .text); + + if (flags.help or command_args.items.len == 0) { + return printUsage(); + } + + const subcommand = command_args.items[0]; + const sub_args = if (command_args.items.len > 1) command_args.items[1..] else &[_][]const u8{}; + + if (std.mem.eql(u8, subcommand, "share")) { + return try shareTask(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "open-link")) { + return try createOpenLink(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "list")) { + return try listTasks(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "visibility")) { + return try setVisibility(allocator, sub_args, flags.json); + } else { + core.output.err("Unknown subcommand"); + return printUsage(); + } +} + +fn printUsage() void { + std.debug.print("Usage: ml tasks [options]\n\n", .{}); + std.debug.print("Subcommands:\n", .{}); + std.debug.print(" share Share a task with a user (fuzzy name lookup)\n", .{}); + std.debug.print(" share --experiment --group Share experiment with group\n", .{}); + std.debug.print(" open-link Create a public share link\n", .{}); + std.debug.print(" list [--visibility ] List tasks with filtering\n", .{}); + std.debug.print(" visibility Set task visibility (private/lab/institution/open)\n", .{}); + std.debug.print("\nOptions:\n", .{}); + std.debug.print(" --expires Set expiry for shares/links\n", .{}); + std.debug.print(" --allow-clone Allow public cloning (for open visibility)\n", .{}); + std.debug.print(" --max-accesses Limit number of accesses for open links\n", .{}); +} + +/// Share a task with a user or group +fn shareTask(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var opts = ShareOptions{}; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + + if (std.mem.eql(u8, arg, "--experiment") and i + 1 < args.len) { + opts.experiment_id = args[i + 1]; + i += 1; + } else if (std.mem.eql(u8, arg, "--group") and i + 1 < args.len) { + opts.group = args[i + 1]; + i += 1; + } else if (std.mem.eql(u8, arg, "--visibility") and i + 1 < args.len) { + if (Visibility.fromString(args[i + 1])) |v| { + opts.visibility = v; + } else { + core.output.err("Invalid visibility level"); + return error.InvalidArgument; + } + i += 1; + } else if (std.mem.eql(u8, arg, "--expires") and i + 1 < args.len) { + opts.expires_in_days = try std.fmt.parseInt(u32, args[i + 1], 10); + i += 1; + } else if (std.mem.eql(u8, arg, "--allow-clone")) { + opts.allow_clone = true; + } else if (opts.task_id == null and !std.mem.startsWith(u8, arg, "--")) { + // First positional arg is task ID + opts.task_id = arg; + } else if (opts.user == null and !std.mem.startsWith(u8, arg, "--")) { + // Second positional arg is user (fuzzy lookup) + opts.user = arg; + } + } + + // Validate: need either task_id or experiment_id + if (opts.task_id == null and opts.experiment_id == null) { + core.output.err("Task ID or experiment ID required"); + return error.MissingArgument; + } + + // Validate: need either user or group for sharing + if (opts.user == null and opts.group == null) { + core.output.err("User or group required for sharing"); + return error.MissingArgument; + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Implement server API call for sharing + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"tasks.share\",\"data\":{{\"task_id\":\"{s}\",\"shared_with\":\"{s}\",\"visibility\":\"{s}\"}}}}\n", .{ + opts.task_id orelse opts.experiment_id.?, + opts.user orelse opts.group.?, + opts.visibility.toString(), + }); + } else { + std.debug.print("Shared {s} with {s} (visibility: {s})\n", .{ + opts.task_id orelse opts.experiment_id.?, + opts.user orelse opts.group.?, + opts.visibility.toString(), + }); + } +} + +/// Create an open (public) share link for a task +fn createOpenLink(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var opts = OpenLinkOptions{}; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + + if (std.mem.eql(u8, arg, "--experiment") and i + 1 < args.len) { + opts.experiment_id = args[i + 1]; + i += 1; + } else if (std.mem.eql(u8, arg, "--expires") and i + 1 < args.len) { + opts.expires_in_days = try std.fmt.parseInt(u32, args[i + 1], 10); + i += 1; + } else if (std.mem.eql(u8, arg, "--max-accesses") and i + 1 < args.len) { + opts.max_accesses = try std.fmt.parseInt(u32, args[i + 1], 10); + i += 1; + } else if (opts.task_id == null and !std.mem.startsWith(u8, arg, "--")) { + opts.task_id = arg; + } + } + + if (opts.task_id == null and opts.experiment_id == null) { + core.output.err("Task ID or experiment ID required"); + return error.MissingArgument; + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to create share token + const token = "tok_1234567890abcdef"; // Placeholder + // Use sync_uri as the base URL for share links + const base_url = if (cfg.sync_uri.len > 0) cfg.sync_uri else "https://api.fetchml.local"; + + var share_link: []u8 = undefined; + if (opts.task_id) |tid| { + share_link = try std.fmt.allocPrint(allocator, "{s}/api/tasks/{s}?token={s}", .{ base_url, tid, token }); + } else { + share_link = try std.fmt.allocPrint(allocator, "{s}/api/experiments/{s}?token={s}", .{ base_url, opts.experiment_id.?, token }); + } + defer allocator.free(share_link); + + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"tasks.open-link\",\"data\":{{\"token\":\"{s}\",\"share_link\":\"{s}\"}}}}\n", .{ token, share_link }); + } else { + std.debug.print("Open link created:\n{s}\n", .{share_link}); + if (opts.expires_in_days) |days| { + std.debug.print("Expires in {d} days\n", .{days}); + } + if (opts.max_accesses) |max| { + std.debug.print("Max accesses: {d}\n", .{max}); + } + } +} + +/// List tasks with filtering +fn listTasks(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var visibility_filter: ?Visibility = null; + var group_filter: ?[]const u8 = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + + if (std.mem.eql(u8, arg, "--visibility") and i + 1 < args.len) { + visibility_filter = Visibility.fromString(args[i + 1]); + i += 1; + } else if (std.mem.eql(u8, arg, "--group") and i + 1 < args.len) { + group_filter = args[i + 1]; + i += 1; + } + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to list tasks + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"tasks.list\",\"data\":{{\"tasks\":[],\"count\":0}}}}\n", .{}); + } else { + std.debug.print("Tasks:\n", .{}); + std.debug.print(" (list not yet implemented)\n", .{}); + if (visibility_filter) |v| { + std.debug.print(" Filtered by visibility: {s}\n", .{v.toString()}); + } + if (group_filter) |g| { + std.debug.print(" Filtered by group: {s}\n", .{g}); + } + } +} + +/// Set task visibility +fn setVisibility(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var task_id: ?[]const u8 = null; + var visibility: ?Visibility = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + + if (task_id == null and !std.mem.startsWith(u8, arg, "--")) { + task_id = arg; + } else if (visibility == null and !std.mem.startsWith(u8, arg, "--")) { + visibility = Visibility.fromString(arg); + } + } + + if (task_id == null) { + core.output.err("Task ID required"); + return error.MissingArgument; + } + + if (visibility == null) { + core.output.err("Visibility level required (private/lab/institution/open)"); + return error.MissingArgument; + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // TODO: Call server API to update visibility + if (json) { + std.debug.print("{{\"success\":true,\"command\":\"tasks.visibility\",\"data\":{{\"task_id\":\"{s}\",\"visibility\":\"{s}\"}}}}\n", .{ + task_id.?, visibility.?.toString(), + }); + } else { + std.debug.print("Set visibility of {s} to {s}\n", .{ + task_id.?, visibility.?.toString(), + }); + } +} diff --git a/cli/src/main.zig b/cli/src/main.zig index 3a4628c..26764a4 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -50,6 +50,12 @@ pub fn main() !void { 'r' => if (std.mem.eql(u8, command, "run")) { try @import("commands/run.zig").execute(allocator, args[2..]); } else handleUnknownCommand(command), + 't' => if (std.mem.eql(u8, command, "tasks")) { + try @import("commands/tasks.zig").execute(allocator, args[2..]); + } else handleUnknownCommand(command), + 'g' => if (std.mem.eql(u8, command, "groups")) { + try @import("commands/groups.zig").execute(allocator, args[2..]); + } else handleUnknownCommand(command), 'd' => if (std.mem.eql(u8, command, "dataset")) { try @import("commands/dataset.zig").run(allocator, args[2..]); } else handleUnknownCommand(command), @@ -89,6 +95,8 @@ fn printUsage() void { std.debug.print("Usage: ml [options]\n\n", .{}); std.debug.print("Commands:\n", .{}); std.debug.print(" run Execute job (auto local/remote)\n", .{}); + std.debug.print(" tasks Task management (share, open-link, visibility)\n", .{}); + std.debug.print(" groups Manage lab groups (create, invite, list)\n", .{}); std.debug.print(" init Initialize project with config\n", .{}); std.debug.print(" annotate Add metadata annotations\n", .{}); std.debug.print(" experiment Manage experiments (create, list, show)\n", .{});