1use super::args::{
7 CertifyCommand, CheckpointCommand, Cli, Commands, DaemonCommand, KnowledgeCommand,
8 KnowledgeInvalidationState, McpClient, ProfileCommand, ResultCommand, SnapshotCommand,
9 WorkflowAuthoringCommand,
10};
11use crate::browser::policy::{BrowserPolicy, PolicyCapability};
12use crate::browser::profile::ProfileManager;
13use crate::browser::session::{
14 ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Cookie,
15 KnowledgeConfidence, KnowledgeStore, Locator, PdfOptions, ReconciliationOptions,
16 SemanticIntentExecutionRequest, SemanticIntentRequest, SemanticObservationLevel,
17 SessionOptions, SessionSnapshotStore, StructuredExtractionRequest, VerificationPredicate,
18 VisualCaptureOptions, WaitCondition, WorkflowAuthoringFormat, WorkflowCheckpoint,
19 WorkflowDefinition, WorkflowDiagnosticSeverity, WorkflowRecordingSession, compile_workflow,
20 default_knowledge_store_path, diff_workflows, format_workflow_yaml, preview_workflow,
21 record_semantic_events,
22};
23use crate::capabilities::GlassCapabilityManifest;
24use crate::reliability::{
25 ReliabilityFixtureManifest, ReliabilityReplayBundle, ReliabilityScenario,
26 ReliabilityScenarioObservation, build_reliability_scorecard,
27};
28use crate::reliability_runner::{ReliabilityRunOptions, run_reliability_scenario};
29use crate::results::{ResponseMode, ResultStore, project_and_store};
30use base64::Engine;
31use serde::Serialize;
32use serde_json::Value;
33use std::collections::BTreeMap;
34use std::io::Read;
35use std::path::Path;
36use std::process::Stdio;
37use std::time::Duration;
38use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
39
40pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
43 let policy = policy_from_cli(&cli)?;
44 if cli.experimental_extensions {
45 eprintln!(concat!(
46 "warning: experimental extensions are enabled; extension code is untrusted, ",
47 "sandbox support is required, and behavior may break"
48 ));
49 }
50 if cli.mcp {
51 return crate::mcp::server::run_mcp_server(&cli).await;
52 }
53
54 match &cli.command {
55 Some(Commands::InstallChromium { update }) => {
56 let path = crate::browser::chrome::download_chromium(*update).await?;
57 println!("Chrome for Testing installed at {}", path.display());
58 return Ok(());
59 }
60 Some(Commands::Capabilities) => {
61 print_json(
62 &GlassCapabilityManifest::for_policy_with_experimental_extensions(
63 &policy,
64 cli.experimental_extensions,
65 ),
66 )?;
67 return Ok(());
68 }
69 Some(Commands::Daemon { action }) => {
70 dispatch_daemon(action).await?;
71 return Ok(());
72 }
73 Some(Commands::Doctor { json }) => {
74 dispatch_doctor(&cli, &policy, *json).await?;
75 return Ok(());
76 }
77 Some(Commands::McpConfig { client, print }) => {
78 dispatch_mcp_config(*client, *print)?;
79 return Ok(());
80 }
81 Some(Commands::Certify { action }) if !matches!(action, CertifyCommand::Run { .. }) => {
82 dispatch_certify(action)?;
83 return Ok(());
84 }
85 Some(Commands::Profiles { action }) => {
86 policy.require(PolicyCapability::PersistentProfile)?;
87 dispatch_profiles(action.as_ref())?;
88 return Ok(());
89 }
90 Some(Commands::DeleteProfile { name }) => {
91 policy.require(PolicyCapability::PersistentProfile)?;
92 ProfileManager::new().delete_profile(name)?;
93 println!("deleted profile {name}");
94 return Ok(());
95 }
96 Some(Commands::Knowledge { action }) => {
97 policy.require(PolicyCapability::PersistentProfile)?;
98 dispatch_knowledge(action, cli.knowledge_store.as_deref(), &cli.profile)?;
99 return Ok(());
100 }
101 Some(Commands::Snapshot { action }) if !matches!(action, SnapshotCommand::Create) => {
102 dispatch_snapshot(action, &cli.profile)?;
103 return Ok(());
104 }
105 Some(Commands::Result { action }) => {
106 dispatch_result(action)?;
107 return Ok(());
108 }
109 Some(Commands::Workflow {
110 action: Some(action),
111 input: None,
112 }) => {
113 dispatch_workflow_authoring(action)?;
114 return Ok(());
115 }
116 Some(Commands::Tui) | None if cli.prompt.is_none() => {
117 return crate::tui::app::run_tui(&cli).await;
118 }
119 _ => {}
120 }
121
122 let options = SessionOptions {
123 port: cli.port,
124 chrome_path: cli.chrome_path.clone(),
125 profile: cli.profile.clone(),
126 incognito: cli.incognito,
127 attach: cli.attach,
128 target_id: cli.target_id.clone(),
129 frame_id: cli.frame_id.clone(),
130 headed: cli.headed,
131 interaction_mode: cli.interaction,
132 audit: cli.audit,
133 policy: None,
134 };
135 let session = BrowserSession::start_with_policy(&options, policy).await?;
136 let result = if let Some(prompt) = &cli.prompt {
137 run_prompt(&session, prompt, cli.response_mode).await
138 } else if let Some(command) = &cli.command {
139 run_command(&session, command, cli.response_mode).await
140 } else {
141 Ok(())
142 };
143 if let Err(error) = &result
144 && cli.trace_on_error
145 {
146 let trace = session
147 .failure_trace_for(
148 cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
149 error.to_string(),
150 )
151 .await;
152 eprintln!("{}", serde_json::to_string(&trace)?);
153 }
154 let close_result = session.close().await;
155 result?;
156 close_result
157}
158
159async fn dispatch_daemon(action: &DaemonCommand) -> BrowserResult<()> {
160 match action {
161 DaemonCommand::Start { socket, status } => {
162 print_json(&crate::daemon::start(socket.as_deref(), status.as_deref()).await?)?;
163 }
164 DaemonCommand::Status { socket, status } => {
165 print_json(&crate::daemon::status(
166 socket.as_deref(),
167 status.as_deref(),
168 )?)?;
169 }
170 DaemonCommand::Stop { socket, status } => {
171 crate::daemon::stop(socket.as_deref(), status.as_deref())?;
172 print_json(&serde_json::json!({"status": "stopped"}))?;
173 }
174 DaemonCommand::Doctor { socket, status } => {
175 print_json(&crate::daemon::doctor(
176 socket.as_deref(),
177 status.as_deref(),
178 )?)?;
179 }
180 DaemonCommand::Logs { status } => {
181 print_json(&crate::daemon::logs(status.as_deref())?)?;
182 }
183 DaemonCommand::AcknowledgeRecovery {
184 status,
185 request_ids,
186 } => {
187 print_json(&crate::daemon::acknowledge_recovery(
188 status.as_deref(),
189 request_ids,
190 )?)?;
191 }
192 DaemonCommand::Serve { socket, status } => {
193 crate::daemon::serve(socket, status).await?;
194 }
195 }
196 Ok(())
197}
198
199async fn dispatch_doctor(cli: &Cli, policy: &BrowserPolicy, json: bool) -> BrowserResult<()> {
200 let executable = std::env::current_exe()
201 .ok()
202 .map(|path| path.display().to_string());
203 let chrome_path =
204 crate::browser::chrome::resolve_chrome_path(None).map(|path| path.display().to_string());
205 let profile_path = ProfileManager::new().profile_dir(&cli.profile);
206 let runtime_path = default_result_store_path();
207 let profile_writable = path_is_writable(profile_path.parent());
208 let runtime_writable = path_is_writable(runtime_path.parent());
209 let browser_version = chrome_path.as_deref().and_then(|path| {
210 std::process::Command::new(path)
211 .arg("--version")
212 .output()
213 .ok()
214 .filter(|output| output.status.success())
215 .and_then(|output| String::from_utf8(output.stdout).ok())
216 .map(|version| version.trim().to_string())
217 });
218 let mcp_initialized = probe_mcp_initialization(executable.as_deref()).await;
219 let (daemon_socket, daemon_status) = crate::daemon::default_paths();
220 let daemon = crate::daemon::doctor(Some(&daemon_socket), Some(&daemon_status))?;
221 let profiles = ProfileManager::new().list_profiles().unwrap_or_default();
222 let knowledge_path = cli
223 .knowledge_store
224 .clone()
225 .unwrap_or_else(|| default_knowledge_store_path(&cli.profile));
226 let knowledge_exists = knowledge_path.is_file();
227 let manifest = GlassCapabilityManifest::for_policy_with_experimental_extensions(
228 policy,
229 cli.experimental_extensions,
230 );
231 let platform_supported = manifest.constraints.platform != "unsupported";
232 let config_root = dirs::config_dir();
233 let config_writable = path_is_writable(config_root.as_deref());
234 let browser_available = chrome_path.is_some();
235 let status = if platform_supported
236 && browser_available
237 && config_writable
238 && profile_writable
239 && runtime_writable
240 && mcp_initialized
241 {
242 "ready"
243 } else {
244 "degraded"
245 };
246 let findings = serde_json::json!([
247 {
248 "severity": "info",
249 "code": "runtime.executable",
250 "message": executable.as_deref().unwrap_or("unable to resolve executable path"),
251 "remediation": null
252 },
253 {
254 "severity": if browser_available { "info" } else { "warning" },
255 "code": if browser_available { "browser.found" } else { "browser.missing" },
256 "message": if browser_available { "Chrome or Chromium was discovered" } else { "Chrome or Chromium was not discovered" },
257 "remediation": if browser_available { Value::Null } else { Value::String("install Chromium or pass --chrome-path".into()) }
258 },
259 {
260 "severity": if browser_version.is_some() { "info" } else { "warning" },
261 "code": if browser_version.is_some() { "browser.version" } else { "browser.versionUnavailable" },
262 "message": browser_version.as_deref().unwrap_or("browser version could not be queried"),
263 "remediation": if browser_version.is_some() { Value::Null } else { Value::String("verify the browser executable is runnable".into()) }
264 },
265 {
266 "severity": if profile_writable { "info" } else { "warning" },
267 "code": if profile_writable { "runtime.profileWritable" } else { "runtime.profileNotWritable" },
268 "message": profile_path.display().to_string(),
269 "remediation": if profile_writable { Value::Null } else { Value::String("choose a writable profile parent directory".into()) }
270 },
271 {
272 "severity": if runtime_writable { "info" } else { "warning" },
273 "code": if runtime_writable { "runtime.artifactStoreWritable" } else { "runtime.artifactStoreNotWritable" },
274 "message": runtime_path.display().to_string(),
275 "remediation": if runtime_writable { Value::Null } else { Value::String("choose a writable cache directory".into()) }
276 },
277 {
278 "severity": if config_writable { "info" } else { "warning" },
279 "code": if config_writable { "runtime.configWritable" } else { "runtime.configNotWritable" },
280 "message": if config_writable { "Glass configuration directory is writable" } else { "Glass configuration directory is unavailable or not writable" },
281 "remediation": if config_writable { Value::Null } else { Value::String("choose a writable HOME/XDG config directory".into()) }
282 },
283 {
284 "severity": "info",
285 "code": "mcp.stdoutClean",
286 "message": "MCP uses stdout for protocol frames and diagnostics use stderr",
287 "remediation": null
288 },
289 {
290 "severity": if mcp_initialized { "info" } else { "warning" },
291 "code": if mcp_initialized { "mcp.initialized" } else { "mcp.initializationFailed" },
292 "message": if mcp_initialized { "MCP initialize completed with clean stdout" } else { "MCP initialize did not return a valid response" },
293 "remediation": if mcp_initialized { Value::Null } else { Value::String("run the installed executable with --mcp and inspect stderr".into()) }
294 },
295 {
296 "severity": "info",
297 "code": "capability.manifest",
298 "message": "capability statuses are included in the report",
299 "remediation": null
300 }
301 ]);
302 let report = serde_json::json!({
303 "status": status,
304 "version": env!("CARGO_PKG_VERSION"),
305 "platform": manifest.constraints.platform,
306 "executable": executable,
307 "browser": {
308 "family": manifest.constraints.browser_family,
309 "version": browser_version,
310 "chromeAvailable": browser_available,
311 "chromePath": chrome_path,
312 "cdpPort": cli.port,
313 "cdpReachable": crate::browser::chrome::check_chrome_health(cli.port).await,
314 },
315 "profile": {"path": profile_path, "writable": profile_writable},
316 "runtime": {"artifactStore": runtime_path, "writable": runtime_writable},
317 "daemon": daemon,
318 "profiles": {"count": profiles.len(), "names": profiles},
319 "policy": {
320 "preset": manifest.constraints.policy,
321 "capabilities": manifest.capabilities,
322 "capabilityStatuses": manifest.capability_statuses,
323 },
324 "knowledgeStore": {"path": knowledge_path, "exists": knowledge_exists},
325 "extensions": {
326 "enabled": manifest.capabilities.get("extensions").copied().unwrap_or(false),
327 "status": manifest.capability_statuses.get("extensions"),
328 "loader": "disabled",
329 },
330 "findings": findings,
331 });
332 if json {
333 print_json(&report)?;
334 } else {
335 println!("Glass doctor: {status}");
336 for finding in report["findings"].as_array().into_iter().flatten() {
337 println!(
338 "{} {}: {}",
339 finding["severity"].as_str().unwrap_or("info"),
340 finding["code"].as_str().unwrap_or("runtime.unknown"),
341 finding["message"].as_str().unwrap_or("unknown")
342 );
343 }
344 }
345 Ok(())
346}
347
348fn path_is_writable(path: Option<&Path>) -> bool {
349 let Some(path) = path else {
350 return false;
351 };
352 let candidate = if path.exists() {
353 path.to_path_buf()
354 } else {
355 path.ancestors()
356 .find(|ancestor| ancestor.exists())
357 .unwrap_or(path)
358 .to_path_buf()
359 };
360 candidate.is_dir()
361 && std::fs::metadata(&candidate)
362 .map(|metadata| !metadata.permissions().readonly())
363 .unwrap_or(false)
364}
365
366async fn probe_mcp_initialization(executable: Option<&str>) -> bool {
367 let Some(executable) = executable else {
368 return false;
369 };
370 let mut child = match tokio::process::Command::new(executable)
371 .arg("--mcp")
372 .stdin(Stdio::piped())
373 .stdout(Stdio::piped())
374 .stderr(Stdio::null())
375 .spawn()
376 {
377 Ok(child) => child,
378 Err(_) => return false,
379 };
380 let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"glass-doctor","version":"0.2.2"}}}"#;
381 let Some(mut stdin) = child.stdin.take() else {
382 let _ = child.kill().await;
383 return false;
384 };
385 if stdin.write_all(request).await.is_err() || stdin.write_all(b"\n").await.is_err() {
386 let _ = child.kill().await;
387 return false;
388 }
389 drop(stdin);
390 let Some(stdout) = child.stdout.take() else {
391 let _ = child.kill().await;
392 return false;
393 };
394 let mut reader = BufReader::new(stdout);
395 let mut line = String::new();
396 let success = tokio::time::timeout(Duration::from_secs(2), reader.read_line(&mut line))
397 .await
398 .ok()
399 .and_then(Result::ok)
400 .is_some_and(|bytes| bytes > 0 && line.contains("\"result\""));
401 let _ = child.kill().await;
402 success
403}
404
405fn dispatch_mcp_config(client: McpClient, _print: bool) -> BrowserResult<()> {
406 let executable = std::env::current_exe()?;
407 let command = executable.display().to_string();
408 let value = match client {
409 McpClient::Generic => serde_json::json!({
410 "command": command,
411 "args": ["--mcp"]
412 }),
413 McpClient::ClaudeCode => serde_json::json!({
414 "mcpServers": {"glass": {"command": command, "args": ["--mcp"]}}
415 }),
416 McpClient::Codex => serde_json::json!({
417 "mcp_servers": {"glass": {"command": command, "args": ["--mcp"]}}
418 }),
419 };
420 print_json(&value)?;
421 Ok(())
422}
423
424fn dispatch_result(action: &ResultCommand) -> BrowserResult<()> {
425 let root = default_result_store_path();
426 let store = ResultStore::new(root);
427 match action {
428 ResultCommand::Show { result_id, section } => {
429 let artifact = store.load(result_id)?;
430 let value = if let Some(section) = section {
431 artifact
432 .details
433 .get(section)
434 .cloned()
435 .ok_or_else(|| format!("result section not found: {section}"))?
436 } else {
437 serde_json::to_value(artifact)?
438 };
439 print_json(&value)?;
440 }
441 ResultCommand::Purge { older_than } => {
442 let age = parse_result_age(older_than)?;
443 print_json(&serde_json::json!({
444 "removed": store.purge_older_than(age)?,
445 "olderThan": older_than,
446 }))?;
447 }
448 }
449 Ok(())
450}
451
452fn default_result_store_path() -> std::path::PathBuf {
453 dirs::cache_dir()
454 .unwrap_or_else(std::env::temp_dir)
455 .join("glass")
456 .join("results")
457}
458
459fn parse_result_age(value: &str) -> BrowserResult<Duration> {
460 let (number, suffix) = value.split_at(
461 value
462 .find(|character: char| !character.is_ascii_digit())
463 .unwrap_or(value.len()),
464 );
465 let amount: u64 = number
466 .parse()
467 .map_err(|_| "older-than must be a positive duration such as 7d or 24h")?;
468 if amount == 0 {
469 return Err("older-than must be positive".into());
470 }
471 let seconds = match suffix {
472 "s" => amount,
473 "m" => amount.saturating_mul(60),
474 "h" => amount.saturating_mul(60 * 60),
475 "d" => amount.saturating_mul(24 * 60 * 60),
476 _ => return Err("older-than must end in s, m, h, or d".into()),
477 };
478 Ok(Duration::from_secs(seconds))
479}
480
481fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
482 if let Some(prompt) = prompt {
483 let lower = prompt.trim().to_ascii_lowercase();
484 return if lower.starts_with("double click ") {
485 ActionKind::DoubleClick
486 } else if lower.starts_with("click ") {
487 ActionKind::Click
488 } else if lower.starts_with("type ") {
489 ActionKind::Type
490 } else {
491 ActionKind::Click
492 };
493 }
494 match command {
495 Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
496 Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
497 Some(Commands::Click { .. })
498 | Some(Commands::Preflight { .. })
499 | Some(Commands::ClickAt { .. }) => ActionKind::Click,
500 Some(Commands::Hover { .. }) => ActionKind::Hover,
501 Some(Commands::Drag { .. }) => ActionKind::Drag,
502 Some(Commands::Type { .. }) => ActionKind::Type,
503 Some(Commands::Key { .. }) => ActionKind::KeyPress,
504 Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
505 Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
506 Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
507 Some(Commands::Clear { .. }) => ActionKind::Clear,
508 Some(Commands::Check { .. }) => ActionKind::Check,
509 Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
510 Some(Commands::Select { .. }) => ActionKind::Select,
511 Some(Commands::Upload { .. }) => ActionKind::Upload,
512 Some(Commands::Scroll { .. }) => ActionKind::Scroll,
513 _ => ActionKind::Click,
514 }
515}
516
517fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
518 let manager = ProfileManager::new();
519 match action {
520 None | Some(ProfileCommand::List) => {
521 let profiles = manager.list_profiles()?;
522 if profiles.is_empty() {
523 println!("no saved profiles");
524 } else {
525 for profile in profiles {
526 println!("{profile}");
527 }
528 }
529 }
530 Some(ProfileCommand::Create { name }) => {
531 manager.create_profile(name)?;
532 println!("created profile {name}");
533 }
534 Some(ProfileCommand::Delete { name }) => {
535 manager.delete_profile(name)?;
536 println!("deleted profile {name}");
537 }
538 }
539 Ok(())
540}
541
542fn dispatch_certify(action: &CertifyCommand) -> BrowserResult<()> {
543 match action {
544 CertifyCommand::Run { .. } => {
545 unreachable!("browser-backed reliability runs are handled after startup")
546 }
547 CertifyCommand::Plan { scenario, fixture } => {
548 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
549 let fixture =
550 ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture)?)?;
551 let plan = scenario.execution_plan(&fixture)?;
552 print_json(&serde_json::json!({
553 "status": "valid",
554 "plan": plan,
555 }))?;
556 }
557 CertifyCommand::Release {
558 version,
559 scenarios,
560 observations,
561 replays,
562 } => {
563 let scenario_value = read_json_input(Some(scenarios))?;
564 let scenarios: Vec<ReliabilityScenario> = if scenario_value.is_array() {
565 serde_json::from_value(scenario_value)?
566 } else {
567 vec![serde_json::from_value(scenario_value)?]
568 };
569 let observations: Vec<ReliabilityScenarioObservation> =
570 serde_json::from_value(read_json_input(Some(observations))?)?;
571 let replays_validated = if let Some(replays) = replays {
572 let replay_value = read_json_input(Some(replays))?;
573 let replay_values: Vec<Value> = if replay_value.is_array() {
574 serde_json::from_value(replay_value)?
575 } else {
576 vec![replay_value]
577 };
578 let mut replay_by_id = BTreeMap::new();
579 for replay_value in replay_values {
580 let scenario_id = replay_value
581 .get("scenarioId")
582 .and_then(Value::as_str)
583 .ok_or("replay bundle is missing scenarioId")?
584 .to_string();
585 let scenario = scenarios
586 .iter()
587 .find(|scenario| scenario.id == scenario_id)
588 .ok_or_else(|| {
589 format!("replay references unknown scenario {scenario_id}")
590 })?;
591 let bundle = ReliabilityReplayBundle::from_value(replay_value, scenario)?;
592 if replay_by_id.insert(scenario_id.clone(), bundle).is_some() {
593 return Err(format!("duplicate replay for scenario {scenario_id}").into());
594 }
595 }
596 if replay_by_id.len() != scenarios.len() {
597 return Err("replay evidence must cover every scenario".into());
598 }
599 let observations_by_id: BTreeMap<_, _> = observations
600 .iter()
601 .map(|observation| (observation.scenario_id.as_str(), observation))
602 .collect();
603 for (scenario_id, replay) in &replay_by_id {
604 let observation =
605 observations_by_id
606 .get(scenario_id.as_str())
607 .ok_or_else(|| {
608 format!("replay has no matching observation for {scenario_id}")
609 })?;
610 if serde_json::to_value(&replay.observation)?
611 != serde_json::to_value(observation)?
612 {
613 return Err(format!("replay observation differs for {scenario_id}").into());
614 }
615 }
616 true
617 } else {
618 false
619 };
620 let scorecard = build_reliability_scorecard(&scenarios, &observations)?;
621 let certified = scorecard.certified;
622 print_json(&serde_json::json!({
623 "status": if certified { "certified" } else { "blocked" },
624 "version": version,
625 "tool": {"name": "glass", "version": env!("CARGO_PKG_VERSION")},
626 "replaysValidated": replays_validated,
627 "gate": &scorecard.gate,
628 "scorecard": &scorecard,
629 }))?;
630 if !certified {
631 return Err("reliability certification blocked".into());
632 }
633 }
634 CertifyCommand::Replay { scenario, input } => {
635 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
636 let bundle =
637 ReliabilityReplayBundle::from_value(read_json_input(Some(input))?, &scenario)?;
638 print_json(&serde_json::json!({
639 "status": "valid",
640 "scenarioId": &bundle.scenario_id,
641 "replayHash": bundle.content_hash(&scenario)?,
642 }))?;
643 }
644 CertifyCommand::ReplayDiff {
645 scenario,
646 before,
647 after,
648 } => {
649 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
650 let before =
651 ReliabilityReplayBundle::from_value(read_json_input(Some(before))?, &scenario)?;
652 let after =
653 ReliabilityReplayBundle::from_value(read_json_input(Some(after))?, &scenario)?;
654 let comparison = before.compare(&after, &scenario)?;
655 print_json(&serde_json::json!({
656 "status": if comparison.equivalent { "equivalent" } else { "changed" },
657 "comparison": comparison,
658 }))?;
659 }
660 }
661 Ok(())
662}
663
664async fn dispatch_certify_run(
665 session: &BrowserSession,
666 scenario_path: &std::path::Path,
667 fixture_path: &std::path::Path,
668 url: &str,
669 workflow_root: &std::path::Path,
670 inputs_path: Option<&std::path::Path>,
671 output: Option<&std::path::Path>,
672) -> BrowserResult<()> {
673 let scenario = ReliabilityScenario::from_json(&std::fs::read_to_string(scenario_path)?)?;
674 let fixture = ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture_path)?)?;
675 let inputs: BTreeMap<String, Value> = match inputs_path {
676 Some(path) => serde_json::from_str(&std::fs::read_to_string(path)?)
677 .map_err(|error| format!("invalid workflow inputs: {error}"))?,
678 None => BTreeMap::new(),
679 };
680 session.navigate(url).await?;
681 let evidence = run_reliability_scenario(
682 session,
683 &scenario,
684 &fixture,
685 &ReliabilityRunOptions {
686 workflow_root: workflow_root.to_path_buf(),
687 inputs,
688 },
689 )
690 .await?;
691 let value = serde_json::json!({
692 "observation": evidence.observation,
693 "replay": evidence.replay,
694 });
695 if let Some(output) = output {
696 tokio::fs::write(output, serde_json::to_vec_pretty(&value)?).await?;
697 }
698 print_json(&value)?;
699 Ok(())
700}
701
702fn dispatch_knowledge(
703 action: &KnowledgeCommand,
704 explicit_path: Option<&std::path::Path>,
705 profile: &str,
706) -> BrowserResult<()> {
707 ProfileManager::validate_name(profile)?;
708 let path = explicit_path
709 .map(std::path::Path::to_path_buf)
710 .unwrap_or_else(|| default_knowledge_store_path(profile));
711 let mut store = KnowledgeStore::open(path)?;
712 match action {
713 KnowledgeCommand::List => print_json(store.snapshot())?,
714 KnowledgeCommand::Show { record_id } => {
715 let record = store
716 .get(record_id)
717 .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
718 print_json(record)?;
719 }
720 KnowledgeCommand::Explain { record_id } => {
721 let record = store
722 .get(record_id)
723 .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
724 print_json(&serde_json::json!({
725 "recordId": &record.record_id,
726 "kind": record.kind,
727 "confidence": record.confidence,
728 "scope": &record.scope,
729 "source": &record.source,
730 "invalidation": &record.invalidation,
731 "history": &record.history,
732 "contentHash": record.content_hash()?,
733 "assessment": "requires a fresh observation; stored knowledge is never an authorization",
734 }))?;
735 }
736 KnowledgeCommand::Stats => print_json(&store.stats()?)?,
737 KnowledgeCommand::Export { output } => {
738 let canonical = store.snapshot().to_canonical_json()?;
739 if let Some(output) = output {
740 std::fs::write(output, canonical)?;
741 println!("exported knowledge to {}", output.display());
742 } else {
743 println!("{canonical}");
744 }
745 }
746 KnowledgeCommand::Import { input } => {
747 let snapshot = serde_json::from_value(read_json_input(Some(input))?)
748 .map_err(|error| format!("invalid knowledge snapshot: {error}"))?;
749 store.replace_snapshot(snapshot)?;
750 print_json(&store.stats()?)?;
751 }
752 KnowledgeCommand::Invalidate {
753 record_id,
754 state,
755 reason,
756 observed_at,
757 } => {
758 let next = match state {
759 KnowledgeInvalidationState::Stale => KnowledgeConfidence::Stale,
760 KnowledgeInvalidationState::Contradicted => KnowledgeConfidence::Contradicted,
761 KnowledgeInvalidationState::Quarantined => KnowledgeConfidence::Quarantined,
762 };
763 let change = store.transition(
764 record_id,
765 next,
766 reason
767 .clone()
768 .unwrap_or_else(|| "caller invalidated record".into()),
769 observed_at
770 .clone()
771 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
772 false,
773 )?;
774 print_json(&change)?;
775 }
776 KnowledgeCommand::Purge { origin } => print_json(&store.purge_origin(origin)?)?,
777 }
778 Ok(())
779}
780
781fn dispatch_snapshot(action: &SnapshotCommand, profile: &str) -> BrowserResult<()> {
782 ProfileManager::validate_name(profile)?;
783 let store = SessionSnapshotStore::new(crate::browser::session::default_session_snapshot_path(
784 profile,
785 ));
786 match action {
787 SnapshotCommand::Create => unreachable!("snapshot creation requires a browser session"),
788 SnapshotCommand::List => print_json(&store.list()?)?,
789 SnapshotCommand::Inspect { snapshot_id } => print_json(&store.load(snapshot_id)?)?,
790 SnapshotCommand::Diff { from, to } => print_json(&store.diff(from, to)?)?,
791 SnapshotCommand::Purge => print_json(&serde_json::json!({"removed": store.purge()?}))?,
792 }
793 Ok(())
794}
795
796fn dispatch_workflow_authoring(action: &WorkflowAuthoringCommand) -> BrowserResult<()> {
797 match action {
798 WorkflowAuthoringCommand::Templates { name, output } => {
799 let names = [
800 "account-search",
801 "checkout-submit",
802 "profile-update",
803 "report-download",
804 "support-ticket",
805 ];
806 let Some(name) = name else {
807 print_json(&names)?;
808 return Ok(());
809 };
810 let source = workflow_template(name)
811 .ok_or_else(|| format!("unknown workflow template: {name}"))?;
812 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
813 if document
814 .diagnostics
815 .iter()
816 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
817 {
818 return Err(format!("workflow template {name} failed compilation").into());
819 }
820 if let Some(output) = output {
821 std::fs::write(output, source)?;
822 } else {
823 print!("{source}");
824 }
825 }
826 WorkflowAuthoringCommand::Init { name, output } => {
827 let template = match name.as_str() {
828 "search" => "account-search",
829 "form-submit" => "checkout-submit",
830 "paginated-extraction" => "report-download",
831 "authenticated-session" => "profile-update",
832 "dialog-and-download" => "support-ticket",
833 _ => return Err(format!("unknown workflow starter: {name}").into()),
834 };
835 let source = workflow_template(template)
836 .ok_or_else(|| format!("missing workflow starter: {template}"))?;
837 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
838 if document
839 .diagnostics
840 .iter()
841 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
842 {
843 return Err(format!("workflow starter {name} failed compilation").into());
844 }
845 if let Some(output) = output {
846 std::fs::write(output, source)?;
847 } else {
848 print!("{source}");
849 }
850 }
851 WorkflowAuthoringCommand::Compile { input, output } => {
852 let source = std::fs::read_to_string(input)?;
853 let document = compile_workflow(&source, authoring_format(input))?;
854 if let Some(output) = output {
855 std::fs::write(output, &document.canonical_json)?;
856 println!("compiled workflow to {}", output.display());
857 } else {
858 println!("{}", document.canonical_json);
859 }
860 if document
861 .diagnostics
862 .iter()
863 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
864 {
865 return Err("workflow compilation produced error diagnostics".into());
866 }
867 }
868 WorkflowAuthoringCommand::Format { input, output } => {
869 let source = std::fs::read_to_string(input)?;
870 let document = compile_workflow(&source, authoring_format(input))?;
871 let formatted = format_workflow_yaml(&document.definition)?;
872 if let Some(output) = output {
873 std::fs::write(output, formatted)?;
874 println!("formatted workflow to {}", output.display());
875 } else {
876 print!("{formatted}");
877 }
878 }
879 WorkflowAuthoringCommand::Preview { input } => {
880 let source = std::fs::read_to_string(input)?;
881 let document = compile_workflow(&source, authoring_format(input))?;
882 let preview = preview_workflow(&document.definition)?;
883 print_json(&serde_json::json!({
884 "preview": preview,
885 "diagnostics": document.diagnostics,
886 }))?;
887 }
888 WorkflowAuthoringCommand::Diff { before, after } => {
889 let before_source = std::fs::read_to_string(before)?;
890 let after_source = std::fs::read_to_string(after)?;
891 let before_document = compile_workflow(&before_source, authoring_format(before))?;
892 let after_document = compile_workflow(&after_source, authoring_format(after))?;
893 let diff = diff_workflows(&before_document.definition, &after_document.definition)?;
894 print_json(&serde_json::json!({
895 "diff": diff,
896 "beforeDiagnostics": before_document.diagnostics,
897 "afterDiagnostics": after_document.diagnostics,
898 }))?;
899 }
900 WorkflowAuthoringCommand::Record { input, output } => {
901 let value = read_json_input(input.as_ref())?;
902 let session: WorkflowRecordingSession = serde_json::from_value(value)?;
903 let draft = record_semantic_events(session)?;
904 let serialized = serde_json::to_string_pretty(&draft)?;
905 if let Some(output) = output {
906 std::fs::write(output, serialized)?;
907 println!("recorded workflow draft to {}", output.display());
908 } else {
909 println!("{serialized}");
910 }
911 }
912 WorkflowAuthoringCommand::Validate { input } => {
913 let source = std::fs::read_to_string(input)?;
914 let document = compile_workflow(&source, authoring_format(input))?;
915 print_json(&document)?;
916 }
917 WorkflowAuthoringCommand::Lint {
918 input,
919 warnings_as_errors,
920 } => {
921 let source = std::fs::read_to_string(input)?;
922 let document = compile_workflow(&source, authoring_format(input))?;
923 let failed = document.diagnostics.iter().any(|diagnostic| {
924 diagnostic.severity == WorkflowDiagnosticSeverity::Error
925 || (*warnings_as_errors
926 && diagnostic.severity == WorkflowDiagnosticSeverity::Warning)
927 });
928 print_json(&document.diagnostics)?;
929 if failed {
930 return Err("workflow lint failed".into());
931 }
932 }
933 }
934 Ok(())
935}
936
937fn authoring_format(path: &std::path::Path) -> WorkflowAuthoringFormat {
938 if path
939 .extension()
940 .and_then(|extension| extension.to_str())
941 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
942 {
943 WorkflowAuthoringFormat::Json
944 } else {
945 WorkflowAuthoringFormat::Yaml
946 }
947}
948
949fn workflow_template(name: &str) -> Option<&'static str> {
950 Some(match name {
951 "account-search" => include_str!("templates/account-search.yaml"),
952 "checkout-submit" => include_str!("templates/checkout-submit.yaml"),
953 "profile-update" => include_str!("templates/profile-update.yaml"),
954 "report-download" => include_str!("templates/report-download.yaml"),
955 "support-ticket" => include_str!("templates/support-ticket.yaml"),
956 _ => return None,
957 })
958}
959async fn run_command(
960 session: &BrowserSession,
961 command: &Commands,
962 response_mode: ResponseMode,
963) -> BrowserResult<()> {
964 match command {
965 Commands::Certify {
966 action:
967 CertifyCommand::Run {
968 scenario,
969 fixture,
970 url,
971 workflow_root,
972 inputs,
973 output,
974 },
975 } => {
976 dispatch_certify_run(
977 session,
978 scenario,
979 fixture,
980 url,
981 workflow_root,
982 inputs.as_deref(),
983 output.as_deref(),
984 )
985 .await?;
986 }
987 Commands::Snapshot {
988 action: SnapshotCommand::Create,
989 } => {
990 let observation = session
991 .semantic_observe(SemanticObservationLevel::Structured)
992 .await?;
993 let snapshot = crate::browser::session::SessionSnapshot::from_observation(
994 session.profile.clone(),
995 observation,
996 );
997 let store = SessionSnapshotStore::new(
998 crate::browser::session::default_session_snapshot_path(&session.profile),
999 );
1000 store.save(&snapshot)?;
1001 print_json(&snapshot)?;
1002 }
1003 Commands::Capabilities
1004 | Commands::Daemon { .. }
1005 | Commands::Doctor { .. }
1006 | Commands::McpConfig { .. }
1007 | Commands::Result { .. }
1008 | Commands::Certify { .. }
1009 | Commands::Knowledge { .. }
1010 | Commands::Snapshot { .. } => {
1011 unreachable!("offline commands are handled before browser startup")
1012 }
1013 Commands::Navigate {
1014 url,
1015 timeout_ms,
1016 expected_revision,
1017 } => {
1018 if let Some(expected_revision) = expected_revision {
1019 print_json(
1020 &session
1021 .navigate_with_revision(
1022 url,
1023 Duration::from_millis(*timeout_ms),
1024 *expected_revision,
1025 )
1026 .await?,
1027 )?;
1028 } else {
1029 let page = session
1030 .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
1031 .await?;
1032 print_json(&page)?;
1033 }
1034 }
1035 Commands::Click {
1036 target,
1037 expected_revision,
1038 } => {
1039 if let Some(expected_revision) = expected_revision {
1040 print_json(
1041 &session
1042 .click_with_revision(target, *expected_revision)
1043 .await?,
1044 )?;
1045 } else {
1046 print_json(&session.click(target).await?)?;
1047 }
1048 }
1049 Commands::Preflight { target, action } => {
1050 print_json(&session.preflight_with_action(target, *action).await)?;
1051 }
1052 Commands::ClickAt { x, y } => {
1053 print_json(&session.click_at(*x, *y).await?)?;
1054 }
1055 Commands::ClickExpectPopup {
1056 target,
1057 expected_revision,
1058 } => {
1059 print_json(
1060 &session
1061 .click_expect_popup_with_revision(target, *expected_revision)
1062 .await?,
1063 )?;
1064 }
1065 Commands::DoubleClick {
1066 target,
1067 expected_revision,
1068 } => {
1069 print_json(
1070 &session
1071 .double_click_with_revision(target, *expected_revision)
1072 .await?,
1073 )?;
1074 }
1075 Commands::Hover { target } => print_json(&session.hover(target).await?)?,
1076 Commands::Drag {
1077 source,
1078 destination,
1079 expected_revision,
1080 } => {
1081 print_json(
1082 &session
1083 .drag_with_revision(source, destination, *expected_revision)
1084 .await?,
1085 )?;
1086 }
1087 Commands::Type {
1088 text,
1089 target,
1090 expected_revision,
1091 } => {
1092 print_json(
1093 &session
1094 .type_text_with_expected_revision(text, target.as_deref(), *expected_revision)
1095 .await?,
1096 )?;
1097 }
1098 Commands::Key {
1099 key,
1100 expected_revision,
1101 } => print_json(
1102 &session
1103 .key_press_with_revision(key, *expected_revision)
1104 .await?,
1105 )?,
1106 Commands::KeyDown {
1107 key,
1108 expected_revision,
1109 } => print_json(
1110 &session
1111 .key_down_with_revision(key, *expected_revision)
1112 .await?,
1113 )?,
1114 Commands::KeyUp {
1115 key,
1116 expected_revision,
1117 } => print_json(
1118 &session
1119 .key_up_with_revision(key, *expected_revision)
1120 .await?,
1121 )?,
1122 Commands::Shortcut {
1123 shortcut,
1124 expected_revision,
1125 } => print_json(
1126 &session
1127 .shortcut_with_revision(shortcut, *expected_revision)
1128 .await?,
1129 )?,
1130 Commands::Clear {
1131 target,
1132 expected_revision,
1133 } => print_json(
1134 &session
1135 .clear_with_revision(target, *expected_revision)
1136 .await?,
1137 )?,
1138 Commands::Check {
1139 target,
1140 expected_revision,
1141 } => print_json(
1142 &session
1143 .check_with_revision(target, *expected_revision)
1144 .await?,
1145 )?,
1146 Commands::Uncheck {
1147 target,
1148 expected_revision,
1149 } => print_json(
1150 &session
1151 .uncheck_with_revision(target, *expected_revision)
1152 .await?,
1153 )?,
1154 Commands::Select {
1155 target,
1156 value,
1157 expected_revision,
1158 } => {
1159 print_json(
1160 &session
1161 .select_option_with_revision(target, value, *expected_revision)
1162 .await?,
1163 )?;
1164 }
1165 Commands::Upload {
1166 target,
1167 files,
1168 expected_revision,
1169 } => {
1170 print_json(
1171 &session
1172 .upload_files_with_revision(target, files, *expected_revision)
1173 .await?,
1174 )?;
1175 }
1176 Commands::Screenshot {
1177 output,
1178 format,
1179 quality,
1180 scale,
1181 full_page,
1182 clip,
1183 target,
1184 } => {
1185 let output = session
1186 .policy()
1187 .require_output_path(std::path::Path::new(output))?;
1188 let capture = session
1189 .capture_visual(&VisualCaptureOptions {
1190 format: *format,
1191 quality: *quality,
1192 scale: *scale,
1193 clip: *clip,
1194 full_page: *full_page,
1195 target: target.clone(),
1196 })
1197 .await?;
1198 let mut source = base64::read::DecoderReader::new(
1199 capture.data.as_bytes(),
1200 &base64::engine::general_purpose::STANDARD,
1201 );
1202 let mut file = std::fs::File::create(&output)?;
1203 std::io::copy(&mut source, &mut file)?;
1204 println!("wrote {}", output.display());
1205 print_json(&capture.metadata)?;
1206 }
1207 Commands::Text => println!("{}", session.text().await?),
1208 Commands::Dom => print_json(&session.deep_dom().await?)?,
1209 Commands::Observe {
1210 deep_dom,
1211 screenshot,
1212 form_values,
1213 semantic_level,
1214 region,
1215 } => {
1216 if let Some(level_name) = semantic_level {
1217 if *deep_dom || *screenshot || *form_values {
1218 return Err(
1219 "semantic observation cannot be combined with deep DOM, screenshot, or form values"
1220 .into(),
1221 );
1222 }
1223 let level = parse_semantic_level(level_name)?;
1224 if let Some(region_id) = region {
1225 let page = session.semantic_observe(level).await?;
1226 print_json(
1227 &session
1228 .semantic_expand_region(region_id, page.revision, level)
1229 .await?,
1230 )?;
1231 } else {
1232 print_json(&session.semantic_observe(level).await?)?;
1233 }
1234 return Ok(());
1235 }
1236 let context = match (*deep_dom, *screenshot, *form_values) {
1237 (false, false, false) => session.observe().await?,
1238 (true, false, false) => session.observe_with_dom().await?,
1239 (false, true, false) => session.observe_with_screenshot().await?,
1240 (true, true, false) => session.observe_with_dom_and_screenshot().await?,
1241 (false, false, true) => session.observe_with_form_values().await?,
1242 _ => return Err("form values can only be combined with compact observe".into()),
1243 };
1244 print_json_mode(&context, response_mode)?;
1245 }
1246 Commands::InspectPage => print_json_mode(&session.inspect_page().await?, response_mode)?,
1247 Commands::FindTarget { input } => {
1248 let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1249 &read_json_input(Some(input))?,
1250 )?)?;
1251 print_json_mode(&session.find_target(&request).await?, response_mode)?;
1252 }
1253 Commands::ActAndVerify {
1254 input,
1255 predicate,
1256 timeout_ms,
1257 } => {
1258 let request = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1259 &read_json_input(Some(input))?,
1260 )?)?;
1261 let predicate = predicate
1262 .as_deref()
1263 .map(serde_json::from_str::<VerificationPredicate>)
1264 .transpose()?;
1265 print_json_mode(
1266 &session
1267 .act_and_verify(&request, predicate, Duration::from_millis(*timeout_ms))
1268 .await?,
1269 response_mode,
1270 )?;
1271 }
1272 Commands::ExtractStructured { input } => {
1273 let request: StructuredExtractionRequest =
1274 serde_json::from_value(read_json_input(Some(input))?)?;
1275 print_json_mode(&session.extract_structured(&request).await?, response_mode)?;
1276 }
1277 Commands::RecoverRun { execution_id } => {
1278 print_json_mode(&session.recover_run(execution_id)?, response_mode)?;
1279 }
1280 Commands::Scroll {
1281 dx,
1282 dy,
1283 expected_revision,
1284 } => {
1285 print_json(
1286 &session
1287 .scroll_with_revision(*dx, *dy, *expected_revision)
1288 .await?,
1289 )?;
1290 }
1291 Commands::Wait {
1292 condition,
1293 timeout_ms,
1294 } => {
1295 print_json(
1296 &session
1297 .wait(
1298 WaitCondition::parse(condition)?,
1299 Duration::from_millis(*timeout_ms),
1300 )
1301 .await?,
1302 )?;
1303 }
1304 Commands::Diagnostics { duration_ms } => print_json(
1305 &session
1306 .diagnostics(Duration::from_millis(*duration_ms))
1307 .await?,
1308 )?,
1309 Commands::AcceptDialog => {
1310 session.accept_dialog().await?;
1311 print_json(&serde_json::json!({"dialog": "accepted"}))?;
1312 }
1313 Commands::DismissDialog => {
1314 session.dismiss_dialog().await?;
1315 print_json(&serde_json::json!({"dialog": "dismissed"}))?;
1316 }
1317 Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
1318 Commands::Download {
1319 destination,
1320 timeout_ms,
1321 } => print_json(
1322 &session
1323 .wait_for_download(destination, Duration::from_millis(*timeout_ms))
1324 .await?,
1325 )?,
1326 Commands::Targets => print_json(&session.list_targets().await?)?,
1327 Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
1328 Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
1329 Commands::CloseTarget { id } => {
1330 session.close_target(id).await?;
1331 print_json(&serde_json::json!({"closed": id}))?;
1332 }
1333 Commands::Frames => print_json(&session.list_frames().await?)?,
1334 Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
1335 Commands::Evaluate { expression } => {
1336 print_json(&session.evaluate(expression).await?)?;
1337 }
1338 Commands::Cookies => print_json(&session.cookies().await?)?,
1339 Commands::ExportCookies { output } => {
1340 let cookies = session.cookies().await?;
1341 let bytes = serde_json::to_vec_pretty(&cookies)?;
1342 tokio::fs::write(output, bytes).await?;
1343 println!("cookies exported to {}", output.display());
1344 }
1345 Commands::ImportCookies { input } => {
1346 const MAX_COOKIE_FILE_BYTES: u64 = 512 * 1024;
1347 let metadata = tokio::fs::metadata(input).await?;
1348 if metadata.len() > MAX_COOKIE_FILE_BYTES {
1349 return Err(format!(
1350 "cookie file exceeds the {}-byte limit",
1351 MAX_COOKIE_FILE_BYTES
1352 )
1353 .into());
1354 }
1355 let bytes = tokio::fs::read(input).await?;
1356 let cookies: Vec<Cookie> = serde_json::from_slice(&bytes)?;
1357 session.set_cookies(&cookies).await?;
1358 println!("{} cookies imported", cookies.len());
1359 }
1360 Commands::Pdf { output, background } => {
1361 let mut opts = PdfOptions::letter();
1362 if *background {
1363 opts.print_background = Some(true);
1364 }
1365 let data = session.print_to_pdf(&opts).await?;
1366 let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
1367 tokio::fs::write(&output, &bytes).await?;
1368 println!("PDF saved to {output} ({} bytes)", bytes.len());
1369 }
1370 Commands::FillForm {
1371 fields,
1372 expected_revision,
1373 } => {
1374 let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
1375 let field_refs: Vec<(String, String)> = parsed
1376 .iter()
1377 .map(|v| {
1378 (
1379 v["target"].as_str().unwrap_or("").to_string(),
1380 v["value"].as_str().unwrap_or("").to_string(),
1381 )
1382 })
1383 .collect();
1384 let field_slices: Vec<(&str, &str)> = field_refs
1385 .iter()
1386 .map(|(t, v)| (t.as_str(), v.as_str()))
1387 .collect();
1388 print_json(
1389 &session
1390 .fill_form_with_expected_revision(&field_slices, *expected_revision)
1391 .await?,
1392 )?;
1393 }
1394 Commands::Batch {
1395 input,
1396 atomic,
1397 mode,
1398 expected_revision,
1399 } => {
1400 let payload = read_json_input(input.as_ref())?;
1401 let steps_value = payload.get("steps").cloned().unwrap_or(payload);
1402 let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
1403 .map_err(|error| format!("invalid batch document: {error}"))?;
1404 print_json(
1405 &session
1406 .run_batch_with_mode(&steps, *atomic, *mode, *expected_revision)
1407 .await?,
1408 )?;
1409 }
1410 Commands::Workflow {
1411 action: None,
1412 input,
1413 } => {
1414 let payload = read_json_input(input.as_ref())?;
1415 let workflow_value = payload
1416 .get("workflow")
1417 .cloned()
1418 .unwrap_or_else(|| payload.clone());
1419 let inputs_value = payload
1420 .get("inputs")
1421 .cloned()
1422 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
1423 let workflow = WorkflowDefinition::from_value(workflow_value)
1424 .map_err(|error| format!("invalid workflow: {error}"))?;
1425 let inputs: BTreeMap<String, Value> = serde_json::from_value(inputs_value)
1426 .map_err(|error| format!("invalid workflow inputs: {error}"))?;
1427 print_json(&session.run_workflow(&workflow, &inputs).await?)?;
1428 }
1429 Commands::Workflow {
1430 action: Some(_), ..
1431 } => unreachable!("offline workflow authoring commands are handled before browser startup"),
1432 Commands::WorkflowResume {
1433 workflow,
1434 checkpoint,
1435 inputs,
1436 } => {
1437 let workflow = WorkflowDefinition::from_value(read_json_input(Some(workflow))?)
1438 .map_err(|error| format!("invalid workflow: {error}"))?;
1439 let checkpoint: WorkflowCheckpoint =
1440 serde_json::from_value(read_json_input(Some(checkpoint))?)
1441 .map_err(|error| format!("invalid workflow checkpoint: {error}"))?;
1442 let inputs: BTreeMap<String, Value> = match inputs {
1443 Some(path) => serde_json::from_value(read_json_input(Some(path))?)
1444 .map_err(|error| format!("invalid workflow inputs: {error}"))?,
1445 None => BTreeMap::new(),
1446 };
1447 print_json(
1448 &session
1449 .resume_workflow(&workflow, &inputs, &checkpoint)
1450 .await?,
1451 )?;
1452 }
1453 Commands::ResolveIntent { input } => {
1454 let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1455 &read_json_input(input.as_ref())?,
1456 )?)?;
1457 print_json(&session.resolve_intent(&request).await?)?;
1458 }
1459 Commands::ExecuteIntent { input } => {
1460 let execution = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1461 &read_json_input(input.as_ref())?,
1462 )?)?;
1463 print_json(&session.execute_intent(&execution).await?)?;
1464 }
1465 Commands::Verify {
1466 predicate,
1467 timeout_ms,
1468 } => {
1469 let predicate: VerificationPredicate = serde_json::from_str(predicate)
1470 .map_err(|error| format!("invalid verification predicate: {error}"))?;
1471 print_json(
1472 &session
1473 .verify(predicate, Duration::from_millis(*timeout_ms))
1474 .await?,
1475 )?;
1476 }
1477 Commands::ReconcileRefs {
1478 from_revision,
1479 hints,
1480 scope,
1481 refs,
1482 } => {
1483 let options = ReconciliationOptions {
1484 hints: hints
1485 .iter()
1486 .map(|hint| Locator::parse(hint))
1487 .collect::<BrowserResult<Vec<_>>>()?,
1488 scope_ref: scope.clone(),
1489 };
1490 print_json(
1491 &session
1492 .reconcile_references_with_options(*from_revision, refs, &options)
1493 .await?,
1494 )?;
1495 }
1496 Commands::ObserveDelta => {
1497 print_json(&session.observe_delta().await?)?;
1498 }
1499 Commands::Checkpoint { action } => match action {
1500 CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
1501 CheckpointCommand::Import { input } => {
1502 let checkpoint: CheckpointV1 =
1503 serde_json::from_value(read_json_input(input.as_ref())?)
1504 .map_err(|error| format!("invalid checkpoint: {error}"))?;
1505 session.import_checkpoint(&checkpoint).await?;
1506 print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
1507 }
1508 },
1509 Commands::ClipboardRead => {
1510 let text = session.clipboard_read().await?;
1511 println!("{text}");
1512 }
1513 Commands::ClipboardWrite { text } => {
1514 session.clipboard_write(text).await?;
1515 println!("Text written to clipboard");
1516 }
1517 Commands::Tui
1518 | Commands::InstallChromium { .. }
1519 | Commands::Profiles { .. }
1520 | Commands::DeleteProfile { .. } => {
1521 unreachable!("handled before starting a browser session")
1522 }
1523 }
1524 Ok(())
1525}
1526
1527fn parse_semantic_level(value: &str) -> BrowserResult<SemanticObservationLevel> {
1528 match value {
1529 "summary" => Ok(SemanticObservationLevel::Summary),
1530 "interactive" => Ok(SemanticObservationLevel::Interactive),
1531 "structured" => Ok(SemanticObservationLevel::Structured),
1532 "detailed" => Ok(SemanticObservationLevel::Detailed),
1533 "raw" => Ok(SemanticObservationLevel::Raw),
1534 _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
1535 }
1536}
1537
1538fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
1539 let mut input = String::new();
1540 match path {
1541 Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
1542 None => std::io::stdin().read_to_string(&mut input)?,
1543 };
1544 Ok(serde_json::from_str(&input)?)
1545}
1546
1547pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
1548 Ok(BrowserPolicy::new(
1549 cli.policy,
1550 std::env::current_dir()?,
1551 cli.policy_allow.iter().copied(),
1552 cli.policy_confirm.iter().copied(),
1553 )?
1554 .with_host_rules(
1555 cli.policy_allow_host.iter().cloned(),
1556 cli.policy_deny_host.iter().cloned(),
1557 )?
1558 .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
1559}
1560
1561async fn run_prompt(
1562 session: &BrowserSession,
1563 prompt: &str,
1564 response_mode: ResponseMode,
1565) -> BrowserResult<()> {
1566 let trimmed = prompt.trim();
1567 let lower = trimmed.to_lowercase();
1568
1569 for prefix in ["navigate to ", "go to ", "open "] {
1570 if lower.starts_with(prefix) {
1571 let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
1572 print_json_mode(&page, response_mode)?;
1573 return Ok(());
1574 }
1575 }
1576 if let Some(rest) = lower.strip_prefix("click ") {
1577 let target = &trimmed[trimmed.len() - rest.len()..];
1578 print_json_mode(
1579 &session.click(target.trim_matches('"')).await?,
1580 response_mode,
1581 )?;
1582 return Ok(());
1583 }
1584 if let Some(rest) = lower.strip_prefix("double click ") {
1585 let target = &trimmed[trimmed.len() - rest.len()..];
1586 print_json_mode(
1587 &session.double_click(target.trim_matches('"')).await?,
1588 response_mode,
1589 )?;
1590 return Ok(());
1591 }
1592 if let Some(rest) = lower.strip_prefix("type ") {
1593 let text = &trimmed[trimmed.len() - rest.len()..];
1594 print_json_mode(
1595 &session.type_text(text.trim_matches('"'), None).await?,
1596 response_mode,
1597 )?;
1598 return Ok(());
1599 }
1600 if lower.starts_with("screenshot") {
1601 let output = trimmed
1602 .split_once(char::is_whitespace)
1603 .map(|(_, value)| value.trim())
1604 .filter(|value| !value.is_empty())
1605 .unwrap_or("screenshot.png");
1606 let output = session
1607 .policy()
1608 .require_output_path(std::path::Path::new(output))?;
1609 std::fs::write(&output, session.screenshot_png().await?)?;
1610 println!("wrote {}", output.display());
1611 return Ok(());
1612 }
1613 if matches!(
1614 lower.as_str(),
1615 "text" | "get text" | "page text" | "get content"
1616 ) {
1617 println!("{}", session.text().await?);
1618 return Ok(());
1619 }
1620 if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
1621 print_json_mode(&session.deep_dom().await?, response_mode)?;
1622 return Ok(());
1623 }
1624 if matches!(lower.as_str(), "observe" | "context") {
1625 print_json_mode(&session.observe().await?, response_mode)?;
1626 return Ok(());
1627 }
1628
1629 print_json_mode(&session.evaluate(trimmed).await?, response_mode)?;
1630 Ok(())
1631}
1632
1633fn print_json_mode<T: Serialize + ?Sized>(value: &T, mode: ResponseMode) -> BrowserResult<()> {
1634 let value = serde_json::to_value(value)?;
1635 let projected = project_and_store(value, mode, "cli", default_result_store_path())?;
1636 println!("{}", compact_json(&projected)?);
1637 Ok(())
1638}
1639
1640fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
1641 println!("{}", compact_json(value)?);
1642 Ok(())
1643}
1644
1645fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
1646 let mut value = serde_json::to_value(value)?;
1647 let payload = serde_json::to_vec(&value)?;
1648 let payload_bytes = payload.len();
1649 if let Some(object) = value.as_object_mut() {
1650 object.insert(
1651 "contextCost".to_string(),
1652 serde_json::json!({
1653 "payloadBytes": payload_bytes,
1654 "estimatedTokens": payload_bytes.div_ceil(4)
1655 }),
1656 );
1657 }
1658 Ok(serde_json::to_string(&value)?)
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::*;
1664 use serde_json::json;
1665
1666 #[test]
1667 fn structured_cli_output_is_compact_json() {
1668 let output = compact_json(&json!({
1669 "page": {"title": "Glass", "url": "https://example.com"},
1670 "items": [1, 2]
1671 }))
1672 .unwrap();
1673
1674 let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
1675 assert!(!output.contains('\n'));
1676 assert_eq!(parsed["items"], json!([1, 2]));
1677 assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
1678 assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
1679 }
1680
1681 #[test]
1682 fn path_writability_accepts_missing_child_under_writable_parent() {
1683 let root = std::env::temp_dir().join(format!("glass-doctor-test-{}", std::process::id()));
1684 std::fs::create_dir_all(&root).unwrap();
1685 let missing_child = root.join("future").join("artifact");
1686
1687 assert!(path_is_writable(Some(&missing_child)));
1688
1689 std::fs::remove_dir_all(root).unwrap();
1690 }
1691
1692 #[test]
1693 fn path_writability_rejects_missing_path_without_parent() {
1694 assert!(!path_is_writable(None));
1695 }
1696
1697 #[test]
1698 fn workflow_starter_templates_compile_without_coordinate_actions() {
1699 for name in [
1700 "account-search",
1701 "checkout-submit",
1702 "profile-update",
1703 "report-download",
1704 "support-ticket",
1705 ] {
1706 let source = workflow_template(name).unwrap();
1707 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml).unwrap();
1708 assert!(
1709 !document
1710 .diagnostics
1711 .iter()
1712 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error),
1713 "{name} diagnostics: {:?}",
1714 document.diagnostics
1715 );
1716 assert!(!source.contains("clickAt"));
1717 }
1718 }
1719}