Skip to main content

browser_automation_cli/commands_prd/
mod.rs

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