browser_automation_cli/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! # browser-automation-cli
3//!
4//! One-shot Chrome CDP automation library and CLI for AI agents.
5//!
6//! Lifecycle is always **BORN → EXECUTE → FINALIZE → DIE** in a single process.
7//! There is no daemon, no npm runtime, and no remote telemetry.
8//!
9//! ## Overview
10//!
11//! - Parse argv with clap ([`cli`])
12//! - Dispatch one command or a multi-step `run --script` session
13//! - Launch system Chrome/Chromium through chromiumoxide CDP
14//! - Always attempt FINALIZE (Browser.close, wait, kill fallback)
15//!
16//! ## Quick Start
17//!
18//! ```bash
19//! cargo install --path . --locked
20//! browser-automation-cli doctor --offline --quick --json
21//! browser-automation-cli goto https://example.com --json
22//! ```
23//!
24//! Library entry for embedding or tests:
25//!
26//! ```no_run
27//! use std::process::ExitCode;
28//!
29//! fn main() -> ExitCode {
30//! browser_automation_cli::run()
31//! }
32//! ```
33//!
34//! ## Features
35//!
36//! Cargo features control **optional locale packs** only (MVP always includes `en` + `pt-BR`):
37//!
38//! | Feature | Purpose |
39//! |---------|---------|
40//! | `i18n-cjk` | Scaffold for zh-Hans / zh-Hant / ja / ko packs |
41//! | `i18n-rtl` | Scaffold for ar / he (RTL) packs |
42//! | `i18n-europe` | Scaffold for additional European packs |
43//! | `i18n-full` | Enables all optional i18n scaffolds |
44//! | `i18n-pseudo` | Pseudolocalization (dev only) |
45//!
46//! Rustdoc embeds Mermaid lifecycle diagrams via `aquamarine` on documented entry points.
47//! Optional CLI categories are process flags:
48//!
49//! - `--category-memory` — deep heap tools
50//! - `--category-extensions` — extension tools
51//! - `--category-third-party` — third-party DevTools helpers
52//! - `--category-webmcp` — webmcp tools
53//! - `--experimental-vision` — coordinate click
54//! - `--experimental-screencast` — screencast export (needs ffmpeg)
55//!
56//! ## Targets
57//!
58//! Documented and tested for:
59//!
60//! - `x86_64-unknown-linux-gnu`
61//! - `x86_64-apple-darwin`
62//! - `aarch64-apple-darwin`
63//! - `x86_64-pc-windows-msvc`
64//! - `aarch64-unknown-linux-musl`
65//!
66//! Chrome automation is not supported on `wasm32-unknown-unknown`.
67//!
68//! ## MSRV
69//!
70//! Minimum Supported Rust Version is **1.88.0** (`rust-version` in `Cargo.toml`).
71//!
72//! ## Graceful shutdown (one-shot)
73//!
74//! Detect → signal → await, scoped for a **CLI one-shot** (not a long-lived server):
75//!
76//! | Phase | Mechanism |
77//! |-------|-----------|
78//! | Detect | [`browser::shutdown_signal`] — SIGINT/SIGTERM (Unix), Ctrl-C/Break (Windows) |
79//! | Signal | [`lifecycle::Lifecycle`] `CancellationToken` → exit **130** |
80//! | Await | `OneShotSession::shutdown` (Browser.close + wait ≤5s + kill); residual SIGTERM→grace→SIGKILL |
81//! | Pipeline | SIGPIPE default + BrokenPipe → exit **141**; dual flush before DIE |
82//! | Force | Second OS signal runs residual [`lifecycle::Lifecycle::finalize`] |
83//!
84//! Daemon-only rules (TaskTracker fleets, SIGHUP reload, readiness probes, `sd_notify`)
85//! are **N/A** by product law.
86//!
87//! ## Safety
88//!
89//! - No remote telemetry is emitted by this crate (no OTEL/OTLP/Sentry)
90//! - Local tracing: stderr by default; optional rotated JSON under XDG state
91//! (`log_to_file`); see [`telemetry`]
92//! - Unix paths may call `libc` for signal defaults and last-resort process kill
93//! - Windows paths may use Job Objects (`win_job`) for residual-zero process trees
94//!
95//! ### docs.rs / rustdoc feature gates (nightly)
96//!
97//! - `docs.rs` builds this crate with `--cfg docsrs` (see `[package.metadata.docs.rs]`)
98//! - Under `docsrs`, the crate root enables `#![feature(doc_cfg)]` so
99//! `#[doc(cfg(...))]` and `#[cfg_attr(docsrs, doc(cfg(...)))]` render platform
100//! and feature badges on multi-target docs
101//! - **`doc_auto_cfg` is not used**: as of the October 2025 rustdoc consolidation,
102//! automatic cfg labels live under `doc_cfg` only; enabling the removed
103//! `doc_auto_cfg` feature gate risks nightly docs.rs failures
104//! - Stable `cargo doc` does not enable `docsrs`; platform items still compile via
105//! normal `#[cfg(unix)]` / `#[cfg(windows)]` without the experimental feature
106//!
107//! ## Error handling
108//!
109//! Public errors use [`error::CliError`] with sysexits-style exit codes.
110//! JSON agents should parse the envelope from [`envelope`].
111//!
112//! ## Examples
113//!
114//! ```no_run
115//! use browser_automation_cli::error::{CliError, ErrorKind};
116//! use browser_automation_cli::exit_code_for;
117//!
118//! let err = CliError::new(ErrorKind::Unavailable, "chrome not found");
119//! assert_eq!(exit_code_for(&err), 69);
120//! ```
121//!
122//! ## See also
123//!
124//! - Crate README and `docs/HOW_TO_USE.md`
125//! - `docs/schemas/` for JSON contracts
126//! - `skill/browser-automation-cli-en/SKILL.md` for agent skill surface
127//! - Local validation: `scripts/docs-check.sh` (HTML + optional rustdoc JSON; no CI/GHA)
128
129// docs.rs / nightly: `doc_cfg` only (rules_rust_docsrs — no `doc_auto_cfg`).
130#![cfg_attr(docsrs, feature(doc_cfg))]
131#![warn(missing_docs)]
132#![warn(rustdoc::missing_crate_level_docs)]
133#![deny(rustdoc::broken_intra_doc_links)]
134#![warn(rustdoc::private_intra_doc_links)]
135#![deny(rustdoc::invalid_html_tags)]
136#![deny(rustdoc::invalid_rust_codeblocks)]
137#![deny(rustdoc::bare_urls)]
138#![warn(rustdoc::redundant_explicit_links)]
139// Document every `unsafe` block (rules: English + crates.io safety docs).
140#![warn(clippy::undocumented_unsafe_blocks)]
141#![warn(clippy::multiple_unsafe_ops_per_block)]
142#![deny(unsafe_op_in_unsafe_fn)]
143// Const / static hygiene (rules_rust_const_static_inicializacao).
144#![deny(static_mut_refs)]
145#![warn(clippy::declare_interior_mutable_const)]
146#![warn(clippy::borrow_interior_mutable_const)]
147// Ownership / borrowing hygiene (rules_rust_ownership_borrowing_lifetimes).
148#![warn(clippy::redundant_clone)]
149#![warn(clippy::needless_pass_by_value)]
150#![warn(clippy::ptr_arg)]
151#![warn(clippy::implicit_clone)]
152#![warn(clippy::unnecessary_to_owned)]
153#![warn(clippy::cloned_instead_of_copied)]
154#![warn(clippy::map_clone)]
155#![warn(clippy::mut_mut)]
156#![warn(clippy::needless_lifetimes)]
157
158/// Chrome one-shot session: launch, actions, reap.
159pub mod browser;
160/// HTTP/parse cache under XDG (one-shot L1 + SQLite L2).
161pub mod cache;
162/// Clap derive surface and global flags.
163pub mod cli;
164/// Injectable wall clock for deterministic tests.
165pub mod clock;
166/// ANSI color helpers for human stderr diagnostics.
167pub mod color;
168/// PRD command dispatch (meta paths and browser one-shot).
169pub mod commands_prd;
170/// Config surface (re-export of XDG; layout name for clap rules).
171pub mod config;
172/// Shared constants (schema version, product name).
173pub mod constants;
174/// Local install diagnostics (`doctor`).
175pub mod doctor;
176/// JSON success/error envelopes for agents.
177pub mod envelope;
178/// Typed CLI errors and exit codes.
179pub mod error;
180/// One-shot filesystem path discovery (`find-paths`).
181pub mod find_paths;
182/// Locale messaging helpers.
183pub mod i18n;
184/// Install path helpers for doctor and packaging checks.
185pub mod install;
186/// Shared JSON / NDJSON helpers (BOM strip, size ceilings, compact encode).
187pub mod json_util;
188/// Cooperative cancel and FINALIZE ledger.
189pub mod lifecycle;
190/// Optional one-shot LLM HTTP extract (XDG key only).
191pub mod llm_local;
192/// Local MITM capture, CA, and HAR export (one-shot).
193pub mod mitm_local;
194/// Native CDP stack (browser, network, snapshot, heap).
195pub mod native;
196/// Canonical stdout/stderr writers (BrokenPipe → 141, explicit flush).
197pub mod output;
198/// One-shot QR encode/decode (no Chrome).
199pub mod qr_local;
200/// Owned residual path discovery (CLI marker + chromium tmp).
201pub mod residual;
202/// Named retry policies with backoff and jitter.
203pub mod retry;
204/// robots.txt policy enforcement.
205pub mod robots;
206/// Local scrape/crawl/map/search/parse (HTTP + files; one-shot).
207pub mod scrape_local;
208/// Structural lint scan/rewrite one-shot (§5AC).
209pub mod sg_local;
210/// XLSX write-only path via rust_xlsxwriter (§5Z).
211pub mod sheet_local;
212/// Cross-platform host helpers (PATH, console UTF-8/VT, sandbox, WSL/container).
213pub mod platform;
214/// Input and path validation helpers.
215pub mod validation;
216/// Windows Job Object helpers (stubs on non-Windows).
217pub mod win_job;
218/// Workflow journal DAG (petgraph + SQLite), one-shot run/resume.
219pub mod workflow_local;
220/// Bounded parallelism budget (`--max-concurrency`, Semaphore, Rayon, join_bounded).
221pub mod concurrency;
222/// Tokio runtime builders (budgeted multi-thread workers for CDP + HTTP fan-out).
223pub mod runtime_util;
224/// Local tracing init (stderr + optional XDG rotated JSON; no remote export).
225pub mod telemetry;
226/// XDG Base Directory paths and config file (no `.env` at runtime).
227pub mod xdg;
228
229#[cfg(test)]
230#[allow(missing_docs)]
231pub mod test_utils;
232
233use std::process::ExitCode;
234
235use clap::{CommandFactory, Parser};
236
237use crate::cli::Cli;
238use crate::error::CliError;
239use crate::lifecycle::Lifecycle;
240
241/// Parse process argv and run the one-shot CLI.
242///
243/// Thin wrapper over [`run_from_args`] with `std::env::args_os()`.
244///
245/// Always attempts FINALIZE before returning, including on clap help/version paths.
246///
247/// # Lifecycle
248///
249/// ```mermaid
250/// flowchart LR
251/// BORN → EXECUTE → FINALIZE → DIE
252/// ```
253///
254/// # Returns
255///
256/// Process [`ExitCode`] mapped from sysexits-style CLI codes.
257#[cfg_attr(doc, aquamarine::aquamarine)]
258pub fn run() -> ExitCode {
259 run_from_args(std::env::args_os())
260}
261
262/// Parse the given argv (including program name as first element) and run.
263///
264/// Enables tests and embedders to inject argv without mutating process-global
265/// state. Stdin/stdout/stderr remain the process streams (Unix pipes / agent
266/// contract); full stream injection is reserved for unit tests of [`output`].
267///
268/// # Returns
269///
270/// Process [`ExitCode`] mapped from sysexits-style CLI codes.
271pub fn run_from_args<I, T>(args: I) -> ExitCode
272where
273 I: IntoIterator<Item = T>,
274 T: Into<std::ffi::OsString> + Clone,
275{
276 // Phase 1 multiplatform: Windows console UTF-8 + VT before any user-facing I/O.
277 crate::platform::configure_console();
278
279 // SAFETY:
280 // - Contract: restore default SIGPIPE so BrokenPipe becomes EPIPE (exit 141 path).
281 // - Invariant: `signal` is async-signal-safe and only replaces the disposition.
282 // - Caller/callee: process owns its signal table; no other handler is required at BORN.
283 // - See: `man 2 signal`, POSIX SIG_DFL; product maps EPIPE via `output::map_io_error`.
284 #[cfg(unix)]
285 unsafe {
286 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
287 }
288
289 #[cfg(windows)]
290 {
291 std::env::set_var("MSYS_NO_PATHCONV", "1");
292 std::env::set_var("MSYS2_ARG_CONV_EXCL", "*");
293 }
294
295 let life = Lifecycle::new();
296 let args: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
297 let wants_json = args.iter().any(|a| a == "--json");
298
299 let cli = match Cli::try_parse_from(&args) {
300 Ok(c) => c,
301 Err(e) => {
302 // clap: DisplayHelp/DisplayVersion → exit 0; usage errors → 2
303 // GAP-002: when `--json` is on argv, emit agent envelope on stdout (not human clap only).
304 let code = e.exit_code();
305 let is_help_or_version = matches!(
306 e.kind(),
307 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
308 );
309 if wants_json && !is_help_or_version && code != 0 {
310 let msg = e.to_string();
311 let err = crate::error::CliError::with_suggestion(
312 crate::error::ErrorKind::Usage,
313 msg.lines().next().unwrap_or("invalid arguments").to_string(),
314 "Check --help or schema --cmd <name>; pass valid argv",
315 );
316 let _ = crate::envelope::print_error_json(&err);
317 } else {
318 let _ = e.print();
319 }
320 let _ = crate::output::flush_stdout();
321 let _ = crate::output::flush_stderr();
322 life.finalize();
323 return ExitCode::from(code as u8);
324 }
325 };
326
327 // Accessibility / agent plain stderr (also honors NO_COLOR / CLICOLOR / TERM=dumb).
328 // Phase 2 of i18n boot: TTY/plain before any colored human text.
329 crate::color::set_plain(cli.globals.plain);
330
331 // Resolve UI locale once (flag > BROWSER_AUTOMATION_CLI_LANG > XDG > sys-locale > en).
332 // Human suggestions only; machine JSON `error.message` stays English.
333 let resolved = crate::i18n::resolve_locale(cli.globals.lang.as_deref());
334 crate::i18n::set_effective_idioma(resolved);
335
336 // Process-wide concurrency budget (rules_rust_paralelismo): every fan-out
337 // reads `concurrency::effective_limit()`. `0` = auto (CPU × free RAM).
338 crate::concurrency::install_limit(cli.globals.max_concurrency);
339 crate::concurrency::install_rayon_pool_once();
340
341 // Install subscriber once; hold WorkerGuard (file path) until FINALIZE/DIE so
342 // non_blocking flushes (rules_rust_logs: never mem::forget the guard).
343 let _telemetry = crate::telemetry::init_telemetry(crate::telemetry::TelemetryOpts {
344 quiet: cli.globals.quiet,
345 verbose: cli.globals.verbose,
346 debug: cli.globals.debug,
347 plain: cli.globals.plain,
348 });
349
350 let code = commands_prd::dispatch(cli, &life);
351 // FINALIZE: flush both streams (rules: flush stdout+stderr before exit).
352 let _ = crate::output::flush_stdout();
353 let _ = crate::output::flush_stderr();
354 // finalize is called inside dispatch; call again is idempotent
355 life.finalize();
356 // Drop `_telemetry` after flush so file WorkerGuard drains last lines.
357 drop(_telemetry);
358 if code <= 0 {
359 ExitCode::SUCCESS
360 } else if code >= 256 {
361 ExitCode::from(255)
362 } else {
363 ExitCode::from(code as u8)
364 }
365}
366
367/// Run clap `debug_assert` on the command tree (tests and diagnostics).
368pub fn command_factory_debug_assert() {
369 Cli::command().debug_assert();
370}
371
372/// Map a [`CliError`] to its process exit code without parsing argv.
373///
374/// Useful for unit tests and library callers that already hold a typed error.
375pub fn exit_code_for(err: &CliError) -> u8 {
376 err.exit_code()
377}
378
379/// Build identity for `version` and packaging diagnostics.
380///
381/// `git_sha` / `build_timestamp` come from `build.rs` (`cargo:rustc-env`).
382pub fn build_identity() -> serde_json::Value {
383 serde_json::json!({
384 "name": env!("CARGO_PKG_NAME"),
385 "version": env!("CARGO_PKG_VERSION"),
386 "git_sha": option_env!("GIT_SHA").unwrap_or("unknown"),
387 "build_timestamp": option_env!("BUILD_TIMESTAMP").unwrap_or("unknown"),
388 })
389}
390
391