Skip to main content

browser_automation_cli/commands_prd/
mod.rs

1//! PRD command dispatch (paths leves + Layer A/B browser one-shot).
2#![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
23/// Dispatch parsed CLI. Returns process exit code (0 success).
24pub 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            // trailing_var_arg can capture global flags placed after `exec`;
379            // peel them so agents can write: exec goto URL --json
380            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
781/// Peel known global flags mistakenly captured by `exec` trailing_var_arg.
782fn 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    // init_script / beforeunload applied inside session when multi-step run is used;
808    // single-shot goto keeps robots path; flags accepted for tool-ref CLI parity.
809    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    // Prefer explicit wait_timeout_ms for text/selector; fall back to ms
1003    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            // OR semantics: try each text until one succeeds (tool-ref wait_for array)
1012            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        // Normalize bare eN → @eN
1179        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            // Best-effort: auto-accept any beforeunload via dialog handler
1267            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    // Fail-fast payload: ok:false with partial steps (still non-zero exit).
1548    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    // Single-step path for simple argv forms; multi-step uses run --script.
1602    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    // One-shot operator webhook; no product telemetry.
2042    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    // browser engine: CDP scrape always includes outerHTML for multi-format payload.
2101    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        // Fallback: still surface text-only with format marker so agents see format intent.
2114        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
2445/// Run lighthouse binary and return envelope data (shared by CLI and `run` scripts).
2446pub(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    // One-shot CLI always launches its own Chrome via lighthouse CLI for navigation mode.
2475    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    // Lighthouse may write report.report.html / report.report.json depending on version.
2503    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        // Some builds write plain report.json next to html
2516        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, &current)?;
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
2746/// Tool-ref heap filterName enum (closed set).
2747const 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    // Free-text substring filters stay allowed; enum-like names must match tool-ref.
2759    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
2773/// Paginate/filter a JSON array field for heap list ops (tool-ref pageIdx/pageSize/filterName).
2774fn 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        // Offline heapsnapshot parser does not recompute retainer-kind filters;
2809        // record the requested enum for agents and keep full list (honest Partial).
2810        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                    // Service workers may take a moment to register after --load-extension.
2880                    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            // One-shot has no persistent Chrome profile: uninstall means "not loaded next process".
2962            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}