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 }, } /// Top-level commands an agent is likely to mistype, used for "did you mean" /// suggestions on an unknown command (issue #29). Not exhaustive — just the /// common verbs plus a few known wrong-guesses mapped to the real command. const KNOWN_COMMANDS: &[&str] = &[ "open", "navigate", "click", "fill", "type", "press", "snapshot", "screenshot", "eval", "get", "text", "html", "frames", "find", "wait", "scroll", "hover", "select", "check", "uncheck", "tab", "tabs", "close", "back", "forward", "reload", "sessions", "status", "daemon", "doctor", "upgrade", "connect", "cookies", "mouse", "keyboard", "stream", "frame", "profiles", "title", "url", "is", "drag", "dialog", "upload", ]; /// Levenshtein distance, capped — small inputs only (command names). fn edit_distance(a: &str, b: &str) -> usize { let a: Vec = a.chars().collect(); let b: Vec = b.chars().collect(); let mut prev: Vec = (0..=b.len()).collect(); let mut curr = vec![0usize; b.len() + 1]; for (i, &ca) in a.iter().enumerate() { curr[0] = i + 1; for (j, &cb) in b.iter().enumerate() { let cost = if ca == cb { 0 } else { 1 }; curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost); } std::mem::swap(&mut prev, &mut curr); } prev[b.len()] } /// Closest known command within a small edit distance, or a prefix/substring /// match — `None` if nothing is close enough to suggest confidently. fn nearest_command(input: &str) -> Option { let lower = input.to_lowercase(); // Exact prefix/substring hits first (e.g. "session" -> "sessions"). if let Some(c) = KNOWN_COMMANDS .iter() .find(|c| c.starts_with(&lower) || lower.starts_with(**c)) { return Some(c.to_string()); } // Tolerance scales with length: short words get distance 1, longer get 2. let max_dist = if lower.len() <= 4 { 1 } else { 2 }; KNOWN_COMMANDS .iter() .map(|c| (*c, edit_distance(&lower, c))) .filter(|(_, d)| *d <= max_dist) .min_by_key(|(_, d)| *d) .map(|(c, _)| c.to_string()) } impl ParseError { pub fn format(&self) -> String { match self { ParseError::UnknownCommand { command } => match nearest_command(command) { Some(suggestion) => format!( "Unknown command: {}\nDid you mean: chrome-use {}?", command, suggestion ), None => 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: chrome-use {}", context, usage ) } ParseError::InvalidValue { message, usage } => { format!("{}\nUsage: chrome-use {}", 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))?; let mut cookie = json!({ "name": name, "value": value }); let obj = cookie.as_object_mut().unwrap(); // Preserve any CDP Network.setCookie attributes present on the // source object so a full auth state round-trips: httpOnly session // tokens, per-domain cookies (a single export spans .chatgpt.com, // .openai.com, ...), and secure/sameSite/expiry. A bare // {name,value} export is unchanged. Common aliases from DevTools / // EditThisCookie / extension exports are accepted. if let Some(v) = c.get("url").and_then(|v| v.as_str()) { obj.insert("url".into(), json!(v)); } if let Some(v) = c.get("domain").and_then(|v| v.as_str()) { obj.insert("domain".into(), json!(v)); } if let Some(v) = c.get("path").and_then(|v| v.as_str()) { obj.insert("path".into(), json!(v)); } if let Some(v) = c.get("secure").and_then(|v| v.as_bool()) { obj.insert("secure".into(), json!(v)); } if let Some(v) = c .get("httpOnly") .or_else(|| c.get("httponly")) .or_else(|| c.get("http_only")) .and_then(|v| v.as_bool()) { obj.insert("httpOnly".into(), json!(v)); } if let Some(v) = c .get("sameSite") .or_else(|| c.get("samesite")) .or_else(|| c.get("same_site")) .and_then(|v| v.as_str()) { let norm = match v.to_lowercase().as_str() { "strict" => "Strict", "lax" => "Lax", "none" | "no_restriction" => "None", _ => "", }; if !norm.is_empty() { obj.insert("sameSite".into(), json!(norm)); } } // CDP `expires` is seconds since the Unix epoch (f64). Accept // `expires` or EditThisCookie's `expirationDate`. if let Some(v) = c .get("expires") .or_else(|| c.get("expirationDate")) .and_then(|v| v.as_f64()) { if v > 0.0 { obj.insert("expires".into(), json!(v)); } } out.push(cookie); } 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. // The URL is the first positional arg, skipping flags AND any value // consumed by `--wait-until` (so it isn't mistaken for the URL). let first_url = { let mut url = None; let mut skip_next = false; for a in &rest { if skip_next { skip_next = false; continue; } if *a == "--wait-until" { skip_next = true; continue; } if !a.starts_with("--") { url = Some(a); break; } } url }; 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"); } // `--reuse-tab`: adopt an existing tab already on this URL instead of // navigating/spawning a new one (issue #21 — avoids duplicate tabs on // rebind, preserves in-page state). if rest.iter().any(|a| *a == "--reuse-tab" || *a == "--reuse") { nav_cmd["reuseTab"] = json!(true); } // Explicit readiness override (issue #10): SPAs whose `load` event // never fires (a long-lived XHR/websocket holds it open) hang out the // load-event wait. `--wait-until domcontentloaded` returns as soon as // the DOM is parsed. if let Some(i) = rest.iter().position(|a| *a == "--wait-until") { let val = rest.get(i + 1).ok_or(ParseError::MissingArguments { context: "open --wait-until".to_string(), usage: "open --wait-until ", })?; if !["load", "domcontentloaded", "networkidle", "none"].contains(val) { return Err(ParseError::InvalidValue { message: format!("Unknown --wait-until value: {}", val), usage: "open --wait-until ", }); } nav_cmd["waitUntil"] = json!(val); } 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" })), // Explicit opt-in to raise the active tab to the foreground (the core // skill references it; the daemon handler existed but the CLI didn't map // it — issue #19). Accept the documented camelCase + kebab/lowercase. "bringToFront" | "bring-to-front" | "bringtofront" => { Ok(json!({ "id": id, "action": "bringtofront" })) } // === Core Actions === "click" => { let new_tab = rest.contains(&"--new-tab"); // `--follow`: if the click opens a new tab, switch the active tab to // it (default reports the opened tab but stays put) (issue #24-A). let follow = rest.contains(&"--follow"); // Coordinate click as a first-class form (issue #8.4): when the only // handle is a pixel position, no element/selector is needed. // click e.g. click 449 320 // click , e.g. click 449,320 // click --coords , | --coords let coord_args: Vec<&str> = rest .iter() .copied() .filter(|a| !a.starts_with("--")) .collect(); if let Some((x, y)) = parse_coords(&coord_args) { return Ok(json!({ "id": id, "action": "click", "x": x, "y": y })); } let sel = rest .iter() .find(|arg| !arg.starts_with("--")) .ok_or_else(|| ParseError::MissingArguments { context: "click".to_string(), usage: "click | click | click --coords , [--new-tab] [--follow]", })?; let mut cmd = json!({ "id": id, "action": "click", "selector": sel }); if new_tab { cmd["newTab"] = json!(true); } if follow { cmd["follow"] = json!(true); } Ok(cmd) } "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" => { // `--key-events` (alias `--keys`): send real per-character keystrokes // instead of Input.insertText, so autocomplete/combobox widgets that // only react to key events fire (e.g. Google address postal lookup). let key_events = rest.iter().any(|a| *a == "--key-events" || *a == "--keys"); let rest: Vec<&str> = rest .iter() .copied() .filter(|a| *a != "--key-events" && *a != "--keys") .collect(); // `type --focused ` types into whatever element currently has // focus (no selector) — for custom widgets that move focus to a hidden // input after you open them. if rest.first() == Some(&"--focused") { return Ok(json!({ "id": id, "action": "type", "focused": true, "text": rest[1..].join(" "), "keyEvents": key_events, })); } let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { context: "type".to_string(), usage: "type (or: type --focused ) [--key-events]", })?; Ok( json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" "), "keyEvents": key_events }), ) } "pick" => { // pick --option "" — atomic combobox select: // open the control, wait for options (incl. portal menus), match by // text, fire the right event sequence, verify. Covers native