1#![allow(missing_docs)]
3
4mod meta;
5mod run;
6
7use std::path::Path;
8
9use crate::browser::{
10 block_on_browser_timeout, run_goto_with_robots, run_keys, run_press, run_scrape, run_type,
11 run_view, run_write, CaptureOpts, OneShotSession,
12};
13use crate::cli::{
14 AssertKind, Cli, Commands, CompletionShell, ConfigAction, ConsoleAction, CookieAction,
15 Devtools3pAction, DialogAction, ExtensionAction, GrabFormat, HeapAction, MitmAction, NetAction,
16 PageAction, PerfAction, ScreencastAction, WebmcpAction, WorkflowAction,
17};
18use crate::envelope::{print_error_json, print_success_json};
19use crate::error::{CliError, ErrorKind};
20use crate::lifecycle::Lifecycle;
21use crate::robots::RobotsPolicy;
22
23pub fn dispatch(cli: Cli, life: &Lifecycle) -> i32 {
25 let json = cli.globals.json
26 || matches!(
27 &cli.command,
28 Commands::Doctor { json: true, .. } | Commands::Commands { json: true }
29 );
30 let capture = CaptureOpts {
31 console: cli.globals.capture_console,
32 network: cli.globals.capture_network,
33 };
34 let timeout_secs = cli.globals.timeout;
35 let artifacts = cli.globals.artifacts_dir.clone();
36 let category_memory = cli.globals.category_memory;
37 let category_extensions = cli.globals.category_extensions;
38 let category_third_party = cli.globals.category_third_party;
39 let category_webmcp = cli.globals.category_webmcp;
40 let experimental_screencast = cli.globals.experimental_screencast;
41 let experimental_vision = cli.globals.experimental_vision;
42 let robots =
43 match RobotsPolicy::from_flags(cli.globals.ignore_robots, cli.globals.i_accept_robots_risk)
44 {
45 Ok(p) => p,
46 Err(e) => {
47 life.finalize();
48 return emit_err(&e, json);
49 }
50 };
51
52 let code = match cli.command {
53 Commands::Doctor {
54 offline,
55 quick,
56 fix,
57 json: doc_json,
58 } => crate::doctor::run_doctor(crate::doctor::DoctorOptions {
59 offline,
60 quick,
61 fix,
62 json: json || doc_json,
63 }),
64 Commands::Commands { json: cmd_json } => match meta::list_commands(json || cmd_json) {
65 Ok(()) => 0,
66 Err(e) => emit_err(&e, json || cmd_json),
67 },
68 Commands::Schema { cmd } => match meta::schema_for_cmd(&cmd, json) {
69 Ok(()) => 0,
70 Err(e) => emit_err(&e, json),
71 },
72 Commands::Version => match handle_version(json) {
73 Ok(()) => 0,
74 Err(e) => emit_err(&e, json),
75 },
76 Commands::Goto {
77 url,
78 init_script,
79 handle_before_unload,
80 navigation_timeout_ms,
81 } => {
82 match handle_goto(
83 life,
84 &url,
85 robots,
86 capture,
87 timeout_secs,
88 json,
89 init_script.as_deref(),
90 handle_before_unload,
91 navigation_timeout_ms,
92 ) {
93 Ok(()) => 0,
94 Err(e) => emit_err(&e, json),
95 }
96 }
97 Commands::View { verbose, path } => {
98 match handle_view(life, verbose, path.as_deref(), capture, timeout_secs, json) {
99 Ok(()) => 0,
100 Err(e) => emit_err(&e, json),
101 }
102 }
103 Commands::Press {
104 target,
105 dblclick,
106 include_snapshot,
107 } => {
108 match handle_press(
109 life,
110 &target,
111 dblclick,
112 include_snapshot,
113 capture,
114 timeout_secs,
115 json,
116 ) {
117 Ok(()) => 0,
118 Err(e) => emit_err(&e, json),
119 }
120 }
121 Commands::ClickAt {
122 x,
123 y,
124 dblclick,
125 include_snapshot,
126 } => {
127 if !experimental_vision {
128 let e = CliError::with_suggestion(
129 ErrorKind::Usage,
130 "click-at requires --experimental-vision",
131 crate::i18n::suggestion_key("vision_required", None),
132 );
133 emit_err(&e, json)
134 } else {
135 match handle_click_at(
136 life,
137 x,
138 y,
139 dblclick,
140 include_snapshot,
141 capture,
142 timeout_secs,
143 json,
144 ) {
145 Ok(()) => 0,
146 Err(e) => emit_err(&e, json),
147 }
148 }
149 }
150 Commands::Write {
151 target,
152 value,
153 include_snapshot,
154 } => {
155 match handle_write(
156 life,
157 &target,
158 &value,
159 include_snapshot,
160 capture,
161 timeout_secs,
162 json,
163 ) {
164 Ok(()) => 0,
165 Err(e) => emit_err(&e, json),
166 }
167 }
168 Commands::Keys {
169 key,
170 include_snapshot,
171 } => match handle_keys(life, &key, include_snapshot, capture, timeout_secs, json) {
172 Ok(()) => 0,
173 Err(e) => emit_err(&e, json),
174 },
175 Commands::Type {
176 target,
177 text,
178 clear,
179 submit,
180 focus_only,
181 } => match handle_type(
182 life,
183 target.as_deref(),
184 &text,
185 clear,
186 submit.as_deref(),
187 focus_only,
188 capture,
189 timeout_secs,
190 json,
191 ) {
192 Ok(()) => 0,
193 Err(e) => emit_err(&e, json),
194 },
195 Commands::Wait {
196 ms,
197 text,
198 selector,
199 state,
200 wait_timeout_ms,
201 include_snapshot,
202 } => {
203 match handle_wait(
204 life,
205 ms,
206 &text,
207 selector.as_deref(),
208 state.as_deref(),
209 wait_timeout_ms,
210 include_snapshot,
211 capture,
212 timeout_secs,
213 json,
214 ) {
215 Ok(()) => 0,
216 Err(e) => emit_err(&e, json),
217 }
218 }
219 Commands::Hover {
220 target,
221 include_snapshot,
222 } => match handle_hover(life, &target, include_snapshot, capture, timeout_secs, json) {
223 Ok(()) => 0,
224 Err(e) => emit_err(&e, json),
225 },
226 Commands::Drag {
227 from,
228 to,
229 include_snapshot,
230 } => {
231 match handle_drag(
232 life,
233 &from,
234 &to,
235 include_snapshot,
236 capture,
237 timeout_secs,
238 json,
239 ) {
240 Ok(()) => 0,
241 Err(e) => emit_err(&e, json),
242 }
243 }
244 Commands::FillForm {
245 json: fields_json,
246 include_snapshot,
247 } => {
248 match handle_fill_form(
249 life,
250 &fields_json,
251 include_snapshot,
252 capture,
253 timeout_secs,
254 json,
255 ) {
256 Ok(()) => 0,
257 Err(e) => emit_err(&e, json),
258 }
259 }
260 Commands::Upload {
261 target,
262 path,
263 include_snapshot,
264 } => {
265 match handle_upload(
266 life,
267 &target,
268 &path,
269 include_snapshot,
270 capture,
271 timeout_secs,
272 json,
273 ) {
274 Ok(()) => 0,
275 Err(e) => emit_err(&e, json),
276 }
277 }
278 Commands::Back => match handle_history(life, "back", capture, timeout_secs, json) {
279 Ok(()) => 0,
280 Err(e) => emit_err(&e, json),
281 },
282 Commands::Forward => match handle_history(life, "forward", capture, timeout_secs, json) {
283 Ok(()) => 0,
284 Err(e) => emit_err(&e, json),
285 },
286 Commands::Reload {
287 ignore_cache,
288 init_script,
289 handle_before_unload,
290 } => {
291 match handle_reload(
292 life,
293 ignore_cache,
294 init_script.as_deref(),
295 handle_before_unload,
296 capture,
297 timeout_secs,
298 json,
299 ) {
300 Ok(()) => 0,
301 Err(e) => emit_err(&e, json),
302 }
303 }
304 Commands::Eval {
305 expression,
306 args,
307 dialog_action,
308 file_path,
309 } => {
310 match handle_eval(
311 life,
312 &expression,
313 args.as_deref(),
314 dialog_action.as_deref(),
315 file_path.as_deref(),
316 capture,
317 timeout_secs,
318 json,
319 ) {
320 Ok(()) => 0,
321 Err(e) => emit_err(&e, json),
322 }
323 }
324 Commands::Grab {
325 path,
326 format,
327 full_page,
328 quality,
329 element,
330 } => match handle_grab(
331 life,
332 path.as_deref(),
333 format,
334 full_page,
335 quality,
336 element.as_deref(),
337 artifacts.as_deref(),
338 capture,
339 timeout_secs,
340 json,
341 ) {
342 Ok(()) => 0,
343 Err(e) => emit_err(&e, json),
344 },
345 Commands::PrintPdf { path, url } => {
346 match handle_print_pdf(
347 life,
348 path.as_deref(),
349 url.as_deref(),
350 robots,
351 capture,
352 timeout_secs,
353 json,
354 ) {
355 Ok(()) => 0,
356 Err(e) => emit_err(&e, json),
357 }
358 }
359 Commands::Monitor { action } => match handle_monitor(action, robots, timeout_secs, json) {
360 Ok(()) => 0,
361 Err(e) => emit_err(&e, json),
362 },
363 Commands::Run { script } => {
364 let flags = run::RunFlags::from_globals(
365 experimental_vision,
366 experimental_screencast,
367 category_memory,
368 category_extensions,
369 category_third_party,
370 category_webmcp,
371 );
372 match handle_run(life, &script, robots, capture, timeout_secs, json, flags) {
373 Ok(()) => 0,
374 Err(e) => emit_err(&e, json),
375 }
376 }
377 Commands::Exec { args } => {
378 let (args, json_from_trail) = peel_trailing_globals(&args);
381 let json = json || json_from_trail;
382 let flags = run::RunFlags::from_globals(
383 experimental_vision,
384 experimental_screencast,
385 category_memory,
386 category_extensions,
387 category_third_party,
388 category_webmcp,
389 );
390 match handle_exec(life, &args, robots, capture, timeout_secs, json, flags) {
391 Ok(()) => 0,
392 Err(e) => emit_err(&e, json),
393 }
394 }
395 Commands::Extract {
396 target,
397 attr,
398 llm,
399 question,
400 schema_json,
401 } => {
402 match handle_extract(
403 life,
404 &target,
405 attr.as_deref(),
406 llm,
407 question.as_deref(),
408 schema_json.as_deref(),
409 capture,
410 timeout_secs,
411 json,
412 ) {
413 Ok(()) => 0,
414 Err(e) => emit_err(&e, json),
415 }
416 }
417 Commands::Text { target } => {
418 match handle_text(life, &target, capture, timeout_secs, json) {
419 Ok(()) => 0,
420 Err(e) => emit_err(&e, json),
421 }
422 }
423 Commands::Scroll {
424 target,
425 delta_x,
426 delta_y,
427 } => match handle_scroll(
428 life,
429 target.as_deref(),
430 delta_x,
431 delta_y,
432 capture,
433 timeout_secs,
434 json,
435 ) {
436 Ok(()) => 0,
437 Err(e) => emit_err(&e, json),
438 },
439 Commands::Cookie { action } => {
440 match handle_cookie(life, action, capture, timeout_secs, json) {
441 Ok(()) => 0,
442 Err(e) => emit_err(&e, json),
443 }
444 }
445 Commands::Attr { target, name } => {
446 match handle_attr(life, &target, &name, capture, timeout_secs, json) {
447 Ok(()) => 0,
448 Err(e) => emit_err(&e, json),
449 }
450 }
451 Commands::Assert { kind } => match handle_assert(life, kind, capture, timeout_secs, json) {
452 Ok(()) => 0,
453 Err(e) => emit_err(&e, json),
454 },
455 Commands::Console { action } => {
456 match handle_console(life, action, capture, timeout_secs, json) {
457 Ok(()) => 0,
458 Err(e) => emit_err(&e, json),
459 }
460 }
461 Commands::Net { action } => match handle_net(life, action, capture, timeout_secs, json) {
462 Ok(()) => 0,
463 Err(e) => emit_err(&e, json),
464 },
465 Commands::Page { action } => match handle_page(life, action, capture, timeout_secs, json) {
466 Ok(()) => 0,
467 Err(e) => emit_err(&e, json),
468 },
469 Commands::Dialog { action } => {
470 match handle_dialog(life, action, capture, timeout_secs, json) {
471 Ok(()) => 0,
472 Err(e) => emit_err(&e, json),
473 }
474 }
475 Commands::Scrape {
476 url,
477 format,
478 engine,
479 only_main_content,
480 webhook_url,
481 } => {
482 match handle_scrape(
483 life,
484 &url,
485 robots,
486 capture,
487 timeout_secs,
488 json,
489 &format,
490 &engine,
491 only_main_content,
492 webhook_url.as_deref(),
493 ) {
494 Ok(()) => 0,
495 Err(e) => emit_err(&e, json),
496 }
497 }
498 Commands::BatchScrape {
499 urls_file,
500 format,
501 concurrency,
502 } => match handle_batch_scrape(&urls_file, robots, &format, concurrency, json) {
503 Ok(()) => 0,
504 Err(e) => emit_err(&e, json),
505 },
506 Commands::Crawl {
507 url,
508 limit,
509 max_depth,
510 format,
511 same_host,
512 } => match handle_crawl(&url, robots, limit, max_depth, &format, same_host, json) {
513 Ok(()) => 0,
514 Err(e) => emit_err(&e, json),
515 },
516 Commands::Map {
517 url,
518 limit,
519 max_depth,
520 } => match handle_map(&url, robots, limit, max_depth, json) {
521 Ok(()) => 0,
522 Err(e) => emit_err(&e, json),
523 },
524 Commands::Search { query, limit } => match handle_search(&query, robots, limit, json) {
525 Ok(()) => 0,
526 Err(e) => emit_err(&e, json),
527 },
528 Commands::Parse { path, redact_pii } => match handle_parse(&path, redact_pii, json) {
529 Ok(()) => 0,
530 Err(e) => emit_err(&e, json),
531 },
532 Commands::Qr { action } => match handle_qr(action, json) {
533 Ok(()) => 0,
534 Err(e) => emit_err(&e, json),
535 },
536 Commands::FindPaths {
537 pattern,
538 paths,
539 extension,
540 hidden,
541 no_ignore,
542 max_depth,
543 entry_type,
544 limit,
545 } => match handle_find_paths(
546 pattern.as_deref(),
547 &paths,
548 extension.as_deref(),
549 hidden,
550 no_ignore,
551 max_depth,
552 entry_type.as_deref(),
553 limit,
554 json,
555 ) {
556 Ok(()) => 0,
557 Err(e) => emit_err(&e, json),
558 },
559 Commands::Mitm { action } => match handle_mitm(action, json) {
560 Ok(()) => 0,
561 Err(e) => emit_err(&e, json),
562 },
563 Commands::Workflow { action } => match handle_workflow(action, json) {
564 Ok(()) => 0,
565 Err(e) => emit_err(&e, json),
566 },
567 Commands::Config { action } => match handle_config(action, json) {
568 Ok(()) => 0,
569 Err(e) => emit_err(&e, json),
570 },
571 Commands::Emulate {
572 user_agent,
573 locale,
574 timezone,
575 offline,
576 latitude,
577 longitude,
578 media,
579 network_conditions,
580 cpu_throttling_rate,
581 color_scheme,
582 extra_headers,
583 viewport,
584 } => match handle_emulate(
585 life,
586 user_agent.as_deref(),
587 locale.as_deref(),
588 timezone.as_deref(),
589 offline,
590 latitude,
591 longitude,
592 media.as_deref(),
593 network_conditions.as_deref(),
594 cpu_throttling_rate,
595 color_scheme.as_deref(),
596 extra_headers.as_deref(),
597 viewport.as_deref(),
598 capture,
599 timeout_secs,
600 json,
601 ) {
602 Ok(()) => 0,
603 Err(e) => emit_err(&e, json),
604 },
605 Commands::Resize {
606 width,
607 height,
608 scale,
609 mobile,
610 } => match handle_resize(
611 life,
612 width,
613 height,
614 scale,
615 mobile,
616 capture,
617 timeout_secs,
618 json,
619 ) {
620 Ok(()) => 0,
621 Err(e) => emit_err(&e, json),
622 },
623 Commands::Perf { action } => match handle_perf(life, action, capture, timeout_secs, json) {
624 Ok(()) => 0,
625 Err(e) => emit_err(&e, json),
626 },
627 Commands::Lighthouse {
628 url,
629 out_dir,
630 device,
631 mode,
632 lighthouse_path,
633 } => match handle_lighthouse(
634 &url,
635 out_dir.as_deref(),
636 &device,
637 &mode,
638 lighthouse_path.as_deref(),
639 json,
640 ) {
641 Ok(()) => 0,
642 Err(e) => emit_err(&e, json),
643 },
644 Commands::Screencast { action } => {
645 if !experimental_screencast {
646 emit_err(
647 &CliError::with_suggestion(
648 ErrorKind::Usage,
649 "screencast requires --experimental-screencast",
650 "Pass --experimental-screencast on the same invocation",
651 ),
652 json,
653 )
654 } else {
655 match handle_screencast(life, action, capture, timeout_secs, json) {
656 Ok(()) => 0,
657 Err(e) => emit_err(&e, json),
658 }
659 }
660 }
661 Commands::Heap { action } => {
662 let deep = !matches!(
663 &action,
664 HeapAction::Take { .. } | HeapAction::Summary { .. } | HeapAction::Close { .. }
665 );
666 if deep && !category_memory {
667 emit_err(
668 &CliError::with_suggestion(
669 ErrorKind::Usage,
670 "deep heap tools require --category-memory",
671 "Pass --category-memory (heap take/summary/close work without deep graph ops)",
672 ),
673 json,
674 )
675 } else {
676 match handle_heap(life, action, capture, timeout_secs, json) {
677 Ok(()) => 0,
678 Err(e) => emit_err(&e, json),
679 }
680 }
681 }
682 Commands::Extension { action } => {
683 if !category_extensions {
684 emit_err(
685 &CliError::with_suggestion(
686 ErrorKind::Usage,
687 "extension tools require --category-extensions",
688 "Pass --category-extensions on the same invocation",
689 ),
690 json,
691 )
692 } else {
693 match handle_extension(life, action, capture, timeout_secs, json) {
694 Ok(()) => 0,
695 Err(e) => emit_err(&e, json),
696 }
697 }
698 }
699 Commands::Devtools3p { action } => {
700 if !category_third_party {
701 emit_err(
702 &CliError::with_suggestion(
703 ErrorKind::Usage,
704 "devtools3p requires --category-third-party",
705 "Pass --category-third-party on the same invocation",
706 ),
707 json,
708 )
709 } else {
710 match handle_devtools3p(life, action, capture, timeout_secs, json) {
711 Ok(()) => 0,
712 Err(e) => emit_err(&e, json),
713 }
714 }
715 }
716 Commands::Webmcp { action } => {
717 if !category_webmcp {
718 emit_err(
719 &CliError::with_suggestion(
720 ErrorKind::Usage,
721 "webmcp requires --category-webmcp",
722 "Pass --category-webmcp on the same invocation",
723 ),
724 json,
725 )
726 } else {
727 match handle_webmcp(life, action, capture, timeout_secs, json) {
728 Ok(()) => 0,
729 Err(e) => emit_err(&e, json),
730 }
731 }
732 }
733 Commands::Completions { shell } => match handle_completions(shell) {
734 Ok(()) => 0,
735 Err(e) => emit_err(&e, json),
736 },
737 };
738
739 life.finalize();
740 code
741}
742
743fn handle_version(json: bool) -> Result<(), CliError> {
744 let data = serde_json::json!({
745 "name": "browser-automation-cli",
746 "version": env!("CARGO_PKG_VERSION"),
747 });
748 emit_ok(data, json, |d| {
749 println!(
750 "{}",
751 d.get("version").and_then(|v| v.as_str()).unwrap_or("")
752 );
753 })
754}
755
756fn emit_ok<F>(data: serde_json::Value, json: bool, text: F) -> Result<(), CliError>
757where
758 F: FnOnce(&serde_json::Value),
759{
760 if json {
761 print_success_json(data)?;
762 } else {
763 text(&data);
764 }
765 Ok(())
766}
767
768fn emit_err(err: &CliError, json: bool) -> i32 {
769 let localized = crate::i18n::localize_error_suggestion(err);
770 if json {
771 let _ = print_error_json(&localized);
772 } else {
773 eprintln!("error: {localized}");
774 if let Some(s) = localized.suggestion() {
775 eprintln!("suggestion: {s}");
776 }
777 }
778 localized.exit_code() as i32
779}
780
781fn peel_trailing_globals(args: &[String]) -> (Vec<String>, bool) {
783 let mut json = false;
784 let mut out = Vec::with_capacity(args.len());
785 for a in args {
786 match a.as_str() {
787 "--json" => json = true,
788 other => out.push(other.to_string()),
789 }
790 }
791 (out, json)
792}
793
794#[allow(clippy::too_many_arguments)]
795fn handle_goto(
796 life: &Lifecycle,
797 url: &str,
798 robots: RobotsPolicy,
799 capture: CaptureOpts,
800 timeout_secs: u64,
801 json: bool,
802 init_script: Option<&str>,
803 handle_before_unload: bool,
804 navigation_timeout_ms: Option<u64>,
805) -> Result<(), CliError> {
806 let _ = (init_script, handle_before_unload, navigation_timeout_ms);
807 let data = block_on_browser_timeout(
810 run_goto_with_robots(life, url, capture, robots),
811 timeout_secs,
812 )?;
813 emit_ok(data, json, |d| {
814 let u = d.get("url").and_then(|v| v.as_str()).unwrap_or(url);
815 let t = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
816 println!("ok url={u} title={t}");
817 })
818}
819
820fn handle_view(
821 life: &Lifecycle,
822 verbose: bool,
823 path: Option<&Path>,
824 capture: CaptureOpts,
825 timeout_secs: u64,
826 json: bool,
827) -> Result<(), CliError> {
828 let path_owned = path.map(|p| p.to_path_buf());
829 let data = block_on_browser_timeout(
830 async move {
831 let mut data = run_view(life, verbose, capture).await?;
832 if let Some(p) = path_owned.as_ref() {
833 if let Some(parent) = p.parent() {
834 if !parent.as_os_str().is_empty() {
835 std::fs::create_dir_all(parent).map_err(|e| {
836 CliError::new(ErrorKind::Io, format!("view --path mkdir: {e}"))
837 })?;
838 }
839 }
840 let tree = data.get("tree").and_then(|v| v.as_str()).unwrap_or("");
841 std::fs::write(p, tree.as_bytes())
842 .map_err(|e| CliError::new(ErrorKind::Io, format!("view --path write: {e}")))?;
843 if let Some(obj) = data.as_object_mut() {
844 obj.insert(
845 "path".to_string(),
846 serde_json::Value::String(p.display().to_string()),
847 );
848 }
849 }
850 Ok(data)
851 },
852 timeout_secs,
853 )?;
854 emit_ok(data, json, |d| {
855 if let Some(p) = d.get("path").and_then(|v| v.as_str()) {
856 println!("ok view path={p}");
857 } else if let Some(tree) = d.get("tree").and_then(|v| v.as_str()) {
858 print!("{tree}");
859 if !tree.ends_with('\n') {
860 println!();
861 }
862 } else {
863 println!("ok view");
864 }
865 })
866}
867
868fn handle_press(
869 life: &Lifecycle,
870 target: &str,
871 dblclick: bool,
872 include_snapshot: bool,
873 capture: CaptureOpts,
874 timeout_secs: u64,
875 json: bool,
876) -> Result<(), CliError> {
877 let data = block_on_browser_timeout(
878 run_press(life, target, dblclick, include_snapshot, capture),
879 timeout_secs,
880 )?;
881 emit_ok(data, json, |_| {
882 println!("ok pressed={target} dblclick={dblclick}");
883 })
884}
885
886fn handle_write(
887 life: &Lifecycle,
888 target: &str,
889 value: &str,
890 include_snapshot: bool,
891 capture: CaptureOpts,
892 timeout_secs: u64,
893 json: bool,
894) -> Result<(), CliError> {
895 let data = block_on_browser_timeout(
896 run_write(life, target, value, include_snapshot, capture),
897 timeout_secs,
898 )?;
899 emit_ok(data, json, |_| {
900 println!("ok written={target} len={}", value.len());
901 })
902}
903
904#[allow(clippy::too_many_arguments)]
905fn handle_click_at(
906 life: &Lifecycle,
907 x: f64,
908 y: f64,
909 dblclick: bool,
910 include_snapshot: bool,
911 capture: CaptureOpts,
912 timeout_secs: u64,
913 json: bool,
914) -> Result<(), CliError> {
915 let data = block_on_browser_timeout(
916 async move {
917 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
918 if let Ok(mut ledger) = life.ledger.lock() {
919 ledger.chrome_launched = true;
920 ledger.chrome_pid = session.chrome_pid();
921 }
922 let _ = session
923 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
924 .await?;
925 let r = session.click_at(x, y, dblclick, include_snapshot).await;
926 let close = session.shutdown().await;
927 if let Ok(mut ledger) = life.ledger.lock() {
928 ledger.chrome_launched = false;
929 ledger.chrome_pid = None;
930 }
931 close?;
932 r
933 },
934 timeout_secs,
935 )?;
936 emit_ok(data, json, |_| {
937 println!("ok click-at x={x} y={y} dbl={dblclick}")
938 })
939}
940
941fn handle_keys(
942 life: &Lifecycle,
943 key: &str,
944 include_snapshot: bool,
945 capture: CaptureOpts,
946 timeout_secs: u64,
947 json: bool,
948) -> Result<(), CliError> {
949 let data =
950 block_on_browser_timeout(run_keys(life, key, include_snapshot, capture), timeout_secs)?;
951 emit_ok(data, json, |_| println!("ok key={key}"))
952}
953
954#[allow(clippy::too_many_arguments)]
955fn handle_type(
956 life: &Lifecycle,
957 target: Option<&str>,
958 text: &str,
959 clear: bool,
960 submit: Option<&str>,
961 focus_only: bool,
962 capture: CaptureOpts,
963 timeout_secs: u64,
964 json: bool,
965) -> Result<(), CliError> {
966 if target.is_none() && !focus_only {
967 return Err(CliError::with_suggestion(
968 ErrorKind::Usage,
969 "type requires a target or --focus-only",
970 "Pass TARGET or --focus-only for the focused element",
971 ));
972 }
973 let data = block_on_browser_timeout(
974 run_type(life, target, text, clear, submit, focus_only, capture),
975 timeout_secs,
976 )?;
977 let label = target.unwrap_or("(focused)");
978 emit_ok(data, json, |_| {
979 println!(
980 "ok typed={label} len={} clear={clear} submit={submit:?} focus_only={focus_only}",
981 text.len()
982 );
983 })
984}
985
986#[allow(clippy::too_many_arguments)]
987fn handle_wait(
988 life: &Lifecycle,
989 ms: u64,
990 texts: &[String],
991 selector: Option<&str>,
992 state: Option<&str>,
993 wait_timeout_ms: Option<u64>,
994 include_snapshot: bool,
995 capture: CaptureOpts,
996 timeout_secs: u64,
997 json: bool,
998) -> Result<(), CliError> {
999 let texts_owned = texts.to_vec();
1000 let selector_owned = selector.map(|s| s.to_string());
1001 let state_owned = state.map(|s| s.to_string());
1002 let wait_ms = wait_timeout_ms.or(if ms == 0 { None } else { Some(ms) });
1004 let data = block_on_browser_timeout(
1005 async move {
1006 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1007 if let Ok(mut ledger) = life.ledger.lock() {
1008 ledger.chrome_launched = true;
1009 ledger.chrome_pid = session.chrome_pid();
1010 }
1011 let r = if texts_owned.is_empty() {
1013 session
1014 .wait_for(
1015 wait_ms,
1016 None,
1017 selector_owned.as_deref(),
1018 state_owned.as_deref(),
1019 include_snapshot,
1020 )
1021 .await
1022 } else {
1023 let mut last_err = None;
1024 let mut ok = None;
1025 for t in &texts_owned {
1026 match session
1027 .wait_for(
1028 wait_ms,
1029 Some(t.as_str()),
1030 selector_owned.as_deref(),
1031 state_owned.as_deref(),
1032 false,
1033 )
1034 .await
1035 {
1036 Ok(v) => {
1037 ok = Some(v);
1038 break;
1039 }
1040 Err(e) => last_err = Some(e),
1041 }
1042 }
1043 match ok {
1044 Some(v) => {
1045 if include_snapshot {
1046 Ok(session
1047 .attach_snapshot_if(true, v)
1048 .await
1049 .unwrap_or_else(|_| serde_json::json!({"waited": true})))
1050 } else {
1051 Ok(v)
1052 }
1053 }
1054 None => Err(last_err.unwrap_or_else(|| {
1055 CliError::new(ErrorKind::Browser, "wait: no text matched")
1056 })),
1057 }
1058 };
1059 let close = session.shutdown().await;
1060 if let Ok(mut ledger) = life.ledger.lock() {
1061 ledger.chrome_launched = false;
1062 ledger.chrome_pid = None;
1063 }
1064 close?;
1065 r
1066 },
1067 timeout_secs,
1068 )?;
1069 emit_ok(data, json, |d| {
1070 println!("ok wait {}", d);
1071 })
1072}
1073
1074fn with_session_blank<F, Fut>(
1075 life: &Lifecycle,
1076 capture: CaptureOpts,
1077 timeout_secs: u64,
1078 f: F,
1079) -> Result<serde_json::Value, CliError>
1080where
1081 F: FnOnce(OneShotSession) -> Fut + Send + 'static,
1082 Fut: std::future::Future<Output = Result<(OneShotSession, serde_json::Value), CliError>> + Send,
1083{
1084 block_on_browser_timeout(
1085 async move {
1086 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1087 if let Ok(mut ledger) = life.ledger.lock() {
1088 ledger.chrome_launched = true;
1089 ledger.chrome_pid = session.chrome_pid();
1090 }
1091 let _ = session
1092 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1093 .await?;
1094 let (session, value) = f(session).await?;
1095 let close = session.shutdown().await;
1096 if let Ok(mut ledger) = life.ledger.lock() {
1097 ledger.chrome_launched = false;
1098 ledger.chrome_pid = None;
1099 }
1100 close?;
1101 Ok(value)
1102 },
1103 timeout_secs,
1104 )
1105}
1106
1107fn handle_hover(
1108 life: &Lifecycle,
1109 target: &str,
1110 include_snapshot: bool,
1111 capture: CaptureOpts,
1112 timeout_secs: u64,
1113 json: bool,
1114) -> Result<(), CliError> {
1115 let target = target.to_string();
1116 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1117 let v = session.hover(&target, include_snapshot).await?;
1118 Ok((session, v))
1119 })?;
1120 emit_ok(data, json, |_| println!("ok hover"))
1121}
1122
1123fn handle_drag(
1124 life: &Lifecycle,
1125 from: &str,
1126 to: &str,
1127 include_snapshot: bool,
1128 capture: CaptureOpts,
1129 timeout_secs: u64,
1130 json: bool,
1131) -> Result<(), CliError> {
1132 let from = from.to_string();
1133 let to = to.to_string();
1134 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1135 let v = session.drag(&from, &to, include_snapshot).await?;
1136 Ok((session, v))
1137 })?;
1138 emit_ok(data, json, |_| println!("ok drag"))
1139}
1140
1141fn handle_fill_form(
1142 life: &Lifecycle,
1143 fields_json: &str,
1144 include_snapshot: bool,
1145 capture: CaptureOpts,
1146 timeout_secs: u64,
1147 json: bool,
1148) -> Result<(), CliError> {
1149 let parsed: serde_json::Value = serde_json::from_str(fields_json).map_err(|e| {
1150 CliError::with_suggestion(
1151 ErrorKind::Usage,
1152 format!("fill-form --json parse error: {e}"),
1153 r##"Pass JSON array: [{"target":"input","value":"x"}] or [{"uid":"@e1","value":"x"}]"##,
1154 )
1155 })?;
1156 let arr = parsed.as_array().ok_or_else(|| {
1157 CliError::with_suggestion(
1158 ErrorKind::Usage,
1159 "fill-form --json must be a JSON array",
1160 r##"[{"target":"input","value":"x"}]"##,
1161 )
1162 })?;
1163 let mut fields = Vec::new();
1164 for item in arr {
1165 let target = item
1166 .get("target")
1167 .or_else(|| item.get("uid"))
1168 .or_else(|| item.get("selector"))
1169 .or_else(|| item.get("ref"))
1170 .and_then(|v| v.as_str())
1171 .ok_or_else(|| {
1172 CliError::new(
1173 ErrorKind::Usage,
1174 "fill-form field missing target/uid/selector/ref",
1175 )
1176 })?
1177 .to_string();
1178 let target = if target.starts_with('e')
1180 && target.len() > 1
1181 && target[1..].chars().all(|c| c.is_ascii_digit())
1182 {
1183 format!("@{target}")
1184 } else {
1185 target
1186 };
1187 let value = item
1188 .get("value")
1189 .or_else(|| item.get("text"))
1190 .and_then(|v| v.as_str())
1191 .ok_or_else(|| CliError::new(ErrorKind::Usage, "fill-form field missing value"))?
1192 .to_string();
1193 fields.push((target, value));
1194 }
1195 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1196 let v = session.fill_form(&fields, include_snapshot).await?;
1197 Ok((session, v))
1198 })?;
1199 emit_ok(data, json, |d| {
1200 let n = d.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1201 println!("ok fill-form count={n}");
1202 })
1203}
1204
1205fn handle_upload(
1206 life: &Lifecycle,
1207 target: &str,
1208 path: &Path,
1209 include_snapshot: bool,
1210 capture: CaptureOpts,
1211 timeout_secs: u64,
1212 json: bool,
1213) -> Result<(), CliError> {
1214 let target = target.to_string();
1215 let path = path.to_path_buf();
1216 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1217 let v = session.upload(&target, &path, include_snapshot).await?;
1218 Ok((session, v))
1219 })?;
1220 emit_ok(data, json, |d| {
1221 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1222 println!("ok upload path={p}");
1223 })
1224}
1225
1226fn handle_history(
1227 life: &Lifecycle,
1228 direction: &str,
1229 capture: CaptureOpts,
1230 timeout_secs: u64,
1231 json: bool,
1232) -> Result<(), CliError> {
1233 let direction = direction.to_string();
1234 let direction_label = direction.clone();
1235 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1236 let v = match direction.as_str() {
1237 "back" => session.back().await?,
1238 "forward" => session.forward().await?,
1239 other => {
1240 return Err(CliError::new(
1241 ErrorKind::Usage,
1242 format!("unknown history direction: {other}"),
1243 ))
1244 }
1245 };
1246 Ok((session, v))
1247 })?;
1248 emit_ok(data, json, |_| println!("ok {direction_label}"))
1249}
1250
1251fn handle_reload(
1252 life: &Lifecycle,
1253 ignore_cache: bool,
1254 init_script: Option<&str>,
1255 handle_before_unload: bool,
1256 capture: CaptureOpts,
1257 timeout_secs: u64,
1258 json: bool,
1259) -> Result<(), CliError> {
1260 let init = init_script.map(|s| s.to_string());
1261 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1262 if let Some(ref js) = init {
1263 let _ = session.eval(js, None, Some("accept"), None).await;
1264 }
1265 if handle_before_unload {
1266 let _ = session
1268 .eval(
1269 "window.addEventListener('beforeunload', e => { e.returnValue=''; });",
1270 None,
1271 Some("accept"),
1272 None,
1273 )
1274 .await;
1275 }
1276 let v = session.reload(ignore_cache).await?;
1277 Ok((session, v))
1278 })?;
1279 emit_ok(data, json, |_| {
1280 println!("ok reload ignore_cache={ignore_cache}")
1281 })
1282}
1283
1284#[allow(clippy::too_many_arguments)]
1285fn handle_eval(
1286 life: &Lifecycle,
1287 expression: &str,
1288 args: Option<&str>,
1289 dialog_action: Option<&str>,
1290 file_path: Option<&Path>,
1291 capture: CaptureOpts,
1292 timeout_secs: u64,
1293 json: bool,
1294) -> Result<(), CliError> {
1295 let expr = expression.to_string();
1296 let args_owned = args.map(|s| s.to_string());
1297 let dialog_owned = dialog_action.map(|s| s.to_string());
1298 let path_owned = file_path.map(|p| p.to_path_buf());
1299 let data = block_on_browser_timeout(
1300 async move {
1301 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1302 if let Ok(mut ledger) = life.ledger.lock() {
1303 ledger.chrome_launched = true;
1304 ledger.chrome_pid = session.chrome_pid();
1305 }
1306 let _ = session
1307 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1308 .await?;
1309 let r = session
1310 .eval(
1311 &expr,
1312 args_owned.as_deref(),
1313 dialog_owned.as_deref(),
1314 path_owned.as_deref(),
1315 )
1316 .await;
1317 let close = session.shutdown().await;
1318 if let Ok(mut ledger) = life.ledger.lock() {
1319 ledger.chrome_launched = false;
1320 ledger.chrome_pid = None;
1321 }
1322 close?;
1323 r
1324 },
1325 timeout_secs,
1326 )?;
1327 emit_ok(data, json, |d| {
1328 println!(
1329 "ok eval={}",
1330 d.get("result").unwrap_or(&serde_json::Value::Null)
1331 );
1332 })
1333}
1334
1335#[allow(clippy::too_many_arguments)]
1336fn handle_grab(
1337 life: &Lifecycle,
1338 path: Option<&Path>,
1339 format: GrabFormat,
1340 full_page: bool,
1341 quality: Option<i32>,
1342 element: Option<&str>,
1343 artifacts: Option<&Path>,
1344 capture: CaptureOpts,
1345 timeout_secs: u64,
1346 json: bool,
1347) -> Result<(), CliError> {
1348 let fmt = match format {
1349 GrabFormat::Png => "png",
1350 GrabFormat::Jpeg => "jpeg",
1351 GrabFormat::Webp => "webp",
1352 };
1353 if let Some(a) = artifacts {
1354 std::fs::create_dir_all(a)
1355 .map_err(|e| CliError::new(ErrorKind::Io, format!("artifacts-dir mkdir: {e}")))?;
1356 }
1357 let path_owned = path.map(|p| p.to_path_buf()).or_else(|| {
1358 artifacts.map(|a| {
1359 a.join(format!(
1360 "grab-{}.{}",
1361 std::time::SystemTime::now()
1362 .duration_since(std::time::UNIX_EPOCH)
1363 .map(|d| d.as_millis())
1364 .unwrap_or(0),
1365 fmt
1366 ))
1367 })
1368 });
1369 if let Some(ref p) = path_owned {
1370 if let Some(parent) = p.parent() {
1371 if !parent.as_os_str().is_empty() {
1372 std::fs::create_dir_all(parent)
1373 .map_err(|e| CliError::new(ErrorKind::Io, format!("grab path mkdir: {e}")))?;
1374 }
1375 }
1376 }
1377 let element_owned = element.map(|s| s.to_string());
1378 let data = block_on_browser_timeout(
1379 async move {
1380 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1381 if let Ok(mut ledger) = life.ledger.lock() {
1382 ledger.chrome_launched = true;
1383 ledger.chrome_pid = session.chrome_pid();
1384 }
1385 let _ = session
1386 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1387 .await?;
1388 let r = session
1389 .grab(
1390 path_owned.as_deref(),
1391 fmt,
1392 full_page,
1393 quality,
1394 element_owned.as_deref(),
1395 )
1396 .await;
1397 let close = session.shutdown().await;
1398 if let Ok(mut ledger) = life.ledger.lock() {
1399 ledger.chrome_launched = false;
1400 ledger.chrome_pid = None;
1401 }
1402 close?;
1403 r
1404 },
1405 timeout_secs,
1406 )?;
1407 emit_ok(data, json, |d| {
1408 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1409 println!("ok grab path={p}");
1410 })
1411}
1412
1413fn handle_print_pdf(
1414 life: &Lifecycle,
1415 path: Option<&Path>,
1416 url: Option<&str>,
1417 robots: RobotsPolicy,
1418 capture: CaptureOpts,
1419 timeout_secs: u64,
1420 json: bool,
1421) -> Result<(), CliError> {
1422 let path = path.map(|p| p.to_path_buf());
1423 let url = url.map(|s| s.to_string());
1424 let data = block_on_browser_timeout(
1425 async {
1426 let mut session =
1427 crate::browser::OneShotSession::launch_headless_with_capture(capture).await?;
1428 if let Ok(mut ledger) = life.ledger.lock() {
1429 ledger.chrome_launched = true;
1430 ledger.chrome_pid = session.chrome_pid();
1431 }
1432 if let Some(u) = url.as_deref() {
1433 let _ = session.goto(u, robots).await?;
1434 }
1435 let out = session.print_pdf(path.as_deref()).await;
1436 let _ = session.shutdown().await;
1437 if let Ok(mut ledger) = life.ledger.lock() {
1438 ledger.chrome_launched = false;
1439 ledger.chrome_pid = None;
1440 }
1441 out
1442 },
1443 timeout_secs,
1444 )?;
1445 emit_ok(data, json, |d| {
1446 let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1447 println!("ok print-pdf path={p}");
1448 })
1449}
1450
1451fn handle_monitor(
1452 action: crate::cli::MonitorAction,
1453 robots: RobotsPolicy,
1454 timeout_secs: u64,
1455 json: bool,
1456) -> Result<(), CliError> {
1457 use sha2::{Digest, Sha256};
1458 match action {
1459 crate::cli::MonitorAction::Check {
1460 url,
1461 baseline,
1462 write_baseline,
1463 engine,
1464 } => {
1465 let engine_l = engine.to_ascii_lowercase();
1466 let text = if engine_l == "browser" {
1467 return Err(CliError::with_suggestion(
1468 ErrorKind::Usage,
1469 "monitor check --engine browser is reserved; use http for baseline hash",
1470 "Pass --engine http (default) for one-shot baseline compare",
1471 ));
1472 } else {
1473 let opts = crate::scrape_local::ScrapeOpts {
1474 format: crate::scrape_local::ScrapeFormat::Text,
1475 engine: "http".into(),
1476 ..Default::default()
1477 };
1478 let page = block_on_browser_timeout(
1479 crate::scrape_local::scrape_http(&url, robots, &opts),
1480 timeout_secs,
1481 )?;
1482 page.get("text")
1483 .and_then(|v| v.as_str())
1484 .unwrap_or("")
1485 .to_string()
1486 };
1487 let mut hasher = Sha256::new();
1488 hasher.update(text.as_bytes());
1489 let hash = hex::encode(hasher.finalize());
1490 let baseline_exists = baseline.exists();
1491 let (changed, previous_hash) = if baseline_exists {
1492 let prev = std::fs::read_to_string(&baseline).map_err(|e| {
1493 CliError::new(
1494 ErrorKind::Io,
1495 format!("read baseline {}: {e}", baseline.display()),
1496 )
1497 })?;
1498 let prev = prev.trim().to_string();
1499 (prev != hash, Some(prev))
1500 } else {
1501 (true, None)
1502 };
1503 if write_baseline || !baseline_exists {
1504 if let Some(parent) = baseline.parent() {
1505 if !parent.as_os_str().is_empty() {
1506 let _ = std::fs::create_dir_all(parent);
1507 }
1508 }
1509 std::fs::write(&baseline, format!("{hash}\n")).map_err(|e| {
1510 CliError::new(
1511 ErrorKind::Io,
1512 format!("write baseline {}: {e}", baseline.display()),
1513 )
1514 })?;
1515 }
1516 let data = serde_json::json!({
1517 "url": url,
1518 "baseline": baseline.display().to_string(),
1519 "hash": hash,
1520 "previous_hash": previous_hash,
1521 "changed": changed,
1522 "baseline_written": write_baseline || !baseline_exists,
1523 "engine": "http",
1524 });
1525 emit_ok(data, json, |d| {
1526 let ch = d.get("changed").and_then(|v| v.as_bool()).unwrap_or(false);
1527 println!("ok monitor check changed={ch}");
1528 })
1529 }
1530 }
1531}
1532
1533fn handle_run(
1534 life: &Lifecycle,
1535 script: &Path,
1536 robots: RobotsPolicy,
1537 capture: CaptureOpts,
1538 timeout_secs: u64,
1539 json: bool,
1540 flags: run::RunFlags,
1541) -> Result<(), CliError> {
1542 let script = script.to_path_buf();
1543 let data = block_on_browser_timeout(
1544 run::run_script_with_flags(life, &script, robots, capture, flags),
1545 timeout_secs,
1546 )?;
1547 if data.get("ok") == Some(&serde_json::json!(false)) {
1549 let kind = data
1550 .pointer("/error/kind")
1551 .and_then(|v| v.as_str())
1552 .unwrap_or("data");
1553 let message = data
1554 .pointer("/error/message")
1555 .and_then(|v| v.as_str())
1556 .unwrap_or("run fail-fast")
1557 .to_string();
1558 let suggestion = data
1559 .pointer("/error/suggestion")
1560 .and_then(|v| v.as_str())
1561 .unwrap_or_else(|| crate::i18n::suggestion_key("run_fail_fast", None))
1562 .to_string();
1563 let err_kind = match kind {
1564 "usage" => ErrorKind::Usage,
1565 "unavailable" => ErrorKind::Unavailable,
1566 "browser" => ErrorKind::Browser,
1567 "timeout" => ErrorKind::Timeout,
1568 "data" => ErrorKind::Data,
1569 _ => ErrorKind::Software,
1570 };
1571 let partial = serde_json::json!({
1572 "total": data.get("total"),
1573 "failed_index": data.get("failed_index"),
1574 "failed_cmd": data.get("failed_cmd"),
1575 "steps": data.get("steps"),
1576 });
1577 return Err(CliError::with_suggestion(err_kind, message, suggestion).with_data(partial));
1578 }
1579 emit_ok(data, json, |d| {
1580 let total = d.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
1581 println!("ok run steps={total}");
1582 })
1583}
1584
1585fn handle_exec(
1586 life: &Lifecycle,
1587 args: &[String],
1588 robots: RobotsPolicy,
1589 capture: CaptureOpts,
1590 timeout_secs: u64,
1591 json: bool,
1592 flags: run::RunFlags,
1593) -> Result<(), CliError> {
1594 if args.is_empty() {
1595 return Err(CliError::with_suggestion(
1596 ErrorKind::Usage,
1597 "exec requires a subcommand (e.g. goto)",
1598 "browser-automation-cli exec goto about:blank",
1599 ));
1600 }
1601 match args[0].as_str() {
1603 "goto" => {
1604 let url = args.get(1).ok_or_else(|| {
1605 CliError::with_suggestion(
1606 ErrorKind::Usage,
1607 "exec goto requires a URL",
1608 "browser-automation-cli exec goto about:blank",
1609 )
1610 })?;
1611 handle_goto(
1612 life,
1613 url,
1614 robots,
1615 capture,
1616 timeout_secs,
1617 json,
1618 None,
1619 false,
1620 None,
1621 )
1622 }
1623 "wait" | "view" | "press" | "write" | "keys" | "type" | "hover" | "back" | "forward"
1624 | "reload" | "eval" | "grab" | "page" | "console" | "net" | "dialog" | "emulate"
1625 | "resize" | "extract" | "text" | "scroll" | "cookie" | "attr" | "assert" | "click-at"
1626 | "drag" | "fill-form" | "upload" | "devtools3p" | "webmcp" | "heap" | "perf"
1627 | "lighthouse" | "screencast" | "extension" => {
1628 let step = run::argv_to_step(args)?;
1629 let data = block_on_browser_timeout(
1630 run::run_one_step(life, step, robots, capture, flags),
1631 timeout_secs,
1632 )?;
1633 emit_ok(data, json, |d| println!("ok exec {d}"))
1634 }
1635 other => Err(CliError::with_suggestion(
1636 ErrorKind::Usage,
1637 format!("unknown exec subcommand: {other}"),
1638 "Use browser-automation-cli exec <cmd> ... or run --script for multi-step NDJSON",
1639 )),
1640 }
1641}
1642
1643#[allow(clippy::too_many_arguments)]
1644fn handle_extract(
1645 life: &Lifecycle,
1646 target: &str,
1647 attr: Option<&str>,
1648 llm: bool,
1649 question: Option<&str>,
1650 schema_json: Option<&std::path::Path>,
1651 capture: CaptureOpts,
1652 timeout_secs: u64,
1653 json: bool,
1654) -> Result<(), CliError> {
1655 if llm {
1656 return handle_extract_llm(target, question, schema_json, json);
1657 }
1658 let target = target.to_string();
1659 let attr = attr.map(|s| s.to_string());
1660 let data = block_on_browser_timeout(
1661 async move {
1662 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1663 if let Ok(mut ledger) = life.ledger.lock() {
1664 ledger.chrome_launched = true;
1665 ledger.chrome_pid = session.chrome_pid();
1666 }
1667 let _ = session
1668 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1669 .await?;
1670 let r = session.extract(&target, attr.as_deref()).await;
1671 let close = session.shutdown().await;
1672 if let Ok(mut ledger) = life.ledger.lock() {
1673 ledger.chrome_launched = false;
1674 ledger.chrome_pid = None;
1675 }
1676 close?;
1677 r
1678 },
1679 timeout_secs,
1680 )?;
1681 emit_ok(data, json, |d| println!("ok extract {d}"))
1682}
1683
1684fn handle_extract_llm(
1685 target: &str,
1686 question: Option<&str>,
1687 schema_json: Option<&std::path::Path>,
1688 json: bool,
1689) -> Result<(), CliError> {
1690 let schema_body = match schema_json {
1691 Some(p) => Some(std::fs::read_to_string(p).map_err(|e| {
1692 CliError::new(
1693 ErrorKind::Io,
1694 format!("read schema-json {}: {e}", p.display()),
1695 )
1696 })?),
1697 None => None,
1698 };
1699 let source_text = if target.starts_with("http://") || target.starts_with("https://") {
1700 let opts = crate::scrape_local::ScrapeOpts {
1701 format: crate::scrape_local::ScrapeFormat::Text,
1702 only_main_content: true,
1703 engine: "http".into(),
1704 max_body_bytes: 2_000_000,
1705 };
1706 let rt = tokio::runtime::Builder::new_multi_thread()
1707 .enable_all()
1708 .build()
1709 .map_err(|e| CliError::new(ErrorKind::Software, format!("runtime: {e}")))?;
1710 let data = rt.block_on(crate::scrape_local::scrape_http(
1711 target,
1712 crate::robots::RobotsPolicy::Honor,
1713 &opts,
1714 ))?;
1715 data.get("text")
1716 .and_then(|t| t.as_str())
1717 .unwrap_or("")
1718 .to_string()
1719 } else if Path::new(target).is_file() {
1720 let parsed = crate::scrape_local::parse_file(Path::new(target))?;
1721 parsed
1722 .get("text")
1723 .and_then(|t| t.as_str())
1724 .unwrap_or("")
1725 .to_string()
1726 } else {
1727 return Err(CliError::with_suggestion(
1728 ErrorKind::Usage,
1729 "extract --llm target must be http(s) URL or local file path",
1730 "Example: browser-automation-cli --json extract --llm --question 'sum' https://example.com",
1731 ));
1732 };
1733 if source_text.trim().is_empty() {
1734 return Err(CliError::new(
1735 ErrorKind::Data,
1736 "extract --llm: empty source text",
1737 ));
1738 }
1739 let data = crate::llm_local::extract_with_llm(&source_text, question, schema_body.as_deref())?;
1740 emit_ok(data, json, |d| println!("ok extract-llm {d}"))
1741}
1742
1743fn handle_attr(
1744 life: &Lifecycle,
1745 target: &str,
1746 name: &str,
1747 capture: CaptureOpts,
1748 timeout_secs: u64,
1749 json: bool,
1750) -> Result<(), CliError> {
1751 handle_extract(
1752 life,
1753 target,
1754 Some(name),
1755 false,
1756 None,
1757 None,
1758 capture,
1759 timeout_secs,
1760 json,
1761 )
1762}
1763
1764fn handle_assert(
1765 life: &Lifecycle,
1766 kind: AssertKind,
1767 capture: CaptureOpts,
1768 timeout_secs: u64,
1769 json: bool,
1770) -> Result<(), CliError> {
1771 let data = block_on_browser_timeout(
1772 async move {
1773 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1774 if let Ok(mut ledger) = life.ledger.lock() {
1775 ledger.chrome_launched = true;
1776 ledger.chrome_pid = session.chrome_pid();
1777 }
1778 let _ = session
1779 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1780 .await?;
1781 let r = match kind {
1782 AssertKind::Url { value, contains } => session.assert_url(&value, contains).await,
1783 AssertKind::Text { value, target } => {
1784 session.assert_text(&value, target.as_deref()).await
1785 }
1786 AssertKind::Console { level, max } => session.assert_console(&level, max).await,
1787 };
1788 let close = session.shutdown().await;
1789 if let Ok(mut ledger) = life.ledger.lock() {
1790 ledger.chrome_launched = false;
1791 ledger.chrome_pid = None;
1792 }
1793 close?;
1794 r
1795 },
1796 timeout_secs,
1797 )?;
1798 emit_ok(data, json, |_| println!("ok assert"))
1799}
1800
1801fn handle_console(
1802 life: &Lifecycle,
1803 action: ConsoleAction,
1804 capture: CaptureOpts,
1805 timeout_secs: u64,
1806 json: bool,
1807) -> Result<(), CliError> {
1808 let data = block_on_browser_timeout(
1809 async move {
1810 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1811 if let Ok(mut ledger) = life.ledger.lock() {
1812 ledger.chrome_launched = true;
1813 ledger.chrome_pid = session.chrome_pid();
1814 }
1815 let _ = session
1816 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1817 .await?;
1818 let r = match action {
1819 ConsoleAction::List {
1820 page_idx,
1821 page_size,
1822 types,
1823 include_preserved,
1824 service_worker_id,
1825 } => session.console_list(
1826 page_idx,
1827 page_size,
1828 types.as_deref(),
1829 include_preserved,
1830 service_worker_id.as_deref(),
1831 ),
1832 ConsoleAction::Get { id } => session.console_get(id),
1833 ConsoleAction::Clear => session.console_clear(),
1834 ConsoleAction::Dump { path } => session.console_dump(&path),
1835 };
1836 let close = session.shutdown().await;
1837 if let Ok(mut ledger) = life.ledger.lock() {
1838 ledger.chrome_launched = false;
1839 ledger.chrome_pid = None;
1840 }
1841 close?;
1842 r
1843 },
1844 timeout_secs,
1845 )?;
1846 emit_ok(data, json, |d| println!("ok console {d}"))
1847}
1848
1849fn handle_net(
1850 life: &Lifecycle,
1851 action: NetAction,
1852 capture: CaptureOpts,
1853 timeout_secs: u64,
1854 json: bool,
1855) -> Result<(), CliError> {
1856 let data = block_on_browser_timeout(
1857 async move {
1858 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1859 if let Ok(mut ledger) = life.ledger.lock() {
1860 ledger.chrome_launched = true;
1861 ledger.chrome_pid = session.chrome_pid();
1862 }
1863 let _ = session
1864 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1865 .await?;
1866 let r = match action {
1867 NetAction::List {
1868 page_idx,
1869 page_size,
1870 resource_types,
1871 include_preserved,
1872 } => session.net_list(
1873 page_idx,
1874 page_size,
1875 resource_types.as_deref(),
1876 include_preserved,
1877 ),
1878 NetAction::Get {
1879 id,
1880 request_path,
1881 response_path,
1882 } => session.net_get(&id, request_path.as_deref(), response_path.as_deref()),
1883 };
1884 let close = session.shutdown().await;
1885 if let Ok(mut ledger) = life.ledger.lock() {
1886 ledger.chrome_launched = false;
1887 ledger.chrome_pid = None;
1888 }
1889 close?;
1890 r
1891 },
1892 timeout_secs,
1893 )?;
1894 emit_ok(data, json, |d| println!("ok net {d}"))
1895}
1896
1897fn handle_page(
1898 life: &Lifecycle,
1899 action: Option<PageAction>,
1900 capture: CaptureOpts,
1901 timeout_secs: u64,
1902 json: bool,
1903) -> Result<(), CliError> {
1904 let action = action.unwrap_or(PageAction::Info);
1905 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1906 let v = match action {
1907 PageAction::Info => session.page_info().await?,
1908 PageAction::List => session.page_list().await?,
1909 PageAction::New {
1910 url,
1911 background,
1912 isolated_context,
1913 } => {
1914 session
1915 .page_new(url.as_deref(), background, isolated_context)
1916 .await?
1917 }
1918 PageAction::Select {
1919 index,
1920 page_id,
1921 bring_to_front,
1922 } => {
1923 let idx = index.or(page_id).ok_or_else(|| {
1924 CliError::with_suggestion(
1925 ErrorKind::Usage,
1926 "page select requires INDEX or --page-id",
1927 "browser-automation-cli page select 0 --json",
1928 )
1929 })?;
1930 session.page_select(idx, bring_to_front).await?
1931 }
1932 PageAction::Close { index, page_id } => session.page_close(index.or(page_id)).await?,
1933 };
1934 Ok((session, v))
1935 })?;
1936 emit_ok(data, json, |d| {
1937 if let (Some(u), Some(t)) = (
1938 d.get("url").and_then(|v| v.as_str()),
1939 d.get("title").and_then(|v| v.as_str()),
1940 ) {
1941 println!("ok page url={u} title={t}");
1942 } else {
1943 println!("ok page {d}");
1944 }
1945 })
1946}
1947
1948fn handle_text(
1949 life: &Lifecycle,
1950 target: &str,
1951 capture: CaptureOpts,
1952 timeout_secs: u64,
1953 json: bool,
1954) -> Result<(), CliError> {
1955 handle_extract(
1956 life,
1957 target,
1958 None,
1959 false,
1960 None,
1961 None,
1962 capture,
1963 timeout_secs,
1964 json,
1965 )
1966}
1967
1968fn handle_scroll(
1969 life: &Lifecycle,
1970 target: Option<&str>,
1971 delta_x: f64,
1972 delta_y: f64,
1973 capture: CaptureOpts,
1974 timeout_secs: u64,
1975 json: bool,
1976) -> Result<(), CliError> {
1977 let target_owned = target.map(|s| s.to_string());
1978 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1979 let v = session
1980 .scroll(target_owned.as_deref(), delta_x, delta_y)
1981 .await?;
1982 Ok((session, v))
1983 })?;
1984 emit_ok(data, json, |d| println!("ok scroll {d}"))
1985}
1986
1987fn handle_cookie(
1988 life: &Lifecycle,
1989 action: CookieAction,
1990 capture: CaptureOpts,
1991 timeout_secs: u64,
1992 json: bool,
1993) -> Result<(), CliError> {
1994 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1995 let v = match action {
1996 CookieAction::List { url } => session.cookie_list(url.as_deref()).await?,
1997 CookieAction::Set { json: body } => session.cookie_set(&body).await?,
1998 CookieAction::Clear => session.cookie_clear().await?,
1999 };
2000 Ok((session, v))
2001 })?;
2002 emit_ok(data, json, |d| println!("ok cookie {d}"))
2003}
2004
2005fn handle_dialog(
2006 life: &Lifecycle,
2007 action: DialogAction,
2008 capture: CaptureOpts,
2009 timeout_secs: u64,
2010 json: bool,
2011) -> Result<(), CliError> {
2012 let data = block_on_browser_timeout(
2013 async move {
2014 let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
2015 if let Ok(mut ledger) = life.ledger.lock() {
2016 ledger.chrome_launched = true;
2017 ledger.chrome_pid = session.chrome_pid();
2018 }
2019 let _ = session
2020 .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2021 .await?;
2022 let r = match action {
2023 DialogAction::Accept { text } => session.dialog(true, text.as_deref()).await,
2024 DialogAction::Dismiss => session.dialog(false, None).await,
2025 };
2026 let close = session.shutdown().await;
2027 if let Ok(mut ledger) = life.ledger.lock() {
2028 ledger.chrome_launched = false;
2029 ledger.chrome_pid = None;
2030 }
2031 close?;
2032 r
2033 },
2034 timeout_secs,
2035 )?;
2036 emit_ok(data, json, |_| println!("ok dialog"))
2037}
2038
2039#[allow(clippy::too_many_arguments)]
2040fn post_webhook(webhook_url: &str, data: &serde_json::Value) -> Result<(), CliError> {
2041 let client = reqwest::blocking::Client::builder()
2043 .timeout(std::time::Duration::from_secs(15))
2044 .build()
2045 .map_err(|e| CliError::new(ErrorKind::Software, format!("webhook client: {e}")))?;
2046 let mut last_err = String::new();
2047 for attempt in 0..3u32 {
2048 match client.post(webhook_url).json(data).send() {
2049 Ok(resp) if resp.status().is_success() => return Ok(()),
2050 Ok(resp) => {
2051 last_err = format!("webhook HTTP {}", resp.status());
2052 }
2053 Err(e) => last_err = format!("webhook: {e}"),
2054 }
2055 if attempt < 2 {
2056 std::thread::sleep(std::time::Duration::from_millis(50 * (1 << attempt)));
2057 }
2058 }
2059 Err(CliError::with_suggestion(
2060 ErrorKind::Unavailable,
2061 last_err,
2062 "Check --webhook-url reachability; operator destination only",
2063 ))
2064}
2065
2066#[allow(clippy::too_many_arguments)]
2067fn handle_scrape(
2068 life: &Lifecycle,
2069 url: &str,
2070 robots: RobotsPolicy,
2071 capture: CaptureOpts,
2072 timeout_secs: u64,
2073 json: bool,
2074 format: &str,
2075 engine: &str,
2076 only_main_content: bool,
2077 webhook_url: Option<&str>,
2078) -> Result<(), CliError> {
2079 let fmt = crate::scrape_local::ScrapeFormat::parse(format)?;
2080 let engine_l = engine.to_ascii_lowercase();
2081 if engine_l == "http" {
2082 let opts = crate::scrape_local::ScrapeOpts {
2083 format: fmt,
2084 only_main_content,
2085 engine: "http".into(),
2086 ..Default::default()
2087 };
2088 let data = block_on_browser_timeout(
2089 crate::scrape_local::scrape_http(url, robots, &opts),
2090 timeout_secs,
2091 )?;
2092 if let Some(wh) = webhook_url {
2093 post_webhook(wh, &data)?;
2094 }
2095 return emit_ok(data, json, |d| {
2096 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2097 println!("ok scrape engine=http source_url={u}");
2098 });
2099 }
2100 let data = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
2102 let html = data
2103 .get("html")
2104 .and_then(|v| v.as_str())
2105 .unwrap_or("")
2106 .to_string();
2107 let source = data
2108 .get("source_url")
2109 .and_then(|v| v.as_str())
2110 .unwrap_or(url)
2111 .to_string();
2112 let data = if html.is_empty() {
2113 let mut d = data;
2115 if let Some(obj) = d.as_object_mut() {
2116 obj.insert(
2117 "format".into(),
2118 serde_json::json!(format!("{:?}", fmt).to_ascii_lowercase()),
2119 );
2120 obj.insert("engine".into(), serde_json::json!("browser"));
2121 }
2122 d
2123 } else {
2124 let opts = crate::scrape_local::ScrapeOpts {
2125 format: fmt,
2126 only_main_content,
2127 engine: "browser".into(),
2128 ..Default::default()
2129 };
2130 crate::scrape_local::build_scrape_payload(&source, 200, &html, &opts, robots)
2131 };
2132 if let Some(wh) = webhook_url {
2133 post_webhook(wh, &data)?;
2134 }
2135 emit_ok(data, json, |d| {
2136 let policy = d
2137 .get("robots_policy")
2138 .and_then(|v| v.as_str())
2139 .unwrap_or("honor");
2140 let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2141 println!("ok scrape source_url={u} robots_policy={policy}");
2142 })
2143}
2144
2145fn handle_batch_scrape(
2146 urls_file: &Path,
2147 robots: RobotsPolicy,
2148 format: &str,
2149 concurrency: usize,
2150 json: bool,
2151) -> Result<(), CliError> {
2152 let urls = crate::scrape_local::read_urls_file(urls_file)?;
2153 let opts = crate::scrape_local::ScrapeOpts {
2154 format: crate::scrape_local::ScrapeFormat::parse(format)?,
2155 engine: "http".into(),
2156 ..Default::default()
2157 };
2158 let data = block_on_browser_timeout(
2159 crate::scrape_local::batch_scrape_http(&urls, robots, &opts, concurrency),
2160 0,
2161 )?;
2162 emit_ok(data, json, |d| {
2163 println!(
2164 "ok batch-scrape count={}",
2165 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2166 );
2167 })
2168}
2169
2170fn handle_crawl(
2171 url: &str,
2172 robots: RobotsPolicy,
2173 limit: usize,
2174 max_depth: usize,
2175 format: &str,
2176 same_host: bool,
2177 json: bool,
2178) -> Result<(), CliError> {
2179 let opts = crate::scrape_local::ScrapeOpts {
2180 format: crate::scrape_local::ScrapeFormat::parse(format)?,
2181 engine: "http".into(),
2182 ..Default::default()
2183 };
2184 let data = block_on_browser_timeout(
2185 crate::scrape_local::crawl_http(url, robots, &opts, limit, max_depth, same_host),
2186 0,
2187 )?;
2188 emit_ok(data, json, |d| {
2189 println!(
2190 "ok crawl count={}",
2191 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2192 );
2193 })
2194}
2195
2196fn handle_map(
2197 url: &str,
2198 robots: RobotsPolicy,
2199 limit: usize,
2200 max_depth: usize,
2201 json: bool,
2202) -> Result<(), CliError> {
2203 let data = block_on_browser_timeout(
2204 crate::scrape_local::map_http(url, robots, limit, max_depth),
2205 0,
2206 )?;
2207 emit_ok(data, json, |d| {
2208 println!(
2209 "ok map count={}",
2210 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2211 );
2212 })
2213}
2214
2215fn handle_search(
2216 query: &str,
2217 robots: RobotsPolicy,
2218 limit: usize,
2219 json: bool,
2220) -> Result<(), CliError> {
2221 let data = block_on_browser_timeout(crate::scrape_local::search_http(query, robots, limit), 0)?;
2222 emit_ok(data, json, |d| {
2223 println!(
2224 "ok search count={}",
2225 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2226 );
2227 })
2228}
2229
2230fn handle_parse(path: &Path, redact_pii: bool, json: bool) -> Result<(), CliError> {
2231 let data = crate::scrape_local::parse_file_opts(path, redact_pii)?;
2232 emit_ok(data, json, |d| {
2233 println!(
2234 "ok parse path={}",
2235 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
2236 );
2237 })
2238}
2239
2240fn handle_qr(action: crate::cli::QrAction, json: bool) -> Result<(), CliError> {
2241 let data = match action {
2242 crate::cli::QrAction::Encode { text, format, path } => {
2243 crate::qr_local::encode(&text, &format, path.as_deref())?
2244 }
2245 crate::cli::QrAction::Decode { path } => crate::qr_local::decode(&path)?,
2246 };
2247 emit_ok(data, json, |d| println!("ok qr {d}"))
2248}
2249
2250#[allow(clippy::too_many_arguments)]
2251fn handle_find_paths(
2252 pattern: Option<&str>,
2253 paths: &[String],
2254 extension: Option<&str>,
2255 hidden: bool,
2256 no_ignore: bool,
2257 max_depth: Option<usize>,
2258 entry_type: Option<&str>,
2259 limit: usize,
2260 json: bool,
2261) -> Result<(), CliError> {
2262 let opts = crate::find_paths::FindPathsOpts {
2263 pattern: pattern.unwrap_or("").to_string(),
2264 roots: crate::find_paths::roots_from(paths),
2265 extension: extension.map(|s| s.to_string()),
2266 hidden,
2267 no_ignore,
2268 max_depth,
2269 entry_type: entry_type.map(|s| s.to_string()),
2270 limit,
2271 };
2272 let data = crate::find_paths::find_paths(&opts)?;
2273 emit_ok(data, json, |d| {
2274 println!(
2275 "ok find-paths count={}",
2276 d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
2277 );
2278 })
2279}
2280
2281fn handle_mitm(action: MitmAction, json: bool) -> Result<(), CliError> {
2282 let data = match action {
2283 MitmAction::Status => crate::mitm_local::status()?,
2284 MitmAction::List { host, limit } => crate::mitm_local::list(host.as_deref(), limit)?,
2285 MitmAction::Get { id } => crate::mitm_local::get(id)?,
2286 MitmAction::Har { out } => crate::mitm_local::export_har(&out)?,
2287 MitmAction::Export { format, out } => {
2288 if format.eq_ignore_ascii_case("har") {
2289 crate::mitm_local::export_har(&out)?
2290 } else {
2291 let path = crate::mitm_local::default_capture_path()?;
2292 let cap = crate::mitm_local::MitmCapture::load(&path, true)?;
2293 let body = serde_json::to_vec_pretty(&serde_json::json!({
2294 "count": cap.items.len(),
2295 "items": cap.items,
2296 }))
2297 .map_err(|e| CliError::new(ErrorKind::Data, format!("export: {e}")))?;
2298 std::fs::write(&out, body)
2299 .map_err(|e| CliError::new(ErrorKind::Io, format!("export write: {e}")))?;
2300 serde_json::json!({
2301 "path": out.display().to_string(),
2302 "format": format,
2303 "count": cap.items.len(),
2304 })
2305 }
2306 }
2307 MitmAction::Domains => crate::mitm_local::domains()?,
2308 MitmAction::Apis { kind } => crate::mitm_local::apis(kind.as_deref())?,
2309 MitmAction::InitCa => crate::mitm_local::ensure_ca()?,
2310 MitmAction::Start { seconds } => {
2311 crate::browser::block_on_browser(crate::mitm_local::start_proxy_oneshot(seconds))?
2312 }
2313 };
2314 emit_ok(data, json, |d| println!("ok mitm {d}"))
2315}
2316
2317fn handle_workflow(action: WorkflowAction, json: bool) -> Result<(), CliError> {
2318 let data = match action {
2319 WorkflowAction::Run { manifest, journal } => {
2320 crate::workflow_local::workflow_run(&manifest, journal.as_deref())?
2321 }
2322 WorkflowAction::Resume { manifest, journal } => {
2323 crate::workflow_local::workflow_resume(&manifest, journal.as_deref())?
2324 }
2325 WorkflowAction::Status { journal, name } => {
2326 crate::workflow_local::workflow_status(journal.as_deref(), name.as_deref())?
2327 }
2328 };
2329 emit_ok(data, json, |d| println!("ok workflow {d}"))
2330}
2331
2332fn handle_config(action: ConfigAction, json: bool) -> Result<(), CliError> {
2333 let data = match action {
2334 ConfigAction::Path => crate::xdg::paths_snapshot()?,
2335 ConfigAction::Init => crate::xdg::init_layout()?,
2336 ConfigAction::Show => crate::xdg::config_get(None)?,
2337 ConfigAction::Set { key, value } => crate::xdg::config_set(&key, &value)?,
2338 ConfigAction::Get { key } => crate::xdg::config_get(key.as_deref())?,
2339 };
2340 emit_ok(data, json, |d| println!("ok config {d}"))
2341}
2342
2343#[allow(clippy::too_many_arguments)]
2344fn handle_emulate(
2345 life: &Lifecycle,
2346 user_agent: Option<&str>,
2347 locale: Option<&str>,
2348 timezone: Option<&str>,
2349 offline: bool,
2350 latitude: Option<f64>,
2351 longitude: Option<f64>,
2352 media: Option<&str>,
2353 network_conditions: Option<&str>,
2354 cpu_throttling_rate: Option<f64>,
2355 color_scheme: Option<&str>,
2356 extra_headers: Option<&str>,
2357 viewport: Option<&str>,
2358 capture: CaptureOpts,
2359 timeout_secs: u64,
2360 json: bool,
2361) -> Result<(), CliError> {
2362 let ua = user_agent.map(|s| s.to_string());
2363 let loc = locale.map(|s| s.to_string());
2364 let tz = timezone.map(|s| s.to_string());
2365 let media = media.map(|s| s.to_string());
2366 let net = network_conditions.map(|s| s.to_string());
2367 let scheme = color_scheme.map(|s| s.to_string());
2368 let headers = extra_headers.map(|s| s.to_string());
2369 let vp = viewport.map(|s| s.to_string());
2370 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2371 let v = session
2372 .emulate(
2373 ua.as_deref(),
2374 loc.as_deref(),
2375 tz.as_deref(),
2376 offline,
2377 latitude,
2378 longitude,
2379 media.as_deref(),
2380 net.as_deref(),
2381 cpu_throttling_rate,
2382 scheme.as_deref(),
2383 headers.as_deref(),
2384 vp.as_deref(),
2385 )
2386 .await?;
2387 Ok((session, v))
2388 })?;
2389 emit_ok(data, json, |_| println!("ok emulate"))
2390}
2391
2392#[allow(clippy::too_many_arguments)]
2393fn handle_resize(
2394 life: &Lifecycle,
2395 width: i32,
2396 height: i32,
2397 scale: f64,
2398 mobile: bool,
2399 capture: CaptureOpts,
2400 timeout_secs: u64,
2401 json: bool,
2402) -> Result<(), CliError> {
2403 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2404 let v = session.resize(width, height, scale, mobile).await?;
2405 Ok((session, v))
2406 })?;
2407 emit_ok(data, json, |_| println!("ok resize {width}x{height}"))
2408}
2409
2410fn handle_perf(
2411 life: &Lifecycle,
2412 action: PerfAction,
2413 capture: CaptureOpts,
2414 timeout_secs: u64,
2415 json: bool,
2416) -> Result<(), CliError> {
2417 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2418 let v = match action {
2419 PerfAction::Start {
2420 path,
2421 reload,
2422 auto_stop,
2423 } => {
2424 session
2425 .perf_start(path.as_deref(), reload, auto_stop)
2426 .await?
2427 }
2428 PerfAction::Stop { path } => session.perf_stop(path.as_deref()).await?,
2429 PerfAction::Insight {
2430 name,
2431 insight_set_id,
2432 insight_name,
2433 } => {
2434 let resolved = insight_name.or(name);
2435 session
2436 .perf_insight(resolved.as_deref(), insight_set_id.as_deref())
2437 .await?
2438 }
2439 };
2440 Ok((session, v))
2441 })?;
2442 emit_ok(data, json, |d| println!("ok perf {d}"))
2443}
2444
2445pub(crate) fn lighthouse_to_value(
2447 url: &str,
2448 out_dir: Option<&Path>,
2449 device: &str,
2450 mode: &str,
2451 lighthouse_path: Option<&Path>,
2452) -> Result<serde_json::Value, CliError> {
2453 let bin = lighthouse_path
2454 .map(|p| p.display().to_string())
2455 .or_else(crate::xdg::lighthouse_path_from_config)
2456 .unwrap_or_else(|| "lighthouse".to_string());
2457 let out = out_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2458 crate::xdg::cache_dir()
2459 .unwrap_or_else(|_| std::env::temp_dir().join("browser-automation-cli"))
2460 .join("lighthouse")
2461 });
2462 std::fs::create_dir_all(&out)
2463 .map_err(|e| CliError::new(ErrorKind::Io, format!("lighthouse out-dir: {e}")))?;
2464 let form_factor = if device.eq_ignore_ascii_case("mobile") {
2465 "mobile"
2466 } else {
2467 "desktop"
2468 };
2469 let mode_norm = if mode.eq_ignore_ascii_case("snapshot") {
2470 "snapshot"
2471 } else {
2472 "navigation"
2473 };
2474 let html_path = out.join("report.html");
2476 let json_path = out.join("report.json");
2477 let output = std::process::Command::new(&bin)
2478 .arg(url)
2479 .arg("--quiet")
2480 .arg("--output=html")
2481 .arg("--output=json")
2482 .arg(format!("--output-path={}", out.join("report").display()))
2483 .arg(format!("--form-factor={form_factor}"))
2484 .arg("--chrome-flags=--headless=new")
2485 .arg("--only-categories=accessibility,seo,best-practices")
2486 .output()
2487 .map_err(|e| {
2488 CliError::with_suggestion(
2489 ErrorKind::Unavailable,
2490 format!("lighthouse spawn failed: {e}"),
2491 crate::i18n::suggestion_key("lighthouse_missing", None),
2492 )
2493 })?;
2494 if !output.status.success() {
2495 let stderr = String::from_utf8_lossy(&output.stderr);
2496 return Err(CliError::with_suggestion(
2497 ErrorKind::Software,
2498 format!("lighthouse exited non-zero: {stderr}"),
2499 "Check URL and lighthouse install",
2500 ));
2501 }
2502 let report_html = if html_path.exists() {
2504 html_path.clone()
2505 } else if out.join("report.report.html").exists() {
2506 out.join("report.report.html")
2507 } else {
2508 html_path.clone()
2509 };
2510 let report_json = if json_path.exists() {
2511 json_path.clone()
2512 } else if out.join("report.report.json").exists() {
2513 out.join("report.report.json")
2514 } else {
2515 out.join("report.json")
2517 };
2518
2519 let mut scores = Vec::new();
2520 let mut passed_audits = 0u64;
2521 let mut failed_audits = 0u64;
2522 if report_json.exists() {
2523 if let Ok(raw) = std::fs::read_to_string(&report_json) {
2524 if let Ok(lhr) = serde_json::from_str::<serde_json::Value>(&raw) {
2525 if let Some(cats) = lhr.get("categories").and_then(|c| c.as_object()) {
2526 for (id, cat) in cats {
2527 scores.push(serde_json::json!({
2528 "id": id,
2529 "title": cat.get("title").and_then(|t| t.as_str()).unwrap_or(id),
2530 "score": cat.get("score"),
2531 }));
2532 }
2533 }
2534 if let Some(audits) = lhr.get("audits").and_then(|a| a.as_object()) {
2535 for a in audits.values() {
2536 if let Some(sc) = a.get("score").and_then(|s| s.as_f64()) {
2537 if sc < 1.0 {
2538 failed_audits += 1;
2539 } else {
2540 passed_audits += 1;
2541 }
2542 }
2543 }
2544 }
2545 }
2546 }
2547 }
2548
2549 Ok(serde_json::json!({
2550 "lighthouse": true,
2551 "url": url,
2552 "device": form_factor,
2553 "mode": mode_norm,
2554 "binary": bin,
2555 "out_dir": out.to_string_lossy(),
2556 "reports": {
2557 "html": report_html.to_string_lossy(),
2558 "json": report_json.to_string_lossy(),
2559 },
2560 "scores": scores,
2561 "passed_audits": passed_audits,
2562 "failed_audits": failed_audits,
2563 }))
2564}
2565
2566fn handle_lighthouse(
2567 url: &str,
2568 out_dir: Option<&Path>,
2569 device: &str,
2570 mode: &str,
2571 lighthouse_path: Option<&Path>,
2572 json: bool,
2573) -> Result<(), CliError> {
2574 let data = lighthouse_to_value(url, out_dir, device, mode, lighthouse_path)?;
2575 emit_ok(data, json, |d| {
2576 println!(
2577 "ok lighthouse report={}",
2578 d.pointer("/reports/html")
2579 .and_then(|v| v.as_str())
2580 .unwrap_or("")
2581 );
2582 })
2583}
2584
2585fn handle_screencast(
2586 life: &Lifecycle,
2587 action: ScreencastAction,
2588 capture: CaptureOpts,
2589 timeout_secs: u64,
2590 json: bool,
2591) -> Result<(), CliError> {
2592 let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2593 let v = match action {
2594 ScreencastAction::Start { path } => session.screencast_start(path.as_deref()).await?,
2595 ScreencastAction::Stop { path } => session.screencast_stop(path.as_deref()).await?,
2596 };
2597 Ok((session, v))
2598 })?;
2599 emit_ok(data, json, |d| println!("ok screencast {d}"))
2600}
2601
2602fn handle_heap(
2603 life: &Lifecycle,
2604 action: HeapAction,
2605 capture: CaptureOpts,
2606 timeout_secs: u64,
2607 json: bool,
2608) -> Result<(), CliError> {
2609 match action {
2610 HeapAction::Take { path } => {
2611 let data =
2612 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2613 let v = session.heap_take(&path).await?;
2614 Ok((session, v))
2615 })?;
2616 emit_ok(data, json, |d| {
2617 println!(
2618 "ok heap take path={}",
2619 d.get("path").and_then(|v| v.as_str()).unwrap_or("")
2620 );
2621 })
2622 }
2623 HeapAction::Close { path } => {
2624 let data = OneShotSession::heap_close(&path)?;
2625 emit_ok(data, json, |_| {
2626 println!("ok heap close path={}", path.display())
2627 })
2628 }
2629 HeapAction::Compare {
2630 base,
2631 current,
2632 class_index,
2633 } => {
2634 let mut data = OneShotSession::heap_compare(&base, ¤t)?;
2635 if let Some(ci) = class_index {
2636 if let Some(obj) = data.as_object_mut() {
2637 obj.insert("class_index".into(), serde_json::json!(ci));
2638 }
2639 }
2640 emit_ok(data, json, |d| println!("ok heap compare {d}"))
2641 }
2642 HeapAction::Summary { path } => {
2643 let data = OneShotSession::heap_file_summary(&path)?;
2644 emit_ok(data, json, |d| println!("ok heap summary {d}"))
2645 }
2646 HeapAction::Details {
2647 path,
2648 filter_name,
2649 page_idx,
2650 page_size,
2651 } => {
2652 validate_heap_filter_name(filter_name.as_deref())?;
2653 let mut data = OneShotSession::heap_details(&path)?;
2654 paginate_filter_json(
2655 &mut data,
2656 "classes",
2657 filter_name.as_deref(),
2658 page_idx,
2659 page_size,
2660 );
2661 emit_ok(data, json, |d| println!("ok heap details {d}"))
2662 }
2663 HeapAction::DupStrings {
2664 path,
2665 page_idx,
2666 page_size,
2667 } => {
2668 let mut data = OneShotSession::heap_dup_strings(&path)?;
2669 paginate_filter_json(&mut data, "strings", None, page_idx, page_size);
2670 emit_ok(data, json, |d| println!("ok heap dup-strings {d}"))
2671 }
2672 HeapAction::ClassNodes {
2673 path,
2674 id,
2675 filter_name,
2676 page_idx,
2677 page_size,
2678 } => {
2679 validate_heap_filter_name(filter_name.as_deref())?;
2680 let mut data = OneShotSession::heap_class_nodes(&path, id)?;
2681 paginate_filter_json(
2682 &mut data,
2683 "nodes",
2684 filter_name.as_deref(),
2685 page_idx,
2686 page_size,
2687 );
2688 emit_ok(data, json, |d| println!("ok heap class-nodes {d}"))
2689 }
2690 HeapAction::Dominators { path, node } => {
2691 let data = OneShotSession::heap_node_op(&path, node, "dominators")?;
2692 emit_ok(data, json, |d| println!("ok heap dominators {d}"))
2693 }
2694 HeapAction::Edges {
2695 path,
2696 node,
2697 page_idx,
2698 page_size,
2699 } => {
2700 let mut data = OneShotSession::heap_node_op(&path, node, "edges")?;
2701 paginate_filter_json(&mut data, "edges", None, page_idx, page_size);
2702 emit_ok(data, json, |d| println!("ok heap edges {d}"))
2703 }
2704 HeapAction::Retainers {
2705 path,
2706 node,
2707 page_idx,
2708 page_size,
2709 } => {
2710 let mut data = OneShotSession::heap_node_op(&path, node, "retainers")?;
2711 paginate_filter_json(&mut data, "retainers", None, page_idx, page_size);
2712 emit_ok(data, json, |d| println!("ok heap retainers {d}"))
2713 }
2714 HeapAction::Paths {
2715 path,
2716 node,
2717 max_depth,
2718 max_nodes,
2719 max_siblings,
2720 } => {
2721 let data = crate::native::heap_snapshot::node_op_with_limits(
2722 &path,
2723 node,
2724 "paths",
2725 max_depth as usize,
2726 max_siblings.unwrap_or(32) as usize,
2727 max_nodes.unwrap_or(200) as usize,
2728 200,
2729 )
2730 .map_err(|e| {
2731 CliError::with_suggestion(
2732 ErrorKind::Io,
2733 e,
2734 "Pass a valid .heapsnapshot path and node id",
2735 )
2736 })?;
2737 emit_ok(data, json, |d| println!("ok heap paths {d}"))
2738 }
2739 HeapAction::ObjectDetails { path, node } => {
2740 let data = OneShotSession::heap_object_details(&path, node)?;
2741 emit_ok(data, json, |d| println!("ok heap object-details {d}"))
2742 }
2743 }
2744}
2745
2746const HEAP_FILTER_NAME_ENUM: &[&str] = &[
2748 "objectsRetainedByDetachedDomNodes",
2749 "objectsRetainedByConsole",
2750 "objectsRetainedByEventHandlers",
2751 "objectsRetainedByContexts",
2752];
2753
2754fn validate_heap_filter_name(filter_name: Option<&str>) -> Result<(), CliError> {
2755 let Some(f) = filter_name else {
2756 return Ok(());
2757 };
2758 if f.starts_with("objectsRetained")
2760 && !HEAP_FILTER_NAME_ENUM
2761 .iter()
2762 .any(|e| e.eq_ignore_ascii_case(f))
2763 {
2764 return Err(CliError::with_suggestion(
2765 ErrorKind::Usage,
2766 format!("invalid heap --filter-name enum: {f}"),
2767 "Use objectsRetainedByDetachedDomNodes|objectsRetainedByConsole|objectsRetainedByEventHandlers|objectsRetainedByContexts or free-text substring",
2768 ));
2769 }
2770 Ok(())
2771}
2772
2773fn paginate_filter_json(
2775 data: &mut serde_json::Value,
2776 array_key: &str,
2777 filter_name: Option<&str>,
2778 page_idx: Option<usize>,
2779 page_size: Option<usize>,
2780) {
2781 let key = {
2782 if data.get(array_key).and_then(|v| v.as_array()).is_some() {
2783 array_key.to_string()
2784 } else {
2785 let mut found = None;
2786 for alt in ["items", "results", "list"] {
2787 if data.get(alt).and_then(|v| v.as_array()).is_some() {
2788 found = Some(alt.to_string());
2789 break;
2790 }
2791 }
2792 match found {
2793 Some(k) => k,
2794 None => return,
2795 }
2796 }
2797 };
2798
2799 let is_enum_filter = filter_name
2800 .map(|f| {
2801 HEAP_FILTER_NAME_ENUM
2802 .iter()
2803 .any(|e| e.eq_ignore_ascii_case(f))
2804 })
2805 .unwrap_or(false);
2806
2807 if is_enum_filter {
2808 if let Some(obj) = data.as_object_mut() {
2811 obj.insert(
2812 "filter_name".into(),
2813 serde_json::json!(filter_name.unwrap()),
2814 );
2815 obj.insert(
2816 "filter_applied".into(),
2817 serde_json::json!("enum_recorded_offline_not_recomputed"),
2818 );
2819 }
2820 }
2821
2822 let Some(arr) = data.get_mut(&key).and_then(|v| v.as_array_mut()) else {
2823 return;
2824 };
2825 if let Some(f) = filter_name {
2826 if !is_enum_filter {
2827 let f_low = f.to_ascii_lowercase();
2828 arr.retain(|item| {
2829 item.get("name")
2830 .or_else(|| item.get("class_name"))
2831 .or_else(|| item.get("string"))
2832 .and_then(|v| v.as_str())
2833 .map(|s| s.to_ascii_lowercase().contains(&f_low))
2834 .unwrap_or(true)
2835 });
2836 }
2837 }
2838 let total = arr.len();
2839 let page = page_idx.unwrap_or(0);
2840 let size = page_size.unwrap_or(total.max(1));
2841 let start = page.saturating_mul(size).min(total);
2842 let end = (start + size).min(total);
2843 let page_items: Vec<serde_json::Value> = arr[start..end].to_vec();
2844 *arr = page_items;
2845 if let Some(obj) = data.as_object_mut() {
2846 obj.insert("total".into(), serde_json::json!(total));
2847 obj.insert("page_idx".into(), serde_json::json!(page));
2848 obj.insert("page_size".into(), serde_json::json!(size));
2849 }
2850}
2851
2852fn handle_extension(
2853 life: &Lifecycle,
2854 action: ExtensionAction,
2855 capture: CaptureOpts,
2856 timeout_secs: u64,
2857 json: bool,
2858) -> Result<(), CliError> {
2859 match action {
2860 ExtensionAction::List => {
2861 let data =
2862 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2863 let v = session.extension_list().await?;
2864 Ok((session, v))
2865 })?;
2866 emit_ok(data, json, |d| println!("ok extension list {d}"))
2867 }
2868 ExtensionAction::Install { path } => {
2869 let path_s = path.display().to_string();
2870 let data = block_on_browser_timeout(
2871 async move {
2872 let mut session =
2873 OneShotSession::launch_with_extensions(capture, vec![path_s.clone()])
2874 .await?;
2875 if let Ok(mut ledger) = life.ledger.lock() {
2876 ledger.chrome_launched = true;
2877 ledger.chrome_pid = session.chrome_pid();
2878 }
2879 let mut listed = session.extension_list().await?;
2881 for _ in 0..20 {
2882 let count = listed.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
2883 if count > 0 {
2884 break;
2885 }
2886 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
2887 listed = session.extension_list().await?;
2888 }
2889 let close = session.shutdown().await;
2890 if let Ok(mut ledger) = life.ledger.lock() {
2891 ledger.chrome_launched = false;
2892 ledger.chrome_pid = None;
2893 }
2894 close?;
2895 Ok(serde_json::json!({
2896 "installed_path": path_s,
2897 "load_extension": true,
2898 "targets": listed,
2899 "note": "one-shot: Chrome launched with --load-extension for this process only",
2900 }))
2901 },
2902 timeout_secs,
2903 )?;
2904 emit_ok(data, json, |d| println!("ok extension install {d}"))
2905 }
2906 ExtensionAction::Reload { id, path } => {
2907 let id = id.clone();
2908 let path_s = path.as_ref().map(|p| p.display().to_string());
2909 let data = block_on_browser_timeout(
2910 async move {
2911 let mut session = if let Some(p) = path_s {
2912 OneShotSession::launch_with_extensions(capture, vec![p]).await?
2913 } else {
2914 OneShotSession::launch_headless_with_capture(capture).await?
2915 };
2916 if let Ok(mut ledger) = life.ledger.lock() {
2917 ledger.chrome_launched = true;
2918 ledger.chrome_pid = session.chrome_pid();
2919 }
2920 let v = session.extension_reload(&id).await;
2921 let close = session.shutdown().await;
2922 if let Ok(mut ledger) = life.ledger.lock() {
2923 ledger.chrome_launched = false;
2924 ledger.chrome_pid = None;
2925 }
2926 close?;
2927 v
2928 },
2929 timeout_secs,
2930 )?;
2931 emit_ok(data, json, |d| println!("ok extension reload {d}"))
2932 }
2933 ExtensionAction::Trigger { id, path } => {
2934 let id = id.clone();
2935 let path_s = path.as_ref().map(|p| p.display().to_string());
2936 let data = block_on_browser_timeout(
2937 async move {
2938 let mut session = if let Some(p) = path_s {
2939 OneShotSession::launch_with_extensions(capture, vec![p]).await?
2940 } else {
2941 OneShotSession::launch_headless_with_capture(capture).await?
2942 };
2943 if let Ok(mut ledger) = life.ledger.lock() {
2944 ledger.chrome_launched = true;
2945 ledger.chrome_pid = session.chrome_pid();
2946 }
2947 let v = session.extension_trigger(&id).await;
2948 let close = session.shutdown().await;
2949 if let Ok(mut ledger) = life.ledger.lock() {
2950 ledger.chrome_launched = false;
2951 ledger.chrome_pid = None;
2952 }
2953 close?;
2954 v
2955 },
2956 timeout_secs,
2957 )?;
2958 emit_ok(data, json, |d| println!("ok extension trigger {d}"))
2959 }
2960 ExtensionAction::Uninstall { id } => {
2961 let data = serde_json::json!({
2963 "uninstalled": id,
2964 "persistent": false,
2965 "note": "one-shot CLI does not keep extensions across processes; omit path on next install",
2966 });
2967 emit_ok(data, json, |_| println!("ok extension uninstall id={id}"))
2968 }
2969 }
2970}
2971
2972fn handle_devtools3p(
2973 life: &Lifecycle,
2974 action: Devtools3pAction,
2975 capture: CaptureOpts,
2976 timeout_secs: u64,
2977 json: bool,
2978) -> Result<(), CliError> {
2979 match action {
2980 Devtools3pAction::List { url } => {
2981 let url = url.unwrap_or_else(|| "about:blank".into());
2982 let data =
2983 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2984 if url != "about:blank" {
2985 let _ = session
2986 .goto(&url, crate::robots::RobotsPolicy::Ignore)
2987 .await?;
2988 }
2989 let v = session.devtools3p_list().await?;
2990 Ok((session, v))
2991 })?;
2992 emit_ok(data, json, |d| println!("ok devtools3p list {d}"))
2993 }
2994 Devtools3pAction::Exec { name, params, url } => {
2995 let url = url.unwrap_or_else(|| "about:blank".into());
2996 let params = params.clone();
2997 let name = name.clone();
2998 let data =
2999 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3000 if url != "about:blank" {
3001 let _ = session
3002 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3003 .await?;
3004 }
3005 let v = session.devtools3p_exec(&name, params.as_deref()).await?;
3006 Ok((session, v))
3007 })?;
3008 emit_ok(data, json, |d| println!("ok devtools3p exec {d}"))
3009 }
3010 }
3011}
3012
3013fn handle_webmcp(
3014 life: &Lifecycle,
3015 action: WebmcpAction,
3016 capture: CaptureOpts,
3017 timeout_secs: u64,
3018 json: bool,
3019) -> Result<(), CliError> {
3020 match action {
3021 WebmcpAction::List { url } => {
3022 let url = url.unwrap_or_else(|| "about:blank".into());
3023 let data =
3024 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3025 if url != "about:blank" {
3026 let _ = session
3027 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3028 .await?;
3029 }
3030 let v = session.webmcp_list().await?;
3031 Ok((session, v))
3032 })?;
3033 emit_ok(data, json, |d| println!("ok webmcp list {d}"))
3034 }
3035 WebmcpAction::Exec { name, input, url } => {
3036 let url = url.unwrap_or_else(|| "about:blank".into());
3037 let name = name.clone();
3038 let input = input.clone();
3039 let data =
3040 with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3041 if url != "about:blank" {
3042 let _ = session
3043 .goto(&url, crate::robots::RobotsPolicy::Ignore)
3044 .await?;
3045 }
3046 let v = session.webmcp_exec(&name, input.as_deref()).await?;
3047 Ok((session, v))
3048 })?;
3049 emit_ok(data, json, |d| println!("ok webmcp exec {d}"))
3050 }
3051 }
3052}
3053
3054fn handle_completions(shell: CompletionShell) -> Result<(), CliError> {
3055 use clap::CommandFactory;
3056 use clap_complete::{generate, shells};
3057 use std::io::Write;
3058
3059 let mut cmd = crate::cli::Cli::command();
3060 let bin = "browser-automation-cli";
3061 let mut out = std::io::stdout();
3062 match shell {
3063 CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin, &mut out),
3064 CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin, &mut out),
3065 CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin, &mut out),
3066 CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin, &mut out),
3067 CompletionShell::Powershell => generate(shells::PowerShell, &mut cmd, bin, &mut out),
3068 }
3069 let _ = out.flush();
3070 Ok(())
3071}