mod browser;
mod cdp;
mod commands;
#[cfg(unix)]
mod daemon;
mod element;
mod element_ref;
mod pipe;
mod run_helpers;
mod session;
mod setup;
mod snapshot;
mod truncate;
pub(crate) type BoxError = Box<dyn std::error::Error>;
use clap::{Parser, Subcommand};
use serde_json::json;
use crate::browser::BrowserOptions;
use crate::cdp::client::CdpClient;
use crate::run_helpers::{cmd_close, cmd_status, cmd_stop, connect_page, error_hint, get_uid_map, json_output, output_action, output_goto, resolve_page_target};
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\
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) → inspect again.\n\
Use --inspect on action commands to combine action + observation in one call.";
const CLI_AFTER_LONG_HELP: &str = include_str!("../llm-guide.txt");
#[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(crate) struct Cli {
#[arg(long, default_value = "default")]
pub(crate) browser: String,
#[arg(long)]
pub(crate) connect: Option<String>,
#[arg(long)]
pub(crate) headed: bool,
#[arg(long, default_value = "30")]
pub(crate) timeout: u64,
#[arg(long)]
pub(crate) ignore_https_errors: bool,
#[arg(long)]
pub(crate) json: bool,
#[arg(long)]
pub(crate) stealth: bool,
#[arg(long)]
pub(crate) max_depth: Option<usize>,
#[arg(long)]
pub(crate) copy_cookies: bool,
#[arg(long, default_value = "default")]
pub(crate) page: String,
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
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>,
},
#[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,
#[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>,
},
Diff,
#[command(alias = "capture")]
Screenshot {
#[arg(long)]
filename: Option<String>,
},
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,
pattern: String,
#[arg(long, default_value = "10")]
timeout: u64,
},
Type {
text: String,
#[arg(long)]
selector: Option<String>,
},
Press {
key: String,
},
Scroll {
target: String,
},
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,
},
Console {
#[arg(long)]
level: Option<String>,
#[arg(long)]
clear: bool,
#[arg(long, default_value = "50")]
limit: usize,
},
Replay {
file: String,
#[arg(long, value_delimiter = ',')]
vars: Option<Vec<String>>,
},
History {
#[arg(long)]
filter: Option<String>,
#[arg(long, default_value = "20")]
limit: usize,
},
Pipe,
Tabs,
Close {
#[arg(long)]
purge: bool,
},
Status,
Stop,
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
}
#[derive(Subcommand)]
enum DaemonAction {
Start,
}
#[tokio::main]
async fn main() {
tokio::spawn(async {
if matches!(tokio::signal::ctrl_c().await, Ok(())) {
if let Ok(store) = session::load_session() {
for browser in store.browsers.values() {
if let Some(pid) = browser.pid {
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
}
}
std::process::exit(130); }
});
let cli = Cli::parse();
let json_mode = cli.json;
if let Err(e) = run(cli).await {
let msg = e.to_string();
if json_mode {
let hint = error_hint(&msg);
let mut obj = json!({"ok": false, "error": msg});
if let Some(h) = hint {
obj["hint"] = json!(h);
}
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
eprintln!("error: {msg}");
if let Some(hint) = error_hint(&msg) {
eprintln!("hint: {hint}");
}
}
if !json_mode {
std::process::exit(1);
}
}
}
async fn run(cli: Cli) -> Result<(), BoxError> {
match cli.command {
Command::Daemon { action } => {
match action {
DaemonAction::Start => {
#[cfg(unix)]
{
let socket_path = session::daemon_socket_path()?;
daemon::run_daemon(&socket_path).await?;
}
#[cfg(not(unix))]
{
return Err("Daemon is not supported on Windows. Commands work without a daemon.".into());
}
}
}
return Ok(());
}
Command::Status => {
return cmd_status(cli.json);
}
Command::Stop => {
return cmd_stop(cli.json).await;
}
Command::Close { purge } => {
return cmd_close(&cli.browser, purge, cli.json);
}
Command::Pipe => {
return pipe::run_pipe(&cli).await;
}
Command::Replay { ref file, ref vars } => {
return pipe::run_replay(&cli, file, vars.as_deref()).await;
}
Command::History { ref filter, limit } => {
let entries = commands::history::run(filter.as_deref(), limit)?;
if cli.json {
let entries_json: Vec<serde_json::Value> = entries
.iter()
.map(|e| json!({"ts": e.ts, "url": e.url, "title": e.title, "page": e.page}))
.collect();
json_output(&json!({"ok": true, "entries": entries_json}));
} else {
let text = commands::history::format_text(&entries);
if text.is_empty() {
println!("No history entries found.");
} else {
println!("{text}");
}
}
return Ok(());
}
_ => {}
}
let mut store = session::load_session()?;
let existing_mode = store.browsers.get(&cli.browser).map(|b| b.headless);
let want_headless = existing_mode.unwrap_or(!cli.headed);
let (conn, browser_client) = if let Some(existing) = store.browsers.get(&cli.browser) {
let mode_matches = existing.headless == want_headless;
let ws = &existing.ws_endpoint;
let http = browser::extract_http_from_ws(ws);
if mode_matches {
if let Ok(client) = CdpClient::connect(ws).await {
let conn = browser::BrowserConnection {
ws_endpoint: ws.clone(),
http_endpoint: Some(http),
pid: existing.pid,
};
(conn, client)
} else {
store.browsers.remove(&cli.browser);
let opts = BrowserOptions {
name: cli.browser.clone(),
headless: want_headless,
ignore_https_errors: cli.ignore_https_errors,
stealth: cli.stealth,
connect: cli.connect.clone(),
copy_cookies: cli.copy_cookies,
};
let conn = browser::resolve_browser(&opts).await?;
let client = CdpClient::connect(&conn.ws_endpoint).await?;
(conn, client)
}
} else {
if let Some(pid) = existing.pid {
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
store.browsers.remove(&cli.browser);
let opts = BrowserOptions {
name: cli.browser.clone(),
headless: want_headless,
ignore_https_errors: cli.ignore_https_errors,
stealth: cli.stealth,
connect: cli.connect.clone(),
copy_cookies: cli.copy_cookies,
};
let conn = browser::resolve_browser(&opts).await?;
let client = CdpClient::connect(&conn.ws_endpoint).await?;
(conn, client)
}
} else {
let needs_existing = !matches!(
cli.command,
Command::Goto { .. } | Command::Pipe
);
if needs_existing {
return Err(format!(
"No browser session '{}'. Run `chrome-agent --browser {} goto <url>` first.",
cli.browser, cli.browser
).into());
}
let opts = BrowserOptions {
name: cli.browser.clone(),
headless: want_headless,
ignore_https_errors: cli.ignore_https_errors,
stealth: cli.stealth,
connect: cli.connect.clone(),
copy_cookies: cli.copy_cookies,
};
let conn = browser::resolve_browser(&opts).await?;
let client = CdpClient::connect(&conn.ws_endpoint).await?;
(conn, client)
};
let http_endpoint = conn.http_endpoint.as_deref().ok_or({
"No HTTP endpoint available. Cannot resolve page WebSocket URL."
})?;
let target_id = {
let browser_session = session::ensure_browser(
&mut store,
&cli.browser,
&conn.ws_endpoint,
conn.pid,
!cli.headed,
);
resolve_page_target(&browser_client, browser_session, &cli.page).await?
};
let _ = session::save_session(&mut store);
let client = connect_page(http_endpoint, &target_id, cli.stealth).await?;
let json_mode = cli.json;
match cli.command {
Command::Goto { url, inspect, max_depth, wait_for } => {
let depth = max_depth.or(cli.max_depth);
let result = commands::goto::run(&client, &url, cli.timeout).await?;
if let Some(ref selector) = wait_for {
commands::wait::run(&client, "selector", selector, cli.timeout).await?;
}
let _ = commands::history::append(&result.url, &result.title, &cli.page);
output_goto(&client, &mut store, &cli.browser, &cli.page, &target_id, &result.url, &result.title, inspect, depth, json_mode).await?;
}
Command::Click { uid, selector, xy, inspect, max_depth } => {
let depth = max_depth.or(cli.max_depth);
let provided = u8::from(uid.is_some()) + u8::from(selector.is_some()) + u8::from(xy.is_some());
if provided == 0 {
return Err("Provide a uid, --selector, or --xy to identify the click target.".into());
}
if provided > 1 {
return Err("Only one of uid, --selector, or --xy can be provided.".into());
}
let msg = if let Some(ref sel) = selector {
crate::element::click_selector(&client, sel).await?;
format!("Clicked selector '{sel}'")
} else if let Some(ref coords) = xy {
if coords.len() != 2 {
return Err("--xy requires exactly 2 values: x,y".into());
}
crate::element::click_at_coords(&client, coords[0], coords[1]).await?;
format!("Clicked at ({}, {})", coords[0], coords[1])
} else {
let uid = uid.as_ref().unwrap();
let uid_map = get_uid_map(&store, &cli.browser, &cli.page);
commands::click::run(&client, &uid_map, uid).await?
};
output_action(&client, &mut store, &cli.browser, &cli.page, &target_id, msg, inspect, depth, json_mode).await?;
}
Command::Fill { uid, selector, value, inspect, max_depth } => {
let depth = max_depth.or(cli.max_depth);
let provided = u8::from(uid.is_some()) + u8::from(selector.is_some());
if provided == 0 {
return Err("Provide --uid or --selector to identify the element.".into());
}
if provided > 1 {
return Err("Only one of --uid or --selector can be provided.".into());
}
let msg = if let Some(ref sel) = selector {
crate::element::fill_selector(&client, sel, &value).await?;
format!("Filled selector '{sel}'")
} else {
let uid = uid.as_ref().unwrap();
let uid_map = get_uid_map(&store, &cli.browser, &cli.page);
commands::fill::run(&client, &uid_map, uid, &value).await?
};
output_action(&client, &mut store, &cli.browser, &cli.page, &target_id, msg, inspect, depth, json_mode).await?;
}
Command::FillForm { pairs, inspect, max_depth } => {
let depth = max_depth.or(cli.max_depth);
let uid_map = get_uid_map(&store, &cli.browser, &cli.page);
let parsed: Result<Vec<(&str, &str)>, _> = pairs
.iter()
.map(|p| {
p.split_once('=')
.ok_or_else(|| format!("Invalid pair (expected uid=value): {p}"))
})
.collect();
let parsed = parsed?;
let msg = commands::fill::run_form(&client, &uid_map, &parsed).await?;
output_action(&client, &mut store, &cli.browser, &cli.page, &target_id, msg, inspect, depth, json_mode).await?;
}
Command::Text { uid, selector, truncate } => {
if uid.is_some() && selector.is_some() {
return Err("Only one of uid or --selector can be provided.".into());
}
let uid_map = get_uid_map(&store, &cli.browser, &cli.page);
let text = commands::text::run(&client, uid.as_deref(), selector.as_deref(), &uid_map).await?;
let full_length = text.chars().count();
let (text, truncated) = if let Some(n) = truncate
&& full_length > n {
(crate::truncate::truncate_str(&text, n, "...").into_owned(), true)
} else {
(text, false)
};
if json_mode {
let mut obj = json!({"ok": true, "text": text});
if truncated {
obj["truncated"] = json!(true);
obj["fullLength"] = json!(full_length);
}
json_output(&obj);
} else {
println!("{text}");
}
}
Command::Read { html, truncate } => {
let result = commands::read::run(&client, html, truncate).await?;
if json_mode {
let mut obj = json!({"ok": true, "title": result.title, "text": result.text_content});
if let Some(excerpt) = &result.excerpt {
obj["excerpt"] = json!(excerpt);
}
if let Some(byline) = &result.byline {
obj["byline"] = json!(byline);
}
json_output(&obj);
} else {
if !result.title.is_empty() {
println!("# {}", result.title);
println!();
}
if html {
if let Some(content) = &result.content {
println!("{content}");
}
} else {
println!("{}", result.text_content);
}
}
}
Command::Back => {
client.send("Runtime.evaluate", json!({"expression": "history.back()"})).await?;
let _ = client.wait_for_event("Page.loadEventFired", std::time::Duration::from_secs(5)).await;
let title: crate::cdp::types::EvaluateResult = client
.call("Runtime.evaluate", json!({"expression": "document.title", "returnByValue": true}))
.await?;
let title_str = title.result.value.as_ref().and_then(|v| v.as_str()).unwrap_or("");
if json_mode {
json_output(&json!({"ok": true, "title": title_str}));
} else {
println!("Navigated back — {title_str}");
}
}
Command::Inspect { verbose, max_depth, uid, filter, scroll, limit } => {
if scroll {
commands::extract::scroll_to_load(&client).await?;
}
let role_filter: Option<Vec<&str>> = filter.as_deref().map(|f| f.split(',').map(str::trim).collect());
let (text, uid_map) = if let Some(max) = limit {
let result = commands::inspect::scroll_collect(&client, verbose, uid.as_deref(), role_filter.as_deref(), max).await?;
(result.text, result.uid_map)
} else {
let s = commands::inspect::run(&client, verbose, max_depth, uid.as_deref(), role_filter.as_deref()).await?;
(s.text, s.uid_map)
};
if let Some(browser_s) = store.browsers.get_mut(&cli.browser) {
let page = session::ensure_page(browser_s, &cli.page, &target_id);
page.uid_map = uid_map;
page.last_snapshot = Some(text.clone());
}
if json_mode {
json_output(&json!({"ok": true, "snapshot": text}));
} else {
println!("{text}");
}
}
Command::Diff => {
let old_snapshot = store
.browsers
.get(&cli.browser)
.and_then(|b| b.pages.get(&cli.page))
.and_then(|p| p.last_snapshot.clone());
let old_text = old_snapshot.ok_or("No previous snapshot. Run 'chrome-agent inspect' first.")?;
let snapshot = commands::inspect::run(&client, false, None, None, None).await?;
let diff = commands::diff::diff_snapshots(&old_text, &snapshot.text);
let stats = commands::diff::diff_stats(&diff);
if let Some(browser_s) = store.browsers.get_mut(&cli.browser) {
let page = session::ensure_page(browser_s, &cli.page, &target_id);
page.last_snapshot = Some(snapshot.text);
page.uid_map = snapshot.uid_map;
}
if json_mode {
json_output(&json!({
"ok": true,
"added": stats.added,
"removed": stats.removed,
"changed": stats.changed,
"diff": diff.trim_end(),
}));
} else {
print!("{diff}");
}
}
Command::Screenshot { filename } => {
let path = commands::screenshot::run(&client, filename.as_deref()).await?;
if json_mode {
json_output(&json!({"ok": true, "path": path}));
} else {
println!("{path}");
}
}
Command::Extract { selector, limit, scroll, a11y } => {
let result = if a11y {
commands::extract::run_a11y(&client, limit, scroll).await?
} else {
if scroll {
commands::extract::scroll_to_load(&client).await?;
}
commands::extract::run(&client, selector.as_deref(), limit).await?
};
if json_mode {
json_output(&commands::extract::to_json(&result));
} else {
print!("{}", commands::extract::format_text(&result));
}
}
Command::Eval { expression, selector } => {
let expr = if let Some(ref sel) = selector {
let escaped = serde_json::to_string(sel).unwrap_or_default();
format!("((el) => {{ if (!el) throw new Error('No element matches selector ' + {escaped}); return {expression} }})(document.querySelector({escaped}))")
} else {
expression
};
if json_mode {
let raw = commands::eval::run_raw(&client, &expr).await?;
json_output(&json!({"ok": true, "result": raw}));
} else {
let result = commands::eval::run(&client, &expr).await?;
println!("{result}");
}
}
Command::Wait { what, pattern, timeout } => {
let msg = commands::wait::run(&client, &what, &pattern, timeout).await?;
if json_mode {
json_output(&json!({"ok": true, "message": msg}));
} else {
println!("{msg}");
}
}
Command::Type { text, selector } => {
if let Some(ref sel) = selector {
crate::element::focus_selector(&client, sel).await?;
}
crate::element::type_text(&client, &text).await?;
let msg = if let Some(sel) = &selector {
format!("Typed {} chars into selector '{sel}'", text.len())
} else {
format!("Typed {} chars", text.len())
};
if json_mode {
json_output(&json!({"ok": true, "message": msg}));
} else {
println!("{msg}");
}
}
Command::Press { key } => {
crate::element::press_key(&client, &key).await?;
let msg = format!("Pressed {key}");
if json_mode {
json_output(&json!({"ok": true, "message": msg}));
} else {
println!("{msg}");
}
}
Command::Scroll { target } => {
let msg = match target.as_str() {
"down" => {
let _: serde_json::Value = client
.call("Runtime.evaluate", json!({
"expression": "window.scrollBy(0, 500)",
"returnByValue": true,
}))
.await?;
"Scrolled down".to_string()
}
"up" => {
let _: serde_json::Value = client
.call("Runtime.evaluate", json!({
"expression": "window.scrollBy(0, -500)",
"returnByValue": true,
}))
.await?;
"Scrolled up".to_string()
}
uid => {
let uid_map = get_uid_map(&store, &cli.browser, &cli.page);
let element_ref = uid_map.get(uid).ok_or_else(|| {
format!("Element uid={uid} not found. Run 'chrome-agent inspect' to get fresh uids.")
})?;
let backend_node_id = element_ref.backend_node_id().ok_or_else(|| {
format!("Element uid={uid} has no resolvable backend node.")
})?;
let resolve_result: crate::cdp::types::ResolveNodeResult = client
.call(
"DOM.resolveNode",
crate::cdp::types::ResolveNodeParams {
node_id: None,
backend_node_id: Some(backend_node_id),
object_group: Some("chrome-agent".into()),
execution_context_id: None,
},
)
.await?;
let object_id = resolve_result.object.object_id.ok_or_else(|| {
format!("Element uid={uid} could not be resolved to a JS object.")
})?;
let _: serde_json::Value = client
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": "function() { this.scrollIntoView({block: 'center'}); }",
"returnByValue": true,
}),
)
.await?;
format!("Scrolled uid={uid} into view")
}
};
if json_mode {
json_output(&json!({"ok": true, "message": msg}));
} else {
println!("{msg}");
}
}
Command::Hover { uid } => {
let uid_map = get_uid_map(&store, &cli.browser, &cli.page);
crate::element::hover(&client, &uid_map, &uid).await?;
let msg = format!("Hovered uid={uid}");
if json_mode {
json_output(&json!({"ok": true, "message": msg}));
} else {
println!("{msg}");
}
}
Command::Network { filter, body, live, limit } => {
let entries = if let Some(secs) = live {
if cli.stealth { eprintln!("warning: --live enables Network domain (detectable)"); }
commands::network::run_live(&client, filter.as_deref(), body, limit, secs).await?
} else {
commands::network::run_retroactive(&client, filter.as_deref(), limit).await?
};
if json_mode {
json_output(&json!({"ok": true, "requests": entries}));
} else {
println!("{}", commands::network::format_text(&entries));
}
}
Command::Console { level, clear, limit } => {
let entries = commands::console::run(&client, level.as_deref(), clear, limit).await?;
if json_mode {
let messages: Vec<serde_json::Value> = entries
.iter()
.map(|e| json!({"level": e.level, "message": e.message, "timestamp": e.timestamp}))
.collect();
json_output(&json!({"ok": true, "messages": messages}));
} else {
println!("{}", commands::console::format_text(&entries));
}
}
Command::Tabs => {
if json_mode {
let tabs = commands::tabs::run_structured(&browser_client, &store).await?;
json_output(&json!({"ok": true, "tabs": tabs}));
} else {
let output = commands::tabs::run(&browser_client, &store).await?;
print!("{output}");
}
}
Command::Daemon { .. } | Command::Status | Command::Stop | Command::Close { .. }
| Command::Pipe | Command::Replay { .. } | Command::History { .. } => {
unreachable!()
}
}
session::save_session(&mut store)?;
Ok(())
}