use std::sync::OnceLock; use crate::color; use crate::connection::Response; static BOUNDARY_NONCE: OnceLock = OnceLock::new(); /// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so /// that untrusted page content cannot predict or spoof the boundary delimiter. /// Process ID or timestamps would be insufficient since pages can read those. fn get_boundary_nonce() -> &'static str { BOUNDARY_NONCE.get_or_init(|| { let mut buf = [0u8; 16]; getrandom::getrandom(&mut buf).expect("failed to generate random nonce"); buf.iter().map(|b| format!("{:02x}", b)).collect() }) } #[derive(Default)] pub struct OutputOptions { pub json: bool, pub content_boundaries: bool, pub max_output: Option, } impl OutputOptions { pub fn from_flags(flags: &crate::flags::Flags) -> Self { Self { json: flags.json, content_boundaries: flags.content_boundaries, max_output: flags.max_output, } } } fn truncate_if_needed(content: &str, max: Option) -> String { let Some(limit) = max else { return content.to_string(); }; // Fast path: byte length is a lower bound on char count, so if the // byte length is within the limit the char count must be too. if content.len() <= limit { return content.to_string(); } // Find the byte offset of the limit-th character. match content.char_indices().nth(limit).map(|(i, _)| i) { Some(byte_offset) => { let total_chars = content.chars().count(); format!( "{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]", &content[..byte_offset], limit, total_chars ) } // Content has fewer than `limit` chars despite more bytes None => content.to_string(), } } fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) { let content = truncate_if_needed(content, opts.max_output); if opts.content_boundaries { let origin_str = origin.unwrap_or("unknown"); let nonce = get_boundary_nonce(); println!( "--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str ); println!("{}", content); println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce); } else { println!("{}", content); } } fn format_storage_value(value: &serde_json::Value) -> String { value .as_str() .map(ToString::to_string) .unwrap_or_else(|| serde_json::to_string(value).unwrap_or_default()) } fn format_storage_text(data: &serde_json::Value) -> Option { if let Some(entries) = data.get("data").and_then(|v| v.as_object()) { if entries.is_empty() { return Some("No storage entries".to_string()); } let lines = entries .iter() .map(|(key, value)| format!("{}: {}", key, format_storage_value(value))) .collect::>(); return Some(lines.join("\n")); } let key = data.get("key").and_then(|v| v.as_str())?; let value = data.get("value")?; Some(format!("{}: {}", key, format_storage_value(value))) } fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) -> Option { match action { Some("stream_disable") => data .get("disabled") .and_then(|v| v.as_bool()) .filter(|disabled| *disabled) .map(|_| "Streaming disabled".to_string()), Some("stream_enable") | Some("stream_status") => { let enabled = data.get("enabled").and_then(|v| v.as_bool())?; if !enabled { return Some("Streaming disabled".to_string()); } let port = data.get("port").and_then(|v| v.as_u64())?; let connected = data .get("connected") .and_then(|v| v.as_bool()) .unwrap_or(false); let screencasting = data .get("screencasting") .and_then(|v| v.as_bool()) .unwrap_or(false); Some(format!( "Streaming enabled on ws://127.0.0.1:{port}\nConnected: {connected}\nScreencasting: {screencasting}" )) } _ => None, } } /// Shorten an over-long string by keeping its head and tail and eliding the /// middle, with a char count. Used so multi-KB URLs (JWT/OTP login links) don't /// flood `tab list`. fn truncate_middle(s: &str, max: usize) -> String { let n = s.chars().count(); if n <= max { return s.to_string(); } let keep = max.saturating_sub(1) / 2; let head: String = s.chars().take(keep).collect(); let tail: String = s.chars().skip(n - keep).collect(); format!("{head}…{tail} [{n} chars]") } pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) { if opts.json { if opts.content_boundaries { let mut json_val = serde_json::to_value(resp).unwrap_or_default(); if let Some(obj) = json_val.as_object_mut() { let nonce = get_boundary_nonce(); let origin = obj .get("data") .and_then(|d| d.get("origin")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); obj.insert( "_boundary".to_string(), serde_json::json!({ "nonce": nonce, "origin": origin, }), ); } println!("{}", serde_json::to_string(&json_val).unwrap_or_default()); } else { println!("{}", serde_json::to_string(resp).unwrap_or_default()); } // JSON mode includes the warning field in the JSON payload already return; } if !resp.success { eprintln!( "{} {}", color::error_indicator(), resp.error.as_deref().unwrap_or("Unknown error") ); // Still print dialog warning after errors, since a pending dialog // is the most common cause of commands timing out if let Some(ref warning) = resp.warning { eprintln!("{} {}", color::warning_indicator(), warning); } return; } if let Some(data) = &resp.data { // A click that opened a new tab: surface it so the agent doesn't read the // unchanged old page as a failed click (issue #24-A). if let Some(opened) = data.get("openedTab") { let tid = opened.get("tabId").and_then(|v| v.as_str()).unwrap_or("?"); let url = opened.get("url").and_then(|v| v.as_str()).unwrap_or(""); let followed = data .get("followed") .and_then(|v| v.as_bool()) .unwrap_or(false); let verb = if followed { "switched to new tab" } else { "opened new tab" }; eprintln!( "{} {} [{}] {}", color::cyan("→"), verb, tid, color::dim(url) ); } // `current`: the active tab's stable handle (#26). if data .get("current") .and_then(|v| v.as_bool()) .unwrap_or(false) { let tid = data.get("tabId").and_then(|v| v.as_str()).unwrap_or("?"); let title = data.get("title").and_then(|v| v.as_str()).unwrap_or(""); let url = data.get("url").and_then(|v| v.as_str()).unwrap_or(""); let target = data.get("targetId").and_then(|v| v.as_str()).unwrap_or(""); println!("{} [{}] {} - {}", color::cyan("→"), tid, title, url); println!(" {}", color::dim(&format!("target: {}", target))); return; } // Cloudflare challenge/clearance preflight (`cf-status`). Checked early // because its response carries `url`/`title`, which later generic // renderers would otherwise swallow. if action == Some("cf_status") { let challenged = data.get("challenged").and_then(|v| v.as_bool()).unwrap_or(false); let rec = data.get("recommendation").and_then(|v| v.as_str()).unwrap_or("?"); let cl = data.get("clearance"); let present = cl.and_then(|c| c.get("present")).and_then(|v| v.as_bool()).unwrap_or(false); let expired = cl.and_then(|c| c.get("expired")).and_then(|v| v.as_bool()).unwrap_or(false); let expires_in = cl.and_then(|c| c.get("expiresIn")).and_then(|v| v.as_i64()); let device = data.get("deviceVerified").and_then(|v| v.as_bool()).unwrap_or(false); let (icon, headline) = match rec { "proceed" => (color::success_indicator().to_string(), "cleared — no challenge, proceed"), "solve" => (color::warning_indicator().to_string(), "Cloudflare challenge active, no valid clearance — solve it"), "reissue" => (color::warning_indicator().to_string(), "challenge active but a clearance cookie exists — stale (IP/UA changed?), re-solve"), _ => (color::cyan("•").to_string(), "unknown"), }; println!("{} {}", icon, headline); println!(" challenged: {}", if challenged { "yes" } else { "no" }); let cl_desc = if !present { "absent".to_string() } else if expired { "present but EXPIRED".to_string() } else if let Some(s) = expires_in { format!("valid, expires in {}m {}s", s / 60, s % 60) } else { "present (session)".to_string() }; println!(" cf_clearance: {}", cl_desc); println!(" device trusted: {}", if device { "yes (CF_VERIFIED_DEVICE)" } else { "no" }); return; } // Dialog status response if action == Some("dialog") { if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) { if has_dialog { let dtype = data .get("type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let message = data.get("message").and_then(|v| v.as_str()).unwrap_or(""); println!( "{} JavaScript {} dialog is open: \"{}\"", color::warning_indicator(), dtype, message ); if let Some(default_prompt) = data.get("defaultPrompt").and_then(|v| v.as_str()) { println!(" Default prompt text: \"{}\"", default_prompt); } println!(" Use `dialog accept [text]` or `dialog dismiss` to resolve it"); } else { println!("{} No dialog is currently open", color::success_indicator()); } print_warning(resp); return; } } if let Some(output) = format_stream_status_text(action, data) { println!("{}", output); return; } if action == Some("storage_get") { if let Some(output) = format_storage_text(data) { println!("{}", output); return; } } // Inspect response (check before generic URL handler since it also has a "url" field) if action == Some("inspect") { let opened = data .get("opened") .and_then(|v| v.as_bool()) .unwrap_or(false); if opened { if let Some(url) = data.get("url").and_then(|v| v.as_str()) { println!("{} Opened DevTools: {}", color::success_indicator(), url); } else { println!("{} Opened DevTools", color::success_indicator()); } } else if let Some(err) = data.get("error").and_then(|v| v.as_str()) { eprintln!("Could not open DevTools: {}", err); } return; } // Navigation response if let Some(url) = data.get("url").and_then(|v| v.as_str()) { let title = data .get("title") .and_then(|v| v.as_str()) .map(str::trim) .filter(|t| !t.is_empty()); match title { Some(t) => { println!("{} {}", color::success_indicator(), color::bold(t)); println!(" {}", color::dim(url)); } // Title-less page: show the URL with the checkmark instead of an // empty title line. None => println!("{} {}", color::success_indicator(), color::dim(url)), } // Soft warning carried in the response (e.g. the load event timed out // but the DOM was ready — issue #10). Goes to stderr so it doesn't // pollute the stdout url/title that scripts parse. if let Some(w) = data.get("warning").and_then(|v| v.as_str()) { eprintln!("⚠ navigation: {w}"); } return; } if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) { println!("{}", cdp_url); return; } // Diff responses -- route by action to avoid fragile shape probing if let Some(obj) = data.as_object() { match action { Some("diff_snapshot") => { print_snapshot_diff(obj); return; } Some("diff_screenshot") => { print_screenshot_diff(obj); return; } Some("diff_url") => { if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) { println!("{}", color::bold("Snapshot diff:")); print_snapshot_diff(snap_data); } if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) { println!("\n{}", color::bold("Screenshot diff:")); print_screenshot_diff(ss_data); } return; } _ => {} } } let origin = data.get("origin").and_then(|v| v.as_str()); // Snapshot if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) { print_with_boundaries(snapshot, origin, opts); // Canvas-app hint: the tree was near-empty but the page paints to a // , so refs are a dead end — point at the screenshot path. if let Some(note) = data.get("note").and_then(|v| v.as_str()) { eprintln!("{}", color::dim(note)); } return; } // Frame list (`chrome-use frames`) if action == Some("frames") { if let Some(list) = data.get("frames").and_then(|v| v.as_array()) { let count = list.len(); println!( "{}", color::bold(&format!("{} frame{}", count, if count == 1 { "" } else { "s" })) ); for f in list { let idx = f.get("index").and_then(|v| v.as_i64()).unwrap_or(0); let kind = f.get("kind").and_then(|v| v.as_str()).unwrap_or("?"); let url = f.get("url").and_then(|v| v.as_str()).unwrap_or(""); let len = f.get("textLen").and_then(|v| v.as_i64()).unwrap_or(0); println!( " [{}] {:<6} {} chars {}", idx, kind, len, color::dim(if url.is_empty() { "(about:blank)" } else { url }) ); } eprintln!( "{}", color::dim("read everything with: chrome-use get text --all-frames") ); } return; } // Title if let Some(title) = data.get("title").and_then(|v| v.as_str()) { println!("{}", title); return; } // Text if let Some(text) = data.get("text").and_then(|v| v.as_str()) { print_with_boundaries(text, origin, opts); return; } // HTML if let Some(html) = data.get("html").and_then(|v| v.as_str()) { print_with_boundaries(html, origin, opts); return; } // Value if let Some(value) = data.get("value").and_then(|v| v.as_str()) { println!("{}", value); return; } // Count if let Some(count) = data.get("count").and_then(|v| v.as_i64()) { println!("{}", count); return; } // Bounding box (get box) if action == Some("boundingbox") { if let Some(obj) = data.as_object() { let x = obj.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); let y = obj.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0); let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0); println!("x: {}", x); println!("y: {}", y); println!("width: {}", w); println!("height: {}", h); } return; } // Computed styles (get styles) if let Some(styles) = data.get("styles").and_then(|v| v.as_object()) { for (key, val) in styles { let display = match val.as_str() { Some(s) => s.to_string(), None => val.to_string(), }; println!("{}: {}", key, display); } return; } // Boolean results if let Some(visible) = data.get("visible").and_then(|v| v.as_bool()) { println!("{}", visible); return; } if let Some(enabled) = data.get("enabled").and_then(|v| v.as_bool()) { println!("{}", enabled); return; } // Stealth self-check (`stealth status` / `doctor`) if let Some(s) = data.get("stealthStatus") { let ok = s.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); let mode = s.get("mode").and_then(|v| v.as_str()).unwrap_or("?"); println!( "{} stealth: {} · mode: {}", if ok { color::success_indicator().to_string() } else { color::cyan("•") }, if ok { "all checks pass" } else { "some checks need attention" }, mode ); if let Some(checks) = s.get("checks").and_then(|v| v.as_array()) { for c in checks { let pass = c.get("pass").and_then(|v| v.as_bool()).unwrap_or(false); let name = c.get("name").and_then(|v| v.as_str()).unwrap_or(""); println!(" {} {}", if pass { "✓" } else { "✗" }, name); } } if let Some(ovs) = s.get("overrides").and_then(|v| v.as_array()) { println!(" applied overrides:"); for o in ovs.iter().filter_map(|v| v.as_str()) { println!(" {}", color::dim(&format!("· {o}"))); } } return; } if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) { println!("{}", checked); return; } // Eval result if let Some(result) = data.get("result") { // Surface which page the eval actually ran on — to stderr, so it // never corrupts the parsed value on stdout. Lets an agent catch tab // drift (commands landing on the wrong tab) before trusting a result, // e.g. a logged-in `fetch` that hit the wrong origin. (In // content-boundaries mode the origin is already in the banner.) if !opts.content_boundaries { if let Some(o) = origin.filter(|o| !o.is_empty()) { eprintln!("eval @ {o}"); } } let formatted = serde_json::to_string_pretty(result).unwrap_or_default(); print_with_boundaries(&formatted, origin, opts); return; } // iOS Devices if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) { if devices.is_empty() { println!("No iOS devices available. Open Xcode to download simulator runtimes."); return; } // Separate real devices from simulators let real_devices: Vec<_> = devices .iter() .filter(|d| { d.get("isRealDevice") .and_then(|v| v.as_bool()) .unwrap_or(false) }) .collect(); let simulators: Vec<_> = devices .iter() .filter(|d| { !d.get("isRealDevice") .and_then(|v| v.as_bool()) .unwrap_or(false) }) .collect(); if !real_devices.is_empty() { println!("Connected Devices:\n"); for device in real_devices.iter() { let name = device .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unknown"); let runtime = device.get("runtime").and_then(|v| v.as_str()).unwrap_or(""); let udid = device.get("udid").and_then(|v| v.as_str()).unwrap_or(""); println!(" {} {} ({})", color::green("●"), name, runtime); println!(" {}", color::dim(udid)); } println!(); } if !simulators.is_empty() { println!("Simulators:\n"); for device in simulators.iter() { let name = device .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unknown"); let runtime = device.get("runtime").and_then(|v| v.as_str()).unwrap_or(""); let state = device .get("state") .and_then(|v| v.as_str()) .unwrap_or("Unknown"); let udid = device.get("udid").and_then(|v| v.as_str()).unwrap_or(""); let state_indicator = if state == "Booted" { color::green("●") } else { color::dim("○") }; println!(" {} {} ({})", state_indicator, name, runtime); println!(" {}", color::dim(udid)); } } return; } // Tabs if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) { // `tab list --full` prints untruncated URLs so a long SSO/redirect // URL can actually be re-opened after a stale session (issue #19). let full = data.get("full").and_then(|v| v.as_bool()).unwrap_or(false); for tab in tabs { let tab_id = tab.get("tabId").and_then(|v| v.as_str()).unwrap_or("?"); let tab_label = tab.get("label").and_then(|v| v.as_str()); let title = tab .get("title") .and_then(|v| v.as_str()) .unwrap_or("Untitled"); // A page can set its title to a multi-KB string (e.g. equal to a // giant JWT/OTP URL); truncate it like the URL so the row stays // readable. let title = truncate_middle(title, 120); let title = title.as_str(); let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); // Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so // the list stays readable instead of flooding the terminal — // unless `--full` was asked for (to re-open the exact URL). let url = if full { url.to_string() } else { truncate_middle(url, 120) }; let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false); let marker = if active { color::cyan("→") } else { " ".to_string() }; if let Some(label) = tab_label { println!("{} [{}] {} {} - {}", marker, tab_id, label, title, url); } else { println!("{} [{}] {} - {}", marker, tab_id, title, url); } // `--full` also surfaces the stable cross-session CDP targetId so // a stranded tab can be adopted from another session via // `tab ` (issue #21). if full { if let Some(target_id) = tab.get("targetId").and_then(|v| v.as_str()) { println!(" {}", color::dim(&format!("target: {}", target_id))); } } } return; } // Tab switch if action == Some("tab_switch") { if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) { let warning = data.get("warning").and_then(|v| v.as_str()); // A non-responding session isn't a real success — show a warning // indicator instead of the green ✓ (issue #29.3). let indicator = if warning.is_some() { color::warning_indicator() } else { color::success_indicator() }; if let Some(url) = data.get("url").and_then(|v| v.as_str()) { println!("{} Switched to tab [{}] ({})", indicator, tab_id, url); } else { println!("{} Switched to tab [{}]", indicator, tab_id); } if let Some(w) = warning { eprintln!("{}", color::dim(w)); } return; } } // New tab/window if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) { if let Some(total) = data.get("total").and_then(|v| v.as_i64()) { let label_noun = match action { Some("window_new") => "Window opened", _ => "Tab opened", }; let tab_label = data.get("label").and_then(|v| v.as_str()); if let Some(lbl) = tab_label { println!( "{} {} [{}] {} ({} total)", color::success_indicator(), label_noun, tab_id, lbl, total ); } else { println!( "{} {} [{}] ({} total)", color::success_indicator(), label_noun, tab_id, total ); } return; } } // Console logs if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) { if opts.content_boundaries { let mut console_output = String::new(); for log in logs { let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); console_output.push_str(&format!( "{} {}\n", color::console_level_prefix(level), text )); } if console_output.ends_with('\n') { console_output.pop(); } print_with_boundaries(&console_output, origin, opts); } else { for log in logs { let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); println!("{} {}", color::console_level_prefix(level), text); } } return; } // Errors if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) { for err in errors { let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); println!("{} {}", color::error_indicator(), msg); } return; } // Cookies if let Some(cookies) = data.get("cookies").and_then(|v| v.as_array()) { for cookie in cookies { let name = cookie.get("name").and_then(|v| v.as_str()).unwrap_or(""); let value = cookie.get("value").and_then(|v| v.as_str()).unwrap_or(""); println!("{}={}", name, value); } return; } // Network requests if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) { // Stamp the page these requests were read from, mirroring `eval @ url`, // so a read against a drifted/wrong tab is obvious (issue #8.1). if let Some(o) = data .get("origin") .and_then(|v| v.as_str()) .filter(|o| !o.is_empty()) { eprintln!("network @ {o}"); } if requests.is_empty() { println!("No requests captured"); } else { for req in requests { let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET"); let url = req.get("url").and_then(|v| v.as_str()).unwrap_or(""); let resource_type = req .get("resourceType") .and_then(|v| v.as_str()) .unwrap_or(""); let request_id = req.get("requestId").and_then(|v| v.as_str()).unwrap_or(""); let status = req.get("status").and_then(|v| v.as_i64()); match status { Some(s) => println!( "[{}] {} {} ({}) {}", request_id, method, url, resource_type, s ), None => println!("[{}] {} {} ({})", request_id, method, url, resource_type), } } } return; } // Cleared (cookies, console, or request log) if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) { if cleared { let label = match action { Some("cookies_clear") => "Cookies cleared", Some("console") => "Console log cleared", _ => "Request log cleared", }; println!("{} {}", color::success_indicator(), label); return; } } // Bounding box if let Some(box_data) = data.get("box") { println!( "{}", serde_json::to_string_pretty(box_data).unwrap_or_default() ); return; } // Element styles if let Some(elements) = data.get("elements").and_then(|v| v.as_array()) { for (i, el) in elements.iter().enumerate() { let tag = el.get("tag").and_then(|v| v.as_str()).unwrap_or("?"); let text = el.get("text").and_then(|v| v.as_str()).unwrap_or(""); println!("[{}] {} \"{}\"", i, tag, text); if let Some(box_data) = el.get("box") { let w = box_data.get("width").and_then(|v| v.as_i64()).unwrap_or(0); let h = box_data.get("height").and_then(|v| v.as_i64()).unwrap_or(0); let x = box_data.get("x").and_then(|v| v.as_i64()).unwrap_or(0); let y = box_data.get("y").and_then(|v| v.as_i64()).unwrap_or(0); println!(" box: {}x{} at ({}, {})", w, h, x, y); } if let Some(styles) = el.get("styles") { let font_size = styles .get("fontSize") .and_then(|v| v.as_str()) .unwrap_or(""); let font_weight = styles .get("fontWeight") .and_then(|v| v.as_str()) .unwrap_or(""); let font_family = styles .get("fontFamily") .and_then(|v| v.as_str()) .unwrap_or(""); let color = styles.get("color").and_then(|v| v.as_str()).unwrap_or(""); let bg = styles .get("backgroundColor") .and_then(|v| v.as_str()) .unwrap_or(""); let radius = styles .get("borderRadius") .and_then(|v| v.as_str()) .unwrap_or(""); println!(" font: {} {} {}", font_size, font_weight, font_family); println!(" color: {}", color); println!(" background: {}", bg); if radius != "0px" { println!(" border-radius: {}", radius); } } println!(); } return; } // Closed (browser or tab) if data.get("closed").is_some() { let label = match action { Some("tab_close") => { if let Some(closed_id) = data.get("tabId").and_then(|v| v.as_str()) { println!("{} Tab [{}] closed", color::success_indicator(), closed_id); return; } "Tab closed" } _ => "Browser closed", }; println!("{} {}", color::success_indicator(), label); return; } // Started actions (profiling, HAR, recording) if let Some(started) = data.get("started").and_then(|v| v.as_bool()) { if started { match action { Some("profiler_start") => { println!("{} Profiling started", color::success_indicator()); } Some("har_start") => { println!("{} HAR recording started", color::success_indicator()); } _ => { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { println!("{} Recording started: {}", color::success_indicator(), path); } else { println!("{} Recording started", color::success_indicator()); } } } return; } } // Recording restart (has "stopped" field - from recording_restart action) if data.get("stopped").is_some() { let path = data .get("path") .and_then(|v| v.as_str()) .unwrap_or("unknown"); if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) { println!( "{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path ); } else { println!("{} Recording started: {}", color::success_indicator(), path); } return; } // Recording stop (has "frames" field - from recording_stop action) if data.get("frames").is_some() { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { if let Some(error) = data.get("error").and_then(|v| v.as_str()) { println!( "{} Recording saved to {} - {}", color::warning_indicator(), path, error ); } else { println!("{} Recording saved to {}", color::success_indicator(), path); } } else { println!("{} Recording stopped", color::success_indicator()); } return; } // Download response (has "suggestedFilename" or "filename" field) if data.get("suggestedFilename").is_some() || data.get("filename").is_some() { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { let filename = data .get("suggestedFilename") .or_else(|| data.get("filename")) .and_then(|v| v.as_str()) .unwrap_or(""); if filename.is_empty() { println!( "{} Downloaded to {}", color::success_indicator(), color::green(path) ); } else { println!( "{} Downloaded to {} ({})", color::success_indicator(), color::green(path), filename ); } return; } } // Trace stop without path if data.get("traceStopped").is_some() { println!("{} Trace stopped", color::success_indicator()); return; } // Path-based operations (screenshot/pdf/trace/har/download/state/video) if let Some(path) = data.get("path").and_then(|v| v.as_str()) { match action.unwrap_or("") { "screenshot" => { println!( "{} Screenshot saved to {}", color::success_indicator(), color::green(path) ); // Stamp which page was captured (mirrors `eval @ url`) so a // screenshot of the wrong/drifted tab is obvious (issue #8.1). if let Some(o) = data .get("origin") .and_then(|v| v.as_str()) .filter(|o| !o.is_empty()) { eprintln!("screenshot @ {o}"); } if let Some(annotations) = data.get("annotations").and_then(|v| v.as_array()) { // Cap the printed legend on dense pages (it can be // hundreds of lines and flood the terminal). The image // still shows every marker; --json returns the full list. const LEGEND_CAP: usize = 40; let total = annotations.len(); for ann in annotations.iter().take(LEGEND_CAP) { let num = ann.get("number").and_then(|n| n.as_u64()).unwrap_or(0); let ref_id = ann.get("ref").and_then(|r| r.as_str()).unwrap_or(""); let role = ann.get("role").and_then(|r| r.as_str()).unwrap_or(""); let name = ann.get("name").and_then(|n| n.as_str()).unwrap_or(""); if name.is_empty() { println!( " {} @{} {}", color::dim(&format!("[{}]", num)), ref_id, role, ); } else { println!( " {} @{} {} {:?}", color::dim(&format!("[{}]", num)), ref_id, role, name, ); } } if total > LEGEND_CAP { println!( " {}", color::dim(&format!( "… and {} more markers (shown in the image; --json for the full list)", total - LEGEND_CAP )) ); } } } "pdf" => println!( "{} PDF saved to {}", color::success_indicator(), color::green(path) ), "trace_stop" => println!( "{} Trace saved to {}", color::success_indicator(), color::green(path) ), "profiler_stop" => println!( "{} Profile saved to {} ({} events)", color::success_indicator(), color::green(path), data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0) ), "har_stop" => println!( "{} HAR saved to {} ({} requests)", color::success_indicator(), color::green(path), data.get("requestCount") .and_then(|c| c.as_u64()) .unwrap_or(0) ), "download" | "waitfordownload" => println!( "{} Download saved to {}", color::success_indicator(), color::green(path) ), "video_stop" => println!( "{} Video saved to {}", color::success_indicator(), color::green(path) ), "state_save" => println!( "{} State saved to {}", color::success_indicator(), color::green(path) ), "state_load" => { if let Some(note) = data.get("note").and_then(|v| v.as_str()) { println!("{}", note); } println!( "{} State path set to {}", color::success_indicator(), color::green(path) ); } // video_start and other commands that provide a path with a note "video_start" => { if let Some(note) = data.get("note").and_then(|v| v.as_str()) { println!("{}", note); } println!("Path: {}", path); } _ => println!( "{} Saved to {}", color::success_indicator(), color::green(path) ), } return; } // State list if let Some(files) = data.get("files").and_then(|v| v.as_array()) { if let Some(dir) = data.get("directory").and_then(|v| v.as_str()) { println!("{}", color::bold(&format!("Saved states in {}", dir))); } if files.is_empty() { println!("{}", color::dim(" No state files found")); } else { for file in files { let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or(""); let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0); let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or(""); let encrypted = file .get("encrypted") .and_then(|v| v.as_bool()) .unwrap_or(false); let size_str = if size > 1024 { format!("{:.1}KB", size as f64 / 1024.0) } else { format!("{}B", size) }; let date_str = modified.split('T').next().unwrap_or(modified); let enc_str = if encrypted { " [encrypted]" } else { "" }; println!( " {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)) ); } } return; } // State rename if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) { let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or(""); let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or(""); println!( "{} Renamed {} -> {}", color::success_indicator(), old_name, new_name ); return; } // State clear if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) { println!( "{} Cleared {} state file(s)", color::success_indicator(), cleared ); return; } // State show summary if let Some(summary) = data.get("summary") { let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0); let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0); let encrypted = data .get("encrypted") .and_then(|v| v.as_bool()) .unwrap_or(false); let enc_str = if encrypted { " (encrypted)" } else { "" }; println!("State file summary{}:", enc_str); println!(" Cookies: {}", cookies); println!(" Origins with localStorage: {}", origins); return; } // State clean if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) { println!( "{} Cleaned {} old state file(s)", color::success_indicator(), cleaned ); return; } // Informational note if let Some(note) = data.get("note").and_then(|v| v.as_str()) { println!("{}", note); return; } // Auth list if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) { if profiles.is_empty() { println!("{}", color::dim("No auth profiles saved")); } else { println!("{}", color::bold("Auth profiles:")); for p in profiles { let name = p.get("name").and_then(|v| v.as_str()).unwrap_or(""); let url = p.get("url").and_then(|v| v.as_str()).unwrap_or(""); let user = p.get("username").and_then(|v| v.as_str()).unwrap_or(""); println!( " {} {} {}", color::green(name), color::dim(user), color::dim(url) ); } } return; } // Auth show if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) { let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or(""); let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or(""); let user = profile .get("username") .and_then(|v| v.as_str()) .unwrap_or(""); let created = profile .get("createdAt") .and_then(|v| v.as_str()) .unwrap_or(""); let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str()); println!("Name: {}", name); println!("URL: {}", url); println!("Username: {}", user); println!("Created: {}", created); if let Some(ll) = last_login { println!("Last login: {}", ll); } return; } // Auth save/update/login/delete if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) { let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); println!( "{} Auth profile '{}' saved", color::success_indicator(), name ); return; } if data .get("updated") .and_then(|v| v.as_bool()) .unwrap_or(false) && !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) { let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); println!( "{} Auth profile '{}' updated", color::success_indicator(), name ); return; } if data .get("loggedIn") .and_then(|v| v.as_bool()) .unwrap_or(false) { let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); if let Some(title) = data.get("title").and_then(|v| v.as_str()) { println!( "{} Logged in as '{}' - {}", color::success_indicator(), name, title ); } else { println!("{} Logged in as '{}'", color::success_indicator(), name); } return; } if data .get("deleted") .and_then(|v| v.as_bool()) .unwrap_or(false) { if let Some(name) = data.get("name").and_then(|v| v.as_str()) { println!( "{} Auth profile '{}' deleted", color::success_indicator(), name ); return; } } // Confirmation required (for orchestrator use) if data .get("confirmation_required") .and_then(|v| v.as_bool()) .unwrap_or(false) { let category = data.get("category").and_then(|v| v.as_str()).unwrap_or(""); let description = data .get("description") .and_then(|v| v.as_str()) .unwrap_or(""); let cid = data .get("confirmation_id") .and_then(|v| v.as_str()) .unwrap_or(""); println!("Confirmation required:"); println!(" {}: {}", category, description); println!(" Run: chrome-use confirm {}", cid); println!(" Or: chrome-use deny {}", cid); return; } if data .get("confirmed") .and_then(|v| v.as_bool()) .unwrap_or(false) { println!("{} Action confirmed", color::success_indicator()); return; } if data .get("denied") .and_then(|v| v.as_bool()) .unwrap_or(false) { println!("{} Action denied", color::success_indicator()); return; } // Default success println!("{} Done", color::success_indicator()); } else { // Success response with no data payload — still confirm the command ran // instead of printing nothing (a silent exit 0 looks like a no-op and // hides whether anything happened). println!("{} Done", color::success_indicator()); } print_warning(resp); } fn print_warning(resp: &Response) { if let Some(ref warning) = resp.warning { eprintln!("{} {}", color::warning_indicator(), warning); } } /// Print command-specific help. Returns true if help was printed, false if command unknown. pub fn print_command_help(command: &str) -> bool { let help = match command { // === Navigation === "open" | "goto" | "navigate" => { r##" chrome-use open - Launch the browser, optionally navigate Usage: chrome-use open [url] Without a URL, launches the browser but stays on about:blank. This lets you stage state (network routes, cookies, init scripts) before the first real navigation — useful for SSR debug, auth setup, and capturing fresh `react suspense` / `vitals` state without noise from a prior page. With a URL, launches and navigates. If no protocol is provided, https:// is automatically prepended. The `goto` and `navigate` aliases still require a URL. Global Options: --json Output as JSON --session Use specific session --headers Set HTTP headers (scoped to this origin) --headed Show browser window (default; headless is forbidden — it's a bot tell) --enable react-devtools Inject the React DevTools hook before any page JS --init-script Register a page init script (repeatable) Examples: chrome-use open # Launch, no nav chrome-use open example.com chrome-use open https://github.com chrome-use open localhost:3000 chrome-use open api.example.com --headers '{"Authorization": "Bearer token"}' # ^ Headers only sent to api.example.com, not other domains # Pre-navigation setup in one turn: chrome-use batch \ '["open"]' \ '["network","route","*","--abort","--resource-type","script"]' \ '["navigate","http://localhost:3000/target"]' "## } "back" => { r##" chrome-use back - Navigate back in history Usage: chrome-use back Goes back one page in the browser history, equivalent to clicking the browser's back button. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use back "## } "forward" => { r##" chrome-use forward - Navigate forward in history Usage: chrome-use forward Goes forward one page in the browser history, equivalent to clicking the browser's forward button. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use forward "## } "reload" => { r##" chrome-use reload - Reload the current page Usage: chrome-use reload Reloads the current page, equivalent to pressing F5 or clicking the browser's reload button. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use reload "## } // === Core Actions === "click" => { r##" chrome-use click - Click an element or a coordinate Usage: chrome-use click [--new-tab] chrome-use click | , | --coords , Clicks on the specified element. The selector can be a CSS selector, XPath, or an element reference from snapshot (e.g., @e1). A bare-number argument is treated as a viewport coordinate, not a selector — `click 449 320` clicks the pixel point (no element needed). Options: --coords , Click a viewport coordinate (explicit form) --new-tab Open link in a new tab instead of navigating current tab (only works on elements with href attribute) Global Options: --json Output as JSON --session Use specific session Examples: chrome-use click "#submit-button" chrome-use click @e1 chrome-use click "button.primary" chrome-use click "//button[@type='submit']" chrome-use click @e3 --new-tab "## } "dblclick" => { r##" chrome-use dblclick - Double-click an element Usage: chrome-use dblclick Double-clicks on the specified element. Useful for text selection or triggering double-click handlers. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use dblclick "#editable-text" chrome-use dblclick @e5 "## } "fill" => { r##" chrome-use fill - Clear and fill an input field Usage: chrome-use fill Clears the input field and fills it with the specified text. This replaces any existing content in the field. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use fill "#email" "user@example.com" chrome-use fill @e3 "Hello World" chrome-use fill "input[name='search']" "query" "## } "type" => { r##" chrome-use type - Type text into an element Usage: chrome-use type Types text into the specified element character by character. Unlike fill, this does not clear existing content first. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use type "#search" "hello" chrome-use type @e2 "additional text" See Also: For typing into contenteditable editors (Lexical, ProseMirror, etc.) without a selector, use 'keyboard type' instead: chrome-use keyboard type "# My Heading" "## } "hover" => { r##" chrome-use hover - Hover over an element Usage: chrome-use hover Moves the mouse to hover over the specified element. Useful for triggering hover states or dropdown menus. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use hover "#dropdown-trigger" chrome-use hover @e4 "## } "focus" => { r##" chrome-use focus - Focus an element Usage: chrome-use focus Sets keyboard focus to the specified element. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use focus "#input-field" chrome-use focus @e2 "## } "check" => { r##" chrome-use check - Check a checkbox Usage: chrome-use check Checks a checkbox element. If already checked, no action is taken. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use check "#terms-checkbox" chrome-use check @e7 "## } "uncheck" => { r##" chrome-use uncheck - Uncheck a checkbox Usage: chrome-use uncheck Unchecks a checkbox element. If already unchecked, no action is taken. Global Options: --json Output as JSON --session Use specific session Examples: chrome-use uncheck "#newsletter-opt-in" chrome-use uncheck @e8 "## } "select" => { r##" chrome-use select - Select a dropdown option Usage: chrome-use select Selects one or more options in a