use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::{json, Value}; use std::io::{self, BufRead}; use crate::color; use crate::flags::Flags; use crate::validation::{is_valid_session_name, session_name_error}; /// Error type for command parsing with contextual information #[derive(Debug)] pub enum ParseError { /// Command does not exist UnknownCommand { command: String }, /// Command exists but subcommand is invalid UnknownSubcommand { subcommand: String, valid_options: &'static [&'static str], }, /// Command/subcommand exists but required arguments are missing MissingArguments { context: String, usage: &'static str, }, /// Argument exists but has an invalid value InvalidValue { message: String, usage: &'static str, }, /// Invalid session name (path traversal or invalid characters) InvalidSessionName { name: String }, } impl ParseError { pub fn format(&self) -> String { match self { ParseError::UnknownCommand { command } => { format!("Unknown command: {}", command) } ParseError::UnknownSubcommand { subcommand, valid_options, } => { format!( "Unknown subcommand: {}\nValid options: {}", subcommand, valid_options.join(", ") ) } ParseError::MissingArguments { context, usage } => { format!( "Missing arguments for: {}\nUsage: agent-browser {}", context, usage ) } ParseError::InvalidValue { message, usage } => { format!("{}\nUsage: agent-browser {}", message, usage) } ParseError::InvalidSessionName { name } => session_name_error(name), } } } 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) -> Result { if args.is_empty() { return Err(ParseError::MissingArguments { context: "".to_string(), usage: " [args...]", }); } let cmd = args[0].as_str(); let rest: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect(); let id = gen_id(); if flags.cli_annotate && cmd != "screenshot" { eprintln!( "{} --annotate only applies to the screenshot command", color::warning_indicator() ); } match cmd { // === Navigation === // Maps to "navigate" action in protocol; reflected in ACTION_CATEGORIES in action-policy.ts "open" | "goto" | "navigate" => { let url = rest.first().ok_or_else(|| ParseError::MissingArguments { context: cmd.to_string(), usage: "open ", })?; let url_lower = url.to_lowercase(); let url = if url_lower.starts_with("http://") || url_lower.starts_with("https://") || url_lower.starts_with("about:") || url_lower.starts_with("data:") || url_lower.starts_with("file:") || url_lower.starts_with("chrome-extension://") || url_lower.starts_with("chrome://") { url.to_string() } else { format!("https://{}", url) }; let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url }); if flags.provider.is_some() { nav_cmd["waitUntil"] = json!("none"); } if let Some(ref headers_json) = flags.headers { let headers = serde_json::from_str::(headers_json).map_err(|_| { ParseError::InvalidValue { message: format!("Invalid JSON for --headers: {}", headers_json), usage: "open --headers '{\"Key\": \"Value\"}'", } })?; nav_cmd["headers"] = headers; } // Include iOS device info if specified (needed for auto-launch with existing daemon) if flags.provider.as_deref() == Some("ios") { if let Some(ref device) = flags.device { nav_cmd["iosDevice"] = json!(device); } } Ok(nav_cmd) } "back" => Ok(json!({ "id": id, "action": "back" })), "forward" => Ok(json!({ "id": id, "action": "forward" })), "reload" => Ok(json!({ "id": id, "action": "reload" })), // === Core Actions === "click" => { let new_tab = rest.contains(&"--new-tab"); let sel = rest .iter() .find(|arg| **arg != "--new-tab") .ok_or_else(|| ParseError::MissingArguments { context: "click".to_string(), usage: "click [--new-tab]", })?; if new_tab { Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true })) } else { Ok(json!({ "id": id, "action": "click", "selector": sel })) } } "dblclick" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "dblclick".to_string(), usage: "dblclick ", })?; Ok(json!({ "id": id, "action": "dblclick", "selector": sel })) } "fill" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "fill".to_string(), usage: "fill ", })?; Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") })) } "type" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "type".to_string(), usage: "type ", })?; Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") })) } "hover" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "hover".to_string(), usage: "hover ", })?; Ok(json!({ "id": id, "action": "hover", "selector": sel })) } "focus" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "focus".to_string(), usage: "focus ", })?; Ok(json!({ "id": id, "action": "focus", "selector": sel })) } "check" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "check".to_string(), usage: "check ", })?; Ok(json!({ "id": id, "action": "check", "selector": sel })) } "uncheck" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "uncheck".to_string(), usage: "uncheck ", })?; Ok(json!({ "id": id, "action": "uncheck", "selector": sel })) } "select" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "select".to_string(), usage: "select ", })?; let _val = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "select".to_string(), usage: "select ", })?; let values = &rest[1..]; if values.len() == 1 { Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values[0] })) } else { Ok(json!({ "id": id, "action": "select", "selector": sel, "values": values })) } } "drag" => { let src = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "drag".to_string(), usage: "drag ", })?; let tgt = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "drag".to_string(), usage: "drag ", })?; Ok(json!({ "id": id, "action": "drag", "source": src, "target": tgt })) } "upload" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "upload".to_string(), usage: "upload ", })?; Ok(json!({ "id": id, "action": "upload", "selector": sel, "files": &rest[1..] })) } "download" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "download".to_string(), usage: "download ", })?; let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "download".to_string(), usage: "download ", })?; Ok(json!({ "id": id, "action": "download", "selector": sel, "path": path })) } // === Keyboard === "press" | "key" => { let key = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "press".to_string(), usage: "press ", })?; Ok(json!({ "id": id, "action": "press", "key": key })) } "keydown" => { let key = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "keydown".to_string(), usage: "keydown ", })?; Ok(json!({ "id": id, "action": "keydown", "key": key })) } "keyup" => { let key = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "keyup".to_string(), usage: "keyup ", })?; Ok(json!({ "id": id, "action": "keyup", "key": key })) } "keyboard" => { let sub = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "keyboard".to_string(), usage: "keyboard ", })?; match *sub { "type" => { let text: String = rest[1..].join(" "); if text.is_empty() { return Err(ParseError::MissingArguments { context: "keyboard type".to_string(), usage: "keyboard type ", }); } Ok(json!({ "id": id, "action": "keyboard", "subaction": "type", "text": text })) } "inserttext" | "insertText" => { let text: String = rest[1..].join(" "); if text.is_empty() { return Err(ParseError::MissingArguments { context: "keyboard inserttext".to_string(), usage: "keyboard inserttext ", }); } Ok( json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }), ) } _ => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: &["type", "inserttext"], }), } } // === Scroll === "scroll" => { let mut cmd = json!({ "id": id, "action": "scroll" }); let obj = cmd.as_object_mut().unwrap(); let mut positional_index = 0; let mut i = 0; while i < rest.len() { match rest[i] { "-s" | "--selector" => { if let Some(s) = rest.get(i + 1) { obj.insert("selector".to_string(), json!(s)); i += 1; } else { return Err(ParseError::MissingArguments { context: "scroll --selector".to_string(), usage: "scroll [direction] [amount] [--selector ]", }); } } arg if arg.starts_with('-') => {} _ => { match positional_index { 0 => { obj.insert("direction".to_string(), json!(rest[i])); } 1 => { if let Ok(n) = rest[i].parse::() { obj.insert("amount".to_string(), json!(n)); } } _ => {} } positional_index += 1; } } i += 1; } if !obj.contains_key("direction") { obj.insert("direction".to_string(), json!("down")); } if !obj.contains_key("amount") { obj.insert("amount".to_string(), json!(300)); } Ok(cmd) } "scrollintoview" | "scrollinto" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "scrollintoview".to_string(), usage: "scrollintoview ", })?; Ok(json!({ "id": id, "action": "scrollintoview", "selector": sel })) } // === Wait === "wait" => { // Check for --url flag: wait --url "**/dashboard" if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") { let url = rest .get(idx + 1) .ok_or_else(|| ParseError::MissingArguments { context: "wait --url".to_string(), usage: "wait --url ", })?; return Ok(json!({ "id": id, "action": "waitforurl", "url": url })); } // Check for --load flag: wait --load networkidle if let Some(idx) = rest.iter().position(|&s| s == "--load" || s == "-l") { let state = rest .get(idx + 1) .ok_or_else(|| ParseError::MissingArguments { context: "wait --load".to_string(), usage: "wait --load ", })?; return Ok(json!({ "id": id, "action": "waitforloadstate", "state": state })); } // Check for --fn flag: wait --fn "window.ready === true" if let Some(idx) = rest.iter().position(|&s| s == "--fn" || s == "-f") { let expr = rest .get(idx + 1) .ok_or_else(|| ParseError::MissingArguments { context: "wait --fn".to_string(), usage: "wait --fn ", })?; return Ok(json!({ "id": id, "action": "waitforfunction", "expression": expr })); } // Check for --text flag: wait --text "Welcome" [--timeout ms] if let Some(idx) = rest.iter().position(|&s| s == "--text" || s == "-t") { let text = rest .get(idx + 1) .ok_or_else(|| ParseError::MissingArguments { context: "wait --text".to_string(), usage: "wait --text ", })?; let mut cmd = json!({ "id": id, "action": "wait", "text": text }); if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") { if let Some(Ok(ms)) = rest.get(t_idx + 1).map(|s| s.parse::()) { cmd["timeout"] = json!(ms); } } return Ok(cmd); } // Check for --download flag: wait --download [path] [--timeout ms] if rest.iter().any(|&s| s == "--download" || s == "-d") { let mut cmd = json!({ "id": id, "action": "waitfordownload" }); // Check for optional path (first non-flag argument after --download) let download_idx = rest .iter() .position(|&s| s == "--download" || s == "-d") .unwrap(); if let Some(path) = rest.get(download_idx + 1) { if !path.starts_with("--") { cmd["path"] = json!(path); } } // Check for optional timeout if let Some(idx) = rest.iter().position(|&s| s == "--timeout") { if let Some(timeout_str) = rest.get(idx + 1) { if let Ok(timeout) = timeout_str.parse::() { cmd["timeout"] = json!(timeout); } } } return Ok(cmd); } // Default: selector or timeout if let Some(arg) = rest.first() { if let Ok(timeout) = arg.parse::() { Ok(json!({ "id": id, "action": "wait", "timeout": timeout })) } else { Ok(json!({ "id": id, "action": "wait", "selector": arg })) } } else { Err(ParseError::MissingArguments { context: "wait".to_string(), usage: "wait ", }) } } // === Screenshot/PDF === "screenshot" => { // screenshot [selector] [path] [--full/-f] // selector: @ref or CSS selector // path: file path (contains / or . or ends with known extension) let mut full_page = false; let positional: Vec<&str> = rest .iter() .filter(|arg| match **arg { "--full" | "-f" => { full_page = true; false } _ => true, }) .copied() .collect(); let (selector, path) = match (positional.first(), positional.get(1)) { (Some(first), Some(second)) => { // Two args: first is selector, second is path (Some(*first), Some(*second)) } (Some(first), None) => { // One arg: determine if it's a selector or a path let is_relative_path = first.starts_with("./") || first.starts_with("../"); let is_selector = !is_relative_path && (first.starts_with('.') || first.starts_with('#') || first.starts_with('@')); let has_path_extension = first.ends_with(".png") || first.ends_with(".jpg") || first.ends_with(".jpeg") || first.ends_with(".webp"); let is_path = is_relative_path || first.contains('/') || has_path_extension; if is_selector || !is_path { (Some(*first), None) } else { (None, Some(*first)) } } _ => (None, None), }; let mut cmd = json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": full_page, "annotate": flags.annotate }); if let Some(ref fmt) = flags.screenshot_format { cmd["format"] = json!(fmt); } if let Some(q) = flags.screenshot_quality { cmd["quality"] = json!(q); if flags.screenshot_format.as_deref() != Some("jpeg") { eprintln!( "{} --screenshot-quality is ignored for PNG; use --screenshot-format jpeg", color::warning_indicator() ); } } if let Some(ref dir) = flags.screenshot_dir { cmd["screenshotDir"] = json!(dir); } Ok(cmd) } "pdf" => { let path = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "pdf".to_string(), usage: "pdf ", })?; Ok(json!({ "id": id, "action": "pdf", "path": path })) } // === 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)); } "-C" | "--cursor" => { // deprecated, cursor-interactive elements are referred by default now obj.insert("cursor".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; } Ok(cmd) } // === Eval === "eval" => { // Check for flags: -b/--base64 or --stdin let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) = if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") { (true, false, &rest[1..]) } else if rest.first() == Some(&"--stdin") { (false, true, &rest[1..]) } else { (false, false, rest.as_slice()) }; let script = if is_stdin { // Read script from stdin let stdin = io::stdin(); let lines: Vec = stdin .lock() .lines() .map(|l| l.unwrap_or_default()) .collect(); lines.join("\n") } else { let raw_script = script_parts.join(" "); if is_base64 { let decoded = STANDARD .decode(&raw_script) .map_err(|_| ParseError::InvalidValue { message: "Invalid base64 encoding".to_string(), usage: "eval -b ", })?; String::from_utf8(decoded).map_err(|_| ParseError::InvalidValue { message: "Base64 decoded to invalid UTF-8".to_string(), usage: "eval -b ", })? } else { raw_script } }; Ok(json!({ "id": id, "action": "evaluate", "script": script })) } // === Close === "close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })), // === Inspect === "inspect" => Ok(json!({ "id": id, "action": "inspect" })), // === Authentication Vault === "auth" => { let sub = rest.first().map(|s| s.as_ref()); match sub { Some("save") => { let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "auth save".to_string(), usage: "agent-browser auth save --url --username --password ", })?; let mut url = None; let mut username = None; let mut password = None; let mut password_stdin = false; let mut username_selector = None; let mut password_selector = None; let mut submit_selector = None; let mut j = 2; while j < rest.len() { match rest[j] { "--url" => { url = rest.get(j + 1).cloned(); j += 1; } "--username" => { username = rest.get(j + 1).cloned(); j += 1; } "--password" => { password = rest.get(j + 1).cloned(); j += 1; } "--password-stdin" => { password_stdin = true; } "--username-selector" => { username_selector = rest.get(j + 1).cloned(); j += 1; } "--password-selector" => { password_selector = rest.get(j + 1).cloned(); j += 1; } "--submit-selector" => { submit_selector = rest.get(j + 1).cloned(); j += 1; } other => { if other.starts_with("--") { return Err(ParseError::InvalidValue { message: format!("unknown flag '{}' for auth save", other), usage: "agent-browser auth save --url --username --password ", }); } } } j += 1; } let url_val = url.ok_or_else(|| ParseError::MissingArguments { context: "auth save".to_string(), usage: "agent-browser auth save --url --username --password [--password-stdin]", })?; let user_val = username.ok_or_else(|| ParseError::MissingArguments { context: "auth save".to_string(), usage: "agent-browser auth save --url --username --password [--password-stdin]", })?; if !password_stdin && password.is_none() { return Err(ParseError::MissingArguments { context: "auth save".to_string(), usage: "agent-browser auth save --url --username --password [--password-stdin]", }); } let mut cmd = json!({ "id": id, "action": "auth_save", "name": name, "url": url_val, "username": user_val, }); if password_stdin { cmd["passwordStdin"] = json!(true); } if let Some(pass_val) = password { cmd["password"] = json!(pass_val); } if let Some(us) = username_selector { cmd["usernameSelector"] = json!(us); } if let Some(ps) = password_selector { cmd["passwordSelector"] = json!(ps); } if let Some(ss) = submit_selector { cmd["submitSelector"] = json!(ss); } Ok(cmd) } Some("login") => { let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "auth login".to_string(), usage: "agent-browser auth login ", })?; Ok(json!({ "id": id, "action": "auth_login", "name": name })) } Some("list") => Ok(json!({ "id": id, "action": "auth_list" })), Some("delete") | Some("remove") => { let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "auth delete".to_string(), usage: "agent-browser auth delete ", })?; Ok(json!({ "id": id, "action": "auth_delete", "name": name })) } Some("show") => { let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "auth show".to_string(), usage: "agent-browser auth show ", })?; Ok(json!({ "id": id, "action": "auth_show", "name": name })) } _ => Err(ParseError::UnknownSubcommand { subcommand: sub.unwrap_or("(none)").to_string(), valid_options: &["save", "login", "list", "delete", "show"], }), } } // === Action Confirmation === "confirm" => { let cid = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "confirm".to_string(), usage: "agent-browser confirm ", })?; Ok(json!({ "id": id, "action": "confirm", "confirmationId": cid })) } "deny" => { let cid = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "deny".to_string(), usage: "agent-browser deny ", })?; Ok(json!({ "id": id, "action": "deny", "confirmationId": cid })) } // === Connect (CDP) === "connect" => { let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "connect".to_string(), usage: "connect ", })?; // Check if it's a URL (ws://, wss://, http://, https://) if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") || endpoint.starts_with("http://") || endpoint.starts_with("https://") { Ok(json!({ "id": id, "action": "launch", "cdpUrl": endpoint })) } else { // It's a port number - validate and use cdpPort field let port: u16 = match endpoint.parse::() { Ok(0) => { return Err(ParseError::InvalidValue { message: "Invalid port: port must be greater than 0".to_string(), usage: "connect ", }); } Ok(p) if p > 65535 => { return Err(ParseError::InvalidValue { message: format!( "Invalid port: {} is out of range (valid range: 1-65535)", p ), usage: "connect ", }); } Ok(p) => p as u16, Err(_) => { return Err(ParseError::InvalidValue { message: format!( "Invalid value: '{}' is not a valid port number or URL", endpoint ), usage: "connect ", }); } }; Ok(json!({ "id": id, "action": "launch", "cdpPort": port })) } } // === Runtime stream control === "stream" => match rest.first().copied() { Some("enable") => { let mut cmd = json!({ "id": id, "action": "stream_enable" }); let mut i = 1; while i < rest.len() { match rest[i] { "--port" => { let value = rest.get(i + 1) .ok_or_else(|| ParseError::MissingArguments { context: "stream enable --port".to_string(), usage: "stream enable [--port ]", })?; let port = value.parse::().map_err(|_| ParseError::InvalidValue { message: format!( "Invalid port: '{}' is not a valid integer", value ), usage: "stream enable [--port ]", })?; if port > u16::MAX as u32 { return Err(ParseError::InvalidValue { message: format!( "Invalid port: {} is out of range (valid range: 0-65535)", port ), usage: "stream enable [--port ]", }); } cmd["port"] = json!(port); i += 2; } flag => { return Err(ParseError::InvalidValue { message: format!("Unknown flag for stream enable: {}", flag), usage: "stream enable [--port ]", }); } } } Ok(cmd) } Some("disable") => Ok(json!({ "id": id, "action": "stream_disable" })), Some("status") => Ok(json!({ "id": id, "action": "stream_status" })), Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: &["enable", "disable", "status"], }), None => Err(ParseError::MissingArguments { context: "stream".to_string(), usage: "stream ", }), }, // === Get === "get" => parse_get(&rest, &id), // === Is (state checks) === "is" => parse_is(&rest, &id), // === Find (locators) === "find" => parse_find(&rest, &id), // === Mouse === "mouse" => parse_mouse(&rest, &id), // === Set (browser settings) === "set" => parse_set(&rest, &id), // === Network === "network" => parse_network(&rest, &id), // === Storage === "storage" => parse_storage(&rest, &id), // === Cookies === "cookies" => { let op = rest.first().unwrap_or(&"get"); match *op { "set" => { let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "cookies set".to_string(), usage: "cookies set [--url ] [--domain ] [--path ] [--httpOnly] [--secure] [--sameSite ] [--expires ]", })?; let value = rest.get(2).ok_or_else(|| ParseError::MissingArguments { context: "cookies set".to_string(), usage: "cookies set [--url ] [--domain ] [--path ] [--httpOnly] [--secure] [--sameSite ] [--expires ]", })?; let mut cookie = json!({ "name": name, "value": value }); // Parse optional flags let mut i = 3; while i < rest.len() { match rest[i] { "--url" => { if let Some(url) = rest.get(i + 1) { cookie["url"] = json!(url); i += 2; } else { return Err(ParseError::MissingArguments { context: "cookies set --url".to_string(), usage: "--url ", }); } } "--domain" => { if let Some(domain) = rest.get(i + 1) { cookie["domain"] = json!(domain); i += 2; } else { return Err(ParseError::MissingArguments { context: "cookies set --domain".to_string(), usage: "--domain ", }); } } "--path" => { if let Some(path) = rest.get(i + 1) { cookie["path"] = json!(path); i += 2; } else { return Err(ParseError::MissingArguments { context: "cookies set --path".to_string(), usage: "--path ", }); } } "--httpOnly" => { cookie["httpOnly"] = json!(true); i += 1; } "--secure" => { cookie["secure"] = json!(true); i += 1; } "--sameSite" => { if let Some(same_site) = rest.get(i + 1) { // Validate sameSite value if *same_site == "Strict" || *same_site == "Lax" || *same_site == "None" { cookie["sameSite"] = json!(same_site); i += 2; } else { return Err(ParseError::MissingArguments { context: "cookies set --sameSite".to_string(), usage: "--sameSite ", }); } } else { return Err(ParseError::MissingArguments { context: "cookies set --sameSite".to_string(), usage: "--sameSite ", }); } } "--expires" => { if let Some(expires_str) = rest.get(i + 1) { if let Ok(expires) = expires_str.parse::() { cookie["expires"] = json!(expires); i += 2; } else { return Err(ParseError::MissingArguments { context: "cookies set --expires".to_string(), usage: "--expires ", }); } } else { return Err(ParseError::MissingArguments { context: "cookies set --expires".to_string(), usage: "--expires ", }); } } _ => { // Unknown flag, skip it (or could error) i += 1; } } } Ok(json!({ "id": id, "action": "cookies_set", "cookies": [cookie] })) } "clear" => Ok(json!({ "id": id, "action": "cookies_clear" })), _ => Ok(json!({ "id": id, "action": "cookies_get" })), } } // === Tabs === "tab" => match rest.first().copied() { Some("new") => { let mut cmd = json!({ "id": id, "action": "tab_new" }); if let Some(url) = rest.get(1) { cmd["url"] = json!(url); } Ok(cmd) } Some("list") => Ok(json!({ "id": id, "action": "tab_list" })), Some("close") => { let mut cmd = json!({ "id": id, "action": "tab_close" }); if let Some(index) = rest.get(1).and_then(|s| s.parse::().ok()) { cmd["index"] = json!(index); } Ok(cmd) } Some(n) if n.parse::().is_ok() => { let index = n.parse::().expect("already checked parse succeeds"); Ok(json!({ "id": id, "action": "tab_switch", "index": index })) } _ => Ok(json!({ "id": id, "action": "tab_list" })), }, // === Window === "window" => { const VALID: &[&str] = &["new"]; match rest.first().copied() { Some("new") => Ok(json!({ "id": id, "action": "window_new" })), Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "window".to_string(), usage: "window ", }), } } // === Frame === "frame" => { if rest.first().copied() == Some("main") { Ok(json!({ "id": id, "action": "mainframe" })) } else { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "frame".to_string(), usage: "frame ", })?; Ok(json!({ "id": id, "action": "frame", "selector": sel })) } } // === Dialog === "dialog" => { const VALID: &[&str] = &["accept", "dismiss", "status"]; match rest.first().copied() { Some("accept") => { let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" }); if let Some(prompt_text) = rest.get(1) { cmd["promptText"] = json!(prompt_text); } Ok(cmd) } Some("dismiss") => { let mut cmd = json!({ "id": id, "action": "dialog", "response": "dismiss" }); if let Some(prompt_text) = rest.get(1) { cmd["promptText"] = json!(prompt_text); } Ok(cmd) } Some("status") => Ok(json!({ "id": id, "action": "dialog", "response": "status" })), Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "dialog".to_string(), usage: "dialog [text]", }), } } // === Debug === "trace" => { const VALID: &[&str] = &["start", "stop"]; match rest.first().copied() { Some("start") => Ok(json!({ "id": id, "action": "trace_start" })), Some("stop") => { let mut cmd = json!({ "id": id, "action": "trace_stop" }); if let Some(path) = rest.get(1) { cmd["path"] = json!(path); } Ok(cmd) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "trace".to_string(), usage: "trace [path]", }), } } // === Profiler (CDP Tracing / Chromium profiling) === "profiler" => { const VALID: &[&str] = &["start", "stop"]; match rest.first().copied() { Some("start") => { let mut cmd = json!({ "id": id, "action": "profiler_start" }); if let Some(idx) = rest.iter().position(|s| *s == "--categories") { if let Some(cats) = rest.get(idx + 1) { let categories: Vec<&str> = cats.split(',').collect(); cmd["categories"] = json!(categories); } else { return Err(ParseError::MissingArguments { context: "profiler start --categories".to_string(), usage: "--categories ", }); } } Ok(cmd) } Some("stop") => { let mut cmd = json!({ "id": id, "action": "profiler_stop" }); if let Some(path) = rest.get(1) { cmd["path"] = json!(path); } Ok(cmd) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "profiler".to_string(), usage: "profiler [options]", }), } } // === Recording (browser video recording) === "record" => { const VALID: &[&str] = &["start", "stop", "restart"]; match rest.first().copied() { Some("start") => { let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "record start".to_string(), usage: "record start [url]", })?; // Optional URL parameter let url = rest.get(2); let mut cmd = json!({ "id": id, "action": "recording_start", "path": path }); if let Some(u) = url { // Add https:// prefix if needed (preserve special schemes) let url_str = if u.starts_with("http") || u.contains("://") { u.to_string() } else { format!("https://{}", u) }; cmd["url"] = json!(url_str); } Ok(cmd) } Some("stop") => Ok(json!({ "id": id, "action": "recording_stop" })), Some("restart") => { let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "record restart".to_string(), usage: "record restart [url]", })?; // Optional URL parameter let url = rest.get(2); let mut cmd = json!({ "id": id, "action": "recording_restart", "path": path }); if let Some(u) = url { // Add https:// prefix if needed (preserve special schemes) let url_str = if u.starts_with("http") || u.contains("://") { u.to_string() } else { format!("https://{}", u) }; cmd["url"] = json!(url_str); } Ok(cmd) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "record".to_string(), usage: "record [path] [url]", }), } } "console" => { let clear = rest.contains(&"--clear"); Ok(json!({ "id": id, "action": "console", "clear": clear })) } "errors" => { let clear = rest.contains(&"--clear"); Ok(json!({ "id": id, "action": "errors", "clear": clear })) } "highlight" => { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "highlight".to_string(), usage: "highlight ", })?; Ok(json!({ "id": id, "action": "highlight", "selector": sel })) } // === Clipboard === "clipboard" => match rest.first().copied() { Some("read") | None => { Ok(json!({ "id": id, "action": "clipboard", "operation": "read" })) } Some("write") => { rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "clipboard write".to_string(), usage: "clipboard write ", })?; let text = rest[1..].join(" "); Ok(json!({ "id": id, "action": "clipboard", "operation": "write", "text": text })) } Some("copy") => Ok(json!({ "id": id, "action": "clipboard", "operation": "copy" })), Some("paste") => Ok(json!({ "id": id, "action": "clipboard", "operation": "paste" })), Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: &["read", "write", "copy", "paste"], }), }, // === State === "state" => { const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"]; match rest.first().copied() { Some("save") => { let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "state save".to_string(), usage: "state save ", })?; Ok(json!({ "id": id, "action": "state_save", "path": path })) } Some("load") => { let path = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "state load".to_string(), usage: "state load ", })?; Ok(json!({ "id": id, "action": "state_load", "path": path })) } Some("list") => Ok(json!({ "id": id, "action": "state_list" })), Some("clear") => { let mut session_name: Option<&str> = None; let mut all = false; let mut i = 1; while i < rest.len() { match rest[i] { "--all" | "-a" => { all = true; } arg if !arg.starts_with('-') => { session_name = Some(arg); } _ => {} } i += 1; } if let Some(name) = session_name { if !is_valid_session_name(name) { return Err(ParseError::InvalidSessionName { name: name.to_string(), }); } } let mut cmd = json!({ "id": id, "action": "state_clear" }); if all { cmd["all"] = json!(true); } if let Some(name) = session_name { cmd["sessionName"] = json!(name); } Ok(cmd) } Some("show") => { let filename = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "state show".to_string(), usage: "state show ", })?; Ok(json!({ "id": id, "action": "state_show", "path": filename })) } Some("clean") => { let mut days: Option = None; let mut i = 1; while i < rest.len() { if rest[i] == "--older-than" { if let Some(d) = rest.get(i + 1) { days = d.parse().ok(); i += 1; } } i += 1; } let days = days.ok_or_else(|| ParseError::MissingArguments { context: "state clean".to_string(), usage: "state clean --older-than ", })?; Ok(json!({ "id": id, "action": "state_clean", "days": days })) } Some("rename") => { let old_name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "state rename".to_string(), usage: "state rename ", })?; let new_name = rest.get(2).ok_or_else(|| ParseError::MissingArguments { context: "state rename".to_string(), usage: "state rename ", })?; let old_name = old_name.trim_end_matches(".json"); let new_name = new_name.trim_end_matches(".json"); if !is_valid_session_name(old_name) { return Err(ParseError::InvalidSessionName { name: old_name.to_string(), }); } if !is_valid_session_name(new_name) { return Err(ParseError::InvalidSessionName { name: new_name.to_string(), }); } Ok( json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }), ) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "state".to_string(), usage: "state ...", }), } } // === iOS-specific commands === "tap" => { // Alias for click (semantic clarity for touch interfaces) let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "tap".to_string(), usage: "tap ", })?; Ok(json!({ "id": id, "action": "tap", "selector": sel })) } "swipe" => { let direction = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "swipe".to_string(), usage: "swipe [distance]", })?; let valid_directions = ["up", "down", "left", "right"]; if !valid_directions.contains(direction) { return Err(ParseError::InvalidValue { message: format!("Invalid swipe direction: {}", direction), usage: "swipe [distance]", }); } let mut cmd = json!({ "id": id, "action": "swipe", "direction": direction }); if let Some(distance) = rest.get(1) { if let Ok(d) = distance.parse::() { cmd.as_object_mut() .unwrap() .insert("distance".to_string(), json!(d)); } } Ok(cmd) } "device" => { match rest.first().copied() { Some("list") | None => { // List available iOS simulators Ok(json!({ "id": id, "action": "device_list" })) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: &["list"], }), } } "diff" => parse_diff(&rest, &id), // === Batch === "batch" => { let bail = rest.contains(&"--bail"); Ok(json!({ "id": id, "action": "batch", "bail": bail })) } _ => Err(ParseError::UnknownCommand { command: cmd.to_string(), }), } } fn parse_diff(rest: &[&str], id: &str) -> Result { const VALID: &[&str] = &["snapshot", "screenshot", "url"]; match rest.first().copied() { Some("snapshot") => { let mut cmd = json!({ "id": id, "action": "diff_snapshot" }); let obj = cmd.as_object_mut().unwrap(); let mut i = 1; while i < rest.len() { match rest[i] { "-b" | "--baseline" => { if let Some(path) = rest.get(i + 1) { obj.insert("baseline".to_string(), json!(path)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff snapshot --baseline".to_string(), usage: "diff snapshot --baseline ", }); } } "-s" | "--selector" => { if let Some(s) = rest.get(i + 1) { obj.insert("selector".to_string(), json!(s)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff snapshot --selector".to_string(), usage: "diff snapshot --selector ", }); } } "-c" | "--compact" => { obj.insert("compact".to_string(), json!(true)); } "-d" | "--depth" => { if let Some(d) = rest.get(i + 1) { match d.parse::() { Ok(n) => { obj.insert("maxDepth".to_string(), json!(n)); i += 1; } Err(_) => { return Err(ParseError::InvalidValue { message: format!( "Depth must be a non-negative integer, got: {}", d ), usage: "diff snapshot --depth ", }); } } } else { return Err(ParseError::MissingArguments { context: "diff snapshot --depth".to_string(), usage: "diff snapshot --depth ", }); } } other if other.starts_with('-') => { return Err(ParseError::InvalidValue { message: format!("Unknown flag: {}", other), usage: "diff snapshot [--baseline ] [--selector ] [--compact] [--depth ]", }); } other => { return Err(ParseError::InvalidValue { message: format!("Unexpected argument: {}", other), usage: "diff snapshot [--baseline ] [--selector ] [--compact] [--depth ]", }); } } i += 1; } Ok(cmd) } Some("screenshot") => { let mut cmd = json!({ "id": id, "action": "diff_screenshot" }); let obj = cmd.as_object_mut().unwrap(); let mut i = 1; while i < rest.len() { match rest[i] { "-b" | "--baseline" => { if let Some(path) = rest.get(i + 1) { obj.insert("baseline".to_string(), json!(path)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff screenshot --baseline".to_string(), usage: "diff screenshot --baseline ", }); } } "-o" | "--output" => { if let Some(path) = rest.get(i + 1) { obj.insert("output".to_string(), json!(path)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff screenshot --output".to_string(), usage: "diff screenshot --output ", }); } } "-t" | "--threshold" => { if let Some(t) = rest.get(i + 1) { match t.parse::() { Ok(n) if (0.0..=1.0).contains(&n) => { obj.insert("threshold".to_string(), json!(n)); i += 1; } Ok(n) => { return Err(ParseError::InvalidValue { message: format!( "Threshold must be between 0 and 1, got {}", n ), usage: "diff screenshot --threshold <0-1>", }); } Err(_) => { return Err(ParseError::InvalidValue { message: format!("Invalid threshold value: {}", t), usage: "diff screenshot --threshold <0-1>", }); } } } else { return Err(ParseError::MissingArguments { context: "diff screenshot --threshold".to_string(), usage: "diff screenshot --threshold <0-1>", }); } } "-s" | "--selector" => { if let Some(s) = rest.get(i + 1) { obj.insert("selector".to_string(), json!(s)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff screenshot --selector".to_string(), usage: "diff screenshot --selector ", }); } } "--full" | "-f" => { obj.insert("fullPage".to_string(), json!(true)); } other if other.starts_with('-') => { return Err(ParseError::InvalidValue { message: format!("Unknown flag: {}", other), usage: "diff screenshot --baseline [--output ] [--threshold <0-1>] [--selector ] [--full/-f]", }); } other => { return Err(ParseError::InvalidValue { message: format!("Unexpected argument: {}", other), usage: "diff screenshot --baseline [--output ] [--threshold <0-1>] [--selector ] [--full/-f]", }); } } i += 1; } if !obj.contains_key("baseline") { return Err(ParseError::MissingArguments { context: "diff screenshot".to_string(), usage: "diff screenshot --baseline ", }); } Ok(cmd) } Some("url") => { let url1 = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "diff url".to_string(), usage: "diff url ", })?; let url2 = rest.get(2).ok_or_else(|| ParseError::MissingArguments { context: "diff url".to_string(), usage: "diff url ", })?; let mut cmd = json!({ "id": id, "action": "diff_url", "url1": url1, "url2": url2, }); let obj = cmd.as_object_mut().unwrap(); let mut i = 3; while i < rest.len() { match rest[i] { "--screenshot" => { obj.insert("screenshot".to_string(), json!(true)); } "--full" | "-f" => { obj.insert("fullPage".to_string(), json!(true)); } "--wait-until" => { if let Some(val) = rest.get(i + 1) { obj.insert("waitUntil".to_string(), json!(val)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff url --wait-until".to_string(), usage: "diff url --wait-until ", }); } } "-s" | "--selector" => { if let Some(s) = rest.get(i + 1) { obj.insert("selector".to_string(), json!(s)); i += 1; } else { return Err(ParseError::MissingArguments { context: "diff url --selector".to_string(), usage: "diff url --selector ", }); } } "-c" | "--compact" => { obj.insert("compact".to_string(), json!(true)); } "-d" | "--depth" => { if let Some(d) = rest.get(i + 1) { match d.parse::() { Ok(n) => { obj.insert("maxDepth".to_string(), json!(n)); i += 1; } Err(_) => { return Err(ParseError::InvalidValue { message: format!( "Depth must be a non-negative integer, got: {}", d ), usage: "diff url --depth ", }); } } } else { return Err(ParseError::MissingArguments { context: "diff url --depth".to_string(), usage: "diff url --depth ", }); } } other if other.starts_with('-') => { return Err(ParseError::InvalidValue { message: format!("Unknown flag: {}", other), usage: "diff url [--screenshot] [--full/-f] [--wait-until ] [--selector ] [--compact] [--depth ]", }); } other => { return Err(ParseError::InvalidValue { message: format!("Unexpected argument: {}", other), usage: "diff url [--screenshot] [--full/-f] [--wait-until ] [--selector ] [--compact] [--depth ]", }); } } i += 1; } Ok(cmd) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "diff".to_string(), usage: "diff ", }), } } fn parse_get(rest: &[&str], id: &str) -> Result { const VALID: &[&str] = &[ "text", "html", "value", "attr", "url", "title", "count", "box", "styles", "cdp-url", ]; match rest.first().copied() { Some("text") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get text".to_string(), usage: "get text ", })?; Ok(json!({ "id": id, "action": "gettext", "selector": sel })) } Some("html") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get html".to_string(), usage: "get html ", })?; Ok(json!({ "id": id, "action": "innerhtml", "selector": sel })) } Some("value") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get value".to_string(), usage: "get value ", })?; Ok(json!({ "id": id, "action": "inputvalue", "selector": sel })) } Some("attr") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get attr".to_string(), usage: "get attr ", })?; let attr = rest.get(2).ok_or_else(|| ParseError::MissingArguments { context: "get attr".to_string(), usage: "get attr ", })?; Ok(json!({ "id": id, "action": "getattribute", "selector": sel, "attribute": attr })) } Some("url") => Ok(json!({ "id": id, "action": "url" })), Some("cdp-url") => Ok(json!({ "id": id, "action": "cdp_url" })), Some("title") => Ok(json!({ "id": id, "action": "title" })), Some("count") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get count".to_string(), usage: "get count ", })?; Ok(json!({ "id": id, "action": "count", "selector": sel })) } Some("box") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get box".to_string(), usage: "get box ", })?; Ok(json!({ "id": id, "action": "boundingbox", "selector": sel })) } Some("styles") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "get styles".to_string(), usage: "get styles ", })?; Ok(json!({ "id": id, "action": "styles", "selector": sel })) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "get".to_string(), usage: "get [args...]", }), } } fn parse_is(rest: &[&str], id: &str) -> Result { const VALID: &[&str] = &["visible", "enabled", "checked"]; match rest.first().copied() { Some("visible") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "is visible".to_string(), usage: "is visible ", })?; Ok(json!({ "id": id, "action": "isvisible", "selector": sel })) } Some("enabled") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "is enabled".to_string(), usage: "is enabled ", })?; Ok(json!({ "id": id, "action": "isenabled", "selector": sel })) } Some("checked") => { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: "is checked".to_string(), usage: "is checked ", })?; Ok(json!({ "id": id, "action": "ischecked", "selector": sel })) } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "is".to_string(), usage: "is ", }), } } fn parse_find(rest: &[&str], id: &str) -> Result { const VALID: &[&str] = &[ "role", "text", "label", "placeholder", "alt", "title", "testid", "first", "last", "nth", ]; let locator = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "find".to_string(), usage: "find [action] [text]", })?; match *locator { "role" | "text" | "label" | "placeholder" | "alt" | "title" | "testid" | "first" | "last" => { let value = rest.get(1).ok_or_else(|| ParseError::MissingArguments { context: format!("find {}", locator), usage: match *locator { "role" => "find role [action] [--name ] [--exact]", "text" => "find text [action] [--exact]", "label" => "find label