Skip to main content

browser_automation_cli/commands_prd/
mod.rs

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