diff --git a/bin/agent-browser b/bin/agent-browser index 0b6a781..973e2ad 100755 --- a/bin/agent-browser +++ b/bin/agent-browser @@ -1,40 +1,19 @@ #!/bin/sh # agent-browser CLI wrapper -# Tries native binary first, falls back to Node.js -# Resolve symlinks to find real script location SCRIPT="$0" while [ -L "$SCRIPT" ]; do SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)" SCRIPT="$(readlink "$SCRIPT")" - # Handle relative symlinks - case "$SCRIPT" in - /*) ;; - *) SCRIPT="$SCRIPT_DIR/$SCRIPT" ;; - esac + case "$SCRIPT" in /*) ;; *) SCRIPT="$SCRIPT_DIR/$SCRIPT" ;; esac done SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)" OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) - -case "$OS" in - darwin) OS="darwin" ;; - linux) OS="linux" ;; - mingw*|msys*|cygwin*) OS="win32" ;; -esac - -case "$ARCH" in - x86_64|amd64) ARCH="x64" ;; - aarch64|arm64) ARCH="arm64" ;; -esac +case "$OS" in darwin) OS="darwin" ;; linux) OS="linux" ;; mingw*|msys*|cygwin*) OS="win32" ;; esac +case "$ARCH" in x86_64|amd64) ARCH="x64" ;; aarch64|arm64) ARCH="arm64" ;; esac BINARY="$SCRIPT_DIR/agent-browser-${OS}-${ARCH}" - -# Try native binary if it exists -if [ -f "$BINARY" ] && [ -x "$BINARY" ]; then - exec "$BINARY" "$@" -fi - -# Fall back to Node.js +[ -f "$BINARY" ] && [ -x "$BINARY" ] && exec "$BINARY" "$@" exec node "$SCRIPT_DIR/../dist/index.js" "$@" diff --git a/bin/agent-browser.cmd b/bin/agent-browser.cmd index f71d7ae..adc144b 100644 --- a/bin/agent-browser.cmd +++ b/bin/agent-browser.cmd @@ -1,28 +1,8 @@ @echo off -:: agent-browser CLI wrapper for Windows -:: Tries native binary first, falls back to Node.js - setlocal - set "SCRIPT_DIR=%~dp0" - -:: Detect architecture -if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( - set "ARCH=x64" -) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( - set "ARCH=arm64" -) else ( - set "ARCH=x64" -) - +if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (set "ARCH=x64") else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (set "ARCH=arm64") else (set "ARCH=x64") set "BINARY=%SCRIPT_DIR%agent-browser-win32-%ARCH%.exe" - -:: Try native binary first -if exist "%BINARY%" ( - "%BINARY%" %* - exit /b %errorlevel% -) - -:: Fall back to Node.js +if exist "%BINARY%" ("%BINARY%" %* & exit /b %errorlevel%) node "%SCRIPT_DIR%..\dist\index.js" %* exit /b %errorlevel% diff --git a/cli/src/main.rs b/cli/src/main.rs index ccb3887..b926bcf 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -17,33 +17,30 @@ struct Request { extra: Value, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Default)] struct Response { success: bool, data: Option, error: Option, } -fn get_socket_path() -> PathBuf { - let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()); +fn get_socket_path(session: &str) -> PathBuf { let tmp = env::temp_dir(); tmp.join(format!("agent-browser-{}.sock", session)) } -fn get_pid_path() -> PathBuf { - let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()); +fn get_pid_path(session: &str) -> PathBuf { let tmp = env::temp_dir(); tmp.join(format!("agent-browser-{}.pid", session)) } -fn is_daemon_running() -> bool { - let pid_path = get_pid_path(); +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::() { - // Check if process exists unsafe { return libc::kill(pid, 0) == 0; } @@ -52,14 +49,13 @@ fn is_daemon_running() -> bool { false } -fn ensure_daemon() -> Result<(), String> { - let socket_path = get_socket_path(); +fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { + let socket_path = get_socket_path(session); - if is_daemon_running() && socket_path.exists() { + if is_daemon_running(session) && socket_path.exists() { return Ok(()); } - // Find daemon.js let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_dir = exe_path.parent().unwrap(); @@ -74,19 +70,21 @@ fn ensure_daemon() -> Result<(), String> { .find(|p| p.exists()) .ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?; - // Start daemon - let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()); - Command::new("node") - .arg(daemon_path) + let mut cmd = Command::new("node"); + cmd.arg(daemon_path) .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", &session) - .stdin(Stdio::null()) + .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))?; - // Wait for socket for _ in 0..50 { if socket_path.exists() { return Ok(()); @@ -97,8 +95,8 @@ fn ensure_daemon() -> Result<(), String> { Err("Daemon failed to start".to_string()) } -fn send_command(cmd: Value) -> Result { - let socket_path = get_socket_path(); +fn send_command(cmd: Value, session: &str) -> Result { + let socket_path = get_socket_path(session); let mut stream = UnixStream::connect(&socket_path) .map_err(|e| format!("Failed to connect: {}", e))?; @@ -127,7 +125,64 @@ fn gen_id() -> String { .as_micros() % 1000000) } -fn parse_command(args: &[String]) -> Option { +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; } @@ -137,52 +192,43 @@ fn parse_command(args: &[String]) -> Option { 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) - }; + 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)? })), - "snapshot" => { - let mut cmd = json!({ "id": id, "action": "snapshot" }); - let obj = cmd.as_object_mut().unwrap(); - for (i, arg) in rest.iter().enumerate() { - match *arg { - "-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)); - } - } - } - "-s" | "--selector" => { - if let Some(s) = rest.get(i + 1) { - obj.insert("selector".to_string(), json!(s)); - } - } - _ => {} - } - } - Some(cmd) + "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 })) } - "screenshot" => Some(json!({ "id": id, "action": "screenshot", "path": rest.get(0) })), - "close" | "quit" | "exit" => Some(json!({ "id": id, "action": "close" })), - "get" => match rest.get(0).map(|s| *s) { - Some("text") => Some(json!({ "id": id, "action": "gettext", "selector": rest.get(1)? })), - Some("url") => Some(json!({ "id": id, "action": "url" })), - Some("title") => Some(json!({ "id": id, "action": "title" })), - _ => None, - }, - "press" => Some(json!({ "id": id, "action": "press", "key": rest.get(0)? })), + "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() { @@ -194,10 +240,246 @@ fn parse_command(args: &[String]) -> Option { None } } - "back" => Some(json!({ "id": id, "action": "back" })), - "forward" => Some(json!({ "id": id, "action": "forward" })), - "reload" => Some(json!({ "id": id, "action": "reload" })), + + // === 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, } } @@ -210,10 +492,11 @@ fn print_response(resp: &Response, json_mode: bool) { if !resp.success { eprintln!("\x1b[31m✗ Error:\x1b[0m {}", resp.error.as_deref().unwrap_or("Unknown error")); - exit(1); + 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); @@ -223,69 +506,211 @@ fn print_response(resp: &Response, json_mode: bool) { 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 (Rust) +agent-browser - fast browser automation CLI for AI agents -Usage: agent-browser [args] [--json] +Usage: agent-browser [args] [options] -Commands: - open Navigate to URL - click Click element (@ref from snapshot) - fill Fill input - type Type text - hover Hover element - snapshot [opts] Get accessibility tree with refs - screenshot [path] Take screenshot - get text Get text content - get url Get current URL - get title Get page title - press Press keyboard key - wait Wait for time or element - eval Evaluate JavaScript - close Close browser +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) + 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 + -i, --interactive Only interactive elements + -c, --compact Remove empty structural elements + -d, --depth Limit tree depth + -s, --selector Scope to CSS selector Options: - --json Output JSON + --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 - agent-browser click @e2 + 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 "#); } @@ -296,7 +721,6 @@ fn run_install(with_deps: bool) { if with_deps { println!("\x1b[36mInstalling system dependencies...\x1b[0m"); - // Detect package manager and install deps let (pkg_mgr, deps) = if which_exists("apt-get") { ("apt-get", vec![ "libxcb-shm0", "libx11-xcb1", "libx11-6", "libxcb1", "libxext6", @@ -338,12 +762,8 @@ fn run_install(with_deps: bool) { 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); - } + 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:"); @@ -353,7 +773,6 @@ fn run_install(with_deps: bool) { } } - // Install browser binaries println!("\x1b[36mInstalling Chromium browser...\x1b[0m"); let status = Command::new("npx") .args(["playwright", "install", "chromium"]) @@ -396,31 +815,32 @@ fn which_exists(cmd: &str) -> bool { fn main() { let args: Vec = env::args().skip(1).collect(); - let json_mode = args.iter().any(|a| a == "--json"); - let clean_args: Vec = args.iter().filter(|a| !a.starts_with("--")).cloned().collect(); + let flags = parse_flags(&args); + let clean = clean_args(&args); - if clean_args.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") { + if clean.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") { print_help(); return; } - // Handle install command separately (doesn't need daemon) - if clean_args.get(0).map(|s| s.as_str()) == Some("install") { + // 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_args) { + let cmd = match parse_command(&clean, &flags) { Some(c) => c, None => { - eprintln!("\x1b[31mUnknown command:\x1b[0m {}", clean_args.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() { - if json_mode { + if let Err(e) = ensure_daemon(&flags.session, flags.headed) { + if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { eprintln!("\x1b[31m✗ Error:\x1b[0m {}", e); @@ -428,16 +848,16 @@ fn main() { exit(1); } - match send_command(cmd) { + match send_command(cmd, &flags.session) { Ok(resp) => { let success = resp.success; - print_response(&resp, json_mode); + print_response(&resp, flags.json); if !success { exit(1); } } Err(e) => { - if json_mode { + if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { eprintln!("\x1b[31m✗ Error:\x1b[0m {}", e); diff --git a/package.json b/package.json index 58d12e9..27711e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-browser", - "version": "0.3.5", + "version": "0.3.6", "description": "Headless browser automation CLI for AI agents", "type": "module", "main": "dist/index.js",