Skip to main content

browser_automation_cli/commands_prd/
mod.rs

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