Skip to main content

browser_automation_cli/commands_prd/
mod.rs

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