This commit is contained in:
Chris Tate
2026-01-11 04:24:44 -06:00
parent 83bcb6be73
commit e36d03c8d9
4 changed files with 536 additions and 157 deletions
+4 -25
View File
@@ -1,40 +1,19 @@
#!/bin/sh #!/bin/sh
# agent-browser CLI wrapper # agent-browser CLI wrapper
# Tries native binary first, falls back to Node.js
# Resolve symlinks to find real script location
SCRIPT="$0" SCRIPT="$0"
while [ -L "$SCRIPT" ]; do while [ -L "$SCRIPT" ]; do
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
SCRIPT="$(readlink "$SCRIPT")" SCRIPT="$(readlink "$SCRIPT")"
# Handle relative symlinks case "$SCRIPT" in /*) ;; *) SCRIPT="$SCRIPT_DIR/$SCRIPT" ;; esac
case "$SCRIPT" in
/*) ;;
*) SCRIPT="$SCRIPT_DIR/$SCRIPT" ;;
esac
done done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
OS=$(uname -s | tr '[:upper:]' '[:lower:]') OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m) ARCH=$(uname -m)
case "$OS" in darwin) OS="darwin" ;; linux) OS="linux" ;; mingw*|msys*|cygwin*) OS="win32" ;; esac
case "$OS" in case "$ARCH" in x86_64|amd64) ARCH="x64" ;; aarch64|arm64) ARCH="arm64" ;; esac
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}" BINARY="$SCRIPT_DIR/agent-browser-${OS}-${ARCH}"
[ -f "$BINARY" ] && [ -x "$BINARY" ] && exec "$BINARY" "$@"
# Try native binary if it exists
if [ -f "$BINARY" ] && [ -x "$BINARY" ]; then
exec "$BINARY" "$@"
fi
# Fall back to Node.js
exec node "$SCRIPT_DIR/../dist/index.js" "$@" exec node "$SCRIPT_DIR/../dist/index.js" "$@"
+2 -22
View File
@@ -1,28 +1,8 @@
@echo off @echo off
:: agent-browser CLI wrapper for Windows
:: Tries native binary first, falls back to Node.js
setlocal setlocal
set "SCRIPT_DIR=%~dp0" set "SCRIPT_DIR=%~dp0"
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (set "ARCH=x64") else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (set "ARCH=arm64") else (set "ARCH=x64")
:: Detect architecture
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" set "BINARY=%SCRIPT_DIR%agent-browser-win32-%ARCH%.exe"
if exist "%BINARY%" ("%BINARY%" %* & exit /b %errorlevel%)
:: Try native binary first
if exist "%BINARY%" (
"%BINARY%" %*
exit /b %errorlevel%
)
:: Fall back to Node.js
node "%SCRIPT_DIR%..\dist\index.js" %* node "%SCRIPT_DIR%..\dist\index.js" %*
exit /b %errorlevel% exit /b %errorlevel%
+519 -99
View File
@@ -17,33 +17,30 @@ struct Request {
extra: Value, extra: Value,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Default)]
struct Response { struct Response {
success: bool, success: bool,
data: Option<Value>, data: Option<Value>,
error: Option<String>, error: Option<String>,
} }
fn get_socket_path() -> PathBuf { fn get_socket_path(session: &str) -> PathBuf {
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
let tmp = env::temp_dir(); let tmp = env::temp_dir();
tmp.join(format!("agent-browser-{}.sock", session)) tmp.join(format!("agent-browser-{}.sock", session))
} }
fn get_pid_path() -> PathBuf { fn get_pid_path(session: &str) -> PathBuf {
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
let tmp = env::temp_dir(); let tmp = env::temp_dir();
tmp.join(format!("agent-browser-{}.pid", session)) tmp.join(format!("agent-browser-{}.pid", session))
} }
fn is_daemon_running() -> bool { fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(); let pid_path = get_pid_path(session);
if !pid_path.exists() { if !pid_path.exists() {
return false; return false;
} }
if let Ok(pid_str) = fs::read_to_string(&pid_path) { if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<i32>() { if let Ok(pid) = pid_str.trim().parse::<i32>() {
// Check if process exists
unsafe { unsafe {
return libc::kill(pid, 0) == 0; return libc::kill(pid, 0) == 0;
} }
@@ -52,14 +49,13 @@ fn is_daemon_running() -> bool {
false false
} }
fn ensure_daemon() -> Result<(), String> { fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
let socket_path = get_socket_path(); let socket_path = get_socket_path(session);
if is_daemon_running() && socket_path.exists() { if is_daemon_running(session) && socket_path.exists() {
return Ok(()); return Ok(());
} }
// Find daemon.js
let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_path = env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path.parent().unwrap(); let exe_dir = exe_path.parent().unwrap();
@@ -74,19 +70,21 @@ fn ensure_daemon() -> Result<(), String> {
.find(|p| p.exists()) .find(|p| p.exists())
.ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?; .ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?;
// Start daemon let mut cmd = Command::new("node");
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()); cmd.arg(daemon_path)
Command::new("node")
.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1") .env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", &session) .env("AGENT_BROWSER_SESSION", session);
.stdin(Stdio::null())
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn() .spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?; .map_err(|e| format!("Failed to start daemon: {}", e))?;
// Wait for socket
for _ in 0..50 { for _ in 0..50 {
if socket_path.exists() { if socket_path.exists() {
return Ok(()); return Ok(());
@@ -97,8 +95,8 @@ fn ensure_daemon() -> Result<(), String> {
Err("Daemon failed to start".to_string()) Err("Daemon failed to start".to_string())
} }
fn send_command(cmd: Value) -> Result<Response, String> { fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
let socket_path = get_socket_path(); let socket_path = get_socket_path(session);
let mut stream = UnixStream::connect(&socket_path) let mut stream = UnixStream::connect(&socket_path)
.map_err(|e| format!("Failed to connect: {}", e))?; .map_err(|e| format!("Failed to connect: {}", e))?;
@@ -127,7 +125,64 @@ fn gen_id() -> String {
.as_micros() % 1000000) .as_micros() % 1000000)
} }
fn parse_command(args: &[String]) -> Option<Value> { 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<String> {
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<Value> {
if args.is_empty() { if args.is_empty() {
return None; return None;
} }
@@ -137,52 +192,43 @@ fn parse_command(args: &[String]) -> Option<Value> {
let id = gen_id(); let id = gen_id();
match cmd { match cmd {
// === Navigation ===
"open" | "goto" | "navigate" => { "open" | "goto" | "navigate" => {
let url = rest.get(0)?; let url = rest.get(0)?;
let url = if url.starts_with("http") { let url = if url.starts_with("http") { url.to_string() } else { format!("https://{}", url) };
url.to_string()
} else {
format!("https://{}", url)
};
Some(json!({ "id": id, "action": "navigate", "url": 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)? })), "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(" ") })), "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(" ") })), "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)? })), "hover" => Some(json!({ "id": id, "action": "hover", "selector": rest.get(0)? })),
"snapshot" => { "focus" => Some(json!({ "id": id, "action": "focus", "selector": rest.get(0)? })),
let mut cmd = json!({ "id": id, "action": "snapshot" }); "check" => Some(json!({ "id": id, "action": "check", "selector": rest.get(0)? })),
let obj = cmd.as_object_mut().unwrap(); "uncheck" => Some(json!({ "id": id, "action": "uncheck", "selector": rest.get(0)? })),
for (i, arg) in rest.iter().enumerate() { "select" => Some(json!({ "id": id, "action": "select", "selector": rest.get(0)?, "value": rest.get(1)? })),
match *arg { "drag" => Some(json!({ "id": id, "action": "drag", "source": rest.get(0)?, "target": rest.get(1)? })),
"-i" | "--interactive" => { obj.insert("interactive".to_string(), json!(true)); } "upload" => Some(json!({ "id": id, "action": "upload", "selector": rest.get(0)?, "files": &rest[1..] })),
"-c" | "--compact" => { obj.insert("compact".to_string(), json!(true)); }
"-d" | "--depth" => { // === Keyboard ===
if let Some(d) = rest.get(i + 1) { "press" | "key" => Some(json!({ "id": id, "action": "press", "key": rest.get(0)? })),
if let Ok(n) = d.parse::<i32>() { "keydown" => Some(json!({ "id": id, "action": "keydown", "key": rest.get(0)? })),
obj.insert("maxDepth".to_string(), json!(n)); "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::<i32>().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)? })),
}
"-s" | "--selector" => { // === Wait ===
if let Some(s) = rest.get(i + 1) {
obj.insert("selector".to_string(), json!(s));
}
}
_ => {}
}
}
Some(cmd)
}
"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)? })),
"wait" => { "wait" => {
if let Some(arg) = rest.get(0) { if let Some(arg) = rest.get(0) {
if arg.parse::<u64>().is_ok() { if arg.parse::<u64>().is_ok() {
@@ -194,10 +240,246 @@ fn parse_command(args: &[String]) -> Option<Value> {
None None
} }
} }
"back" => Some(json!({ "id": id, "action": "back" })),
"forward" => Some(json!({ "id": id, "action": "forward" })), // === Screenshot/PDF ===
"reload" => Some(json!({ "id": id, "action": "reload" })), "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::<i32>() {
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(" ") })), "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::<i32>().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::<i32>().ok()?;
let y = rest.get(2)?.parse::<i32>().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::<i32>().ok()).unwrap_or(100);
let dx = rest.get(2).and_then(|s| s.parse::<i32>().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::<i32>().ok()?;
let h = rest.get(2)?.parse::<i32>().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::<f64>().ok()?;
let lng = rest.get(2)?.parse::<f64>().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::<i32>().ok()) })),
Some(n) if n.parse::<i32>().is_ok() => Some(json!({ "id": id, "action": "tab_switch", "index": n.parse::<i32>().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, _ => None,
} }
} }
@@ -210,10 +492,11 @@ fn print_response(resp: &Response, json_mode: bool) {
if !resp.success { if !resp.success {
eprintln!("\x1b[31m✗ Error:\x1b[0m {}", resp.error.as_deref().unwrap_or("Unknown error")); eprintln!("\x1b[31m✗ Error:\x1b[0m {}", resp.error.as_deref().unwrap_or("Unknown error"));
exit(1); return;
} }
if let Some(data) = &resp.data { if let Some(data) = &resp.data {
// Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) { 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()) { 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[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title);
@@ -223,52 +506,186 @@ fn print_response(resp: &Response, json_mode: bool) {
println!("{}", url); println!("{}", url);
return; return;
} }
// Snapshot
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) { if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
println!("{}", snapshot); println!("{}", snapshot);
return; return;
} }
// Title
if let Some(title) = data.get("title").and_then(|v| v.as_str()) { if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{}", title); println!("{}", title);
return; return;
} }
// Text
if let Some(text) = data.get("text").and_then(|v| v.as_str()) { if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
println!("{}", text); println!("{}", text);
return; 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") { if let Some(result) = data.get("result") {
println!("{}", serde_json::to_string_pretty(result).unwrap_or_default()); println!("{}", serde_json::to_string_pretty(result).unwrap_or_default());
return; 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() { if data.get("closed").is_some() {
println!("\x1b[32m✓\x1b[0m Browser closed"); println!("\x1b[32m✓\x1b[0m Browser closed");
return; 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"); println!("\x1b[32m✓\x1b[0m Done");
} }
} }
fn print_help() { fn print_help() {
println!(r#" println!(r#"
agent-browser - fast browser automation CLI (Rust) agent-browser - fast browser automation CLI for AI agents
Usage: agent-browser <command> [args] [--json] Usage: agent-browser <command> [args] [options]
Commands: Core Commands:
open <url> Navigate to URL open <url> Navigate to URL
click <sel> Click element (@ref from snapshot) click <sel> Click element (or @ref)
fill <sel> <text> Fill input dblclick <sel> Double-click element
type <sel> <text> Type text type <sel> <text> Type into element
fill <sel> <text> Clear and fill
press <key> Press key (Enter, Tab, Control+a)
hover <sel> Hover element hover <sel> Hover element
snapshot [opts] Get accessibility tree with refs focus <sel> Focus element
check <sel> Check checkbox
uncheck <sel> Uncheck checkbox
select <sel> <val> Select dropdown option
drag <src> <dst> Drag and drop
upload <sel> <files...> Upload files
scroll <dir> [px] Scroll (up/down/left/right)
scrollintoview <sel> Scroll element into view
wait <sel|ms> Wait for element or time
screenshot [path] Take screenshot screenshot [path] Take screenshot
get text <sel> Get text content pdf <path> Save as PDF
get url Get current URL snapshot Accessibility tree with refs (for AI)
get title Get page title eval <js> Run JavaScript
press <key> Press keyboard key
wait <ms|sel> Wait for time or element
eval <js> Evaluate JavaScript
close Close browser close Close browser
Navigation:
back Go back
forward Go forward
reload Reload page
Get Info: agent-browser get <what> [selector]
text, html, value, attr <name>, title, url, count, box
Check State: agent-browser is <what> <selector>
visible, enabled, checked
Find Elements: agent-browser find <locator> <value> <action> [text]
role, text, label, placeholder, alt, title, testid, first, last, nth
Mouse: agent-browser mouse <action> [args]
move <x> <y>, down [btn], up [btn], wheel <dy> [dx]
Browser Settings: agent-browser set <setting> [value]
viewport <w> <h>, device <name>, geo <lat> <lng>
offline [on|off], headers <json>, credentials <user> <pass>
media [dark|light] [reduced-motion]
Network: agent-browser network <action>
route <url> [--abort|--body <json>]
unroute [url]
requests [--clear] [--filter <pattern>]
Storage:
cookies [get|set|clear] Manage cookies
storage <local|session> Manage web storage
Tabs:
tab [new|list|close|<n>] Manage tabs
Debug:
trace start|stop [path] Record trace
console [--clear] View console logs
errors [--clear] View page errors
highlight <sel> Highlight element
Setup: Setup:
install Install browser binaries install Install browser binaries
install --with-deps Also install system dependencies (Linux) install --with-deps Also install system dependencies (Linux)
@@ -280,12 +697,20 @@ Snapshot Options:
-s, --selector <sel> Scope to CSS selector -s, --selector <sel> Scope to CSS selector
Options: Options:
--json Output JSON --session <name> 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: Examples:
agent-browser open example.com agent-browser open example.com
agent-browser snapshot -i agent-browser snapshot -i # Interactive elements only
agent-browser click @e2 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 { if with_deps {
println!("\x1b[36mInstalling system dependencies...\x1b[0m"); println!("\x1b[36mInstalling system dependencies...\x1b[0m");
// Detect package manager and install deps
let (pkg_mgr, deps) = if which_exists("apt-get") { let (pkg_mgr, deps) = if which_exists("apt-get") {
("apt-get", vec![ ("apt-get", vec![
"libxcb-shm0", "libx11-xcb1", "libx11-6", "libxcb1", "libxext6", "libxcb-shm0", "libx11-xcb1", "libx11-6", "libxcb1", "libxext6",
@@ -338,12 +762,8 @@ fn run_install(with_deps: bool) {
match status { match status {
Ok(s) if s.success() => println!("\x1b[32m✓\x1b[0m System dependencies installed"), Ok(s) if s.success() => println!("\x1b[32m✓\x1b[0m System dependencies installed"),
Ok(_) => { Ok(_) => eprintln!("\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."),
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),
}
Err(e) => {
eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e);
}
} }
} else { } else {
println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:"); 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"); println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
let status = Command::new("npx") let status = Command::new("npx")
.args(["playwright", "install", "chromium"]) .args(["playwright", "install", "chromium"])
@@ -396,31 +815,32 @@ fn which_exists(cmd: &str) -> bool {
fn main() { fn main() {
let args: Vec<String> = env::args().skip(1).collect(); let args: Vec<String> = env::args().skip(1).collect();
let json_mode = args.iter().any(|a| a == "--json"); let flags = parse_flags(&args);
let clean_args: Vec<String> = args.iter().filter(|a| !a.starts_with("--")).cloned().collect(); 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(); print_help();
return; return;
} }
// Handle install command separately (doesn't need daemon) // Handle install separately
if clean_args.get(0).map(|s| s.as_str()) == Some("install") { if clean.get(0).map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d"); let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
run_install(with_deps); run_install(with_deps);
return; return;
} }
let cmd = match parse_command(&clean_args) { let cmd = match parse_command(&clean, &flags) {
Some(c) => c, Some(c) => c,
None => { 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); exit(1);
} }
}; };
if let Err(e) = ensure_daemon() { if let Err(e) = ensure_daemon(&flags.session, flags.headed) {
if json_mode { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
eprintln!("\x1b[31m✗ Error:\x1b[0m {}", e); eprintln!("\x1b[31m✗ Error:\x1b[0m {}", e);
@@ -428,16 +848,16 @@ fn main() {
exit(1); exit(1);
} }
match send_command(cmd) { match send_command(cmd, &flags.session) {
Ok(resp) => { Ok(resp) => {
let success = resp.success; let success = resp.success;
print_response(&resp, json_mode); print_response(&resp, flags.json);
if !success { if !success {
exit(1); exit(1);
} }
} }
Err(e) => { Err(e) => {
if json_mode { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
eprintln!("\x1b[31m✗ Error:\x1b[0m {}", e); eprintln!("\x1b[31m✗ Error:\x1b[0m {}", e);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-browser", "name": "agent-browser",
"version": "0.3.5", "version": "0.3.6",
"description": "Headless browser automation CLI for AI agents", "description": "Headless browser automation CLI for AI agents",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",