Skip to main content

glass/cli/
runner.rs

1//! CLI command dispatch and session orchestration.
2//!
3//! Routes parsed CLI arguments to the appropriate runner: one-shot browser
4//! commands, interactive TUI, or the MCP stdio server.
5
6use super::args::{
7    CertifyCommand, CheckpointCommand, Cli, Commands, DaemonCommand, KnowledgeCommand,
8    KnowledgeInvalidationState, 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
40/// Top-level command-line entry point: parses CLI arguments and dispatches
41/// to the appropriate runner (one-shot, TUI, or MCP server).
42pub 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}