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
This commit is contained in:
Jeremie Fraeys 2026-03-08 13:03:10 -04:00
parent 1c7205c0a0
commit 5ae997ceb3
No known key found for this signature in database
4 changed files with 611 additions and 0 deletions

276
cli/src/commands/groups.zig Normal file
View file

@ -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 <subcommand> [options]\n\n", .{});
std.debug.print("Manage lab groups for task sharing.\n\n", .{});
std.debug.print("Subcommands:\n", .{});
std.debug.print(" create <name> [--description <desc>] Create a new group\n", .{});
std.debug.print(" invite <group> <user> [--role <role>] Invite user to group (default: member)\n", .{});
std.debug.print(" list List groups you belong to\n", .{});
std.debug.print(" show <group> Show group details and members\n", .{});
std.debug.print(" accept <invitation-id> Accept a group invitation\n", .{});
std.debug.print(" decline <invitation-id> Decline a group invitation\n", .{});
std.debug.print("\nOptions:\n", .{});
std.debug.print(" --description <text> 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.?});
}
}

View file

@ -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 <text> Research hypothesis\n", .{});
std.debug.print(" --context <text> Background information\n", .{});
std.debug.print(" --tags <csv> Comma-separated tags\n", .{});
std.debug.print("\nSharing Options:\n", .{});
std.debug.print(" --visibility <level> Set visibility (private/lab/institution/open, default: lab)\n", .{});
std.debug.print(" --group <group-id> 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 <run_id> Re-run inheriting everything by default\n", .{});
std.debug.print(" --inherit-narrative Only inherit hypothesis/context/tags\n", .{});

304
cli/src/commands/tasks.zig Normal file
View file

@ -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 <subcommand> [options]\n\n", .{});
std.debug.print("Subcommands:\n", .{});
std.debug.print(" share <task-id> <user> Share a task with a user (fuzzy name lookup)\n", .{});
std.debug.print(" share --experiment <id> --group <group> Share experiment with group\n", .{});
std.debug.print(" open-link <task-id> Create a public share link\n", .{});
std.debug.print(" list [--visibility <level>] List tasks with filtering\n", .{});
std.debug.print(" visibility <task-id> <level> Set task visibility (private/lab/institution/open)\n", .{});
std.debug.print("\nOptions:\n", .{});
std.debug.print(" --expires <days> Set expiry for shares/links\n", .{});
std.debug.print(" --allow-clone Allow public cloning (for open visibility)\n", .{});
std.debug.print(" --max-accesses <n> 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(),
});
}
}

View file

@ -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 <command> [options]\n\n", .{});
std.debug.print("Commands:\n", .{});
std.debug.print(" run <job> 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 <id> Add metadata annotations\n", .{});
std.debug.print(" experiment Manage experiments (create, list, show)\n", .{});