diff --git a/cli/src/commands.rs b/cli/src/commands.rs new file mode 100644 index 0000000..a329889 --- /dev/null +++ b/cli/src/commands.rs @@ -0,0 +1,357 @@ +use serde_json::{json, Value}; + +use crate::flags::Flags; + +pub fn gen_id() -> String { + format!( + "r{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() + % 1000000 + ) +} + +pub fn parse_command(args: &[String], flags: &Flags) -> Option { + if args.is_empty() { + return None; + } + + let cmd = args[0].as_str(); + let rest: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect(); + let id = gen_id(); + + match cmd { + // === Navigation === + "open" | "goto" | "navigate" => { + let url = rest.get(0)?; + let url = if url.starts_with("http") { + url.to_string() + } else { + format!("https://{}", url) + }; + Some(json!({ "id": id, "action": "navigate", "url": url })) + } + "back" => Some(json!({ "id": id, "action": "back" })), + "forward" => Some(json!({ "id": id, "action": "forward" })), + "reload" => Some(json!({ "id": id, "action": "reload" })), + + // === Core Actions === + "click" => Some(json!({ "id": id, "action": "click", "selector": rest.get(0)? })), + "dblclick" => Some(json!({ "id": id, "action": "dblclick", "selector": rest.get(0)? })), + "fill" => Some(json!({ "id": id, "action": "fill", "selector": rest.get(0)?, "value": rest[1..].join(" ") })), + "type" => Some(json!({ "id": id, "action": "type", "selector": rest.get(0)?, "text": rest[1..].join(" ") })), + "hover" => Some(json!({ "id": id, "action": "hover", "selector": rest.get(0)? })), + "focus" => Some(json!({ "id": id, "action": "focus", "selector": rest.get(0)? })), + "check" => Some(json!({ "id": id, "action": "check", "selector": rest.get(0)? })), + "uncheck" => Some(json!({ "id": id, "action": "uncheck", "selector": rest.get(0)? })), + "select" => Some(json!({ "id": id, "action": "select", "selector": rest.get(0)?, "value": rest.get(1)? })), + "drag" => Some(json!({ "id": id, "action": "drag", "source": rest.get(0)?, "target": rest.get(1)? })), + "upload" => Some(json!({ "id": id, "action": "upload", "selector": rest.get(0)?, "files": &rest[1..] })), + + // === Keyboard === + "press" | "key" => Some(json!({ "id": id, "action": "press", "key": rest.get(0)? })), + "keydown" => Some(json!({ "id": id, "action": "keydown", "key": rest.get(0)? })), + "keyup" => Some(json!({ "id": id, "action": "keyup", "key": rest.get(0)? })), + + // === Scroll === + "scroll" => { + let dir = rest.get(0).unwrap_or(&"down"); + let amount = rest.get(1).and_then(|s| s.parse::().ok()).unwrap_or(300); + Some(json!({ "id": id, "action": "scroll", "direction": dir, "amount": amount })) + } + "scrollintoview" | "scrollinto" => { + Some(json!({ "id": id, "action": "scrollintoview", "selector": rest.get(0)? })) + } + + // === Wait === + "wait" => { + if let Some(arg) = rest.get(0) { + if arg.parse::().is_ok() { + Some(json!({ "id": id, "action": "wait", "timeout": arg.parse::().unwrap() })) + } else { + Some(json!({ "id": id, "action": "wait", "selector": arg })) + } + } else { + None + } + } + + // === Screenshot/PDF === + "screenshot" => { + Some(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full })) + } + "pdf" => Some(json!({ "id": id, "action": "pdf", "path": rest.get(0)? })), + + // === Snapshot === + "snapshot" => { + let mut cmd = json!({ "id": id, "action": "snapshot" }); + let obj = cmd.as_object_mut().unwrap(); + let mut i = 0; + while i < rest.len() { + match rest[i] { + "-i" | "--interactive" => { + obj.insert("interactive".to_string(), json!(true)); + } + "-c" | "--compact" => { + obj.insert("compact".to_string(), json!(true)); + } + "-d" | "--depth" => { + if let Some(d) = rest.get(i + 1) { + if let Ok(n) = d.parse::() { + obj.insert("maxDepth".to_string(), json!(n)); + i += 1; + } + } + } + "-s" | "--selector" => { + if let Some(s) = rest.get(i + 1) { + obj.insert("selector".to_string(), json!(s)); + i += 1; + } + } + _ => {} + } + i += 1; + } + Some(cmd) + } + + // === Eval === + "eval" => Some(json!({ "id": id, "action": "evaluate", "script": rest.join(" ") })), + + // === Close === + "close" | "quit" | "exit" => Some(json!({ "id": id, "action": "close" })), + + // === Get === + "get" => match rest.get(0).map(|s| *s) { + Some("text") => Some(json!({ "id": id, "action": "gettext", "selector": rest.get(1)? })), + Some("html") => Some(json!({ "id": id, "action": "innerhtml", "selector": rest.get(1)? })), + Some("value") => Some(json!({ "id": id, "action": "inputvalue", "selector": rest.get(1)? })), + Some("attr") => Some(json!({ "id": id, "action": "getattribute", "selector": rest.get(1)?, "attribute": rest.get(2)? })), + Some("url") => Some(json!({ "id": id, "action": "url" })), + Some("title") => Some(json!({ "id": id, "action": "title" })), + Some("count") => Some(json!({ "id": id, "action": "count", "selector": rest.get(1)? })), + Some("box") => Some(json!({ "id": id, "action": "boundingbox", "selector": rest.get(1)? })), + _ => None, + }, + + // === Is (state checks) === + "is" => match rest.get(0).map(|s| *s) { + Some("visible") => Some(json!({ "id": id, "action": "isvisible", "selector": rest.get(1)? })), + Some("enabled") => Some(json!({ "id": id, "action": "isenabled", "selector": rest.get(1)? })), + Some("checked") => Some(json!({ "id": id, "action": "ischecked", "selector": rest.get(1)? })), + _ => None, + }, + + // === Find (locators) === + "find" => parse_find(&rest, &id), + + // === Mouse === + "mouse" => match rest.get(0).map(|s| *s) { + Some("move") => { + let x = rest.get(1)?.parse::().ok()?; + let y = rest.get(2)?.parse::().ok()?; + Some(json!({ "id": id, "action": "mousemove", "x": x, "y": y })) + } + Some("down") => { + Some(json!({ "id": id, "action": "mousedown", "button": rest.get(1).unwrap_or(&"left") })) + } + Some("up") => { + Some(json!({ "id": id, "action": "mouseup", "button": rest.get(1).unwrap_or(&"left") })) + } + Some("wheel") => { + let dy = rest.get(1).and_then(|s| s.parse::().ok()).unwrap_or(100); + let dx = rest.get(2).and_then(|s| s.parse::().ok()).unwrap_or(0); + Some(json!({ "id": id, "action": "mousewheel", "deltaX": dx, "deltaY": dy })) + } + _ => None, + }, + + // === Set (browser settings) === + "set" => parse_set(&rest, &id), + + // === Network === + "network" => match rest.get(0).map(|s| *s) { + Some("route") => { + let url = rest.get(1)?; + let abort = rest.iter().any(|&s| s == "--abort"); + let body_idx = rest.iter().position(|&s| s == "--body"); + let body = body_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); + Some(json!({ "id": id, "action": "route", "url": url, "abort": abort, "body": body })) + } + Some("unroute") => Some(json!({ "id": id, "action": "unroute", "url": rest.get(1) })), + Some("requests") => { + let clear = rest.iter().any(|&s| s == "--clear"); + let filter_idx = rest.iter().position(|&s| s == "--filter"); + let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); + Some(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter })) + } + _ => None, + }, + + // === Storage === + "storage" => match rest.get(0).map(|s| *s) { + Some("local") | Some("session") => { + let storage_type = rest.get(0)?; + let op = rest.get(1).unwrap_or(&"get"); + let key = rest.get(2); + let value = rest.get(3); + Some(json!({ "id": id, "action": "storage", "storageType": storage_type, "operation": op, "key": key, "value": value })) + } + _ => None, + }, + + // === Cookies === + "cookies" => { + let op = rest.get(0).unwrap_or(&"get"); + match *op { + "get" => Some(json!({ "id": id, "action": "cookies", "operation": "get", "name": rest.get(1) })), + "set" => Some(json!({ "id": id, "action": "cookies", "operation": "set", "name": rest.get(1)?, "value": rest.get(2)? })), + "clear" => Some(json!({ "id": id, "action": "cookies", "operation": "clear" })), + _ => Some(json!({ "id": id, "action": "cookies", "operation": "get" })), + } + } + + // === Tabs === + "tab" => match rest.get(0).map(|s| *s) { + Some("new") => Some(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })), + Some("list") => Some(json!({ "id": id, "action": "tab_list" })), + Some("close") => { + Some(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::().ok()) })) + } + Some(n) if n.parse::().is_ok() => { + Some(json!({ "id": id, "action": "tab_switch", "index": n.parse::().unwrap() })) + } + _ => Some(json!({ "id": id, "action": "tab_list" })), + }, + + // === Window === + "window" => match rest.get(0).map(|s| *s) { + Some("new") => Some(json!({ "id": id, "action": "window_new" })), + _ => None, + }, + + // === Frame === + "frame" => { + if rest.get(0).map(|s| *s) == Some("main") { + Some(json!({ "id": id, "action": "frame_main" })) + } else { + Some(json!({ "id": id, "action": "frame", "selector": rest.get(0)? })) + } + } + + // === Dialog === + "dialog" => match rest.get(0).map(|s| *s) { + Some("accept") => { + Some(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) })) + } + Some("dismiss") => Some(json!({ "id": id, "action": "dialog", "response": "dismiss" })), + _ => None, + }, + + // === Debug === + "trace" => match rest.get(0).map(|s| *s) { + Some("start") => Some(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })), + Some("stop") => Some(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })), + _ => None, + }, + "console" => { + let clear = rest.iter().any(|&s| s == "--clear"); + Some(json!({ "id": id, "action": "console", "clear": clear })) + } + "errors" => { + let clear = rest.iter().any(|&s| s == "--clear"); + Some(json!({ "id": id, "action": "errors", "clear": clear })) + } + "highlight" => Some(json!({ "id": id, "action": "highlight", "selector": rest.get(0)? })), + + // === State === + "state" => match rest.get(0).map(|s| *s) { + Some("save") => Some(json!({ "id": id, "action": "state_save", "path": rest.get(1)? })), + Some("load") => Some(json!({ "id": id, "action": "state_load", "path": rest.get(1)? })), + _ => None, + }, + + _ => None, + } +} + +fn parse_find(rest: &[&str], id: &str) -> Option { + let locator = rest.get(0)?; + let value = rest.get(1)?; + let subaction = rest.get(2).unwrap_or(&"click"); + let fill_value = if rest.len() > 3 { + Some(rest[3..].join(" ")) + } else { + None + }; + + let name_idx = rest.iter().position(|&s| s == "--name"); + let name = name_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); + let exact = rest.iter().any(|&s| s == "--exact"); + + match *locator { + "role" => Some(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })), + "text" => Some(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })), + "label" => Some(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })), + "placeholder" => Some(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })), + "alt" => Some(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact })), + "title" => Some(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })), + "testid" => Some(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })), + "first" => Some(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })), + "last" => Some(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })), + "nth" => { + let idx = value.parse::().ok()?; + let sel = rest.get(2)?; + let sub = rest.get(3).unwrap_or(&"click"); + let fv = if rest.len() > 4 { + Some(rest[4..].join(" ")) + } else { + None + }; + Some(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv })) + } + _ => None, + } +} + +fn parse_set(rest: &[&str], id: &str) -> Option { + match rest.get(0).map(|s| *s) { + Some("viewport") => { + let w = rest.get(1)?.parse::().ok()?; + let h = rest.get(2)?.parse::().ok()?; + Some(json!({ "id": id, "action": "viewport", "width": w, "height": h })) + } + Some("device") => Some(json!({ "id": id, "action": "device", "device": rest.get(1)? })), + Some("geo") | Some("geolocation") => { + let lat = rest.get(1)?.parse::().ok()?; + let lng = rest.get(2)?.parse::().ok()?; + Some(json!({ "id": id, "action": "geolocation", "latitude": lat, "longitude": lng })) + } + Some("offline") => { + let off = rest.get(1).map(|s| *s != "off" && *s != "false").unwrap_or(true); + Some(json!({ "id": id, "action": "offline", "offline": off })) + } + Some("headers") => { + let headers_json = rest.get(1)?; + Some(json!({ "id": id, "action": "headers", "headers": headers_json })) + } + Some("credentials") | Some("auth") => { + Some(json!({ "id": id, "action": "credentials", "username": rest.get(1)?, "password": rest.get(2)? })) + } + Some("media") => { + let color = if rest.iter().any(|&s| s == "dark") { + "dark" + } else if rest.iter().any(|&s| s == "light") { + "light" + } else { + "no-preference" + }; + let reduced = rest.iter().any(|&s| s == "reduced-motion"); + Some(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced })) + } + _ => None, + } +} diff --git a/cli/src/connection.rs b/cli/src/connection.rs new file mode 100644 index 0000000..bbb8d2c --- /dev/null +++ b/cli/src/connection.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::env; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +#[cfg(unix)] +use std::os::unix::net::UnixStream; + +#[derive(Serialize)] +#[allow(dead_code)] +pub struct Request { + pub id: String, + pub action: String, + #[serde(flatten)] + pub extra: Value, +} + +#[derive(Deserialize, Serialize, Default)] +pub struct Response { + pub success: bool, + pub data: Option, + pub error: Option, +} + +#[allow(dead_code)] +pub enum Connection { + #[cfg(unix)] + Unix(UnixStream), + Tcp(TcpStream), +} + +impl Read for Connection { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Connection::Unix(s) => s.read(buf), + Connection::Tcp(s) => s.read(buf), + } + } +} + +impl Write for Connection { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Connection::Unix(s) => s.write(buf), + Connection::Tcp(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Connection::Unix(s) => s.flush(), + Connection::Tcp(s) => s.flush(), + } + } +} + +impl Connection { + pub fn set_read_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Connection::Unix(s) => s.set_read_timeout(dur), + Connection::Tcp(s) => s.set_read_timeout(dur), + } + } + + pub fn set_write_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Connection::Unix(s) => s.set_write_timeout(dur), + Connection::Tcp(s) => s.set_write_timeout(dur), + } + } +} + +#[cfg(unix)] +fn get_socket_path(session: &str) -> PathBuf { + let tmp = env::temp_dir(); + tmp.join(format!("agent-browser-{}.sock", session)) +} + +fn get_pid_path(session: &str) -> PathBuf { + let tmp = env::temp_dir(); + tmp.join(format!("agent-browser-{}.pid", session)) +} + +#[cfg(windows)] +fn get_port_path(session: &str) -> PathBuf { + let tmp = env::temp_dir(); + tmp.join(format!("agent-browser-{}.port", session)) +} + +#[cfg(windows)] +fn get_port_for_session(session: &str) -> u16 { + let mut hash: i32 = 0; + for c in session.chars() { + hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32); + } + 49152 + ((hash.abs() as u16) % 16383) +} + +#[cfg(unix)] +fn is_daemon_running(session: &str) -> bool { + let pid_path = get_pid_path(session); + if !pid_path.exists() { + return false; + } + if let Ok(pid_str) = fs::read_to_string(&pid_path) { + if let Ok(pid) = pid_str.trim().parse::() { + unsafe { + return libc::kill(pid, 0) == 0; + } + } + } + false +} + +#[cfg(windows)] +fn is_daemon_running(session: &str) -> bool { + let pid_path = get_pid_path(session); + if !pid_path.exists() { + return false; + } + let port = get_port_for_session(session); + TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_millis(100), + ) + .is_ok() +} + +fn daemon_ready(session: &str) -> bool { + #[cfg(unix)] + { + get_socket_path(session).exists() + } + #[cfg(windows)] + { + let port = get_port_for_session(session); + TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_millis(50), + ) + .is_ok() + } +} + +pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { + if is_daemon_running(session) && daemon_ready(session) { + return Ok(()); + } + + let exe_path = env::current_exe().map_err(|e| e.to_string())?; + let exe_dir = exe_path.parent().unwrap(); + + let daemon_paths = [ + exe_dir.join("daemon.js"), + exe_dir.join("../dist/daemon.js"), + PathBuf::from("dist/daemon.js"), + ]; + + let daemon_path = daemon_paths + .iter() + .find(|p| p.exists()) + .ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?; + + let mut cmd = Command::new("node"); + cmd.arg(daemon_path) + .env("AGENT_BROWSER_DAEMON", "1") + .env("AGENT_BROWSER_SESSION", session); + + if headed { + cmd.env("AGENT_BROWSER_HEADED", "1"); + } + + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to start daemon: {}", e))?; + + for _ in 0..50 { + if daemon_ready(session) { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + + Err("Daemon failed to start".to_string()) +} + +fn connect(session: &str) -> Result { + #[cfg(unix)] + { + let socket_path = get_socket_path(session); + UnixStream::connect(&socket_path) + .map(Connection::Unix) + .map_err(|e| format!("Failed to connect: {}", e)) + } + #[cfg(windows)] + { + let port = get_port_for_session(session); + TcpStream::connect(format!("127.0.0.1:{}", port)) + .map(Connection::Tcp) + .map_err(|e| format!("Failed to connect: {}", e)) + } +} + +pub fn send_command(cmd: Value, session: &str) -> Result { + let mut stream = connect(session)?; + + stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let mut json_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?; + json_str.push('\n'); + + stream + .write_all(json_str.as_bytes()) + .map_err(|e| format!("Failed to send: {}", e))?; + + let mut reader = BufReader::new(stream); + let mut response_line = String::new(); + reader + .read_line(&mut response_line) + .map_err(|e| format!("Failed to read: {}", e))?; + + serde_json::from_str(&response_line).map_err(|e| format!("Invalid response: {}", e)) +} diff --git a/cli/src/flags.rs b/cli/src/flags.rs new file mode 100644 index 0000000..223734e --- /dev/null +++ b/cli/src/flags.rs @@ -0,0 +1,58 @@ +use std::env; + +pub struct Flags { + pub json: bool, + pub full: bool, + pub headed: bool, + pub debug: bool, + pub session: String, +} + +pub fn parse_flags(args: &[String]) -> Flags { + let mut flags = Flags { + json: false, + full: false, + headed: false, + debug: false, + session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), + }; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--json" => flags.json = true, + "--full" | "-f" => flags.full = true, + "--headed" => flags.headed = true, + "--debug" => flags.debug = true, + "--session" => { + if let Some(s) = args.get(i + 1) { + flags.session = s.clone(); + i += 1; + } + } + _ => {} + } + i += 1; + } + flags +} + +pub fn clean_args(args: &[String]) -> Vec { + let mut result = Vec::new(); + let mut skip_next = false; + + for arg in args.iter() { + if skip_next { + skip_next = false; + continue; + } + if arg == "--session" { + skip_next = true; + continue; + } + if !arg.starts_with("--") && arg != "-f" { + result.push(arg.clone()); + } + } + result +} diff --git a/cli/src/install.rs b/cli/src/install.rs new file mode 100644 index 0000000..297af2b --- /dev/null +++ b/cli/src/install.rs @@ -0,0 +1,181 @@ +use std::process::{exit, Command, Stdio}; + +pub fn run_install(with_deps: bool) { + let is_linux = cfg!(target_os = "linux"); + + if is_linux { + if with_deps { + println!("\x1b[36mInstalling system dependencies...\x1b[0m"); + + let (pkg_mgr, deps) = if which_exists("apt-get") { + ( + "apt-get", + vec![ + "libxcb-shm0", + "libx11-xcb1", + "libx11-6", + "libxcb1", + "libxext6", + "libxrandr2", + "libxcomposite1", + "libxcursor1", + "libxdamage1", + "libxfixes3", + "libxi6", + "libgtk-3-0", + "libpangocairo-1.0-0", + "libpango-1.0-0", + "libatk1.0-0", + "libcairo-gobject2", + "libcairo2", + "libgdk-pixbuf-2.0-0", + "libxrender1", + "libasound2", + "libfreetype6", + "libfontconfig1", + "libdbus-1-3", + "libnss3", + "libnspr4", + "libatk-bridge2.0-0", + "libdrm2", + "libxkbcommon0", + "libatspi2.0-0", + "libcups2", + "libxshmfence1", + "libgbm1", + ], + ) + } else if which_exists("dnf") { + ( + "dnf", + vec![ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups-libs", + "libdrm", + "libXcomposite", + "libXdamage", + "libXrandr", + "mesa-libgbm", + "pango", + "alsa-lib", + "libxkbcommon", + "libxcb", + "libX11-xcb", + "libX11", + "libXext", + "libXcursor", + "libXfixes", + "libXi", + "gtk3", + "cairo-gobject", + ], + ) + } else if which_exists("yum") { + ( + "yum", + vec![ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups-libs", + "libdrm", + "libXcomposite", + "libXdamage", + "libXrandr", + "mesa-libgbm", + "pango", + "alsa-lib", + "libxkbcommon", + ], + ) + } else { + eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)"); + exit(1); + }; + + let install_cmd = match pkg_mgr { + "apt-get" => { + format!( + "sudo apt-get update && sudo apt-get install -y {}", + deps.join(" ") + ) + } + _ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")), + }; + + println!("Running: {}", install_cmd); + let status = Command::new("sh").arg("-c").arg(&install_cmd).status(); + + match status { + Ok(s) if s.success() => { + println!("\x1b[32m✓\x1b[0m System dependencies installed") + } + Ok(_) => eprintln!( + "\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo." + ), + Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e), + } + } else { + println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:"); + println!(" agent-browser install --with-deps"); + println!(" or: npx playwright install-deps chromium"); + println!(); + } + } + + println!("\x1b[36mInstalling Chromium browser...\x1b[0m"); + let status = Command::new("npx") + .args(["playwright", "install", "chromium"]) + .status(); + + match status { + Ok(s) if s.success() => { + println!("\x1b[32m✓\x1b[0m Chromium installed successfully"); + if is_linux && !with_deps { + println!(); + println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:"); + println!(" agent-browser install --with-deps"); + } + } + Ok(_) => { + eprintln!("\x1b[31m✗\x1b[0m Failed to install browser"); + if is_linux { + println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:"); + println!(" agent-browser install --with-deps"); + } + exit(1); + } + Err(e) => { + eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e); + eprintln!("Make sure Node.js is installed and npx is in your PATH"); + exit(1); + } + } +} + +fn which_exists(cmd: &str) -> bool { + #[cfg(unix)] + { + Command::new("which") + .arg(cmd) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + #[cfg(windows)] + { + Command::new("where") + .arg(cmd) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 535ce22..d715e3f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,979 +1,48 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +mod commands; +mod connection; +mod flags; +mod install; +mod output; + +use serde_json::json; use std::env; -use std::fs; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::path::PathBuf; -use std::process::{exit, Command, Stdio}; -use std::thread; -use std::time::Duration; +use std::process::exit; -// Unix socket support (Unix only) -#[cfg(unix)] -use std::os::unix::net::UnixStream; - -#[derive(Serialize)] -#[allow(dead_code)] -struct Request { - id: String, - action: String, - #[serde(flatten)] - extra: Value, -} - -#[derive(Deserialize, Serialize, Default)] -struct Response { - success: bool, - data: Option, - error: Option, -} - -// Connection type abstraction -#[allow(dead_code)] -enum Connection { - #[cfg(unix)] - Unix(UnixStream), - Tcp(TcpStream), -} - -impl Read for Connection { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - match self { - #[cfg(unix)] - Connection::Unix(s) => s.read(buf), - Connection::Tcp(s) => s.read(buf), - } - } -} - -impl Write for Connection { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - match self { - #[cfg(unix)] - Connection::Unix(s) => s.write(buf), - Connection::Tcp(s) => s.write(buf), - } - } - - fn flush(&mut self) -> std::io::Result<()> { - match self { - #[cfg(unix)] - Connection::Unix(s) => s.flush(), - Connection::Tcp(s) => s.flush(), - } - } -} - -impl Connection { - fn set_read_timeout(&self, dur: Option) -> std::io::Result<()> { - match self { - #[cfg(unix)] - Connection::Unix(s) => s.set_read_timeout(dur), - Connection::Tcp(s) => s.set_read_timeout(dur), - } - } - - fn set_write_timeout(&self, dur: Option) -> std::io::Result<()> { - match self { - #[cfg(unix)] - Connection::Unix(s) => s.set_write_timeout(dur), - Connection::Tcp(s) => s.set_write_timeout(dur), - } - } -} - -#[cfg(unix)] -fn get_socket_path(session: &str) -> PathBuf { - let tmp = env::temp_dir(); - tmp.join(format!("agent-browser-{}.sock", session)) -} - -fn get_pid_path(session: &str) -> PathBuf { - let tmp = env::temp_dir(); - tmp.join(format!("agent-browser-{}.pid", session)) -} - -#[cfg(windows)] -fn get_port_path(session: &str) -> PathBuf { - let tmp = env::temp_dir(); - tmp.join(format!("agent-browser-{}.port", session)) -} - -/// Get port number for TCP mode (Windows) -/// Uses a hash of the session name to get a consistent port -#[cfg(windows)] -fn get_port_for_session(session: &str) -> u16 { - let mut hash: i32 = 0; - for c in session.chars() { - hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32); - } - // Port range 49152-65535 (dynamic/private ports) - 49152 + ((hash.abs() as u16) % 16383) -} - -#[cfg(unix)] -fn is_daemon_running(session: &str) -> bool { - let pid_path = get_pid_path(session); - if !pid_path.exists() { - return false; - } - if let Ok(pid_str) = fs::read_to_string(&pid_path) { - if let Ok(pid) = pid_str.trim().parse::() { - unsafe { - return libc::kill(pid, 0) == 0; - } - } - } - false -} - -#[cfg(windows)] -fn is_daemon_running(session: &str) -> bool { - let pid_path = get_pid_path(session); - if !pid_path.exists() { - return false; - } - // On Windows, try to connect to the port to check if daemon is running - let port = get_port_for_session(session); - TcpStream::connect_timeout( - &format!("127.0.0.1:{}", port).parse().unwrap(), - Duration::from_millis(100) - ).is_ok() -} - -fn daemon_ready(session: &str) -> bool { - #[cfg(unix)] - { - get_socket_path(session).exists() - } - #[cfg(windows)] - { - // On Windows, try to connect to verify daemon is ready - let port = get_port_for_session(session); - TcpStream::connect_timeout( - &format!("127.0.0.1:{}", port).parse().unwrap(), - Duration::from_millis(50) - ).is_ok() - } -} - -fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { - if is_daemon_running(session) && daemon_ready(session) { - return Ok(()); - } - - let exe_path = env::current_exe().map_err(|e| e.to_string())?; - let exe_dir = exe_path.parent().unwrap(); - - let daemon_paths = [ - exe_dir.join("daemon.js"), - exe_dir.join("../dist/daemon.js"), - PathBuf::from("dist/daemon.js"), - ]; - - let daemon_path = daemon_paths - .iter() - .find(|p| p.exists()) - .ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?; - - let mut cmd = Command::new("node"); - cmd.arg(daemon_path) - .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", session); - - if headed { - cmd.env("AGENT_BROWSER_HEADED", "1"); - } - - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to start daemon: {}", e))?; - - for _ in 0..50 { - if daemon_ready(session) { - return Ok(()); - } - thread::sleep(Duration::from_millis(100)); - } - - Err("Daemon failed to start".to_string()) -} - -fn connect(session: &str) -> Result { - #[cfg(unix)] - { - let socket_path = get_socket_path(session); - UnixStream::connect(&socket_path) - .map(Connection::Unix) - .map_err(|e| format!("Failed to connect: {}", e)) - } - #[cfg(windows)] - { - let port = get_port_for_session(session); - TcpStream::connect(format!("127.0.0.1:{}", port)) - .map(Connection::Tcp) - .map_err(|e| format!("Failed to connect: {}", e)) - } -} - -fn send_command(cmd: Value, session: &str) -> Result { - let mut stream = connect(session)?; - - stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); - stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); - - let mut json_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?; - json_str.push('\n'); - - stream.write_all(json_str.as_bytes()) - .map_err(|e| format!("Failed to send: {}", e))?; - - let mut reader = BufReader::new(stream); - let mut response_line = String::new(); - reader.read_line(&mut response_line) - .map_err(|e| format!("Failed to read: {}", e))?; - - serde_json::from_str(&response_line) - .map_err(|e| format!("Invalid response: {}", e)) -} - -fn gen_id() -> String { - format!("r{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_micros() % 1000000) -} - -struct Flags { - json: bool, - full: bool, - headed: bool, - debug: bool, - session: String, -} - -fn parse_flags(args: &[String]) -> Flags { - let mut flags = Flags { - json: false, - full: false, - headed: false, - debug: false, - session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), - }; - - let mut i = 0; - while i < args.len() { - match args[i].as_str() { - "--json" => flags.json = true, - "--full" | "-f" => flags.full = true, - "--headed" => flags.headed = true, - "--debug" => flags.debug = true, - "--session" => { - if let Some(s) = args.get(i + 1) { - flags.session = s.clone(); - i += 1; - } - } - _ => {} - } - i += 1; - } - flags -} - -fn clean_args(args: &[String]) -> Vec { - let mut result = Vec::new(); - let mut skip_next = false; - - for (_i, arg) in args.iter().enumerate() { - if skip_next { - skip_next = false; - continue; - } - if arg == "--session" { - skip_next = true; - continue; - } - if !arg.starts_with("--") && arg != "-f" { - result.push(arg.clone()); - } - } - result -} - -fn parse_command(args: &[String], flags: &Flags) -> Option { - if args.is_empty() { - return None; - } - - let cmd = args[0].as_str(); - let rest: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect(); - let id = gen_id(); - - match cmd { - // === Navigation === - "open" | "goto" | "navigate" => { - let url = rest.get(0)?; - let url = if url.starts_with("http") { url.to_string() } else { format!("https://{}", url) }; - Some(json!({ "id": id, "action": "navigate", "url": url })) - } - "back" => Some(json!({ "id": id, "action": "back" })), - "forward" => Some(json!({ "id": id, "action": "forward" })), - "reload" => Some(json!({ "id": id, "action": "reload" })), - - // === Core Actions === - "click" => Some(json!({ "id": id, "action": "click", "selector": rest.get(0)? })), - "dblclick" => Some(json!({ "id": id, "action": "dblclick", "selector": rest.get(0)? })), - "fill" => Some(json!({ "id": id, "action": "fill", "selector": rest.get(0)?, "value": rest[1..].join(" ") })), - "type" => Some(json!({ "id": id, "action": "type", "selector": rest.get(0)?, "text": rest[1..].join(" ") })), - "hover" => Some(json!({ "id": id, "action": "hover", "selector": rest.get(0)? })), - "focus" => Some(json!({ "id": id, "action": "focus", "selector": rest.get(0)? })), - "check" => Some(json!({ "id": id, "action": "check", "selector": rest.get(0)? })), - "uncheck" => Some(json!({ "id": id, "action": "uncheck", "selector": rest.get(0)? })), - "select" => Some(json!({ "id": id, "action": "select", "selector": rest.get(0)?, "value": rest.get(1)? })), - "drag" => Some(json!({ "id": id, "action": "drag", "source": rest.get(0)?, "target": rest.get(1)? })), - "upload" => Some(json!({ "id": id, "action": "upload", "selector": rest.get(0)?, "files": &rest[1..] })), - - // === Keyboard === - "press" | "key" => Some(json!({ "id": id, "action": "press", "key": rest.get(0)? })), - "keydown" => Some(json!({ "id": id, "action": "keydown", "key": rest.get(0)? })), - "keyup" => Some(json!({ "id": id, "action": "keyup", "key": rest.get(0)? })), - - // === Scroll === - "scroll" => { - let dir = rest.get(0).unwrap_or(&"down"); - let amount = rest.get(1).and_then(|s| s.parse::().ok()).unwrap_or(300); - Some(json!({ "id": id, "action": "scroll", "direction": dir, "amount": amount })) - } - "scrollintoview" | "scrollinto" => Some(json!({ "id": id, "action": "scrollintoview", "selector": rest.get(0)? })), - - // === Wait === - "wait" => { - if let Some(arg) = rest.get(0) { - if arg.parse::().is_ok() { - Some(json!({ "id": id, "action": "wait", "timeout": arg.parse::().unwrap() })) - } else { - Some(json!({ "id": id, "action": "wait", "selector": arg })) - } - } else { - None - } - } - - // === Screenshot/PDF === - "screenshot" => Some(json!({ "id": id, "action": "screenshot", "path": rest.get(0), "fullPage": flags.full })), - "pdf" => Some(json!({ "id": id, "action": "pdf", "path": rest.get(0)? })), - - // === Snapshot === - "snapshot" => { - let mut cmd = json!({ "id": id, "action": "snapshot" }); - let obj = cmd.as_object_mut().unwrap(); - let mut i = 0; - while i < rest.len() { - match rest[i] { - "-i" | "--interactive" => { obj.insert("interactive".to_string(), json!(true)); } - "-c" | "--compact" => { obj.insert("compact".to_string(), json!(true)); } - "-d" | "--depth" => { - if let Some(d) = rest.get(i + 1) { - if let Ok(n) = d.parse::() { - obj.insert("maxDepth".to_string(), json!(n)); - i += 1; - } - } - } - "-s" | "--selector" => { - if let Some(s) = rest.get(i + 1) { - obj.insert("selector".to_string(), json!(s)); - i += 1; - } - } - _ => {} - } - i += 1; - } - Some(cmd) - } - - // === Eval === - "eval" => Some(json!({ "id": id, "action": "evaluate", "script": rest.join(" ") })), - - // === Close === - "close" | "quit" | "exit" => Some(json!({ "id": id, "action": "close" })), - - // === Get === - "get" => match rest.get(0).map(|s| *s) { - Some("text") => Some(json!({ "id": id, "action": "gettext", "selector": rest.get(1)? })), - Some("html") => Some(json!({ "id": id, "action": "innerhtml", "selector": rest.get(1)? })), - Some("value") => Some(json!({ "id": id, "action": "inputvalue", "selector": rest.get(1)? })), - Some("attr") => Some(json!({ "id": id, "action": "getattribute", "selector": rest.get(1)?, "attribute": rest.get(2)? })), - Some("url") => Some(json!({ "id": id, "action": "url" })), - Some("title") => Some(json!({ "id": id, "action": "title" })), - Some("count") => Some(json!({ "id": id, "action": "count", "selector": rest.get(1)? })), - Some("box") => Some(json!({ "id": id, "action": "boundingbox", "selector": rest.get(1)? })), - _ => None, - }, - - // === Is (state checks) === - "is" => match rest.get(0).map(|s| *s) { - Some("visible") => Some(json!({ "id": id, "action": "isvisible", "selector": rest.get(1)? })), - Some("enabled") => Some(json!({ "id": id, "action": "isenabled", "selector": rest.get(1)? })), - Some("checked") => Some(json!({ "id": id, "action": "ischecked", "selector": rest.get(1)? })), - _ => None, - }, - - // === Find (locators) === - "find" => { - let locator = rest.get(0)?; - let value = rest.get(1)?; - let subaction = rest.get(2).unwrap_or(&"click"); - let fill_value = if rest.len() > 3 { Some(rest[3..].join(" ")) } else { None }; - - // Check for --name flag - let name_idx = rest.iter().position(|&s| s == "--name"); - let name = name_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); - let exact = rest.iter().any(|&s| s == "--exact"); - - match *locator { - "role" => Some(json!({ "id": id, "action": "getbyrole", "role": value, "subaction": subaction, "value": fill_value, "name": name, "exact": exact })), - "text" => Some(json!({ "id": id, "action": "getbytext", "text": value, "subaction": subaction, "exact": exact })), - "label" => Some(json!({ "id": id, "action": "getbylabel", "label": value, "subaction": subaction, "value": fill_value, "exact": exact })), - "placeholder" => Some(json!({ "id": id, "action": "getbyplaceholder", "placeholder": value, "subaction": subaction, "value": fill_value, "exact": exact })), - "alt" => Some(json!({ "id": id, "action": "getbyalttext", "text": value, "subaction": subaction, "exact": exact })), - "title" => Some(json!({ "id": id, "action": "getbytitle", "text": value, "subaction": subaction, "exact": exact })), - "testid" => Some(json!({ "id": id, "action": "getbytestid", "testId": value, "subaction": subaction, "value": fill_value })), - "first" => Some(json!({ "id": id, "action": "nth", "selector": value, "index": 0, "subaction": subaction, "value": fill_value })), - "last" => Some(json!({ "id": id, "action": "nth", "selector": value, "index": -1, "subaction": subaction, "value": fill_value })), - "nth" => { - let idx = value.parse::().ok()?; - let sel = rest.get(2)?; - let sub = rest.get(3).unwrap_or(&"click"); - let fv = if rest.len() > 4 { Some(rest[4..].join(" ")) } else { None }; - Some(json!({ "id": id, "action": "nth", "selector": sel, "index": idx, "subaction": sub, "value": fv })) - } - _ => None, - } - } - - // === Mouse === - "mouse" => match rest.get(0).map(|s| *s) { - Some("move") => { - let x = rest.get(1)?.parse::().ok()?; - let y = rest.get(2)?.parse::().ok()?; - Some(json!({ "id": id, "action": "mousemove", "x": x, "y": y })) - } - Some("down") => Some(json!({ "id": id, "action": "mousedown", "button": rest.get(1).unwrap_or(&"left") })), - Some("up") => Some(json!({ "id": id, "action": "mouseup", "button": rest.get(1).unwrap_or(&"left") })), - Some("wheel") => { - let dy = rest.get(1).and_then(|s| s.parse::().ok()).unwrap_or(100); - let dx = rest.get(2).and_then(|s| s.parse::().ok()).unwrap_or(0); - Some(json!({ "id": id, "action": "mousewheel", "deltaX": dx, "deltaY": dy })) - } - _ => None, - }, - - // === Set (browser settings) === - "set" => match rest.get(0).map(|s| *s) { - Some("viewport") => { - let w = rest.get(1)?.parse::().ok()?; - let h = rest.get(2)?.parse::().ok()?; - Some(json!({ "id": id, "action": "viewport", "width": w, "height": h })) - } - Some("device") => Some(json!({ "id": id, "action": "device", "device": rest.get(1)? })), - Some("geo") | Some("geolocation") => { - let lat = rest.get(1)?.parse::().ok()?; - let lng = rest.get(2)?.parse::().ok()?; - Some(json!({ "id": id, "action": "geolocation", "latitude": lat, "longitude": lng })) - } - Some("offline") => { - let off = rest.get(1).map(|s| *s != "off" && *s != "false").unwrap_or(true); - Some(json!({ "id": id, "action": "offline", "offline": off })) - } - Some("headers") => { - let headers_json = rest.get(1)?; - Some(json!({ "id": id, "action": "headers", "headers": headers_json })) - } - Some("credentials") | Some("auth") => { - Some(json!({ "id": id, "action": "credentials", "username": rest.get(1)?, "password": rest.get(2)? })) - } - Some("media") => { - let color = if rest.iter().any(|&s| s == "dark") { "dark" } else if rest.iter().any(|&s| s == "light") { "light" } else { "no-preference" }; - let reduced = rest.iter().any(|&s| s == "reduced-motion"); - Some(json!({ "id": id, "action": "media", "colorScheme": color, "reducedMotion": reduced })) - } - _ => None, - }, - - // === Network === - "network" => match rest.get(0).map(|s| *s) { - Some("route") => { - let url = rest.get(1)?; - let abort = rest.iter().any(|&s| s == "--abort"); - let body_idx = rest.iter().position(|&s| s == "--body"); - let body = body_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); - Some(json!({ "id": id, "action": "route", "url": url, "abort": abort, "body": body })) - } - Some("unroute") => Some(json!({ "id": id, "action": "unroute", "url": rest.get(1) })), - Some("requests") => { - let clear = rest.iter().any(|&s| s == "--clear"); - let filter_idx = rest.iter().position(|&s| s == "--filter"); - let filter = filter_idx.and_then(|i| rest.get(i + 1).map(|s| *s)); - Some(json!({ "id": id, "action": "requests", "clear": clear, "filter": filter })) - } - _ => None, - }, - - // === Storage === - "storage" => match rest.get(0).map(|s| *s) { - Some("local") | Some("session") => { - let storage_type = rest.get(0)?; - let op = rest.get(1).unwrap_or(&"get"); - let key = rest.get(2); - let value = rest.get(3); - Some(json!({ "id": id, "action": "storage", "storageType": storage_type, "operation": op, "key": key, "value": value })) - } - _ => None, - }, - - // === Cookies === - "cookies" => { - let op = rest.get(0).unwrap_or(&"get"); - match *op { - "get" => Some(json!({ "id": id, "action": "cookies", "operation": "get", "name": rest.get(1) })), - "set" => Some(json!({ "id": id, "action": "cookies", "operation": "set", "name": rest.get(1)?, "value": rest.get(2)? })), - "clear" => Some(json!({ "id": id, "action": "cookies", "operation": "clear" })), - _ => Some(json!({ "id": id, "action": "cookies", "operation": "get" })), - } - } - - // === Tabs === - "tab" => match rest.get(0).map(|s| *s) { - Some("new") => Some(json!({ "id": id, "action": "tab_new", "url": rest.get(1) })), - Some("list") => Some(json!({ "id": id, "action": "tab_list" })), - Some("close") => Some(json!({ "id": id, "action": "tab_close", "index": rest.get(1).and_then(|s| s.parse::().ok()) })), - Some(n) if n.parse::().is_ok() => Some(json!({ "id": id, "action": "tab_switch", "index": n.parse::().unwrap() })), - _ => Some(json!({ "id": id, "action": "tab_list" })), - }, - - // === Window === - "window" => match rest.get(0).map(|s| *s) { - Some("new") => Some(json!({ "id": id, "action": "window_new" })), - _ => None, - }, - - // === Frame === - "frame" => { - if rest.get(0).map(|s| *s) == Some("main") { - Some(json!({ "id": id, "action": "frame_main" })) - } else { - Some(json!({ "id": id, "action": "frame", "selector": rest.get(0)? })) - } - } - - // === Dialog === - "dialog" => match rest.get(0).map(|s| *s) { - Some("accept") => Some(json!({ "id": id, "action": "dialog", "response": "accept", "promptText": rest.get(1) })), - Some("dismiss") => Some(json!({ "id": id, "action": "dialog", "response": "dismiss" })), - _ => None, - }, - - // === Debug === - "trace" => match rest.get(0).map(|s| *s) { - Some("start") => Some(json!({ "id": id, "action": "trace_start", "path": rest.get(1) })), - Some("stop") => Some(json!({ "id": id, "action": "trace_stop", "path": rest.get(1) })), - _ => None, - }, - "console" => { - let clear = rest.iter().any(|&s| s == "--clear"); - Some(json!({ "id": id, "action": "console", "clear": clear })) - } - "errors" => { - let clear = rest.iter().any(|&s| s == "--clear"); - Some(json!({ "id": id, "action": "errors", "clear": clear })) - } - "highlight" => Some(json!({ "id": id, "action": "highlight", "selector": rest.get(0)? })), - - // === State === - "state" => match rest.get(0).map(|s| *s) { - Some("save") => Some(json!({ "id": id, "action": "state_save", "path": rest.get(1)? })), - Some("load") => Some(json!({ "id": id, "action": "state_load", "path": rest.get(1)? })), - _ => None, - }, - - _ => None, - } -} - -fn print_response(resp: &Response, json_mode: bool) { - if json_mode { - println!("{}", serde_json::to_string(resp).unwrap_or_default()); - return; - } - - if !resp.success { - eprintln!("\x1b[31m✗\x1b[0m {}", resp.error.as_deref().unwrap_or("Unknown error")); - return; - } - - if let Some(data) = &resp.data { - // Navigation response - if let Some(url) = data.get("url").and_then(|v| v.as_str()) { - if let Some(title) = data.get("title").and_then(|v| v.as_str()) { - println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title); - println!("\x1b[2m {}\x1b[0m", url); - return; - } - println!("{}", url); - return; - } - // Snapshot - if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) { - println!("{}", snapshot); - return; - } - // Title - if let Some(title) = data.get("title").and_then(|v| v.as_str()) { - println!("{}", title); - return; - } - // Text - if let Some(text) = data.get("text").and_then(|v| v.as_str()) { - println!("{}", text); - return; - } - // HTML - if let Some(html) = data.get("html").and_then(|v| v.as_str()) { - println!("{}", html); - return; - } - // Value - if let Some(value) = data.get("value").and_then(|v| v.as_str()) { - println!("{}", value); - return; - } - // Count - if let Some(count) = data.get("count").and_then(|v| v.as_i64()) { - println!("{}", count); - return; - } - // Boolean results - if let Some(visible) = data.get("visible").and_then(|v| v.as_bool()) { - println!("{}", visible); - return; - } - if let Some(enabled) = data.get("enabled").and_then(|v| v.as_bool()) { - println!("{}", enabled); - return; - } - if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) { - println!("{}", checked); - return; - } - // Eval result - if let Some(result) = data.get("result") { - println!("{}", serde_json::to_string_pretty(result).unwrap_or_default()); - return; - } - // Tabs - if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) { - for (i, tab) in tabs.iter().enumerate() { - let title = tab.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled"); - let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false); - let marker = if active { "→" } else { " " }; - println!("{} [{}] {} - {}", marker, i, title, url); - } - return; - } - // Console logs - if let Some(logs) = data.get("logs").and_then(|v| v.as_array()) { - for log in logs { - let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); - let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); - let color = match level { - "error" => "\x1b[31m", - "warning" => "\x1b[33m", - "info" => "\x1b[36m", - _ => "\x1b[0m", - }; - println!("{}[{}]\x1b[0m {}", color, level, text); - } - return; - } - // Errors - if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) { - for err in errors { - let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); - println!("\x1b[31m✗\x1b[0m {}", msg); - } - return; - } - // Cookies - if let Some(cookies) = data.get("cookies").and_then(|v| v.as_array()) { - for cookie in cookies { - let name = cookie.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let value = cookie.get("value").and_then(|v| v.as_str()).unwrap_or(""); - println!("{}={}", name, value); - } - return; - } - // Bounding box - if let Some(box_data) = data.get("box") { - println!("{}", serde_json::to_string_pretty(box_data).unwrap_or_default()); - return; - } - // Closed - if data.get("closed").is_some() { - println!("\x1b[32m✓\x1b[0m Browser closed"); - return; - } - // Screenshot path - if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path); - return; - } - // Default success - println!("\x1b[32m✓\x1b[0m Done"); - } -} - -fn print_help() { - println!(r#" -agent-browser - fast browser automation CLI for AI agents - -Usage: agent-browser [args] [options] - -Core Commands: - open Navigate to URL - click Click element (or @ref) - dblclick Double-click element - type Type into element - fill Clear and fill - press Press key (Enter, Tab, Control+a) - hover Hover element - focus Focus element - check Check checkbox - uncheck Uncheck checkbox - select Select dropdown option - drag Drag and drop - upload Upload files - scroll [px] Scroll (up/down/left/right) - scrollintoview Scroll element into view - wait Wait for element or time - screenshot [path] Take screenshot - pdf Save as PDF - snapshot Accessibility tree with refs (for AI) - eval Run JavaScript - close Close browser - -Navigation: - back Go back - forward Go forward - reload Reload page - -Get Info: agent-browser get [selector] - text, html, value, attr , title, url, count, box - -Check State: agent-browser is - visible, enabled, checked - -Find Elements: agent-browser find [text] - role, text, label, placeholder, alt, title, testid, first, last, nth - -Mouse: agent-browser mouse [args] - move , down [btn], up [btn], wheel [dx] - -Browser Settings: agent-browser set [value] - viewport , device , geo - offline [on|off], headers , credentials - media [dark|light] [reduced-motion] - -Network: agent-browser network - route [--abort|--body ] - unroute [url] - requests [--clear] [--filter ] - -Storage: - cookies [get|set|clear] Manage cookies - storage Manage web storage - -Tabs: - tab [new|list|close|] Manage tabs - -Debug: - trace start|stop [path] Record trace - console [--clear] View console logs - errors [--clear] View page errors - highlight Highlight element - -Setup: - install Install browser binaries - install --with-deps Also install system dependencies (Linux) - -Snapshot Options: - -i, --interactive Only interactive elements - -c, --compact Remove empty structural elements - -d, --depth Limit tree depth - -s, --selector Scope to CSS selector - -Options: - --session Isolated session (or AGENT_BROWSER_SESSION env) - --json JSON output - --full, -f Full page screenshot - --headed Show browser window (not headless) - --debug Debug output - -Examples: - agent-browser open example.com - agent-browser snapshot -i # Interactive elements only - agent-browser click @e2 # Click by ref from snapshot - agent-browser fill @e3 "test@example.com" - agent-browser find role button click --name Submit - agent-browser get text @e1 - agent-browser screenshot --full -"#); -} - -fn run_install(with_deps: bool) { - let is_linux = cfg!(target_os = "linux"); - - if is_linux { - if with_deps { - println!("\x1b[36mInstalling system dependencies...\x1b[0m"); - - let (pkg_mgr, deps) = if which_exists("apt-get") { - ("apt-get", vec![ - "libxcb-shm0", "libx11-xcb1", "libx11-6", "libxcb1", "libxext6", - "libxrandr2", "libxcomposite1", "libxcursor1", "libxdamage1", "libxfixes3", - "libxi6", "libgtk-3-0", "libpangocairo-1.0-0", "libpango-1.0-0", "libatk1.0-0", - "libcairo-gobject2", "libcairo2", "libgdk-pixbuf-2.0-0", "libxrender1", - "libasound2", "libfreetype6", "libfontconfig1", "libdbus-1-3", "libnss3", - "libnspr4", "libatk-bridge2.0-0", "libdrm2", "libxkbcommon0", "libatspi2.0-0", - "libcups2", "libxshmfence1", "libgbm1", - ]) - } else if which_exists("dnf") { - ("dnf", vec![ - "nss", "nspr", "atk", "at-spi2-atk", "cups-libs", "libdrm", - "libXcomposite", "libXdamage", "libXrandr", "mesa-libgbm", "pango", - "alsa-lib", "libxkbcommon", "libxcb", "libX11-xcb", "libX11", "libXext", - "libXcursor", "libXfixes", "libXi", "gtk3", "cairo-gobject", - ]) - } else if which_exists("yum") { - ("yum", vec![ - "nss", "nspr", "atk", "at-spi2-atk", "cups-libs", "libdrm", - "libXcomposite", "libXdamage", "libXrandr", "mesa-libgbm", "pango", - "alsa-lib", "libxkbcommon", - ]) - } else { - eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)"); - exit(1); - }; - - let install_cmd = match pkg_mgr { - "apt-get" => format!("sudo apt-get update && sudo apt-get install -y {}", deps.join(" ")), - _ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")), - }; - - println!("Running: {}", install_cmd); - let status = Command::new("sh") - .arg("-c") - .arg(&install_cmd) - .status(); - - match status { - Ok(s) if s.success() => println!("\x1b[32m✓\x1b[0m System dependencies installed"), - Ok(_) => eprintln!("\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."), - Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e), - } - } else { - println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:"); - println!(" agent-browser install --with-deps"); - println!(" or: npx playwright install-deps chromium"); - println!(); - } - } - - println!("\x1b[36mInstalling Chromium browser...\x1b[0m"); - let status = Command::new("npx") - .args(["playwright", "install", "chromium"]) - .status(); - - match status { - Ok(s) if s.success() => { - println!("\x1b[32m✓\x1b[0m Chromium installed successfully"); - if is_linux && !with_deps { - println!(); - println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:"); - println!(" agent-browser install --with-deps"); - } - } - Ok(_) => { - eprintln!("\x1b[31m✗\x1b[0m Failed to install browser"); - if is_linux { - println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:"); - println!(" agent-browser install --with-deps"); - } - exit(1); - } - Err(e) => { - eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e); - eprintln!("Make sure Node.js is installed and npx is in your PATH"); - exit(1); - } - } -} - -fn which_exists(cmd: &str) -> bool { - #[cfg(unix)] - { - Command::new("which") - .arg(cmd) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - #[cfg(windows)] - { - Command::new("where") - .arg(cmd) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } -} +use commands::{gen_id, parse_command}; +use connection::{ensure_daemon, send_command}; +use flags::{clean_args, parse_flags}; +use install::run_install; +use output::{print_help, print_response}; fn main() { let args: Vec = env::args().skip(1).collect(); let flags = parse_flags(&args); let clean = clean_args(&args); - + if clean.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") { print_help(); return; } - + // Handle install separately if clean.get(0).map(|s| s.as_str()) == Some("install") { let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d"); run_install(with_deps); return; } - + let cmd = match parse_command(&clean, &flags) { Some(c) => c, None => { - eprintln!("\x1b[31mUnknown command:\x1b[0m {}", clean.get(0).unwrap_or(&String::new())); + eprintln!( + "\x1b[31mUnknown command:\x1b[0m {}", + clean.get(0).unwrap_or(&String::new()) + ); eprintln!("\x1b[2mRun: agent-browser --help\x1b[0m"); exit(1); } }; - + if let Err(e) = ensure_daemon(&flags.session, flags.headed) { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); @@ -982,7 +51,7 @@ fn main() { } exit(1); } - + // If --headed flag is set, send launch command first to switch to headed mode if flags.headed { let launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": false }); @@ -992,7 +61,7 @@ fn main() { } } } - + match send_command(cmd, &flags.session) { Ok(resp) => { let success = resp.success; diff --git a/cli/src/output.rs b/cli/src/output.rs new file mode 100644 index 0000000..18b4eba --- /dev/null +++ b/cli/src/output.rs @@ -0,0 +1,245 @@ +use crate::connection::Response; + +pub fn print_response(resp: &Response, json_mode: bool) { + if json_mode { + println!("{}", serde_json::to_string(resp).unwrap_or_default()); + return; + } + + if !resp.success { + eprintln!( + "\x1b[31m✗\x1b[0m {}", + resp.error.as_deref().unwrap_or("Unknown error") + ); + return; + } + + if let Some(data) = &resp.data { + // Navigation response + if let Some(url) = data.get("url").and_then(|v| v.as_str()) { + if let Some(title) = data.get("title").and_then(|v| v.as_str()) { + println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title); + println!("\x1b[2m {}\x1b[0m", url); + return; + } + println!("{}", url); + return; + } + // Snapshot + if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) { + println!("{}", snapshot); + return; + } + // Title + if let Some(title) = data.get("title").and_then(|v| v.as_str()) { + println!("{}", title); + return; + } + // Text + if let Some(text) = data.get("text").and_then(|v| v.as_str()) { + println!("{}", text); + return; + } + // HTML + if let Some(html) = data.get("html").and_then(|v| v.as_str()) { + println!("{}", html); + return; + } + // Value + if let Some(value) = data.get("value").and_then(|v| v.as_str()) { + println!("{}", value); + return; + } + // Count + if let Some(count) = data.get("count").and_then(|v| v.as_i64()) { + println!("{}", count); + return; + } + // Boolean results + if let Some(visible) = data.get("visible").and_then(|v| v.as_bool()) { + println!("{}", visible); + return; + } + if let Some(enabled) = data.get("enabled").and_then(|v| v.as_bool()) { + println!("{}", enabled); + return; + } + if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) { + println!("{}", checked); + return; + } + // Eval result + if let Some(result) = data.get("result") { + println!( + "{}", + serde_json::to_string_pretty(result).unwrap_or_default() + ); + return; + } + // Tabs + if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) { + for (i, tab) in tabs.iter().enumerate() { + let title = tab + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled"); + let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false); + let marker = if active { "→" } else { " " }; + println!("{} [{}] {} - {}", marker, i, title, url); + } + return; + } + // Console logs + if let Some(logs) = data.get("logs").and_then(|v| v.as_array()) { + for log in logs { + let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); + let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let color = match level { + "error" => "\x1b[31m", + "warning" => "\x1b[33m", + "info" => "\x1b[36m", + _ => "\x1b[0m", + }; + println!("{}[{}]\x1b[0m {}", color, level, text); + } + return; + } + // Errors + if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) { + for err in errors { + let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); + println!("\x1b[31m✗\x1b[0m {}", msg); + } + return; + } + // Cookies + if let Some(cookies) = data.get("cookies").and_then(|v| v.as_array()) { + for cookie in cookies { + let name = cookie.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = cookie.get("value").and_then(|v| v.as_str()).unwrap_or(""); + println!("{}={}", name, value); + } + return; + } + // Bounding box + if let Some(box_data) = data.get("box") { + println!( + "{}", + serde_json::to_string_pretty(box_data).unwrap_or_default() + ); + return; + } + // Closed + if data.get("closed").is_some() { + println!("\x1b[32m✓\x1b[0m Browser closed"); + return; + } + // Screenshot path + if let Some(path) = data.get("path").and_then(|v| v.as_str()) { + println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path); + return; + } + // Default success + println!("\x1b[32m✓\x1b[0m Done"); + } +} + +pub fn print_help() { + println!( + r#" +agent-browser - fast browser automation CLI for AI agents + +Usage: agent-browser [args] [options] + +Core Commands: + open Navigate to URL + click Click element (or @ref) + dblclick Double-click element + type Type into element + fill Clear and fill + press Press key (Enter, Tab, Control+a) + hover Hover element + focus Focus element + check Check checkbox + uncheck Uncheck checkbox + select Select dropdown option + drag Drag and drop + upload Upload files + scroll [px] Scroll (up/down/left/right) + scrollintoview Scroll element into view + wait Wait for element or time + screenshot [path] Take screenshot + pdf Save as PDF + snapshot Accessibility tree with refs (for AI) + eval Run JavaScript + close Close browser + +Navigation: + back Go back + forward Go forward + reload Reload page + +Get Info: agent-browser get [selector] + text, html, value, attr , title, url, count, box + +Check State: agent-browser is + visible, enabled, checked + +Find Elements: agent-browser find [text] + role, text, label, placeholder, alt, title, testid, first, last, nth + +Mouse: agent-browser mouse [args] + move , down [btn], up [btn], wheel [dx] + +Browser Settings: agent-browser set [value] + viewport , device , geo + offline [on|off], headers , credentials + media [dark|light] [reduced-motion] + +Network: agent-browser network + route [--abort|--body ] + unroute [url] + requests [--clear] [--filter ] + +Storage: + cookies [get|set|clear] Manage cookies + storage Manage web storage + +Tabs: + tab [new|list|close|] Manage tabs + +Debug: + trace start|stop [path] Record trace + console [--clear] View console logs + errors [--clear] View page errors + highlight Highlight element + +Setup: + install Install browser binaries + install --with-deps Also install system dependencies (Linux) + +Snapshot Options: + -i, --interactive Only interactive elements + -c, --compact Remove empty structural elements + -d, --depth Limit tree depth + -s, --selector Scope to CSS selector + +Options: + --session Isolated session (or AGENT_BROWSER_SESSION env) + --json JSON output + --full, -f Full page screenshot + --headed Show browser window (not headless) + --debug Debug output + +Examples: + agent-browser open example.com + agent-browser snapshot -i # Interactive elements only + agent-browser click @e2 # Click by ref from snapshot + agent-browser fill @e3 "test@example.com" + agent-browser find role button click --name Submit + agent-browser get text @e1 + agent-browser screenshot --full +"# + ); +}