use clap::{ArgGroup, Parser, Subcommand, ValueEnum};
const AFTER_LONG_HELP: &str = "\
COMMAND REFERENCE:
Launch & connect:
ff-rdp launch [--headless] [--profile PATH | --temp-profile] [--auto-consent] [--port PORT]
ff-rdp doctor # diagnose connection, port, tabs, version
ff-rdp tabs
Navigate & wait:
ff-rdp navigate <URL> [--with-network] [--wait-text T | --wait-selector S] [--wait-timeout MS]
ff-rdp reload [--wait-idle [--idle-ms MS] [--reload-timeout MS]]
ff-rdp back | forward
ff-rdp wait --selector S | --text T | --eval JS [--wait-timeout MS]
Page content:
ff-rdp eval <SCRIPT> | --file PATH | --stdin [--stringify] [--no-isolate]
ff-rdp page-text
ff-rdp dom <SEL> [--text | --attrs | --text-attrs | --inner-html | --count]
ff-rdp dom stats
ff-rdp dom tree [SEL] [--depth N] [--max-chars N]
ff-rdp snapshot [--depth N] [--max-chars N]
Interaction:
ff-rdp click <SEL>
ff-rdp type <SEL> <TEXT> [--clear] # or: type --selector S --text T [--clear]
Scrolling:
ff-rdp scroll to <SEL> [--block top|center|bottom] [--smooth]
ff-rdp scroll by [--dy PX | --page-down | --page-up] [--dx PX] [--smooth]
ff-rdp scroll top | bottom
ff-rdp scroll container <SEL> [--dy PX] [--to-end | --to-start]
ff-rdp scroll until <SEL> [--direction up|down] [--timeout MS]
ff-rdp scroll text <TEXT>
CSS & styles:
ff-rdp computed <SEL> [--prop NAME | --all]
ff-rdp styles <SEL> [--properties P1,P2 | --applied | --layout]
ff-rdp geometry <SEL>... [--visible-only]
ff-rdp responsive <SEL>... [--widths W1,W2,...]
Accessibility:
ff-rdp a11y [--depth N] [--selector SEL] [--interactive]
ff-rdp a11y contrast [--selector SEL] [--fail-only]
ff-rdp a11y summary
Performance:
ff-rdp perf [--type TYPE] [--filter URL] [--group-by domain]
ff-rdp perf vitals | summary | audit
ff-rdp perf compare <URL>... [--label L1,L2,...]
Monitoring:
ff-rdp console [--level LEVEL] [--pattern REGEX] [--follow]
ff-rdp network [--filter URL] [--method M] [--follow]
Storage:
ff-rdp cookies [--name NAME]
ff-rdp storage local|session [--key KEY]
Screenshot & debug:
ff-rdp screenshot [-o PATH | --base64] [--full-page | --viewport-height PX]
ff-rdp inspect <ACTOR_ID> [--depth N]
ff-rdp sources [--filter URL | --pattern REGEX]
AI AGENT TIPS:
- Use --format text instead of JSON for 3-10x fewer tokens
- Use eval --stringify '<expr>' to get actual values instead of actor grip metadata
- Use styles --properties color,display,font-size (bare styles dumps ~500 properties)
- Use a11y summary for a flat list instead of the full tree (can be 400+ lines)
- Use snapshot --depth 3 for a quick page overview
- Use dom \"sel\" --text-attrs to get both text content and attributes together
- Follow the contextual hints (-> lines) for suggested next commands
COOKBOOK:
# Launch Firefox (safe alongside your normal browser)
ff-rdp launch
ff-rdp launch --headless
ff-rdp launch --headless --auto-consent
# Navigate and verify
ff-rdp navigate https://example.com --wait-text \"Welcome\"
ff-rdp eval \"document.title\"
ff-rdp dom \"h1\" --text
# Fill and submit a form
ff-rdp type \"input[name=email]\" \"user@example.com\" --clear
ff-rdp type \"input[name=password]\" \"secret\" --clear
ff-rdp click \"button[type=submit]\"
ff-rdp wait --text \"Dashboard\" --wait-timeout 10000
# Full page audit
ff-rdp navigate https://example.com --with-network
ff-rdp perf audit
ff-rdp a11y contrast --fail-only
ff-rdp network --detail --limit 10
ff-rdp screenshot -o audit.png
# Performance
ff-rdp perf vitals --jq '.results.lcp_ms'
ff-rdp perf --all --jq '[.results | sort_by(-.duration_ms) | limit(5;.) | {url,duration_ms}]'
ff-rdp perf compare https://a.example https://b.example --label \"Before,After\"
# Network debugging
ff-rdp network --detail --jq '[.results[] | select(.status >= 400) | {url,status}]'
ff-rdp network --follow --filter \".js\"
# Console monitoring
ff-rdp console --level error --jq '.results[].message'
ff-rdp console --follow --level error
# Scrolling (overflow containers, lazy-loaded content)
ff-rdp scroll by --page-down
ff-rdp scroll container \".sidebar\" --to-end
ff-rdp scroll until \".load-more-sentinel\" --timeout 10000
ff-rdp scroll text \"Contact Us\"
# Accessibility
ff-rdp a11y summary --format text
ff-rdp a11y contrast --fail-only
ff-rdp a11y --interactive --jq '[.. | select(.role? == \"link\") | .name]'
# DOM and CSS inspection
ff-rdp dom \"a[href]\" --text-attrs
ff-rdp dom stats --jq '.results.node_count'
ff-rdp computed h1 --prop color
ff-rdp styles \"h1\" --properties color,display,font-size
ff-rdp geometry \".modal\" \".overlay\" --jq '.results.overlaps'
# Responsive testing
ff-rdp responsive \"h1\" \"nav\" \".sidebar\" --widths 320,768,1440
# Screenshot for AI vision
ff-rdp screenshot --base64
OUTPUT FORMAT:
All commands return JSON: {\"results\": ..., \"total\": N, \"meta\": {...}}
Truncated output adds: {\"truncated\": true, \"hint\": \"showing 20 of 84, use --all\"}
Use --jq to filter the envelope: --jq '.results[0]', --jq '.total'
Use --format text for human-readable tables (mutually exclusive with --jq)
Use --detail for per-entry output on list commands (default is summary view)
Contextual hints suggest follow-up commands: \"hints\": [...] in JSON, -> lines in text
Hints default: on for --format text, off for JSON. Override: --hints / --no-hints
--jq always suppresses hints (pipeline needs clean data)
TROUBLESHOOTING:
When stuck, run `ff-rdp doctor` first — it probes daemon, port owner,
RDP handshake, tab count, and Firefox version in one command.
Common failure modes:
\"port N is already in use\" -> ff-rdp doctor # who is on the port
\"no tabs available\" -> ff-rdp doctor # is Firefox even talking
\"could not connect to Firefox\" -> ff-rdp doctor # is the listener up
\"actor error from server1...\" -> ff-rdp doctor # stale connection?
Connection timeout / hang -> ff-rdp doctor # then increase --timeout
Zero results:
network returns 0 -> page loaded before connection; use navigate --with-network
console returns 0 -> use --follow to stream, or eval 'console.log(\"test\")'
cookies returns 0 -> consent banner may be blocking; use launch --auto-consent
Connection errors:
\"could not connect\" -> run ff-rdp launch first (safe alongside normal browser)
Timeout -> increase --timeout or check --port matches the launched instance";
#[derive(Parser)]
#[command(
name = "ff-rdp",
about = "Firefox Remote Debugging Protocol CLI\n\nQuick start: ff-rdp launch # start Firefox with debugging enabled\n ff-rdp navigate <URL> # open a page",
long_about = "Firefox Remote Debugging Protocol CLI
Quick start:
ff-rdp launch Launch a new Firefox instance with remote debugging
ff-rdp launch --headless Launch headless (no visible window)
ff-rdp navigate https://example.com
'ff-rdp launch' starts a separate Firefox process that won't interfere with
any already-running Firefox windows — it uses a temporary profile and
the -no-remote flag automatically.",
after_help = "Tip: Run 'ff-rdp launch' first to start Firefox with remote debugging.\n It won't affect any existing Firefox windows — safe to run alongside\n your normal browser.",
after_long_help = AFTER_LONG_HELP,
version
)]
pub struct Cli {
#[arg(long, default_value = "localhost", global = true)]
pub host: String,
#[arg(long, default_value_t = 6000, global = true)]
pub port: u16,
#[arg(long, global = true)]
pub tab: Option<String>,
#[arg(long, global = true)]
pub tab_id: Option<String>,
#[arg(long, global = true)]
pub jq: Option<String>,
#[arg(long, default_value_t = 5000, global = true)]
pub timeout: u64,
#[arg(long, global = true)]
pub no_daemon: bool,
#[arg(long, default_value_t = 300, global = true)]
pub daemon_timeout: u64,
#[arg(long, global = true)]
pub allow_unsafe_urls: bool,
#[arg(long, global = true)]
pub limit: Option<usize>,
#[arg(long, global = true, conflicts_with = "limit")]
pub all: bool,
#[arg(long, global = true)]
pub sort: Option<String>,
#[arg(long, global = true, conflicts_with = "desc")]
pub asc: bool,
#[arg(long, global = true, conflicts_with = "asc")]
pub desc: bool,
#[arg(long, global = true, value_delimiter = ',')]
pub fields: Option<Vec<String>>,
#[arg(long, global = true)]
pub detail: bool,
#[arg(long, default_value = "json", global = true)]
pub format: String,
#[arg(long, global = true, conflicts_with = "no_hints")]
pub hints: bool,
#[arg(long, global = true, conflicts_with = "hints")]
pub no_hints: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
#[command(long_about = "List open browser tabs.
Output: {\"results\": [{\"url\": \"...\", \"title\": \"...\", \"actor\": \"...\", \"selected\": true}], \"total\": N, \"meta\": {...}}")]
Tabs,
#[command(long_about = "Navigate to a URL.
The URL is a positional argument (not a flag). There is no --url option.
Examples:
ff-rdp navigate https://example.com
ff-rdp navigate https://example.com --with-network
ff-rdp navigate https://example.com --wait-text \"Welcome\"
Output: {\"results\": {\"url\": \"...\", \"title\": \"...\"}, \"total\": 1, \"meta\": {...}}")]
Navigate {
url: String,
#[arg(long)]
with_network: bool,
#[arg(long, default_value_t = 10000)]
network_timeout: u64,
#[arg(long, conflicts_with = "wait_selector")]
wait_text: Option<String>,
#[arg(long, conflicts_with = "wait_text")]
wait_selector: Option<String>,
#[arg(long, default_value_t = 5000)]
wait_timeout: u64,
},
#[command(long_about = "Evaluate JavaScript in the target tab.
Three input modes (exactly one required):
Positional: ff-rdp eval 'document.title'
From file: ff-rdp eval --file script.js
From stdin: echo 'document.title' | ff-rdp eval --stdin
Prefer --file or --stdin for scripts that contain shell metacharacters,
optional chaining (?.), template literals, or multi-line statements — shell
quoting can mangle them and produce a SyntaxError at column 1.
By default the script is wrapped in an isolated IIFE so `const`/`let`
declarations don't leak across calls (Firefox's console actor shares scope
across evaluations otherwise, so two consecutive `eval 'const x = 1; x'`
calls would error with \"redeclaration of const x\"). Single expressions
like `1 + 1` still return their value.
Pass --no-isolate to opt out and share scope across calls — useful for
incrementally building up helpers in an interactive debugging session.
Output: {\"results\": <value>, \"total\": 1, \"meta\": {...}}
When the result is a non-primitive (object, array), Firefox returns actor grip
metadata (actor IDs, class names) instead of the actual values. Use --stringify
to wrap the expression in JSON.stringify() and get the real data back.")]
#[command(group(
ArgGroup::new("eval_source")
.required(true)
.multiple(false)
.args(["script", "file", "stdin"])
))]
Eval {
script: Option<String>,
#[arg(long, value_name = "PATH")]
file: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
stringify: bool,
#[arg(long)]
no_isolate: bool,
},
PageText,
#[command(long_about = "Query DOM elements by CSS selector.
Output: {\"results\": [\"<html_string>\", ...], \"total\": N, \"meta\": {...}}
With --count: {\"results\": {\"count\": N}, \"total\": 1, \"meta\": {...}}")]
Dom {
#[command(subcommand)]
dom_command: Option<DomCommand>,
selector: Option<String>,
#[arg(long, group = "output_mode")]
outer_html: bool,
#[arg(long, group = "output_mode")]
inner_html: bool,
#[arg(long, group = "output_mode")]
text: bool,
#[arg(long, group = "output_mode")]
attrs: bool,
#[arg(long, group = "output_mode")]
text_attrs: bool,
#[arg(long, group = "output_mode")]
count: bool,
},
#[command(long_about = "Read console messages.
Default: 50 messages, sorted by timestamp (newest first).
Output always includes a `summary` field with totals and per-level counts so
callers can tell at a glance whether the filter caught what they expected.
Output: {\"results\": [{\"level\": \"...\", \"message\": \"...\", \"source\": \"...\", \"line\": N, \"timestamp\": N}], \"summary\": {\"total\": N, \"shown\": Z, \"by_level\": {...}, \"matched\": M}, \"total\": N, \"meta\": {...}}")]
Console {
#[arg(long)]
level: Option<String>,
#[arg(long)]
pattern: Option<String>,
#[arg(long)]
follow: bool,
},
#[command(long_about = "Show network requests captured by the WatcherActor.
In direct mode (--no-daemon), only requests made after the connection is
established are reliably captured. When no live network events are available
(e.g. the page finished loading before ff-rdp connected), the command
automatically falls back to the Performance API to retrieve historical
resource timing data. Fallback entries have source=performance-api in the
output metadata and lack HTTP status codes.
Recommended workflows:
- Daemon mode (default): run `ff-rdp` without --no-daemon so the daemon
buffers events continuously across commands.
- Navigate with capture: use `ff-rdp navigate --with-network <url>` to
start network monitoring before the page load begins.
The --filter and --method flags narrow results after capture; they do not
affect which requests Firefox records.
Default: 20 results, sorted by duration (slowest first).
Output (summary mode): {\"results\": {\"total_requests\": N, \"total_transfer_bytes\": N, \"by_cause_type\": {...}, \"slowest\": [...], \"timeout_reached\": false}, \"total\": N, \"meta\": {...}}
Output (--detail): {\"results\": [{\"url\": \"...\", \"method\": \"GET\", \"status\": 200, \"duration_ms\": N, ...}], \"total\": N, \"meta\": {...}}")]
Network {
#[arg(long)]
filter: Option<String>,
#[arg(long)]
method: Option<String>,
#[arg(long)]
follow: bool,
},
#[command(
long_about = "Query browser Performance API entries and Core Web Vitals.
Default: 20 resources, sorted by duration (slowest first).
Output: {\"results\": [{\"url\": \"...\", \"duration_ms\": N, \"transfer_size\": N, ...}], \"total\": N, \"meta\": {...}}"
)]
Perf {
#[command(subcommand)]
perf_command: Option<PerfCommand>,
#[arg(long = "type", default_value = "resource")]
entry_type: String,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
group_by: Option<String>,
},
#[command(long_about = "Capture a screenshot.
By default the screenshot is captured at the current viewport size.
Use --full-page to capture the entire scrollable document (up to
document.scrollingElement.scrollHeight) or --viewport-height N for an
explicit override.
Output: {\"results\": {\"path\": \"...\", \"width\": N, \"height\": N}, \"total\": 1, \"meta\": {...}}
With --base64: {\"results\": {\"base64\": \"...\"}, \"total\": 1, \"meta\": {...}}")]
Screenshot {
#[arg(long, short, conflicts_with = "base64")]
output: Option<String>,
#[arg(long, conflicts_with = "output")]
base64: bool,
#[arg(long, conflicts_with = "viewport_height")]
full_page: bool,
#[arg(long, value_name = "PX", conflicts_with = "full_page")]
viewport_height: Option<u32>,
},
Click {
selector: String,
},
#[command(long_about = "Type text into an input element matching a CSS selector.
Selector and text can be supplied positionally or via flags:
ff-rdp type 'input[name=email]' 'user@example.com'
ff-rdp type --selector 'input[name=email]' --text 'user@example.com'
Both forms work identically; mixing positional and flag for the same value errors.
The value is set via the native HTMLInputElement/HTMLTextAreaElement/HTMLSelectElement
prototype setter so React/Vue/Svelte value trackers are invalidated, and `input`
and `change` events are dispatched after the assignment.
Output: {\"results\": {\"typed\": true, \"tag\": \"INPUT\", \"value\": \"...\"}, \"total\": 1, \"meta\": {...}}")]
Type {
selector_pos: Option<String>,
text_pos: Option<String>,
#[arg(long = "selector", value_name = "SELECTOR")]
selector_flag: Option<String>,
#[arg(long = "text", value_name = "TEXT")]
text_flag: Option<String>,
#[arg(long)]
clear: bool,
},
#[command(group(ArgGroup::new("condition").required(true).multiple(false)))]
Wait {
#[arg(long, group = "condition")]
selector: Option<String>,
#[arg(long, group = "condition")]
text: Option<String>,
#[arg(long, group = "condition")]
eval: Option<String>,
#[arg(long, default_value_t = 5000)]
wait_timeout: u64,
},
#[command(
long_about = "List cookies via the Firefox StorageActor (includes httpOnly, secure, sameSite, etc.).
Output: {\"results\": [{\"name\": \"...\", \"value\": \"...\", \"domain\": \"...\", \"path\": \"...\", \"secure\": true, \"httpOnly\": true}], \"total\": N, \"meta\": {...}}"
)]
Cookies {
#[arg(long)]
name: Option<String>,
},
Storage {
storage_type: String,
#[arg(long)]
key: Option<String>,
},
A11y {
#[command(subcommand)]
a11y_command: Option<A11yCommand>,
#[arg(long, default_value_t = 6)]
depth: u32,
#[arg(long, default_value_t = 50000)]
max_chars: u32,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
interactive: bool,
},
#[command(long_about = "Reload the page.
With --wait-idle, the command blocks after reload until network activity has been
idle for --idle-ms (default 500) or the --reload-timeout expires (default 10000).
Examples:
ff-rdp reload
ff-rdp reload --wait-idle
ff-rdp reload --wait-idle --idle-ms 1000 --reload-timeout 30000
Output (plain): {\"results\": {\"action\": \"reload\"}, \"total\": 1, \"meta\": {...}}
Output (wait-idle): {\"results\": {\"reloaded\": true, \"idle_at_ms\": N, \"requests_observed\": M}, \"total\": 1, \"meta\": {...}}")]
Reload {
#[arg(long)]
wait_idle: bool,
#[arg(long, default_value_t = 500, requires = "wait_idle")]
idle_ms: u64,
#[arg(long, default_value_t = 10000, requires = "wait_idle")]
reload_timeout: u64,
},
Back,
Forward,
Inspect {
actor_id: String,
#[arg(long, default_value_t = 1)]
depth: u32,
},
Sources {
#[arg(long)]
filter: Option<String>,
#[arg(long)]
pattern: Option<String>,
},
#[command(
long_about = "Dump structured page snapshot for LLM consumption: DOM tree with semantic roles, key attributes, interactive elements, and text content.
Output: {\"results\": {\"tag\": \"HTML\", \"children\": [...], ...}, \"total\": 1, \"meta\": {...}}"
)]
Snapshot {
#[arg(long, default_value_t = 6)]
depth: u32,
#[arg(long, default_value_t = 50000)]
max_chars: u32,
},
#[command(name = "_daemon", hide = true)]
Daemon,
Geometry {
#[arg(required = true)]
selectors: Vec<String>,
#[arg(long)]
visible_only: bool,
},
Responsive {
#[arg(required = true)]
selectors: Vec<String>,
#[arg(long, value_delimiter = ',', default_value = "320,768,1024,1440")]
widths: Vec<u32>,
},
#[command(
long_about = "Quick wrapper around getComputedStyle() for CSS debugging.
Returns non-default computed style properties for every element matching the
selector. Multi-match behaviour mirrors `dom`: one entry per matching element,
each with {selector, index, computed: {...}}.
ff-rdp computed h1
ff-rdp computed h1 --prop color
ff-rdp computed .card --all
Output (multi-match): {\"results\": [{\"selector\": \"...\", \"index\": 0, \"computed\": {...}}], \"total\": N, \"meta\": {...}}
Output (--prop): single string value per match
Output (--all): full resolved-style object per match (dumps every property)"
)]
Computed {
selector: String,
#[arg(long, value_name = "NAME")]
prop: Option<String>,
#[arg(long, conflicts_with = "prop")]
all: bool,
},
Styles {
selector: String,
#[arg(long, group = "style_mode")]
applied: bool,
#[arg(long, group = "style_mode")]
layout: bool,
#[arg(long, value_delimiter = ',', conflicts_with_all = ["applied", "layout"])]
properties: Option<Vec<String>>,
},
#[command(long_about = "Scroll the page or a specific element.
Subcommands:
scroll to <SELECTOR> Scroll element into viewport
scroll by Scroll viewport by pixels or a page
scroll top Scroll to the very top of the page
scroll bottom Scroll to the very bottom of the page
scroll container <SEL> Scroll an overflow container
scroll until <SELECTOR> Scroll until element is visible
scroll text <TEXT> Find text and scroll to it")]
Scroll {
#[command(subcommand)]
scroll_command: ScrollCommand,
},
#[command(
long_about = "Launch a new Firefox instance with remote debugging enabled.
This is safe to run while your normal Firefox browser is open — it always
uses the -no-remote flag and a separate profile, so the new instance is
fully independent and won't interfere with existing windows.
By default a temporary profile is created with the necessary devtools prefs
enabled. Use --profile to reuse an existing profile, or --temp-profile to
make the temporary profile explicit.
Examples:
ff-rdp launch # launch with temp profile on port 6000
ff-rdp launch --headless # headless mode (no visible window)
ff-rdp launch --port 9222 # use a different debug port
ff-rdp launch --auto-consent # auto-dismiss cookie banners
ff-rdp launch --profile ~/my-prof # reuse an existing profile
Output: {\"pid\": N, \"host\": \"...\", \"port\": N, \"headless\": bool, \"profile\": \"...\"}"
)]
Launch {
#[arg(long)]
headless: bool,
#[arg(long, conflicts_with = "temp_profile")]
profile: Option<String>,
#[arg(long, conflicts_with = "profile")]
temp_profile: bool,
#[arg(long)]
debug_port: Option<u16>,
#[arg(long)]
auto_consent: bool,
},
#[command(long_about = "Diagnose the ff-rdp connection top-to-bottom.
Probes (in order):
1. Daemon registry — is a daemon running and reachable?
2. Port owner — who is listening on --port (PID, process, uptime)?
3. RDP handshake — can we receive a Firefox greeting?
4. Tabs — how many tabs are exposed by the connected target?
5. Firefox version — within the tested compatibility range?
Run this whenever a command fails with \"no tabs available\", a connection
timeout, or any error you don't immediately understand. Exits 0 when every
probe passes, 1 otherwise.
Output: {\"results\": [{\"name\": \"...\", \"status\": \"pass|warn|fail\", \"detail\": \"...\", \"hint\": \"...\"}], \"total\": N, \"meta\": {...}}")]
Doctor,
}
#[derive(Subcommand)]
pub enum PerfCommand {
Vitals,
Summary,
Audit,
Compare {
#[arg(required = true, num_args = 2..)]
urls: Vec<String>,
#[arg(long, value_delimiter = ',')]
label: Option<Vec<String>>,
},
}
#[derive(Subcommand)]
pub enum A11yCommand {
Contrast {
#[arg(long)]
selector: Option<String>,
#[arg(long)]
fail_only: bool,
},
Summary,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum ScrollBlock {
Top,
Start,
Center,
Bottom,
End,
Nearest,
}
impl ScrollBlock {
pub fn as_spec(self) -> &'static str {
match self {
ScrollBlock::Top | ScrollBlock::Start => "start",
ScrollBlock::Center => "center",
ScrollBlock::Bottom | ScrollBlock::End => "end",
ScrollBlock::Nearest => "nearest",
}
}
}
#[derive(Subcommand)]
pub enum ScrollCommand {
#[command(long_about = "Scroll an element into the viewport.
Output: {\"results\": {\"scrolled\": true, \"selector\": \"...\", \"viewport\": {...}, \"target\": {...}, \"atEnd\": bool}, \"total\": 1, \"meta\": {...}}")]
To {
selector: String,
#[arg(long, value_enum, default_value_t = ScrollBlock::Top)]
block: ScrollBlock,
#[arg(long)]
smooth: bool,
},
#[command(
long_about = "Scroll the viewport by pixels or by a full page.
--page-down and --page-up scroll by 85% of the viewport height.
--page-down and --page-up are mutually exclusive with --dy and with each other.
Negative values for --dy/--dx are accepted (use 'scroll by --dy -500' or '--dy=-500').
Output: {\"results\": {\"scrolled\": true, \"viewport\": {...}, \"scrollHeight\": N, \"atEnd\": bool}, \"total\": 1, \"meta\": {...}}",
allow_negative_numbers = true
)]
By {
#[arg(long, default_value_t = 0)]
dx: i64,
#[arg(long, conflicts_with_all = ["page_down", "page_up"])]
dy: Option<i64>,
#[arg(long, conflicts_with_all = ["dy", "page_up"])]
page_down: bool,
#[arg(long, conflicts_with_all = ["dy", "page_down"])]
page_up: bool,
#[arg(long)]
smooth: bool,
},
#[command(long_about = "Scroll to the very top of the page.
Uses window.scrollTo(0, 0) for an instant jump to the top.
Output: {\"results\": {\"scrolled\": true, \"viewport\": {...}, \"scrollHeight\": N, \"atEnd\": bool}, \"total\": 1, \"meta\": {...}}")]
Top,
#[command(long_about = "Scroll to the very bottom of the page.
Uses window.scrollTo(0, document.documentElement.scrollHeight) for an instant jump to the bottom.
Output: {\"results\": {\"scrolled\": true, \"viewport\": {...}, \"scrollHeight\": N, \"atEnd\": bool}, \"total\": 1, \"meta\": {...}}")]
Bottom,
#[command(
long_about = "Scroll an overflow container element (scrollTop/scrollLeft).
--to-end scrolls to the bottom; --to-start scrolls to the top.
Output: {\"results\": {\"scrolled\": true, \"selector\": \"...\", \"before\": {...}, \"after\": {...}, \"scrollHeight\": N, \"clientHeight\": N, \"atEnd\": bool}, \"total\": 1, \"meta\": {...}}"
)]
Container {
selector: String,
#[arg(long, default_value_t = 0)]
dx: i64,
#[arg(long, default_value_t = 0)]
dy: i64,
#[arg(long, conflicts_with_all = ["to_start", "dx", "dy"])]
to_end: bool,
#[arg(long, conflicts_with_all = ["to_end", "dx", "dy"])]
to_start: bool,
},
#[command(long_about = "Scroll until an element is visible in the viewport.
Polls every 200ms, scrolling by 80% of the viewport height each step.
Output: {\"results\": {\"found\": true, \"selector\": \"...\", \"elapsed_ms\": N, \"scrolls\": N, \"viewport\": {...}, \"target\": {...}}, \"total\": 1, \"meta\": {...}}")]
Until {
selector: String,
#[arg(long, default_value = "down")]
direction: String,
#[arg(long, default_value_t = 10000)]
timeout: u64,
},
#[command(
long_about = "Find a text string on the page and scroll its container element into view.
Uses TreeWalker + NodeFilter.SHOW_TEXT to find the first matching text node (case-sensitive).
Output: {\"results\": {\"scrolled\": true, \"text\": \"...\", \"viewport\": {...}, \"target\": {\"tag\": \"...\", \"rect\": {...}}}, \"total\": 1, \"meta\": {...}}"
)]
Text {
text: String,
},
}
#[derive(Subcommand)]
pub enum DomCommand {
Stats,
Tree {
selector: Option<String>,
#[arg(long, default_value_t = 6)]
depth: u32,
#[arg(long, default_value_t = 50000)]
max_chars: u32,
},
}