Skip to main content

browser_automation_cli/commands_prd/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! PRD command dispatch (path-level + Layer A/B browser one-shot).
3//!
4//! # Workload
5//!
6//! **Mista (orchestration):** dispatch is sequential per invocation (one CLI
7//! process). Multi-item fan-out lives inside callees (`scrape_local` JoinSet,
8//! `sg`/`find-paths` Rayon, CDP `join_bounded`). Browser batch/crawl stays
9//! sequential under product law (N-129). Large disk dumps from handlers use
10//! [`crate::concurrency::write_bytes_blocking`] (under `block_on`) or
11//! [`crate::concurrency::write_bytes_sync`] (outer CLI thread).
12//!
13//! # Module map (D-01 maintainability)
14//!
15//! | Module | Responsibility |
16//! |--------|----------------|
17//! | `mod` (this file) | `dispatch` match table + session-bound handlers |
18//! | [`meta`] | agent inventory (`commands`, `schema`) |
19//! | [`run`] | multi-step script engine (`run` / `exec`) |
20//!
21//! New **command families** should land in sibling modules and be wired from
22//! `dispatch`, rather than growing unrelated helpers in this file.
23#![allow(missing_docs)] // clap/handler surface; agent UX is `--help` + skills (D-02/D-11)
24
25mod meta;
26pub mod run;
27
28use std::path::Path;
29
30use serde_json::json;
31
32use crate::browser::{
33    block_on_browser_timeout, run_keys, run_press, run_scrape, run_type, run_view, run_write,
34    CaptureOpts, OneShotSession,
35};
36use crate::cli::{
37    AssertKind, BeforeUnloadAction, Cli, Commands, CompletionShell, ConfigAction, ConsoleAction,
38    CookieAction, Devtools3pAction, DialogAction, ExtensionAction, GrabFormat, HeapAction,
39    MitmAction, NetAction, PageAction, PerfAction, ScreencastAction, WebmcpAction, WorkflowAction,
40};
41use crate::envelope::{print_error_json, print_success_json};
42use crate::error::{CliError, ErrorKind};
43use crate::lifecycle::Lifecycle;
44use crate::robots::RobotsPolicy;
45
46/// Dispatch parsed CLI. Returns process exit code (0 success).
47pub fn dispatch(cli: Cli, life: &Lifecycle) -> i32 {
48    // Global `--json` only (no local shadowing on doctor/commands).
49    let json = cli.globals.json;
50    let capture = CaptureOpts {
51        console: cli.globals.capture_console,
52        network: cli.globals.capture_network,
53    };
54    // CLI --timeout > 0 wins; else XDG config timeout (rules: global timeout obligatory).
55    let timeout_secs = crate::config::resolve_global_timeout(cli.globals.timeout);
56    let step_timeout_secs =
57        crate::config::resolve_step_timeout(cli.globals.step_timeout, timeout_secs);
58    let artifacts = cli.globals.artifacts_dir.clone();
59    let category_memory = cli.globals.category_memory;
60    let category_extensions = cli.globals.category_extensions;
61    let category_third_party = cli.globals.category_third_party;
62    let category_webmcp = cli.globals.category_webmcp;
63    let experimental_screencast = cli.globals.experimental_screencast;
64    let experimental_vision = cli.globals.experimental_vision;
65    let json_steps = cli.globals.json_steps;
66    let robots =
67        match RobotsPolicy::from_flags(cli.globals.ignore_robots, cli.globals.i_accept_robots_risk)
68        {
69            Ok(p) => p,
70            Err(e) => {
71                life.finalize();
72                return emit_err(&e, json);
73            }
74        };
75
76    let code = match cli.command {
77        Commands::Doctor {
78            offline,
79            quick,
80            fix,
81        } => crate::doctor::run_doctor(crate::doctor::DoctorOptions {
82            offline,
83            quick,
84            fix,
85            json,
86        }),
87        Commands::Commands => match meta::list_commands(json) {
88            Ok(()) => 0,
89            Err(e) => emit_err(&e, json),
90        },
91        Commands::Schema { cmd, cmd_positional } => {
92            // GAP-022: positional `schema run` or `schema --cmd run`.
93            let resolved = cmd_positional.or(cmd).filter(|s| !s.trim().is_empty());
94            match resolved {
95                Some(c) => match meta::schema_for_cmd(&c, json) {
96                    Ok(()) => 0,
97                    Err(e) => emit_err(&e, json),
98                },
99                None => emit_err(
100                    &CliError::with_suggestion(
101                        ErrorKind::Usage,
102                        "schema requires a command name",
103                        "Use: browser-automation-cli schema run   or   schema --cmd run",
104                    ),
105                    json,
106                ),
107            }
108        },
109        Commands::Version => match handle_version(json) {
110            Ok(()) => 0,
111            Err(e) => emit_err(&e, json),
112        },
113        Commands::Locale => match handle_locale(json) {
114            Ok(()) => 0,
115            Err(e) => emit_err(&e, json),
116        },
117        Commands::Goto {
118            url,
119            init_script,
120            handle_before_unload,
121            navigation_timeout_ms,
122        } => {
123            match handle_goto(
124                life,
125                &url,
126                robots,
127                capture,
128                timeout_secs,
129                json,
130                init_script.as_deref(),
131                handle_before_unload,
132                navigation_timeout_ms,
133            ) {
134                Ok(()) => 0,
135                Err(e) => emit_err(&e, json),
136            }
137        }
138        Commands::View {
139            verbose,
140            path,
141            allow_empty,
142        } => {
143            match handle_view(
144                life,
145                verbose,
146                path.as_deref(),
147                allow_empty,
148                capture,
149                timeout_secs,
150                json,
151            ) {
152                Ok(()) => 0,
153                Err(e) => emit_err(&e, json),
154            }
155        }
156        Commands::Press {
157            target,
158            dblclick,
159            include_snapshot,
160        } => {
161            match handle_press(
162                life,
163                &target,
164                dblclick,
165                include_snapshot,
166                capture,
167                timeout_secs,
168                json,
169            ) {
170                Ok(()) => 0,
171                Err(e) => emit_err(&e, json),
172            }
173        }
174        Commands::ClickAt {
175            x,
176            y,
177            dblclick,
178            include_snapshot,
179        } => {
180            if !experimental_vision {
181                let e = CliError::with_suggestion(
182                    ErrorKind::Usage,
183                    "click-at requires --experimental-vision",
184                    crate::i18n::suggestion_key("vision_required", None),
185                );
186                emit_err(&e, json)
187            } else {
188                match handle_click_at(
189                    life,
190                    x,
191                    y,
192                    dblclick,
193                    include_snapshot,
194                    capture,
195                    timeout_secs,
196                    json,
197                ) {
198                    Ok(()) => 0,
199                    Err(e) => emit_err(&e, json),
200                }
201            }
202        }
203        Commands::Write {
204            target,
205            value,
206            include_snapshot,
207        } => {
208            match handle_write(
209                life,
210                &target,
211                &value,
212                include_snapshot,
213                capture,
214                timeout_secs,
215                json,
216            ) {
217                Ok(()) => 0,
218                Err(e) => emit_err(&e, json),
219            }
220        }
221        Commands::Keys {
222            key,
223            include_snapshot,
224        } => match handle_keys(life, &key, include_snapshot, capture, timeout_secs, json) {
225            Ok(()) => 0,
226            Err(e) => emit_err(&e, json),
227        },
228        Commands::Type {
229            target,
230            text,
231            clear,
232            submit,
233            focus_only,
234        } => match handle_type(
235            life,
236            target.as_deref(),
237            &text,
238            clear,
239            submit.as_deref(),
240            focus_only,
241            capture,
242            timeout_secs,
243            json,
244        ) {
245            Ok(()) => 0,
246            Err(e) => emit_err(&e, json),
247        },
248        Commands::Wait {
249            ms,
250            text,
251            selector,
252            state,
253            wait_timeout_ms,
254            include_snapshot,
255        } => {
256            match handle_wait(
257                life,
258                ms,
259                &text,
260                selector.as_deref(),
261                state.as_deref(),
262                wait_timeout_ms,
263                include_snapshot,
264                capture,
265                timeout_secs,
266                json,
267            ) {
268                Ok(()) => 0,
269                Err(e) => emit_err(&e, json),
270            }
271        }
272        Commands::Hover {
273            target,
274            include_snapshot,
275        } => match handle_hover(life, &target, include_snapshot, capture, timeout_secs, json) {
276            Ok(()) => 0,
277            Err(e) => emit_err(&e, json),
278        },
279        Commands::Drag {
280            from,
281            to,
282            include_snapshot,
283        } => {
284            match handle_drag(
285                life,
286                &from,
287                &to,
288                include_snapshot,
289                capture,
290                timeout_secs,
291                json,
292            ) {
293                Ok(()) => 0,
294                Err(e) => emit_err(&e, json),
295            }
296        }
297        Commands::FillForm {
298            fields_json,
299            include_snapshot,
300        } => {
301            match handle_fill_form(
302                life,
303                &fields_json,
304                include_snapshot,
305                capture,
306                timeout_secs,
307                json,
308            ) {
309                Ok(()) => 0,
310                Err(e) => emit_err(&e, json),
311            }
312        }
313        Commands::Upload {
314            target,
315            path,
316            include_snapshot,
317        } => {
318            match handle_upload(
319                life,
320                &target,
321                &path,
322                include_snapshot,
323                capture,
324                timeout_secs,
325                json,
326            ) {
327                Ok(()) => 0,
328                Err(e) => emit_err(&e, json),
329            }
330        }
331        Commands::Back => match handle_history(life, "back", capture, timeout_secs, json) {
332            Ok(()) => 0,
333            Err(e) => emit_err(&e, json),
334        },
335        Commands::Forward => match handle_history(life, "forward", capture, timeout_secs, json) {
336            Ok(()) => 0,
337            Err(e) => emit_err(&e, json),
338        },
339        Commands::Reload {
340            ignore_cache,
341            init_script,
342            handle_before_unload,
343        } => {
344            match handle_reload(
345                life,
346                ignore_cache,
347                init_script.as_deref(),
348                handle_before_unload,
349                capture,
350                timeout_secs,
351                json,
352            ) {
353                Ok(()) => 0,
354                Err(e) => emit_err(&e, json),
355            }
356        }
357        Commands::Eval {
358            expression,
359            args,
360            dialog_action,
361            file_path,
362            service_worker_id,
363        } => {
364            match handle_eval(
365                life,
366                &expression,
367                args.as_deref(),
368                dialog_action.as_deref(),
369                file_path.as_deref(),
370                service_worker_id.as_deref(),
371                capture,
372                timeout_secs,
373                json,
374            ) {
375                Ok(()) => 0,
376                Err(e) => emit_err(&e, json),
377            }
378        }
379        Commands::Grab {
380            path,
381            format,
382            full_page,
383            quality,
384            element,
385        } => match handle_grab(
386            life,
387            path.as_deref(),
388            format,
389            full_page,
390            quality,
391            element.as_deref(),
392            artifacts.as_deref(),
393            capture,
394            timeout_secs,
395            json,
396        ) {
397            Ok(()) => 0,
398            Err(e) => emit_err(&e, json),
399        },
400        Commands::PrintPdf { path, url } => {
401            match handle_print_pdf(
402                life,
403                path.as_deref(),
404                url.as_deref(),
405                robots,
406                capture,
407                timeout_secs,
408                json,
409            ) {
410                Ok(()) => 0,
411                Err(e) => emit_err(&e, json),
412            }
413        }
414        Commands::Monitor { action } => match handle_monitor(action, robots, timeout_secs, json) {
415            Ok(()) => 0,
416            Err(e) => emit_err(&e, json),
417        },
418        Commands::Run { script } => {
419            let flags = run::RunFlags::from_globals(
420                experimental_vision,
421                experimental_screencast,
422                category_memory,
423                category_extensions,
424                category_third_party,
425                category_webmcp,
426                json_steps,
427                step_timeout_secs,
428            );
429            match handle_run(life, &script, robots, capture, timeout_secs, json, flags) {
430                Ok(()) => 0,
431                Err(e) => emit_err(&e, json),
432            }
433        }
434        Commands::Exec { args } => {
435            // trailing_var_arg can capture global flags placed after `exec`;
436            // peel them so agents can write: exec goto URL --json
437            let (args, json_from_trail) = peel_trailing_globals(&args);
438            let json = json || json_from_trail;
439            let flags = run::RunFlags::from_globals(
440                experimental_vision,
441                experimental_screencast,
442                category_memory,
443                category_extensions,
444                category_third_party,
445                category_webmcp,
446                json_steps,
447                step_timeout_secs,
448            );
449            match handle_exec(life, &args, robots, capture, timeout_secs, json, flags) {
450                Ok(()) => 0,
451                Err(e) => emit_err(&e, json),
452            }
453        }
454        Commands::Extract {
455            target,
456            attr,
457            llm,
458            question,
459            schema_json,
460        } => {
461            match handle_extract(
462                life,
463                &target,
464                attr.as_deref(),
465                llm,
466                question.as_deref(),
467                schema_json.as_deref(),
468                capture,
469                timeout_secs,
470                json,
471            ) {
472                Ok(()) => 0,
473                Err(e) => emit_err(&e, json),
474            }
475        }
476        Commands::Text { target } => {
477            match handle_text(life, &target, capture, timeout_secs, json) {
478                Ok(()) => 0,
479                Err(e) => emit_err(&e, json),
480            }
481        }
482        Commands::Scroll {
483            target,
484            delta_x,
485            delta_y,
486        } => match handle_scroll(
487            life,
488            target.as_deref(),
489            delta_x,
490            delta_y,
491            capture,
492            timeout_secs,
493            json,
494        ) {
495            Ok(()) => 0,
496            Err(e) => emit_err(&e, json),
497        },
498        Commands::Cookie { action } => {
499            match handle_cookie(life, action, capture, timeout_secs, json) {
500                Ok(()) => 0,
501                Err(e) => emit_err(&e, json),
502            }
503        }
504        Commands::Attr { target, name } => {
505            match handle_attr(life, &target, &name, capture, timeout_secs, json) {
506                Ok(()) => 0,
507                Err(e) => emit_err(&e, json),
508            }
509        }
510        Commands::Assert { kind } => match handle_assert(life, kind, capture, timeout_secs, json) {
511            Ok(()) => 0,
512            Err(e) => emit_err(&e, json),
513        },
514        Commands::Console { action } => {
515            match handle_console(life, action, capture, timeout_secs, json) {
516                Ok(()) => 0,
517                Err(e) => emit_err(&e, json),
518            }
519        }
520        Commands::Net { action } => match handle_net(life, action, capture, timeout_secs, json) {
521            Ok(()) => 0,
522            Err(e) => emit_err(&e, json),
523        },
524        Commands::Page { action } => match handle_page(life, action, capture, timeout_secs, json) {
525            Ok(()) => 0,
526            Err(e) => emit_err(&e, json),
527        },
528        Commands::Dialog { action } => {
529            match handle_dialog(life, action, capture, timeout_secs, json) {
530                Ok(()) => 0,
531                Err(e) => emit_err(&e, json),
532            }
533        }
534        Commands::Scrape {
535            url,
536            format,
537            engine,
538            only_main_content,
539            webhook_url,
540        } => {
541            match handle_scrape(
542                life,
543                &url,
544                robots,
545                capture,
546                timeout_secs,
547                json,
548                &format,
549                &engine,
550                only_main_content,
551                webhook_url.as_deref(),
552            ) {
553                Ok(()) => 0,
554                Err(e) => emit_err(&e, json),
555            }
556        }
557        Commands::BatchScrape {
558            urls_file,
559            format,
560            concurrency,
561            engine,
562        } => match handle_batch_scrape(
563            life,
564            &urls_file,
565            robots,
566            capture,
567            timeout_secs,
568            &format,
569            concurrency,
570            &engine,
571            json,
572        ) {
573            Ok(()) => 0,
574            Err(e) => emit_err(&e, json),
575        },
576        Commands::Crawl {
577            url,
578            limit,
579            max_depth,
580            format,
581            same_host,
582            engine,
583        } => match handle_crawl(
584            life,
585            &url,
586            robots,
587            capture,
588            timeout_secs,
589            limit,
590            max_depth,
591            &format,
592            same_host,
593            &engine,
594            json,
595        ) {
596            Ok(()) => 0,
597            Err(e) => emit_err(&e, json),
598        },
599        Commands::Map {
600            url,
601            limit,
602            max_depth,
603        } => match handle_map(&url, robots, limit, max_depth, json) {
604            Ok(()) => 0,
605            Err(e) => emit_err(&e, json),
606        },
607        Commands::Search { query, limit } => match handle_search(&query, robots, limit, json) {
608            Ok(()) => 0,
609            Err(e) => emit_err(&e, json),
610        },
611        Commands::Parse { path, redact_pii } => match handle_parse(&path, redact_pii, json) {
612            Ok(()) => 0,
613            Err(e) => emit_err(&e, json),
614        },
615        Commands::Qr { action } => match handle_qr(action, json) {
616            Ok(()) => 0,
617            Err(e) => emit_err(&e, json),
618        },
619        Commands::FindPaths {
620            pattern,
621            paths,
622            extension,
623            hidden,
624            no_ignore,
625            max_depth,
626            entry_type,
627            limit,
628            glob,
629        } => match handle_find_paths(
630            pattern.as_deref(),
631            &paths,
632            extension.as_deref(),
633            hidden,
634            no_ignore,
635            max_depth,
636            entry_type.as_deref(),
637            limit,
638            glob.as_deref(),
639            json,
640        ) {
641            Ok(()) => 0,
642            Err(e) => emit_err(&e, json),
643        },
644        Commands::SgScan { paths, limit } => match handle_sg_scan(&paths, limit, json) {
645            Ok(()) => 0,
646            Err(e) => emit_err(&e, json),
647        },
648        Commands::SgRewrite { paths, apply } => match handle_sg_rewrite(&paths, apply, json) {
649            Ok(()) => 0,
650            Err(e) => emit_err(&e, json),
651        },
652        Commands::SheetWrite { input, out, sheet } => {
653            match handle_sheet_write(&input, &out, &sheet, json) {
654                Ok(()) => 0,
655                Err(e) => emit_err(&e, json),
656            }
657        }
658        Commands::Mitm { action } => match handle_mitm(action, json) {
659            Ok(()) => 0,
660            Err(e) => emit_err(&e, json),
661        },
662        Commands::Workflow { action } => match handle_workflow(action, json) {
663            Ok(()) => 0,
664            Err(e) => emit_err(&e, json),
665        },
666        Commands::Config { action } => match handle_config(action, json) {
667            Ok(()) => 0,
668            Err(e) => emit_err(&e, json),
669        },
670        Commands::Emulate {
671            user_agent,
672            locale,
673            timezone,
674            offline,
675            latitude,
676            longitude,
677            media,
678            network_conditions,
679            cpu_throttling_rate,
680            color_scheme,
681            extra_headers,
682            viewport,
683        } => match handle_emulate(
684            life,
685            user_agent.as_deref(),
686            locale.as_deref(),
687            timezone.as_deref(),
688            offline,
689            latitude,
690            longitude,
691            media.as_deref(),
692            network_conditions.as_deref(),
693            cpu_throttling_rate,
694            color_scheme.as_deref(),
695            extra_headers.as_deref(),
696            viewport.as_deref(),
697            capture,
698            timeout_secs,
699            json,
700        ) {
701            Ok(()) => 0,
702            Err(e) => emit_err(&e, json),
703        },
704        Commands::Resize {
705            width,
706            height,
707            scale,
708            mobile,
709        } => match handle_resize(
710            life,
711            width,
712            height,
713            scale,
714            mobile,
715            capture,
716            timeout_secs,
717            json,
718        ) {
719            Ok(()) => 0,
720            Err(e) => emit_err(&e, json),
721        },
722        Commands::Perf { action } => match handle_perf(life, action, capture, timeout_secs, json) {
723            Ok(()) => 0,
724            Err(e) => emit_err(&e, json),
725        },
726        Commands::Lighthouse {
727            url,
728            out_dir,
729            device,
730            mode,
731            lighthouse_path,
732        } => match handle_lighthouse(
733            &url,
734            out_dir.as_deref(),
735            &device,
736            &mode,
737            lighthouse_path.as_deref(),
738            json,
739        ) {
740            Ok(()) => 0,
741            Err(e) => emit_err(&e, json),
742        },
743        Commands::Screencast { action } => {
744            if !experimental_screencast {
745                emit_err(
746                    &CliError::with_suggestion(
747                        ErrorKind::Usage,
748                        "screencast requires --experimental-screencast",
749                        "Pass --experimental-screencast on the same invocation",
750                    ),
751                    json,
752                )
753            } else {
754                match handle_screencast(life, action, capture, timeout_secs, json) {
755                    Ok(()) => 0,
756                    Err(e) => emit_err(&e, json),
757                }
758            }
759        }
760        Commands::Heap { action } => {
761            let deep = !matches!(
762                &action,
763                HeapAction::Take { .. } | HeapAction::Summary { .. } | HeapAction::Close { .. }
764            );
765            if deep && !category_memory {
766                emit_err(
767                    &CliError::with_suggestion(
768                        ErrorKind::Usage,
769                        "deep heap tools require --category-memory",
770                        "Pass --category-memory (heap take/summary/close work without deep graph ops)",
771                    ),
772                    json,
773                )
774            } else {
775                match handle_heap(life, action, capture, timeout_secs, json) {
776                    Ok(()) => 0,
777                    Err(e) => emit_err(&e, json),
778                }
779            }
780        }
781        Commands::Extension { action } => {
782            if !category_extensions {
783                emit_err(
784                    &CliError::with_suggestion(
785                        ErrorKind::Usage,
786                        "extension tools require --category-extensions",
787                        "Pass --category-extensions on the same invocation",
788                    ),
789                    json,
790                )
791            } else {
792                match handle_extension(life, action, capture, timeout_secs, json) {
793                    Ok(()) => 0,
794                    Err(e) => emit_err(&e, json),
795                }
796            }
797        }
798        Commands::Devtools3p { action } => {
799            if !category_third_party {
800                emit_err(
801                    &CliError::with_suggestion(
802                        ErrorKind::Usage,
803                        "devtools3p requires --category-third-party",
804                        "Pass --category-third-party on the same invocation",
805                    ),
806                    json,
807                )
808            } else {
809                match handle_devtools3p(life, action, capture, timeout_secs, json) {
810                    Ok(()) => 0,
811                    Err(e) => emit_err(&e, json),
812                }
813            }
814        }
815        Commands::Webmcp { action } => {
816            if !category_webmcp {
817                emit_err(
818                    &CliError::with_suggestion(
819                        ErrorKind::Usage,
820                        "webmcp requires --category-webmcp",
821                        "Pass --category-webmcp on the same invocation",
822                    ),
823                    json,
824                )
825            } else {
826                match handle_webmcp(life, action, capture, timeout_secs, json) {
827                    Ok(()) => 0,
828                    Err(e) => emit_err(&e, json),
829                }
830            }
831        }
832        Commands::Completions { shell } => match handle_completions(shell) {
833            Ok(()) => 0,
834            Err(e) => emit_err(&e, json),
835        },
836        Commands::Man { out } => match handle_man(out.as_deref()) {
837            Ok(()) => 0,
838            Err(e) => emit_err(&e, json),
839        },
840    };
841
842    life.finalize();
843    code
844}
845
846fn handle_version(json: bool) -> Result<(), CliError> {
847    let data = crate::build_identity();
848    emit_ok(data, json, |d| {
849        let ver = d.get("version").and_then(|v| v.as_str()).unwrap_or("");
850        let sha = d.get("git_sha").and_then(|v| v.as_str()).unwrap_or("unknown");
851        if sha != "unknown" {
852            crate::output::writeln_stdout(format!("{ver} ({sha})"))
853        } else {
854            crate::output::writeln_stdout(ver)
855        }
856    })
857}
858
859fn handle_locale(json: bool) -> Result<(), CliError> {
860    let data = crate::i18n::locale_diagnostics();
861    emit_ok(data, json, |d| {
862        let resolved = d.get("resolved").and_then(|v| v.as_str()).unwrap_or("en");
863        let source = d.get("source").and_then(|v| v.as_str()).unwrap_or("default");
864        let system = d
865            .get("system_locale")
866            .and_then(|v| v.as_str())
867            .unwrap_or("-");
868        // Human labels follow resolved locale; values stay BCP47/English keys.
869        let label_r = crate::i18n::Mensagem::LocaleResolved.texto(crate::i18n::effective_idioma());
870        let label_s = crate::i18n::Mensagem::LocaleSource.texto(crate::i18n::effective_idioma());
871        crate::output::writeln_stdout(format!(
872            "{label_r}: {resolved}\n{label_s}: {source}\nsystem: {system}"
873        ))
874    })
875}
876
877fn emit_ok<F>(data: serde_json::Value, json: bool, text: F) -> Result<(), CliError>
878where
879    F: FnOnce(&serde_json::Value) -> Result<(), CliError>,
880{
881    if json {
882        print_success_json(data)?;
883    } else {
884        text(&data)?;
885    }
886    crate::output::flush_stdout()?;
887    Ok(())
888}
889
890fn emit_err(err: &CliError, json: bool) -> i32 {
891    let localized = crate::i18n::localize_error_suggestion(err);
892    if json {
893        match print_error_json(&localized) {
894            Ok(()) => {}
895            Err(e) if e.kind() == ErrorKind::BrokenPipe => return 141,
896            Err(_) => {}
897        }
898    } else {
899        let _ = crate::output::writeln_stderr(format!("error: {localized}"));
900        if let Some(s) = localized.suggestion() {
901            let _ = crate::output::writeln_stderr(format!("suggestion: {s}"));
902        }
903    }
904    let _ = crate::output::flush_stdout();
905    localized.exit_code() as i32
906}
907
908/// Peel known global flags mistakenly captured by `exec` trailing_var_arg.
909fn peel_trailing_globals(args: &[String]) -> (Vec<String>, bool) {
910    let mut json = false;
911    let mut out = Vec::with_capacity(args.len());
912    for a in args {
913        match a.as_str() {
914            "--json" => json = true,
915            other => out.push(other.to_string()),
916        }
917    }
918    (out, json)
919}
920
921#[allow(clippy::too_many_arguments)]
922fn handle_goto(
923    life: &Lifecycle,
924    url: &str,
925    robots: RobotsPolicy,
926    capture: CaptureOpts,
927    timeout_secs: u64,
928    json: bool,
929    init_script: Option<&str>,
930    handle_before_unload: Option<BeforeUnloadAction>,
931    navigation_timeout_ms: Option<u64>,
932) -> Result<(), CliError> {
933    let init = init_script.map(|s| s.to_string());
934    let url_owned = url.to_string();
935    let beforeunload = handle_before_unload.map(|a| a.as_str());
936    let data = block_on_browser_timeout(
937        crate::browser::run_goto_with_options(
938            life,
939            &url_owned,
940            capture,
941            robots,
942            init.as_deref(),
943            beforeunload,
944            navigation_timeout_ms,
945        ),
946        timeout_secs,
947    )?;
948    emit_ok(data, json, |d| {
949        let u = d.get("url").and_then(|v| v.as_str()).unwrap_or(url);
950        let t = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
951        crate::output::writeln_stdout(&format!("ok url={u} title={t}"))?;
952        Ok(())
953    })
954}
955
956fn handle_view(
957    life: &Lifecycle,
958    verbose: bool,
959    path: Option<&Path>,
960    allow_empty: bool,
961    capture: CaptureOpts,
962    timeout_secs: u64,
963    json: bool,
964) -> Result<(), CliError> {
965    let path_owned = path.map(|p| p.to_path_buf());
966    let data = block_on_browser_timeout(
967        async move {
968            let mut data = run_view(life, verbose, capture).await?;
969            let tree = data.get("tree").and_then(|v| v.as_str()).unwrap_or("");
970            let empty = tree.contains("empty page")
971                || data
972                    .get("url")
973                    .and_then(|v| v.as_str())
974                    .is_some_and(|u| u == "about:blank");
975            if empty && !allow_empty {
976                return Err(CliError::with_suggestion(
977                    ErrorKind::Usage,
978                    "view returned empty page (no content); refuse silent success",
979                    "Navigate with goto first, or pass --allow-empty for blank snapshots",
980                ));
981            }
982            if let Some(obj) = data.as_object_mut() {
983                obj.insert("empty".into(), serde_json::json!(empty));
984            }
985            if let Some(p) = path_owned.as_ref() {
986                // PAR-83: tree dump off async/block_on worker (may be multi-MB).
987                let tree = data
988                    .get("tree")
989                    .and_then(|v| v.as_str())
990                    .unwrap_or("")
991                    .to_string();
992                crate::concurrency::write_bytes_blocking(p.clone(), tree.into_bytes())
993                    .await
994                    .map_err(|e| {
995                        CliError::new(ErrorKind::Io, format!("view --path write: {e}"))
996                    })?;
997                if let Some(obj) = data.as_object_mut() {
998                    obj.insert(
999                        "path".to_string(),
1000                        serde_json::Value::String(p.display().to_string()),
1001                    );
1002                }
1003            }
1004            Ok(data)
1005        },
1006        timeout_secs,
1007    )?;
1008    emit_ok(data, json, |d| {
1009        if let Some(p) = d.get("path").and_then(|v| v.as_str()) {
1010            crate::output::writeln_stdout(&format!("ok view path={p}"))?;
1011        } else if let Some(tree) = d.get("tree").and_then(|v| v.as_str()) {
1012            crate::output::write_stdout(tree.as_bytes())?;
1013            if !tree.ends_with('\n') {
1014                crate::output::writeln_stdout("")?;
1015            }
1016        } else {
1017            crate::output::writeln_stdout("ok view")?;
1018        }
1019        Ok(())
1020    })
1021}
1022
1023fn handle_press(
1024    life: &Lifecycle,
1025    target: &str,
1026    dblclick: bool,
1027    include_snapshot: bool,
1028    capture: CaptureOpts,
1029    timeout_secs: u64,
1030    json: bool,
1031) -> Result<(), CliError> {
1032    let data = block_on_browser_timeout(
1033        run_press(life, target, dblclick, include_snapshot, capture),
1034        timeout_secs,
1035    )?;
1036    emit_ok(data, json, |_| {
1037        crate::output::writeln_stdout(&format!("ok pressed={target} dblclick={dblclick}"))?;
1038        Ok(())
1039    })
1040}
1041
1042fn handle_write(
1043    life: &Lifecycle,
1044    target: &str,
1045    value: &str,
1046    include_snapshot: bool,
1047    capture: CaptureOpts,
1048    timeout_secs: u64,
1049    json: bool,
1050) -> Result<(), CliError> {
1051    let data = block_on_browser_timeout(
1052        run_write(life, target, value, include_snapshot, capture),
1053        timeout_secs,
1054    )?;
1055    emit_ok(data, json, |_| {
1056        crate::output::writeln_stdout(&format!("ok written={target} len={}", value.len()))?;
1057        Ok(())
1058    })
1059}
1060
1061#[allow(clippy::too_many_arguments)]
1062fn handle_click_at(
1063    life: &Lifecycle,
1064    x: f64,
1065    y: f64,
1066    dblclick: bool,
1067    include_snapshot: bool,
1068    capture: CaptureOpts,
1069    timeout_secs: u64,
1070    json: bool,
1071) -> Result<(), CliError> {
1072    let data = block_on_browser_timeout(
1073        async move {
1074            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1075            life.record_chrome(session.chrome_pid());
1076            let _ = session
1077                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1078                .await?;
1079            let r = session.click_at(x, y, dblclick, include_snapshot).await;
1080            let close = session.shutdown().await;
1081            life.clear_chrome();
1082            close?;
1083            r
1084        },
1085        timeout_secs,
1086    )?;
1087    emit_ok(data, json, |_| {
1088        crate::output::writeln_stdout(&format!("ok click-at x={x} y={y} dbl={dblclick}"))
1089    })
1090}
1091
1092fn handle_keys(
1093    life: &Lifecycle,
1094    key: &str,
1095    include_snapshot: bool,
1096    capture: CaptureOpts,
1097    timeout_secs: u64,
1098    json: bool,
1099) -> Result<(), CliError> {
1100    let data =
1101        block_on_browser_timeout(run_keys(life, key, include_snapshot, capture), timeout_secs)?;
1102    emit_ok(data, json, |_|  crate::output::writeln_stdout(&format!("ok key={key}")))
1103}
1104
1105#[allow(clippy::too_many_arguments)]
1106fn handle_type(
1107    life: &Lifecycle,
1108    target: Option<&str>,
1109    text: &str,
1110    clear: bool,
1111    submit: Option<&str>,
1112    focus_only: bool,
1113    capture: CaptureOpts,
1114    timeout_secs: u64,
1115    json: bool,
1116) -> Result<(), CliError> {
1117    if target.is_none() && !focus_only {
1118        return Err(CliError::with_suggestion(
1119            ErrorKind::Usage,
1120            "type requires a target or --focus-only",
1121            "Pass TARGET or --focus-only for the focused element",
1122        ));
1123    }
1124    let data = block_on_browser_timeout(
1125        run_type(life, target, text, clear, submit, focus_only, capture),
1126        timeout_secs,
1127    )?;
1128    let label = target.unwrap_or("(focused)");
1129    emit_ok(data, json, |_| {
1130        crate::output::writeln_stdout(&format!("ok typed={label} len={} clear={clear} submit={submit:?} focus_only={focus_only}",
1131            text.len()))?;
1132            Ok(())
1133    })
1134}
1135
1136#[allow(clippy::too_many_arguments)]
1137fn handle_wait(
1138    life: &Lifecycle,
1139    ms: u64,
1140    texts: &[String],
1141    selector: Option<&str>,
1142    state: Option<&str>,
1143    wait_timeout_ms: Option<u64>,
1144    include_snapshot: bool,
1145    capture: CaptureOpts,
1146    timeout_secs: u64,
1147    json: bool,
1148) -> Result<(), CliError> {
1149    let texts_owned = texts.to_vec();
1150    let selector_owned = selector.map(|s| s.to_string());
1151    let state_owned = state.map(|s| s.to_string());
1152    // Prefer explicit wait_timeout_ms for text/selector; fall back to ms
1153    let wait_ms = wait_timeout_ms.or(if ms == 0 { None } else { Some(ms) });
1154    let data = block_on_browser_timeout(
1155        async move {
1156            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1157            life.record_chrome(session.chrome_pid());
1158            // OR semantics: try each text until one succeeds (tool-ref wait_for array)
1159            let r = if texts_owned.is_empty() {
1160                session
1161                    .wait_for(
1162                        wait_ms,
1163                        None,
1164                        selector_owned.as_deref(),
1165                        state_owned.as_deref(),
1166                        include_snapshot,
1167                    )
1168                    .await
1169            } else {
1170                let mut last_err = None;
1171                let mut ok = None;
1172                for t in &texts_owned {
1173                    match session
1174                        .wait_for(
1175                            wait_ms,
1176                            Some(t.as_str()),
1177                            selector_owned.as_deref(),
1178                            state_owned.as_deref(),
1179                            false,
1180                        )
1181                        .await
1182                    {
1183                        Ok(v) => {
1184                            ok = Some(v);
1185                            break;
1186                        }
1187                        Err(e) => last_err = Some(e),
1188                    }
1189                }
1190                match ok {
1191                    Some(v) => {
1192                        if include_snapshot {
1193                            Ok(session
1194                                .attach_snapshot_if(true, v)
1195                                .await
1196                                .unwrap_or_else(|_| serde_json::json!({"waited": true})))
1197                        } else {
1198                            Ok(v)
1199                        }
1200                    }
1201                    None => Err(last_err.unwrap_or_else(|| {
1202                        CliError::new(ErrorKind::Browser, "wait: no text matched")
1203                    })),
1204                }
1205            };
1206            let close = session.shutdown().await;
1207            life.clear_chrome();
1208            close?;
1209            r
1210        },
1211        timeout_secs,
1212    )?;
1213    emit_ok(data, json, |d| {
1214        crate::output::writeln_stdout(&format!("ok wait {}", d))?;
1215        Ok(())
1216    })
1217}
1218
1219fn with_session_blank<F, Fut>(
1220    life: &Lifecycle,
1221    capture: CaptureOpts,
1222    timeout_secs: u64,
1223    f: F,
1224) -> Result<serde_json::Value, CliError>
1225where
1226    F: FnOnce(OneShotSession) -> Fut + Send + 'static,
1227    Fut: std::future::Future<Output = Result<(OneShotSession, serde_json::Value), CliError>> + Send,
1228{
1229    block_on_browser_timeout(
1230        async move {
1231            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1232            life.record_chrome(session.chrome_pid());
1233            let _ = session
1234                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1235                .await?;
1236            let (session, value) = f(session).await?;
1237            let close = session.shutdown().await;
1238            life.clear_chrome();
1239            close?;
1240            Ok(value)
1241        },
1242        timeout_secs,
1243    )
1244}
1245
1246fn handle_hover(
1247    life: &Lifecycle,
1248    target: &str,
1249    include_snapshot: bool,
1250    capture: CaptureOpts,
1251    timeout_secs: u64,
1252    json: bool,
1253) -> Result<(), CliError> {
1254    let target = target.to_string();
1255    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1256        let v = session.hover(&target, include_snapshot).await?;
1257        Ok((session, v))
1258    })?;
1259    emit_ok(data, json, |_|  crate::output::writeln_stdout("ok hover"))
1260}
1261
1262fn handle_drag(
1263    life: &Lifecycle,
1264    from: &str,
1265    to: &str,
1266    include_snapshot: bool,
1267    capture: CaptureOpts,
1268    timeout_secs: u64,
1269    json: bool,
1270) -> Result<(), CliError> {
1271    let from = from.to_string();
1272    let to = to.to_string();
1273    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1274        let v = session.drag(&from, &to, include_snapshot).await?;
1275        Ok((session, v))
1276    })?;
1277    emit_ok(data, json, |_|  crate::output::writeln_stdout("ok drag"))
1278}
1279
1280fn handle_fill_form(
1281    life: &Lifecycle,
1282    fields_json: &str,
1283    include_snapshot: bool,
1284    capture: CaptureOpts,
1285    timeout_secs: u64,
1286    json: bool,
1287) -> Result<(), CliError> {
1288    let parsed: serde_json::Value =
1289        crate::json_util::parse_cli_json_value(fields_json, "fill-form --fields-json").map_err(
1290            |e| {
1291                CliError::with_suggestion(
1292                    ErrorKind::Usage,
1293                    format!("fill-form --fields-json parse error: {}", e.message()),
1294                    r##"Pass JSON array: [{"target":"input","value":"x"}] or [{"uid":"@e1","value":"x"}]"##,
1295                )
1296            },
1297        )?;
1298    let arr = parsed.as_array().ok_or_else(|| {
1299        CliError::with_suggestion(
1300            ErrorKind::Usage,
1301            "fill-form --fields-json must be a JSON array",
1302            r##"[{"target":"input","value":"x"}]"##,
1303        )
1304    })?;
1305    let mut fields = Vec::new();
1306    for item in arr {
1307        let target = item
1308            .get("target")
1309            .or_else(|| item.get("uid"))
1310            .or_else(|| item.get("selector"))
1311            .or_else(|| item.get("ref"))
1312            .and_then(|v| v.as_str())
1313            .ok_or_else(|| {
1314                CliError::new(
1315                    ErrorKind::Usage,
1316                    "fill-form field missing target/uid/selector/ref",
1317                )
1318            })?
1319            .to_string();
1320        // Normalize bare eN → @eN
1321        let target = if target.starts_with('e')
1322            && target.len() > 1
1323            && target[1..].chars().all(|c| c.is_ascii_digit())
1324        {
1325            format!("@{target}")
1326        } else {
1327            target
1328        };
1329        let value = item
1330            .get("value")
1331            .or_else(|| item.get("text"))
1332            .and_then(|v| v.as_str())
1333            .ok_or_else(|| CliError::new(ErrorKind::Usage, "fill-form field missing value"))?
1334            .to_string();
1335        fields.push((target, value));
1336    }
1337    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1338        let v = session.fill_form(&fields, include_snapshot).await?;
1339        Ok((session, v))
1340    })?;
1341    emit_ok(data, json, |d| {
1342        let n = d.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
1343        crate::output::writeln_stdout(&format!("ok fill-form count={n}"))?;
1344        Ok(())
1345    })
1346}
1347
1348fn handle_upload(
1349    life: &Lifecycle,
1350    target: &str,
1351    path: &Path,
1352    include_snapshot: bool,
1353    capture: CaptureOpts,
1354    timeout_secs: u64,
1355    json: bool,
1356) -> Result<(), CliError> {
1357    let target = target.to_string();
1358    let path = path.to_path_buf();
1359    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1360        let v = session.upload(&target, &path, include_snapshot).await?;
1361        Ok((session, v))
1362    })?;
1363    emit_ok(data, json, |d| {
1364        let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1365        crate::output::writeln_stdout(&format!("ok upload path={p}"))?;
1366        Ok(())
1367    })
1368}
1369
1370fn handle_history(
1371    life: &Lifecycle,
1372    direction: &str,
1373    capture: CaptureOpts,
1374    timeout_secs: u64,
1375    json: bool,
1376) -> Result<(), CliError> {
1377    let direction = direction.to_string();
1378    let direction_label = direction.clone();
1379    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1380        let v = match direction.as_str() {
1381            "back" => session.back().await?,
1382            "forward" => session.forward().await?,
1383            other => {
1384                return Err(CliError::new(
1385                    ErrorKind::Usage,
1386                    format!("unknown history direction: {other}"),
1387                ))
1388            }
1389        };
1390        Ok((session, v))
1391    })?;
1392    emit_ok(data, json, |_|  crate::output::writeln_stdout(&format!("ok {direction_label}")))
1393}
1394
1395fn handle_reload(
1396    life: &Lifecycle,
1397    ignore_cache: bool,
1398    init_script: Option<&str>,
1399    handle_before_unload: Option<BeforeUnloadAction>,
1400    capture: CaptureOpts,
1401    timeout_secs: u64,
1402    json: bool,
1403) -> Result<(), CliError> {
1404    // Single-shot reload without a prior URL cannot apply init_script meaningfully.
1405    // Require multi-step `run` (session already on a document) OR reject blank-only.
1406    if init_script.is_some() {
1407        return Err(CliError::with_suggestion(
1408            ErrorKind::Usage,
1409            "reload --init-script requires multi-step `run` with a prior goto in the same process",
1410            "Use: browser-automation-cli run --script steps.jsonl  (goto then reload --init-script …)",
1411        ));
1412    }
1413    let beforeunload = handle_before_unload.map(|a| a.as_str().to_string());
1414    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
1415        // GAP-A009/A005/A006: CDP Page.reload + dialog pump; no preventDefault inject.
1416        let v = session
1417            .reload_with_options(ignore_cache, None, beforeunload.as_deref())
1418            .await?;
1419        Ok((session, v))
1420    })?;
1421    emit_ok(data, json, |_| {
1422        crate::output::writeln_stdout(&format!("ok reload ignore_cache={ignore_cache}"))
1423    })
1424}
1425
1426#[allow(clippy::too_many_arguments)]
1427fn handle_eval(
1428    life: &Lifecycle,
1429    expression: &str,
1430    args: Option<&str>,
1431    dialog_action: Option<&str>,
1432    file_path: Option<&Path>,
1433    service_worker_id: Option<&str>,
1434    capture: CaptureOpts,
1435    timeout_secs: u64,
1436    json: bool,
1437) -> Result<(), CliError> {
1438    let expr = expression.to_string();
1439    let args_owned = args.map(|s| s.to_string());
1440    let dialog_owned = dialog_action.map(|s| s.to_string());
1441    let path_owned = file_path.map(|p| p.to_path_buf());
1442    let sw_owned = service_worker_id.map(|s| s.to_string());
1443    let data = block_on_browser_timeout(
1444        async move {
1445            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1446            life.with_ledger_mut(|ledger| {
1447                ledger.chrome_launched = true;
1448                ledger.chrome_pid = session.chrome_pid();
1449                if let Some(dir) = session.temp_user_data_dir() {
1450                    ledger.profile_dir = Some(dir);
1451                }
1452            });
1453            let r = if let Some(ref sw) = sw_owned {
1454                session.eval_service_worker(sw, &expr).await
1455            } else {
1456                let _ = session
1457                    .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1458                    .await?;
1459                session
1460                    .eval(
1461                        &expr,
1462                        args_owned.as_deref(),
1463                        dialog_owned.as_deref(),
1464                        path_owned.as_deref(),
1465                    )
1466                    .await
1467            };
1468            let close = session.shutdown().await;
1469            life.clear_chrome();
1470            close?;
1471            r
1472        },
1473        timeout_secs,
1474    )?;
1475    emit_ok(data, json, |d| {
1476        crate::output::writeln_stdout(&format!("ok eval={}",
1477            d.get("result").unwrap_or(&serde_json::Value::Null)))?;
1478            Ok(())
1479    })
1480}
1481
1482#[allow(clippy::too_many_arguments)]
1483fn handle_grab(
1484    life: &Lifecycle,
1485    path: Option<&Path>,
1486    format: GrabFormat,
1487    full_page: bool,
1488    quality: Option<i32>,
1489    element: Option<&str>,
1490    artifacts: Option<&Path>,
1491    capture: CaptureOpts,
1492    timeout_secs: u64,
1493    json: bool,
1494) -> Result<(), CliError> {
1495    let fmt = match format {
1496        GrabFormat::Png => "png",
1497        GrabFormat::Jpeg => "jpeg",
1498        GrabFormat::Webp => "webp",
1499    };
1500    if let Some(a) = artifacts {
1501        std::fs::create_dir_all(a)
1502            .map_err(|e| CliError::new(ErrorKind::Io, format!("artifacts-dir mkdir: {e}")))?;
1503    }
1504    let path_owned = path.map(|p| p.to_path_buf()).or_else(|| {
1505        artifacts.map(|a| {
1506            a.join(format!(
1507                "grab-{}.{}",
1508                std::time::SystemTime::now()
1509                    .duration_since(std::time::UNIX_EPOCH)
1510                    .map(|d| d.as_millis())
1511                    .unwrap_or(0),
1512                fmt
1513            ))
1514        })
1515    });
1516    if let Some(ref p) = path_owned {
1517        if let Some(parent) = p.parent() {
1518            if !parent.as_os_str().is_empty() {
1519                std::fs::create_dir_all(parent)
1520                    .map_err(|e| CliError::new(ErrorKind::Io, format!("grab path mkdir: {e}")))?;
1521            }
1522        }
1523    }
1524    let element_owned = element.map(|s| s.to_string());
1525    let data = block_on_browser_timeout(
1526        async move {
1527            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1528            life.record_chrome(session.chrome_pid());
1529            let _ = session
1530                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1531                .await?;
1532            let r = session
1533                .grab(
1534                    path_owned.as_deref(),
1535                    fmt,
1536                    full_page,
1537                    quality,
1538                    element_owned.as_deref(),
1539                )
1540                .await;
1541            let close = session.shutdown().await;
1542            life.clear_chrome();
1543            close?;
1544            r
1545        },
1546        timeout_secs,
1547    )?;
1548    emit_ok(data, json, |d| {
1549        let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1550        crate::output::writeln_stdout(&format!("ok grab path={p}"))?;
1551        Ok(())
1552    })
1553}
1554
1555fn handle_print_pdf(
1556    life: &Lifecycle,
1557    path: Option<&Path>,
1558    url: Option<&str>,
1559    robots: RobotsPolicy,
1560    capture: CaptureOpts,
1561    timeout_secs: u64,
1562    json: bool,
1563) -> Result<(), CliError> {
1564    // GAP-013: refuse blank PDF without an explicit URL (agent-first).
1565    if url.map(str::trim).filter(|u| !u.is_empty()).is_none() {
1566        return Err(CliError::with_suggestion(
1567            ErrorKind::Usage,
1568            "print-pdf requires --url (blank about:blank PDF refused)",
1569            "Pass --url <page> or use multi-step run with goto then print-pdf",
1570        ));
1571    }
1572    let path = path.map(|p| p.to_path_buf());
1573    let url = url.map(|s| s.to_string());
1574    let data = block_on_browser_timeout(
1575        async {
1576            let mut session =
1577                crate::browser::OneShotSession::launch_headless_with_capture(capture).await?;
1578            life.record_chrome(session.chrome_pid());
1579            if let Some(u) = url.as_deref() {
1580                let _ = session.goto(u, robots).await?;
1581            }
1582            let out = session.print_pdf(path.as_deref()).await;
1583            let _ = session.shutdown().await;
1584            life.clear_chrome();
1585            out
1586        },
1587        timeout_secs,
1588    )?;
1589    emit_ok(data, json, |d| {
1590        let p = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
1591        crate::output::writeln_stdout(&format!("ok print-pdf path={p}"))?;
1592        Ok(())
1593    })
1594}
1595
1596fn handle_monitor(
1597    action: crate::cli::MonitorAction,
1598    robots: RobotsPolicy,
1599    timeout_secs: u64,
1600    json: bool,
1601) -> Result<(), CliError> {
1602    use sha2::{Digest, Sha256};
1603    match action {
1604        crate::cli::MonitorAction::Check {
1605            url,
1606            baseline,
1607            write_baseline,
1608            engine,
1609        } => {
1610            let engine_l = engine.to_ascii_lowercase();
1611            let text = if engine_l == "browser" {
1612                return Err(CliError::with_suggestion(
1613                    ErrorKind::Usage,
1614                    "monitor check --engine browser is reserved; use http for baseline hash",
1615                    "Pass --engine http (default) for one-shot baseline compare",
1616                ));
1617            } else {
1618                let opts = crate::scrape_local::ScrapeOpts {
1619                    format: crate::scrape_local::ScrapeFormat::Text,
1620                    engine: "http".into(),
1621                    ..Default::default()
1622                };
1623                let page = block_on_browser_timeout(
1624                    crate::scrape_local::scrape_http(&url, robots, &opts),
1625                    timeout_secs,
1626                )?;
1627                page.get("text")
1628                    .and_then(|v| v.as_str())
1629                    .unwrap_or("")
1630                    .to_string()
1631            };
1632            let mut hasher = Sha256::new();
1633            hasher.update(text.as_bytes());
1634            let hash = hex::encode(hasher.finalize());
1635            let baseline_exists = baseline.exists();
1636            let (changed, previous_hash) = if baseline_exists {
1637                // Outer CLI path (no Tokio worker) — sync helper is correct (PAR-83).
1638                let prev = std::fs::read_to_string(&baseline).map_err(|e| {
1639                    CliError::new(
1640                        ErrorKind::Io,
1641                        format!("read baseline {}: {e}", baseline.display()),
1642                    )
1643                })?;
1644                let prev = prev.trim().to_string();
1645                (prev != hash, Some(prev))
1646            } else {
1647                (true, None)
1648            };
1649            if write_baseline || !baseline_exists {
1650                crate::concurrency::write_bytes_sync(&baseline, format!("{hash}\n").as_bytes())
1651                    .map_err(|e| {
1652                        CliError::new(
1653                            ErrorKind::Io,
1654                            format!("write baseline {}: {e}", baseline.display()),
1655                        )
1656                    })?;
1657            }
1658            let data = serde_json::json!({
1659                "url": url,
1660                "baseline": baseline.display().to_string(),
1661                "hash": hash,
1662                "previous_hash": previous_hash,
1663                "changed": changed,
1664                "baseline_written": write_baseline || !baseline_exists,
1665                "engine": "http",
1666            });
1667            emit_ok(data, json, |d| {
1668                let ch = d.get("changed").and_then(|v| v.as_bool()).unwrap_or(false);
1669                crate::output::writeln_stdout(&format!("ok monitor check changed={ch}"))?;
1670                Ok(())
1671    })
1672        }
1673    }
1674}
1675
1676fn handle_run(
1677    life: &Lifecycle,
1678    script: &Path,
1679    robots: RobotsPolicy,
1680    capture: CaptureOpts,
1681    timeout_secs: u64,
1682    json: bool,
1683    flags: run::RunFlags,
1684) -> Result<(), CliError> {
1685    let script = script.to_path_buf();
1686    let data = block_on_browser_timeout(
1687        run::run_script_with_flags(life, &script, robots, capture, flags),
1688        timeout_secs,
1689    )?;
1690    // Fail-fast payload: ok:false with partial steps (still non-zero exit).
1691    if data.get("ok") == Some(&serde_json::json!(false)) {
1692        let kind = data
1693            .pointer("/error/kind")
1694            .and_then(|v| v.as_str())
1695            .unwrap_or("data");
1696        let message = data
1697            .pointer("/error/message")
1698            .and_then(|v| v.as_str())
1699            .unwrap_or("run fail-fast")
1700            .to_string();
1701        let suggestion = data
1702            .pointer("/error/suggestion")
1703            .and_then(|v| v.as_str())
1704            .unwrap_or_else(|| crate::i18n::suggestion_key("run_fail_fast", None))
1705            .to_string();
1706        let err_kind = match kind {
1707            "usage" => ErrorKind::Usage,
1708            "unavailable" => ErrorKind::Unavailable,
1709            "browser" => ErrorKind::Browser,
1710            "timeout" => ErrorKind::Timeout,
1711            "cancelled" => ErrorKind::Cancelled,
1712            "broken-pipe" => ErrorKind::BrokenPipe,
1713            "data" => ErrorKind::Data,
1714            _ => ErrorKind::Software,
1715        };
1716        let partial = serde_json::json!({
1717            "total": data.get("total"),
1718            "failed_index": data.get("failed_index"),
1719            "failed_cmd": data.get("failed_cmd"),
1720            "steps": data.get("steps"),
1721        });
1722        return Err(CliError::with_suggestion(err_kind, message, suggestion).with_data(partial));
1723    }
1724    emit_ok(data, json, |d| {
1725        let total = d.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
1726        crate::output::writeln_stdout(&format!("ok run steps={total}"))?;
1727        Ok(())
1728    })
1729}
1730
1731fn handle_exec(
1732    life: &Lifecycle,
1733    args: &[String],
1734    robots: RobotsPolicy,
1735    capture: CaptureOpts,
1736    timeout_secs: u64,
1737    json: bool,
1738    flags: run::RunFlags,
1739) -> Result<(), CliError> {
1740    if args.is_empty() {
1741        return Err(CliError::with_suggestion(
1742            ErrorKind::Usage,
1743            "exec requires a subcommand (e.g. goto)",
1744            "browser-automation-cli exec goto about:blank",
1745        ));
1746    }
1747    // Single-step path for simple argv forms; multi-step uses run --script.
1748    match args[0].as_str() {
1749        "goto" => {
1750            let url = args.get(1).ok_or_else(|| {
1751                CliError::with_suggestion(
1752                    ErrorKind::Usage,
1753                    "exec goto requires a URL",
1754                    "browser-automation-cli exec goto about:blank",
1755                )
1756            })?;
1757            handle_goto(
1758                life,
1759                url,
1760                robots,
1761                capture,
1762                timeout_secs,
1763                json,
1764                None,
1765                None,
1766                None,
1767            )
1768        }
1769        "wait" | "view" | "press" | "write" | "keys" | "type" | "hover" | "back" | "forward"
1770        | "reload" | "eval" | "grab" | "page" | "console" | "net" | "dialog" | "emulate"
1771        | "resize" | "extract" | "text" | "scroll" | "cookie" | "attr" | "assert" | "click-at"
1772        | "drag" | "fill-form" | "upload" | "devtools3p" | "webmcp" | "heap" | "perf"
1773        | "lighthouse" | "screencast" | "extension" => {
1774            let step = run::argv_to_step(args)?;
1775            let data = block_on_browser_timeout(
1776                run::run_one_step(life, step, robots, capture, flags),
1777                timeout_secs,
1778            )?;
1779            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok exec {d}")))
1780        }
1781        other => Err(CliError::with_suggestion(
1782            ErrorKind::Usage,
1783            format!("unknown exec subcommand: {other}"),
1784            "Use browser-automation-cli exec <cmd> ... or run --script for multi-step NDJSON",
1785        )),
1786    }
1787}
1788
1789#[allow(clippy::too_many_arguments)]
1790fn handle_extract(
1791    life: &Lifecycle,
1792    target: &str,
1793    attr: Option<&str>,
1794    llm: bool,
1795    question: Option<&str>,
1796    schema_json: Option<&std::path::Path>,
1797    capture: CaptureOpts,
1798    timeout_secs: u64,
1799    json: bool,
1800) -> Result<(), CliError> {
1801    if llm {
1802        // GAP-015: DOM selector/ref → textContent then LLM (XDG key); http(s)/file still supported.
1803        if !(target.starts_with("http://")
1804            || target.starts_with("https://")
1805            || Path::new(target).is_file())
1806        {
1807            let target_owned = target.to_string();
1808            let attr_owned = attr.map(|s| s.to_string());
1809            let dom = block_on_browser_timeout(
1810                async move {
1811                    let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1812                    life.record_chrome(session.chrome_pid());
1813                    // Selector alone needs a live page; agents should prefer run multi-step.
1814                    // Best-effort: extract on about:blank fails → clear usage.
1815                    let r = session.extract(&target_owned, attr_owned.as_deref()).await;
1816                    let close = session.shutdown().await;
1817                    life.clear_chrome();
1818                    close?;
1819                    r
1820                },
1821                timeout_secs,
1822            );
1823            match dom {
1824                Ok(v) => {
1825                    let text = v
1826                        .get("text")
1827                        .or_else(|| v.get("value"))
1828                        .and_then(|t| t.as_str())
1829                        .unwrap_or("")
1830                        .to_string();
1831                    if text.trim().is_empty() {
1832                        return Err(CliError::with_suggestion(
1833                            ErrorKind::Usage,
1834                            "extract --llm with selector produced empty text (need navigated page)",
1835                            "Use multi-step run: goto then extract --llm, or pass http(s)/file target",
1836                        ));
1837                    }
1838                    return handle_extract_llm_text(&text, question, schema_json, json);
1839                }
1840                Err(e) => {
1841                    return Err(CliError::with_suggestion(
1842                        ErrorKind::Usage,
1843                        format!("extract --llm selector path failed: {}", e.message()),
1844                        "Pass http(s) URL, local file, or use run with goto then extract",
1845                    ));
1846                }
1847            }
1848        }
1849        return handle_extract_llm(target, question, schema_json, json);
1850    }
1851    let target = target.to_string();
1852    let attr = attr.map(|s| s.to_string());
1853    let data = block_on_browser_timeout(
1854        async move {
1855            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1856            life.record_chrome(session.chrome_pid());
1857            let _ = session
1858                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1859                .await?;
1860            let r = session.extract(&target, attr.as_deref()).await;
1861            let close = session.shutdown().await;
1862            life.clear_chrome();
1863            close?;
1864            r
1865        },
1866        timeout_secs,
1867    )?;
1868    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok extract {d}")))
1869}
1870
1871fn handle_extract_llm(
1872    target: &str,
1873    question: Option<&str>,
1874    schema_json: Option<&std::path::Path>,
1875    json: bool,
1876) -> Result<(), CliError> {
1877    let source_text = if target.starts_with("http://") || target.starts_with("https://") {
1878        let opts = crate::scrape_local::ScrapeOpts {
1879            format: crate::scrape_local::ScrapeFormat::Text,
1880            only_main_content: true,
1881            engine: "http".into(),
1882            max_body_bytes: 2_000_000,
1883        };
1884        // HTTP-only: current_thread runtime (rules_rust_latencia — no unbounded
1885        // multi_thread workers for a one-shot scrape before LLM extract).
1886        let data = crate::runtime_util::block_on_io(crate::scrape_local::scrape_http(
1887            target,
1888            crate::robots::RobotsPolicy::Honor,
1889            &opts,
1890        ))?;
1891        data.get("text")
1892            .and_then(|t| t.as_str())
1893            .unwrap_or("")
1894            .to_string()
1895    } else if Path::new(target).is_file() {
1896        let parsed = crate::scrape_local::parse_file(Path::new(target))?;
1897        parsed
1898            .get("text")
1899            .and_then(|t| t.as_str())
1900            .unwrap_or("")
1901            .to_string()
1902    } else {
1903        return Err(CliError::with_suggestion(
1904            ErrorKind::Usage,
1905            "extract --llm target must be http(s) URL or local file path",
1906            "Example: browser-automation-cli --json extract --llm --question 'sum' https://example.com",
1907        ));
1908    };
1909    if source_text.trim().is_empty() {
1910        return Err(CliError::new(
1911            ErrorKind::Data,
1912            "extract --llm: empty source text",
1913        ));
1914    }
1915    handle_extract_llm_text(&source_text, question, schema_json, json)
1916}
1917
1918/// GAP-015: run LLM extract on already-fetched text (DOM or HTTP).
1919fn handle_extract_llm_text(
1920    source_text: &str,
1921    question: Option<&str>,
1922    schema_json: Option<&std::path::Path>,
1923    json: bool,
1924) -> Result<(), CliError> {
1925    let schema_body = match schema_json {
1926        Some(p) => Some(std::fs::read_to_string(p).map_err(|e| {
1927            CliError::new(
1928                ErrorKind::Io,
1929                format!("read schema-json {}: {e}", p.display()),
1930            )
1931        })?),
1932        None => None,
1933    };
1934    if source_text.trim().is_empty() {
1935        return Err(CliError::new(
1936            ErrorKind::Data,
1937            "extract --llm: empty source text",
1938        ));
1939    }
1940    let data = crate::llm_local::extract_with_llm(source_text, question, schema_body.as_deref())?;
1941    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok extract-llm {d}")))
1942}
1943
1944fn handle_attr(
1945    life: &Lifecycle,
1946    target: &str,
1947    name: &str,
1948    capture: CaptureOpts,
1949    timeout_secs: u64,
1950    json: bool,
1951) -> Result<(), CliError> {
1952    handle_extract(
1953        life,
1954        target,
1955        Some(name),
1956        false,
1957        None,
1958        None,
1959        capture,
1960        timeout_secs,
1961        json,
1962    )
1963}
1964
1965fn handle_assert(
1966    life: &Lifecycle,
1967    kind: AssertKind,
1968    capture: CaptureOpts,
1969    timeout_secs: u64,
1970    json: bool,
1971) -> Result<(), CliError> {
1972    let data = block_on_browser_timeout(
1973        async move {
1974            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
1975            life.record_chrome(session.chrome_pid());
1976            let _ = session
1977                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
1978                .await?;
1979            let r = match kind {
1980                AssertKind::Url { value, contains } => session.assert_url(&value, contains).await,
1981                AssertKind::Text { value, target } => {
1982                    session.assert_text(&value, target.as_deref()).await
1983                }
1984                AssertKind::Console { level, max } => session.assert_console(&level, max).await,
1985                AssertKind::ConsoleEmpty => session.assert_console_empty().await,
1986                AssertKind::ConsoleNoMatch { pattern } => {
1987                    session.assert_console_no_match(&pattern).await
1988                },
1989            };
1990            let close = session.shutdown().await;
1991            life.clear_chrome();
1992            close?;
1993            r
1994        },
1995        timeout_secs,
1996    )?;
1997    emit_ok(data, json, |_|  crate::output::writeln_stdout("ok assert"))
1998}
1999
2000fn handle_console(
2001    life: &Lifecycle,
2002    action: ConsoleAction,
2003    capture: CaptureOpts,
2004    timeout_secs: u64,
2005    json: bool,
2006) -> Result<(), CliError> {
2007    let data = block_on_browser_timeout(
2008        async move {
2009            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
2010            life.record_chrome(session.chrome_pid());
2011            let _ = session
2012                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2013                .await?;
2014            let r = match action {
2015                ConsoleAction::List {
2016                    page_idx,
2017                    page_size,
2018                    types,
2019                    include_preserved,
2020                    service_worker_id,
2021                } => session.console_list(
2022                    page_idx,
2023                    page_size,
2024                    types.as_deref(),
2025                    include_preserved,
2026                    service_worker_id.as_deref(),
2027                ),
2028                ConsoleAction::Get { id } => session.console_get(id),
2029                ConsoleAction::Clear => session.console_clear(),
2030                ConsoleAction::Dump { path } => session.console_dump(&path).await,
2031            };
2032            let close = session.shutdown().await;
2033            life.clear_chrome();
2034            close?;
2035            r
2036        },
2037        timeout_secs,
2038    )?;
2039    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok console {d}")))
2040}
2041
2042fn handle_net(
2043    life: &Lifecycle,
2044    action: NetAction,
2045    capture: CaptureOpts,
2046    timeout_secs: u64,
2047    json: bool,
2048) -> Result<(), CliError> {
2049    let data = block_on_browser_timeout(
2050        async move {
2051            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
2052            life.record_chrome(session.chrome_pid());
2053            let _ = session
2054                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2055                .await?;
2056            let r = match action {
2057                NetAction::List {
2058                    page_idx,
2059                    page_size,
2060                    resource_types,
2061                    include_preserved,
2062                } => session.net_list(
2063                    page_idx,
2064                    page_size,
2065                    resource_types.as_deref(),
2066                    include_preserved,
2067                ),
2068                NetAction::Get {
2069                    id,
2070                    request_path,
2071                    response_path,
2072                } => {
2073                    session
2074                        .net_get(&id, request_path.as_deref(), response_path.as_deref())
2075                        .await
2076                }
2077            };
2078            let close = session.shutdown().await;
2079            life.clear_chrome();
2080            close?;
2081            r
2082        },
2083        timeout_secs,
2084    )?;
2085    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok net {d}")))
2086}
2087
2088fn handle_page(
2089    life: &Lifecycle,
2090    action: Option<PageAction>,
2091    capture: CaptureOpts,
2092    timeout_secs: u64,
2093    json: bool,
2094) -> Result<(), CliError> {
2095    let action = action.unwrap_or(PageAction::Info);
2096    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2097        let v = match action {
2098            PageAction::Info => session.page_info().await?,
2099            PageAction::List => session.page_list().await?,
2100            PageAction::New {
2101                url,
2102                background,
2103                isolated_context,
2104            } => {
2105                session
2106                    .page_new(url.as_deref(), background, isolated_context.as_deref())
2107                    .await?
2108            }
2109            PageAction::Select {
2110                index,
2111                page_id,
2112                bring_to_front,
2113            } => {
2114                let idx = index.or(page_id).ok_or_else(|| {
2115                    CliError::with_suggestion(
2116                        ErrorKind::Usage,
2117                        "page select requires INDEX or --page-id",
2118                        "browser-automation-cli page select 0 --json",
2119                    )
2120                })?;
2121                session.page_select(idx, bring_to_front).await?
2122            }
2123            PageAction::Close { index, page_id } => session.page_close(index.or(page_id)).await?,
2124            PageAction::TabId => {
2125                let tab = session.active_tab_id_string().ok_or_else(|| {
2126                    CliError::with_suggestion(
2127                        ErrorKind::Browser,
2128                        "no active tab id",
2129                        "Open a page first (goto / page new)",
2130                    )
2131                })?;
2132                serde_json::json!({
2133                    "tab_id": tab,
2134                    "tool": "get_tab_id",
2135                })
2136            }
2137        };
2138        Ok((session, v))
2139    })?;
2140    emit_ok(data, json, |d| {
2141        if let Some(tab) = d.get("tab_id").and_then(|v| v.as_str()) {
2142            crate::output::writeln_stdout(&format!("ok page tab-id={tab}"))?;
2143        } else if let (Some(u), Some(t)) = (
2144            d.get("url").and_then(|v| v.as_str()),
2145            d.get("title").and_then(|v| v.as_str()),
2146        ) {
2147            crate::output::writeln_stdout(&format!("ok page url={u} title={t}"))?;
2148        } else {
2149            crate::output::writeln_stdout(&format!("ok page {d}"))?;
2150        }
2151        Ok(())
2152    })
2153}
2154
2155fn handle_text(
2156    life: &Lifecycle,
2157    target: &str,
2158    capture: CaptureOpts,
2159    timeout_secs: u64,
2160    json: bool,
2161) -> Result<(), CliError> {
2162    handle_extract(
2163        life,
2164        target,
2165        None,
2166        false,
2167        None,
2168        None,
2169        capture,
2170        timeout_secs,
2171        json,
2172    )
2173}
2174
2175fn handle_scroll(
2176    life: &Lifecycle,
2177    target: Option<&str>,
2178    delta_x: f64,
2179    delta_y: f64,
2180    capture: CaptureOpts,
2181    timeout_secs: u64,
2182    json: bool,
2183) -> Result<(), CliError> {
2184    let target_owned = target.map(|s| s.to_string());
2185    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2186        let v = session
2187            .scroll(target_owned.as_deref(), delta_x, delta_y)
2188            .await?;
2189        Ok((session, v))
2190    })?;
2191    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok scroll {d}")))
2192}
2193
2194fn handle_cookie(
2195    life: &Lifecycle,
2196    action: CookieAction,
2197    capture: CaptureOpts,
2198    timeout_secs: u64,
2199    json: bool,
2200) -> Result<(), CliError> {
2201    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2202        let v = match action {
2203            CookieAction::List { url } => session.cookie_list(url.as_deref()).await?,
2204            CookieAction::Set {
2205                cookies_json: body,
2206            } => session.cookie_set(&body).await?,
2207            CookieAction::Clear => session.cookie_clear().await?,
2208        };
2209        Ok((session, v))
2210    })?;
2211    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok cookie {d}")))
2212}
2213
2214fn handle_dialog(
2215    life: &Lifecycle,
2216    action: DialogAction,
2217    capture: CaptureOpts,
2218    timeout_secs: u64,
2219    json: bool,
2220) -> Result<(), CliError> {
2221    let data = block_on_browser_timeout(
2222        async move {
2223            let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
2224            life.record_chrome(session.chrome_pid());
2225            let _ = session
2226                .goto("about:blank", crate::robots::RobotsPolicy::Honor)
2227                .await?;
2228            let (r, if_present) = match action {
2229                DialogAction::Accept { text, if_present } => {
2230                    (session.dialog(true, text.as_deref()).await, if_present)
2231                }
2232                DialogAction::Dismiss { if_present } => {
2233                    (session.dialog(false, None).await, if_present)
2234                }
2235            };
2236            let r = match r {
2237                Ok(v) => Ok(v),
2238                Err(e) if if_present => {
2239                    let msg = e.message().to_ascii_lowercase();
2240                    if msg.contains("no dialog")
2241                        || msg.contains("not showing")
2242                        || msg.contains("-32602")
2243                        || msg.contains("dialog failed")
2244                    {
2245                        Ok(serde_json::json!({
2246                            "dialog_shown": false,
2247                            "if_present": true,
2248                            "ok": true,
2249                        }))
2250                    } else {
2251                        Err(e)
2252                    }
2253                }
2254                Err(e) => Err(e),
2255            };
2256            let close = session.shutdown().await;
2257            life.clear_chrome();
2258            close?;
2259            r
2260        },
2261        timeout_secs,
2262    )?;
2263    emit_ok(data, json, |_|  crate::output::writeln_stdout("ok dialog"))
2264}
2265
2266#[allow(clippy::too_many_arguments)]
2267fn post_webhook(webhook_url: &str, data: &serde_json::Value) -> Result<(), CliError> {
2268    // One-shot operator webhook; no product telemetry. Reuse process-wide client.
2269    let client = crate::llm_local::shared_blocking_http_client()?;
2270    let mut last_err = String::new();
2271    for attempt in 0..3u32 {
2272        match client
2273            .post(webhook_url)
2274            .timeout(std::time::Duration::from_secs(15))
2275            .json(data)
2276            .send()
2277        {
2278            Ok(resp) if resp.status().is_success() => return Ok(()),
2279            Ok(resp) => {
2280                last_err = format!("webhook HTTP {}", resp.status());
2281            }
2282            Err(e) => last_err = format!("webhook: {e}"),
2283        }
2284        if attempt < 2 {
2285            std::thread::sleep(std::time::Duration::from_millis(50 * (1 << attempt)));
2286        }
2287    }
2288    Err(CliError::with_suggestion(
2289        ErrorKind::Unavailable,
2290        last_err,
2291        "Check --webhook-url reachability; operator destination only",
2292    ))
2293}
2294
2295#[allow(clippy::too_many_arguments)]
2296fn handle_scrape(
2297    life: &Lifecycle,
2298    url: &str,
2299    robots: RobotsPolicy,
2300    capture: CaptureOpts,
2301    timeout_secs: u64,
2302    json: bool,
2303    formats: &[String],
2304    engine: &str,
2305    only_main_content: bool,
2306    webhook_url: Option<&str>,
2307) -> Result<(), CliError> {
2308    // GAP-009: support multi-format in one invocation.
2309    let formats: Vec<&str> = if formats.is_empty() {
2310        vec!["text"]
2311    } else {
2312        formats.iter().map(String::as_str).collect()
2313    };
2314    let engine_l = engine.to_ascii_lowercase();
2315
2316    if engine_l == "http" {
2317        if formats.len() == 1 {
2318            let fmt = crate::scrape_local::ScrapeFormat::parse(formats[0])?;
2319            let opts = crate::scrape_local::ScrapeOpts {
2320                format: fmt,
2321                only_main_content,
2322                engine: "http".into(),
2323                ..Default::default()
2324            };
2325            let data = block_on_browser_timeout(
2326                crate::scrape_local::scrape_http(url, robots, &opts),
2327                timeout_secs,
2328            )?;
2329            if let Some(wh) = webhook_url {
2330                post_webhook(wh, &data)?;
2331            }
2332            return emit_ok(data, json, |d| {
2333                let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2334                crate::output::writeln_stdout(&format!("ok scrape engine=http source_url={u}"))?;
2335                Ok(())
2336    });
2337        }
2338        // Multi-format HTTP: fetch once as html then derive.
2339        let opts_html = crate::scrape_local::ScrapeOpts {
2340            format: crate::scrape_local::ScrapeFormat::Html,
2341            only_main_content,
2342            engine: "http".into(),
2343            ..Default::default()
2344        };
2345        let base = block_on_browser_timeout(
2346            crate::scrape_local::scrape_http(url, robots, &opts_html),
2347            timeout_secs,
2348        )?;
2349        let html = base
2350            .get("html")
2351            .or_else(|| base.get("content"))
2352            .and_then(|v| v.as_str())
2353            .unwrap_or("")
2354            .to_string();
2355        let source = base
2356            .get("source_url")
2357            .and_then(|v| v.as_str())
2358            .unwrap_or(url)
2359            .to_string();
2360        let status = base.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
2361        let mut formats_out = serde_json::Map::new();
2362        for f in &formats {
2363            let fmt = crate::scrape_local::ScrapeFormat::parse(f)?;
2364            let opts = crate::scrape_local::ScrapeOpts {
2365                format: fmt,
2366                only_main_content,
2367                engine: "http".into(),
2368                ..Default::default()
2369            };
2370            let part =
2371                crate::scrape_local::build_scrape_payload(&source, status, &html, &opts, robots);
2372            formats_out.insert(f.replace('-', "_"), part);
2373        }
2374        let data = json!({
2375            "source_url": source,
2376            "engine": "http",
2377            "formats": formats_out,
2378            "format_list": formats,
2379        });
2380        if let Some(wh) = webhook_url {
2381            post_webhook(wh, &data)?;
2382        }
2383        return emit_ok(data, json, |d| {
2384            let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2385            crate::output::writeln_stdout(&format!("ok scrape engine=http multi-format source_url={u}"))?;
2386            Ok(())
2387    });
2388    }
2389
2390    // browser engine: CDP scrape once, derive formats from HTML.
2391    let data = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
2392    let html = data
2393        .get("html")
2394        .and_then(|v| v.as_str())
2395        .unwrap_or("")
2396        .to_string();
2397    let source = data
2398        .get("source_url")
2399        .and_then(|v| v.as_str())
2400        .unwrap_or(url)
2401        .to_string();
2402    let data = if formats.len() == 1 {
2403        let fmt = crate::scrape_local::ScrapeFormat::parse(formats[0])?;
2404        if html.is_empty() {
2405            let mut d = data;
2406            if let Some(obj) = d.as_object_mut() {
2407                obj.insert(
2408                    "format".into(),
2409                    serde_json::json!(format!("{:?}", fmt).to_ascii_lowercase()),
2410                );
2411                obj.insert("engine".into(), serde_json::json!("browser"));
2412            }
2413            d
2414        } else {
2415            let opts = crate::scrape_local::ScrapeOpts {
2416                format: fmt,
2417                only_main_content,
2418                engine: "browser".into(),
2419                ..Default::default()
2420            };
2421            crate::scrape_local::build_scrape_payload(&source, 200, &html, &opts, robots)
2422        }
2423    } else {
2424        let mut formats_out = serde_json::Map::new();
2425        for f in &formats {
2426            let fmt = crate::scrape_local::ScrapeFormat::parse(f)?;
2427            let opts = crate::scrape_local::ScrapeOpts {
2428                format: fmt,
2429                only_main_content,
2430                engine: "browser".into(),
2431                ..Default::default()
2432            };
2433            let part =
2434                crate::scrape_local::build_scrape_payload(&source, 200, &html, &opts, robots);
2435            formats_out.insert(f.replace('-', "_"), part);
2436        }
2437        json!({
2438            "source_url": source,
2439            "engine": "browser",
2440            "formats": formats_out,
2441            "format_list": formats,
2442            "robots_policy": robots.as_str(),
2443        })
2444    };
2445    if let Some(wh) = webhook_url {
2446        post_webhook(wh, &data)?;
2447    }
2448    emit_ok(data, json, |d| {
2449        let policy = d
2450            .get("robots_policy")
2451            .and_then(|v| v.as_str())
2452            .unwrap_or("honor");
2453        let u = d.get("source_url").and_then(|v| v.as_str()).unwrap_or(url);
2454        crate::output::writeln_stdout(&format!("ok scrape source_url={u} robots_policy={policy}"))?;
2455        Ok(())
2456    })
2457}
2458
2459#[allow(clippy::too_many_arguments)]
2460fn handle_batch_scrape(
2461    life: &Lifecycle,
2462    urls_file: &Path,
2463    robots: RobotsPolicy,
2464    capture: CaptureOpts,
2465    timeout_secs: u64,
2466    format: &str,
2467    concurrency: usize,
2468    engine: &str,
2469    json: bool,
2470) -> Result<(), CliError> {
2471    let urls = crate::scrape_local::read_urls_file(urls_file)?;
2472    let engine_l = engine.to_ascii_lowercase();
2473    if engine_l == "browser" {
2474        // GAP-010: one shared Chrome session — sequential navigations by product
2475        // law (single Page). Parallelism is HTTP engine + CDP internal fan-out.
2476        // Budget is still reported so agents can size HTTP batch equivalently.
2477        // Budget reported for agents; browser path stays sequential (single Page).
2478        let budget = crate::concurrency::resolve_permits(concurrency);
2479        let mut pages = Vec::new();
2480        let mut errors = Vec::new();
2481        for u in &urls {
2482            match block_on_browser_timeout(run_scrape(life, u, robots, capture), timeout_secs) {
2483                Ok(v) => pages.push(v),
2484                Err(e) => errors.push(json!({ "url": u, "error": e.message() })),
2485            }
2486        }
2487        let data = json!({
2488            "count": pages.len(),
2489            "pages": pages,
2490            "errors": errors,
2491            "engine": "browser",
2492            "format": format,
2493            "concurrency_budget": budget,
2494            "note": "browser engine is single-session sequential; use --engine http for parallel fetches",
2495        });
2496        return emit_ok(data, json, |d| {
2497            crate::output::writeln_stdout(&format!("ok batch-scrape engine=browser count={}",
2498                d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2499                Ok(())
2500    });
2501    }
2502    let opts = crate::scrape_local::ScrapeOpts {
2503        format: crate::scrape_local::ScrapeFormat::parse(format)?,
2504        engine: "http".into(),
2505        ..Default::default()
2506    };
2507    let data = block_on_browser_timeout(
2508        crate::scrape_local::batch_scrape_http(&urls, robots, &opts, concurrency),
2509        0,
2510    )?;
2511    emit_ok(data, json, |d| {
2512        crate::output::writeln_stdout(&format!("ok batch-scrape count={}",
2513            d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2514            Ok(())
2515    })
2516}
2517
2518#[allow(clippy::too_many_arguments)]
2519fn handle_crawl(
2520    life: &Lifecycle,
2521    url: &str,
2522    robots: RobotsPolicy,
2523    capture: CaptureOpts,
2524    timeout_secs: u64,
2525    limit: usize,
2526    max_depth: usize,
2527    format: &str,
2528    same_host: bool,
2529    engine: &str,
2530    json: bool,
2531) -> Result<(), CliError> {
2532    let engine_l = engine.to_ascii_lowercase();
2533    if engine_l == "browser" {
2534        // GAP-010: browser crawl = map links via browser scrape of seed then sequential goto.
2535        let seed = block_on_browser_timeout(run_scrape(life, url, robots, capture), timeout_secs)?;
2536        let mut pages = vec![seed.clone()];
2537        let mut seen = std::collections::BTreeSet::new();
2538        seen.insert(url.to_string());
2539        let links: Vec<String> = seed
2540            .get("links")
2541            .and_then(|v| v.as_array())
2542            .map(|a| {
2543                a.iter()
2544                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
2545                    .collect()
2546            })
2547            .unwrap_or_default();
2548        for link in links.into_iter().take(limit.saturating_sub(1)) {
2549            if !seen.insert(link.clone()) {
2550                continue;
2551            }
2552            if same_host {
2553                // best-effort same host: compare host strings loosely
2554                if let (Ok(seed_u), Ok(link_u)) = (
2555                    url::Url::parse(url),
2556                    url::Url::parse(&link),
2557                ) {
2558                    if seed_u.host_str() != link_u.host_str() {
2559                        continue;
2560                    }
2561                }
2562            }
2563            match block_on_browser_timeout(run_scrape(life, &link, robots, capture), timeout_secs) {
2564                Ok(v) => pages.push(v),
2565                Err(_) => continue,
2566            }
2567            if pages.len() >= limit {
2568                break;
2569            }
2570        }
2571        let _ = max_depth; // depth>1 reserved; seed+1 hop for one-shot safety
2572        let data = json!({
2573            "count": pages.len(),
2574            "pages": pages,
2575            "engine": "browser",
2576            "format": format,
2577            "seed": url,
2578            "max_depth_applied": 1,
2579        });
2580        return emit_ok(data, json, |d| {
2581            crate::output::writeln_stdout(&format!("ok crawl engine=browser count={}",
2582                d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2583                Ok(())
2584    });
2585    }
2586    let opts = crate::scrape_local::ScrapeOpts {
2587        format: crate::scrape_local::ScrapeFormat::parse(format)?,
2588        engine: "http".into(),
2589        ..Default::default()
2590    };
2591    let data = block_on_browser_timeout(
2592        crate::scrape_local::crawl_http(url, robots, &opts, limit, max_depth, same_host),
2593        0,
2594    )?;
2595    emit_ok(data, json, |d| {
2596        crate::output::writeln_stdout(&format!("ok crawl count={}",
2597            d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2598            Ok(())
2599    })
2600}
2601
2602fn handle_map(
2603    url: &str,
2604    robots: RobotsPolicy,
2605    limit: usize,
2606    max_depth: usize,
2607    json: bool,
2608) -> Result<(), CliError> {
2609    let data = block_on_browser_timeout(
2610        crate::scrape_local::map_http(url, robots, limit, max_depth),
2611        0,
2612    )?;
2613    emit_ok(data, json, |d| {
2614        crate::output::writeln_stdout(&format!("ok map count={}",
2615            d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2616            Ok(())
2617    })
2618}
2619
2620fn handle_search(
2621    query: &str,
2622    robots: RobotsPolicy,
2623    limit: usize,
2624    json: bool,
2625) -> Result<(), CliError> {
2626    let data = block_on_browser_timeout(crate::scrape_local::search_http(query, robots, limit), 0)?;
2627    emit_ok(data, json, |d| {
2628        crate::output::writeln_stdout(&format!("ok search count={}",
2629            d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2630            Ok(())
2631    })
2632}
2633
2634fn handle_parse(path: &Path, redact_pii: bool, json: bool) -> Result<(), CliError> {
2635    let data = crate::scrape_local::parse_file_opts(path, redact_pii)?;
2636    emit_ok(data, json, |d| {
2637        crate::output::writeln_stdout(&format!("ok parse path={}",
2638            d.get("path").and_then(|v| v.as_str()).unwrap_or("")))?;
2639            Ok(())
2640    })
2641}
2642
2643fn handle_qr(action: crate::cli::QrAction, json: bool) -> Result<(), CliError> {
2644    let data = match action {
2645        crate::cli::QrAction::Encode { text, format, path } => {
2646            crate::qr_local::encode(&text, &format, path.as_deref())?
2647        }
2648        crate::cli::QrAction::Decode { path } => crate::qr_local::decode(&path)?,
2649    };
2650    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok qr {d}")))
2651}
2652
2653#[allow(clippy::too_many_arguments)]
2654fn handle_find_paths(
2655    pattern: Option<&str>,
2656    paths: &[String],
2657    extension: Option<&str>,
2658    hidden: bool,
2659    no_ignore: bool,
2660    max_depth: Option<usize>,
2661    entry_type: Option<&str>,
2662    limit: usize,
2663    glob: Option<&str>,
2664    json: bool,
2665) -> Result<(), CliError> {
2666    let opts = crate::find_paths::FindPathsOpts {
2667        pattern: pattern.unwrap_or("").to_string(),
2668        roots: crate::find_paths::roots_from(paths),
2669        extension: extension.map(|s| s.to_string()),
2670        hidden,
2671        no_ignore,
2672        max_depth,
2673        entry_type: entry_type.map(|s| s.to_string()),
2674        limit,
2675        glob: glob.map(|s| s.to_string()),
2676    };
2677    let data = crate::find_paths::find_paths(&opts)?;
2678    emit_ok(data, json, |d| {
2679        crate::output::writeln_stdout(&format!("ok find-paths count={}",
2680            d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2681            Ok(())
2682    })
2683}
2684
2685fn handle_sg_scan(paths: &[String], limit: usize, json: bool) -> Result<(), CliError> {
2686    let roots: Vec<std::path::PathBuf> = if paths.is_empty() {
2687        vec![std::path::PathBuf::from(".")]
2688    } else {
2689        paths.iter().map(std::path::PathBuf::from).collect()
2690    };
2691    let data = crate::sg_local::sg_scan(&roots, limit)?;
2692    emit_ok(data, json, |d| {
2693        crate::output::writeln_stdout(&format!("ok sg-scan count={}",
2694            d.get("count").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2695            Ok(())
2696    })
2697}
2698
2699fn handle_sg_rewrite(paths: &[String], apply: bool, json: bool) -> Result<(), CliError> {
2700    let roots: Vec<std::path::PathBuf> = if paths.is_empty() {
2701        vec![std::path::PathBuf::from(".")]
2702    } else {
2703        paths.iter().map(std::path::PathBuf::from).collect()
2704    };
2705    let data = crate::sg_local::sg_rewrite(&roots, apply)?;
2706    emit_ok(data, json, |d| {
2707        crate::output::writeln_stdout(&format!("ok sg-rewrite apply={} planned={}",
2708            d.get("apply").and_then(|v| v.as_bool()).unwrap_or(false),
2709            d.get("planned").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2710            Ok(())
2711    })
2712}
2713
2714fn handle_sheet_write(
2715    input: &std::path::Path,
2716    out: &std::path::Path,
2717    sheet: &str,
2718    json: bool,
2719) -> Result<(), CliError> {
2720    let data = crate::sheet_local::sheet_write(input, out, sheet)?;
2721    emit_ok(data, json, |d| {
2722        crate::output::writeln_stdout(&format!("ok sheet-write path={} rows={}",
2723            d.get("path").and_then(|v| v.as_str()).unwrap_or(""),
2724            d.get("rows").and_then(|v| v.as_u64()).unwrap_or(0)))?;
2725            Ok(())
2726    })
2727}
2728
2729fn handle_mitm(action: MitmAction, json: bool) -> Result<(), CliError> {
2730    let data = match action {
2731        MitmAction::Status => crate::mitm_local::status()?,
2732        MitmAction::List { host, limit } => crate::mitm_local::list(host.as_deref(), limit)?,
2733        MitmAction::Get { id } => crate::mitm_local::get(id)?,
2734        MitmAction::Har { out } => crate::mitm_local::export_har(&out)?,
2735        MitmAction::Export { format, out } => {
2736            if format.eq_ignore_ascii_case("har") {
2737                crate::mitm_local::export_har(&out)?
2738            } else {
2739                let path = crate::mitm_local::default_capture_path()?;
2740                let cap = crate::mitm_local::MitmCapture::load(&path, true)?;
2741                let body = serde_json::to_vec_pretty(&serde_json::json!({
2742                    "count": cap.items.len(),
2743                    "items": cap.items,
2744                }))
2745                .map_err(|e| CliError::new(ErrorKind::Data, format!("export: {e}")))?;
2746                // Outer CLI sync path (PAR-83): write_bytes_sync for large capture dumps.
2747                crate::concurrency::write_bytes_sync(&out, &body)
2748                    .map_err(|e| CliError::new(ErrorKind::Io, format!("export write: {e}")))?;
2749                serde_json::json!({
2750                    "path": out.display().to_string(),
2751                    "format": format,
2752                    "count": cap.items.len(),
2753                })
2754            }
2755        }
2756        MitmAction::Domains => crate::mitm_local::domains()?,
2757        MitmAction::Apis { kind } => crate::mitm_local::apis(kind.as_deref())?,
2758        MitmAction::InitCa => crate::mitm_local::ensure_ca()?,
2759        MitmAction::Start { seconds } => {
2760            crate::browser::block_on_browser(crate::mitm_local::start_proxy_oneshot(seconds))?
2761        }
2762        MitmAction::CaptureUrl {
2763            url,
2764            seconds,
2765            har,
2766            hosts,
2767        } => crate::browser::block_on_browser(crate::mitm_local::capture_url_oneshot(
2768            &url,
2769            seconds,
2770            har.as_deref(),
2771            hosts.as_deref(),
2772        ))?,
2773        MitmAction::Graphql { limit } => crate::mitm_local::graphql(limit)?,
2774        MitmAction::Ws { action } => match action {
2775            crate::cli::MitmWsAction::List { limit } => crate::mitm_local::ws_list(limit)?,
2776            crate::cli::MitmWsAction::Get { id } => crate::mitm_local::ws_get(id)?,
2777        },
2778        MitmAction::Block { host, path } => {
2779            crate::mitm_local::block_rule(host.as_deref(), path.as_deref())?
2780        }
2781        MitmAction::Allow { host } => crate::mitm_local::allow_host(&host)?,
2782        MitmAction::Redact { secrets } => crate::mitm_local::redact_policy(secrets)?,
2783    };
2784    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok mitm {d}")))
2785}
2786
2787fn handle_workflow(action: WorkflowAction, json: bool) -> Result<(), CliError> {
2788    let data = match action {
2789        WorkflowAction::Run { manifest, journal } => {
2790            crate::workflow_local::workflow_run(&manifest, journal.as_deref())?
2791        }
2792        WorkflowAction::Resume { manifest, journal } => {
2793            crate::workflow_local::workflow_resume(&manifest, journal.as_deref())?
2794        }
2795        WorkflowAction::Status { journal, name } => {
2796            crate::workflow_local::workflow_status(journal.as_deref(), name.as_deref())?
2797        }
2798    };
2799    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok workflow {d}")))
2800}
2801
2802fn handle_config(action: ConfigAction, json: bool) -> Result<(), CliError> {
2803    let data = match action {
2804        ConfigAction::Path => crate::xdg::paths_snapshot()?,
2805        ConfigAction::Init => crate::xdg::init_layout()?,
2806        ConfigAction::Show => crate::xdg::config_get(None)?,
2807        ConfigAction::Set { key, value } => crate::xdg::config_set(&key, &value)?,
2808        ConfigAction::Get { key } => crate::xdg::config_get(key.as_deref())?,
2809        ConfigAction::ListKeys => crate::xdg::config_list_keys()?,
2810    };
2811    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok config {d}")))
2812}
2813
2814#[allow(clippy::too_many_arguments)]
2815fn handle_emulate(
2816    life: &Lifecycle,
2817    user_agent: Option<&str>,
2818    locale: Option<&str>,
2819    timezone: Option<&str>,
2820    offline: bool,
2821    latitude: Option<f64>,
2822    longitude: Option<f64>,
2823    media: Option<&str>,
2824    network_conditions: Option<&str>,
2825    cpu_throttling_rate: Option<f64>,
2826    color_scheme: Option<&str>,
2827    extra_headers: Option<&str>,
2828    viewport: Option<&str>,
2829    capture: CaptureOpts,
2830    timeout_secs: u64,
2831    json: bool,
2832) -> Result<(), CliError> {
2833    let ua = user_agent.map(|s| s.to_string());
2834    let loc = locale.map(|s| s.to_string());
2835    let tz = timezone.map(|s| s.to_string());
2836    let media = media.map(|s| s.to_string());
2837    let net = network_conditions.map(|s| s.to_string());
2838    let scheme = color_scheme.map(|s| s.to_string());
2839    let headers = extra_headers.map(|s| s.to_string());
2840    let vp = viewport.map(|s| s.to_string());
2841    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2842        let v = session
2843            .emulate(
2844                ua.as_deref(),
2845                loc.as_deref(),
2846                tz.as_deref(),
2847                offline,
2848                latitude,
2849                longitude,
2850                media.as_deref(),
2851                net.as_deref(),
2852                cpu_throttling_rate,
2853                scheme.as_deref(),
2854                headers.as_deref(),
2855                vp.as_deref(),
2856            )
2857            .await?;
2858        Ok((session, v))
2859    })?;
2860    emit_ok(data, json, |_|  crate::output::writeln_stdout("ok emulate"))
2861}
2862
2863#[allow(clippy::too_many_arguments)]
2864fn handle_resize(
2865    life: &Lifecycle,
2866    width: i32,
2867    height: i32,
2868    scale: f64,
2869    mobile: bool,
2870    capture: CaptureOpts,
2871    timeout_secs: u64,
2872    json: bool,
2873) -> Result<(), CliError> {
2874    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2875        let v = session.resize(width, height, scale, mobile).await?;
2876        Ok((session, v))
2877    })?;
2878    emit_ok(data, json, |_|  crate::output::writeln_stdout(&format!("ok resize {width}x{height}")))
2879}
2880
2881fn handle_perf(
2882    life: &Lifecycle,
2883    action: PerfAction,
2884    capture: CaptureOpts,
2885    timeout_secs: u64,
2886    json: bool,
2887) -> Result<(), CliError> {
2888    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
2889        let v = match action {
2890            PerfAction::Start {
2891                path,
2892                reload,
2893                auto_stop,
2894            } => {
2895                session
2896                    .perf_start(path.as_deref(), reload, auto_stop)
2897                    .await?
2898            }
2899            PerfAction::Stop { path } => session.perf_stop(path.as_deref()).await?,
2900            PerfAction::Insight {
2901                name,
2902                insight_set_id,
2903                insight_name,
2904            } => {
2905                let resolved = insight_name.or(name);
2906                session
2907                    .perf_insight(resolved.as_deref(), insight_set_id.as_deref())
2908                    .await?
2909            }
2910        };
2911        Ok((session, v))
2912    })?;
2913    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok perf {d}")))
2914}
2915
2916/// Where the lighthouse binary was resolved from (agent-honest; GAP-A010 / LH-1).
2917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2918pub(crate) enum LighthouseSource {
2919    /// Explicit `--lighthouse-path` flag.
2920    Flag,
2921    /// XDG `config set lighthouse_path`.
2922    Xdg,
2923    /// Found on PATH via which-equivalent.
2924    Path,
2925    /// Local e2e mock script (`mock-lighthouse`).
2926    Mock,
2927}
2928
2929impl LighthouseSource {
2930    fn as_str(self) -> &'static str {
2931        match self {
2932            Self::Flag => "flag",
2933            Self::Xdg => "xdg",
2934            Self::Path => "path",
2935            Self::Mock => "mock",
2936        }
2937    }
2938}
2939
2940/// Resolve lighthouse binary: flag → XDG → PATH (rules processos_externos).
2941pub(crate) fn resolve_lighthouse_binary(
2942    cli_path: Option<&Path>,
2943) -> Result<(std::path::PathBuf, LighthouseSource), CliError> {
2944    if let Some(p) = cli_path {
2945        if p.is_file() {
2946            let source = if p
2947                .file_name()
2948                .and_then(|n| n.to_str())
2949                .is_some_and(|n| n.contains("mock-lighthouse"))
2950            {
2951                LighthouseSource::Mock
2952            } else {
2953                LighthouseSource::Flag
2954            };
2955            return Ok((p.to_path_buf(), source));
2956        }
2957        return Err(CliError::with_suggestion(
2958            ErrorKind::Usage,
2959            format!("lighthouse path not found: {}", p.display()),
2960            "Pass an absolute executable path to --lighthouse-path",
2961        ));
2962    }
2963    if let Some(xdg) = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty()) {
2964        let p = Path::new(&xdg);
2965        if p.is_file() {
2966            let source = if xdg.contains("mock-lighthouse") {
2967                LighthouseSource::Mock
2968            } else {
2969                LighthouseSource::Xdg
2970            };
2971            return Ok((p.to_path_buf(), source));
2972        }
2973    }
2974    if let Some(p) = which_lighthouse() {
2975        return Ok((Path::new(&p).to_path_buf(), LighthouseSource::Path));
2976    }
2977    Err(CliError::with_suggestion(
2978        ErrorKind::Unavailable,
2979        "lighthouse binary not found on PATH or XDG lighthouse_path",
2980        crate::i18n::suggestion_key("lighthouse_missing", None),
2981    ))
2982}
2983
2984fn which_lighthouse() -> Option<String> {
2985    crate::platform::which_bin("lighthouse").map(|p| p.display().to_string())
2986}
2987
2988/// Run lighthouse binary and return envelope data (shared by CLI and `run` scripts).
2989pub(crate) fn lighthouse_to_value(
2990    url: &str,
2991    out_dir: Option<&Path>,
2992    device: &str,
2993    mode: &str,
2994    lighthouse_path: Option<&Path>,
2995) -> Result<serde_json::Value, CliError> {
2996    let (bin_path, binary_source) = resolve_lighthouse_binary(lighthouse_path)?;
2997    let bin = bin_path.display().to_string();
2998    let out = out_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
2999        crate::xdg::cache_dir()
3000            .unwrap_or_else(|_| std::env::temp_dir().join("browser-automation-cli"))
3001            .join("lighthouse")
3002    });
3003    std::fs::create_dir_all(&out)
3004        .map_err(|e| CliError::new(ErrorKind::Io, format!("lighthouse out-dir: {e}")))?;
3005    let form_factor = if device.eq_ignore_ascii_case("mobile") {
3006        "mobile"
3007    } else {
3008        "desktop"
3009    };
3010    let mode_norm = if mode.eq_ignore_ascii_case("snapshot") {
3011        "snapshot"
3012    } else if mode.eq_ignore_ascii_case("navigation") || mode.is_empty() {
3013        "navigation"
3014    } else {
3015        return Err(CliError::with_suggestion(
3016            ErrorKind::Usage,
3017            format!("unsupported lighthouse mode: {mode}"),
3018            "Use --mode navigation or --mode snapshot",
3019        ));
3020    };
3021    // Map mode to real Lighthouse CLI args (GAP-006). Snapshot uses gather-mode.
3022    let html_path = out.join("report.html");
3023    let json_path = out.join("report.json");
3024    let mut cmd = std::process::Command::new(&bin);
3025    cmd.arg(url)
3026        .arg("--quiet")
3027        .arg("--output=html")
3028        .arg("--output=json")
3029        .arg(format!("--output-path={}", out.join("report").display()))
3030        .arg(format!("--form-factor={form_factor}"))
3031        .arg("--chrome-flags=--headless=new")
3032        .arg("--only-categories=accessibility,seo,best-practices");
3033    if mode_norm == "snapshot" {
3034        // Lighthouse user-flows / gather-mode snapshot (when supported by binary).
3035        cmd.arg("--gather-mode=snapshot");
3036    }
3037    let output = cmd.output().map_err(|e| {
3038        CliError::with_suggestion(
3039            ErrorKind::Unavailable,
3040            format!("lighthouse spawn failed: {e}"),
3041            crate::i18n::suggestion_key("lighthouse_missing", None),
3042        )
3043    })?;
3044    if !output.status.success() {
3045        let stderr = String::from_utf8_lossy(&output.stderr);
3046        return Err(CliError::with_suggestion(
3047            ErrorKind::Software,
3048            format!("lighthouse exited non-zero: {stderr}"),
3049            "Check URL and lighthouse install",
3050        ));
3051    }
3052    // Lighthouse may write report.report.html / report.report.json depending on version.
3053    // Move paths when the preferred name exists; otherwise build alternate PathBufs.
3054    let report_html = if html_path.exists() {
3055        html_path
3056    } else if out.join("report.report.html").exists() {
3057        out.join("report.report.html")
3058    } else {
3059        html_path
3060    };
3061    let report_json = if json_path.exists() {
3062        json_path
3063    } else if out.join("report.report.json").exists() {
3064        out.join("report.report.json")
3065    } else {
3066        // Some builds write plain report.json next to html
3067        out.join("report.json")
3068    };
3069
3070    let mut scores = Vec::new();
3071    let mut passed_audits = 0u64;
3072    let mut failed_audits = 0u64;
3073    if report_json.exists() {
3074        if let Ok(raw) = std::fs::read_to_string(&report_json) {
3075            if let Ok(lhr) = serde_json::from_str::<serde_json::Value>(&raw) {
3076                if let Some(cats) = lhr.get("categories").and_then(|c| c.as_object()) {
3077                    for (id, cat) in cats {
3078                        scores.push(serde_json::json!({
3079                            "id": id,
3080                            "title": cat.get("title").and_then(|t| t.as_str()).unwrap_or(id),
3081                            "score": cat.get("score"),
3082                        }));
3083                    }
3084                }
3085                if let Some(audits) = lhr.get("audits").and_then(|a| a.as_object()) {
3086                    for a in audits.values() {
3087                        if let Some(sc) = a.get("score").and_then(|s| s.as_f64()) {
3088                            if sc < 1.0 {
3089                                failed_audits += 1;
3090                            } else {
3091                                passed_audits += 1;
3092                            }
3093                        }
3094                    }
3095                }
3096            }
3097        }
3098    }
3099
3100    Ok(serde_json::json!({
3101        "lighthouse": true,
3102        "url": url,
3103        "device": form_factor,
3104        "mode": mode_norm,
3105        "binary": bin,
3106        "binary_source": binary_source.as_str(),
3107        "binary_present": true,
3108        "out_dir": out.to_string_lossy(),
3109        "reports": {
3110            "html": report_html.to_string_lossy(),
3111            "json": report_json.to_string_lossy(),
3112        },
3113        "scores": scores,
3114        "passed_audits": passed_audits,
3115        "failed_audits": failed_audits,
3116    }))
3117}
3118
3119fn handle_lighthouse(
3120    url: &str,
3121    out_dir: Option<&Path>,
3122    device: &str,
3123    mode: &str,
3124    lighthouse_path: Option<&Path>,
3125    json: bool,
3126) -> Result<(), CliError> {
3127    let data = lighthouse_to_value(url, out_dir, device, mode, lighthouse_path)?;
3128    emit_ok(data, json, |d| {
3129        crate::output::writeln_stdout(&format!("ok lighthouse report={}",
3130            d.pointer("/reports/html")
3131                .and_then(|v| v.as_str())
3132                .unwrap_or("")))?;
3133                Ok(())
3134    })
3135}
3136
3137fn handle_screencast(
3138    life: &Lifecycle,
3139    action: ScreencastAction,
3140    capture: CaptureOpts,
3141    timeout_secs: u64,
3142    json: bool,
3143) -> Result<(), CliError> {
3144    let data = with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3145        let v = match action {
3146            ScreencastAction::Start { path } => session.screencast_start(path.as_deref()).await?,
3147            ScreencastAction::Stop { path } => session.screencast_stop(path.as_deref()).await?,
3148        };
3149        Ok((session, v))
3150    })?;
3151    emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok screencast {d}")))
3152}
3153
3154fn handle_heap(
3155    life: &Lifecycle,
3156    action: HeapAction,
3157    capture: CaptureOpts,
3158    timeout_secs: u64,
3159    json: bool,
3160) -> Result<(), CliError> {
3161    match action {
3162        HeapAction::Take { path } => {
3163            let data =
3164                with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3165                    let v = session.heap_take(&path).await?;
3166                    Ok((session, v))
3167                })?;
3168            emit_ok(data, json, |d| {
3169                crate::output::writeln_stdout(&format!("ok heap take path={}",
3170                    d.get("path").and_then(|v| v.as_str()).unwrap_or("")))?;
3171                    Ok(())
3172    })
3173        }
3174        HeapAction::Close { path } => {
3175            let data = OneShotSession::heap_close(&path)?;
3176            emit_ok(data, json, |_| {
3177                crate::output::writeln_stdout(&format!("ok heap close path={}", path.display()))
3178            })
3179        }
3180        HeapAction::Compare {
3181            base,
3182            current,
3183            class_index,
3184        } => {
3185            let mut data = OneShotSession::heap_compare(&base, &current)?;
3186            if let Some(ci) = class_index {
3187                if let Some(obj) = data.as_object_mut() {
3188                    obj.insert("class_index".into(), serde_json::json!(ci));
3189                }
3190            }
3191            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap compare {d}")))
3192        }
3193        HeapAction::Summary { path } => {
3194            let data = OneShotSession::heap_file_summary(&path)?;
3195            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap summary {d}")))
3196        }
3197        HeapAction::Details {
3198            path,
3199            filter_name,
3200            page_idx,
3201            page_size,
3202        } => {
3203            validate_heap_filter_name(filter_name.as_deref())?;
3204            let mut data = OneShotSession::heap_details(&path)?;
3205            paginate_filter_json(
3206                &mut data,
3207                "classes",
3208                filter_name.as_deref(),
3209                page_idx,
3210                page_size,
3211            );
3212            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap details {d}")))
3213        }
3214        HeapAction::DupStrings {
3215            path,
3216            page_idx,
3217            page_size,
3218        } => {
3219            let mut data = OneShotSession::heap_dup_strings(&path)?;
3220            paginate_filter_json(&mut data, "strings", None, page_idx, page_size);
3221            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap dup-strings {d}")))
3222        }
3223        HeapAction::ClassNodes {
3224            path,
3225            id,
3226            filter_name,
3227            page_idx,
3228            page_size,
3229        } => {
3230            validate_heap_filter_name(filter_name.as_deref())?;
3231            let mut data = OneShotSession::heap_class_nodes(&path, id)?;
3232            paginate_filter_json(
3233                &mut data,
3234                "nodes",
3235                filter_name.as_deref(),
3236                page_idx,
3237                page_size,
3238            );
3239            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap class-nodes {d}")))
3240        }
3241        HeapAction::Dominators { path, node } => {
3242            let data = OneShotSession::heap_node_op(&path, node, "dominators")?;
3243            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap dominators {d}")))
3244        }
3245        HeapAction::Edges {
3246            path,
3247            node,
3248            page_idx,
3249            page_size,
3250        } => {
3251            let mut data = OneShotSession::heap_node_op(&path, node, "edges")?;
3252            paginate_filter_json(&mut data, "edges", None, page_idx, page_size);
3253            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap edges {d}")))
3254        }
3255        HeapAction::Retainers {
3256            path,
3257            node,
3258            page_idx,
3259            page_size,
3260        } => {
3261            let mut data = OneShotSession::heap_node_op(&path, node, "retainers")?;
3262            paginate_filter_json(&mut data, "retainers", None, page_idx, page_size);
3263            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap retainers {d}")))
3264        }
3265        HeapAction::Paths {
3266            path,
3267            node,
3268            max_depth,
3269            max_nodes,
3270            max_siblings,
3271        } => {
3272            let data = crate::native::heap_snapshot::node_op_with_limits(
3273                &path,
3274                node,
3275                "paths",
3276                max_depth as usize,
3277                max_siblings.unwrap_or(32) as usize,
3278                max_nodes.unwrap_or(200) as usize,
3279                200,
3280            )
3281            .map_err(|e| {
3282                CliError::with_suggestion(
3283                    ErrorKind::Io,
3284                    e,
3285                    "Pass a valid .heapsnapshot path and node id",
3286                )
3287            })?;
3288            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap paths {d}")))
3289        }
3290        HeapAction::ObjectDetails { path, node } => {
3291            let data = OneShotSession::heap_object_details(&path, node)?;
3292            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok heap object-details {d}")))
3293        }
3294    }
3295}
3296
3297/// Tool-ref heap filterName enum (closed set).
3298const HEAP_FILTER_NAME_ENUM: &[&str] = &[
3299    "objectsRetainedByDetachedDomNodes",
3300    "objectsRetainedByConsole",
3301    "objectsRetainedByEventHandlers",
3302    "objectsRetainedByContexts",
3303];
3304
3305fn validate_heap_filter_name(filter_name: Option<&str>) -> Result<(), CliError> {
3306    let Some(f) = filter_name else {
3307        return Ok(());
3308    };
3309    // Free-text substring filters stay allowed; enum-like names must match tool-ref.
3310    if f.starts_with("objectsRetained")
3311        && !HEAP_FILTER_NAME_ENUM
3312            .iter()
3313            .any(|e| e.eq_ignore_ascii_case(f))
3314    {
3315        return Err(CliError::with_suggestion(
3316            ErrorKind::Usage,
3317            format!("invalid heap --filter-name enum: {f}"),
3318            "Use objectsRetainedByDetachedDomNodes|objectsRetainedByConsole|objectsRetainedByEventHandlers|objectsRetainedByContexts or free-text substring",
3319        ));
3320    }
3321    Ok(())
3322}
3323
3324/// Paginate/filter a JSON array field for heap list ops (tool-ref pageIdx/pageSize/filterName).
3325fn paginate_filter_json(
3326    data: &mut serde_json::Value,
3327    array_key: &str,
3328    filter_name: Option<&str>,
3329    page_idx: Option<usize>,
3330    page_size: Option<usize>,
3331) {
3332    let key = {
3333        if data.get(array_key).and_then(|v| v.as_array()).is_some() {
3334            array_key.to_string()
3335        } else {
3336            let mut found = None;
3337            for alt in ["items", "results", "list"] {
3338                if data.get(alt).and_then(|v| v.as_array()).is_some() {
3339                    found = Some(alt.to_string());
3340                    break;
3341                }
3342            }
3343            match found {
3344                Some(k) => k,
3345                None => return,
3346            }
3347        }
3348    };
3349
3350    let is_enum_filter = filter_name
3351        .map(|f| {
3352            HEAP_FILTER_NAME_ENUM
3353                .iter()
3354                .any(|e| e.eq_ignore_ascii_case(f))
3355        })
3356        .unwrap_or(false);
3357
3358    if is_enum_filter {
3359        // Offline heapsnapshot parser does not recompute retainer-kind filters;
3360        // record the requested enum for agents and keep full list (honest Partial).
3361        if let Some(obj) = data.as_object_mut() {
3362            obj.insert(
3363                "filter_name".into(),
3364                serde_json::json!(filter_name.unwrap()),
3365            );
3366            obj.insert(
3367                "filter_applied".into(),
3368                serde_json::json!("enum_recorded_offline_not_recomputed"),
3369            );
3370        }
3371    }
3372
3373    let Some(arr) = data.get_mut(&key).and_then(|v| v.as_array_mut()) else {
3374        return;
3375    };
3376    if let Some(f) = filter_name {
3377        if !is_enum_filter {
3378            let f_low = f.to_ascii_lowercase();
3379            arr.retain(|item| {
3380                item.get("name")
3381                    .or_else(|| item.get("class_name"))
3382                    .or_else(|| item.get("string"))
3383                    .and_then(|v| v.as_str())
3384                    .map(|s| s.to_ascii_lowercase().contains(&f_low))
3385                    .unwrap_or(true)
3386            });
3387        }
3388    }
3389    let total = arr.len();
3390    let page = page_idx.unwrap_or(0);
3391    let size = page_size.unwrap_or(total.max(1));
3392    let start = page.saturating_mul(size).min(total);
3393    let end = (start + size).min(total);
3394    let page_items: Vec<serde_json::Value> = arr[start..end].to_vec();
3395    *arr = page_items;
3396    if let Some(obj) = data.as_object_mut() {
3397        obj.insert("total".into(), serde_json::json!(total));
3398        obj.insert("page_idx".into(), serde_json::json!(page));
3399        obj.insert("page_size".into(), serde_json::json!(size));
3400    }
3401}
3402
3403fn handle_extension(
3404    life: &Lifecycle,
3405    action: ExtensionAction,
3406    capture: CaptureOpts,
3407    timeout_secs: u64,
3408    json: bool,
3409) -> Result<(), CliError> {
3410    match action {
3411        ExtensionAction::List => {
3412            let data =
3413                with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3414                    let v = session.extension_list().await?;
3415                    Ok((session, v))
3416                })?;
3417            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok extension list {d}")))
3418        }
3419        ExtensionAction::Install { path } => {
3420            let path_s = path.display().to_string();
3421            let data = block_on_browser_timeout(
3422                async move {
3423                    let mut session =
3424                        OneShotSession::launch_with_extensions(capture, vec![path_s.clone()])
3425                            .await?;
3426                    life.record_chrome(session.chrome_pid());
3427                    // Service workers may take a moment to register after --load-extension.
3428                    let mut listed = session.extension_list().await?;
3429                    for _ in 0..20 {
3430                        let count = listed.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
3431                        if count > 0 {
3432                            break;
3433                        }
3434                        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
3435                        listed = session.extension_list().await?;
3436                    }
3437                    let close = session.shutdown().await;
3438                    life.clear_chrome();
3439                    close?;
3440                    Ok(serde_json::json!({
3441                        "installed_path": path_s,
3442                        "load_extension": true,
3443                        "targets": listed,
3444                        "note": "one-shot: Chrome launched with --load-extension for this process only",
3445                    }))
3446                },
3447                timeout_secs,
3448            )?;
3449            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok extension install {d}")))
3450        }
3451        ExtensionAction::Reload { id, path } => {
3452            // Match already owns `id`/`path` — move into the async block (no clone).
3453            let path_s = path.as_ref().map(|p| p.display().to_string());
3454            let data = block_on_browser_timeout(
3455                async move {
3456                    let mut session = if let Some(p) = path_s {
3457                        OneShotSession::launch_with_extensions(capture, vec![p]).await?
3458                    } else {
3459                        OneShotSession::launch_headless_with_capture(capture).await?
3460                    };
3461                    life.record_chrome(session.chrome_pid());
3462                    let v = session.extension_reload(&id).await;
3463                    let close = session.shutdown().await;
3464                    life.clear_chrome();
3465                    close?;
3466                    v
3467                },
3468                timeout_secs,
3469            )?;
3470            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok extension reload {d}")))
3471        }
3472        ExtensionAction::Trigger { id, path } => {
3473            let path_s = path.as_ref().map(|p| p.display().to_string());
3474            let data = block_on_browser_timeout(
3475                async move {
3476                    let mut session = if let Some(p) = path_s {
3477                        OneShotSession::launch_with_extensions(capture, vec![p]).await?
3478                    } else {
3479                        OneShotSession::launch_headless_with_capture(capture).await?
3480                    };
3481                    life.record_chrome(session.chrome_pid());
3482                    let v = session.extension_trigger(&id).await;
3483                    let close = session.shutdown().await;
3484                    life.clear_chrome();
3485                    close?;
3486                    v
3487                },
3488                timeout_secs,
3489            )?;
3490            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok extension trigger {d}")))
3491        }
3492        ExtensionAction::Uninstall { id } => {
3493            // One clone for human emit after the async move consumes `id`.
3494            let id_print = id.clone();
3495            // Prefer in-process unload when a session can be opened; otherwise honest metadata.
3496            let data = block_on_browser_timeout(
3497                async move {
3498                    let mut session = OneShotSession::launch_headless_with_capture(capture).await?;
3499                    life.with_ledger_mut(|ledger| {
3500                        ledger.chrome_launched = true;
3501                        ledger.chrome_pid = session.chrome_pid();
3502                        if let Some(dir) = session.temp_user_data_dir() {
3503                            ledger.profile_dir = Some(dir);
3504                        }
3505                    });
3506                    let v = session.extension_uninstall(&id).await;
3507                    let close = session.shutdown().await;
3508                    life.clear_chrome_and_profile();
3509                    close?;
3510                    v
3511                },
3512                timeout_secs,
3513            )?;
3514            emit_ok(data, json, |d| {
3515                let effect = d.get("effect").and_then(|v| v.as_str()).unwrap_or("?");
3516                crate::output::writeln_stdout(&format!("ok extension uninstall id={id_print} effect={effect}"))
3517            })
3518        }
3519    }
3520}
3521
3522fn handle_devtools3p(
3523    life: &Lifecycle,
3524    action: Devtools3pAction,
3525    capture: CaptureOpts,
3526    timeout_secs: u64,
3527    json: bool,
3528) -> Result<(), CliError> {
3529    match action {
3530        Devtools3pAction::List { url } => {
3531            let url = url.unwrap_or_else(|| "about:blank".into());
3532            let data =
3533                with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3534                    if url != "about:blank" {
3535                        let _ = session
3536                            .goto(&url, crate::robots::RobotsPolicy::Ignore)
3537                            .await?;
3538                    }
3539                    let v = session.devtools3p_list().await?;
3540                    Ok((session, v))
3541                })?;
3542            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok devtools3p list {d}")))
3543        }
3544        Devtools3pAction::Exec { name, params, url } => {
3545            // `name`/`params` already owned by the match binding — move, do not clone.
3546            let url = url.unwrap_or_else(|| "about:blank".into());
3547            let data =
3548                with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3549                    if url != "about:blank" {
3550                        let _ = session
3551                            .goto(&url, crate::robots::RobotsPolicy::Ignore)
3552                            .await?;
3553                    }
3554                    let v = session.devtools3p_exec(&name, params.as_deref()).await?;
3555                    Ok((session, v))
3556                })?;
3557            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok devtools3p exec {d}")))
3558        }
3559    }
3560}
3561
3562fn handle_webmcp(
3563    life: &Lifecycle,
3564    action: WebmcpAction,
3565    capture: CaptureOpts,
3566    timeout_secs: u64,
3567    json: bool,
3568) -> Result<(), CliError> {
3569    match action {
3570        WebmcpAction::List { url } => {
3571            let url = url.unwrap_or_else(|| "about:blank".into());
3572            let data =
3573                with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3574                    if url != "about:blank" {
3575                        let _ = session
3576                            .goto(&url, crate::robots::RobotsPolicy::Ignore)
3577                            .await?;
3578                    }
3579                    let v = session.webmcp_list().await?;
3580                    Ok((session, v))
3581                })?;
3582            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok webmcp list {d}")))
3583        }
3584        WebmcpAction::Exec { name, input, url } => {
3585            // Move owned match bindings into the async block (no redundant clone).
3586            let url = url.unwrap_or_else(|| "about:blank".into());
3587            let data =
3588                with_session_blank(life, capture, timeout_secs, move |mut session| async move {
3589                    if url != "about:blank" {
3590                        let _ = session
3591                            .goto(&url, crate::robots::RobotsPolicy::Ignore)
3592                            .await?;
3593                    }
3594                    let v = session.webmcp_exec(&name, input.as_deref()).await?;
3595                    Ok((session, v))
3596                })?;
3597            emit_ok(data, json, |d|  crate::output::writeln_stdout(&format!("ok webmcp exec {d}")))
3598        }
3599    }
3600}
3601
3602fn handle_completions(shell: CompletionShell) -> Result<(), CliError> {
3603    use clap::CommandFactory;
3604    use clap_complete::{generate, shells};
3605    use std::io::Write;
3606
3607    let mut cmd = crate::cli::Cli::command();
3608    let bin = "browser-automation-cli";
3609    let mut out = std::io::stdout();
3610    match shell {
3611        CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin, &mut out),
3612        CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin, &mut out),
3613        CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin, &mut out),
3614        CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin, &mut out),
3615        CompletionShell::Powershell => generate(shells::PowerShell, &mut cmd, bin, &mut out),
3616    }
3617    let _ = out.flush();
3618    Ok(())
3619}
3620
3621/// Render man page (roff) with clap_mangen to stdout or `--out PATH`.
3622fn handle_man(out: Option<&Path>) -> Result<(), CliError> {
3623    use clap::CommandFactory;
3624    use std::io::Write;
3625
3626    let cmd = crate::cli::Cli::command();
3627    let man = clap_mangen::Man::new(cmd);
3628    let mut buf = Vec::new();
3629    man.render(&mut buf).map_err(|e| {
3630        CliError::new(
3631            ErrorKind::Software,
3632            format!("manpage render failed: {e}"),
3633        )
3634    })?;
3635
3636    if let Some(path) = out {
3637        crate::validation::reject_path_traversal(path).map_err(|m| {
3638            CliError::with_suggestion(ErrorKind::Usage, m, "Pass a path without `..` components")
3639        })?;
3640        if let Some(parent) = path.parent() {
3641            if !parent.as_os_str().is_empty() {
3642                std::fs::create_dir_all(parent).map_err(|e| {
3643                    CliError::new(ErrorKind::Io, format!("create man parent: {e}"))
3644                })?;
3645            }
3646        }
3647        // Atomic write: temp + rename beside destination when possible.
3648        let tmp = path.with_extension("1.tmp");
3649        std::fs::write(&tmp, &buf)
3650            .map_err(|e| CliError::new(ErrorKind::Io, format!("write man temp: {e}")))?;
3651        std::fs::rename(&tmp, path).map_err(|e| {
3652            let _ = std::fs::remove_file(&tmp);
3653            CliError::new(ErrorKind::Io, format!("rename man page: {e}"))
3654        })?;
3655    } else {
3656        let mut stdout = std::io::stdout();
3657        stdout
3658            .write_all(&buf)
3659            .map_err(|e| CliError::new(ErrorKind::BrokenPipe, format!("stdout: {e}")))?;
3660        let _ = stdout.flush();
3661    }
3662    Ok(())
3663}
3664
3665#[cfg(test)]
3666mod lighthouse_tests {
3667    use super::*;
3668    use std::path::PathBuf;
3669
3670    #[test]
3671    fn mock_lighthouse_parses_scores() {
3672        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3673        let mock = root.join("scripts/mock-lighthouse.sh");
3674        if !mock.is_file() {
3675            eprintln!("skip: mock-lighthouse.sh missing");
3676            return;
3677        }
3678        #[cfg(unix)]
3679        {
3680            use std::os::unix::fs::PermissionsExt;
3681            let _ = std::fs::set_permissions(&mock, std::fs::Permissions::from_mode(0o755));
3682        }
3683        let out = tempfile::tempdir().expect("tmp");
3684        let v = lighthouse_to_value(
3685            "https://example.com",
3686            Some(out.path()),
3687            "desktop",
3688            "navigation",
3689            Some(&mock),
3690        )
3691        .expect("mock lighthouse");
3692        assert_eq!(
3693            v.get("binary_source").and_then(|s| s.as_str()),
3694            Some("mock")
3695        );
3696        assert_eq!(
3697            v.get("binary_present").and_then(|b| b.as_bool()),
3698            Some(true)
3699        );
3700        let scores = v
3701            .get("scores")
3702            .and_then(|s| s.as_array())
3703            .cloned()
3704            .unwrap_or_default();
3705        assert!(!scores.is_empty(), "expected scores from mock LHR, got {v}");
3706    }
3707
3708    #[test]
3709    fn resolve_missing_is_unavailable() {
3710        let err =
3711            resolve_lighthouse_binary(Some(Path::new("/no/such/lighthouse-bin-xyz"))).unwrap_err();
3712        assert_eq!(err.kind(), ErrorKind::Usage);
3713    }
3714}