1use 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
34pub 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}