use clap::{ArgGroup, Args, Parser, Subcommand}; #[derive(Debug, Parser)] #[command( name = "tty7", version, about = "A terminal workbench, from the command line", long_about = "One name, three artifacts: this CLI, the tty7 GUI (tty7-app), and\n\ tty7-server.\n\n\ The GUI and this CLI are both clients of the server on each machine —\n\ neither needs the other to be running. This CLI is built for coding\n\ agents: every verb is non-interactive, and --json makes the output\n\ machine-readable.\n\n\ An agent reaches the server through this binary, not through a wire\n\ protocol of its own. Inside a tty7 shell, $TTY7_CONFIG_DIR points at\n\ the server, and $TTY7_PANE / $TTY7_WS let the address-taking verbs run\n\ with no address given." )] pub struct Cli { #[arg( short = 'm', long, global = true, value_name = "MACHINE", help = "Route to that machine over the local server's existing link" )] pub machine: Option, #[arg( long, global = true, help = "Machine-readable output, one JSON object per command" )] pub json: bool, #[arg(short, long, global = true, help = "Quiet mode: no output on success")] pub quiet: bool, #[arg( value_name = "PATH", help = "Launch or activate the GUI, opening a new tab at PATH if given" )] pub path: Option, #[command(subcommand)] pub command: Option, } #[derive(Debug, Subcommand)] pub enum Command { #[command(about = "This machine on one screen (= tty7 ws ls)")] Ls, #[command(about = "Start a pane running a command, stream it, pass through the exit code")] Run(RunArgs), #[command(about = "Create a workspace and its first tab, print the id")] New { #[arg(value_name = "PATH")] path: Option, #[arg( long, help = "Also open a window on it, if a GUI is running on this machine" )] open: bool, }, #[command(about = "Split a pane (= tty7 pane split)")] Split(SplitArgs), // The key list is built from the table it is a list *of*, rather than // written out here: a hand-copied vocabulary drifts the first time a key // is added, and this is the text a caller reaches for to learn the names. #[command( about = "Type text into a pane, or send it keystrokes with --key", long_about = crate::keys::send_long_help() )] Send(SendArgs), #[command( about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \ escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \ newest scrollback segment by default" )] Capture(CaptureArgs), #[command(about = "Processes and listening ports inside a pane")] Procs { #[arg(value_name = "%PANE")] target: Option, }, #[command(about = "Every agent on this machine: running, waiting for a reply, idle")] Agents, #[command( about = "Block until a pane's agent needs input, finishes its turn, or the pane \ exits — the orchestration primitive: `tty7 wait %3 && tty7 capture %3 --plain`" )] Wait(WaitArgs), #[command(about = "Stream server events, one per line (NDJSON with --json)")] Events, #[command(about = "Server health at a glance (= tty7 server status)")] Status, #[command( about = "Check this install: socket, dialect, config, versions, agent hooks, links, \ context" )] Doctor, #[command( subcommand, about = "Workspaces: the named trees of tabs and panes the server keeps alive" )] Ws(WsCmd), #[command(subcommand, about = "Tabs within a workspace (@N addresses)")] Tab(TabCmd), #[command(subcommand, about = "Panes: the terminals themselves (%N addresses)")] Pane(PaneCmd), #[command( subcommand, about = "Machines: this one plus every remote the server has a link to" )] Machine(MachineCmd), #[command(subcommand, about = "The tty7-server process on this machine")] Server(ServerCmd), } #[derive(Debug, Args)] pub struct RunArgs { #[arg(long, help = "Keep the pane after the command exits")] pub keep: bool, #[arg(long, value_name = "DIR", help = "Working directory for the command")] pub cwd: Option, #[arg( long, value_name = "WORKSPACE", help = "Sets the pane's TTY7_WS and, with --keep, the workspace the kept pane is \ filed into; defaults to $TTY7_WS inside a tty7 shell" )] pub ws: Option, #[arg( last = true, required = true, value_name = "CMD", help = "The command, after `--`: tty7 run -- cargo test" )] pub cmd: Vec, } #[derive(Debug, Args)] #[command(group = ArgGroup::new("axis").required(true))] pub struct SplitArgs { #[arg( value_name = "%PANE", help = "Pane to split; defaults to $TTY7_PANE inside a tty7 shell" )] pub target: Option, // Spelled out, with the one-letter forms kept as aliases: `--h` alone reads // like a typo for `-h`, and scripts written against it keep working. #[arg( long = "horizontal", visible_alias = "h", group = "axis", help = "Side by side: the new pane goes to the right" )] pub horizontal: bool, #[arg( long = "vertical", visible_alias = "v", group = "axis", help = "Stacked: the new pane goes below" )] pub vertical: bool, #[arg( long, default_value_t = 0.5, value_name = "RATIO", help = "Share kept by the existing pane" )] pub ratio: f32, } #[derive(Debug, Args)] pub struct SendArgs { #[arg(value_name = "%PANE|TEXT")] pub first: Option, #[arg(value_name = "TEXT")] pub second: Option, #[arg( long, help = "Press Enter after the text, or on its own when there is none \ (= --key enter)" )] pub enter: bool, // Text covers "type this command"; it cannot express the keystrokes a pane // asks for once something is already running — the arrow keys a permission // prompt is answered with, the Escape that closes a TUI, the Ctrl-C that // stops a runaway build. Repeatable, and delivered in the order given. #[arg( long = "key", value_name = "KEY", value_parser = crate::keys::parse, help = "Send a keystroke instead of text; repeat for a sequence \ (C-c, escape, up, enter, …). See `tty7 send --help`" )] pub keys: Vec, } /// One resting place a `wait` can end on. Three ontologies meet here, which is /// why the list is longer than the agent ladder: `Idle`/`Working`/`Waiting`/ /// `Done` are the agent status the server keeps from hook events, `NoAgent` /// and `Free` describe the pane itself, and `Exit` is the pane being gone. /// /// `NoAgent` exists because the alternative was worse: a pane with nothing /// reporting used to read as `idle`, so `--until idle` answered "yes, done" /// about a shell that was midway through a build. Saying "no agent is /// reporting here" is both true and the thing a caller needs in order to /// switch to `Free`. #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] pub enum WaitState { Idle, Working, Waiting, Done, /// Nothing is reporting agent status in this pane — a plain shell, or an /// agent whose hooks are not installed. Watch `Free` for those. #[value(name = "no-agent")] NoAgent, /// The pane is back to its bare shell: the foreground command has exited. /// Costs one extra request per poll, so it is only checked when asked for. Free, Exit, } impl WaitState { /// The wire spelling: what `--until` accepts and what `--json` reports. /// Written out rather than derived from the variant name so renaming a /// variant cannot silently rewrite the JSON contract. pub fn name(self) -> &'static str { match self { WaitState::Idle => "idle", WaitState::Working => "working", WaitState::Waiting => "waiting", WaitState::Done => "done", WaitState::NoAgent => "no-agent", WaitState::Free => "free", WaitState::Exit => "exit", } } } #[derive(Debug, Args)] pub struct WaitArgs { #[arg( value_name = "%PANE", help = "Pane to watch; defaults to $TTY7_PANE inside a tty7 shell" )] pub target: Option, // The default is the two states worth waking for plus the one nobody can // wait past: "my peer needs input", "my peer finished", "my peer died". // `free` is deliberately not in it — it is the answer for a pane running a // command rather than an agent, and it costs a second request per poll. #[arg( long, value_name = "STATE,…", value_delimiter = ',', default_values = ["waiting", "done", "exit"], help = "States that end the wait; `free` waits for a plain command to finish" )] pub until: Vec, #[arg( long, value_name = "SECS", help = "Give up after this many seconds, with exit code 124 (the `timeout(1)` \ convention, so scripts can tell \"not yet\" from \"broken\")" )] pub timeout: Option, // The agent status the server keeps is a level, not an edge: `done` stands // until the next turn starts, `waiting` until the agent moves again. So a // wait issued right after a `send` would answer with last turn's status // before the agent has even read the input. `--changed` refuses the state // the pane was already in, which is what a delegation loop wants on every // round after the first. // // `free` is level-triggered the same way and needs the same guard, but a // shell that goes free → busy → free returns to the state it started in, // so comparing against a baseline would miss it. There the rule is instead // "we watched something run": `free` only counts once the pane has been // seen busy, which is exactly "the command I just sent has finished". #[arg( long, help = "Ignore the state the pane was already in — only wake on a state it \ moved into after the wait began; with `free`, wait until something \ has actually run (use this after `send`)" )] pub changed: bool, #[arg( long, value_name = "MS", default_value_t = 500, value_parser = clap::value_parser!(u64).range(50..=3_600_000), help = "Poll every this many milliseconds" )] pub interval: u64, } #[derive(Debug, Args)] pub struct CaptureArgs { #[arg( value_name = "%PANE", help = "Pane to read; defaults to $TTY7_PANE inside a tty7 shell" )] pub target: Option, #[arg( long, help = "Print the whole scrollback ring; the ring splits into segments on resize, \ and without this flag only the last segment is printed (for a \ never-resized pane the two are identical)" )] pub scrollback: bool, #[arg( long, help = "Replay the output through a terminal and print the resulting text: no \ escapes, wrapped lines rejoined, overwrites and cursor moves applied" )] pub plain: bool, } #[derive(Debug, Subcommand)] pub enum WsCmd { #[command(about = "Every workspace on this machine")] Ls, #[command(about = "One workspace as a tree: tabs, splits, panes")] Tree { #[arg(value_name = "WORKSPACE")] ws: Option, }, #[command(about = "Create an empty workspace")] New { #[arg(value_name = "NAME")] name: Option, }, #[command(about = "Name or rename a workspace")] Rename { #[arg(value_name = "WORKSPACE")] ws: String, #[arg(value_name = "NAME")] name: String, }, #[command(about = "End the shells, keep the layout (not implemented yet)")] Stop { #[arg(value_name = "WORKSPACE")] ws: String, }, #[command(about = "Delete the workspace and hang up its panes")] Rm { #[arg(value_name = "WORKSPACE")] ws: String, }, #[command(about = "Become the workspace's controlling client")] Attach { #[arg(value_name = "WORKSPACE")] ws: String, }, #[command(about = "Let go without interrupting anything")] Detach { #[arg(value_name = "WORKSPACE")] ws: String, }, } #[derive(Debug, Subcommand)] pub enum TabCmd { #[command(about = "Tabs of a workspace")] Ls { #[arg(value_name = "WORKSPACE")] ws: Option, }, #[command(about = "Add a tab with a fresh shell")] New { #[arg(value_name = "WORKSPACE")] ws: Option, #[arg( long, value_name = "DIR", help = "Working directory for the tab's shell" )] cwd: Option, }, #[command(about = "Close a tab and every pane in it")] Close { #[arg(value_name = "@TAB")] tab: String, }, #[command(about = "Name or rename a tab")] Rename { #[arg(value_name = "@TAB")] tab: String, #[arg(value_name = "NAME")] name: String, }, #[command(about = "Move a tab to another position in its workspace")] Move { #[arg(value_name = "@TAB")] tab: String, #[arg(value_name = "INDEX")] index: u64, }, } #[derive(Debug, Subcommand)] pub enum PaneCmd { #[command(about = "Panes of a workspace, or of the whole machine")] Ls { #[arg(value_name = "WORKSPACE")] ws: Option, #[arg( long, conflicts_with = "ws", help = "Every pane the server runs, including ones no workspace holds \ (an interrupted `run` leaves those behind)" )] all: bool, }, #[command(about = "Split a pane in two")] Split(SplitArgs), #[command(about = "Close panes; their shells are hung up")] Close { #[arg(value_name = "%PANE", help = "Panes to close; defaults to $TTY7_PANE")] targets: Vec, // `pane ls --all` has been able to *show* the panes an interrupted // `run` leaves behind for a while, and the only way to act on that was // to read ids off the table and close them one at a time. The CLI // creates these; it should be able to clear them. #[arg( long, conflicts_with = "targets", help = "Close every pane no workspace holds — what an interrupted `run` \ leaves behind. Lists them; pass --json for the ids" )] orphans: bool, }, } #[derive(Debug, Subcommand)] pub enum MachineCmd { #[command(about = "This machine and every linked remote")] Ls, #[command(about = "Open a link to a machine from an SSH profile (not implemented yet)")] Connect { #[arg(value_name = "PROFILE")] profile: String, }, #[command(about = "Drop the link; the remote server keeps running (not implemented yet)")] Disconnect { #[arg(value_name = "MACHINE")] machine: String, }, } #[derive(Debug, Subcommand)] pub enum ServerCmd { #[command(about = "Start the server on this machine")] Start, #[command(about = "Stop the server; sessions end")] Stop, #[command(about = "Restart in place; sessions keep running (--hard: stop, then start)")] Restart { #[arg(long, help = "Stop, then start — every session ends")] hard: bool, }, #[command(about = "Version, uptime, panes, links")] Status, #[command(about = "Tail the server log")] Logs, } #[cfg(test)] mod tests { use super::*; use clap::error::ErrorKind; fn parse(args: &[&str]) -> Cli { Cli::try_parse_from(args).expect("this invocation is part of the documented grammar") } #[test] fn bare_tty7_is_the_gui_launcher() { let cli = parse(&["tty7"]); assert!(cli.command.is_none(), "no subcommand means launch the GUI"); assert!(cli.path.is_none()); } #[test] fn a_path_that_is_not_a_verb_still_launches_the_gui() { let cli = parse(&["tty7", "C:\\Users\\me\\proj"]); assert!(cli.command.is_none()); assert_eq!( cli.path.as_deref(), Some(std::path::Path::new("C:\\Users\\me\\proj")) ); } #[cfg(windows)] #[test] fn a_gui_path_preserves_native_windows_arguments() { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt as _; let native_path = OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xD800, b'x' as u16]); let cli = Cli::try_parse_from([OsString::from("tty7"), native_path.clone()]) .expect("PathBuf arguments accept native Windows strings"); assert_eq!(cli.path, Some(std::path::PathBuf::from(native_path))); } #[test] fn every_top_level_verb_parses() { assert!(matches!(parse(&["tty7", "ls"]).command, Some(Command::Ls))); assert!(matches!( parse(&["tty7", "new"]).command, Some(Command::New { path: None, .. }) )); assert!(matches!( parse(&["tty7", "new", "C:\\proj"]).command, Some(Command::New { path: Some(p), .. }) if p == "C:\\proj" )); assert!(matches!( parse(&["tty7", "agents"]).command, Some(Command::Agents) )); assert!(matches!( parse(&["tty7", "events"]).command, Some(Command::Events) )); assert!(matches!( parse(&["tty7", "status"]).command, Some(Command::Status) )); assert!(matches!( parse(&["tty7", "doctor"]).command, Some(Command::Doctor) )); assert!(matches!( parse(&["tty7", "procs", "%3"]).command, Some(Command::Procs { target: Some(t) }) if t == "%3" )); } #[test] fn run_takes_the_command_after_a_double_dash() { let cli = parse(&["tty7", "run", "--", "cargo", "test", "--keep"]); let Some(Command::Run(args)) = cli.command else { panic!("run did not parse"); }; assert_eq!(args.cmd, vec!["cargo", "test", "--keep"]); assert!( !args.keep, "--keep after -- belongs to the child, not to tty7" ); let cli = parse(&["tty7", "run", "--keep", "--cwd", "C:\\proj", "--", "make"]); let Some(Command::Run(args)) = cli.command else { panic!("run did not parse"); }; assert!(args.keep); assert_eq!(args.cwd.as_deref(), Some("C:\\proj")); assert_eq!(args.cmd, vec!["make"]); } #[test] fn run_without_a_command_is_a_usage_error() { let err = Cli::try_parse_from(["tty7", "run"]).unwrap_err(); assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument); } #[test] fn split_requires_exactly_one_axis() { let cli = parse(&["tty7", "split", "--v"]); let Some(Command::Split(args)) = cli.command else { panic!("split did not parse"); }; assert!(args.vertical && !args.horizontal); assert!(args.target.is_none(), "the pane comes from $TTY7_PANE"); assert_eq!(args.ratio, 0.5); let cli = parse(&["tty7", "pane", "split", "%42", "--h", "--ratio", "0.3"]); let Some(Command::Pane(PaneCmd::Split(args))) = cli.command else { panic!("pane split did not parse"); }; assert!(args.horizontal); assert_eq!(args.target.as_deref(), Some("%42")); assert_eq!(args.ratio, 0.3); let err = Cli::try_parse_from(["tty7", "split", "%1"]).unwrap_err(); assert_eq!( err.kind(), ErrorKind::MissingRequiredArgument, "an axis must be chosen" ); let err = Cli::try_parse_from(["tty7", "split", "--h", "--v"]).unwrap_err(); assert_eq!( err.kind(), ErrorKind::ArgumentConflict, "the two axes exclude each other" ); } #[test] fn send_takes_an_optional_address_then_text() { let cli = parse(&["tty7", "send", "%42", "make -j8", "--enter"]); let Some(Command::Send(args)) = cli.command else { panic!("send did not parse"); }; assert_eq!(args.first.as_deref(), Some("%42")); assert_eq!(args.second.as_deref(), Some("make -j8")); assert!(args.enter); let cli = parse(&["tty7", "send", "make -j8"]); let Some(Command::Send(args)) = cli.command else { panic!("send did not parse"); }; assert_eq!(args.first.as_deref(), Some("make -j8")); assert!(args.second.is_none()); } /// `--key` is the reason TEXT became optional: `send %42 --key C-c` has an /// address and no text, which every other shape would read as a mistake. #[test] fn send_accepts_keys_with_or_without_text() { let cli = parse(&["tty7", "send", "%42", "--key", "C-c"]); let Some(Command::Send(args)) = cli.command else { panic!("send did not parse"); }; assert_eq!(args.first.as_deref(), Some("%42")); assert!(args.second.is_none()); assert_eq!(args.keys.len(), 1); assert_eq!(args.keys[0].bytes, vec![0x03]); // A sequence keeps the order it was written in — that is the whole // point for a menu that has to be walked down and then confirmed. let cli = parse(&["tty7", "send", "--key", "down", "--key", "enter"]); let Some(Command::Send(args)) = cli.command else { panic!("send did not parse"); }; assert!(args.first.is_none(), "the pane comes from $TTY7_PANE"); let names: Vec<&str> = args.keys.iter().map(|k| k.name.as_str()).collect(); assert_eq!(names, vec!["down", "enter"]); // An unknown key is a usage error, caught before anything is sent — // half a key sequence in a live pane is worse than none. let err = Cli::try_parse_from(["tty7", "send", "--key", "f7"]).unwrap_err(); assert_eq!(err.exit_code(), 2); } #[test] fn every_ws_verb_parses() { assert!(matches!( parse(&["tty7", "ws", "ls"]).command, Some(Command::Ws(WsCmd::Ls)) )); assert!(matches!( parse(&["tty7", "ws", "tree", "api"]).command, Some(Command::Ws(WsCmd::Tree { ws: Some(w) })) if w == "api" )); assert!(matches!( parse(&["tty7", "ws", "new", "api"]).command, Some(Command::Ws(WsCmd::New { name: Some(n) })) if n == "api" )); assert!(matches!( parse(&["tty7", "ws", "rename", "api", "web"]).command, Some(Command::Ws(WsCmd::Rename { ws, name })) if ws == "api" && name == "web" )); assert!(matches!( parse(&["tty7", "ws", "stop", "api"]).command, Some(Command::Ws(WsCmd::Stop { ws })) if ws == "api" )); assert!(matches!( parse(&["tty7", "ws", "rm", "api"]).command, Some(Command::Ws(WsCmd::Rm { ws })) if ws == "api" )); assert!(matches!( parse(&["tty7", "ws", "attach", "api"]).command, Some(Command::Ws(WsCmd::Attach { ws })) if ws == "api" )); assert!(matches!( parse(&["tty7", "ws", "detach", "api"]).command, Some(Command::Ws(WsCmd::Detach { ws })) if ws == "api" )); } #[test] fn every_tab_verb_parses() { assert!(matches!( parse(&["tty7", "tab", "ls", "api"]).command, Some(Command::Tab(TabCmd::Ls { ws: Some(w) })) if w == "api" )); assert!(matches!( parse(&["tty7", "tab", "new", "api", "--cwd", "C:\\proj"]).command, Some(Command::Tab(TabCmd::New { ws: Some(w), cwd: Some(c) })) if w == "api" && c == "C:\\proj" )); assert!(matches!( parse(&["tty7", "tab", "close", "@7"]).command, Some(Command::Tab(TabCmd::Close { tab })) if tab == "@7" )); assert!(matches!( parse(&["tty7", "tab", "rename", "@7", "build"]).command, Some(Command::Tab(TabCmd::Rename { tab, name })) if tab == "@7" && name == "build" )); assert!(matches!( parse(&["tty7", "tab", "move", "@7", "0"]).command, Some(Command::Tab(TabCmd::Move { tab, index: 0 })) if tab == "@7" )); } #[test] fn every_pane_verb_parses() { assert!(matches!( parse(&["tty7", "pane", "ls"]).command, Some(Command::Pane(PaneCmd::Ls { ws: None, all: false })) )); assert!(matches!( parse(&["tty7", "pane", "close", "%9"]).command, Some(Command::Pane(PaneCmd::Close { targets, orphans: false })) if targets == ["%9"] )); // Several at once, because a cleanup usually has more than one thing // to clean up — and the whole registry with `--orphans`. assert!(matches!( parse(&["tty7", "pane", "close", "%9", "%10"]).command, Some(Command::Pane(PaneCmd::Close { targets, .. })) if targets == ["%9", "%10"] )); assert!(matches!( parse(&["tty7", "pane", "close", "--orphans"]).command, Some(Command::Pane(PaneCmd::Close { targets, orphans: true })) if targets.is_empty() )); // Naming panes *and* asking for every orphan is a contradiction: which // set did the caller mean? Refuse rather than pick one. let err = Cli::try_parse_from(["tty7", "pane", "close", "%9", "--orphans"]).unwrap_err(); assert_eq!(err.kind(), ErrorKind::ArgumentConflict); } #[test] fn every_machine_and_server_verb_parses() { assert!(matches!( parse(&["tty7", "machine", "ls"]).command, Some(Command::Machine(MachineCmd::Ls)) )); assert!(matches!( parse(&["tty7", "machine", "connect", "devbox"]).command, Some(Command::Machine(MachineCmd::Connect { profile })) if profile == "devbox" )); assert!(matches!( parse(&["tty7", "machine", "disconnect", "devbox"]).command, Some(Command::Machine(MachineCmd::Disconnect { machine })) if machine == "devbox" )); for (verb, want) in [ ("start", ServerCmd::Start), ("stop", ServerCmd::Stop), ("restart", ServerCmd::Restart { hard: false }), ("status", ServerCmd::Status), ("logs", ServerCmd::Logs), ] { let cli = parse(&["tty7", "server", verb]); let Some(Command::Server(got)) = cli.command else { panic!("server {verb} did not parse"); }; assert_eq!( std::mem::discriminant(&got), std::mem::discriminant(&want), "server {verb} parsed as the wrong verb" ); } assert!(matches!( parse(&["tty7", "server", "restart", "--hard"]).command, Some(Command::Server(ServerCmd::Restart { hard: true })) )); } #[test] fn global_flags_are_accepted_anywhere() { let cli = parse(&["tty7", "-m", "devbox", "ls", "--json"]); assert_eq!(cli.machine.as_deref(), Some("devbox")); assert!(cli.json); assert!(matches!(cli.command, Some(Command::Ls))); let cli = parse(&["tty7", "ws", "ls", "-q", "-m", "devbox"]); assert!(cli.quiet); assert_eq!(cli.machine.as_deref(), Some("devbox")); let cli = parse(&["tty7", "capture", "%3", "--scrollback", "--json"]); assert!(cli.json); let Some(Command::Capture(args)) = cli.command else { panic!("capture did not parse"); }; assert!(args.scrollback); assert!(!args.plain, "raw bytes stay the default"); assert_eq!(args.target.as_deref(), Some("%3")); } #[test] fn capture_plain_is_its_own_flag_and_composes_with_scrollback() { // The two answer different questions — how much to replay, and whether // to replay it through a grid — so neither implies or excludes the other. let Some(Command::Capture(args)) = parse(&["tty7", "capture", "--plain"]).command else { panic!("capture did not parse"); }; assert!(args.plain && !args.scrollback); assert!(args.target.is_none(), "the pane comes from $TTY7_PANE"); let Some(Command::Capture(args)) = parse(&["tty7", "capture", "%3", "--plain", "--scrollback"]).command else { panic!("capture did not parse"); }; assert!(args.plain && args.scrollback); } #[test] fn usage_errors_exit_with_code_2() { let err = Cli::try_parse_from(["tty7", "ws", "frobnicate"]).unwrap_err(); assert_eq!( err.exit_code(), 2, "clap's usage-error exit code is the CLI contract" ); let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err(); assert_eq!(err.exit_code(), 2); } }