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 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-17 08:48:50 -05:00
committed by GitHub
co-authored by Matt Van Horn Claude Opus 4.6
parent a865dd56e0
commit 7734bb2702
5 changed files with 234 additions and 1 deletions
+152 -1
View File
@@ -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<Vec<String>> = 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<serde_json::Value> = 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::*;