use clap::{Parser, Subcommand};
fn validate_header(value: &str) -> Result<String, String> {
crate::commands::goto::parse_header(value).map_err(|e| e.to_string())?;
Ok(value.to_string())
}
fn parse_positive_usize(value: &str) -> Result<usize, String> {
let parsed = value
.parse::<usize>()
.map_err(|_| format!("invalid positive integer: {value}"))?;
if parsed == 0 {
Err("value must be greater than zero".to_string())
} else {
Ok(parsed)
}
}
const CLI_LONG_ABOUT: &str = "\
chrome-agent — browser automation for AI agents. Controls Chrome via CDP.\n\
Single binary, zero runtime dependencies. Named pages persist between invocations.\n\
\n\
Reading a response: ok:true means the command ran, not that the page complied.\n\
Read `verdict` (and `value.verbatim` after a fill) before reporting success; branch on\n\
`next` — never repeat an action on `unknown`, because the first one may have landed.\n\
\n\
Use --stealth to bypass bot detection (Cloudflare, Turnstile).\n\
Use --copy-cookies to access sites where you're already logged in (X.com, Gmail).\n\
\n\
Workflow: inspect → read uids → act (click/fill) → assert.\n\
Use --inspect on action commands to combine action + observation in one call.";
const CLI_AFTER_LONG_HELP: &str = include_str!("../llm-guide.txt");
pub const BEFORE_VERB_ONLY: &[(&str, &str)] = &[
(
"--timeout",
"`wait` and `download` declare their own --timeout with different defaults, so this one \
is not global",
),
(
"--max-depth",
"the action commands declare their own --max-depth for `--inspect`, so this one is not \
global",
),
];
#[derive(Parser)]
#[command(
name = "chrome-agent",
version,
about = "chrome-agent — browser automation for AI agents",
long_about = CLI_LONG_ABOUT,
after_long_help = CLI_AFTER_LONG_HELP,
)]
#[allow(clippy::struct_excessive_bools)]
pub struct Cli {
#[arg(long, default_value = "default", global = true)]
pub browser: String,
#[arg(long, global = true)]
pub connect: Option<String>,
#[arg(long, global = true)]
pub proxy_server: Option<String>,
#[arg(long, global = true)]
pub headed: bool,
#[arg(long, default_value = "30")]
pub timeout: u64,
#[arg(long, global = true)]
pub ignore_https_errors: bool,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub stealth: bool,
#[arg(long)]
pub max_depth: Option<usize>,
#[arg(long, default_value = "auto", value_parser = ["auto", "off"], global = true)]
pub verdict: String,
#[arg(long, default_value = "1200", global = true)]
pub budget: usize,
#[arg(long, default_value = "dispatch", value_parser = ["dispatch", "refuse", "guard"], global = true)]
pub on_intercept: String,
#[arg(long, global = true)]
pub copy_cookies: bool,
#[arg(long = "chrome-arg", global = true, allow_hyphen_values = true)]
pub chrome_args: Vec<String>,
#[arg(long, default_value = "default", global = true)]
pub page: String,
#[arg(long, default_value = "accept", value_parser = ["accept", "dismiss", "manual"], ignore_case = true, global = true)]
pub dialog: String,
#[arg(long, global = true)]
pub dialog_text: Option<String>,
#[command(subcommand)]
pub command: Command,
}
pub use crate::cli_actions::{AssertWhat, DaemonAction, EmulateAction, MacroAction, WebmcpAction};
#[derive(Subcommand)]
pub enum Command {
#[command(alias = "navigate", alias = "open", alias = "go")]
Goto {
url: String,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
#[arg(long)]
wait_for: Option<String>,
#[arg(long = "header", value_parser = validate_header)]
headers: Vec<String>,
},
#[command(alias = "tap")]
Click {
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long, value_delimiter = ',')]
xy: Option<Vec<f64>>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Fill {
value: String,
#[arg(long)]
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
#[command(name = "fill-form")]
FillForm {
pairs: Vec<String>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Text {
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
truncate: Option<usize>,
},
Read {
#[arg(long)]
html: bool,
#[arg(long)]
truncate: Option<usize>,
},
Back,
Forward {
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Dblclick {
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long, value_delimiter = ',')]
xy: Option<Vec<f64>>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Select {
value: String,
#[arg(long)]
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Check {
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Uncheck {
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Upload {
files: Vec<String>,
#[arg(long)]
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
Drag {
from: String,
to: String,
#[arg(long)]
inspect: bool,
#[arg(long)]
max_depth: Option<usize>,
},
#[command(alias = "snap", alias = "snapshot", alias = "tree")]
Inspect {
#[arg(long)]
verbose: bool,
#[arg(long)]
max_depth: Option<usize>,
#[arg(long)]
uid: Option<String>,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
scroll: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
urls: bool,
#[arg(long)]
max_chars: Option<usize>,
#[arg(long, default_value = "0")]
offset: usize,
},
Diff,
#[command(alias = "capture")]
Screenshot {
#[arg(long)]
filename: Option<String>,
#[arg(long, default_value = "png", value_parser = ["png", "jpeg", "jpg"], ignore_case = true)]
format: String,
#[arg(long)]
quality: Option<u32>,
#[arg(long)]
max_width: Option<u32>,
#[arg(long)]
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
},
#[command(group = clap::ArgGroup::new("download_target").required(true).args(["url", "uid", "selector"]))]
Download {
url: Option<String>,
#[arg(long)]
uid: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
out: Option<String>,
#[arg(long, default_value = "30")]
timeout: u64,
#[arg(
long,
default_value = "67108864",
value_parser = parse_positive_usize
)]
max_bytes: usize,
},
Pdf {
#[arg(long)]
filename: Option<String>,
#[arg(long)]
landscape: bool,
#[arg(long)]
background: bool,
},
Extract {
#[arg(long)]
selector: Option<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long)]
scroll: bool,
#[arg(long)]
a11y: bool,
},
#[command(alias = "js", alias = "execute")]
Eval {
expression: String,
#[arg(long)]
selector: Option<String>,
},
Wait {
what: String,
#[arg(default_value = "")]
pattern: String,
#[arg(long, default_value = "10")]
timeout: u64,
#[arg(long, default_value = "500")]
idle_ms: u64,
},
Assert {
#[command(subcommand)]
what: AssertWhat,
},
Type {
text: String,
#[arg(long)]
selector: Option<String>,
},
Press {
key: String,
},
Scroll {
target: String,
#[arg(long, default_value = "500")]
px: u64,
},
Hover {
uid: String,
},
Network {
#[arg(long)]
filter: Option<String>,
#[arg(long)]
body: bool,
#[arg(long)]
live: Option<u64>,
#[arg(long, default_value = "50")]
limit: usize,
#[arg(long)]
abort: Option<String>,
},
Console {
#[arg(long)]
level: Option<String>,
#[arg(long)]
clear: bool,
#[arg(long, default_value = "50")]
limit: usize,
},
Macro {
#[command(subcommand)]
action: MacroAction,
},
Replay {
file: String,
#[arg(long, value_delimiter = ',')]
vars: Option<Vec<String>>,
},
History {
#[arg(long)]
filter: Option<String>,
#[arg(long, default_value = "20")]
limit: usize,
},
Frame {
target: String,
},
Emulate {
#[command(subcommand)]
action: EmulateAction,
},
Webmcp {
#[command(subcommand)]
action: WebmcpAction,
},
Batch {
#[arg(long)]
stop_on_error: bool,
},
Pipe,
Tabs,
Close {
#[arg(long)]
purge: bool,
#[arg(long)]
purge_orphans: bool,
#[arg(long)]
orphans: bool,
},
Status,
Stop,
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn download_max_bytes_defaults_and_accepts_an_explicit_limit() {
let default = Cli::try_parse_from(["chrome-agent", "download", "https://example.com/a"])
.unwrap();
let explicit = Cli::try_parse_from([
"chrome-agent",
"download",
"https://example.com/a",
"--max-bytes",
"10",
])
.unwrap();
assert!(matches!(
default.command,
Command::Download { max_bytes: 67_108_864, .. }
));
assert!(matches!(
explicit.command,
Command::Download { max_bytes: 10, .. }
));
}
#[test]
fn download_max_bytes_rejects_zero() {
assert!(
Cli::try_parse_from([
"chrome-agent",
"download",
"https://example.com/a",
"--max-bytes",
"0",
])
.is_err()
);
}
#[test]
fn assert_requires_exactly_one_comparator_and_one_target() {
let ok = |args: &[&str]| {
Cli::try_parse_from(std::iter::once("chrome-agent").chain(args.iter().copied()))
};
assert!(ok(&["assert", "value", "--selector", "#a", "--equals", "x"]).is_ok());
assert!(ok(&["assert", "value", "--uid", "n1", "--contains", "x"]).is_ok());
assert!(ok(&["assert", "value", "--selector", "#a", "--equals", "x", "--contains", "y"]).is_err());
assert!(ok(&["assert", "value", "--selector", "#a"]).is_err());
assert!(ok(&["assert", "value", "--selector", "#a", "--uid", "n1", "--equals", "x"]).is_err());
assert!(ok(&["assert", "value", "--equals", "x"]).is_err());
assert!(ok(&["assert", "text", "--contains", "x"]).is_ok());
assert!(ok(&["assert", "text"]).is_err());
assert!(ok(&["assert", "text", "--equals", "x"]).is_err());
assert!(ok(&["assert", "state", "--selector", "#a", "--checked"]).is_ok());
assert!(ok(&["assert", "state", "--selector", "#a", "--checked", "--unchecked"]).is_err());
assert!(ok(&["assert", "state", "--selector", "#a"]).is_err());
assert!(ok(&["assert", "state", "--checked"]).is_err());
assert!(ok(&["assert", "exists", "--selector", ".row"]).is_ok());
assert!(ok(&["assert", "exists", "--selector", ".row", "--count", "3"]).is_ok());
assert!(ok(&["assert", "exists", "--selector", ".row", "--count", "3", "--min", "1"]).is_err());
assert!(ok(&["assert", "exists", "--count", "3"]).is_err());
assert!(ok(&["assert", "url", "--equals", "https://a/"]).is_ok());
assert!(ok(&["assert", "url", "--selector", "#a", "--equals", "x"]).is_err());
}
#[test]
fn batch_stop_on_error_is_off_by_default() {
let default = Cli::try_parse_from(["chrome-agent", "batch"]).unwrap();
let explicit = Cli::try_parse_from(["chrome-agent", "batch", "--stop-on-error"]).unwrap();
assert!(matches!(default.command, Command::Batch { stop_on_error: false }));
assert!(matches!(explicit.command, Command::Batch { stop_on_error: true }));
}
#[test]
fn parses_managed_browser_proxy() {
let cli = Cli::try_parse_from([
"chrome-agent",
"--proxy-server",
"http://127.0.0.1:8080",
"status",
])
.unwrap();
assert_eq!(cli.proxy_server.as_deref(), Some("http://127.0.0.1:8080"));
}
#[test]
fn chrome_arg_accepts_a_flag_shaped_value_space_separated_and_glued() {
let space_separated = Cli::try_parse_from([
"chrome-agent",
"--chrome-arg",
"--enable-features=WebMCP,WebMCPTesting",
"status",
])
.unwrap();
assert_eq!(
space_separated.chrome_args,
vec!["--enable-features=WebMCP,WebMCPTesting".to_string()]
);
let glued = Cli::try_parse_from([
"chrome-agent",
"--chrome-arg=--enable-features=WebMCP,WebMCPTesting",
"status",
])
.unwrap();
assert_eq!(glued.chrome_args, space_separated.chrome_args);
}
#[test]
fn chrome_arg_is_repeatable_and_global() {
let cli = Cli::try_parse_from([
"chrome-agent",
"status",
"--chrome-arg",
"--disable-gpu",
"--chrome-arg",
"--auto-open-devtools-for-tabs",
])
.unwrap();
assert_eq!(
cli.chrome_args,
vec!["--disable-gpu".to_string(), "--auto-open-devtools-for-tabs".to_string()]
);
}
}