main zmx / src / main.zig
Matthias Bertschy  ·  2026-09-28
   1const std = @import("std");
   2const zmx = @import("lib.zig");
   3const Environ = std.process.Environ;
   4const build_options = @import("build_options");
   5const ipc = @import("ipc.zig");
   6const log = @import("log.zig");
   7const completions = @import("completions.zig");
   8const util = @import("util.zig");
   9const socket = @import("socket.zig");
  10const label = @import("label.zig");
  11const lib_posix = @import("posix.zig");
  12const signal = @import("signal.zig");
  13const Cfg = @import("cfg.zig");
  14const version = build_options.version;
  15const ghostty_version = build_options.ghostty_version;
  16
  17pub const std_options: std.Options = .{
  18    .logFn = log.zmxLogFn,
  19    .log_level = .debug,
  20};
  21
  22fn detectHelp(arg: []const u8) bool {
  23    return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
  24}
  25
  26/// Prints an error message to stderr and exits with status 1.
  27fn printError(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn {
  28    var buf: [4096]u8 = undefined;
  29    var w = std.Io.File.stderr().writer(io, &buf);
  30    w.interface.print("error: " ++ fmt ++ "\n", args) catch {};
  31    w.interface.flush() catch {};
  32    std.process.exit(1);
  33}
  34pub fn main(init: std.process.Init) !void {
  35    const gpa = init.gpa;
  36    const io = init.io;
  37
  38    // Every subcommand may write to a Unix-domain socket; a peer that
  39    // disappears between probe and send would otherwise kill us before
  40    // write() can return BrokenPipe. Inherited across fork, so this also
  41    // covers the daemon.
  42    signal.ignoreSigpipe();
  43
  44    var args = init.minimal.args.iterate();
  45    defer args.deinit();
  46    _ = args.next(); // skip program name
  47
  48    var cfg = try Cfg.init(gpa, io);
  49    defer cfg.deinit(gpa);
  50
  51    const log_path = try std.fs.path.join(gpa, &.{ cfg.log_dir, "zmx.log" });
  52    defer gpa.free(log_path);
  53    const log_mode = std.Io.File.Permissions.fromMode(@intCast(cfg.log_mode));
  54    try log.log_system.init(io, log_path, log_mode);
  55    defer log.log_system.deinit();
  56
  57    const shell_env = init.environ_map.get("SHELL") orelse "/bin/sh";
  58
  59    const cmd = args.next() orelse {
  60        return list(gpa, io, &cfg, false);
  61    };
  62
  63    if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) {
  64        return printVersion(io, &cfg);
  65    } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
  66        return help(io);
  67    } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l") or std.mem.eql(u8, cmd, "ls")) {
  68        var short = false;
  69        while (args.next()) |arg| {
  70            if (detectHelp(arg)) return help(io);
  71            if (std.mem.eql(u8, arg, "--short")) short = true;
  72        }
  73        return list(gpa, io, &cfg, short);
  74    } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
  75        const sesh_name = args.next() orelse {
  76            return printError(io, "session name required (or run inside a zmx session)", .{});
  77        };
  78        if (detectHelp(sesh_name)) return help(io);
  79        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
  80        defer gpa.free(sesh);
  81        const single_kv = args.next() orelse "";
  82        return labelGet(gpa, io, &cfg, sesh, single_kv);
  83    } else if (std.mem.eql(u8, cmd, "set")) {
  84        const sesh_name = args.next() orelse {
  85            return printError(io, "session name required (or run inside a zmx session)", .{});
  86        };
  87        if (detectHelp(sesh_name)) return help(io);
  88        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
  89        defer gpa.free(sesh);
  90
  91        var kvs = std.ArrayList(u8).empty;
  92        defer kvs.deinit(gpa);
  93        var first = true;
  94        while (args.next()) |arg| {
  95            if (!first) try kvs.append(gpa, ' ');
  96            try kvs.appendSlice(gpa, arg);
  97            first = false;
  98        }
  99        if (kvs.items.len == 0) {
 100            return printError(io, "at least one key=value pair required", .{});
 101        }
 102        return labelSet(gpa, io, &cfg, sesh, kvs.items);
 103    } else if (std.mem.eql(u8, cmd, "clear") or std.mem.eql(u8, cmd, "cl")) {
 104        const sesh_name = args.next() orelse {
 105            return printError(io, "session name required (or run inside a zmx session)", .{});
 106        };
 107        if (detectHelp(sesh_name)) return help(io);
 108        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
 109        defer gpa.free(sesh);
 110        return labelClear(gpa, io, &cfg, sesh);
 111    } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
 112        const arg = args.next() orelse {
 113            return printError(io, "completions requires a shell argument (bash, zsh, fish, nu, yash)", .{});
 114        };
 115        if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
 116            return help(io);
 117        }
 118        const shell = completions.Shell.fromString(arg) orelse {
 119            return printError(io, "unknown shell \"{s}\" (valid: bash, zsh, fish, nu, yash)", .{arg});
 120        };
 121        return printCompletions(io, shell);
 122    } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
 123        return detachAll(gpa, io, &cfg);
 124    } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
 125        var session_name: ?[]const u8 = null;
 126        var format: util.HistoryFormat = .plain;
 127        var scrollback_rows: ?u32 = null;
 128        while (args.next()) |arg| {
 129            if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
 130                return help(io);
 131            } else if (std.mem.eql(u8, arg, "--vt")) {
 132                format = .vt;
 133            } else if (std.mem.eql(u8, arg, "--html")) {
 134                format = .html;
 135            } else if (std.mem.eql(u8, arg, "--screen")) {
 136                if (scrollback_rows != null) return printError(io, "--screen and --scrollback may only be specified once", .{});
 137                scrollback_rows = 0;
 138            } else if (std.mem.eql(u8, arg, "--scrollback")) {
 139                if (scrollback_rows != null) return printError(io, "--screen and --scrollback may only be specified once", .{});
 140                const count = args.next() orelse return printError(io, "--scrollback requires a row count", .{});
 141                scrollback_rows = std.fmt.parseInt(u32, count, 10) catch return printError(io, "invalid scrollback row count: {s}", .{count});
 142            } else if (session_name == null) {
 143                session_name = arg;
 144            }
 145        }
 146        const sesh_env = socket.getSeshNameFromEnv();
 147        const raw_name = session_name orelse (if (sesh_env.len > 0) sesh_env else {
 148            return printError(io, "session name required (or run inside a zmx session)", .{});
 149        });
 150        const sesh = try socket.getSeshName(gpa, raw_name);
 151        defer gpa.free(sesh);
 152        return history(gpa, io, &cfg, sesh, format, scrollback_rows);
 153    } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 154        var attach_args: std.ArrayList([]const u8) = .empty;
 155        defer attach_args.deinit(gpa);
 156        while (args.next()) |arg| {
 157            try attach_args.append(gpa, arg);
 158        }
 159
 160        const parsed = parseAttachArgs(attach_args.items);
 161        if (parsed.want_help) {
 162            return help(io);
 163        }
 164        if (parsed.missing_labels_value) {
 165            return printError(io, "--labels requires \"key=value ...\"", .{});
 166        }
 167        // Before ensureSession, so a rejected label does not leave a session
 168        // behind that the caller never asked for.
 169        if (parsed.labels) |kvs| assertLabels(io, kvs);
 170
 171        var command: ?[][]const u8 = null;
 172        if (parsed.command_start < attach_args.items.len) {
 173            command = attach_args.items[parsed.command_start..];
 174        }
 175
 176        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 177        const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 178        const cwd = cwd_buf[0..cwd_len];
 179
 180        const env_keys = lib_posix.getenv("ZMX_TRACK_ENV") orelse cfg.tracked_envs;
 181        const env_str = try getTrackedEnvStr(gpa, env_keys, init.environ_map);
 182        defer gpa.free(env_str);
 183
 184        zmx.attach(gpa, io, parsed.session_name, .{
 185            .command = command,
 186            .cwd = cwd,
 187            .shell = shell_env,
 188            .labels = parsed.labels,
 189            .tracked_env = env_str,
 190        }) catch |err| {
 191            return printError(io, "cannot connect to session \"{s}\": {s}", .{ parsed.session_name, @errorName(err) });
 192        };
 193        return;
 194    } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 195        const session_name = args.next() orelse "";
 196        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 197            return help(io);
 198        }
 199
 200        var cmd_args_raw: std.ArrayList([]const u8) = .empty;
 201        defer cmd_args_raw.deinit(gpa);
 202        var detached = false;
 203        while (args.next()) |arg| {
 204            // Once the command has started, -d belongs to the command.
 205            if (cmd_args_raw.items.len == 0 and std.mem.startsWith(u8, arg, "-d")) {
 206                detached = true;
 207            } else {
 208                try cmd_args_raw.append(gpa, arg);
 209            }
 210        }
 211
 212        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 213        const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 214        const cwd = cwd_buf[0..cwd_len];
 215
 216        var stdin_bytes: ?[]const u8 = null;
 217        var stdin_buf = std.ArrayList(u8).empty;
 218        defer stdin_buf.deinit(gpa);
 219
 220        if (cmd_args_raw.items.len == 0) {
 221            const stdin_file = std.Io.File.stdin();
 222            defer stdin_file.close(io);
 223            var stdbuf: [4096]u8 = undefined;
 224            var reader = stdin_file.reader(io, &stdbuf);
 225            if (!try stdin_file.isTty(io)) {
 226                while (true) {
 227                    var dest: [1024]u8 = undefined;
 228                    const n = try reader.interface.readSliceShort(&dest);
 229                    if (n == 0) break;
 230                    try stdin_buf.appendSlice(gpa, dest[0..n]);
 231                }
 232                if (stdin_buf.items.len > 0) {
 233                    stdin_bytes = stdin_buf.items;
 234                }
 235            }
 236        }
 237
 238        if (cmd_args_raw.items.len == 0 and stdin_bytes == null) {
 239            return printError(io, "run requires a command (or pipe input via stdin)", .{});
 240        }
 241
 242        const exit_code = zmx.run(gpa, io, session_name, cmd_args_raw.items, .{
 243            .detached = detached,
 244            .cwd = cwd,
 245            .shell = shell_env,
 246            .stdin_bytes = stdin_bytes,
 247        }) catch |err| switch (err) {
 248            error.CommandRequired => return printError(io, "run requires a command (or pipe input via stdin)", .{}),
 249            else => return printError(io, "session not ready: {s}", .{@errorName(err)}),
 250        };
 251        lib_posix.exit(exit_code);
 252    } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
 253        const session_name = args.next() orelse "";
 254        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 255            return help(io);
 256        }
 257        if (session_name.len == 0) {
 258            return printError(io, "session name required", .{});
 259        }
 260
 261        var text_parts: std.ArrayList([]const u8) = .empty;
 262        defer text_parts.deinit(gpa);
 263        while (args.next()) |arg| {
 264            try text_parts.append(gpa, arg);
 265        }
 266
 267        send(gpa, io, session_name, text_parts.items, .Send) catch |err| {
 268            if (err == error.SessionUnresponsive) std.process.exit(1);
 269            return printError(io, "send failed: {s}", .{@errorName(err)});
 270        };
 271    } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) {
 272        const session_name = args.next() orelse "";
 273        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 274            return help(io);
 275        }
 276        if (session_name.len == 0) {
 277            return printError(io, "session name required", .{});
 278        }
 279
 280        var text_parts: std.ArrayList([]const u8) = .empty;
 281        defer text_parts.deinit(gpa);
 282        while (args.next()) |arg| {
 283            try text_parts.append(gpa, arg);
 284        }
 285
 286        send(gpa, io, session_name, text_parts.items, .Output) catch |err| {
 287            if (err == error.SessionUnresponsive) std.process.exit(1);
 288            return printError(io, "print failed: {s}", .{@errorName(err)});
 289        };
 290    } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 291        var stderr_buffer: [1024]u8 = undefined;
 292        var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
 293        const stderr = &stderr_writer.interface;
 294
 295        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 296        defer {
 297            for (matchers.items) |m| {
 298                gpa.free(m.name);
 299            }
 300            matchers.deinit(gpa);
 301        }
 302        var force = false;
 303        while (args.next()) |session_name| {
 304            if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 305                return help(io);
 306            }
 307            if (std.mem.eql(u8, session_name, "--force")) {
 308                force = true;
 309                continue;
 310            }
 311            const m = try socket.parseSessionArg(gpa, session_name);
 312            try matchers.append(gpa, m);
 313        }
 314        if (matchers.items.len == 0) {
 315            return printError(io, "session name required", .{});
 316        }
 317        const sessions = try zmx.list(gpa, io);
 318        defer {
 319            for (sessions) |session| {
 320                session.deinit(gpa);
 321            }
 322            gpa.free(sessions);
 323        }
 324
 325        var killed_any = false;
 326        for (sessions) |session| {
 327            for (matchers.items) |m| {
 328                if (!m.matches(session.name)) continue;
 329                kill(gpa, io, &cfg, session.name, force) catch |err| {
 330                    if (err == error.SessionNotFound and force) {
 331                        killed_any = true;
 332                        continue;
 333                    }
 334                    try stderr.print("failed to kill session={s}: {s}\n", .{ session.name, @errorName(err) });
 335                    try stderr.flush();
 336                };
 337                killed_any = true;
 338                break;
 339            }
 340        }
 341        if (!killed_any) {
 342            for (matchers.items) |m| {
 343                if (m.is_prefix) continue;
 344                kill(gpa, io, &cfg, m.name, force) catch |err| {
 345                    if (err == error.SessionNotFound and force) {
 346                        killed_any = true;
 347                        continue;
 348                    }
 349                    return printError(io, "failed to kill session={s}: {s}", .{ m.name, @errorName(err) });
 350                };
 351                killed_any = true;
 352            }
 353            if (!killed_any) return printError(io, "no matching sessions found", .{});
 354        }
 355    } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 356        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 357        defer {
 358            for (matchers.items) |m| {
 359                gpa.free(m.name);
 360            }
 361            matchers.deinit(gpa);
 362        }
 363        while (args.next()) |session_name| {
 364            if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 365                return help(io);
 366            }
 367            const m = try socket.parseSessionArg(gpa, session_name);
 368            try matchers.append(gpa, m);
 369        }
 370        if (matchers.items.len == 0) {
 371            return printError(io, "session name required", .{});
 372        }
 373        return wait(gpa, io, &cfg, matchers);
 374    } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
 375        var names: std.ArrayList([]const u8) = .empty;
 376        defer names.deinit(gpa);
 377        while (args.next()) |session_name| {
 378            if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 379                return help(io);
 380            }
 381            try names.append(gpa, session_name);
 382        }
 383        if (names.items.len == 0) {
 384            return printError(io, "session name required", .{});
 385        }
 386        _ = zmx.tail(gpa, io, names.items, null) catch |err| switch (err) {
 387            error.SessionNotFound => return printError(io, "no matching sessions found", .{}),
 388            else => return err,
 389        };
 390    } else if (std.mem.eql(u8, cmd, "write") or std.mem.eql(u8, cmd, "wr")) {
 391        const session_name = args.next() orelse "";
 392        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 393            return help(io);
 394        }
 395        if (session_name.len == 0) {
 396            return printError(io, "session name required", .{});
 397        }
 398        const file_path = args.next() orelse "";
 399        if (std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h")) {
 400            return help(io);
 401        }
 402        if (file_path.len == 0) {
 403            return printError(io, "file path required", .{});
 404        }
 405
 406        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 407        const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 408        const cwd = cwd_buf[0..cwd_len];
 409
 410        writeFile(gpa, io, session_name, file_path, cwd, shell_env) catch |err| {
 411            if (err == error.SessionUnresponsive) std.process.exit(1);
 412            return printError(io, "write failed: {s}", .{@errorName(err)});
 413        };
 414    } else if (std.mem.eql(u8, cmd, "print-env")) {
 415        var shell_mode = false;
 416        var session_name: ?[]const u8 = null;
 417        var single_kv: []const u8 = "";
 418
 419        while (args.next()) |arg| {
 420            if (detectHelp(arg)) return help(io);
 421            if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--shell")) {
 422                shell_mode = true;
 423            } else if (session_name == null) {
 424                session_name = arg;
 425            } else if (single_kv.len == 0) {
 426                single_kv = arg;
 427            }
 428        }
 429
 430        const sesh_arg = session_name orelse {
 431            return printError(io, "session name required (or run inside a zmx session)", .{});
 432        };
 433        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_arg);
 434        defer gpa.free(sesh);
 435        return envGet(gpa, io, &cfg, sesh, single_kv, shell_mode);
 436    } else {
 437        return printError(io, "unknown command \"{s}\"", .{cmd});
 438    }
 439}
 440
 441fn help(io: std.Io) !void {
 442    const help_text =
 443        \\zmx - session persistence for terminal processes
 444        \\
 445        \\Usage: zmx <command> [args...]
 446        \\
 447        \\Commands:
 448        \\  [a]ttach [--labels kv] <name> [command...]                 Attach to session, creating if needed
 449        \\  [r]un <name> [-d] [command...]                             Send command without attaching
 450        \\  [s]end <name> <text...>                                    Send raw input to session PTY
 451        \\  [p]rint <name> <text...>                                   Inject text into session display
 452        \\  [wr]ite <name> <file_path>                                 Write stdin to file_path through the session
 453        \\  [d]etach                                                   Detach all clients (ctrl+\\ for current client)
 454        \\  [l]ist|ls [--short]                                        List active sessions
 455        \\  [g]et <name>                                               Get session labels
 456        \\  set <name> k=v ...                                         Set session labels (k= to remove)
 457        \\  [cl]ear <name>                                             Clear all session labels
 458        \\  print-env [-s] <name> [key]                                Print tracked environment variables
 459        \\  [k]ill <name>... [--force]                                 Kill session and all attached clients
 460        \\  [hi]story <name> [--vt|--html] [--screen|--scrollback]     Output session scrollback
 461        \\  [w]ait <name>...                                           Wait for session tasks to complete
 462        \\  [t]ail <name>...                                           Follow session output
 463        \\  [c]ompletions <shell>                                      Shell completions (bash, zsh, fish, nu, yash)
 464        \\  [v]ersion                                                  Show version and metadata (socket dir, log dir)
 465        \\  [h]elp                                                     Show this help
 466        \\
 467        \\Attach:
 468        \\  This will spawn a login $SHELL with a PTY.  You can provide a
 469        \\  command instead of creating a shell.
 470        \\
 471        \\  --labels applies labels as the session is created, in the same form
 472        \\  `zmx set` takes. A caller that creates and then labels in two steps
 473        \\  leaves an unlabelled session behind if it dies between them.
 474        \\
 475        \\  Examples:
 476        \\    zmx attach dev
 477        \\    zmx attach dev vim
 478        \\    zmx attach --labels "project=api role=worker" build
 479        \\
 480        \\History:
 481        \\  This should generally be used with `tail` to print the last lines
 482        \\  of the session's scrollback history.
 483        \\
 484        \\  --screen outputs only the active screen, and --scrollback N adds up
 485        \\  to N rows of scrollback above it, so pollers can skip formatting
 486        \\  history they would discard. Both work with --vt and --html.
 487        \\
 488        \\  Examples:
 489        \\    zmx history <session> | tail -100
 490        \\    zmx history --screen <session>
 491        \\    zmx history --scrollback 512 <session>
 492        \\
 493        \\Run:
 494        \\  Commands run inside a PTY using bash
 495        \\  Commands are passed as-is: do not wrap in quotes.
 496        \\  Commands run sequentially: do not send multiple in parallel.
 497        \\  Stdin is redirected from /dev/null to prevent interactive programs
 498        \\  (pagers, editors, prompts) from blocking. Use `zmx send` for
 499        \\  commands that need user input, or pipe data directly:
 500        \\    echo "data" | zmx run dev cat
 501        \\
 502        \\  `-d` will detach from the calling terminal. Use `wait` to track
 503        \\  its status.
 504        \\
 505        \\  Examples:
 506        \\    zmx run dev ls
 507        \\    zmx run dev zig build
 508        \\    zmx run dev grep -r TODO src
 509        \\    zmx run dev git log --oneline          # pager won't block
 510        \\    echo "hello" | zmx run dev cat         # piped stdin still works
 511        \\
 512        \\    # heredoc
 513        \\    printf "cat << 'EOF'\r\nHello $USER\r\nToday is $(date).\r\nEOF" | zmx run dev
 514        \\
 515        \\    # non-blocking
 516        \\    zmx run dev -d sleep 10
 517        \\    zmx wait dev
 518        \\
 519        \\Send:
 520        \\  Sends raw text to the session's PTY input (fire-and-forget).
 521        \\  Unlike `run`, no completion marker is appended and no exit code
 522        \\  is tracked.  Useful for TUI applications, interactive prompts,
 523        \\  or any program that reads stdin directly.
 524        \\
 525        \\  Text is sent byte-for-byte with no automatic carriage return.
 526        \\  Append \r yourself when you want the shell to execute a command.
 527        \\
 528        \\  Text can also be piped via stdin:
 529        \\    printf 'ls -la\r' | zmx send dev
 530        \\
 531        \\  Examples:
 532        \\    printf 'echo hello\r' | zmx send dev
 533        \\    zmx send dev $(printf '\x03')
 534        \\    zmx send dev /compact
 535        \\
 536        \\Print:
 537        \\  Injects text directly into the session display and scrollback.
 538        \\  Never touches the PTY input -- the shell sees nothing.
 539        \\  Caller is responsible for newlines (\\r\\n).
 540        \\
 541        \\  Examples:
 542        \\    printf '\\r\\nhello\\r\\n' | zmx print dev
 543        \\    zmx print dev "$(printf '\\r\\nalert\\r\\n')"
 544        \\
 545        \\Write:
 546        \\  Writes stdin to file_path inside the session. Works over SSH.
 547        \\  file_path can be absolute or relative to the session shell's cwd.
 548        \\  Requires base64 and printf in the remote environment.
 549        \\  Large files are chunked automatically (~48KB per chunk).
 550        \\  File path must not contain single quotes.
 551        \\
 552        \\  Examples:
 553        \\    echo "hello" | zmx write dev /tmp/hello.txt
 554        \\    cat main.zig | zmx write dev src/main.zig
 555        \\
 556        \\Wait:
 557        \\  Used with a detached run task to track its status.  Multiple
 558        \\  sessions can be provided.
 559        \\
 560        \\  Examples:
 561        \\    zmx run dev -d sleep 10
 562        \\    zmx wait dev
 563        \\    zmx wait dev other
 564        \\
 565        \\Labels:
 566        \\  Attach key=value labels to live sessions for discovery and
 567        \\  filtering. Labels are in-memory and scoped to session lifetime.
 568        \\
 569        \\  Examples:
 570        \\    zmx set dev project=zmx env=dev
 571        \\    zmx set dev project=            # unset a label
 572        \\    zmx set . status=fail           # "." resolves to current session
 573        \\    zmx get dev
 574        \\    zmx get dev project
 575        \\    zmx set next "$(zmx get prev)"  # set labels from other session
 576        \\    zmx list | grep project=zmx
 577        \\    zmx clear dev
 578        \\
 579        \\Print-env:
 580        \\  Print tracked environment variables for the leader client of a session.
 581        \\
 582        \\  Flags:
 583        \\    -s, --shell       Output POSIX export/unset commands for eval
 584        \\
 585        \\  Examples:
 586        \\    zmx print-env .
 587        \\    zmx print-env -s .
 588        \\    zmx print-env dev
 589        \\    zmx print-env dev DISPLAY
 590        \\    eval "$(zmx print-env -s .)"    # inside shell session precmd hook
 591        \\
 592        \\Environment variables:
 593        \\  SHELL                Default shell for new sessions
 594        \\  ZMX_DIR              Socket directory (priority 1)
 595        \\  XDG_RUNTIME_DIR      Socket directory (priority 2)
 596        \\  TMPDIR               Socket directory (priority 3)
 597        \\  ZMX_SESSION          Session name (injected automatically; makes attach
 598        \\                       switch this session)
 599        \\  ZMX_SESSION_PREFIX   Prefix added to all session names
 600        \\  ZMX_TRACK_ENV        Comma-separated list of environment variables to track
 601        \\                       from attaching clients
 602        \\  ZMX_DIR_MODE         Sets mode for socket and log directories (octal, defaults to 0750)
 603        \\  ZMX_LOG_MODE         Sets mode for log files (octal, defaults to 0640)
 604        \\  ZMX_NO_DETACH_KEY    Disables the ctrl+\ detach shortcut (set to any value)
 605        \\
 606    ;
 607    var buf: [8192]u8 = undefined;
 608    var w = std.Io.File.stdout().writer(io, &buf);
 609    try w.interface.print(help_text, .{});
 610    try w.interface.flush();
 611}
 612
 613fn printVersion(io: std.Io, cfg: *Cfg) !void {
 614    var buf: [256]u8 = undefined;
 615    var w = std.Io.File.stdout().writer(io, &buf);
 616    try w.interface.print(
 617        "zmx\t\t{s}\nghostty_vt\t{s}\nsocket_dir\t{s}\nlog_dir\t\t{s}\n",
 618        .{ version, ghostty_version, cfg.socket_dir, cfg.log_dir },
 619    );
 620    try w.interface.flush();
 621}
 622
 623fn printCompletions(io: std.Io, shell: completions.Shell) !void {
 624    const script = shell.getCompletionScript();
 625    var buf: [8192]u8 = undefined;
 626    var w = std.Io.File.stdout().writer(io, &buf);
 627    try w.interface.print("{s}\n", .{script});
 628    try w.interface.flush();
 629}
 630
 631fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(socket.SessionMatch)) !void {
 632    _ = cfg;
 633    var stdout_buffer: [1024]u8 = undefined;
 634    var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
 635    const stdout = &stdout_writer.interface;
 636
 637    var stderr_buffer: [1024]u8 = undefined;
 638    var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
 639    const stderr = &stderr_writer.interface;
 640
 641    // Highest match count seen so far. Lets us distinguish "sessions haven't
 642    // appeared yet" (keep polling) from "sessions we were tracking
 643    // disappeared" (fail -- daemon crashed or was killed).
 644    var max_seen: i32 = 0;
 645    var zero_match_iters: u32 = 0;
 646
 647    var agg_exit_code: u8 = 0;
 648    var last_print: std.Io.Timestamp = .zero;
 649    var prev_done: i32 = 0;
 650    while (true) {
 651        agg_exit_code = 0;
 652        const sessions = try zmx.list(alloc, io);
 653        defer {
 654            for (sessions) |session| {
 655                session.deinit(alloc);
 656            }
 657            alloc.free(sessions);
 658        }
 659        var total: i32 = 0;
 660        var done: i32 = 0;
 661
 662        for (sessions) |session| {
 663            var found = false;
 664            for (matchers.items) |m| {
 665                if (m.matches(session.name)) {
 666                    found = true;
 667                    break;
 668                }
 669            }
 670            if (!found) continue;
 671
 672            total += 1;
 673            if (session.is_error) {
 674                // Daemon unreachable (probe timed out). On Timeout the socket
 675                // is no longer deleted, so this session would otherwise
 676                // persist as task_ended_at==0 forever → infinite "still
 677                // waiting". Count it as done+failed so wait terminates.
 678                try stderr.print(
 679                    "[{d}] task unreachable: {s} ({s})\n",
 680                    .{
 681                        std.Io.Timestamp.now(io, .real).toSeconds(),
 682                        session.name,
 683                        session.error_name orelse "unknown",
 684                    },
 685                );
 686                try stderr.flush();
 687                agg_exit_code = 1;
 688                done += 1;
 689                continue;
 690            }
 691            if (session.task_ended_at == 0) {
 692                const now = std.Io.Timestamp.now(io, .real);
 693                if (now.toSeconds() - last_print.toSeconds() >= 5) {
 694                    try stdout.print(
 695                        "[{d}] waiting task={s}\n",
 696                        .{ now.toSeconds(), session.name },
 697                    );
 698                    try stdout.flush();
 699                    last_print = now;
 700                }
 701                continue;
 702            }
 703            if (done >= prev_done) {
 704                // Newly completed — print immediately
 705                try stdout.print(
 706                    "[{d}] completed task={s} exit_code={d}\n",
 707                    .{ session.task_ended_at.?, session.name, session.task_exit_code.? },
 708                );
 709                try stdout.flush();
 710            }
 711            if (session.task_exit_code != 0) {
 712                agg_exit_code = session.task_exit_code orelse 0;
 713            }
 714            done += 1;
 715        }
 716
 717        // Check disappearance BEFORE completion: if one of N sessions
 718        // crashed and the remaining N-1 happen to be done, total==done
 719        // would be a false success.
 720        if (total < max_seen) {
 721            try stderr.print(
 722                "error: {d} session(s) disappeared before completing\n",
 723                .{max_seen - total},
 724            );
 725            try stderr.flush();
 726            std.process.exit(1);
 727            return;
 728        }
 729        max_seen = total;
 730
 731        if (total > 0 and total == done) {
 732            break;
 733        }
 734
 735        if (max_seen == 0) {
 736            // `zmx run foo && zmx wait foo` is essentially sequential, so
 737            // matching sessions should be visible from the first poll. If
 738            // nothing appears after a few iterations it's almost certainly a
 739            // typo, not a slow start.
 740            zero_match_iters += 1;
 741            if (zero_match_iters >= 3) {
 742                try stderr.print("error: no matching sessions found\n", .{});
 743                try stderr.flush();
 744                std.process.exit(2);
 745                return;
 746            }
 747        }
 748
 749        prev_done = done;
 750        std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1000), .real) catch unreachable;
 751    }
 752
 753    if (agg_exit_code == 0) {
 754        try stdout.print("task(s) completed!\n", .{});
 755    } else {
 756        try stdout.print("task(s) failed!\n", .{});
 757    }
 758    try stdout.flush();
 759
 760    const sessions = try zmx.list(alloc, io);
 761    defer {
 762        for (sessions) |session| {
 763            session.deinit(alloc);
 764        }
 765        alloc.free(sessions);
 766    }
 767    for (sessions) |session| {
 768        var found = false;
 769        for (matchers.items) |m| {
 770            if (m.matches(session.name)) {
 771                found = true;
 772                break;
 773            }
 774        }
 775        if (!found) continue;
 776        if (session.task_exit_code.? > 0) {
 777            try stdout.print("---\n", .{});
 778            try stdout.print("[{d}] failed task={s} exit_status={d}\n", .{
 779                session.task_ended_at.?,
 780                session.name,
 781                session.task_exit_code.?,
 782            });
 783
 784            // Fetch and print the last 20 lines of history for debugging
 785            const history_lines: usize = 20;
 786            const history_text = zmx.history(alloc, io, session.name, .plain) catch null;
 787            if (history_text) |text| {
 788                defer alloc.free(text);
 789                try stdout.print("\nLast {d} lines of {s} history:\n", .{ history_lines, session.name });
 790
 791                // Count lines and find the start of the last N lines
 792                var total_lines: usize = 0;
 793                var it = std.mem.splitScalar(u8, text, '\n');
 794                while (it.next()) |_| {
 795                    total_lines += 1;
 796                }
 797
 798                const skip = if (total_lines > history_lines) total_lines - history_lines else 0;
 799                var current: usize = 0;
 800                it = std.mem.splitScalar(u8, text, '\n');
 801                while (it.next()) |line| {
 802                    if (current >= skip) {
 803                        try stdout.print("{s}\n", .{line});
 804                    }
 805                    current += 1;
 806                }
 807            }
 808
 809            try stdout.print("\nSee the logs:\nzmx history {s}\nzmx attach {s}\n", .{ session.name, session.name });
 810            try stdout.flush();
 811        }
 812    }
 813
 814    std.process.exit(agg_exit_code);
 815}
 816
 817fn list(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, short: bool) !void {
 818    const current_session = socket.getSeshNameFromEnv();
 819    var buf: [4096]u8 = undefined;
 820    var stdout = std.Io.File.stdout().writer(io, &buf);
 821    const sessions = try zmx.list(alloc, io);
 822    defer {
 823        for (sessions) |session| {
 824            session.deinit(alloc);
 825        }
 826        alloc.free(sessions);
 827    }
 828
 829    if (sessions.len == 0) {
 830        if (short) return;
 831        var errbuf: [4096]u8 = undefined;
 832        var stderr = std.Io.File.stderr().writer(io, &errbuf);
 833        try stderr.interface.print("no sessions found in {s}\n", .{cfg.socket_dir});
 834        try stderr.interface.flush();
 835        return;
 836    }
 837
 838    std.mem.sort(util.SessionEntry, sessions, {}, util.SessionEntry.lessThan);
 839
 840    for (sessions) |session| {
 841        try util.writeSessionLine(&stdout.interface, session, short, current_session);
 842        try stdout.interface.flush();
 843    }
 844}
 845
 846fn detachAll(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg) !void {
 847    _ = cfg;
 848    const session_name = socket.getSeshNameFromEnv();
 849    if (session_name.len == 0) {
 850        return printError(io, "not inside a zmx session (ZMX_SESSION not set)", .{});
 851    }
 852    std.log.info("detach all session={s}", .{session_name});
 853    zmx.detach(alloc, io, session_name) catch |err| switch (err) {
 854        error.BrokenPipe, error.ConnectionResetByPeer => return,
 855        else => return err,
 856    };
 857}
 858
 859fn kill(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, force: bool) !void {
 860    _ = cfg;
 861    std.log.info("kill session={s}", .{session_name});
 862    const status = zmx.kill(alloc, io, session_name, .{ .force = force }) catch |err| switch (err) {
 863        error.SessionNotFound => return error.SessionNotFound,
 864        error.SessionUnresponsive => return error.SessionUnresponsive,
 865        error.BrokenPipe, error.ConnectionResetByPeer => return,
 866        else => return err,
 867    };
 868
 869    if (status == .cleaned_stale) {
 870        var ebuf: [4096]u8 = undefined;
 871        var ew = std.Io.File.stderr().writer(io, &ebuf);
 872        ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
 873        ew.interface.flush() catch {};
 874        return;
 875    }
 876
 877    var buf: [100]u8 = undefined;
 878    var w = std.Io.File.stdout().writer(io, &buf);
 879    try w.interface.print("killed session {s}\n", .{session_name});
 880    try w.interface.flush();
 881}
 882
 883fn printLabelError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
 884    var buf: [4096]u8 = undefined;
 885    var w = std.Io.File.stderr().writer(io, &buf);
 886    switch (err) {
 887        error.Timeout => w.interface.print(
 888            "error: session \"{s}\" does not support labels (daemon too old?)\n",
 889            .{session_name},
 890        ) catch {},
 891        error.ConnectionRefused, error.Unexpected => w.interface.print(
 892            "error: session \"{s}\" not found or unresponsive\n",
 893            .{session_name},
 894        ) catch {},
 895        else => w.interface.print(
 896            "error: {s}\n",
 897            .{@errorName(err)},
 898        ) catch {},
 899    }
 900    w.interface.flush() catch {};
 901    std.process.exit(1);
 902}
 903
 904fn labelGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8) !void {
 905    _ = cfg;
 906    std.log.info("label get session={s}", .{session_name});
 907
 908    const key = if (single_kv.len == 0) null else single_kv;
 909    const val = zmx.get(alloc, io, session_name, key) catch |err| {
 910        if (err == error.LabelKeyNotFound) {
 911            return printError(io, "label key \"{s}\" not found in session \"{s}\"", .{ single_kv, session_name });
 912        }
 913        printLabelError(io, session_name, err);
 914    };
 915    defer alloc.free(val);
 916
 917    var buf: [4096]u8 = undefined;
 918    var stdout = std.Io.File.stdout().writer(io, &buf);
 919    try stdout.interface.print("{s}", .{val});
 920    try stdout.interface.flush();
 921}
 922
 923/// Rejects a malformed label set before the caller acts on it. Exits rather
 924/// than returning, so `attach --labels` can check its labels before a session
 925/// exists to be left behind.
 926fn assertLabels(io: std.Io, labels: []const u8) void {
 927    var kvs = label.LabelIterator.init(labels);
 928    while (kvs.next()) |kv| {
 929        label.assertLabel(kv.key, kv.value) catch |err| {
 930            var buf: [4096]u8 = undefined;
 931            var w = std.Io.File.stderr().writer(io, &buf);
 932            const msg = "error: key-value kvs can only contain [a-z, A-Z, 0-9, -_.] characters";
 933            switch (err) {
 934                error.LabelKeyEmpty => {
 935                    w.interface.print("error: label key cannot be empty\n", .{}) catch {};
 936                },
 937                error.LabelKeyReservedName => {
 938                    w.interface.print("error: \"{s}\" is a read-only built-in field\n", .{kv.key}) catch {};
 939                },
 940                error.LabelKeyInvalidChar => {
 941                    w.interface.print("{s}: key=[{s}]\n", .{ msg, kv.key }) catch {};
 942                },
 943                error.LabelValueInvalidChar => {
 944                    w.interface.print("{s}: value=[{s}]\n", .{ msg, kv.value }) catch {};
 945                },
 946            }
 947            w.interface.flush() catch {};
 948            std.process.exit(1);
 949        };
 950    }
 951}
 952
 953fn getTrackedEnvStr(
 954    alloc: std.mem.Allocator,
 955    env_keys: []const u8,
 956    env_map: *const std.process.Environ.Map,
 957) ![]u8 {
 958    var out = std.ArrayList(u8).empty;
 959    errdefer out.deinit(alloc);
 960
 961    var iter = std.mem.splitScalar(u8, env_keys, ',');
 962    while (iter.next()) |entry| {
 963        const env = std.mem.trim(u8, entry, " \t\r\n");
 964        if (env.len == 0) continue;
 965        if (env_map.get(env)) |val| {
 966            try out.appendSlice(alloc, env);
 967            try out.append(alloc, '=');
 968            try out.appendSlice(alloc, val);
 969            try out.append(alloc, '\n');
 970        } else {
 971            try out.append(alloc, '-');
 972            try out.appendSlice(alloc, env);
 973            try out.append(alloc, '\n');
 974        }
 975    }
 976
 977    return out.toOwnedSlice(alloc);
 978}
 979
 980pub const EnvEntry = struct {
 981    key: []const u8,
 982    value: ?[]const u8,
 983};
 984
 985pub const EnvIterator = struct {
 986    it: std.mem.SplitIterator(u8, .scalar),
 987
 988    pub fn init(payload: []const u8) EnvIterator {
 989        return .{ .it = std.mem.splitScalar(u8, payload, '\n') };
 990    }
 991
 992    pub fn next(self: *EnvIterator) ?EnvEntry {
 993        while (self.it.next()) |line| {
 994            if (line.len == 0) continue;
 995            if (line[0] == '-') {
 996                return .{ .key = line[1..], .value = null };
 997            } else if (std.mem.indexOfScalar(u8, line, '=')) |eq_idx| {
 998                return .{ .key = line[0..eq_idx], .value = line[eq_idx + 1 ..] };
 999            }
1000        }
1001        return null;
1002    }
1003};
1004
1005fn getEnvValue(key: []const u8, payload: []const u8) error{EnvVarNotFound}![]const u8 {
1006    var it = EnvIterator.init(payload);
1007    while (it.next()) |entry| {
1008        if (std.mem.eql(u8, entry.key, key)) {
1009            return entry.value orelse return error.EnvVarNotFound;
1010        }
1011    }
1012    return error.EnvVarNotFound;
1013}
1014
1015fn printEnvError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
1016    var buf: [4096]u8 = undefined;
1017    var w = std.Io.File.stderr().writer(io, &buf);
1018    switch (err) {
1019        error.SessionNotFound => w.interface.print("error: session \"{s}\" not found\n", .{session_name}) catch {},
1020        error.DaemonUnresponsive => w.interface.print("error: session \"{s}\" daemon unresponsive\n", .{session_name}) catch {},
1021        error.EnvVarNotFound => w.interface.print("error: environment variable not found\n", .{}) catch {},
1022        else => w.interface.print(
1023            "error: could not communicate with session \"{s}\" err={s}\n",
1024            .{ session_name, @errorName(err) },
1025        ) catch {},
1026    }
1027    w.interface.flush() catch {};
1028    std.process.exit(1);
1029}
1030
1031fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8, shell_mode: bool) !void {
1032    _ = cfg;
1033    std.log.info("env get session={s}", .{session_name});
1034
1035    const key = if (single_kv.len > 0) single_kv else null;
1036    const payload = zmx.printEnv(alloc, io, session_name, key) catch |err| {
1037        printEnvError(io, session_name, err);
1038    };
1039    defer alloc.free(payload);
1040
1041    var buf: [4096]u8 = undefined;
1042    var stdout = std.Io.File.stdout().writer(io, &buf);
1043    if (single_kv.len > 0) {
1044        try stdout.interface.print("{s}\n", .{payload});
1045        try stdout.interface.flush();
1046        return;
1047    }
1048
1049    if (payload.len == 0) return;
1050
1051    if (shell_mode) {
1052        var it = EnvIterator.init(payload);
1053        while (it.next()) |entry| {
1054            if (entry.value) |val| {
1055                try stdout.interface.print("export {s}='", .{entry.key});
1056                for (val) |c| {
1057                    if (c == '\'') {
1058                        try stdout.interface.print("'\\''", .{});
1059                    } else {
1060                        try stdout.interface.print("{c}", .{c});
1061                    }
1062                }
1063                try stdout.interface.print("';\n", .{});
1064            } else {
1065                try stdout.interface.print("unset {s};\n", .{entry.key});
1066            }
1067        }
1068    } else {
1069        try stdout.interface.print("{s}", .{payload});
1070    }
1071    try stdout.interface.flush();
1072}
1073
1074fn labelSet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, labels: []const u8) !void {
1075    _ = cfg;
1076    std.log.info("label set session={s}", .{session_name});
1077
1078    assertLabels(io, labels);
1079    zmx.set(alloc, io, session_name, labels) catch |err| {
1080        printLabelError(io, session_name, err);
1081    };
1082}
1083
1084fn labelClear(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8) !void {
1085    _ = cfg;
1086    std.log.info("label clear session={s}", .{session_name});
1087
1088    zmx.clear(alloc, io, session_name) catch |err| {
1089        printLabelError(io, session_name, err);
1090    };
1091}
1092
1093fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat, scrollback_rows: ?u32) !void {
1094    _ = cfg;
1095    std.log.info("history session={s}", .{session_name});
1096
1097    const data = zmx.historyRange(alloc, io, session_name, format, scrollback_rows) catch |err| switch (err) {
1098        error.SessionNotFound => return printError(io, "session \"{s}\" does not exist", .{session_name}),
1099        error.SessionUnresponsive => return printError(io, "session \"{s}\" is unresponsive (SessionUnresponsive)", .{session_name}),
1100        error.CaptureNotSupported => return printError(io, "session \"{s}\" does not support --screen/--scrollback (daemon too old?)", .{session_name}),
1101        error.Timeout => {
1102            std.log.err("timeout waiting for history response", .{});
1103            return;
1104        },
1105        error.ConnectionClosed, error.ReadFailed => return,
1106        else => return err,
1107    };
1108    defer alloc.free(data);
1109
1110    _ = lib_posix.write(lib_posix.STDOUT_FILENO, data) catch return;
1111}
1112
1113const AttachArgs = struct {
1114    /// Session name, or "" when the caller did not name one.
1115    session_name: []const u8 = "",
1116    /// Index of the first word of the session command.
1117    command_start: usize = 0,
1118    /// `--labels "k=v ..."`: labels to apply once the session exists, in the
1119    /// same space-separated form `zmx set` takes.
1120    labels: ?[]const u8 = null,
1121    want_help: bool = false,
1122    /// `--labels` was given with nothing to apply.
1123    missing_labels_value: bool = false,
1124};
1125
1126/// Parses the arguments that follow `zmx attach`. Flags are only recognized
1127/// before the session name, so everything after it stays part of the command
1128/// handed to the session.
1129fn parseAttachArgs(argv: []const []const u8) AttachArgs {
1130    const labels_flag = "--labels";
1131    var parsed: AttachArgs = .{};
1132    var i: usize = 0;
1133    while (i < argv.len) : (i += 1) {
1134        const arg = argv[i];
1135        if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
1136            parsed.want_help = true;
1137            return parsed;
1138        }
1139        if (std.mem.startsWith(u8, arg, labels_flag ++ "=")) {
1140            parsed.labels = arg[labels_flag.len + 1 ..];
1141            continue;
1142        }
1143        if (std.mem.eql(u8, arg, labels_flag)) {
1144            if (i + 1 >= argv.len) {
1145                parsed.missing_labels_value = true;
1146                parsed.command_start = argv.len;
1147                return parsed;
1148            }
1149            i += 1;
1150            parsed.labels = argv[i];
1151            continue;
1152        }
1153        parsed.session_name = arg;
1154        i += 1;
1155        break;
1156    }
1157    parsed.command_start = i;
1158    return parsed;
1159}
1160
1161fn writeFile(gpa: std.mem.Allocator, io: std.Io, session_name: []const u8, file_path: []const u8, cwd: []const u8, shell: ?[]const u8) !void {
1162    _ = try zmx.create(gpa, io, session_name, .{ .cwd = cwd, .shell = shell });
1163
1164    const stdin_fd = lib_posix.STDIN_FILENO;
1165    var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1166    defer stdin_buf.deinit(gpa);
1167
1168    while (true) {
1169        var tmp: [4096]u8 = undefined;
1170        const n = lib_posix.read(stdin_fd, &tmp) catch |err| {
1171            if (err == error.WouldBlock) break;
1172            return err;
1173        };
1174        if (n == 0) break;
1175        try stdin_buf.appendSlice(gpa, tmp[0..n]);
1176    }
1177
1178    zmx.write(gpa, io, session_name, file_path, stdin_buf.items) catch |err| {
1179        if (err == error.SessionUnresponsive) {
1180            var errbuf: [4096]u8 = undefined;
1181            var ew = std.Io.File.stderr().writer(io, &errbuf);
1182            ew.interface.print("session {s} is unresponsive\ndaemon may be busy: try again\n", .{session_name}) catch {};
1183            ew.interface.flush() catch {};
1184            return error.SessionUnresponsive;
1185        }
1186        return err;
1187    };
1188
1189    var buf: [4096]u8 = undefined;
1190    var w = std.Io.File.stdout().writer(io, &buf);
1191    try w.interface.print("file created {s}\n", .{file_path});
1192    try w.interface.flush();
1193}
1194
1195fn send(alloc: std.mem.Allocator, io: std.Io, session_name: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
1196    std.log.info("send session={s}", .{session_name});
1197
1198    var payload = std.ArrayList(u8).empty;
1199    defer payload.deinit(alloc);
1200
1201    if (text_parts.len > 0) {
1202        for (text_parts, 0..) |part, i| {
1203            if (i > 0) try payload.append(alloc, ' ');
1204            try payload.appendSlice(alloc, part);
1205        }
1206    } else {
1207        // Read from stdin when no text arguments provided.
1208        const stdin_file = std.Io.File.stdin();
1209        defer stdin_file.close(io);
1210        var stdin_buf: [4096]u8 = undefined;
1211        var reader = stdin_file.reader(io, &stdin_buf);
1212        if (!try stdin_file.isTty(io)) {
1213            while (true) {
1214                var dest: [1024]u8 = undefined;
1215                const n = try reader.interface.readSliceShort(&dest);
1216                if (n == 0) break; // EOF
1217                try payload.appendSlice(alloc, dest[0..n]);
1218            }
1219            // Strip trailing newline from piped input; the caller is
1220            // responsible for including \r when submission is desired.
1221            // For .Output the caller controls exact bytes, so don't strip.
1222            if (tag != .Output and payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
1223                _ = payload.pop();
1224            }
1225        }
1226    }
1227
1228    if (payload.items.len == 0) {
1229        return printError(io, "text argument required (or pipe input via stdin)", .{});
1230    }
1231
1232    const res = switch (tag) {
1233        .Send => zmx.send(alloc, io, session_name, payload.items),
1234        .Output => zmx.print(alloc, io, session_name, payload.items),
1235        else => unreachable,
1236    };
1237    res catch |err| {
1238        if (err == error.SessionUnresponsive) {
1239            var errbuf: [4096]u8 = undefined;
1240            var ew = std.Io.File.stderr().writer(io, &errbuf);
1241            ew.interface.print("session {s} is unresponsive\ndaemon may be busy: try again\n", .{session_name}) catch {};
1242            ew.interface.flush() catch {};
1243            return error.SessionUnresponsive;
1244        }
1245        return err;
1246    };
1247}
1248
1249test "parseAttachArgs reads a bare session name" {
1250    const parsed = parseAttachArgs(&.{"dev"});
1251    try std.testing.expectEqualStrings("dev", parsed.session_name);
1252    try std.testing.expect(!parsed.want_help);
1253    try std.testing.expectEqual(@as(usize, 1), parsed.command_start);
1254}
1255
1256test "parseAttachArgs keeps the session command intact" {
1257    const argv: []const []const u8 = &.{ "dev", "vim", "-n" };
1258    const parsed = parseAttachArgs(argv);
1259    try std.testing.expectEqualStrings("dev", parsed.session_name);
1260    // -n after the session name belongs to the command, not to zmx.
1261    try std.testing.expectEqualSlices([]const u8, argv[1..], argv[parsed.command_start..]);
1262}
1263
1264test "parseAttachArgs reports help before the session name" {
1265    try std.testing.expect(parseAttachArgs(&.{"--help"}).want_help);
1266    try std.testing.expect(parseAttachArgs(&.{"-h"}).want_help);
1267    try std.testing.expect(parseAttachArgs(&.{ "--labels", "a=1", "--help" }).want_help);
1268    // Once a session name is read, -h belongs to the command.
1269    try std.testing.expect(!parseAttachArgs(&.{ "dev", "-h" }).want_help);
1270}
1271
1272test "parseAttachArgs reads --labels in both forms" {
1273    for ([_][]const []const u8{
1274        &.{ "--labels", "a=1 b=2", "build" },
1275        &.{ "--labels=a=1 b=2", "build" },
1276    }) |argv| {
1277        const parsed = parseAttachArgs(argv);
1278        try std.testing.expectEqualStrings("a=1 b=2", parsed.labels.?);
1279        try std.testing.expectEqualStrings("build", parsed.session_name);
1280        try std.testing.expect(!parsed.missing_labels_value);
1281        try std.testing.expectEqual(argv.len, parsed.command_start);
1282    }
1283}
1284
1285test "parseAttachArgs combines --labels with a session command" {
1286    const argv: []const []const u8 = &.{ "--labels", "a=1", "build", "make", "--labels" };
1287    const parsed = parseAttachArgs(argv);
1288    try std.testing.expectEqualStrings("a=1", parsed.labels.?);
1289    try std.testing.expectEqualStrings("build", parsed.session_name);
1290    // --labels after the session name belongs to the command.
1291    try std.testing.expectEqualSlices([]const u8, argv[3..], argv[parsed.command_start..]);
1292}
1293
1294test "parseAttachArgs reports --labels with no value" {
1295    const parsed = parseAttachArgs(&.{"--labels"});
1296    try std.testing.expect(parsed.missing_labels_value);
1297    try std.testing.expectEqual(@as(?[]const u8, null), parsed.labels);
1298}
1299
1300test "parseAttachArgs leaves labels unset when the flag is absent" {
1301    const parsed = parseAttachArgs(&.{"dev"});
1302    try std.testing.expectEqual(@as(?[]const u8, null), parsed.labels);
1303    try std.testing.expect(!parsed.missing_labels_value);
1304}
1305
1306test "getTrackedEnvStr includes set and unset entries" {
1307    const alloc = std.testing.allocator;
1308    var env_map = std.process.Environ.Map.init(alloc);
1309    defer env_map.deinit();
1310
1311    try env_map.put("DISPLAY", ":1");
1312    try env_map.put("SSH_AUTH_SOCK", "/tmp/ssh.sock");
1313
1314    const str = try getTrackedEnvStr(alloc, "DISPLAY, KITTY_WINDOW_ID, SSH_AUTH_SOCK", &env_map);
1315    defer alloc.free(str);
1316
1317    try std.testing.expectEqualStrings("DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n", str);
1318}
1319
1320test "getEnvValue extracts set value or returns EnvVarNotFound for unset" {
1321    const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n";
1322
1323    try std.testing.expectEqualStrings(":1", try getEnvValue("DISPLAY", payload));
1324    try std.testing.expectEqualStrings("/tmp/ssh.sock", try getEnvValue("SSH_AUTH_SOCK", payload));
1325    try std.testing.expectError(error.EnvVarNotFound, getEnvValue("KITTY_WINDOW_ID", payload));
1326    try std.testing.expectError(error.EnvVarNotFound, getEnvValue("NONEXISTENT", payload));
1327}
1328
1329test "EnvIterator parses set and unset entries correctly" {
1330    const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n";
1331    var it = EnvIterator.init(payload);
1332
1333    const e1 = it.next().?;
1334    try std.testing.expectEqualStrings("DISPLAY", e1.key);
1335    try std.testing.expectEqualStrings(":1", e1.value.?);
1336
1337    const e2 = it.next().?;
1338    try std.testing.expectEqualStrings("KITTY_WINDOW_ID", e2.key);
1339    try std.testing.expect(e2.value == null);
1340
1341    const e3 = it.next().?;
1342    try std.testing.expectEqualStrings("SSH_AUTH_SOCK", e3.key);
1343    try std.testing.expectEqualStrings("/tmp/ssh.sock", e3.value.?);
1344
1345    try std.testing.expect(it.next() == null);
1346}