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}