2024-04-29 16:44:18 +02:00
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
use std::num::ParseIntError;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
2025-05-08 11:46:11 +02:00
|
|
|
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
|
2025-03-12 13:15:02 +01:00
|
|
|
|
2024-04-29 16:44:18 +02:00
|
|
|
pub const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080";
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Parser)]
|
|
|
|
|
#[clap(author, version, about)]
|
|
|
|
|
#[command(name = "asciinema")]
|
|
|
|
|
pub struct Cli {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
pub command: Commands,
|
|
|
|
|
|
|
|
|
|
/// asciinema server URL
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(long, global = true, display_order = 100, value_name = "URL")]
|
2024-04-29 16:44:18 +02:00
|
|
|
pub server_url: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Quiet mode, i.e. suppress diagnostic messages
|
2025-05-03 17:28:10 +02:00
|
|
|
#[clap(short, long, global = true, display_order = 101)]
|
2024-04-29 16:44:18 +02:00
|
|
|
pub quiet: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
pub enum Commands {
|
|
|
|
|
/// Record a terminal session
|
|
|
|
|
Rec(Record),
|
|
|
|
|
|
|
|
|
|
/// Replay a terminal session
|
|
|
|
|
Play(Play),
|
|
|
|
|
|
|
|
|
|
/// Stream a terminal session
|
|
|
|
|
Stream(Stream),
|
|
|
|
|
|
2025-03-12 13:15:02 +01:00
|
|
|
/// Record and/or stream a terminal session
|
|
|
|
|
Session(Session),
|
|
|
|
|
|
2024-04-29 16:44:18 +02:00
|
|
|
/// Concatenate multiple recordings
|
|
|
|
|
Cat(Cat),
|
|
|
|
|
|
|
|
|
|
/// Convert a recording into another format
|
|
|
|
|
Convert(Convert),
|
|
|
|
|
|
|
|
|
|
/// Upload a recording to an asciinema server
|
|
|
|
|
Upload(Upload),
|
|
|
|
|
|
|
|
|
|
/// Authenticate this CLI with an asciinema server account
|
|
|
|
|
Auth(Auth),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Record {
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Output file path
|
2025-05-03 17:28:10 +02:00
|
|
|
pub output_path: String,
|
2024-04-29 16:44:18 +02:00
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Output file format [default: asciicast-v3]
|
|
|
|
|
#[arg(short = 'f', long, value_enum, value_name = "FORMAT")]
|
|
|
|
|
pub output_format: Option<Format>,
|
|
|
|
|
|
|
|
|
|
/// Command to start in the session [default: $SHELL]
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub command: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Enable input (keys) recording
|
2024-04-29 16:44:18 +02:00
|
|
|
#[arg(long, short = 'I', alias = "stdin")]
|
2025-05-03 17:28:10 +02:00
|
|
|
pub rec_input: bool,
|
|
|
|
|
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Comma-separated list of env vars to capture [default: SHELL]
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(long, value_name = "VARS")]
|
|
|
|
|
pub rec_env: Option<String>,
|
2024-04-29 16:44:18 +02:00
|
|
|
|
|
|
|
|
/// Append to an existing recording file
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub append: bool,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Overwrite output file if it already exists
|
2024-04-29 16:44:18 +02:00
|
|
|
#[arg(long, conflicts_with = "append")]
|
|
|
|
|
pub overwrite: bool,
|
|
|
|
|
|
|
|
|
|
/// Title of the recording
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Limit idle time to a given number of seconds
|
|
|
|
|
#[arg(short, long, value_name = "SECS")]
|
|
|
|
|
pub idle_time_limit: Option<f64>,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Headless mode, i.e. don't use TTY for input/output
|
2024-05-04 22:39:35 +02:00
|
|
|
#[arg(long)]
|
|
|
|
|
pub headless: bool,
|
|
|
|
|
|
2025-03-12 13:15:02 +01:00
|
|
|
/// Override terminal size for the session
|
2025-05-07 17:15:09 +02:00
|
|
|
#[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size)]
|
|
|
|
|
pub window_size: Option<(Option<u16>, Option<u16>)>,
|
2024-05-04 22:19:32 +02:00
|
|
|
|
2024-04-29 16:44:18 +02:00
|
|
|
#[arg(long, hide = true)]
|
2024-09-20 10:50:47 +02:00
|
|
|
pub cols: Option<u16>,
|
2024-04-29 16:44:18 +02:00
|
|
|
|
|
|
|
|
#[arg(long, hide = true)]
|
2024-09-20 10:50:47 +02:00
|
|
|
pub rows: Option<u16>,
|
2025-05-03 17:28:10 +02:00
|
|
|
|
|
|
|
|
#[arg(long, hide = true)]
|
|
|
|
|
pub raw: bool,
|
2024-04-29 16:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Play {
|
|
|
|
|
#[arg(value_name = "FILENAME_OR_URL")]
|
|
|
|
|
pub filename: String,
|
|
|
|
|
|
|
|
|
|
/// Limit idle time to a given number of seconds
|
|
|
|
|
#[arg(short, long, value_name = "SECS")]
|
|
|
|
|
pub idle_time_limit: Option<f64>,
|
|
|
|
|
|
|
|
|
|
/// Set playback speed
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub speed: Option<f64>,
|
|
|
|
|
|
|
|
|
|
/// Loop loop loop loop
|
|
|
|
|
#[arg(short, long, name = "loop")]
|
|
|
|
|
pub loop_: bool,
|
|
|
|
|
|
|
|
|
|
/// Automatically pause on markers
|
|
|
|
|
#[arg(short = 'm', long)]
|
|
|
|
|
pub pause_on_markers: bool,
|
2024-09-19 10:02:43 -07:00
|
|
|
|
2025-05-06 21:04:42 +02:00
|
|
|
/// Auto-resize terminal window to always match the original size (supported on some terminals)
|
2024-09-19 10:02:43 -07:00
|
|
|
#[arg(short = 'r', long)]
|
|
|
|
|
pub resize: bool,
|
2024-04-29 16:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
2025-05-08 11:46:11 +02:00
|
|
|
#[clap(group(ArgGroup::new("mode").args(&["local", "remote"]).multiple(true).required(true)))]
|
2024-04-29 16:44:18 +02:00
|
|
|
pub struct Stream {
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Stream the session via a local HTTP server
|
|
|
|
|
#[arg(short, long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)]
|
|
|
|
|
pub local: Option<SocketAddr>,
|
|
|
|
|
|
|
|
|
|
/// Stream the session via a remote asciinema server
|
|
|
|
|
#[arg(short, long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)]
|
|
|
|
|
pub remote: Option<RelayTarget>,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Command to start in the session [default: $SHELL]
|
2024-04-29 16:44:18 +02:00
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub command: Option<String>,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Enable input (keys) recording
|
|
|
|
|
#[arg(long, short = 'I')]
|
|
|
|
|
pub rec_input: bool,
|
|
|
|
|
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Comma-separated list of env vars to capture [default: SHELL]
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(long, value_name = "VARS")]
|
|
|
|
|
pub rec_env: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Headless mode, i.e. don't use TTY for input/output
|
2024-05-04 22:39:35 +02:00
|
|
|
#[arg(long)]
|
|
|
|
|
pub headless: bool,
|
|
|
|
|
|
2024-04-29 16:44:18 +02:00
|
|
|
/// Override terminal size for the session
|
2025-05-07 17:15:09 +02:00
|
|
|
#[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size)]
|
|
|
|
|
pub window_size: Option<(Option<u16>, Option<u16>)>,
|
2024-05-04 22:19:32 +02:00
|
|
|
|
2024-04-29 16:44:18 +02:00
|
|
|
/// Log file path
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(long, value_name = "PATH")]
|
2024-04-29 16:44:18 +02:00
|
|
|
pub log_file: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-12 13:15:02 +01:00
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Session {
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Save the session in a file
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(short, long, value_name = "PATH")]
|
|
|
|
|
pub output_file: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Output file format [default: asciicast-v3]
|
|
|
|
|
#[arg(short = 'f', long, value_enum, value_name = "FORMAT")]
|
|
|
|
|
pub output_format: Option<Format>,
|
|
|
|
|
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Stream the session via a local HTTP server
|
|
|
|
|
#[arg(short = 'l', long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)]
|
|
|
|
|
pub stream_local: Option<SocketAddr>,
|
|
|
|
|
|
|
|
|
|
/// Stream the session via a remote asciinema server
|
|
|
|
|
#[arg(short = 'r', long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)]
|
|
|
|
|
pub stream_remote: Option<RelayTarget>,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Command to start in the session [default: $SHELL]
|
2025-03-12 13:15:02 +01:00
|
|
|
#[arg(short, long)]
|
2025-05-03 17:28:10 +02:00
|
|
|
pub command: Option<String>,
|
2025-03-12 13:15:02 +01:00
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Enable input (keys) recording
|
|
|
|
|
#[arg(long, short = 'I')]
|
|
|
|
|
pub rec_input: bool,
|
|
|
|
|
|
2025-05-06 14:27:46 +02:00
|
|
|
/// Comma-separated list of env vars to capture [default: SHELL]
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(long, value_name = "VARS")]
|
|
|
|
|
pub rec_env: Option<String>,
|
2025-03-12 13:15:02 +01:00
|
|
|
|
|
|
|
|
/// Append to an existing recording file
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub append: bool,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Overwrite output file if it already exists
|
2025-03-12 13:15:02 +01:00
|
|
|
#[arg(long, conflicts_with = "append")]
|
|
|
|
|
pub overwrite: bool,
|
|
|
|
|
|
|
|
|
|
/// Title of the recording
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
|
|
|
|
/// Limit idle time to a given number of seconds
|
|
|
|
|
#[arg(short, long, value_name = "SECS")]
|
|
|
|
|
pub idle_time_limit: Option<f64>,
|
|
|
|
|
|
2025-05-03 17:28:10 +02:00
|
|
|
/// Headless mode, i.e. don't use TTY for input/output
|
2025-03-12 13:15:02 +01:00
|
|
|
#[arg(long)]
|
|
|
|
|
pub headless: bool,
|
|
|
|
|
|
|
|
|
|
/// Override terminal size for the session
|
2025-05-07 17:15:09 +02:00
|
|
|
#[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size)]
|
|
|
|
|
pub window_size: Option<(Option<u16>, Option<u16>)>,
|
2025-03-12 13:15:02 +01:00
|
|
|
|
|
|
|
|
/// Log file path
|
2025-05-03 17:28:10 +02:00
|
|
|
#[arg(long, value_name = "PATH")]
|
2025-03-12 13:15:02 +01:00
|
|
|
pub log_file: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-29 16:44:18 +02:00
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Cat {
|
2025-05-06 18:59:54 +02:00
|
|
|
#[arg(required = true, num_args = 2..)]
|
2024-04-29 16:44:18 +02:00
|
|
|
pub filename: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Convert {
|
2025-05-06 14:40:57 +02:00
|
|
|
/// File to convert from, in asciicast format (use - for stdin)
|
2024-04-29 16:44:18 +02:00
|
|
|
#[arg(value_name = "INPUT_FILENAME_OR_URL")]
|
|
|
|
|
pub input_filename: String,
|
|
|
|
|
|
2025-05-06 14:40:57 +02:00
|
|
|
/// File to convert to (use - for stdout)
|
2024-04-29 16:44:18 +02:00
|
|
|
pub output_filename: String,
|
|
|
|
|
|
2025-04-24 11:00:14 +02:00
|
|
|
/// Output file format [default: asciicast-v3]
|
2025-05-06 14:40:57 +02:00
|
|
|
#[arg(short = 'f', long, value_enum, value_name = "FORMAT")]
|
|
|
|
|
pub output_format: Option<Format>,
|
2024-04-29 16:44:18 +02:00
|
|
|
|
|
|
|
|
/// Overwrite target file if it already exists
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
pub overwrite: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Upload {
|
|
|
|
|
/// Filename/path of asciicast to upload
|
|
|
|
|
pub filename: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Args)]
|
|
|
|
|
pub struct Auth {}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
|
|
|
|
pub enum Format {
|
2025-04-24 11:00:14 +02:00
|
|
|
AsciicastV3,
|
|
|
|
|
AsciicastV2,
|
2024-04-29 16:44:18 +02:00
|
|
|
Raw,
|
|
|
|
|
Txt,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
2025-04-10 11:25:09 +02:00
|
|
|
#[allow(dead_code)]
|
2024-04-29 16:44:18 +02:00
|
|
|
pub enum RelayTarget {
|
|
|
|
|
StreamId(String),
|
|
|
|
|
WsProducerUrl(url::Url),
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-07 17:15:09 +02:00
|
|
|
fn parse_window_size(s: &str) -> Result<(Option<u16>, Option<u16>), String> {
|
2024-04-29 16:44:18 +02:00
|
|
|
match s.split_once('x') {
|
|
|
|
|
Some((cols, "")) => {
|
|
|
|
|
let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?;
|
|
|
|
|
|
|
|
|
|
Ok((Some(cols), None))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(("", rows)) => {
|
|
|
|
|
let rows: u16 = rows.parse().map_err(|e: ParseIntError| e.to_string())?;
|
|
|
|
|
|
|
|
|
|
Ok((None, Some(rows)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some((cols, rows)) => {
|
|
|
|
|
let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?;
|
|
|
|
|
let rows: u16 = rows.parse().map_err(|e: ParseIntError| e.to_string())?;
|
|
|
|
|
|
|
|
|
|
Ok((Some(cols), Some(rows)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None => Err(s.to_owned()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_forward_target(s: &str) -> Result<RelayTarget, String> {
|
|
|
|
|
let s = s.trim();
|
|
|
|
|
|
|
|
|
|
match url::Url::parse(s) {
|
|
|
|
|
Ok(url) => {
|
|
|
|
|
let scheme = url.scheme();
|
|
|
|
|
|
|
|
|
|
if scheme == "ws" || scheme == "wss" {
|
|
|
|
|
Ok(RelayTarget::WsProducerUrl(url))
|
|
|
|
|
} else {
|
|
|
|
|
Err("must be a WebSocket URL (ws:// or wss://)".to_owned())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(url::ParseError::RelativeUrlWithoutBase) => Ok(RelayTarget::StreamId(s.to_owned())),
|
|
|
|
|
Err(e) => Err(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|