From 9ebe7ed6ec8d11e90e34ddeae925e5fd1f97b43f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 17:32:57 +0000 Subject: [PATCH] feat!: require session names and rework wait, send, and kill flags CLI surface changes from dogfooding feedback: - wait: --idle is now a plain flag (fires after 2s of quiet) and --text replaces --for, so --timeout is the only duration. Durations accept h, hr, and d units, and value flags accept --flag=value. - send: the session is a positional argument and text moves behind --text: 'boo send build --text make --enter'. The -s flag is gone. - Session names are required everywhere: the implicit only-session and most-recently-active fallbacks are removed, along with the unused pick-most-recent machinery. - exorcise is removed; 'boo kill --all' is the one way to end every session. Bump version to 0.4.0. --- README.md | 21 ++-- build.zig.zon | 2 +- src/help.zig | 104 ++++++++----------- src/main.zig | 238 +++++++++++++++++++------------------------ test/integration.zig | 42 ++++---- 5 files changed, 182 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index 92e83fc..61da21f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ boo ls # list sessions boo attach work # reattach (steals if attached elsewhere) boo at w # same: alias + unique-prefix matching boo kill work # end a session -boo exorcise # end every session +boo kill --all # end every session ``` With no name, `boo new` names the session after the current directory, @@ -94,21 +94,22 @@ natural sandbox for scripts and AI agents driving interactive programs. The canonical loop: ```sh -boo new build -d -- bash # 1. headless session -boo send -s build 'make' --enter # 2. type into it -boo wait build --idle 2s # 3. let output settle -boo peek build --scrollback # 4. read the screen -boo kill build # 5. clean up +boo new build -d -- bash # 1. headless session +boo send build --text 'make' --enter # 2. type into it +boo wait build --idle # 3. let output settle +boo peek build --scrollback # 4. read the screen +boo kill build # 5. clean up ``` - **Reading state**: `peek` prints the rendered screen reconstructed from terminal state, not a raw byte log: ordered, fully redrawn, and stable. `--scrollback` includes history; `--json` adds size, cursor, and title. -- **Waiting**: `wait --for ` blocks until the screen contains the - text; `wait --idle ` until output settles; `--timeout ` - exits 4 instead of hanging forever. No more sleep-and-poll loops. -- **Sending input**: `send` is literal: no escape processing, no +- **Waiting**: `wait --text ` blocks until the screen contains + the text; `wait --idle` until output has been quiet for 2 seconds; + `--timeout ` exits 4 instead of hanging forever (durations: + `500ms`, `2s`, `1m`, `4h`, `1d`). No more sleep-and-poll loops. +- **Sending input**: `send --text` is literal: no escape processing, no implicit newline, no quoting layer to fight. `--enter` submits, `--key Enter,C-c,Up` names control keys, and stdin mode is binary safe. diff --git a/build.zig.zon b/build.zig.zon index c5781e9..53a59ac 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .boo, - .version = "0.3.0", + .version = "0.4.0", .fingerprint = 0x8b7acdfd255f0e34, .minimum_zig_version = "0.15.2", .dependencies = .{ diff --git a/src/help.zig b/src/help.zig index 98c371c..1bdca9a 100644 --- a/src/help.zig +++ b/src/help.zig @@ -26,13 +26,12 @@ pub const overview = \\ \\commands: \\ new [name] [-d] [-- cmd...] start a session (attach unless -d) - \\ attach, at [name] attach a session (steals politely) + \\ attach, at attach a session (steals politely) \\ ls [--json] list sessions - \\ send [-s name] [text] type into a session - \\ peek [name] print the session's screen - \\ wait [name] block until output matches or settles - \\ kill [name | --all] end a session - \\ exorcise end every session + \\ send [flags] type into a session + \\ peek print the session's screen + \\ wait block until output matches or settles + \\ kill end a session, or all of them \\ version print the version \\ help [command | topic] this overview, or detailed help \\ @@ -44,10 +43,8 @@ pub const overview = \\'boo help --all' to print every page at once. \\ \\session selection: - \\ Commands taking [name] accept a unique prefix of the session - \\ name. With no name, the only session is used, or the most - \\ recently active one for read-only commands; commands that - \\ destroy state never guess between multiple sessions. + \\ Commands taking accept a unique prefix of the session + \\ name (e.g. 'boo attach bu' for "build"). \\ \\environment: \\ BOO_DIR socket directory @@ -90,23 +87,22 @@ pub const commands = [_]Entry{ .name = "attach", .alias = "at", .body = - \\usage: boo attach [name] - \\ boo at [name] + \\usage: boo attach + \\ boo at \\ \\Attach this terminal to a session. The screen, scrollback, \\cursor, and title are restored from terminal state. If the \\session is attached elsewhere, the other client is detached \\(the session is stolen). \\ - \\With no name: the only session, or the most recently active - \\one. A unique prefix of a name is accepted. + \\A unique prefix of the name is accepted. \\ \\Inside the session, press C-a d to detach. See 'boo help \\keys' for all bindings. \\ \\examples: - \\ boo attach grab the most recent session - \\ boo at bu attach "build" by prefix + \\ boo attach build reattach "build" + \\ boo at bu the same, by prefix \\ , }, @@ -129,35 +125,34 @@ pub const commands = [_]Entry{ .{ .name = "send", .body = - \\usage: boo send [-s session] [text] [flags] + \\usage: boo send [--text ] [--key ] [flags] \\ \\Type into a session, exactly as if the text had been typed - \\at the keyboard. Text is sent literally: no escape + \\at the keyboard. --text is sent literally: no escape \\processing and no implicit newline, so there is never a - \\quoting layer to fight. With no text and no --key, bytes - \\are read from stdin (binary safe, NUL excluded). + \\quoting layer to fight. With neither --text nor --key, + \\bytes are read from stdin (binary safe, NUL excluded). \\ \\flags: - \\ -s target session (default: only/most recent) - \\ --enter append Enter after everything else - \\ --key send named keys, comma separated: - \\ Enter, Tab, Escape, Space, Backspace, - \\ Up, Down, Left, Right, Home, End, C-a..C-z. - \\ Cannot be combined with text; use two calls. - \\ --stdin force reading from stdin + \\ --text the text to type + \\ --enter append Enter after everything else + \\ --key send named keys, comma separated: + \\ Enter, Tab, Escape, Space, Backspace, + \\ Up, Down, Left, Right, Home, End, C-a..C-z. + \\ Cannot be combined with --text; use two calls. + \\ --stdin force reading from stdin \\ \\examples: - \\ boo send 'make test' --enter run a command - \\ boo send -s build 'make' --enter ...in session "build" - \\ boo send --key C-c interrupt the program - \\ printf 'y\n' | boo send -s build pipe bytes in + \\ boo send build --text 'make test' --enter run a command + \\ boo send build --key C-c interrupt it + \\ printf 'y\n' | boo send build pipe bytes in \\ , }, .{ .name = "peek", .body = - \\usage: boo peek [name] [--scrollback] [--json] + \\usage: boo peek [--scrollback] [--json] \\ \\Print the session's rendered screen: what a human attached \\right now would see, reconstructed from terminal state (not @@ -177,34 +172,34 @@ pub const commands = [_]Entry{ .{ .name = "wait", .body = - \\usage: boo wait [name] (--for | --idle ) [--timeout ] + \\usage: boo wait (--text | --idle) [--timeout ] \\ \\Block until something happens in the session, then exit 0. \\Replaces sleep-and-poll loops in scripts. \\ \\flags: - \\ --for until the rendered screen contains + \\ --text until the rendered screen contains \\ (plain substring match) - \\ --idle until the session has produced no output - \\ for + \\ --idle until the session has produced no output + \\ for 2 seconds \\ --timeout give up and exit 4 (default: 30s) \\ - \\Durations are an integer with a unit: 500ms, 2s, 1m. + \\Durations are an integer with a unit: 500ms, 2s, 1m, 4h + \\(or 4hr), 1d. Flags also accept --flag=value. \\ \\examples: - \\ boo wait build --for 'PASS' --timeout 2m - \\ boo wait build --idle 2s && boo peek build + \\ boo wait build --text 'PASS' --timeout 2m + \\ boo wait build --idle && boo peek build \\ , }, .{ .name = "kill", .body = - \\usage: boo kill [name | --all] + \\usage: boo kill \\ \\End a session: its process receives SIGHUP and the daemon - \\exits. With multiple sessions a name is required unless - \\--all is given (also available as 'boo exorcise'). + \\exits. --all ends every session and sweeps stale sockets. \\ \\examples: \\ boo kill build @@ -212,17 +207,6 @@ pub const commands = [_]Entry{ \\ , }, - .{ - .name = "exorcise", - .body = - \\usage: boo exorcise - \\ - \\Banish every session on this machine. The thorough form of - \\'boo kill --all': each session is terminated and stale - \\sockets are swept away. No ghost survives. - \\ - , - }, .{ .name = "version", .body = @@ -270,11 +254,11 @@ pub const topics = [_]Entry{ \\Everything except 'attach' works without a terminal. The \\canonical loop: \\ - \\ boo new build -d -- bash # 1. headless session - \\ boo send -s build 'make' --enter # 2. type into it - \\ boo wait build --idle 2s # 3. let output settle - \\ boo peek build --scrollback # 4. read the screen - \\ boo kill build # 5. clean up + \\ boo new build -d -- bash # 1. headless session + \\ boo send build --text 'make' --enter # 2. type into it + \\ boo wait build --idle # 3. let output settle + \\ boo peek build --scrollback # 4. read the screen + \\ boo kill build # 5. clean up \\ \\reading state: \\ peek prints the rendered screen, not a raw byte stream: @@ -282,8 +266,8 @@ pub const topics = [_]Entry{ \\ history; --json adds size, cursor, and title. \\ \\waiting (instead of sleep): - \\ boo wait --for screen contains - \\ boo wait --idle output quiet for + \\ boo wait --text screen contains + \\ boo wait --idle output quiet for 2 seconds \\ boo wait ... --timeout exit 4 on timeout \\ \\sending input: diff --git a/src/main.zig b/src/main.zig index ac72418..09f35a1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,7 @@ const help = @import("help.zig"); const paths = @import("paths.zig"); const protocol = @import("protocol.zig"); -pub const version = "0.3.0"; +pub const version = "0.4.0"; /// Exit codes, documented in `boo help`. const exit_runtime: u8 = 1; @@ -68,8 +68,7 @@ pub fn main() !void { if (eql(cmd, "send")) return cmdSend(alloc, rest); if (eql(cmd, "peek")) return cmdPeek(alloc, rest); if (eql(cmd, "wait")) return cmdWait(alloc, rest); - if (eql(cmd, "kill")) return cmdKill(alloc, rest, "kill"); - if (eql(cmd, "exorcise")) return cmdKill(alloc, rest, "exorcise"); + if (eql(cmd, "kill")) return cmdKill(alloc, rest); if (eql(cmd, "version") or eql(cmd, "-V") or eql(cmd, "--version")) return cmdVersion(alloc); if (eql(cmd, "help") or eql(cmd, "-h") or eql(cmd, "--help")) return cmdHelp(alloc, rest); fail(exit_usage, "unknown command '{s}' (run 'boo help')", .{cmd}); @@ -79,6 +78,27 @@ fn isHelpFlag(arg: []const u8) bool { return std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help"); } +/// Match a flag that carries a value, in both spellings: `--flag value` +/// (consuming the next argument) and `--flag=value`. Returns null when +/// `args[i]` is some other argument. +fn flagValue( + comptime cmd: []const u8, + comptime flag: []const u8, + args: []const [:0]const u8, + i: *usize, +) ?[]const u8 { + const arg = args[i.*]; + if (std.mem.eql(u8, arg, flag)) { + i.* += 1; + if (i.* >= args.len) usageFail(cmd, flag ++ " requires a value", .{}); + return args[i.*]; + } + if (std.mem.startsWith(u8, arg, flag ++ "=")) { + return arg[flag.len + 1 ..]; + } + return null; +} + fn printHelpPage(name: []const u8) !void { const entry = help.find(name) orelse unreachable; try stdoutWrite(entry.body); @@ -86,14 +106,6 @@ fn printHelpPage(name: []const u8) !void { // -- Session resolution --------------------------------------------------- -/// How to pick a session when no name was given and several exist. -const Pick = enum { - /// Read-only commands fall back to the most recently active session. - read, - /// Destructive commands never guess. - destructive, -}; - fn joinNames(alloc: std.mem.Allocator, names: []const []u8) []const u8 { var out: std.ArrayList(u8) = .empty; for (names, 0..) |name, i| { @@ -103,13 +115,12 @@ fn joinNames(alloc: std.mem.Allocator, names: []const []u8) []const u8 { return out.items; } -/// Resolve an optional session name to an owned, existing session name. +/// Resolve a session name to an owned, existing session name. /// Accepts unique prefixes. Exits with code 3 when nothing matches. fn resolveSession( alloc: std.mem.Allocator, dir: []const u8, - explicit: ?[]const u8, - pick: Pick, + want: []const u8, ) ![]u8 { const sessions = try paths.listSessions(alloc, dir); defer { @@ -117,63 +128,22 @@ fn resolveSession( alloc.free(sessions); } - if (explicit) |want| { - for (sessions) |s| { - if (std.mem.eql(u8, s, want)) return alloc.dupe(u8, s); - } - var match: ?[]const u8 = null; - var count: usize = 0; - for (sessions) |s| { - if (std.mem.startsWith(u8, s, want)) { - match = s; - count += 1; - } - } - if (count == 1) return alloc.dupe(u8, match.?); - if (count > 1) fail(exit_no_session, "ambiguous session '{s}': matches {s}", .{ - want, joinNames(alloc, sessions), - }); - fail(exit_no_session, "no session matching '{s}' (run 'boo ls')", .{want}); - } - - if (sessions.len == 0) { - fail(exit_no_session, "no sessions (run 'boo' or 'boo new' to start one)", .{}); + for (sessions) |s| { + if (std.mem.eql(u8, s, want)) return alloc.dupe(u8, s); } - if (sessions.len == 1) return alloc.dupe(u8, sessions[0]); - - switch (pick) { - .destructive => fail(exit_no_session, "multiple sessions; name one of: {s}", .{ - joinNames(alloc, sessions), - }), - .read => { - if (try pickMostRecent(alloc, dir)) |name| return name; - fail(exit_no_session, "no sessions (run 'boo' or 'boo new' to start one)", .{}); - }, - } -} - -/// The live session with the smallest idle time. Cleans up stale -/// sockets along the way. Returns null when no session is alive. -fn pickMostRecent(alloc: std.mem.Allocator, dir: []const u8) !?[]u8 { - const sessions = try paths.listSessions(alloc, dir); - defer { - for (sessions) |s| alloc.free(s); - alloc.free(sessions); - } - - var best: ?[]u8 = null; - errdefer if (best) |b| alloc.free(b); - var best_idle: i64 = std.math.maxInt(i64); - for (sessions) |name| { - const info = sessionInfo(alloc, dir, name) catch continue orelse continue; - defer alloc.free(info.text); - if (best == null or info.idle_ms < best_idle) { - if (best) |b| alloc.free(b); - best = try alloc.dupe(u8, name); - best_idle = info.idle_ms; + var match: ?[]const u8 = null; + var count: usize = 0; + for (sessions) |s| { + if (std.mem.startsWith(u8, s, want)) { + match = s; + count += 1; } } - return best; + if (count == 1) return alloc.dupe(u8, match.?); + if (count > 1) fail(exit_no_session, "ambiguous session '{s}': matches {s}", .{ + want, joinNames(alloc, sessions), + }); + fail(exit_no_session, "no session matching '{s}' (run 'boo ls')", .{want}); } const SessionInfo = struct { @@ -327,10 +297,11 @@ fn cmdAttach(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { if (name_arg != null) usageFail("attach", "unexpected argument '{s}'", .{arg}); name_arg = arg; } + const want = name_arg orelse usageFail("attach", "a session name is required", .{}); const dir = try paths.socketDir(alloc); defer alloc.free(dir); - const name = try resolveSession(alloc, dir, name_arg, .read); + const name = try resolveSession(alloc, dir, want); defer alloc.free(name); try attachLoop(alloc, dir, name); } @@ -441,7 +412,7 @@ fn cutTab(rest: *[]const u8) ?[]const u8 { } fn cmdSend(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { - var session: ?[]const u8 = null; + var name_arg: ?[]const u8 = null; var text: ?[]const u8 = null; var keys_arg: ?[]const u8 = null; var enter = false; @@ -451,38 +422,35 @@ fn cmdSend(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { while (i < args.len) : (i += 1) { const arg = args[i]; if (isHelpFlag(arg)) return printHelpPage("send"); - if (std.mem.eql(u8, arg, "-s")) { - i += 1; - if (i >= args.len) usageFail("send", "-s requires a session name", .{}); - session = args[i]; - } else if (std.mem.eql(u8, arg, "--enter")) { + if (std.mem.eql(u8, arg, "--enter")) { enter = true; } else if (std.mem.eql(u8, arg, "--stdin")) { stdin = true; - } else if (std.mem.eql(u8, arg, "--key")) { - i += 1; - if (i >= args.len) usageFail("send", "--key requires a key list", .{}); - keys_arg = args[i]; + } else if (flagValue("send", "--text", args, &i)) |v| { + text = v; + } else if (flagValue("send", "--key", args, &i)) |v| { + keys_arg = v; } else if (arg.len > 0 and arg[0] == '-') { usageFail("send", "unknown flag '{s}'", .{arg}); - } else if (text == null) { - text = arg; + } else if (name_arg == null) { + name_arg = arg; } else { - usageFail("send", "multiple text arguments; quote the text", .{}); + usageFail("send", "unexpected argument '{s}'", .{arg}); } } if (text != null and keys_arg != null) { - usageFail("send", "text and --key cannot be combined; use two calls", .{}); + usageFail("send", "--text and --key cannot be combined; use two calls", .{}); } if (stdin and (text != null or keys_arg != null)) { - usageFail("send", "--stdin cannot be combined with text or --key", .{}); + usageFail("send", "--stdin cannot be combined with --text or --key", .{}); } + const want = name_arg orelse usageFail("send", "a session name is required", .{}); // Resolve the session before potentially blocking on stdin. const dir = try paths.socketDir(alloc); defer alloc.free(dir); - const name = try resolveSession(alloc, dir, session, .read); + const name = try resolveSession(alloc, dir, want); defer alloc.free(name); var payload: std.ArrayList(u8) = .empty; @@ -612,10 +580,11 @@ fn cmdPeek(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { usageFail("peek", "unexpected argument '{s}'", .{arg}); } } + const want = name_arg orelse usageFail("peek", "a session name is required", .{}); const dir = try paths.socketDir(alloc); defer alloc.free(dir); - const name = try resolveSession(alloc, dir, name_arg, .read); + const name = try resolveSession(alloc, dir, want); defer alloc.free(name); const result = try mustControl(alloc, dir, name, &.{ @@ -653,28 +622,25 @@ fn cmdPeek(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { try stdoutWrite(out.items); } +/// How long output must stay quiet for `wait --idle` to fire. +const idle_settle_ms: i64 = 2000; + fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { var name_arg: ?[]const u8 = null; - var for_text: ?[]const u8 = null; - var idle_str: ?[]const u8 = null; + var text: ?[]const u8 = null; + var idle = false; var timeout_str: []const u8 = "30s"; var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (isHelpFlag(arg)) return printHelpPage("wait"); - if (std.mem.eql(u8, arg, "--for")) { - i += 1; - if (i >= args.len) usageFail("wait", "--for requires text", .{}); - for_text = args[i]; - } else if (std.mem.eql(u8, arg, "--idle")) { - i += 1; - if (i >= args.len) usageFail("wait", "--idle requires a duration", .{}); - idle_str = args[i]; - } else if (std.mem.eql(u8, arg, "--timeout")) { - i += 1; - if (i >= args.len) usageFail("wait", "--timeout requires a duration", .{}); - timeout_str = args[i]; + if (std.mem.eql(u8, arg, "--idle")) { + idle = true; + } else if (flagValue("wait", "--text", args, &i)) |v| { + text = v; + } else if (flagValue("wait", "--timeout", args, &i)) |v| { + timeout_str = v; } else if (arg.len > 0 and arg[0] == '-') { usageFail("wait", "unknown flag '{s}'", .{arg}); } else if (name_arg == null) { @@ -684,25 +650,21 @@ fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { } } - if ((for_text == null) == (idle_str == null)) { - usageFail("wait", "exactly one of --for or --idle is required", .{}); + if ((text != null) == idle) { + usageFail("wait", "exactly one of --text or --idle is required", .{}); } + const want = name_arg orelse usageFail("wait", "a session name is required", .{}); const timeout_ms = parseDurationMs(timeout_str) orelse - usageFail("wait", "bad duration '{s}' (use 500ms, 2s, 1m)", .{timeout_str}); - const idle_ms: i64 = if (idle_str) |s| - @intCast(parseDurationMs(s) orelse - usageFail("wait", "bad duration '{s}' (use 500ms, 2s, 1m)", .{s})) - else - 0; + usageFail("wait", "bad duration '{s}' (use 500ms, 2s, 1m, 4h, 1d)", .{timeout_str}); const dir = try paths.socketDir(alloc); defer alloc.free(dir); - const name = try resolveSession(alloc, dir, name_arg, .read); + const name = try resolveSession(alloc, dir, want); defer alloc.free(name); const deadline = std.time.milliTimestamp() + @as(i64, @intCast(timeout_ms)); while (true) { - if (for_text) |needle| { + if (text) |needle| { const result = try mustControl(alloc, dir, name, &.{ "peek", "screen" }); defer alloc.free(result.text); if (!result.ok) fail(exit_runtime, "{s}", .{result.text}); @@ -713,7 +675,7 @@ fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { const info = try sessionInfo(alloc, dir, name) orelse fail(exit_no_session, "no session named {s}", .{name}); defer alloc.free(info.text); - if (info.out_idle_ms >= idle_ms) return; + if (info.out_idle_ms >= idle_settle_ms) return; } if (std.time.milliTimestamp() >= deadline) { fail(exit_timeout, "wait: timed out after {s}", .{timeout_str}); @@ -722,27 +684,23 @@ fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { } } -fn cmdKill(alloc: std.mem.Allocator, args: []const [:0]const u8, comptime cmd: []const u8) !void { - const is_exorcise = comptime std.mem.eql(u8, cmd, "exorcise"); - var all = is_exorcise; +fn cmdKill(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { + var all = false; var name_arg: ?[]const u8 = null; for (args) |arg| { - if (isHelpFlag(arg)) return printHelpPage(cmd); + if (isHelpFlag(arg)) return printHelpPage("kill"); if (std.mem.eql(u8, arg, "--all")) { - if (is_exorcise) usageFail(cmd, "unexpected flag '--all'", .{}); all = true; } else if (arg.len > 0 and arg[0] == '-') { - usageFail(cmd, "unknown flag '{s}'", .{arg}); - } else if (is_exorcise or all) { - usageFail(cmd, "unexpected argument '{s}'", .{arg}); + usageFail("kill", "unknown flag '{s}'", .{arg}); } else if (name_arg == null) { name_arg = arg; } else { - usageFail(cmd, "unexpected argument '{s}'", .{arg}); + usageFail("kill", "unexpected argument '{s}'", .{arg}); } } if (all and name_arg != null) { - usageFail(cmd, "--all cannot be combined with a session name", .{}); + usageFail("kill", "--all cannot be combined with a session name", .{}); } const dir = try paths.socketDir(alloc); @@ -767,7 +725,8 @@ fn cmdKill(alloc: std.mem.Allocator, args: []const [:0]const u8, comptime cmd: [ return; } - const name = try resolveSession(alloc, dir, name_arg, .destructive); + const want = name_arg orelse usageFail("kill", "a session name or --all is required", .{}); + const name = try resolveSession(alloc, dir, want); defer alloc.free(name); const result = try mustControl(alloc, dir, name, &.{"quit"}); defer alloc.free(result.text); @@ -837,19 +796,24 @@ fn appendJsonString(alloc: std.mem.Allocator, out: *std.ArrayList(u8), s: []cons try out.append(alloc, '"'); } -/// Parse durations like 500ms, 2s, 1m. Returns milliseconds. +/// Parse a duration like 500ms, 2s, 1m, 4h (or 4hr), 1d. Returns +/// milliseconds. fn parseDurationMs(s: []const u8) ?u64 { - if (std.mem.endsWith(u8, s, "ms")) { - const n = std.fmt.parseInt(u64, s[0 .. s.len - 2], 10) catch return null; - return n; - } - if (std.mem.endsWith(u8, s, "s")) { - const n = std.fmt.parseInt(u64, s[0 .. s.len - 1], 10) catch return null; - return n * std.time.ms_per_s; - } - if (std.mem.endsWith(u8, s, "m")) { - const n = std.fmt.parseInt(u64, s[0 .. s.len - 1], 10) catch return null; - return n * std.time.ms_per_min; + const Unit = struct { suffix: []const u8, ms: u64 }; + // "ms" must match before "s", and "hr" before "h". + const units = [_]Unit{ + .{ .suffix = "ms", .ms = 1 }, + .{ .suffix = "hr", .ms = std.time.ms_per_hour }, + .{ .suffix = "s", .ms = std.time.ms_per_s }, + .{ .suffix = "m", .ms = std.time.ms_per_min }, + .{ .suffix = "h", .ms = std.time.ms_per_hour }, + .{ .suffix = "d", .ms = std.time.ms_per_day }, + }; + for (units) |unit| { + if (std.mem.endsWith(u8, s, unit.suffix)) { + const n = std.fmt.parseInt(u64, s[0 .. s.len - unit.suffix.len], 10) catch return null; + return std.math.mul(u64, n, unit.ms) catch null; + } } return null; } @@ -936,9 +900,13 @@ test "parseDurationMs" { try std.testing.expectEqual(@as(?u64, 500), parseDurationMs("500ms")); try std.testing.expectEqual(@as(?u64, 2000), parseDurationMs("2s")); try std.testing.expectEqual(@as(?u64, 60_000), parseDurationMs("1m")); + try std.testing.expectEqual(@as(?u64, 4 * 3_600_000), parseDurationMs("4h")); + try std.testing.expectEqual(@as(?u64, 4 * 3_600_000), parseDurationMs("4hr")); + try std.testing.expectEqual(@as(?u64, 10 * 86_400_000), parseDurationMs("10d")); try std.testing.expectEqual(@as(?u64, null), parseDurationMs("2")); try std.testing.expectEqual(@as(?u64, null), parseDurationMs("s")); - try std.testing.expectEqual(@as(?u64, null), parseDurationMs("2h")); + try std.testing.expectEqual(@as(?u64, null), parseDurationMs("hr")); + try std.testing.expectEqual(@as(?u64, null), parseDurationMs("2x")); } test "appendKey named keys" { diff --git a/test/integration.zig b/test/integration.zig index 5e761d8..f342f9d 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -147,7 +147,7 @@ const Harness = struct { /// Type text into the session, followed by Enter. fn sendLine(self: *Harness, session: []const u8, text: []const u8) !void { - try self.runOk(&.{ "send", "-s", session, text, "--enter" }); + try self.runOk(&.{ "send", session, "--text", text, "--enter" }); } /// Poll the session's screen contents until `needle` shows up. @@ -759,7 +759,7 @@ test "help: overview, command pages, topics, and version" { defer alloc.free(overview.stderr); try std.testing.expect(overview.term.Exited == 0); try std.testing.expect(std.mem.indexOf(u8, overview.stdout, "commands:") != null); - try std.testing.expect(std.mem.indexOf(u8, overview.stdout, "exorcise") != null); + try std.testing.expect(std.mem.indexOf(u8, overview.stdout, "kill") != null); const send_page = try h.run(&.{ "help", "send" }); defer alloc.free(send_page.stdout); @@ -845,13 +845,13 @@ test "send --key presses named keys" { try h.startDetached("keys", &.{"cat"}); // Type a line without Enter, then press Enter by name; cat only // echoes the line back once the key arrives. - try h.runOk(&.{ "send", "-s", "keys", "key-mark" }); - try h.runOk(&.{ "send", "-s", "keys", "--key", "Enter" }); + try h.runOk(&.{ "send", "keys", "--text", "key-mark" }); + try h.runOk(&.{ "send", "keys", "--key", "Enter" }); const content = try h.waitPeekContains("keys", "key-mark"); defer alloc.free(content); } -test "wait --for and --idle observe session output" { +test "wait --text and --idle observe session output" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); defer h.deinit(); @@ -859,14 +859,16 @@ test "wait --for and --idle observe session output" { try h.startDetached("w1", &.{"cat"}); try h.sendLine("w1", "wait-mark"); - // --for: returns once the text is on screen. - try h.runOk(&.{ "wait", "w1", "--for", "wait-mark", "--timeout", "10s" }); + // --text: returns once the text is on screen. The =value flag + // spelling is accepted too. + try h.runOk(&.{ "wait", "w1", "--text=wait-mark", "--timeout", "10s" }); - // --idle: cat produces no further output, so this settles quickly. - try h.runOk(&.{ "wait", "w1", "--idle", "200ms", "--timeout", "10s" }); + // --idle: cat produces no further output, so this settles once + // the screen has been quiet for the built-in threshold. + try h.runOk(&.{ "wait", "w1", "--idle", "--timeout", "10s" }); // Timeout: text that never appears exits with the documented code. - try h.runExit(&.{ "wait", "w1", "--for", "NEVER-APPEARS", "--timeout", "300ms" }, 4); + try h.runExit(&.{ "wait", "w1", "--text", "NEVER-APPEARS", "--timeout", "300ms" }, 4); } test "zero-arg boo prints the help overview" { @@ -888,14 +890,14 @@ test "zero-arg boo prints the help overview" { try std.testing.expect(std.mem.indexOf(u8, ls.stdout, "No sessions") != null); } -test "exorcise banishes every session" { +test "kill --all banishes every session" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); defer h.deinit(); try h.startDetached("ghost1", &.{"cat"}); try h.startDetached("ghost2", &.{"cat"}); - try h.runOk(&.{"exorcise"}); + try h.runOk(&.{ "kill", "--all" }); var deadline = Deadline.init(default_timeout_ms); while (true) { @@ -904,7 +906,7 @@ test "exorcise banishes every session" { alloc.free(result.stdout); alloc.free(result.stderr); if (empty) break; - try deadline.tick("sessions survived the exorcism"); + try deadline.tick("sessions survived kill --all"); } try h.runExit(&.{ "peek", "ghost1" }, 3); } @@ -922,14 +924,16 @@ test "exit codes distinguish usage, missing sessions, and ambiguity" { try h.runExit(&.{ "attach", "nosuchzz" }, 3); try h.runExit(&.{ "kill", "nosuchzz" }, 3); - // Destructive commands never guess between sessions. - try h.runExit(&.{"kill"}, 3); + // Session names are required; nothing is guessed. + try h.runExit(&.{"kill"}, 2); + try h.runExit(&.{"attach"}, 2); + try h.runExit(&.{"peek"}, 2); // Usage errors exit 2. try h.runExit(&.{"frobnicate"}, 2); try h.runExit(&.{ "wait", "alpha" }, 2); - try h.runExit(&.{ "send", "-s", "alpha", "--key", "NoSuchKey" }, 2); - try h.runExit(&.{ "send", "-s", "alpha", "text", "--key", "Enter" }, 2); + try h.runExit(&.{ "send", "alpha", "--key", "NoSuchKey" }, 2); + try h.runExit(&.{ "send", "alpha", "--text", "text", "--key", "Enter" }, 2); try h.runExit(&.{ "help", "nosuchtopic" }, 2); try h.runExit(&.{ "kill", "--all", "alpha" }, 2); } @@ -988,8 +992,8 @@ test "agent loop: new, send, wait, peek, kill" { // The documented automation loop, end to end, with no terminal. try h.startDetached("agent", &.{"sh"}); try h.sendLine("agent", "echo result-$((40+2))"); - try h.runOk(&.{ "wait", "agent", "--for", "result-42", "--timeout", "10s" }); - try h.runOk(&.{ "wait", "agent", "--idle", "200ms", "--timeout", "10s" }); + try h.runOk(&.{ "wait", "agent", "--text", "result-42", "--timeout", "10s" }); + try h.runOk(&.{ "wait", "agent", "--idle", "--timeout=10s" }); const content = try h.waitPeekContains("agent", "result-42"); defer alloc.free(content);