1use super::args::{CheckpointCommand, Cli, Commands, ProfileCommand};
7use crate::browser::policy::{BrowserPolicy, PolicyCapability};
8use crate::browser::profile::ProfileManager;
9use crate::browser::session::{
10 ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Locator, PdfOptions,
11 ReconciliationOptions, SessionOptions, VisualCaptureOptions, WaitCondition,
12};
13use base64::Engine;
14use serde::Serialize;
15use std::io::Read;
16use std::time::Duration;
17
18pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
21 let policy = policy_from_cli(&cli)?;
22 if cli.mcp {
23 return crate::mcp::server::run_mcp_server(&cli).await;
24 }
25
26 match &cli.command {
27 Some(Commands::InstallChromium { update }) => {
28 let path = crate::browser::chrome::download_chromium(*update).await?;
29 println!("Chrome for Testing installed at {}", path.display());
30 return Ok(());
31 }
32 Some(Commands::Profiles { action }) => {
33 policy.require(PolicyCapability::PersistentProfile)?;
34 dispatch_profiles(action.as_ref())?;
35 return Ok(());
36 }
37 Some(Commands::DeleteProfile { name }) => {
38 policy.require(PolicyCapability::PersistentProfile)?;
39 ProfileManager::new().delete_profile(name)?;
40 println!("deleted profile {name}");
41 return Ok(());
42 }
43 Some(Commands::Tui) | None if cli.prompt.is_none() => {
44 return crate::tui::app::run_tui(&cli).await;
45 }
46 _ => {}
47 }
48
49 let options = SessionOptions {
50 port: cli.port,
51 chrome_path: cli.chrome_path.clone(),
52 profile: cli.profile.clone(),
53 incognito: cli.incognito,
54 attach: cli.attach,
55 target_id: cli.target_id.clone(),
56 frame_id: cli.frame_id.clone(),
57 headed: cli.headed,
58 interaction_mode: cli.interaction,
59 audit: cli.audit,
60 policy: None,
61 };
62 let session = BrowserSession::start_with_policy(&options, policy).await?;
63 let result = if let Some(prompt) = &cli.prompt {
64 run_prompt(&session, prompt).await
65 } else if let Some(command) = &cli.command {
66 run_command(&session, command).await
67 } else {
68 Ok(())
69 };
70 if let Err(error) = &result
71 && cli.trace_on_error
72 {
73 let trace = session
74 .failure_trace_for(
75 cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
76 error.to_string(),
77 )
78 .await;
79 eprintln!("{}", serde_json::to_string(&trace)?);
80 }
81 let close_result = session.close().await;
82 result?;
83 close_result
84}
85
86fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
87 if let Some(prompt) = prompt {
88 let lower = prompt.trim().to_ascii_lowercase();
89 return if lower.starts_with("double click ") {
90 ActionKind::DoubleClick
91 } else if lower.starts_with("click ") {
92 ActionKind::Click
93 } else if lower.starts_with("type ") {
94 ActionKind::Type
95 } else {
96 ActionKind::Click
97 };
98 }
99 match command {
100 Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
101 Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
102 Some(Commands::Click { .. })
103 | Some(Commands::Preflight { .. })
104 | Some(Commands::ClickAt { .. }) => ActionKind::Click,
105 Some(Commands::Hover { .. }) => ActionKind::Hover,
106 Some(Commands::Drag { .. }) => ActionKind::Drag,
107 Some(Commands::Type { .. }) => ActionKind::Type,
108 Some(Commands::Key { .. }) => ActionKind::KeyPress,
109 Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
110 Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
111 Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
112 Some(Commands::Clear { .. }) => ActionKind::Clear,
113 Some(Commands::Check { .. }) => ActionKind::Check,
114 Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
115 Some(Commands::Select { .. }) => ActionKind::Select,
116 Some(Commands::Upload { .. }) => ActionKind::Upload,
117 Some(Commands::Scroll { .. }) => ActionKind::Scroll,
118 _ => ActionKind::Click,
119 }
120}
121
122fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
123 let manager = ProfileManager::new();
124 match action {
125 None | Some(ProfileCommand::List) => {
126 let profiles = manager.list_profiles()?;
127 if profiles.is_empty() {
128 println!("no saved profiles");
129 } else {
130 for profile in profiles {
131 println!("{profile}");
132 }
133 }
134 }
135 Some(ProfileCommand::Create { name }) => {
136 manager.create_profile(name)?;
137 println!("created profile {name}");
138 }
139 Some(ProfileCommand::Delete { name }) => {
140 manager.delete_profile(name)?;
141 println!("deleted profile {name}");
142 }
143 }
144 Ok(())
145}
146
147async fn run_command(session: &BrowserSession, command: &Commands) -> BrowserResult<()> {
148 match command {
149 Commands::Navigate { url, timeout_ms } => {
150 let page = session
151 .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
152 .await?;
153 print_json(&page)?;
154 }
155 Commands::Click { target } => {
156 print_json(&session.click(target).await?)?;
157 }
158 Commands::Preflight { target, action } => {
159 print_json(&session.preflight_with_action(target, *action).await)?;
160 }
161 Commands::ClickAt { x, y } => {
162 print_json(&session.click_at(*x, *y).await?)?;
163 }
164 Commands::ClickExpectPopup { target } => {
165 print_json(&session.click_expect_popup(target).await?)?;
166 }
167 Commands::DoubleClick { target } => {
168 print_json(&session.double_click(target).await?)?;
169 }
170 Commands::Hover { target } => print_json(&session.hover(target).await?)?,
171 Commands::Drag {
172 source,
173 destination,
174 } => {
175 print_json(&session.drag(source, destination).await?)?;
176 }
177 Commands::Type { text, target } => {
178 print_json(&session.type_text(text, target.as_deref()).await?)?;
179 }
180 Commands::Key { key } => print_json(&session.key_press(key).await?)?,
181 Commands::KeyDown { key } => print_json(&session.key_down(key).await?)?,
182 Commands::KeyUp { key } => print_json(&session.key_up(key).await?)?,
183 Commands::Shortcut { shortcut } => print_json(&session.shortcut(shortcut).await?)?,
184 Commands::Clear { target } => print_json(&session.clear(target).await?)?,
185 Commands::Check { target } => print_json(&session.check(target).await?)?,
186 Commands::Uncheck { target } => print_json(&session.uncheck(target).await?)?,
187 Commands::Select { target, value } => {
188 print_json(&session.select_option(target, value).await?)?;
189 }
190 Commands::Upload { target, files } => {
191 print_json(&session.upload_files(target, files).await?)?;
192 }
193 Commands::Screenshot {
194 output,
195 format,
196 quality,
197 scale,
198 full_page,
199 clip,
200 target,
201 } => {
202 let output = session
203 .policy()
204 .require_output_path(std::path::Path::new(output))?;
205 let capture = session
206 .capture_visual(&VisualCaptureOptions {
207 format: *format,
208 quality: *quality,
209 scale: *scale,
210 clip: *clip,
211 full_page: *full_page,
212 target: target.clone(),
213 })
214 .await?;
215 let mut source = base64::read::DecoderReader::new(
216 capture.data.as_bytes(),
217 &base64::engine::general_purpose::STANDARD,
218 );
219 let mut file = std::fs::File::create(&output)?;
220 std::io::copy(&mut source, &mut file)?;
221 println!("wrote {}", output.display());
222 print_json(&capture.metadata)?;
223 }
224 Commands::Text => println!("{}", session.text().await?),
225 Commands::Dom => print_json(&session.deep_dom().await?)?,
226 Commands::Observe {
227 deep_dom,
228 screenshot,
229 form_values,
230 } => {
231 let context = match (*deep_dom, *screenshot, *form_values) {
232 (false, false, false) => session.observe().await?,
233 (true, false, false) => session.observe_with_dom().await?,
234 (false, true, false) => session.observe_with_screenshot().await?,
235 (true, true, false) => session.observe_with_dom_and_screenshot().await?,
236 (false, false, true) => session.observe_with_form_values().await?,
237 _ => return Err("form values can only be combined with compact observe".into()),
238 };
239 print_json(&context)?;
240 }
241 Commands::Scroll { dx, dy } => {
242 print_json(&session.scroll(*dx, *dy).await?)?;
243 }
244 Commands::Wait {
245 condition,
246 timeout_ms,
247 } => {
248 print_json(
249 &session
250 .wait(
251 WaitCondition::parse(condition)?,
252 Duration::from_millis(*timeout_ms),
253 )
254 .await?,
255 )?;
256 }
257 Commands::Diagnostics { duration_ms } => print_json(
258 &session
259 .diagnostics(Duration::from_millis(*duration_ms))
260 .await?,
261 )?,
262 Commands::AcceptDialog => {
263 session.accept_dialog().await?;
264 print_json(&serde_json::json!({"dialog": "accepted"}))?;
265 }
266 Commands::DismissDialog => {
267 session.dismiss_dialog().await?;
268 print_json(&serde_json::json!({"dialog": "dismissed"}))?;
269 }
270 Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
271 Commands::Download {
272 destination,
273 timeout_ms,
274 } => print_json(
275 &session
276 .wait_for_download(destination, Duration::from_millis(*timeout_ms))
277 .await?,
278 )?,
279 Commands::Targets => print_json(&session.list_targets().await?)?,
280 Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
281 Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
282 Commands::CloseTarget { id } => {
283 session.close_target(id).await?;
284 print_json(&serde_json::json!({"closed": id}))?;
285 }
286 Commands::Frames => print_json(&session.list_frames().await?)?,
287 Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
288 Commands::Evaluate { expression } => {
289 print_json(&session.evaluate(expression).await?)?;
290 }
291 Commands::Cookies => print_json(&session.cookies().await?)?,
292 Commands::Pdf { output, background } => {
293 let mut opts = PdfOptions::letter();
294 if *background {
295 opts.print_background = Some(true);
296 }
297 let data = session.print_to_pdf(&opts).await?;
298 let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
299 tokio::fs::write(&output, &bytes).await?;
300 println!("PDF saved to {output} ({} bytes)", bytes.len());
301 }
302 Commands::FillForm { fields } => {
303 let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
304 let field_refs: Vec<(String, String)> = parsed
305 .iter()
306 .map(|v| {
307 (
308 v["target"].as_str().unwrap_or("").to_string(),
309 v["value"].as_str().unwrap_or("").to_string(),
310 )
311 })
312 .collect();
313 let field_slices: Vec<(&str, &str)> = field_refs
314 .iter()
315 .map(|(t, v)| (t.as_str(), v.as_str()))
316 .collect();
317 print_json(&session.fill_form(&field_slices).await?)?;
318 }
319 Commands::Batch { input, atomic } => {
320 let payload = read_json_input(input.as_ref())?;
321 let steps_value = payload.get("steps").cloned().unwrap_or(payload);
322 let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
323 .map_err(|error| format!("invalid batch document: {error}"))?;
324 print_json(&session.run_batch_with_options(&steps, *atomic).await?)?;
325 }
326 Commands::ReconcileRefs {
327 from_revision,
328 hints,
329 scope,
330 refs,
331 } => {
332 let options = ReconciliationOptions {
333 hints: hints
334 .iter()
335 .map(|hint| Locator::parse(hint))
336 .collect::<BrowserResult<Vec<_>>>()?,
337 scope_ref: scope.clone(),
338 };
339 print_json(
340 &session
341 .reconcile_references_with_options(*from_revision, refs, &options)
342 .await?,
343 )?;
344 }
345 Commands::ObserveDelta => {
346 print_json(&session.observe_delta().await?)?;
347 }
348 Commands::Checkpoint { action } => match action {
349 CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
350 CheckpointCommand::Import { input } => {
351 let checkpoint: CheckpointV1 =
352 serde_json::from_value(read_json_input(input.as_ref())?)
353 .map_err(|error| format!("invalid checkpoint: {error}"))?;
354 session.import_checkpoint(&checkpoint).await?;
355 print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
356 }
357 },
358 Commands::ClipboardRead => {
359 let text = session.clipboard_read().await?;
360 println!("{text}");
361 }
362 Commands::ClipboardWrite { text } => {
363 session.clipboard_write(text).await?;
364 println!("Text written to clipboard");
365 }
366 Commands::Tui
367 | Commands::InstallChromium { .. }
368 | Commands::Profiles { .. }
369 | Commands::DeleteProfile { .. } => {
370 unreachable!("handled before starting a browser session")
371 }
372 }
373 Ok(())
374}
375
376fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
377 let mut input = String::new();
378 match path {
379 Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
380 None => std::io::stdin().read_to_string(&mut input)?,
381 };
382 Ok(serde_json::from_str(&input)?)
383}
384
385pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
386 Ok(BrowserPolicy::new(
387 cli.policy,
388 std::env::current_dir()?,
389 cli.policy_allow.iter().copied(),
390 cli.policy_confirm.iter().copied(),
391 )?
392 .with_host_rules(
393 cli.policy_allow_host.iter().cloned(),
394 cli.policy_deny_host.iter().cloned(),
395 )?
396 .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
397}
398
399async fn run_prompt(session: &BrowserSession, prompt: &str) -> BrowserResult<()> {
400 let trimmed = prompt.trim();
401 let lower = trimmed.to_lowercase();
402
403 for prefix in ["navigate to ", "go to ", "open "] {
404 if lower.starts_with(prefix) {
405 let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
406 print_json(&page)?;
407 return Ok(());
408 }
409 }
410 if let Some(rest) = lower.strip_prefix("click ") {
411 let target = &trimmed[trimmed.len() - rest.len()..];
412 print_json(&session.click(target.trim_matches('"')).await?)?;
413 return Ok(());
414 }
415 if let Some(rest) = lower.strip_prefix("double click ") {
416 let target = &trimmed[trimmed.len() - rest.len()..];
417 print_json(&session.double_click(target.trim_matches('"')).await?)?;
418 return Ok(());
419 }
420 if let Some(rest) = lower.strip_prefix("type ") {
421 let text = &trimmed[trimmed.len() - rest.len()..];
422 print_json(&session.type_text(text.trim_matches('"'), None).await?)?;
423 return Ok(());
424 }
425 if lower.starts_with("screenshot") {
426 let output = trimmed
427 .split_once(char::is_whitespace)
428 .map(|(_, value)| value.trim())
429 .filter(|value| !value.is_empty())
430 .unwrap_or("screenshot.png");
431 let output = session
432 .policy()
433 .require_output_path(std::path::Path::new(output))?;
434 std::fs::write(&output, session.screenshot_png().await?)?;
435 println!("wrote {}", output.display());
436 return Ok(());
437 }
438 if matches!(
439 lower.as_str(),
440 "text" | "get text" | "page text" | "get content"
441 ) {
442 println!("{}", session.text().await?);
443 return Ok(());
444 }
445 if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
446 print_json(&session.deep_dom().await?)?;
447 return Ok(());
448 }
449 if matches!(lower.as_str(), "observe" | "context") {
450 print_json(&session.observe().await?)?;
451 return Ok(());
452 }
453
454 print_json(&session.evaluate(trimmed).await?)?;
455 Ok(())
456}
457
458fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
459 println!("{}", compact_json(value)?);
460 Ok(())
461}
462
463fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
464 let mut value = serde_json::to_value(value)?;
465 let payload = serde_json::to_vec(&value)?;
466 let payload_bytes = payload.len();
467 if let Some(object) = value.as_object_mut() {
468 object.insert(
469 "contextCost".to_string(),
470 serde_json::json!({
471 "payloadBytes": payload_bytes,
472 "estimatedTokens": payload_bytes.div_ceil(4)
473 }),
474 );
475 }
476 Ok(serde_json::to_string(&value)?)
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use serde_json::json;
483
484 #[test]
485 fn structured_cli_output_is_compact_json() {
486 let output = compact_json(&json!({
487 "page": {"title": "Glass", "url": "https://example.com"},
488 "items": [1, 2]
489 }))
490 .unwrap();
491
492 let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
493 assert!(!output.contains('\n'));
494 assert_eq!(parsed["items"], json!([1, 2]));
495 assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
496 assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
497 }
498}