Skip to main content

glass/cli/
runner.rs

1//! CLI command dispatch and session orchestration.
2//!
3//! Routes parsed CLI arguments to the appropriate runner: one-shot browser
4//! commands, interactive TUI, or the MCP stdio server.
5
6use super::args::{CheckpointCommand, Cli, Commands, ProfileCommand};
7use crate::browser::policy::{BrowserPolicy, PolicyCapability};
8use crate::browser::profile::ProfileManager;
9use crate::browser::session::{
10    ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Locator, PdfOptions,
11    ReconciliationOptions, SessionOptions, VisualCaptureOptions, WaitCondition,
12};
13use base64::Engine;
14use serde::Serialize;
15use std::io::Read;
16use std::time::Duration;
17
18/// Top-level command-line entry point: parses CLI arguments and dispatches
19/// to the appropriate runner (one-shot, TUI, or MCP server).
20pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
21    let policy = policy_from_cli(&cli)?;
22    if cli.mcp {
23        return crate::mcp::server::run_mcp_server(&cli).await;
24    }
25
26    match &cli.command {
27        Some(Commands::InstallChromium { update }) => {
28            let path = crate::browser::chrome::download_chromium(*update).await?;
29            println!("Chrome for Testing installed at {}", path.display());
30            return Ok(());
31        }
32        Some(Commands::Profiles { action }) => {
33            policy.require(PolicyCapability::PersistentProfile)?;
34            dispatch_profiles(action.as_ref())?;
35            return Ok(());
36        }
37        Some(Commands::DeleteProfile { name }) => {
38            policy.require(PolicyCapability::PersistentProfile)?;
39            ProfileManager::new().delete_profile(name)?;
40            println!("deleted profile {name}");
41            return Ok(());
42        }
43        Some(Commands::Tui) | None if cli.prompt.is_none() => {
44            return crate::tui::app::run_tui(&cli).await;
45        }
46        _ => {}
47    }
48
49    let options = SessionOptions {
50        port: cli.port,
51        chrome_path: cli.chrome_path.clone(),
52        profile: cli.profile.clone(),
53        incognito: cli.incognito,
54        attach: cli.attach,
55        target_id: cli.target_id.clone(),
56        frame_id: cli.frame_id.clone(),
57        headed: cli.headed,
58        interaction_mode: cli.interaction,
59        audit: cli.audit,
60        policy: None,
61    };
62    let session = BrowserSession::start_with_policy(&options, policy).await?;
63    let result = if let Some(prompt) = &cli.prompt {
64        run_prompt(&session, prompt).await
65    } else if let Some(command) = &cli.command {
66        run_command(&session, command).await
67    } else {
68        Ok(())
69    };
70    if let Err(error) = &result
71        && cli.trace_on_error
72    {
73        let trace = session
74            .failure_trace_for(
75                cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
76                error.to_string(),
77            )
78            .await;
79        eprintln!("{}", serde_json::to_string(&trace)?);
80    }
81    let close_result = session.close().await;
82    result?;
83    close_result
84}
85
86fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
87    if let Some(prompt) = prompt {
88        let lower = prompt.trim().to_ascii_lowercase();
89        return if lower.starts_with("double click ") {
90            ActionKind::DoubleClick
91        } else if lower.starts_with("click ") {
92            ActionKind::Click
93        } else if lower.starts_with("type ") {
94            ActionKind::Type
95        } else {
96            ActionKind::Click
97        };
98    }
99    match command {
100        Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
101        Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
102        Some(Commands::Click { .. })
103        | Some(Commands::Preflight { .. })
104        | Some(Commands::ClickAt { .. }) => ActionKind::Click,
105        Some(Commands::Hover { .. }) => ActionKind::Hover,
106        Some(Commands::Drag { .. }) => ActionKind::Drag,
107        Some(Commands::Type { .. }) => ActionKind::Type,
108        Some(Commands::Key { .. }) => ActionKind::KeyPress,
109        Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
110        Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
111        Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
112        Some(Commands::Clear { .. }) => ActionKind::Clear,
113        Some(Commands::Check { .. }) => ActionKind::Check,
114        Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
115        Some(Commands::Select { .. }) => ActionKind::Select,
116        Some(Commands::Upload { .. }) => ActionKind::Upload,
117        Some(Commands::Scroll { .. }) => ActionKind::Scroll,
118        _ => ActionKind::Click,
119    }
120}
121
122fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
123    let manager = ProfileManager::new();
124    match action {
125        None | Some(ProfileCommand::List) => {
126            let profiles = manager.list_profiles()?;
127            if profiles.is_empty() {
128                println!("no saved profiles");
129            } else {
130                for profile in profiles {
131                    println!("{profile}");
132                }
133            }
134        }
135        Some(ProfileCommand::Create { name }) => {
136            manager.create_profile(name)?;
137            println!("created profile {name}");
138        }
139        Some(ProfileCommand::Delete { name }) => {
140            manager.delete_profile(name)?;
141            println!("deleted profile {name}");
142        }
143    }
144    Ok(())
145}
146
147async fn run_command(session: &BrowserSession, command: &Commands) -> BrowserResult<()> {
148    match command {
149        Commands::Navigate { url, timeout_ms } => {
150            let page = session
151                .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
152                .await?;
153            print_json(&page)?;
154        }
155        Commands::Click { target } => {
156            print_json(&session.click(target).await?)?;
157        }
158        Commands::Preflight { target, action } => {
159            print_json(&session.preflight_with_action(target, *action).await)?;
160        }
161        Commands::ClickAt { x, y } => {
162            print_json(&session.click_at(*x, *y).await?)?;
163        }
164        Commands::ClickExpectPopup { target } => {
165            print_json(&session.click_expect_popup(target).await?)?;
166        }
167        Commands::DoubleClick { target } => {
168            print_json(&session.double_click(target).await?)?;
169        }
170        Commands::Hover { target } => print_json(&session.hover(target).await?)?,
171        Commands::Drag {
172            source,
173            destination,
174        } => {
175            print_json(&session.drag(source, destination).await?)?;
176        }
177        Commands::Type { text, target } => {
178            print_json(&session.type_text(text, target.as_deref()).await?)?;
179        }
180        Commands::Key { key } => print_json(&session.key_press(key).await?)?,
181        Commands::KeyDown { key } => print_json(&session.key_down(key).await?)?,
182        Commands::KeyUp { key } => print_json(&session.key_up(key).await?)?,
183        Commands::Shortcut { shortcut } => print_json(&session.shortcut(shortcut).await?)?,
184        Commands::Clear { target } => print_json(&session.clear(target).await?)?,
185        Commands::Check { target } => print_json(&session.check(target).await?)?,
186        Commands::Uncheck { target } => print_json(&session.uncheck(target).await?)?,
187        Commands::Select { target, value } => {
188            print_json(&session.select_option(target, value).await?)?;
189        }
190        Commands::Upload { target, files } => {
191            print_json(&session.upload_files(target, files).await?)?;
192        }
193        Commands::Screenshot {
194            output,
195            format,
196            quality,
197            scale,
198            full_page,
199            clip,
200            target,
201        } => {
202            let output = session
203                .policy()
204                .require_output_path(std::path::Path::new(output))?;
205            let capture = session
206                .capture_visual(&VisualCaptureOptions {
207                    format: *format,
208                    quality: *quality,
209                    scale: *scale,
210                    clip: *clip,
211                    full_page: *full_page,
212                    target: target.clone(),
213                })
214                .await?;
215            let mut source = base64::read::DecoderReader::new(
216                capture.data.as_bytes(),
217                &base64::engine::general_purpose::STANDARD,
218            );
219            let mut file = std::fs::File::create(&output)?;
220            std::io::copy(&mut source, &mut file)?;
221            println!("wrote {}", output.display());
222            print_json(&capture.metadata)?;
223        }
224        Commands::Text => println!("{}", session.text().await?),
225        Commands::Dom => print_json(&session.deep_dom().await?)?,
226        Commands::Observe {
227            deep_dom,
228            screenshot,
229            form_values,
230        } => {
231            let context = match (*deep_dom, *screenshot, *form_values) {
232                (false, false, false) => session.observe().await?,
233                (true, false, false) => session.observe_with_dom().await?,
234                (false, true, false) => session.observe_with_screenshot().await?,
235                (true, true, false) => session.observe_with_dom_and_screenshot().await?,
236                (false, false, true) => session.observe_with_form_values().await?,
237                _ => return Err("form values can only be combined with compact observe".into()),
238            };
239            print_json(&context)?;
240        }
241        Commands::Scroll { dx, dy } => {
242            print_json(&session.scroll(*dx, *dy).await?)?;
243        }
244        Commands::Wait {
245            condition,
246            timeout_ms,
247        } => {
248            print_json(
249                &session
250                    .wait(
251                        WaitCondition::parse(condition)?,
252                        Duration::from_millis(*timeout_ms),
253                    )
254                    .await?,
255            )?;
256        }
257        Commands::Diagnostics { duration_ms } => print_json(
258            &session
259                .diagnostics(Duration::from_millis(*duration_ms))
260                .await?,
261        )?,
262        Commands::AcceptDialog => {
263            session.accept_dialog().await?;
264            print_json(&serde_json::json!({"dialog": "accepted"}))?;
265        }
266        Commands::DismissDialog => {
267            session.dismiss_dialog().await?;
268            print_json(&serde_json::json!({"dialog": "dismissed"}))?;
269        }
270        Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
271        Commands::Download {
272            destination,
273            timeout_ms,
274        } => print_json(
275            &session
276                .wait_for_download(destination, Duration::from_millis(*timeout_ms))
277                .await?,
278        )?,
279        Commands::Targets => print_json(&session.list_targets().await?)?,
280        Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
281        Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
282        Commands::CloseTarget { id } => {
283            session.close_target(id).await?;
284            print_json(&serde_json::json!({"closed": id}))?;
285        }
286        Commands::Frames => print_json(&session.list_frames().await?)?,
287        Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
288        Commands::Evaluate { expression } => {
289            print_json(&session.evaluate(expression).await?)?;
290        }
291        Commands::Cookies => print_json(&session.cookies().await?)?,
292        Commands::Pdf { output, background } => {
293            let mut opts = PdfOptions::letter();
294            if *background {
295                opts.print_background = Some(true);
296            }
297            let data = session.print_to_pdf(&opts).await?;
298            let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
299            tokio::fs::write(&output, &bytes).await?;
300            println!("PDF saved to {output} ({} bytes)", bytes.len());
301        }
302        Commands::FillForm { fields } => {
303            let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
304            let field_refs: Vec<(String, String)> = parsed
305                .iter()
306                .map(|v| {
307                    (
308                        v["target"].as_str().unwrap_or("").to_string(),
309                        v["value"].as_str().unwrap_or("").to_string(),
310                    )
311                })
312                .collect();
313            let field_slices: Vec<(&str, &str)> = field_refs
314                .iter()
315                .map(|(t, v)| (t.as_str(), v.as_str()))
316                .collect();
317            print_json(&session.fill_form(&field_slices).await?)?;
318        }
319        Commands::Batch { input, atomic } => {
320            let payload = read_json_input(input.as_ref())?;
321            let steps_value = payload.get("steps").cloned().unwrap_or(payload);
322            let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
323                .map_err(|error| format!("invalid batch document: {error}"))?;
324            print_json(&session.run_batch_with_options(&steps, *atomic).await?)?;
325        }
326        Commands::ReconcileRefs {
327            from_revision,
328            hints,
329            scope,
330            refs,
331        } => {
332            let options = ReconciliationOptions {
333                hints: hints
334                    .iter()
335                    .map(|hint| Locator::parse(hint))
336                    .collect::<BrowserResult<Vec<_>>>()?,
337                scope_ref: scope.clone(),
338            };
339            print_json(
340                &session
341                    .reconcile_references_with_options(*from_revision, refs, &options)
342                    .await?,
343            )?;
344        }
345        Commands::ObserveDelta => {
346            print_json(&session.observe_delta().await?)?;
347        }
348        Commands::Checkpoint { action } => match action {
349            CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
350            CheckpointCommand::Import { input } => {
351                let checkpoint: CheckpointV1 =
352                    serde_json::from_value(read_json_input(input.as_ref())?)
353                        .map_err(|error| format!("invalid checkpoint: {error}"))?;
354                session.import_checkpoint(&checkpoint).await?;
355                print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
356            }
357        },
358        Commands::ClipboardRead => {
359            let text = session.clipboard_read().await?;
360            println!("{text}");
361        }
362        Commands::ClipboardWrite { text } => {
363            session.clipboard_write(text).await?;
364            println!("Text written to clipboard");
365        }
366        Commands::Tui
367        | Commands::InstallChromium { .. }
368        | Commands::Profiles { .. }
369        | Commands::DeleteProfile { .. } => {
370            unreachable!("handled before starting a browser session")
371        }
372    }
373    Ok(())
374}
375
376fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
377    let mut input = String::new();
378    match path {
379        Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
380        None => std::io::stdin().read_to_string(&mut input)?,
381    };
382    Ok(serde_json::from_str(&input)?)
383}
384
385pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
386    Ok(BrowserPolicy::new(
387        cli.policy,
388        std::env::current_dir()?,
389        cli.policy_allow.iter().copied(),
390        cli.policy_confirm.iter().copied(),
391    )?
392    .with_host_rules(
393        cli.policy_allow_host.iter().cloned(),
394        cli.policy_deny_host.iter().cloned(),
395    )?
396    .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
397}
398
399async fn run_prompt(session: &BrowserSession, prompt: &str) -> BrowserResult<()> {
400    let trimmed = prompt.trim();
401    let lower = trimmed.to_lowercase();
402
403    for prefix in ["navigate to ", "go to ", "open "] {
404        if lower.starts_with(prefix) {
405            let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
406            print_json(&page)?;
407            return Ok(());
408        }
409    }
410    if let Some(rest) = lower.strip_prefix("click ") {
411        let target = &trimmed[trimmed.len() - rest.len()..];
412        print_json(&session.click(target.trim_matches('"')).await?)?;
413        return Ok(());
414    }
415    if let Some(rest) = lower.strip_prefix("double click ") {
416        let target = &trimmed[trimmed.len() - rest.len()..];
417        print_json(&session.double_click(target.trim_matches('"')).await?)?;
418        return Ok(());
419    }
420    if let Some(rest) = lower.strip_prefix("type ") {
421        let text = &trimmed[trimmed.len() - rest.len()..];
422        print_json(&session.type_text(text.trim_matches('"'), None).await?)?;
423        return Ok(());
424    }
425    if lower.starts_with("screenshot") {
426        let output = trimmed
427            .split_once(char::is_whitespace)
428            .map(|(_, value)| value.trim())
429            .filter(|value| !value.is_empty())
430            .unwrap_or("screenshot.png");
431        let output = session
432            .policy()
433            .require_output_path(std::path::Path::new(output))?;
434        std::fs::write(&output, session.screenshot_png().await?)?;
435        println!("wrote {}", output.display());
436        return Ok(());
437    }
438    if matches!(
439        lower.as_str(),
440        "text" | "get text" | "page text" | "get content"
441    ) {
442        println!("{}", session.text().await?);
443        return Ok(());
444    }
445    if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
446        print_json(&session.deep_dom().await?)?;
447        return Ok(());
448    }
449    if matches!(lower.as_str(), "observe" | "context") {
450        print_json(&session.observe().await?)?;
451        return Ok(());
452    }
453
454    print_json(&session.evaluate(trimmed).await?)?;
455    Ok(())
456}
457
458fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
459    println!("{}", compact_json(value)?);
460    Ok(())
461}
462
463fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
464    let mut value = serde_json::to_value(value)?;
465    let payload = serde_json::to_vec(&value)?;
466    let payload_bytes = payload.len();
467    if let Some(object) = value.as_object_mut() {
468        object.insert(
469            "contextCost".to_string(),
470            serde_json::json!({
471                "payloadBytes": payload_bytes,
472                "estimatedTokens": payload_bytes.div_ceil(4)
473            }),
474        );
475    }
476    Ok(serde_json::to_string(&value)?)
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use serde_json::json;
483
484    #[test]
485    fn structured_cli_output_is_compact_json() {
486        let output = compact_json(&json!({
487            "page": {"title": "Glass", "url": "https://example.com"},
488            "items": [1, 2]
489        }))
490        .unwrap();
491
492        let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
493        assert!(!output.contains('\n'));
494        assert_eq!(parsed["items"], json!([1, 2]));
495        assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
496        assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
497    }
498}