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