use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[must_use]
pub fn parse_key_notation(s: &str) -> Option<u8> {
let s = s.trim().to_lowercase();
if let Some(rest) = s.strip_prefix("ctrl-").or_else(|| s.strip_prefix("ctrl+")) {
if rest.len() == 1 {
let c = rest.chars().next()?;
if c.is_ascii_alphabetic() {
return Some((c as u8) - b'a' + 1);
}
}
return None;
}
if let Some(rest) = s.strip_prefix('^') {
if rest.len() == 1 {
let c = rest.chars().next()?;
if c.is_ascii_alphabetic() {
return Some((c as u8) - b'a' + 1);
}
}
return None;
}
if s.len() == 1 {
return Some(s.as_bytes()[0]);
}
None
}
pub fn split_send_positionals(
id: Option<String>,
payload: Option<String>,
selector_used: bool,
) -> Result<(Option<String>, Option<String>), String> {
if !selector_used {
return Ok((id, payload));
}
if id.is_some() && payload.is_some() {
return Err("specify either an agent ID or --label/--proc/--all, not both".to_string());
}
Ok((None, payload.or(id)))
}
#[must_use]
pub fn split_send_keys_positionals(
id: Option<String>,
mut keys: Vec<String>,
selector_used: bool,
) -> (Option<String>, Vec<String>) {
if selector_used && let Some(first) = id {
keys.insert(0, first);
return (None, keys);
}
(id, keys)
}
#[must_use]
pub fn parse_key_sequence(s: &str) -> Option<Vec<u8>> {
let s = s.trim().to_lowercase();
if let Some(byte) = parse_key_notation(&s) {
return Some(vec![byte]);
}
match s.as_str() {
"up" => Some(vec![0x1b, 0x5b, 0x41]), "down" => Some(vec![0x1b, 0x5b, 0x42]), "right" => Some(vec![0x1b, 0x5b, 0x43]), "left" => Some(vec![0x1b, 0x5b, 0x44]),
"enter" | "return" => Some(vec![0x0d]), "tab" => Some(vec![0x09]), "escape" | "esc" => Some(vec![0x1b]), "backspace" => Some(vec![0x7f]), "delete" | "del" => Some(vec![0x1b, 0x5b, 0x33, 0x7e]), "space" => Some(vec![0x20]),
"home" => Some(vec![0x1b, 0x5b, 0x48]), "end" => Some(vec![0x1b, 0x5b, 0x46]), "pageup" | "pgup" => Some(vec![0x1b, 0x5b, 0x35, 0x7e]), "pagedown" | "pgdn" | "pgdown" => Some(vec![0x1b, 0x5b, 0x36, 0x7e]),
"f1" => Some(vec![0x1b, 0x4f, 0x50]), "f2" => Some(vec![0x1b, 0x4f, 0x51]), "f3" => Some(vec![0x1b, 0x4f, 0x52]), "f4" => Some(vec![0x1b, 0x4f, 0x53]),
_ => None,
}
}
#[derive(Debug, Parser)]
#[command(name = "vessel", version, about)]
pub struct Cli {
#[arg(long, env = "VESSEL_SOCKET")]
pub socket: Option<PathBuf>,
#[arg(short, long)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Spawn {
#[arg(long, default_value = "24")]
rows: u16,
#[arg(long, default_value = "80")]
cols: u16,
#[arg(long, short)]
name: Option<String>,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
timeout: Option<u64>,
#[arg(long)]
max_output: Option<u64>,
#[arg(long, short, value_name = "KEY=VALUE")]
env: Vec<String>,
#[arg(long, value_delimiter = ',')]
env_inherit: Vec<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long, value_name = "SIZE")]
memory_limit: Option<String>,
#[arg(long)]
no_resize: bool,
#[arg(long)]
record: bool,
#[arg(long)]
after: Vec<String>,
#[arg(long)]
wait_for: Vec<String>,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
#[arg(last = true, required = true)]
cmd: Vec<String>,
},
List {
#[arg(long)]
all: bool,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, default_value = "pretty")]
format: String,
#[arg(long, hide = true)]
json: bool,
},
Kill {
id: Option<String>,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
all: bool,
#[arg(long, short)]
force: bool,
#[arg(long, short)]
proc: Option<String>,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
},
Signal {
id: Option<String>,
#[arg(long, short)]
signal: String,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
all: bool,
#[arg(long, short)]
proc: Option<String>,
},
Send {
id: Option<String>,
text: Option<String>,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
all: bool,
#[arg(long)]
proc: Option<String>,
#[arg(short = 'p', long, alias = "bracketed")]
paste: bool,
#[arg(short = 'n', long)]
newline: bool,
#[arg(short = 'e', long)]
enter: bool,
#[arg(long, value_name = "MS")]
submit_delay_ms: Option<u64>,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
},
SendBytes {
id: Option<String>,
hex: Option<String>,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
all: bool,
#[arg(long)]
proc: Option<String>,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
},
SendKeys {
id: Option<String>,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
all: bool,
#[arg(long)]
proc: Option<String>,
keys: Vec<String>,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
},
Tail {
id: String,
#[arg(short = 'n', long, default_value = "10")]
lines: usize,
#[arg(short, long)]
follow: bool,
#[arg(long)]
raw: bool,
#[arg(long)]
replay: bool,
},
Dump {
id: String,
#[arg(long)]
since: Option<u64>,
#[arg(long, default_value = "text")]
format: String,
},
Snapshot {
id: String,
#[arg(long)]
raw: bool,
#[arg(long)]
diff: Option<String>,
},
Attach {
id: String,
#[arg(long)]
readonly: bool,
#[arg(long, default_value = "ctrl-g")]
detach_key: String,
},
Server {
#[arg(long)]
daemon: bool,
},
Shutdown,
#[command(after_help = "\
SUBAGENT WORKFLOW:
Spawn a child, wait for it to finish, then check its exit code:
child=$(vessel spawn --name parent/child -- my-command --flag)
vessel wait --exited \"$child\"
echo \"Exit code: $?\"
Wait for multiple agents to exit:
vessel wait --exited worker-1 worker-2 worker-3
Return when any listed agent exits:
vessel wait --exited --any worker-1 worker-2 worker-3
Combined with output conditions (single agent only):
vessel wait --exited --contains 'done' --print my-agent")]
Wait {
id: Vec<String>,
#[arg(long)]
exited: bool,
#[arg(long)]
any: bool,
#[arg(long)]
contains: Option<String>,
#[arg(long)]
pattern: Option<String>,
#[arg(long, value_name = "MILLIS")]
stable: Option<u64>,
#[arg(long, short, default_value = "0")]
timeout: u64,
#[arg(long, short)]
print: bool,
},
Assert {
id: String,
#[arg(long)]
contains: Option<String>,
#[arg(long)]
not_contains: Option<String>,
#[arg(long)]
pattern: Option<String>,
#[arg(long, short, default_value = "0")]
timeout: u64,
},
Exec {
#[arg(long, default_value = "24")]
rows: u16,
#[arg(long, default_value = "80")]
cols: u16,
#[arg(long, short, default_value = "30")]
timeout: u64,
#[arg(long, default_value = "sh")]
shell: String,
#[arg(last = true, required = true)]
cmd: Vec<String>,
},
Doctor,
Events {
#[arg(long, short, value_delimiter = ',')]
filter: Vec<String>,
#[arg(long)]
output: bool,
},
Subscribe {
#[arg(long, short)]
id: Vec<String>,
#[arg(long, short)]
label: Vec<String>,
#[arg(long, short)]
prefix: bool,
#[arg(long, default_value = "raw")]
format: String,
},
View {
#[arg(long, default_value = "tmux")]
mux: String,
#[arg(long, default_value = "panes")]
mode: String,
#[arg(long)]
no_resize: bool,
#[arg(long, short)]
label: Vec<String>,
#[arg(long)]
new_session: bool,
},
Resize {
id: String,
#[arg(long)]
rows: u16,
#[arg(long)]
cols: u16,
#[arg(long)]
clear: bool,
},
#[command(hide = true)]
ResizePanes {
#[arg(long, default_value = "panes")]
mode: String,
},
Recording {
id: String,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
},
GenTest {
id: String,
},
Env {
id: String,
#[arg(long)]
format: Option<String>,
#[arg(long, hide = true)]
json: bool,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_key_notation_ctrl_format() {
assert_eq!(parse_key_notation("ctrl-a"), Some(0x01));
assert_eq!(parse_key_notation("ctrl-g"), Some(0x07));
assert_eq!(parse_key_notation("ctrl-z"), Some(0x1a));
assert_eq!(parse_key_notation("ctrl+a"), Some(0x01));
assert_eq!(parse_key_notation("CTRL-A"), Some(0x01));
assert_eq!(parse_key_notation("Ctrl-G"), Some(0x07));
}
#[test]
fn test_parse_key_notation_caret_format() {
assert_eq!(parse_key_notation("^a"), Some(0x01));
assert_eq!(parse_key_notation("^g"), Some(0x07));
assert_eq!(parse_key_notation("^G"), Some(0x07));
assert_eq!(parse_key_notation("^Z"), Some(0x1a));
}
#[test]
fn test_parse_key_notation_single_char() {
assert_eq!(parse_key_notation("d"), Some(b'd'));
assert_eq!(parse_key_notation("x"), Some(b'x'));
assert_eq!(parse_key_notation("D"), Some(b'd'));
}
#[test]
fn test_parse_key_notation_invalid() {
assert_eq!(parse_key_notation("ctrl-"), None);
assert_eq!(parse_key_notation("ctrl-ab"), None);
assert_eq!(parse_key_notation("^"), None);
assert_eq!(parse_key_notation("^ab"), None);
assert_eq!(parse_key_notation("ab"), None);
assert_eq!(parse_key_notation(""), None);
}
#[test]
fn test_parse_key_sequence_arrow_keys() {
assert_eq!(parse_key_sequence("up"), Some(vec![0x1b, 0x5b, 0x41]));
assert_eq!(parse_key_sequence("down"), Some(vec![0x1b, 0x5b, 0x42]));
assert_eq!(parse_key_sequence("right"), Some(vec![0x1b, 0x5b, 0x43]));
assert_eq!(parse_key_sequence("left"), Some(vec![0x1b, 0x5b, 0x44]));
assert_eq!(parse_key_sequence("UP"), Some(vec![0x1b, 0x5b, 0x41])); }
#[test]
fn test_parse_key_sequence_special_keys() {
assert_eq!(parse_key_sequence("enter"), Some(vec![0x0d]));
assert_eq!(parse_key_sequence("return"), Some(vec![0x0d]));
assert_eq!(parse_key_sequence("tab"), Some(vec![0x09]));
assert_eq!(parse_key_sequence("escape"), Some(vec![0x1b]));
assert_eq!(parse_key_sequence("esc"), Some(vec![0x1b]));
assert_eq!(parse_key_sequence("backspace"), Some(vec![0x7f]));
assert_eq!(
parse_key_sequence("delete"),
Some(vec![0x1b, 0x5b, 0x33, 0x7e])
);
assert_eq!(parse_key_sequence("space"), Some(vec![0x20]));
assert_eq!(parse_key_sequence("SPACE"), Some(vec![0x20]));
assert_eq!(parse_key_sequence(" space "), Some(vec![0x20]));
}
#[test]
fn test_parse_key_sequence_navigation() {
assert_eq!(parse_key_sequence("home"), Some(vec![0x1b, 0x5b, 0x48]));
assert_eq!(parse_key_sequence("end"), Some(vec![0x1b, 0x5b, 0x46]));
assert_eq!(
parse_key_sequence("pageup"),
Some(vec![0x1b, 0x5b, 0x35, 0x7e])
);
assert_eq!(
parse_key_sequence("pagedown"),
Some(vec![0x1b, 0x5b, 0x36, 0x7e])
);
assert_eq!(
parse_key_sequence("pgup"),
Some(vec![0x1b, 0x5b, 0x35, 0x7e])
);
}
#[test]
fn test_parse_key_sequence_function_keys() {
assert_eq!(parse_key_sequence("f1"), Some(vec![0x1b, 0x4f, 0x50]));
assert_eq!(parse_key_sequence("f2"), Some(vec![0x1b, 0x4f, 0x51]));
assert_eq!(parse_key_sequence("f3"), Some(vec![0x1b, 0x4f, 0x52]));
assert_eq!(parse_key_sequence("f4"), Some(vec![0x1b, 0x4f, 0x53]));
}
#[test]
fn test_parse_key_sequence_control_chars() {
assert_eq!(parse_key_sequence("ctrl-c"), Some(vec![0x03]));
assert_eq!(parse_key_sequence("ctrl-d"), Some(vec![0x04]));
assert_eq!(parse_key_sequence("^c"), Some(vec![0x03]));
}
#[test]
fn test_parse_key_sequence_single_chars() {
assert_eq!(parse_key_sequence("a"), Some(vec![b'a']));
assert_eq!(parse_key_sequence("x"), Some(vec![b'x']));
assert_eq!(parse_key_sequence("5"), Some(vec![b'5']));
}
#[test]
fn test_parse_key_sequence_invalid() {
assert_eq!(parse_key_sequence("invalid-key"), None);
assert_eq!(parse_key_sequence("arrow-up"), None);
assert_eq!(parse_key_sequence(""), None);
}
#[test]
fn positionals_unchanged_without_a_selector() {
let got = split_send_positionals(Some("agent".into()), Some("hello".into()), false);
assert_eq!(got, Ok((Some("agent".into()), Some("hello".into()))));
}
#[test]
fn id_required_form_allows_a_missing_payload() {
let got = split_send_positionals(Some("agent".into()), None, false);
assert_eq!(got, Ok((Some("agent".into()), None)));
}
#[test]
fn selector_shifts_the_payload_out_of_the_id_slot() {
let got = split_send_positionals(Some("hello".into()), None, true);
assert_eq!(got, Ok((None, Some("hello".into()))));
}
#[test]
fn selector_with_no_payload_stays_empty() {
let got = split_send_positionals(None, None, true);
assert_eq!(got, Ok((None, None)));
}
#[test]
fn selector_plus_explicit_id_is_rejected() {
let err = split_send_positionals(Some("agent".into()), Some("hello".into()), true)
.expect_err("id + selector must be rejected");
assert!(err.contains("not both"), "unexpected error: {err}");
}
#[test]
fn send_keys_selector_reclaims_the_first_key() {
let (id, keys) =
split_send_keys_positionals(Some("ctrl-c".into()), vec!["enter".into()], true);
assert_eq!(id, None);
assert_eq!(keys, vec!["ctrl-c".to_string(), "enter".to_string()]);
}
#[test]
fn send_keys_without_a_selector_keeps_the_id() {
let (id, keys) =
split_send_keys_positionals(Some("agent".into()), vec!["up".into()], false);
assert_eq!(id, Some("agent".into()));
assert_eq!(keys, vec!["up".to_string()]);
}
}