1use clap::{Parser, Subcommand};
7use std::path::PathBuf;
8
9use crate::browser::policy::{PolicyCapability, PolicyPreset};
10use crate::browser::session::{InteractionMode, PreflightAction, VisualClip, VisualFormat};
11
12#[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 #[arg(long, global = true, value_enum, default_value_t = PolicyPreset::Development)]
25 pub policy: PolicyPreset,
26
27 #[arg(long = "policy-allow", global = true, value_enum)]
29 pub policy_allow: Vec<PolicyCapability>,
30
31 #[arg(long = "policy-confirm", global = true, value_enum)]
33 pub policy_confirm: Vec<PolicyCapability>,
34
35 #[arg(long = "policy-confirm-once", global = true, value_enum)]
37 pub policy_confirm_once: Vec<PolicyCapability>,
38
39 #[arg(long = "policy-allow-host", global = true)]
41 pub policy_allow_host: Vec<String>,
42
43 #[arg(long = "policy-deny-host", global = true)]
45 pub policy_deny_host: Vec<String>,
46
47 #[arg(long, global = true, default_value = "default")]
49 pub profile: String,
50
51 #[arg(long, global = true)]
53 pub incognito: bool,
54
55 #[arg(long, global = true)]
58 pub attach: bool,
59
60 #[arg(long = "target-id", global = true)]
63 pub target_id: Option<String>,
64
65 #[arg(long = "frame-id", global = true)]
67 pub frame_id: Option<String>,
68
69 #[arg(long, global = true, default_value_t = 9222)]
71 pub port: u16,
72
73 #[arg(long, global = true)]
75 pub headed: bool,
76
77 #[arg(long, global = true, value_enum, default_value_t = InteractionMode::Human)]
79 pub interaction: InteractionMode,
80
81 #[arg(long, global = true)]
83 pub audit: bool,
84
85 #[arg(long, global = true)]
87 pub trace_on_error: bool,
88
89 #[arg(long = "chrome-path", alias = "chrome", global = true)]
91 pub chrome_path: Option<PathBuf>,
92
93 #[arg(long)]
95 pub mcp: bool,
96
97 #[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 InstallChromium {
109 #[arg(long)]
111 update: bool,
112 },
113
114 Profiles {
116 #[command(subcommand)]
117 action: Option<ProfileCommand>,
118 },
119
120 DeleteProfile { name: String },
122
123 Navigate {
125 url: String,
126 #[arg(long, default_value_t = 20_000)]
127 timeout_ms: u64,
128 },
129
130 Click { target: String },
132
133 Preflight {
135 target: String,
136 #[arg(long, value_enum, default_value_t = PreflightAction::Click)]
137 action: PreflightAction,
138 },
139
140 ClickAt { x: f64, y: f64 },
142
143 ClickExpectPopup { target: String },
145
146 DoubleClick { target: String },
148
149 Hover { target: String },
151
152 Drag { source: String, destination: String },
154
155 Type {
157 text: String,
158 #[arg(long)]
159 target: Option<String>,
160 },
161
162 Key { key: String },
164
165 KeyDown { key: String },
167
168 KeyUp { key: String },
170
171 Shortcut { shortcut: String },
173
174 Clear { target: String },
176
177 Check { target: String },
179
180 Uncheck { target: String },
182
183 Select { target: String, value: String },
185
186 Upload {
188 target: String,
189 #[arg(required = true)]
190 files: Vec<PathBuf>,
191 },
192
193 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 Text,
213
214 Dom,
216
217 Observe {
219 #[arg(long)]
221 deep_dom: bool,
222 #[arg(long)]
224 screenshot: bool,
225 #[arg(long)]
227 form_values: bool,
228 },
229
230 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 {
240 condition: String,
241 #[arg(long, default_value_t = 10_000)]
242 timeout_ms: u64,
243 },
244
245 Diagnostics {
247 #[arg(long, default_value_t = 1_000)]
248 duration_ms: u64,
249 },
250
251 AcceptDialog,
253
254 DismissDialog,
256
257 DismissConsent,
259
260 Download {
262 destination: PathBuf,
263 #[arg(long, default_value_t = 30_000)]
264 timeout_ms: u64,
265 },
266
267 Targets,
269
270 NewTarget { url: String },
272
273 SelectTarget { id: String },
275
276 CloseTarget { id: String },
278
279 Frames,
281
282 SelectFrame { id: String },
284
285 Evaluate { expression: String },
287
288 Cookies,
290
291 Pdf {
293 #[arg(short, long, default_value = "page.pdf")]
294 output: String,
295 #[arg(long)]
296 background: bool,
297 },
298
299 FillForm {
301 #[arg(long)]
303 fields: String,
304 },
305
306 Batch {
308 input: Option<PathBuf>,
310 #[arg(long)]
311 atomic: bool,
312 },
313
314 ReconcileRefs {
316 #[arg(long)]
317 from_revision: u64,
318 #[arg(long = "hint")]
320 hints: Vec<String>,
321 #[arg(long)]
323 scope: Option<String>,
324 #[arg(required = true)]
325 refs: Vec<String>,
326 },
327
328 ObserveDelta,
330
331 Checkpoint {
333 #[command(subcommand)]
334 action: CheckpointCommand,
335 },
336
337 ClipboardRead,
339
340 ClipboardWrite { text: String },
342
343 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}