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