use clap::{Parser, Subcommand};
use std::path::PathBuf;
use crate::browser::policy::{PolicyCapability, PolicyPreset};
use crate::browser::session::{
BatchMode, InteractionMode, PreflightAction, VisualClip, VisualFormat,
};
#[derive(Debug, Parser)]
#[command(
name = "glass",
version,
about = "Lightweight local-first browser agent using raw Chrome DevTools Protocol"
)]
pub struct Cli {
#[arg(long, global = true, value_enum, default_value_t = PolicyPreset::Development)]
pub policy: PolicyPreset,
#[arg(long = "policy-allow", global = true, value_enum)]
pub policy_allow: Vec<PolicyCapability>,
#[arg(long = "policy-confirm", global = true, value_enum)]
pub policy_confirm: Vec<PolicyCapability>,
#[arg(long = "policy-confirm-once", global = true, value_enum)]
pub policy_confirm_once: Vec<PolicyCapability>,
#[arg(long = "policy-allow-host", global = true)]
pub policy_allow_host: Vec<String>,
#[arg(long = "policy-deny-host", global = true)]
pub policy_deny_host: Vec<String>,
#[arg(long, global = true, default_value = "default")]
pub profile: String,
#[arg(long, global = true)]
pub incognito: bool,
#[arg(long, global = true)]
pub attach: bool,
#[arg(long = "target-id", global = true)]
pub target_id: Option<String>,
#[arg(long = "frame-id", global = true)]
pub frame_id: Option<String>,
#[arg(long, global = true, default_value_t = 9222)]
pub port: u16,
#[arg(long, global = true)]
pub headed: bool,
#[arg(long, global = true, value_enum, default_value_t = InteractionMode::Human)]
pub interaction: InteractionMode,
#[arg(long, global = true)]
pub audit: bool,
#[arg(long, global = true)]
pub trace_on_error: bool,
#[arg(long = "chrome-path", alias = "chrome", global = true)]
pub chrome_path: Option<PathBuf>,
#[arg(long)]
pub mcp: bool,
#[arg(value_name = "PROMPT")]
pub prompt: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
InstallChromium {
#[arg(long)]
update: bool,
},
Profiles {
#[command(subcommand)]
action: Option<ProfileCommand>,
},
DeleteProfile { name: String },
Navigate {
url: String,
#[arg(long, default_value_t = 20_000)]
timeout_ms: u64,
#[arg(long)]
expected_revision: Option<u64>,
},
Click {
target: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Preflight {
target: String,
#[arg(long, value_enum, default_value_t = PreflightAction::Click)]
action: PreflightAction,
},
ClickAt { x: f64, y: f64 },
ClickExpectPopup {
target: String,
#[arg(long)]
expected_revision: Option<u64>,
},
DoubleClick {
target: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Hover { target: String },
Drag {
source: String,
destination: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Type {
text: String,
#[arg(long)]
target: Option<String>,
#[arg(long)]
expected_revision: Option<u64>,
},
Key {
key: String,
#[arg(long)]
expected_revision: Option<u64>,
},
KeyDown {
key: String,
#[arg(long)]
expected_revision: Option<u64>,
},
KeyUp {
key: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Shortcut {
shortcut: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Clear {
target: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Check {
target: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Uncheck {
target: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Select {
target: String,
value: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Upload {
target: String,
#[arg(required = true)]
files: Vec<PathBuf>,
#[arg(long)]
expected_revision: Option<u64>,
},
Screenshot {
#[arg(short, long, default_value = "screenshot.png")]
output: String,
#[arg(long, value_enum, default_value_t = VisualFormat::Png)]
format: VisualFormat,
#[arg(long)]
quality: Option<u8>,
#[arg(long, default_value_t = 1.0)]
scale: f64,
#[arg(long, conflicts_with_all = ["clip", "target"])]
full_page: bool,
#[arg(long, conflicts_with_all = ["full_page", "target"])]
clip: Option<VisualClip>,
#[arg(long, conflicts_with_all = ["full_page", "clip"])]
target: Option<String>,
},
Text,
Dom,
Observe {
#[arg(long)]
deep_dom: bool,
#[arg(long)]
screenshot: bool,
#[arg(long)]
form_values: bool,
},
Scroll {
#[arg(long, default_value_t = 0.0)]
dx: f64,
#[arg(long, default_value_t = 600.0)]
dy: f64,
#[arg(long)]
expected_revision: Option<u64>,
},
Wait {
condition: String,
#[arg(long, default_value_t = 10_000)]
timeout_ms: u64,
},
Diagnostics {
#[arg(long, default_value_t = 1_000)]
duration_ms: u64,
},
AcceptDialog,
DismissDialog,
DismissConsent,
Download {
destination: PathBuf,
#[arg(long, default_value_t = 30_000)]
timeout_ms: u64,
},
Targets,
NewTarget { url: String },
SelectTarget { id: String },
CloseTarget { id: String },
Frames,
SelectFrame { id: String },
Evaluate { expression: String },
Cookies,
ExportCookies { output: PathBuf },
ImportCookies { input: PathBuf },
Pdf {
#[arg(short, long, default_value = "page.pdf")]
output: String,
#[arg(long)]
background: bool,
},
FillForm {
#[arg(long)]
fields: String,
#[arg(long)]
expected_revision: Option<u64>,
},
Batch {
input: Option<PathBuf>,
#[arg(long)]
atomic: bool,
#[arg(long, value_enum, default_value_t = BatchMode::Unguarded)]
mode: BatchMode,
#[arg(long)]
expected_revision: Option<u64>,
},
Verify {
predicate: String,
#[arg(long, default_value_t = 10_000)]
timeout_ms: u64,
},
ReconcileRefs {
#[arg(long)]
from_revision: u64,
#[arg(long = "hint")]
hints: Vec<String>,
#[arg(long)]
scope: Option<String>,
#[arg(required = true)]
refs: Vec<String>,
},
ObserveDelta,
Checkpoint {
#[command(subcommand)]
action: CheckpointCommand,
},
ClipboardRead,
ClipboardWrite { text: String },
Tui,
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
List,
Create { name: String },
Delete { name: String },
}
#[derive(Debug, Subcommand)]
pub enum CheckpointCommand {
Export,
Import { input: Option<PathBuf> },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observation_and_human_interaction_are_defaults() {
let cli = Cli::try_parse_from(["glass", "observe"]).unwrap();
assert_eq!(cli.interaction, InteractionMode::Human);
assert!(matches!(
cli.command,
Some(Commands::Observe {
deep_dom: false,
screenshot: false,
form_values: false
})
));
}
#[test]
fn screenshot_and_fast_interaction_require_explicit_flags() {
let cli =
Cli::try_parse_from(["glass", "--interaction", "fast", "observe", "--screenshot"])
.unwrap();
assert_eq!(cli.interaction, InteractionMode::Fast);
assert!(matches!(
cli.command,
Some(Commands::Observe {
deep_dom: false,
screenshot: true,
form_values: false
})
));
}
#[test]
fn deep_dom_requires_an_explicit_observation_flag() {
let cli = Cli::try_parse_from(["glass", "observe", "--deep-dom"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Observe {
deep_dom: true,
screenshot: false,
form_values: false
})
));
}
#[test]
fn screenshot_remains_a_separate_explicit_command() {
let cli = Cli::try_parse_from(["glass", "screenshot", "--output", "page.png"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Screenshot { output, .. }) if output == "page.png"
));
}
#[test]
fn double_click_is_an_explicit_action_command() {
let cli = Cli::try_parse_from(["glass", "double-click", "r7:b42"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::DoubleClick { target, .. }) if target == "r7:b42"
));
}
#[test]
fn click_expect_popup_is_an_explicit_action_command() {
let cli = Cli::try_parse_from(["glass", "click-expect-popup", "css=#popup"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::ClickExpectPopup { target, .. }) if target == "css=#popup"
));
}
#[test]
fn wait_has_an_explicit_condition_and_bounded_default() {
let cli = Cli::try_parse_from(["glass", "wait", "text=Ready"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Wait { condition, timeout_ms: 10_000 }) if condition == "text=Ready"
));
}
#[test]
fn topology_commands_are_explicit() {
assert!(matches!(
Cli::try_parse_from(["glass", "targets"]).unwrap().command,
Some(Commands::Targets)
));
let cli = Cli::try_parse_from([
"glass",
"--target-id",
"page-1",
"--frame-id",
"frame-1",
"evaluate",
"document.title",
])
.unwrap();
assert_eq!(cli.target_id.as_deref(), Some("page-1"));
assert_eq!(cli.frame_id.as_deref(), Some("frame-1"));
assert!(matches!(
Cli::try_parse_from(["glass", "select-frame", "frame-1"])
.unwrap()
.command,
Some(Commands::SelectFrame { id }) if id == "frame-1"
));
}
#[test]
fn complete_input_commands_are_explicit() {
assert!(matches!(
Cli::try_parse_from(["glass", "drag", "css=#from", "css=#to"])
.unwrap()
.command,
Some(Commands::Drag { source, destination, .. }) if source == "css=#from" && destination == "css=#to"
));
assert!(matches!(
Cli::try_parse_from(["glass", "shortcut", "Control+A"])
.unwrap()
.command,
Some(Commands::Shortcut { shortcut, .. }) if shortcut == "Control+A"
));
assert!(matches!(
Cli::try_parse_from([
"glass",
"fill-form",
"--fields",
"[]",
"--expected-revision",
"7"
])
.unwrap()
.command,
Some(Commands::FillForm {
fields,
expected_revision: Some(7)
}) if fields == "[]"
));
}
#[test]
fn rejects_unknown_interaction_modes() {
assert!(Cli::try_parse_from(["glass", "--interaction", "instant", "observe"]).is_err());
}
#[test]
fn attach_and_target_id_are_explicit_global_options() {
let cli = Cli::try_parse_from([
"glass",
"--attach",
"--port",
"9333",
"--target-id",
"page-2",
"observe",
])
.unwrap();
assert!(cli.attach);
assert_eq!(cli.port, 9333);
assert_eq!(cli.target_id.as_deref(), Some("page-2"));
}
}