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