From 7734bb270205aee289f1442627cb64b146794590 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 17 Mar 2026 06:48:50 -0700 Subject: [PATCH] feat: add batch command for multi-step workflows (#865) Add `batch` command that reads a JSON array of commands from stdin and executes them sequentially against the daemon. This avoids per-command process startup overhead when AI agents run multi-step browser workflows. Supports --bail to stop on first error (default: continue all) and --json for structured output as an array of results. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- .changeset/feat-batch-command.md | 5 + README.md | 19 ++++ cli/src/commands.rs | 22 +++++ cli/src/main.rs | 153 ++++++++++++++++++++++++++++++- cli/src/output.rs | 36 ++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 .changeset/feat-batch-command.md diff --git a/.changeset/feat-batch-command.md b/.changeset/feat-batch-command.md new file mode 100644 index 0000000..3e29d33 --- /dev/null +++ b/.changeset/feat-batch-command.md @@ -0,0 +1,5 @@ +--- +"agent-browser": minor +--- + +Add `batch` command for executing multiple commands from stdin in a single invocation. Accepts a JSON array of string arrays and returns results sequentially. Supports `--bail` to stop on first error and `--json` for structured output. diff --git a/README.md b/README.md index ffde7bc..298a6c6 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,25 @@ agent-browser wait "#spinner" --state hidden **Load states:** `load`, `domcontentloaded`, `networkidle` +### Batch Execution + +Execute multiple commands in a single invocation by piping a JSON array of +string arrays to `batch`. This avoids per-command process startup overhead +when running multi-step workflows. + +```bash +# Pipe commands as JSON +echo '[ + ["open", "https://example.com"], + ["snapshot", "-i"], + ["click", "@e1"], + ["screenshot", "result.png"] +]' | agent-browser batch --json + +# Stop on first error +agent-browser batch --bail < commands.json +``` + ### Clipboard ```bash diff --git a/cli/src/commands.rs b/cli/src/commands.rs index a71ff3c..fcd39b1 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1317,6 +1317,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result parse_diff(&rest, &id, flags), + // === Batch === + "batch" => { + let bail = rest.contains(&"--bail"); + Ok(json!({ "id": id, "action": "batch", "bail": bail })) + } + _ => Err(ParseError::UnknownCommand { command: cmd.to_string(), }), @@ -3961,4 +3967,20 @@ mod tests { let cmd = parse_command(&args("get cdp-url"), &default_flags()).unwrap(); assert_eq!(cmd["action"], "cdp_url"); } + + // === Batch Tests === + + #[test] + fn test_batch_default() { + let cmd = parse_command(&args("batch"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "batch"); + assert_eq!(cmd["bail"], false); + } + + #[test] + fn test_batch_with_bail() { + let cmd = parse_command(&args("batch --bail"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "batch"); + assert_eq!(cmd["bail"], true); + } } diff --git a/cli/src/main.rs b/cli/src/main.rs index 18dafe8..53d63b9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -21,7 +21,7 @@ use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_I use commands::{gen_id, parse_command, ParseError}; use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions}; -use flags::{clean_args, parse_flags}; +use flags::{clean_args, parse_flags, Flags}; use install::run_install; use output::{ print_command_help, print_help, print_response_with_opts, print_version, OutputOptions, @@ -729,6 +729,13 @@ fn main() { } } + // Handle batch command: read commands from stdin, execute sequentially + if cmd.get("action").and_then(|v| v.as_str()) == Some("batch") { + let bail = cmd.get("bail").and_then(|v| v.as_bool()).unwrap_or(false); + run_batch(&flags, bail); + return; + } + let output_opts = OutputOptions { json: flags.json, content_boundaries: flags.content_boundaries, @@ -809,6 +816,150 @@ fn main() { } } +fn run_batch(flags: &Flags, bail: bool) { + use std::io::Read as _; + + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + if flags.json { + print_json_error(format!("Failed to read stdin: {}", e)); + } else { + eprintln!("{} Failed to read stdin: {}", color::error_indicator(), e); + } + exit(1); + } + + let commands: Vec> = match serde_json::from_str(&input) { + Ok(c) => c, + Err(e) => { + if flags.json { + print_json_error(format!( + "Invalid JSON input: {}. Expected an array of string arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]", + e + )); + } else { + eprintln!( + "{} Invalid JSON input: {}. Expected an array of string arrays.", + color::error_indicator(), + e + ); + } + exit(1); + } + }; + + if commands.is_empty() { + if flags.json { + println!("[]"); + } + return; + } + + let output_opts = OutputOptions { + json: flags.json, + content_boundaries: flags.content_boundaries, + max_output: flags.max_output, + }; + + let mut results: Vec = Vec::new(); + let mut had_error = false; + + for (i, cmd_args) in commands.iter().enumerate() { + if cmd_args.is_empty() { + continue; + } + + let parsed = match parse_command(cmd_args, flags) { + Ok(c) => c, + Err(e) => { + had_error = true; + if flags.json { + results.push(json!({ + "command": cmd_args, + "success": false, + "error": e.format(), + })); + if bail { + break; + } + } else { + eprintln!( + "{} Command {}: {}", + color::error_indicator(), + i + 1, + e.format() + ); + if bail { + exit(1); + } + } + continue; + } + }; + + let action = parsed + .get("action") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + match send_command(parsed, &flags.session) { + Ok(resp) => { + if flags.json { + results.push(json!({ + "command": cmd_args, + "success": resp.success, + "result": resp.data, + "error": resp.error, + })); + } else { + if i > 0 { + println!(); + } + print_response_with_opts(&resp, action.as_deref(), &output_opts); + } + if !resp.success { + had_error = true; + if bail { + if !flags.json { + exit(1); + } + break; + } + } + } + Err(e) => { + had_error = true; + if flags.json { + results.push(json!({ + "command": cmd_args, + "success": false, + "error": e.to_string(), + })); + if bail { + break; + } + } else { + eprintln!("{} Command {}: {}", color::error_indicator(), i + 1, e); + if bail { + exit(1); + } + } + } + } + } + + if flags.json { + println!( + "{}", + serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string()) + ); + } + + if had_error { + exit(1); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/output.rs b/cli/src/output.rs index b98dbea..0eea8ec 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2418,6 +2418,38 @@ Examples: "## } + "batch" => { + r##" +agent-browser batch - Execute multiple commands from stdin + +Usage: echo '' | agent-browser batch [options] + +Reads a JSON array of commands from stdin and executes them sequentially. +Each command is an array of strings matching normal CLI arguments. +Results are printed in order, separated by blank lines (or as a JSON array +with --json). + +Options: + --bail Stop on first error (default: continue all commands) + --json Output results as a JSON array + +Input Format: + A JSON array of string arrays. Each inner array is one command: + [ + ["open", "https://example.com"], + ["snapshot", "-i"], + ["click", "@e1"], + ["fill", "@e2", "test@example.com"], + ["screenshot", "result.png"] + ] + +Examples: + echo '[["open", "https://example.com"], ["snapshot"]]' | agent-browser batch + echo '[["open", "https://example.com"], ["get", "title"]]' | agent-browser batch --json + agent-browser batch --bail < commands.json +"## + } + _ => return false, }; println!("{}", help.trim()); @@ -2508,6 +2540,10 @@ Debug: inspect Open Chrome DevTools for the active page clipboard [text] Read/write clipboard (read, write, copy, paste) +Batch: + batch [--bail] Execute commands from stdin (JSON array of string arrays) + --bail stops on first error (default: continue all) + Auth Vault: auth save [opts] Save auth profile (--url, --username, --password/--password-stdin) auth login Login using saved credentials