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 ) } /// Parse a cookies file in one of three auto-detected formats: /// /// 1. JSON array — `[{"name":"x","value":"y"}, ...]` /// 2. cURL dump — the output of DevTools → Network → Copy → Copy as cURL /// (the Cookie header is extracted from `-H 'cookie: ...'` or /// `-b '...'`/`--cookie '...'`) /// 3. Bare cookie header — `name=value; name2=value2` /// /// Returns a JSON array of cookie objects (each `{ name, value }`) suitable /// for the `cookies_set` daemon action. Error text never echoes the secret /// value. pub fn parse_curl_cookies(raw: &str) -> Result, String> { let trimmed = raw.trim(); if trimmed.is_empty() { return Err("cookies file is empty".to_string()); } if trimmed.starts_with('[') { let arr: Vec = serde_json::from_str(trimmed) .map_err(|e| format!("cookies JSON parse error: {}", e))?; let mut out = Vec::with_capacity(arr.len()); for (i, c) in arr.into_iter().enumerate() { let name = c .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| format!("cookies[{}] missing string name", i))?; let value = c .get("value") .and_then(|v| v.as_str()) .ok_or_else(|| format!("cookies[{}] missing string value", i))?; out.push(json!({ "name": name, "value": value })); } return Ok(out); } // Heuristic: cURL commands start with `curl` followed by space/quote. let looks_like_curl = { let head: String = trimmed.chars().take(5).collect::().to_lowercase(); head.starts_with("curl") && head.len() > 4 && { let c = head.chars().nth(4).unwrap(); c.is_whitespace() || c == '\'' || c == '"' } }; let header = if looks_like_curl { extract_cookie_header_from_curl(trimmed).ok_or_else(|| { "no Cookie header found in this cURL - right-click an authenticated request in DevTools → Network → Copy → Copy as cURL".to_string() })? } else { trimmed.to_string() }; parse_cookie_header(&header) } fn extract_cookie_header_from_curl(curl: &str) -> Option { // Strip bash (`\`) and cmd (`^`) line continuations so -H is on one line. let joined = curl .replace("\\\r\n", " ") .replace("\\\n", " ") .replace("^\r\n", " ") .replace("^\n", " "); if let Some(v) = match_quoted_arg(&joined, "-H", Some("cookie")) { return Some(v); } if let Some(v) = match_quoted_arg(&joined, "-b", None) { return Some(v); } if let Some(v) = match_quoted_arg(&joined, "--cookie", None) { return Some(v); } None } /// Find `flag [header:]value` in haystack and return the value. /// When `expect_header` is set, the quoted value must start with that header /// name followed by a colon (case-insensitive) and the prefix is stripped. fn match_quoted_arg(haystack: &str, flag: &str, expect_header: Option<&str>) -> Option { let bytes = haystack.as_bytes(); let flag_b = flag.as_bytes(); let mut i = 0; while i + flag_b.len() < bytes.len() { if &bytes[i..i + flag_b.len()] != flag_b { i += 1; continue; } // Must be at a word boundary on the left (start of string or whitespace). if i > 0 && !bytes[i - 1].is_ascii_whitespace() { i += 1; continue; } let mut j = i + flag_b.len(); // Require a whitespace separator after the flag. if j >= bytes.len() || !bytes[j].is_ascii_whitespace() { i += 1; continue; } while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; } if j >= bytes.len() { return None; } let quote = bytes[j]; if quote != b'\'' && quote != b'"' { i = j; continue; } let start = j + 1; let mut k = start; while k < bytes.len() && bytes[k] != quote { k += 1; } if k >= bytes.len() { return None; } let value = String::from_utf8_lossy(&bytes[start..k]).into_owned(); if let Some(header) = expect_header { let lower = value.to_lowercase(); let prefix = format!("{}:", header.to_lowercase()); if let Some(stripped) = lower.strip_prefix(&prefix) { let _ = stripped; return Some(value[prefix.len()..].trim().to_string()); } i = k + 1; continue; } return Some(value); } None } fn parse_cookie_header(header: &str) -> Result, String> { let mut out = Vec::new(); for piece in header.split(';') { let piece = piece.trim(); let Some(eq) = piece.find('=') else { continue }; let name = piece[..eq].trim(); let value = piece[eq + 1..].trim(); if !name.is_empty() { out.push(json!({ "name": name, "value": value })); } } if out.is_empty() { return Err("no cookies found in input".to_string()); } Ok(out) } pub fn parse_command(args: &[String], flags: &Flags) -> Result { let mut result = parse_command_inner(args, flags)?; // Inject AGENT_BROWSER_DEFAULT_TIMEOUT into any wait-family command that // doesn't already carry an explicit timeout. Centralised here so that new // wait variants automatically inherit the default without per-variant wiring. if let Some(action) = result.get("action").and_then(|a| a.as_str()) { if action.starts_with("wait") && result.get("timeout").is_none() { if let Some(t) = flags.default_timeout { result["timeout"] = json!(t); } } } Ok(result) } fn parse_command_inner(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" => { // `open` without a URL launches the browser but stays on // about:blank. Lets agents set up routes, cookies, or init // scripts before the first real navigation (see `batch`). // `goto` and `navigate` still require a URL since those verbs // imply the navigation itself. let first_url = rest.iter().find(|a| !a.starts_with("--")); let url = match first_url { Some(u) => *u, None if cmd == "open" => { return Ok(json!({ "id": id, "action": "launch", "headless": !flags.headed })); } None => { return Err(ParseError::MissingArguments { context: cmd.to_string(), usage: "goto ", }); } }; 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); } // --gone / --hidden: wait for an element to leave the DOM or // become invisible. Useful after a click that's supposed to // close a dialog, so the next command fails fast instead of // racing into a half-rendered UI. let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") { Some("detached") } else if rest.iter().any(|&s| s == "--hidden") { Some("hidden") } else { None }; // Default: selector or timeout // First non-flag positional is selector or numeric timeout let positional = rest.iter().find(|&&s| !s.starts_with("--")); let timeout_ms = rest .iter() .position(|&s| s == "--timeout") .and_then(|idx| rest.get(idx + 1)) .and_then(|s| s.parse::().ok()); if let Some(arg) = positional { if let Ok(timeout) = arg.parse::() { Ok(json!({ "id": id, "action": "wait", "timeout": timeout })) } else { let mut cmd = json!({ "id": id, "action": "wait", "selector": arg }); if let Some(state) = state_override { cmd["state"] = json!(state); } if let Some(t) = timeout_ms { cmd["timeout"] = json!(t); } Ok(cmd) } } else { Err(ParseError::MissingArguments { context: "wait".to_string(), usage: "wait [--gone|--hidden] [--timeout ms]", }) } } // === 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" => { obj.insert("cursor".to_string(), json!(true)); } "-u" | "--urls" => { obj.insert("urls".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" => { // --curl mode: import cookies from a JSON array, // raw cURL dump, or bare Cookie header. Scoped to the // host of --domain if provided; otherwise the cookies // have no scope (daemon falls back to current origin). if let Some(curl_idx) = rest.iter().position(|a| *a == "--curl") { let path = rest.get(curl_idx + 1) .ok_or_else(|| { ParseError::MissingArguments { context: "cookies set --curl".to_string(), usage: "cookies set --curl [--domain ] [--url ]", } })?; let raw = std::fs::read_to_string(path).map_err(|e| { ParseError::InvalidValue { message: format!("cookies --curl: cannot read '{}': {}", path, e), usage: "cookies set --curl ", } })?; let mut cookies = parse_curl_cookies(&raw).map_err(|e| ParseError::InvalidValue { message: format!("cookies --curl: {}", e), usage: "cookies set --curl ", })?; let domain_idx = rest.iter().position(|a| *a == "--domain"); let domain = domain_idx.and_then(|i| rest.get(i + 1).copied()); let url_idx = rest.iter().position(|a| *a == "--url"); let url = url_idx.and_then(|i| rest.get(i + 1).copied()); for cookie in cookies.iter_mut() { if let Some(d) = domain { cookie["domain"] = json!(d); cookie["path"] = cookie.get("path").cloned().unwrap_or(json!("/")); } if let Some(u) = url { cookie["url"] = json!(u); } } return Ok(json!({ "id": id, "action": "cookies_set", "cookies": cookies, })); } 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 ]\n or: cookies set --curl [--domain ] [--url ]", })?; 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 ]\n or: cookies set --curl [--domain ] [--url ]", })?; 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") => { // Accepted forms: // tab new [url] // tab new --label [url] // tab new [url] --label let mut cmd = json!({ "id": id, "action": "tab_new" }); let mut i = 1; while i < rest.len() { match rest[i] { "--label" => { let name = rest.get(i + 1).ok_or(ParseError::MissingArguments { context: "tab new --label".to_string(), usage: "tab new --label [url]", })?; cmd["label"] = json!(name); i += 2; } other if !other.starts_with("--") && cmd.get("url").is_none() => { cmd["url"] = json!(other); i += 1; } other => { return Err(ParseError::UnknownSubcommand { subcommand: other.to_string(), valid_options: &["--label", ""], }); } } } 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(tab_ref) = rest.get(1) { cmd["tabId"] = json!(tab_ref); } Ok(cmd) } Some(tab_ref) => Ok(json!({ "id": id, "action": "tab_switch", "tabId": tab_ref, })), None => 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"); let commands: Vec<&str> = rest.iter().filter(|a| **a != "--bail").copied().collect(); let mut cmd = json!({ "id": id, "action": "batch", "bail": bail }); if !commands.is_empty() { cmd["commands"] = json!(commands); } Ok(cmd) } // === React (requires `open --enable react-devtools`) === "react" => parse_react(&rest, &id), // === Core Web Vitals + hydration === "vitals" | "web-vitals" => { let mut cmd = json!({ "id": id, "action": "vitals" }); let json_out = rest.contains(&"--json"); if json_out { cmd["json"] = json!(true); } if let Some(url) = rest.iter().find(|a| !a.starts_with("--")) { cmd["url"] = json!(url); } Ok(cmd) } // === SPA client-side navigation === "pushstate" => { let url = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "pushstate".to_string(), usage: "pushstate ", })?; Ok(json!({ "id": id, "action": "pushstate", "url": url })) } // === Remove init script === "removeinitscript" => { let identifier = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "removeinitscript".to_string(), usage: "removeinitscript ", })?; Ok(json!({ "id": id, "action": "removeinitscript", "identifier": identifier })) } _ => Err(ParseError::UnknownCommand { command: cmd.to_string(), }), } } fn parse_react(rest: &[&str], id: &str) -> Result { const VALID: &[&str] = &["tree", "inspect", "renders", "suspense"]; let sub = rest.first().copied().ok_or(ParseError::MissingArguments { context: "react".to_string(), usage: "react ", })?; let json_out = rest.contains(&"--json"); let flag = |key: &str| -> Value { if json_out { json!({ "id": id, "action": key, "json": true }) } else { json!({ "id": id, "action": key }) } }; match sub { "tree" => Ok(flag("react_tree")), "inspect" => { let id_arg = rest .iter() .skip(1) .find(|a| !a.starts_with("--")) .copied() .ok_or(ParseError::MissingArguments { context: "react inspect".to_string(), usage: "react inspect ", })?; let numeric: i64 = id_arg.parse().map_err(|_| ParseError::InvalidValue { message: format!("react inspect id must be a number, got '{}'", id_arg), usage: "react inspect ", })?; let mut cmd = json!({ "id": id, "action": "react_inspect", "fiberId": numeric }); if json_out { cmd["json"] = json!(true); } Ok(cmd) } "renders" => { let op = rest.get(1).copied().unwrap_or("start"); match op { "start" => Ok(flag("react_renders_start")), "stop" => Ok(flag("react_renders_stop")), other => Err(ParseError::UnknownSubcommand { subcommand: other.to_string(), valid_options: &["start", "stop"], }), } } "suspense" => { let only_dynamic = rest.contains(&"--only-dynamic"); let mut cmd = json!({ "id": id, "action": "react_suspense" }); if json_out { cmd["json"] = json!(true); } if only_dynamic { cmd["onlyDynamic"] = json!(true); } Ok(cmd) } other => Err(ParseError::UnknownSubcommand { subcommand: other.to_string(), valid_options: VALID, }), } } 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