mod commands;
mod daemon;
mod keys;
mod output;
mod paths;
mod pty;
mod render;
mod version_check;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use daemon::protocol::TermSize;
use output::resolve_format;
#[derive(Debug, Parser)]
#[command(
name = "tu",
version,
about = "Headless virtual terminal for AI agents"
)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Usage,
Run {
command: String,
#[arg(trailing_var_arg = true)]
args: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = "120x40", value_parser = parse_size)]
size: TermSize,
#[arg(long, default_value = "1000")]
scrollback: usize,
#[arg(long = "env", value_parser = parse_env)]
envs: Vec<(String, String)>,
#[arg(long)]
cwd: Option<String>,
#[arg(long, default_value = "xterm-256color")]
term: String,
#[arg(long)]
shell: bool,
},
Kill {
#[arg(long, default_value = "default")]
name: String,
},
List,
Status {
#[arg(long, default_value = "default")]
name: String,
},
Screenshot {
#[arg(long, default_value = "default")]
name: String,
#[arg(long)]
png: bool,
#[arg(long, short)]
output: Option<PathBuf>,
#[arg(long)]
stdout: bool,
#[arg(long)]
font: Option<String>,
#[arg(long, default_value = "14", value_parser = parse_font_size)]
font_size: f32,
},
Cursor {
#[arg(long, default_value = "default")]
name: String,
},
Scrollback {
#[arg(long, default_value = "default")]
name: String,
#[arg(long)]
lines: Option<usize>,
},
Type {
text: String,
#[arg(long, default_value = "default")]
name: String,
},
Press {
#[arg(required = true)]
keys: Vec<String>,
#[arg(long, default_value = "default")]
name: String,
},
Paste {
text: String,
#[arg(long, default_value = "default")]
name: String,
},
Resize {
#[arg(value_parser = parse_size)]
size: TermSize,
#[arg(long, default_value = "default")]
name: String,
},
Wait {
#[arg(long, default_value = "default")]
name: String,
#[arg(long)]
stable: Option<u64>,
#[arg(long)]
text: Option<String>,
#[arg(long, default_value = "5000")]
timeout: u64,
},
Monitor {
#[arg(long, default_value = "default")]
name: String,
},
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
#[command(name = "self")]
Self_ {
#[command(subcommand)]
action: commands::self_cmd::SelfAction,
},
}
#[derive(Debug, Subcommand)]
enum DaemonAction {
Start,
Stop,
Status,
}
fn parse_size(s: &str) -> Result<TermSize, String> {
let parts: Vec<&str> = s.split('x').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid size format: {s:?}. Expected COLSxROWS (e.g. 120x40)"
));
}
let cols = parts[0]
.parse::<u16>()
.map_err(|_| format!("Invalid columns: {:?}", parts[0]))?;
let rows = parts[1]
.parse::<u16>()
.map_err(|_| format!("Invalid rows: {:?}", parts[1]))?;
Ok(TermSize { cols, rows })
}
fn parse_env(s: &str) -> Result<(String, String), String> {
let pos = s
.find('=')
.ok_or_else(|| format!("Invalid env format: {s:?}. Expected KEY=VALUE"))?;
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
}
fn parse_font_size(s: &str) -> Result<f32, String> {
let size = s
.parse::<f32>()
.map_err(|_| format!("Invalid font size: {s:?}"))?;
if !size.is_finite() || size <= 0.0 {
return Err(format!(
"Invalid font size: {s:?}. Expected a finite number greater than 0"
));
}
Ok(size)
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let format = resolve_format(cli.json);
let result = match cli.command {
Command::Usage => {
commands::usage::run().await;
Ok(())
}
Command::Self_ { action } => commands::self_cmd::run(action).await,
Command::Daemon { action } => match action {
DaemonAction::Start => commands::daemon_cmd::start().await,
DaemonAction::Stop => commands::daemon_cmd::stop().await,
DaemonAction::Status => commands::daemon_cmd::status().await,
},
Command::Run {
command,
args,
name,
size,
scrollback,
envs,
cwd,
term,
shell,
} => {
commands::run::run(
command, args, name, size, scrollback, envs, cwd, term, shell, format,
)
.await
}
Command::Kill { name } => commands::kill::run(name).await,
Command::List => commands::list::run(format).await,
Command::Status { name } => commands::status::run(name, format).await,
Command::Screenshot {
name,
png,
output,
stdout,
font,
font_size,
} => {
if png {
commands::screenshot::run_png(name, output, stdout, font, font_size).await
} else {
commands::screenshot::run_text(name, format).await
}
}
Command::Cursor { name } => commands::cursor::run(name, format).await,
Command::Scrollback { name, lines } => commands::scrollback::run(name, lines, format).await,
Command::Type { text, name } => commands::type_text::run(name, text).await,
Command::Press { keys, name } => commands::press::run(name, keys).await,
Command::Paste { text, name } => commands::paste::run(name, text).await,
Command::Resize { size, name } => commands::resize::run(name, size).await,
Command::Wait {
name,
stable,
text,
timeout,
} => commands::wait::run(name, stable, text, timeout).await,
Command::Monitor { name } => commands::monitor::run(name).await,
};
if let Err(e) = result {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::Cli;
use clap::Parser;
#[test]
fn screenshot_text_mode_no_args() {
let result = Cli::try_parse_from(["tu", "screenshot"]);
assert!(result.is_ok());
}
#[test]
fn screenshot_png_stdout() {
let result = Cli::try_parse_from(["tu", "screenshot", "--png", "--stdout"]);
assert!(result.is_ok());
}
#[test]
fn screenshot_png_output() {
let result = Cli::try_parse_from(["tu", "screenshot", "--png", "--output", "shot.png"]);
assert!(result.is_ok());
}
#[test]
fn screenshot_rejects_zero_font_size() {
let result =
Cli::try_parse_from(["tu", "screenshot", "--png", "--stdout", "--font-size", "0"]);
assert!(result.is_err());
}
#[test]
fn screenshot_rejects_negative_font_size() {
let result =
Cli::try_parse_from(["tu", "screenshot", "--png", "--stdout", "--font-size", "-1"]);
assert!(result.is_err());
}
}