Skip to main content

glass/cli/
args.rs

1//! CLI argument definitions (clap).
2//!
3//! Defines the top-level `Cli` struct and all subcommands for one-shot
4//! browser operations, profile management, and server modes.
5
6use clap::{Parser, Subcommand};
7use std::path::PathBuf;
8
9use crate::browser::policy::{PolicyCapability, PolicyPreset};
10use crate::browser::session::{InteractionMode, PreflightAction, VisualClip, VisualFormat};
11
12/// Top-level CLI configuration parsed from command-line arguments.
13///
14/// Wraps clap-derived flags for policy, browser selection, session options,
15/// and the subcommand to execute.
16#[derive(Debug, Parser)]
17#[command(
18    name = "glass",
19    version,
20    about = "Lightweight local-first browser agent using raw Chrome DevTools Protocol"
21)]
22pub struct Cli {
23    /// Browser safety preset. Hardened mode fails closed for privileged operations.
24    #[arg(long, global = true, value_enum, default_value_t = PolicyPreset::Development)]
25    pub policy: PolicyPreset,
26
27    /// Explicitly allow a privileged capability under the selected policy.
28    #[arg(long = "policy-allow", global = true, value_enum)]
29    pub policy_allow: Vec<PolicyCapability>,
30
31    /// Return a typed confirmation-required result for this capability.
32    #[arg(long = "policy-confirm", global = true, value_enum)]
33    pub policy_confirm: Vec<PolicyCapability>,
34
35    /// Supply one consumable approval token for a confirmation-required capability.
36    #[arg(long = "policy-confirm-once", global = true, value_enum)]
37    pub policy_confirm_once: Vec<PolicyCapability>,
38
39    /// Permit only these exact hosts in hardened mode (repeatable).
40    #[arg(long = "policy-allow-host", global = true)]
41    pub policy_allow_host: Vec<String>,
42
43    /// Deny these exact hosts in hardened mode (repeatable).
44    #[arg(long = "policy-deny-host", global = true)]
45    pub policy_deny_host: Vec<String>,
46
47    /// Named browser profile used for persistent cookies and storage.
48    #[arg(long, global = true, default_value = "default")]
49    pub profile: String,
50
51    /// Use a temporary browser profile without persistence.
52    #[arg(long, global = true)]
53    pub incognito: bool,
54
55    /// Attach to an existing Chrome CDP endpoint instead of launching Chrome.
56    /// The default profile value is ignored in this mode.
57    #[arg(long, global = true)]
58    pub attach: bool,
59
60    /// Chrome page target ID. Required when the selected endpoint has multiple
61    /// page targets.
62    #[arg(long = "target-id", global = true)]
63    pub target_id: Option<String>,
64
65    /// Chrome frame ID used by commands in this one-shot session.
66    #[arg(long = "frame-id", global = true)]
67    pub frame_id: Option<String>,
68
69    /// Chrome remote debugging port.
70    #[arg(long, global = true, default_value_t = 9222)]
71    pub port: u16,
72
73    /// Show the browser window instead of using headless mode.
74    #[arg(long, global = true)]
75    pub headed: bool,
76
77    /// Pointer behavior for click actions.
78    #[arg(long, global = true, value_enum, default_value_t = InteractionMode::Human)]
79    pub interaction: InteractionMode,
80
81    /// Enable bounded session audit log of high-risk operations.
82    #[arg(long, global = true)]
83    pub audit: bool,
84
85    /// Emit a bounded JSON failure-trace pack when a browser operation fails.
86    #[arg(long, global = true)]
87    pub trace_on_error: bool,
88
89    /// Path to a Chrome/Chromium binary.
90    #[arg(long = "chrome-path", alias = "chrome", global = true)]
91    pub chrome_path: Option<PathBuf>,
92
93    /// Run the MCP server over stdio.
94    #[arg(long)]
95    pub mcp: bool,
96
97    /// One-shot prompt, for example: `navigate to https://example.com`.
98    #[arg(value_name = "PROMPT")]
99    pub prompt: Option<String>,
100
101    #[command(subcommand)]
102    pub command: Option<Commands>,
103}
104
105#[derive(Debug, Subcommand)]
106pub enum Commands {
107    /// Download and install a managed Chrome for Testing build.
108    InstallChromium {
109        /// Reinstall the version pinned by this Glass release.
110        #[arg(long)]
111        update: bool,
112    },
113
114    /// List or manage saved profiles.
115    Profiles {
116        #[command(subcommand)]
117        action: Option<ProfileCommand>,
118    },
119
120    /// Delete a saved profile.
121    DeleteProfile { name: String },
122
123    /// Navigate to a URL.
124    Navigate {
125        url: String,
126        #[arg(long, default_value_t = 20_000)]
127        timeout_ms: u64,
128    },
129
130    /// Click an element by an explicit ref/name/role/text/CSS/ordinal locator.
131    Click { target: String },
132
133    /// Resolve a target and report clickability without performing an action.
134    Preflight {
135        target: String,
136        #[arg(long, value_enum, default_value_t = PreflightAction::Click)]
137        action: PreflightAction,
138    },
139
140    /// Click exact viewport coordinates for canvas/map surfaces.
141    ClickAt { x: f64, y: f64 },
142
143    /// Click an element expected to open exactly one causally verified popup.
144    ClickExpectPopup { target: String },
145
146    /// Double-click an element by an explicit ref/name/role/text/CSS/ordinal locator.
147    DoubleClick { target: String },
148
149    /// Move the pointer over an element without clicking.
150    Hover { target: String },
151
152    /// Drag one element to another uniquely resolved element.
153    Drag { source: String, destination: String },
154
155    /// Type text into the focused element, optionally clicking a target first.
156    Type {
157        text: String,
158        #[arg(long)]
159        target: Option<String>,
160    },
161
162    /// Dispatch one complete key press.
163    Key { key: String },
164
165    /// Dispatch only a key-down event.
166    KeyDown { key: String },
167
168    /// Dispatch only a key-up event.
169    KeyUp { key: String },
170
171    /// Dispatch a modifier shortcut such as Control+A.
172    Shortcut { shortcut: String },
173
174    /// Clear an editable element.
175    Clear { target: String },
176
177    /// Ensure a checkbox or radio is checked.
178    Check { target: String },
179
180    /// Ensure a checkbox is unchecked.
181    Uncheck { target: String },
182
183    /// Select one exact option value.
184    Select { target: String, value: String },
185
186    /// Set a bounded list of regular files on one file input.
187    Upload {
188        target: String,
189        #[arg(required = true)]
190        files: Vec<PathBuf>,
191    },
192
193    /// Capture a PNG screenshot.
194    Screenshot {
195        #[arg(short, long, default_value = "screenshot.png")]
196        output: String,
197        #[arg(long, value_enum, default_value_t = VisualFormat::Png)]
198        format: VisualFormat,
199        #[arg(long)]
200        quality: Option<u8>,
201        #[arg(long, default_value_t = 1.0)]
202        scale: f64,
203        #[arg(long, conflicts_with_all = ["clip", "target"])]
204        full_page: bool,
205        #[arg(long, conflicts_with_all = ["full_page", "target"])]
206        clip: Option<VisualClip>,
207        #[arg(long, conflicts_with_all = ["full_page", "clip"])]
208        target: Option<String>,
209    },
210
211    /// Print the visible page text.
212    Text,
213
214    /// Print the full DOM tree. This is an explicit deep-inspection request.
215    Dom,
216
217    /// Print compact accessibility and text context.
218    Observe {
219        /// Include the full DOM tree. This is an explicit deep-inspection request.
220        #[arg(long)]
221        deep_dom: bool,
222        /// Include a PNG screenshot in the structured context.
223        #[arg(long)]
224        screenshot: bool,
225        /// Include bounded, policy-gated form field values.
226        #[arg(long)]
227        form_values: bool,
228    },
229
230    /// Scroll the page by CSS pixels.
231    Scroll {
232        #[arg(long, default_value_t = 0.0)]
233        dx: f64,
234        #[arg(long, default_value_t = 600.0)]
235        dy: f64,
236    },
237
238    /// Wait for one explicit browser condition until a bounded deadline.
239    Wait {
240        condition: String,
241        #[arg(long, default_value_t = 10_000)]
242        timeout_ms: u64,
243    },
244
245    /// Collect bounded, redacted console and network evidence.
246    Diagnostics {
247        #[arg(long, default_value_t = 1_000)]
248        duration_ms: u64,
249    },
250
251    /// Accept the currently open JavaScript dialog.
252    AcceptDialog,
253
254    /// Dismiss the currently open JavaScript dialog.
255    DismissDialog,
256
257    /// Dismiss a recognized OneTrust/Cookiebot consent wall.
258    DismissConsent,
259
260    /// Wait for one download into an authorized existing directory.
261    Download {
262        destination: PathBuf,
263        #[arg(long, default_value_t = 30_000)]
264        timeout_ms: u64,
265    },
266
267    /// List discoverable page targets without changing the active target.
268    Targets,
269
270    /// Create a page target without selecting it.
271    NewTarget { url: String },
272
273    /// Explicitly select the page target used by subsequent commands.
274    SelectTarget { id: String },
275
276    /// Close one page target.
277    CloseTarget { id: String },
278
279    /// List frames in the active page target.
280    Frames,
281
282    /// Explicitly select the frame used by subsequent commands.
283    SelectFrame { id: String },
284
285    /// Evaluate JavaScript in the current page.
286    Evaluate { expression: String },
287
288    /// List all browser cookies for the current page.
289    Cookies,
290
291    /// Save the current page as a PDF.
292    Pdf {
293        #[arg(short, long, default_value = "page.pdf")]
294        output: String,
295        #[arg(long)]
296        background: bool,
297    },
298
299    /// Fill multiple form fields from a JSON value.
300    FillForm {
301        /// JSON array of {target, value} objects.
302        #[arg(long)]
303        fields: String,
304    },
305
306    /// Execute a bounded typed batch from a JSON array or stdin.
307    Batch {
308        /// JSON file containing the batch steps; omit to read stdin.
309        input: Option<PathBuf>,
310        #[arg(long)]
311        atomic: bool,
312    },
313
314    /// Reconcile revisioned references against the current observation.
315    ReconcileRefs {
316        #[arg(long)]
317        from_revision: u64,
318        /// Stable locators tried positionally after backend identity is gone.
319        #[arg(long = "hint")]
320        hints: Vec<String>,
321        /// Current revisioned landmark/container ref used to narrow relocation.
322        #[arg(long)]
323        scope: Option<String>,
324        #[arg(required = true)]
325        refs: Vec<String>,
326    },
327
328    /// Report a bounded delta from the last compact observation.
329    ObserveDelta,
330
331    /// Export or import a bounded workflow checkpoint.
332    Checkpoint {
333        #[command(subcommand)]
334        action: CheckpointCommand,
335    },
336
337    /// Read text from the system clipboard.
338    ClipboardRead,
339
340    /// Write text to the system clipboard.
341    ClipboardWrite { text: String },
342
343    /// Launch the interactive TUI.
344    Tui,
345}
346
347#[derive(Debug, Subcommand)]
348pub enum ProfileCommand {
349    List,
350    Create { name: String },
351    Delete { name: String },
352}
353
354#[derive(Debug, Subcommand)]
355pub enum CheckpointCommand {
356    Export,
357    Import { input: Option<PathBuf> },
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn observation_and_human_interaction_are_defaults() {
366        let cli = Cli::try_parse_from(["glass", "observe"]).unwrap();
367
368        assert_eq!(cli.interaction, InteractionMode::Human);
369        assert!(matches!(
370            cli.command,
371            Some(Commands::Observe {
372                deep_dom: false,
373                screenshot: false,
374                form_values: false
375            })
376        ));
377    }
378
379    #[test]
380    fn screenshot_and_fast_interaction_require_explicit_flags() {
381        let cli =
382            Cli::try_parse_from(["glass", "--interaction", "fast", "observe", "--screenshot"])
383                .unwrap();
384
385        assert_eq!(cli.interaction, InteractionMode::Fast);
386        assert!(matches!(
387            cli.command,
388            Some(Commands::Observe {
389                deep_dom: false,
390                screenshot: true,
391                form_values: false
392            })
393        ));
394    }
395
396    #[test]
397    fn deep_dom_requires_an_explicit_observation_flag() {
398        let cli = Cli::try_parse_from(["glass", "observe", "--deep-dom"]).unwrap();
399
400        assert!(matches!(
401            cli.command,
402            Some(Commands::Observe {
403                deep_dom: true,
404                screenshot: false,
405                form_values: false
406            })
407        ));
408    }
409
410    #[test]
411    fn screenshot_remains_a_separate_explicit_command() {
412        let cli = Cli::try_parse_from(["glass", "screenshot", "--output", "page.png"]).unwrap();
413
414        assert!(matches!(
415            cli.command,
416            Some(Commands::Screenshot { output, .. }) if output == "page.png"
417        ));
418    }
419
420    #[test]
421    fn double_click_is_an_explicit_action_command() {
422        let cli = Cli::try_parse_from(["glass", "double-click", "r7:b42"]).unwrap();
423
424        assert!(matches!(
425            cli.command,
426            Some(Commands::DoubleClick { target }) if target == "r7:b42"
427        ));
428    }
429
430    #[test]
431    fn click_expect_popup_is_an_explicit_action_command() {
432        let cli = Cli::try_parse_from(["glass", "click-expect-popup", "css=#popup"]).unwrap();
433        assert!(matches!(
434            cli.command,
435            Some(Commands::ClickExpectPopup { target }) if target == "css=#popup"
436        ));
437    }
438
439    #[test]
440    fn wait_has_an_explicit_condition_and_bounded_default() {
441        let cli = Cli::try_parse_from(["glass", "wait", "text=Ready"]).unwrap();
442        assert!(matches!(
443            cli.command,
444            Some(Commands::Wait { condition, timeout_ms: 10_000 }) if condition == "text=Ready"
445        ));
446    }
447
448    #[test]
449    fn topology_commands_are_explicit() {
450        assert!(matches!(
451            Cli::try_parse_from(["glass", "targets"]).unwrap().command,
452            Some(Commands::Targets)
453        ));
454        let cli = Cli::try_parse_from([
455            "glass",
456            "--target-id",
457            "page-1",
458            "--frame-id",
459            "frame-1",
460            "evaluate",
461            "document.title",
462        ])
463        .unwrap();
464        assert_eq!(cli.target_id.as_deref(), Some("page-1"));
465        assert_eq!(cli.frame_id.as_deref(), Some("frame-1"));
466        assert!(matches!(
467            Cli::try_parse_from(["glass", "select-frame", "frame-1"])
468                .unwrap()
469                .command,
470            Some(Commands::SelectFrame { id }) if id == "frame-1"
471        ));
472    }
473
474    #[test]
475    fn complete_input_commands_are_explicit() {
476        assert!(matches!(
477            Cli::try_parse_from(["glass", "drag", "css=#from", "css=#to"])
478                .unwrap()
479                .command,
480            Some(Commands::Drag { source, destination }) if source == "css=#from" && destination == "css=#to"
481        ));
482        assert!(matches!(
483            Cli::try_parse_from(["glass", "shortcut", "Control+A"])
484                .unwrap()
485                .command,
486            Some(Commands::Shortcut { shortcut }) if shortcut == "Control+A"
487        ));
488    }
489
490    #[test]
491    fn rejects_unknown_interaction_modes() {
492        assert!(Cli::try_parse_from(["glass", "--interaction", "instant", "observe"]).is_err());
493    }
494
495    #[test]
496    fn attach_and_target_id_are_explicit_global_options() {
497        let cli = Cli::try_parse_from([
498            "glass",
499            "--attach",
500            "--port",
501            "9333",
502            "--target-id",
503            "page-2",
504            "observe",
505        ])
506        .unwrap();
507
508        assert!(cli.attach);
509        assert_eq!(cli.port, 9333);
510        assert_eq!(cli.target_id.as_deref(), Some("page-2"));
511    }
512}