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::{
7    CertifyCommand, CheckpointCommand, Cli, Commands, DaemonCommand, KnowledgeCommand,
8    KnowledgeInvalidationState, ProfileCommand, WorkflowAuthoringCommand,
9};
10use crate::browser::policy::{BrowserPolicy, PolicyCapability};
11use crate::browser::profile::ProfileManager;
12use crate::browser::session::{
13    ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Cookie,
14    KnowledgeConfidence, KnowledgeStore, Locator, PdfOptions, ReconciliationOptions,
15    SemanticIntentExecutionRequest, SemanticIntentRequest, SemanticObservationLevel,
16    SessionOptions, VerificationPredicate, VisualCaptureOptions, WaitCondition,
17    WorkflowAuthoringFormat, WorkflowCheckpoint, WorkflowDefinition, WorkflowDiagnosticSeverity,
18    WorkflowRecordingSession, compile_workflow, default_knowledge_store_path, diff_workflows,
19    format_workflow_yaml, preview_workflow, record_semantic_events,
20};
21use crate::capabilities::GlassCapabilityManifest;
22use crate::reliability::{
23    ReliabilityFixtureManifest, ReliabilityReplayBundle, ReliabilityScenario,
24    ReliabilityScenarioObservation, build_reliability_scorecard,
25};
26use crate::reliability_runner::{ReliabilityRunOptions, run_reliability_scenario};
27use base64::Engine;
28use serde::Serialize;
29use serde_json::Value;
30use std::collections::BTreeMap;
31use std::io::Read;
32use std::time::Duration;
33
34/// Top-level command-line entry point: parses CLI arguments and dispatches
35/// to the appropriate runner (one-shot, TUI, or MCP server).
36pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
37    let policy = policy_from_cli(&cli)?;
38    if cli.mcp {
39        return crate::mcp::server::run_mcp_server(&cli).await;
40    }
41
42    match &cli.command {
43        Some(Commands::InstallChromium { update }) => {
44            let path = crate::browser::chrome::download_chromium(*update).await?;
45            println!("Chrome for Testing installed at {}", path.display());
46            return Ok(());
47        }
48        Some(Commands::Capabilities) => {
49            print_json(&GlassCapabilityManifest::for_policy(&policy))?;
50            return Ok(());
51        }
52        Some(Commands::Daemon { action }) => {
53            dispatch_daemon(action).await?;
54            return Ok(());
55        }
56        Some(Commands::Doctor) => {
57            dispatch_doctor(&cli, &policy).await?;
58            return Ok(());
59        }
60        Some(Commands::Certify { action }) if !matches!(action, CertifyCommand::Run { .. }) => {
61            dispatch_certify(action)?;
62            return Ok(());
63        }
64        Some(Commands::Profiles { action }) => {
65            policy.require(PolicyCapability::PersistentProfile)?;
66            dispatch_profiles(action.as_ref())?;
67            return Ok(());
68        }
69        Some(Commands::DeleteProfile { name }) => {
70            policy.require(PolicyCapability::PersistentProfile)?;
71            ProfileManager::new().delete_profile(name)?;
72            println!("deleted profile {name}");
73            return Ok(());
74        }
75        Some(Commands::Knowledge { action }) => {
76            policy.require(PolicyCapability::PersistentProfile)?;
77            dispatch_knowledge(action, cli.knowledge_store.as_deref(), &cli.profile)?;
78            return Ok(());
79        }
80        Some(Commands::Workflow {
81            action: Some(action),
82            input: None,
83        }) => {
84            dispatch_workflow_authoring(action)?;
85            return Ok(());
86        }
87        Some(Commands::Tui) | None if cli.prompt.is_none() => {
88            return crate::tui::app::run_tui(&cli).await;
89        }
90        _ => {}
91    }
92
93    let options = SessionOptions {
94        port: cli.port,
95        chrome_path: cli.chrome_path.clone(),
96        profile: cli.profile.clone(),
97        incognito: cli.incognito,
98        attach: cli.attach,
99        target_id: cli.target_id.clone(),
100        frame_id: cli.frame_id.clone(),
101        headed: cli.headed,
102        interaction_mode: cli.interaction,
103        audit: cli.audit,
104        policy: None,
105    };
106    let session = BrowserSession::start_with_policy(&options, policy).await?;
107    let result = if let Some(prompt) = &cli.prompt {
108        run_prompt(&session, prompt).await
109    } else if let Some(command) = &cli.command {
110        run_command(&session, command).await
111    } else {
112        Ok(())
113    };
114    if let Err(error) = &result
115        && cli.trace_on_error
116    {
117        let trace = session
118            .failure_trace_for(
119                cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
120                error.to_string(),
121            )
122            .await;
123        eprintln!("{}", serde_json::to_string(&trace)?);
124    }
125    let close_result = session.close().await;
126    result?;
127    close_result
128}
129
130async fn dispatch_daemon(action: &DaemonCommand) -> BrowserResult<()> {
131    match action {
132        DaemonCommand::Start { socket, status } => {
133            print_json(&crate::daemon::start(socket.as_deref(), status.as_deref()).await?)?;
134        }
135        DaemonCommand::Status { socket, status } => {
136            print_json(&crate::daemon::status(
137                socket.as_deref(),
138                status.as_deref(),
139            )?)?;
140        }
141        DaemonCommand::Stop { socket, status } => {
142            crate::daemon::stop(socket.as_deref(), status.as_deref())?;
143            print_json(&serde_json::json!({"status": "stopped"}))?;
144        }
145        DaemonCommand::Doctor { socket, status } => {
146            print_json(&crate::daemon::doctor(
147                socket.as_deref(),
148                status.as_deref(),
149            )?)?;
150        }
151        DaemonCommand::Logs { status } => {
152            print_json(&crate::daemon::logs(status.as_deref())?)?;
153        }
154        DaemonCommand::AcknowledgeRecovery {
155            status,
156            request_ids,
157        } => {
158            print_json(&crate::daemon::acknowledge_recovery(
159                status.as_deref(),
160                request_ids,
161            )?)?;
162        }
163        DaemonCommand::Serve { socket, status } => {
164            crate::daemon::serve(socket, status).await?;
165        }
166    }
167    Ok(())
168}
169
170async fn dispatch_doctor(cli: &Cli, policy: &BrowserPolicy) -> BrowserResult<()> {
171    let chrome_path =
172        crate::browser::chrome::resolve_chrome_path(None).map(|path| path.display().to_string());
173    let (daemon_socket, daemon_status) = crate::daemon::default_paths();
174    let daemon = crate::daemon::doctor(Some(&daemon_socket), Some(&daemon_status))?;
175    let profiles = ProfileManager::new().list_profiles().unwrap_or_default();
176    let knowledge_path = cli
177        .knowledge_store
178        .clone()
179        .unwrap_or_else(|| default_knowledge_store_path(&cli.profile));
180    let knowledge_exists = knowledge_path.is_file();
181    let manifest = GlassCapabilityManifest::for_policy(policy);
182    let platform_supported = manifest.constraints.platform != "unsupported";
183    print_json(&serde_json::json!({
184        "status": if chrome_path.is_some() && platform_supported { "ready" } else { "degraded" },
185        "version": env!("CARGO_PKG_VERSION"),
186        "platform": manifest.constraints.platform,
187        "browser": {
188            "family": manifest.constraints.browser_family,
189            "chromeAvailable": chrome_path.is_some(),
190            "chromePath": chrome_path,
191            "cdpPort": cli.port,
192            "cdpReachable": crate::browser::chrome::check_chrome_health(cli.port).await,
193        },
194        "daemon": daemon,
195        "profiles": {
196            "count": profiles.len(),
197            "names": profiles,
198        },
199        "policy": {
200            "preset": manifest.constraints.policy,
201            "capabilities": manifest.capabilities,
202        },
203        "knowledgeStore": {
204            "path": knowledge_path,
205            "exists": knowledge_exists,
206        },
207        "extensions": {
208            "enabled": manifest.capabilities.get("extensions").copied().unwrap_or(false),
209            "loader": "disabled",
210        },
211    }))?;
212    Ok(())
213}
214
215fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
216    if let Some(prompt) = prompt {
217        let lower = prompt.trim().to_ascii_lowercase();
218        return if lower.starts_with("double click ") {
219            ActionKind::DoubleClick
220        } else if lower.starts_with("click ") {
221            ActionKind::Click
222        } else if lower.starts_with("type ") {
223            ActionKind::Type
224        } else {
225            ActionKind::Click
226        };
227    }
228    match command {
229        Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
230        Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
231        Some(Commands::Click { .. })
232        | Some(Commands::Preflight { .. })
233        | Some(Commands::ClickAt { .. }) => ActionKind::Click,
234        Some(Commands::Hover { .. }) => ActionKind::Hover,
235        Some(Commands::Drag { .. }) => ActionKind::Drag,
236        Some(Commands::Type { .. }) => ActionKind::Type,
237        Some(Commands::Key { .. }) => ActionKind::KeyPress,
238        Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
239        Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
240        Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
241        Some(Commands::Clear { .. }) => ActionKind::Clear,
242        Some(Commands::Check { .. }) => ActionKind::Check,
243        Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
244        Some(Commands::Select { .. }) => ActionKind::Select,
245        Some(Commands::Upload { .. }) => ActionKind::Upload,
246        Some(Commands::Scroll { .. }) => ActionKind::Scroll,
247        _ => ActionKind::Click,
248    }
249}
250
251fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
252    let manager = ProfileManager::new();
253    match action {
254        None | Some(ProfileCommand::List) => {
255            let profiles = manager.list_profiles()?;
256            if profiles.is_empty() {
257                println!("no saved profiles");
258            } else {
259                for profile in profiles {
260                    println!("{profile}");
261                }
262            }
263        }
264        Some(ProfileCommand::Create { name }) => {
265            manager.create_profile(name)?;
266            println!("created profile {name}");
267        }
268        Some(ProfileCommand::Delete { name }) => {
269            manager.delete_profile(name)?;
270            println!("deleted profile {name}");
271        }
272    }
273    Ok(())
274}
275
276fn dispatch_certify(action: &CertifyCommand) -> BrowserResult<()> {
277    match action {
278        CertifyCommand::Run { .. } => {
279            unreachable!("browser-backed reliability runs are handled after startup")
280        }
281        CertifyCommand::Plan { scenario, fixture } => {
282            let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
283            let fixture =
284                ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture)?)?;
285            let plan = scenario.execution_plan(&fixture)?;
286            print_json(&serde_json::json!({
287                "status": "valid",
288                "plan": plan,
289            }))?;
290        }
291        CertifyCommand::Release {
292            version,
293            scenarios,
294            observations,
295            replays,
296        } => {
297            let scenario_value = read_json_input(Some(scenarios))?;
298            let scenarios: Vec<ReliabilityScenario> = if scenario_value.is_array() {
299                serde_json::from_value(scenario_value)?
300            } else {
301                vec![serde_json::from_value(scenario_value)?]
302            };
303            let observations: Vec<ReliabilityScenarioObservation> =
304                serde_json::from_value(read_json_input(Some(observations))?)?;
305            let replays_validated = if let Some(replays) = replays {
306                let replay_value = read_json_input(Some(replays))?;
307                let replay_values: Vec<Value> = if replay_value.is_array() {
308                    serde_json::from_value(replay_value)?
309                } else {
310                    vec![replay_value]
311                };
312                let mut replay_by_id = BTreeMap::new();
313                for replay_value in replay_values {
314                    let scenario_id = replay_value
315                        .get("scenarioId")
316                        .and_then(Value::as_str)
317                        .ok_or("replay bundle is missing scenarioId")?
318                        .to_string();
319                    let scenario = scenarios
320                        .iter()
321                        .find(|scenario| scenario.id == scenario_id)
322                        .ok_or_else(|| {
323                            format!("replay references unknown scenario {scenario_id}")
324                        })?;
325                    let bundle = ReliabilityReplayBundle::from_value(replay_value, scenario)?;
326                    if replay_by_id.insert(scenario_id.clone(), bundle).is_some() {
327                        return Err(format!("duplicate replay for scenario {scenario_id}").into());
328                    }
329                }
330                if replay_by_id.len() != scenarios.len() {
331                    return Err("replay evidence must cover every scenario".into());
332                }
333                let observations_by_id: BTreeMap<_, _> = observations
334                    .iter()
335                    .map(|observation| (observation.scenario_id.as_str(), observation))
336                    .collect();
337                for (scenario_id, replay) in &replay_by_id {
338                    let observation =
339                        observations_by_id
340                            .get(scenario_id.as_str())
341                            .ok_or_else(|| {
342                                format!("replay has no matching observation for {scenario_id}")
343                            })?;
344                    if serde_json::to_value(&replay.observation)?
345                        != serde_json::to_value(observation)?
346                    {
347                        return Err(format!("replay observation differs for {scenario_id}").into());
348                    }
349                }
350                true
351            } else {
352                false
353            };
354            let scorecard = build_reliability_scorecard(&scenarios, &observations)?;
355            let certified = scorecard.certified;
356            print_json(&serde_json::json!({
357                "status": if certified { "certified" } else { "blocked" },
358                "version": version,
359                "tool": {"name": "glass", "version": env!("CARGO_PKG_VERSION")},
360                "replaysValidated": replays_validated,
361                "gate": &scorecard.gate,
362                "scorecard": &scorecard,
363            }))?;
364            if !certified {
365                return Err("reliability certification blocked".into());
366            }
367        }
368        CertifyCommand::Replay { scenario, input } => {
369            let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
370            let bundle =
371                ReliabilityReplayBundle::from_value(read_json_input(Some(input))?, &scenario)?;
372            print_json(&serde_json::json!({
373                "status": "valid",
374                "scenarioId": &bundle.scenario_id,
375                "replayHash": bundle.content_hash(&scenario)?,
376            }))?;
377        }
378        CertifyCommand::ReplayDiff {
379            scenario,
380            before,
381            after,
382        } => {
383            let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
384            let before =
385                ReliabilityReplayBundle::from_value(read_json_input(Some(before))?, &scenario)?;
386            let after =
387                ReliabilityReplayBundle::from_value(read_json_input(Some(after))?, &scenario)?;
388            let comparison = before.compare(&after, &scenario)?;
389            print_json(&serde_json::json!({
390                "status": if comparison.equivalent { "equivalent" } else { "changed" },
391                "comparison": comparison,
392            }))?;
393        }
394    }
395    Ok(())
396}
397
398async fn dispatch_certify_run(
399    session: &BrowserSession,
400    scenario_path: &std::path::Path,
401    fixture_path: &std::path::Path,
402    url: &str,
403    workflow_root: &std::path::Path,
404    inputs_path: Option<&std::path::Path>,
405    output: Option<&std::path::Path>,
406) -> BrowserResult<()> {
407    let scenario = ReliabilityScenario::from_json(&std::fs::read_to_string(scenario_path)?)?;
408    let fixture = ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture_path)?)?;
409    let inputs: BTreeMap<String, Value> = match inputs_path {
410        Some(path) => serde_json::from_str(&std::fs::read_to_string(path)?)
411            .map_err(|error| format!("invalid workflow inputs: {error}"))?,
412        None => BTreeMap::new(),
413    };
414    session.navigate(url).await?;
415    let evidence = run_reliability_scenario(
416        session,
417        &scenario,
418        &fixture,
419        &ReliabilityRunOptions {
420            workflow_root: workflow_root.to_path_buf(),
421            inputs,
422        },
423    )
424    .await?;
425    let value = serde_json::json!({
426        "observation": evidence.observation,
427        "replay": evidence.replay,
428    });
429    if let Some(output) = output {
430        tokio::fs::write(output, serde_json::to_vec_pretty(&value)?).await?;
431    }
432    print_json(&value)?;
433    Ok(())
434}
435
436fn dispatch_knowledge(
437    action: &KnowledgeCommand,
438    explicit_path: Option<&std::path::Path>,
439    profile: &str,
440) -> BrowserResult<()> {
441    ProfileManager::validate_name(profile)?;
442    let path = explicit_path
443        .map(std::path::Path::to_path_buf)
444        .unwrap_or_else(|| default_knowledge_store_path(profile));
445    let mut store = KnowledgeStore::open(path)?;
446    match action {
447        KnowledgeCommand::List => print_json(store.snapshot())?,
448        KnowledgeCommand::Show { record_id } => {
449            let record = store
450                .get(record_id)
451                .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
452            print_json(record)?;
453        }
454        KnowledgeCommand::Explain { record_id } => {
455            let record = store
456                .get(record_id)
457                .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
458            print_json(&serde_json::json!({
459                "recordId": &record.record_id,
460                "kind": record.kind,
461                "confidence": record.confidence,
462                "scope": &record.scope,
463                "source": &record.source,
464                "invalidation": &record.invalidation,
465                "history": &record.history,
466                "contentHash": record.content_hash()?,
467                "assessment": "requires a fresh observation; stored knowledge is never an authorization",
468            }))?;
469        }
470        KnowledgeCommand::Stats => print_json(&store.stats()?)?,
471        KnowledgeCommand::Export { output } => {
472            let canonical = store.snapshot().to_canonical_json()?;
473            if let Some(output) = output {
474                std::fs::write(output, canonical)?;
475                println!("exported knowledge to {}", output.display());
476            } else {
477                println!("{canonical}");
478            }
479        }
480        KnowledgeCommand::Import { input } => {
481            let snapshot = serde_json::from_value(read_json_input(Some(input))?)
482                .map_err(|error| format!("invalid knowledge snapshot: {error}"))?;
483            store.replace_snapshot(snapshot)?;
484            print_json(&store.stats()?)?;
485        }
486        KnowledgeCommand::Invalidate {
487            record_id,
488            state,
489            reason,
490            observed_at,
491        } => {
492            let next = match state {
493                KnowledgeInvalidationState::Stale => KnowledgeConfidence::Stale,
494                KnowledgeInvalidationState::Contradicted => KnowledgeConfidence::Contradicted,
495                KnowledgeInvalidationState::Quarantined => KnowledgeConfidence::Quarantined,
496            };
497            let change = store.transition(
498                record_id,
499                next,
500                reason
501                    .clone()
502                    .unwrap_or_else(|| "caller invalidated record".into()),
503                observed_at
504                    .clone()
505                    .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
506                false,
507            )?;
508            print_json(&change)?;
509        }
510        KnowledgeCommand::Purge { origin } => print_json(&store.purge_origin(origin)?)?,
511    }
512    Ok(())
513}
514
515fn dispatch_workflow_authoring(action: &WorkflowAuthoringCommand) -> BrowserResult<()> {
516    match action {
517        WorkflowAuthoringCommand::Compile { input, output } => {
518            let source = std::fs::read_to_string(input)?;
519            let document = compile_workflow(&source, authoring_format(input))?;
520            if let Some(output) = output {
521                std::fs::write(output, &document.canonical_json)?;
522                println!("compiled workflow to {}", output.display());
523            } else {
524                println!("{}", document.canonical_json);
525            }
526            if document
527                .diagnostics
528                .iter()
529                .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
530            {
531                return Err("workflow compilation produced error diagnostics".into());
532            }
533        }
534        WorkflowAuthoringCommand::Format { input, output } => {
535            let source = std::fs::read_to_string(input)?;
536            let document = compile_workflow(&source, authoring_format(input))?;
537            let formatted = format_workflow_yaml(&document.definition)?;
538            if let Some(output) = output {
539                std::fs::write(output, formatted)?;
540                println!("formatted workflow to {}", output.display());
541            } else {
542                print!("{formatted}");
543            }
544        }
545        WorkflowAuthoringCommand::Preview { input } => {
546            let source = std::fs::read_to_string(input)?;
547            let document = compile_workflow(&source, authoring_format(input))?;
548            let preview = preview_workflow(&document.definition)?;
549            print_json(&serde_json::json!({
550                "preview": preview,
551                "diagnostics": document.diagnostics,
552            }))?;
553        }
554        WorkflowAuthoringCommand::Diff { before, after } => {
555            let before_source = std::fs::read_to_string(before)?;
556            let after_source = std::fs::read_to_string(after)?;
557            let before_document = compile_workflow(&before_source, authoring_format(before))?;
558            let after_document = compile_workflow(&after_source, authoring_format(after))?;
559            let diff = diff_workflows(&before_document.definition, &after_document.definition)?;
560            print_json(&serde_json::json!({
561                "diff": diff,
562                "beforeDiagnostics": before_document.diagnostics,
563                "afterDiagnostics": after_document.diagnostics,
564            }))?;
565        }
566        WorkflowAuthoringCommand::Record { input, output } => {
567            let value = read_json_input(input.as_ref())?;
568            let session: WorkflowRecordingSession = serde_json::from_value(value)?;
569            let draft = record_semantic_events(session)?;
570            let serialized = serde_json::to_string_pretty(&draft)?;
571            if let Some(output) = output {
572                std::fs::write(output, serialized)?;
573                println!("recorded workflow draft to {}", output.display());
574            } else {
575                println!("{serialized}");
576            }
577        }
578        WorkflowAuthoringCommand::Validate { input } => {
579            let source = std::fs::read_to_string(input)?;
580            let document = compile_workflow(&source, authoring_format(input))?;
581            print_json(&document)?;
582        }
583        WorkflowAuthoringCommand::Lint {
584            input,
585            warnings_as_errors,
586        } => {
587            let source = std::fs::read_to_string(input)?;
588            let document = compile_workflow(&source, authoring_format(input))?;
589            let failed = document.diagnostics.iter().any(|diagnostic| {
590                diagnostic.severity == WorkflowDiagnosticSeverity::Error
591                    || (*warnings_as_errors
592                        && diagnostic.severity == WorkflowDiagnosticSeverity::Warning)
593            });
594            print_json(&document.diagnostics)?;
595            if failed {
596                return Err("workflow lint failed".into());
597            }
598        }
599    }
600    Ok(())
601}
602
603fn authoring_format(path: &std::path::Path) -> WorkflowAuthoringFormat {
604    if path
605        .extension()
606        .and_then(|extension| extension.to_str())
607        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
608    {
609        WorkflowAuthoringFormat::Json
610    } else {
611        WorkflowAuthoringFormat::Yaml
612    }
613}
614
615async fn run_command(session: &BrowserSession, command: &Commands) -> BrowserResult<()> {
616    match command {
617        Commands::Certify {
618            action:
619                CertifyCommand::Run {
620                    scenario,
621                    fixture,
622                    url,
623                    workflow_root,
624                    inputs,
625                    output,
626                },
627        } => {
628            dispatch_certify_run(
629                session,
630                scenario,
631                fixture,
632                url,
633                workflow_root,
634                inputs.as_deref(),
635                output.as_deref(),
636            )
637            .await?;
638        }
639        Commands::Capabilities
640        | Commands::Daemon { .. }
641        | Commands::Doctor
642        | Commands::Certify { .. }
643        | Commands::Knowledge { .. } => {
644            unreachable!("offline commands are handled before browser startup")
645        }
646        Commands::Navigate {
647            url,
648            timeout_ms,
649            expected_revision,
650        } => {
651            if let Some(expected_revision) = expected_revision {
652                print_json(
653                    &session
654                        .navigate_with_revision(
655                            url,
656                            Duration::from_millis(*timeout_ms),
657                            *expected_revision,
658                        )
659                        .await?,
660                )?;
661            } else {
662                let page = session
663                    .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
664                    .await?;
665                print_json(&page)?;
666            }
667        }
668        Commands::Click {
669            target,
670            expected_revision,
671        } => {
672            if let Some(expected_revision) = expected_revision {
673                print_json(
674                    &session
675                        .click_with_revision(target, *expected_revision)
676                        .await?,
677                )?;
678            } else {
679                print_json(&session.click(target).await?)?;
680            }
681        }
682        Commands::Preflight { target, action } => {
683            print_json(&session.preflight_with_action(target, *action).await)?;
684        }
685        Commands::ClickAt { x, y } => {
686            print_json(&session.click_at(*x, *y).await?)?;
687        }
688        Commands::ClickExpectPopup {
689            target,
690            expected_revision,
691        } => {
692            print_json(
693                &session
694                    .click_expect_popup_with_revision(target, *expected_revision)
695                    .await?,
696            )?;
697        }
698        Commands::DoubleClick {
699            target,
700            expected_revision,
701        } => {
702            print_json(
703                &session
704                    .double_click_with_revision(target, *expected_revision)
705                    .await?,
706            )?;
707        }
708        Commands::Hover { target } => print_json(&session.hover(target).await?)?,
709        Commands::Drag {
710            source,
711            destination,
712            expected_revision,
713        } => {
714            print_json(
715                &session
716                    .drag_with_revision(source, destination, *expected_revision)
717                    .await?,
718            )?;
719        }
720        Commands::Type {
721            text,
722            target,
723            expected_revision,
724        } => {
725            print_json(
726                &session
727                    .type_text_with_expected_revision(text, target.as_deref(), *expected_revision)
728                    .await?,
729            )?;
730        }
731        Commands::Key {
732            key,
733            expected_revision,
734        } => print_json(
735            &session
736                .key_press_with_revision(key, *expected_revision)
737                .await?,
738        )?,
739        Commands::KeyDown {
740            key,
741            expected_revision,
742        } => print_json(
743            &session
744                .key_down_with_revision(key, *expected_revision)
745                .await?,
746        )?,
747        Commands::KeyUp {
748            key,
749            expected_revision,
750        } => print_json(
751            &session
752                .key_up_with_revision(key, *expected_revision)
753                .await?,
754        )?,
755        Commands::Shortcut {
756            shortcut,
757            expected_revision,
758        } => print_json(
759            &session
760                .shortcut_with_revision(shortcut, *expected_revision)
761                .await?,
762        )?,
763        Commands::Clear {
764            target,
765            expected_revision,
766        } => print_json(
767            &session
768                .clear_with_revision(target, *expected_revision)
769                .await?,
770        )?,
771        Commands::Check {
772            target,
773            expected_revision,
774        } => print_json(
775            &session
776                .check_with_revision(target, *expected_revision)
777                .await?,
778        )?,
779        Commands::Uncheck {
780            target,
781            expected_revision,
782        } => print_json(
783            &session
784                .uncheck_with_revision(target, *expected_revision)
785                .await?,
786        )?,
787        Commands::Select {
788            target,
789            value,
790            expected_revision,
791        } => {
792            print_json(
793                &session
794                    .select_option_with_revision(target, value, *expected_revision)
795                    .await?,
796            )?;
797        }
798        Commands::Upload {
799            target,
800            files,
801            expected_revision,
802        } => {
803            print_json(
804                &session
805                    .upload_files_with_revision(target, files, *expected_revision)
806                    .await?,
807            )?;
808        }
809        Commands::Screenshot {
810            output,
811            format,
812            quality,
813            scale,
814            full_page,
815            clip,
816            target,
817        } => {
818            let output = session
819                .policy()
820                .require_output_path(std::path::Path::new(output))?;
821            let capture = session
822                .capture_visual(&VisualCaptureOptions {
823                    format: *format,
824                    quality: *quality,
825                    scale: *scale,
826                    clip: *clip,
827                    full_page: *full_page,
828                    target: target.clone(),
829                })
830                .await?;
831            let mut source = base64::read::DecoderReader::new(
832                capture.data.as_bytes(),
833                &base64::engine::general_purpose::STANDARD,
834            );
835            let mut file = std::fs::File::create(&output)?;
836            std::io::copy(&mut source, &mut file)?;
837            println!("wrote {}", output.display());
838            print_json(&capture.metadata)?;
839        }
840        Commands::Text => println!("{}", session.text().await?),
841        Commands::Dom => print_json(&session.deep_dom().await?)?,
842        Commands::Observe {
843            deep_dom,
844            screenshot,
845            form_values,
846            semantic_level,
847            region,
848        } => {
849            if let Some(level_name) = semantic_level {
850                if *deep_dom || *screenshot || *form_values {
851                    return Err(
852                        "semantic observation cannot be combined with deep DOM, screenshot, or form values"
853                            .into(),
854                    );
855                }
856                let level = parse_semantic_level(level_name)?;
857                if let Some(region_id) = region {
858                    let page = session.semantic_observe(level).await?;
859                    print_json(
860                        &session
861                            .semantic_expand_region(region_id, page.revision, level)
862                            .await?,
863                    )?;
864                } else {
865                    print_json(&session.semantic_observe(level).await?)?;
866                }
867                return Ok(());
868            }
869            let context = match (*deep_dom, *screenshot, *form_values) {
870                (false, false, false) => session.observe().await?,
871                (true, false, false) => session.observe_with_dom().await?,
872                (false, true, false) => session.observe_with_screenshot().await?,
873                (true, true, false) => session.observe_with_dom_and_screenshot().await?,
874                (false, false, true) => session.observe_with_form_values().await?,
875                _ => return Err("form values can only be combined with compact observe".into()),
876            };
877            print_json(&context)?;
878        }
879        Commands::Scroll {
880            dx,
881            dy,
882            expected_revision,
883        } => {
884            print_json(
885                &session
886                    .scroll_with_revision(*dx, *dy, *expected_revision)
887                    .await?,
888            )?;
889        }
890        Commands::Wait {
891            condition,
892            timeout_ms,
893        } => {
894            print_json(
895                &session
896                    .wait(
897                        WaitCondition::parse(condition)?,
898                        Duration::from_millis(*timeout_ms),
899                    )
900                    .await?,
901            )?;
902        }
903        Commands::Diagnostics { duration_ms } => print_json(
904            &session
905                .diagnostics(Duration::from_millis(*duration_ms))
906                .await?,
907        )?,
908        Commands::AcceptDialog => {
909            session.accept_dialog().await?;
910            print_json(&serde_json::json!({"dialog": "accepted"}))?;
911        }
912        Commands::DismissDialog => {
913            session.dismiss_dialog().await?;
914            print_json(&serde_json::json!({"dialog": "dismissed"}))?;
915        }
916        Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
917        Commands::Download {
918            destination,
919            timeout_ms,
920        } => print_json(
921            &session
922                .wait_for_download(destination, Duration::from_millis(*timeout_ms))
923                .await?,
924        )?,
925        Commands::Targets => print_json(&session.list_targets().await?)?,
926        Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
927        Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
928        Commands::CloseTarget { id } => {
929            session.close_target(id).await?;
930            print_json(&serde_json::json!({"closed": id}))?;
931        }
932        Commands::Frames => print_json(&session.list_frames().await?)?,
933        Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
934        Commands::Evaluate { expression } => {
935            print_json(&session.evaluate(expression).await?)?;
936        }
937        Commands::Cookies => print_json(&session.cookies().await?)?,
938        Commands::ExportCookies { output } => {
939            let cookies = session.cookies().await?;
940            let bytes = serde_json::to_vec_pretty(&cookies)?;
941            tokio::fs::write(output, bytes).await?;
942            println!("cookies exported to {}", output.display());
943        }
944        Commands::ImportCookies { input } => {
945            const MAX_COOKIE_FILE_BYTES: u64 = 512 * 1024;
946            let metadata = tokio::fs::metadata(input).await?;
947            if metadata.len() > MAX_COOKIE_FILE_BYTES {
948                return Err(format!(
949                    "cookie file exceeds the {}-byte limit",
950                    MAX_COOKIE_FILE_BYTES
951                )
952                .into());
953            }
954            let bytes = tokio::fs::read(input).await?;
955            let cookies: Vec<Cookie> = serde_json::from_slice(&bytes)?;
956            session.set_cookies(&cookies).await?;
957            println!("{} cookies imported", cookies.len());
958        }
959        Commands::Pdf { output, background } => {
960            let mut opts = PdfOptions::letter();
961            if *background {
962                opts.print_background = Some(true);
963            }
964            let data = session.print_to_pdf(&opts).await?;
965            let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
966            tokio::fs::write(&output, &bytes).await?;
967            println!("PDF saved to {output} ({} bytes)", bytes.len());
968        }
969        Commands::FillForm {
970            fields,
971            expected_revision,
972        } => {
973            let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
974            let field_refs: Vec<(String, String)> = parsed
975                .iter()
976                .map(|v| {
977                    (
978                        v["target"].as_str().unwrap_or("").to_string(),
979                        v["value"].as_str().unwrap_or("").to_string(),
980                    )
981                })
982                .collect();
983            let field_slices: Vec<(&str, &str)> = field_refs
984                .iter()
985                .map(|(t, v)| (t.as_str(), v.as_str()))
986                .collect();
987            print_json(
988                &session
989                    .fill_form_with_expected_revision(&field_slices, *expected_revision)
990                    .await?,
991            )?;
992        }
993        Commands::Batch {
994            input,
995            atomic,
996            mode,
997            expected_revision,
998        } => {
999            let payload = read_json_input(input.as_ref())?;
1000            let steps_value = payload.get("steps").cloned().unwrap_or(payload);
1001            let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
1002                .map_err(|error| format!("invalid batch document: {error}"))?;
1003            print_json(
1004                &session
1005                    .run_batch_with_mode(&steps, *atomic, *mode, *expected_revision)
1006                    .await?,
1007            )?;
1008        }
1009        Commands::Workflow {
1010            action: None,
1011            input,
1012        } => {
1013            let payload = read_json_input(input.as_ref())?;
1014            let workflow_value = payload
1015                .get("workflow")
1016                .cloned()
1017                .unwrap_or_else(|| payload.clone());
1018            let inputs_value = payload
1019                .get("inputs")
1020                .cloned()
1021                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
1022            let workflow = WorkflowDefinition::from_value(workflow_value)
1023                .map_err(|error| format!("invalid workflow: {error}"))?;
1024            let inputs: BTreeMap<String, Value> = serde_json::from_value(inputs_value)
1025                .map_err(|error| format!("invalid workflow inputs: {error}"))?;
1026            print_json(&session.run_workflow(&workflow, &inputs).await?)?;
1027        }
1028        Commands::Workflow {
1029            action: Some(_), ..
1030        } => unreachable!("offline workflow authoring commands are handled before browser startup"),
1031        Commands::WorkflowResume {
1032            workflow,
1033            checkpoint,
1034            inputs,
1035        } => {
1036            let workflow = WorkflowDefinition::from_value(read_json_input(Some(workflow))?)
1037                .map_err(|error| format!("invalid workflow: {error}"))?;
1038            let checkpoint: WorkflowCheckpoint =
1039                serde_json::from_value(read_json_input(Some(checkpoint))?)
1040                    .map_err(|error| format!("invalid workflow checkpoint: {error}"))?;
1041            let inputs: BTreeMap<String, Value> = match inputs {
1042                Some(path) => serde_json::from_value(read_json_input(Some(path))?)
1043                    .map_err(|error| format!("invalid workflow inputs: {error}"))?,
1044                None => BTreeMap::new(),
1045            };
1046            print_json(
1047                &session
1048                    .resume_workflow(&workflow, &inputs, &checkpoint)
1049                    .await?,
1050            )?;
1051        }
1052        Commands::ResolveIntent { input } => {
1053            let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1054                &read_json_input(input.as_ref())?,
1055            )?)?;
1056            print_json(&session.resolve_intent(&request).await?)?;
1057        }
1058        Commands::ExecuteIntent { input } => {
1059            let execution = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1060                &read_json_input(input.as_ref())?,
1061            )?)?;
1062            print_json(&session.execute_intent(&execution).await?)?;
1063        }
1064        Commands::Verify {
1065            predicate,
1066            timeout_ms,
1067        } => {
1068            let predicate: VerificationPredicate = serde_json::from_str(predicate)
1069                .map_err(|error| format!("invalid verification predicate: {error}"))?;
1070            print_json(
1071                &session
1072                    .verify(predicate, Duration::from_millis(*timeout_ms))
1073                    .await?,
1074            )?;
1075        }
1076        Commands::ReconcileRefs {
1077            from_revision,
1078            hints,
1079            scope,
1080            refs,
1081        } => {
1082            let options = ReconciliationOptions {
1083                hints: hints
1084                    .iter()
1085                    .map(|hint| Locator::parse(hint))
1086                    .collect::<BrowserResult<Vec<_>>>()?,
1087                scope_ref: scope.clone(),
1088            };
1089            print_json(
1090                &session
1091                    .reconcile_references_with_options(*from_revision, refs, &options)
1092                    .await?,
1093            )?;
1094        }
1095        Commands::ObserveDelta => {
1096            print_json(&session.observe_delta().await?)?;
1097        }
1098        Commands::Checkpoint { action } => match action {
1099            CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
1100            CheckpointCommand::Import { input } => {
1101                let checkpoint: CheckpointV1 =
1102                    serde_json::from_value(read_json_input(input.as_ref())?)
1103                        .map_err(|error| format!("invalid checkpoint: {error}"))?;
1104                session.import_checkpoint(&checkpoint).await?;
1105                print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
1106            }
1107        },
1108        Commands::ClipboardRead => {
1109            let text = session.clipboard_read().await?;
1110            println!("{text}");
1111        }
1112        Commands::ClipboardWrite { text } => {
1113            session.clipboard_write(text).await?;
1114            println!("Text written to clipboard");
1115        }
1116        Commands::Tui
1117        | Commands::InstallChromium { .. }
1118        | Commands::Profiles { .. }
1119        | Commands::DeleteProfile { .. } => {
1120            unreachable!("handled before starting a browser session")
1121        }
1122    }
1123    Ok(())
1124}
1125
1126fn parse_semantic_level(value: &str) -> BrowserResult<SemanticObservationLevel> {
1127    match value {
1128        "summary" => Ok(SemanticObservationLevel::Summary),
1129        "interactive" => Ok(SemanticObservationLevel::Interactive),
1130        "structured" => Ok(SemanticObservationLevel::Structured),
1131        "detailed" => Ok(SemanticObservationLevel::Detailed),
1132        "raw" => Ok(SemanticObservationLevel::Raw),
1133        _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
1134    }
1135}
1136
1137fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
1138    let mut input = String::new();
1139    match path {
1140        Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
1141        None => std::io::stdin().read_to_string(&mut input)?,
1142    };
1143    Ok(serde_json::from_str(&input)?)
1144}
1145
1146pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
1147    Ok(BrowserPolicy::new(
1148        cli.policy,
1149        std::env::current_dir()?,
1150        cli.policy_allow.iter().copied(),
1151        cli.policy_confirm.iter().copied(),
1152    )?
1153    .with_host_rules(
1154        cli.policy_allow_host.iter().cloned(),
1155        cli.policy_deny_host.iter().cloned(),
1156    )?
1157    .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
1158}
1159
1160async fn run_prompt(session: &BrowserSession, prompt: &str) -> BrowserResult<()> {
1161    let trimmed = prompt.trim();
1162    let lower = trimmed.to_lowercase();
1163
1164    for prefix in ["navigate to ", "go to ", "open "] {
1165        if lower.starts_with(prefix) {
1166            let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
1167            print_json(&page)?;
1168            return Ok(());
1169        }
1170    }
1171    if let Some(rest) = lower.strip_prefix("click ") {
1172        let target = &trimmed[trimmed.len() - rest.len()..];
1173        print_json(&session.click(target.trim_matches('"')).await?)?;
1174        return Ok(());
1175    }
1176    if let Some(rest) = lower.strip_prefix("double click ") {
1177        let target = &trimmed[trimmed.len() - rest.len()..];
1178        print_json(&session.double_click(target.trim_matches('"')).await?)?;
1179        return Ok(());
1180    }
1181    if let Some(rest) = lower.strip_prefix("type ") {
1182        let text = &trimmed[trimmed.len() - rest.len()..];
1183        print_json(&session.type_text(text.trim_matches('"'), None).await?)?;
1184        return Ok(());
1185    }
1186    if lower.starts_with("screenshot") {
1187        let output = trimmed
1188            .split_once(char::is_whitespace)
1189            .map(|(_, value)| value.trim())
1190            .filter(|value| !value.is_empty())
1191            .unwrap_or("screenshot.png");
1192        let output = session
1193            .policy()
1194            .require_output_path(std::path::Path::new(output))?;
1195        std::fs::write(&output, session.screenshot_png().await?)?;
1196        println!("wrote {}", output.display());
1197        return Ok(());
1198    }
1199    if matches!(
1200        lower.as_str(),
1201        "text" | "get text" | "page text" | "get content"
1202    ) {
1203        println!("{}", session.text().await?);
1204        return Ok(());
1205    }
1206    if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
1207        print_json(&session.deep_dom().await?)?;
1208        return Ok(());
1209    }
1210    if matches!(lower.as_str(), "observe" | "context") {
1211        print_json(&session.observe().await?)?;
1212        return Ok(());
1213    }
1214
1215    print_json(&session.evaluate(trimmed).await?)?;
1216    Ok(())
1217}
1218
1219fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
1220    println!("{}", compact_json(value)?);
1221    Ok(())
1222}
1223
1224fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
1225    let mut value = serde_json::to_value(value)?;
1226    let payload = serde_json::to_vec(&value)?;
1227    let payload_bytes = payload.len();
1228    if let Some(object) = value.as_object_mut() {
1229        object.insert(
1230            "contextCost".to_string(),
1231            serde_json::json!({
1232                "payloadBytes": payload_bytes,
1233                "estimatedTokens": payload_bytes.div_ceil(4)
1234            }),
1235        );
1236    }
1237    Ok(serde_json::to_string(&value)?)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243    use serde_json::json;
1244
1245    #[test]
1246    fn structured_cli_output_is_compact_json() {
1247        let output = compact_json(&json!({
1248            "page": {"title": "Glass", "url": "https://example.com"},
1249            "items": [1, 2]
1250        }))
1251        .unwrap();
1252
1253        let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
1254        assert!(!output.contains('\n'));
1255        assert_eq!(parsed["items"], json!([1, 2]));
1256        assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
1257        assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
1258    }
1259}