Skip to main content

glass/cli/
args.rs

1//! CLI argument definitions (clap).
2//!
3//! Defines the top-level `Cli` struct and all subcommands for one-shot
4//! browser operations, profile management, and server modes.
5
6use clap::{Parser, Subcommand, ValueEnum};
7use std::path::PathBuf;
8
9use crate::browser::policy::{PolicyCapability, PolicyPreset};
10use crate::browser::session::{
11    BatchMode, InteractionMode, PreflightAction, VisualClip, VisualFormat,
12};
13use crate::results::ResponseMode;
14/// Top-level CLI configuration parsed from command-line arguments.
15///
16/// Wraps clap-derived flags for policy, browser selection, session options,
17/// and the subcommand to execute.
18#[derive(Debug, Parser)]
19#[command(
20    name = "glass",
21    version,
22    about = "Lightweight local-first browser agent using raw Chrome DevTools Protocol"
23)]
24pub struct Cli {
25    /// Browser safety preset. Hardened mode fails closed for privileged operations.
26    #[arg(long, global = true, value_enum, default_value_t = PolicyPreset::Development)]
27    pub policy: PolicyPreset,
28
29    /// Explicitly allow a privileged capability under the selected policy.
30    #[arg(long = "policy-allow", global = true, value_enum)]
31    pub policy_allow: Vec<PolicyCapability>,
32
33    /// Return a typed confirmation-required result for this capability.
34    #[arg(long = "policy-confirm", global = true, value_enum)]
35    pub policy_confirm: Vec<PolicyCapability>,
36
37    /// Supply one consumable approval token for a confirmation-required capability.
38    #[arg(long = "policy-confirm-once", global = true, value_enum)]
39    pub policy_confirm_once: Vec<PolicyCapability>,
40
41    /// Opt into the experimental sandboxed extension capability.
42    #[arg(long, global = true)]
43    pub experimental_extensions: bool,
44
45    /// Permit only these exact hosts in hardened mode (repeatable).
46    #[arg(long = "policy-allow-host", global = true)]
47    pub policy_allow_host: Vec<String>,
48
49    /// Deny these exact hosts in hardened mode (repeatable).
50    #[arg(long = "policy-deny-host", global = true)]
51    pub policy_deny_host: Vec<String>,
52
53    /// Named browser profile used for persistent cookies and storage.
54    #[arg(long, global = true, default_value = "default")]
55    pub profile: String,
56
57    /// Use a temporary browser profile without persistence.
58    #[arg(long, global = true)]
59    pub incognito: bool,
60
61    /// Attach to an existing Chrome CDP endpoint instead of launching Chrome.
62    /// The default profile value is ignored in this mode.
63    #[arg(long, global = true)]
64    pub attach: bool,
65
66    /// Chrome page target ID. Required when the selected endpoint has multiple
67    /// page targets.
68    #[arg(long = "target-id", global = true)]
69    pub target_id: Option<String>,
70
71    /// Chrome frame ID used by commands in this one-shot session.
72    #[arg(long = "frame-id", global = true)]
73    pub frame_id: Option<String>,
74
75    /// Chrome remote debugging port.
76    #[arg(long, global = true, default_value_t = 9222)]
77    pub port: u16,
78
79    /// Show the browser window instead of using headless mode.
80    #[arg(long, global = true)]
81    pub headed: bool,
82
83    /// Pointer behavior for click actions.
84    #[arg(long, global = true, value_enum, default_value_t = InteractionMode::Human)]
85    pub interaction: InteractionMode,
86
87    /// Enable bounded session audit log of high-risk operations.
88    #[arg(long, global = true)]
89    pub audit: bool,
90
91    /// Emit a bounded JSON failure-trace pack when a browser operation fails.
92    #[arg(long, global = true)]
93    pub trace_on_error: bool,
94
95    /// Path to a Chrome/Chromium binary.
96    #[arg(long = "chrome-path", alias = "chrome", global = true)]
97    pub chrome_path: Option<PathBuf>,
98
99    /// Run the MCP server over stdio.
100    #[arg(long)]
101    pub mcp: bool,
102
103    /// Override the per-profile persistent knowledge snapshot path.
104    #[arg(long, global = true)]
105    pub knowledge_store: Option<PathBuf>,
106
107    /// Select the bounded agent-facing response projection.
108    #[arg(long, global = true, value_enum, default_value_t = ResponseMode::Minimal)]
109    pub response_mode: ResponseMode,
110
111    /// One-shot prompt, for example: `navigate to https://example.com`.
112    #[arg(value_name = "PROMPT")]
113    pub prompt: Option<String>,
114
115    #[command(subcommand)]
116    pub command: Option<Commands>,
117}
118
119#[derive(Debug, Clone, Copy, ValueEnum)]
120pub enum McpClient {
121    Generic,
122    ClaudeCode,
123    Codex,
124}
125
126#[derive(Debug, Subcommand)]
127pub enum Commands {
128    /// Download and install a managed Chrome for Testing build.
129    InstallChromium {
130        /// Reinstall the version pinned by this Glass release.
131        #[arg(long)]
132        update: bool,
133    },
134
135    /// Evaluate release evidence and forbidden outcomes without starting a browser.
136    Certify {
137        #[command(subcommand)]
138        action: CertifyCommand,
139    },
140
141    /// Print the versioned Glass capability manifest without starting Chrome.
142    Capabilities,
143
144    /// Start, inspect, stop, or diagnose the local Unix-socket daemon.
145    Daemon {
146        #[command(subcommand)]
147        action: DaemonCommand,
148    },
149
150    /// Inspect local browser, daemon, profile, policy, and store health.
151    Doctor {
152        /// Emit the stable machine-readable diagnostic contract.
153        #[arg(long)]
154        json: bool,
155    },
156
157    /// Print deterministic MCP configuration for a supported client.
158    McpConfig {
159        #[arg(long, value_enum, default_value_t = McpClient::Generic)]
160        client: McpClient,
161        /// Explicitly print the generated JSON configuration.
162        #[arg(long)]
163        print: bool,
164    },
165
166    /// List or manage saved profiles.
167    Profiles {
168        #[command(subcommand)]
169        action: Option<ProfileCommand>,
170    },
171
172    /// Inspect and manage the bounded local knowledge store.
173    Knowledge {
174        #[command(subcommand)]
175        action: KnowledgeCommand,
176    },
177
178    /// Inspect and purge bounded local diagnostic result artifacts.
179    Result {
180        #[command(subcommand)]
181        action: ResultCommand,
182    },
183
184    /// Delete a saved profile.
185    DeleteProfile { name: String },
186
187    /// Navigate to a URL.
188    Navigate {
189        url: String,
190        #[arg(long, default_value_t = 20_000)]
191        timeout_ms: u64,
192        #[arg(long)]
193        expected_revision: Option<u64>,
194    },
195
196    /// Click an element by an explicit ref/name/role/text/CSS/ordinal locator.
197    Click {
198        target: String,
199        #[arg(long)]
200        expected_revision: Option<u64>,
201    },
202
203    /// Resolve a target and report clickability without performing an action.
204    Preflight {
205        target: String,
206        #[arg(long, value_enum, default_value_t = PreflightAction::Click)]
207        action: PreflightAction,
208    },
209
210    /// Click exact viewport coordinates for canvas/map surfaces.
211    ClickAt { x: f64, y: f64 },
212
213    /// Click an element expected to open exactly one causally verified popup.
214    ClickExpectPopup {
215        target: String,
216        #[arg(long)]
217        expected_revision: Option<u64>,
218    },
219
220    /// Double-click an element by an explicit ref/name/role/text/CSS/ordinal locator.
221    DoubleClick {
222        target: String,
223        #[arg(long)]
224        expected_revision: Option<u64>,
225    },
226
227    /// Move the pointer over an element without clicking.
228    Hover { target: String },
229
230    /// Drag one element to another uniquely resolved element.
231    Drag {
232        source: String,
233        destination: String,
234        #[arg(long)]
235        expected_revision: Option<u64>,
236    },
237
238    /// Type text into the focused element, optionally clicking a target first.
239    Type {
240        text: String,
241        #[arg(long)]
242        target: Option<String>,
243        #[arg(long)]
244        expected_revision: Option<u64>,
245    },
246
247    /// Dispatch one complete key press.
248    Key {
249        key: String,
250        #[arg(long)]
251        expected_revision: Option<u64>,
252    },
253
254    /// Dispatch only a key-down event.
255    KeyDown {
256        key: String,
257        #[arg(long)]
258        expected_revision: Option<u64>,
259    },
260
261    /// Dispatch only a key-up event.
262    KeyUp {
263        key: String,
264        #[arg(long)]
265        expected_revision: Option<u64>,
266    },
267
268    /// Dispatch a modifier shortcut such as Control+A.
269    Shortcut {
270        shortcut: String,
271        #[arg(long)]
272        expected_revision: Option<u64>,
273    },
274
275    /// Clear an editable element.
276    Clear {
277        target: String,
278        #[arg(long)]
279        expected_revision: Option<u64>,
280    },
281
282    /// Ensure a checkbox or radio is checked.
283    Check {
284        target: String,
285        #[arg(long)]
286        expected_revision: Option<u64>,
287    },
288
289    /// Ensure a checkbox is unchecked.
290    Uncheck {
291        target: String,
292        #[arg(long)]
293        expected_revision: Option<u64>,
294    },
295
296    /// Select one exact option value.
297    Select {
298        target: String,
299        value: String,
300        #[arg(long)]
301        expected_revision: Option<u64>,
302    },
303
304    /// Set a bounded list of regular files on one file input.
305    Upload {
306        target: String,
307        #[arg(required = true)]
308        files: Vec<PathBuf>,
309        #[arg(long)]
310        expected_revision: Option<u64>,
311    },
312
313    /// Capture a PNG screenshot.
314    Screenshot {
315        #[arg(short, long, default_value = "screenshot.png")]
316        output: String,
317        #[arg(long, value_enum, default_value_t = VisualFormat::Png)]
318        format: VisualFormat,
319        #[arg(long)]
320        quality: Option<u8>,
321        #[arg(long, default_value_t = 1.0)]
322        scale: f64,
323        #[arg(long, conflicts_with_all = ["clip", "target"])]
324        full_page: bool,
325        #[arg(long, conflicts_with_all = ["full_page", "target"])]
326        clip: Option<VisualClip>,
327        #[arg(long, conflicts_with_all = ["full_page", "clip"])]
328        target: Option<String>,
329    },
330
331    /// Print the visible page text.
332    Text,
333
334    /// Print the full DOM tree. This is an explicit deep-inspection request.
335    Dom,
336
337    /// Print compact accessibility and text context.
338    Observe {
339        /// Include the full DOM tree. This is an explicit deep-inspection request.
340        #[arg(long)]
341        deep_dom: bool,
342        /// Include a PNG screenshot in the structured context.
343        #[arg(long)]
344        screenshot: bool,
345        /// Include bounded, policy-gated form field values.
346        #[arg(long)]
347        form_values: bool,
348        /// Return the versioned semantic observation at the requested level.
349        #[arg(long = "level", alias = "semantic-level", value_parser = parse_semantic_level)]
350        semantic_level: Option<String>,
351        /// Expand one semantic region from the current observation.
352        #[arg(long, requires = "semantic_level")]
353        region: Option<String>,
354    },
355
356    /// Capture the bounded task-oriented page inspection contract.
357    InspectPage,
358
359    /// Resolve target candidates without acting.
360    FindTarget { input: PathBuf },
361
362    /// Run one guarded semantic action and verify an optional postcondition.
363    ActAndVerify {
364        input: PathBuf,
365        #[arg(long)]
366        predicate: Option<String>,
367        #[arg(long, default_value_t = 10_000)]
368        timeout_ms: u64,
369    },
370
371    /// Extract typed records from a fresh semantic region.
372    ExtractStructured { input: PathBuf },
373
374    /// Recover a potentially indeterminate execution conservatively.
375    RecoverRun { execution_id: String },
376
377    /// Scroll the page by CSS pixels.
378    Scroll {
379        #[arg(long, default_value_t = 0.0)]
380        dx: f64,
381        #[arg(long, default_value_t = 600.0)]
382        dy: f64,
383        #[arg(long)]
384        expected_revision: Option<u64>,
385    },
386
387    /// Wait for one explicit browser condition until a bounded deadline.
388    Wait {
389        condition: String,
390        #[arg(long, default_value_t = 10_000)]
391        timeout_ms: u64,
392    },
393
394    /// Collect bounded, redacted console and network evidence.
395    Diagnostics {
396        #[arg(long, default_value_t = 1_000)]
397        duration_ms: u64,
398    },
399
400    /// Accept the currently open JavaScript dialog.
401    AcceptDialog,
402
403    /// Dismiss the currently open JavaScript dialog.
404    DismissDialog,
405
406    /// Dismiss a recognized OneTrust/Cookiebot consent wall.
407    DismissConsent,
408
409    /// Wait for one download into an authorized existing directory.
410    Download {
411        destination: PathBuf,
412        #[arg(long, default_value_t = 30_000)]
413        timeout_ms: u64,
414    },
415
416    /// List discoverable page targets without changing the active target.
417    Targets,
418
419    /// Create a page target without selecting it.
420    NewTarget { url: String },
421
422    /// Explicitly select the page target used by subsequent commands.
423    SelectTarget { id: String },
424
425    /// Close one page target.
426    CloseTarget { id: String },
427
428    /// List frames in the active page target.
429    Frames,
430
431    /// Explicitly select the frame used by subsequent commands.
432    SelectFrame { id: String },
433
434    /// Evaluate JavaScript in the current page.
435    Evaluate { expression: String },
436
437    /// List all browser cookies for the current page.
438    Cookies,
439
440    /// Export current browser cookies as bounded JSON.
441    ExportCookies { output: PathBuf },
442
443    /// Import browser cookies from bounded JSON.
444    ImportCookies { input: PathBuf },
445
446    /// Save the current page as a PDF.
447    Pdf {
448        #[arg(short, long, default_value = "page.pdf")]
449        output: String,
450        #[arg(long)]
451        background: bool,
452    },
453
454    /// Fill multiple form fields from a JSON value.
455    FillForm {
456        /// JSON array of {target, value} objects.
457        #[arg(long)]
458        fields: String,
459        /// Initial observation revision required before filling.
460        #[arg(long)]
461        expected_revision: Option<u64>,
462    },
463
464    /// Execute a bounded typed batch from a JSON array or stdin.
465    Batch {
466        /// JSON file containing the batch steps; omit to read stdin.
467        input: Option<PathBuf>,
468        #[arg(long)]
469        atomic: bool,
470        /// Revision policy: fixed, chain, or unguarded.
471        #[arg(long, value_enum, default_value_t = BatchMode::Unguarded)]
472        mode: BatchMode,
473        /// Initial observation revision required by fixed and chain modes.
474        #[arg(long)]
475        expected_revision: Option<u64>,
476    },
477
478    #[command(subcommand_precedence_over_arg = true)]
479    Workflow {
480        /// Offline authoring operation. Omit to execute the workflow.
481        #[command(subcommand)]
482        action: Option<WorkflowAuthoringCommand>,
483        /// JSON file containing `{ "workflow": ..., "inputs": ... }`.
484        input: Option<PathBuf>,
485    },
486
487    /// Reconcile a workflow checkpoint and execute only its safe pending suffix.
488    WorkflowResume {
489        /// JSON file containing the workflow definition.
490        workflow: PathBuf,
491        /// JSON file containing a workflow checkpoint.
492        checkpoint: PathBuf,
493        /// Optional JSON file containing the workflow input map.
494        #[arg(long)]
495        inputs: Option<PathBuf>,
496    },
497
498    /// Resolve a declared intent from JSON or stdin without dispatching it.
499    ResolveIntent {
500        /// JSON file containing the versioned intent request; omit to read stdin.
501        input: Option<PathBuf>,
502    },
503
504    /// Resolve and execute one explicitly selected intent candidate.
505    ExecuteIntent {
506        /// JSON file containing the versioned execution request; omit to read stdin.
507        input: Option<PathBuf>,
508    },
509
510    /// Evaluate a bounded JSON verification predicate.
511    Verify {
512        /// JSON object such as `{"urlEquals":"https://example.com"}`.
513        predicate: String,
514        #[arg(long, default_value_t = 10_000)]
515        timeout_ms: u64,
516    },
517
518    /// Reconcile revisioned references against the current observation.
519    ReconcileRefs {
520        #[arg(long)]
521        from_revision: u64,
522        /// Stable locators tried positionally after backend identity is gone.
523        #[arg(long = "hint")]
524        hints: Vec<String>,
525        /// Current revisioned landmark/container ref used to narrow relocation.
526        #[arg(long)]
527        scope: Option<String>,
528        #[arg(required = true)]
529        refs: Vec<String>,
530    },
531
532    /// Report a bounded delta from the last compact observation.
533    ObserveDelta,
534
535    /// Export or import a bounded workflow checkpoint.
536    Checkpoint {
537        #[command(subcommand)]
538        action: CheckpointCommand,
539    },
540
541    /// Create, inspect, diff, or purge redacted local session snapshots.
542    Snapshot {
543        #[command(subcommand)]
544        action: SnapshotCommand,
545    },
546
547    /// Read text from the system clipboard.
548    ClipboardRead,
549
550    /// Write text to the system clipboard.
551    ClipboardWrite { text: String },
552
553    /// Launch the interactive TUI.
554    Tui,
555}
556
557fn parse_semantic_level(value: &str) -> Result<String, String> {
558    match value {
559        "summary" | "interactive" | "structured" | "detailed" | "raw" => Ok(value.into()),
560        _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
561    }
562}
563
564#[derive(Debug, Subcommand)]
565pub enum ProfileCommand {
566    List,
567    Create { name: String },
568    Delete { name: String },
569}
570
571#[derive(Debug, Subcommand)]
572pub enum KnowledgeCommand {
573    /// List all validated records.
574    List,
575    /// Show one record by ID.
576    Show { record_id: String },
577    /// Explain one record's provenance, lifecycle, and invalidation rules.
578    Explain { record_id: String },
579    /// Print lifecycle and serialized-size statistics.
580    Stats,
581    /// Export the validated snapshot to stdout or a file.
582    Export { output: Option<PathBuf> },
583    /// Import and replace the complete validated snapshot.
584    Import { input: PathBuf },
585    /// Move one record to a non-eligible state.
586    Invalidate {
587        record_id: String,
588        #[arg(value_enum)]
589        state: KnowledgeInvalidationState,
590        #[arg(long)]
591        reason: Option<String>,
592
593        #[arg(long)]
594        observed_at: Option<String>,
595    },
596    /// Remove every record for one exact origin.
597    Purge { origin: String },
598}
599
600#[derive(Debug, Subcommand)]
601pub enum SnapshotCommand {
602    Create,
603    List,
604    Inspect { snapshot_id: String },
605    Diff { from: String, to: String },
606    Purge,
607}
608
609#[derive(Debug, Subcommand)]
610pub enum ResultCommand {
611    /// Show a stored diagnostic artifact, optionally selecting one section.
612    Show {
613        result_id: String,
614        #[arg(long)]
615        section: Option<String>,
616    },
617    /// Purge artifacts older than a bounded duration such as 7d or 24h.
618    Purge {
619        #[arg(long = "older-than")]
620        older_than: String,
621    },
622}
623#[derive(Debug, Clone, Copy, ValueEnum)]
624pub enum KnowledgeInvalidationState {
625    Stale,
626    Contradicted,
627    Quarantined,
628}
629
630#[derive(Debug, Subcommand)]
631pub enum WorkflowAuthoringCommand {
632    /// Compile YAML or JSON source into canonical workflow JSON.
633    Compile {
634        input: PathBuf,
635        #[arg(short, long)]
636        output: Option<PathBuf>,
637    },
638    /// Format a YAML or JSON workflow as deterministic YAML.
639    Format {
640        input: PathBuf,
641        #[arg(short, long)]
642        output: Option<PathBuf>,
643    },
644    /// Show a redacted browser-free execution preview.
645    Preview { input: PathBuf },
646    /// Compare two workflow sources and print migration guidance.
647    Diff { before: PathBuf, after: PathBuf },
648    /// Import explicit semantic evidence into a reviewable draft.
649    Record {
650        /// JSON event envelope; omit to read stdin.
651        #[arg(long)]
652        input: Option<PathBuf>,
653        #[arg(short, long)]
654        output: Option<PathBuf>,
655    },
656    /// Validate authoring source against the canonical workflow contract.
657    Validate { input: PathBuf },
658    /// Run static workflow diagnostics without starting a browser.
659    Lint {
660        input: PathBuf,
661        #[arg(long)]
662        warnings_as_errors: bool,
663    },
664    /// List or initialize one of the reviewable workflow starter templates.
665    Templates {
666        /// Optional template name; omit to list available templates.
667        name: Option<String>,
668        #[arg(short, long)]
669        output: Option<PathBuf>,
670    },
671    /// Initialize one of the five reviewable issue 29 starter templates.
672    Init {
673        /// Template name: search, form-submit, paginated-extraction,
674        /// authenticated-session, or dialog-and-download.
675        name: String,
676        #[arg(short, long)]
677        output: Option<PathBuf>,
678    },
679}
680
681#[derive(Debug, Subcommand)]
682pub enum DaemonCommand {
683    /// Start the daemon in the background.
684    Start {
685        #[arg(long)]
686        socket: Option<PathBuf>,
687        #[arg(long)]
688        status: Option<PathBuf>,
689    },
690    /// Read the daemon status contract.
691    Status {
692        #[arg(long)]
693        socket: Option<PathBuf>,
694        #[arg(long)]
695        status: Option<PathBuf>,
696    },
697    /// Stop the daemon recorded by the status contract.
698    Stop {
699        #[arg(long)]
700        socket: Option<PathBuf>,
701        #[arg(long)]
702        status: Option<PathBuf>,
703    },
704    /// Check the daemon process, status, and local socket.
705    Doctor {
706        #[arg(long)]
707        socket: Option<PathBuf>,
708        #[arg(long)]
709        status: Option<PathBuf>,
710    },
711    /// Read the bounded local daemon log tail.
712    Logs {
713        #[arg(long)]
714        status: Option<PathBuf>,
715    },
716    /// Acknowledge that interrupted workflows were reconciled from checkpoints.
717    AcknowledgeRecovery {
718        #[arg(long)]
719        status: Option<PathBuf>,
720        /// Request ID for every recovery record reconciled from a checkpoint.
721        #[arg(long = "request-id", required = true)]
722        request_ids: Vec<String>,
723    },
724    /// Internal foreground server used by `daemon start`.
725    #[command(hide = true)]
726    Serve {
727        #[arg(long)]
728        socket: PathBuf,
729        #[arg(long)]
730        status: PathBuf,
731    },
732}
733
734#[derive(Debug, Subcommand)]
735pub enum CertifyCommand {
736    /// Run one scenario in a navigated browser fixture and emit evidence.
737    Run {
738        /// JSON scenario to execute.
739        #[arg(long)]
740        scenario: PathBuf,
741        /// JSON fixture manifest used to bind controls and faults.
742        #[arg(long)]
743        fixture: PathBuf,
744        /// Fixture URL to navigate before execution.
745        #[arg(long)]
746        url: String,
747        /// Directory containing workflow sources referenced by the scenario.
748        #[arg(long, default_value = ".")]
749        workflow_root: PathBuf,
750        /// Optional JSON object containing declared workflow inputs.
751        #[arg(long)]
752        inputs: Option<PathBuf>,
753        /// Optional path for the redacted evidence bundle.
754        #[arg(short, long)]
755        output: Option<PathBuf>,
756    },
757    /// Expand a scenario into its manifest-bound execution plan.
758    Plan {
759        /// JSON scenario to plan.
760        #[arg(long)]
761        scenario: PathBuf,
762        /// JSON fixture manifest used to bind controls and faults.
763        #[arg(long)]
764        fixture: PathBuf,
765    },
766    /// Evaluate a release-blocking reliability gate.
767    Release {
768        #[arg(long)]
769        version: String,
770        /// JSON array of validated reliability scenarios.
771        #[arg(long)]
772        scenarios: PathBuf,
773        /// JSON array of scenario observations and oracle evidence.
774        #[arg(long)]
775        observations: PathBuf,
776        /// Optional JSON array of redacted replay bundles to cross-check.
777        #[arg(long)]
778        replays: Option<PathBuf>,
779    },
780    /// Validate one redacted replay bundle against its versioned scenario.
781    Replay {
782        /// JSON scenario used to validate the replay binding.
783        #[arg(long)]
784        scenario: PathBuf,
785        /// JSON replay bundle to validate.
786        #[arg(long)]
787        input: PathBuf,
788    },
789    /// Compare two redacted replay bundles for one scenario.
790    ReplayDiff {
791        /// JSON scenario used to validate both replay bindings.
792        #[arg(long)]
793        scenario: PathBuf,
794        /// Baseline replay bundle.
795        #[arg(long)]
796        before: PathBuf,
797        /// Candidate replay bundle.
798        #[arg(long)]
799        after: PathBuf,
800    },
801}
802
803#[derive(Debug, Subcommand)]
804pub enum CheckpointCommand {
805    Export,
806    Import { input: Option<PathBuf> },
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    #[test]
814    fn observation_and_human_interaction_are_defaults() {
815        let cli = Cli::try_parse_from(["glass", "observe"]).unwrap();
816
817        assert_eq!(cli.interaction, InteractionMode::Human);
818        assert!(matches!(
819            cli.command,
820            Some(Commands::Observe {
821                deep_dom: false,
822                screenshot: false,
823                form_values: false,
824                semantic_level: None,
825                region: None,
826            })
827        ));
828    }
829
830    #[test]
831    fn screenshot_and_fast_interaction_require_explicit_flags() {
832        let cli =
833            Cli::try_parse_from(["glass", "--interaction", "fast", "observe", "--screenshot"])
834                .unwrap();
835
836        assert_eq!(cli.interaction, InteractionMode::Fast);
837        assert!(matches!(
838            cli.command,
839            Some(Commands::Observe {
840                deep_dom: false,
841                screenshot: true,
842                form_values: false,
843                semantic_level: None,
844                region: None,
845            })
846        ));
847    }
848
849    #[test]
850    fn deep_dom_requires_an_explicit_observation_flag() {
851        let cli = Cli::try_parse_from(["glass", "observe", "--deep-dom"]).unwrap();
852
853        assert!(matches!(
854            cli.command,
855            Some(Commands::Observe {
856                deep_dom: true,
857                screenshot: false,
858                form_values: false,
859                semantic_level: None,
860                region: None,
861            })
862        ));
863    }
864
865    #[test]
866    fn semantic_observation_level_and_region_are_explicit() {
867        let cli = Cli::try_parse_from([
868            "glass",
869            "observe",
870            "--level",
871            "interactive",
872            "--region",
873            "region_main",
874        ])
875        .unwrap();
876        assert!(matches!(
877            cli.command,
878            Some(Commands::Observe {
879                semantic_level: Some(level),
880                region: Some(region),
881                ..
882            }) if level == "interactive" && region == "region_main"
883        ));
884
885        assert!(Cli::try_parse_from(["glass", "observe", "--level", "verbose"]).is_err());
886    }
887
888    #[test]
889    fn screenshot_remains_a_separate_explicit_command() {
890        let cli = Cli::try_parse_from(["glass", "screenshot", "--output", "page.png"]).unwrap();
891
892        assert!(matches!(
893            cli.command,
894            Some(Commands::Screenshot { output, .. }) if output == "page.png"
895        ));
896    }
897
898    #[test]
899    fn double_click_is_an_explicit_action_command() {
900        let cli = Cli::try_parse_from(["glass", "double-click", "r7:b42"]).unwrap();
901
902        assert!(matches!(
903            cli.command,
904            Some(Commands::DoubleClick { target, .. }) if target == "r7:b42"
905        ));
906    }
907
908    #[test]
909    fn click_expect_popup_is_an_explicit_action_command() {
910        let cli = Cli::try_parse_from(["glass", "click-expect-popup", "css=#popup"]).unwrap();
911        assert!(matches!(
912            cli.command,
913            Some(Commands::ClickExpectPopup { target, .. }) if target == "css=#popup"
914        ));
915    }
916
917    #[test]
918    fn wait_has_an_explicit_condition_and_bounded_default() {
919        let cli = Cli::try_parse_from(["glass", "wait", "text=Ready"]).unwrap();
920        assert!(matches!(
921            cli.command,
922            Some(Commands::Wait { condition, timeout_ms: 10_000 }) if condition == "text=Ready"
923        ));
924    }
925
926    #[test]
927    fn topology_commands_are_explicit() {
928        assert!(matches!(
929            Cli::try_parse_from(["glass", "targets"]).unwrap().command,
930            Some(Commands::Targets)
931        ));
932        let cli = Cli::try_parse_from([
933            "glass",
934            "--target-id",
935            "page-1",
936            "--frame-id",
937            "frame-1",
938            "evaluate",
939            "document.title",
940        ])
941        .unwrap();
942        assert_eq!(cli.target_id.as_deref(), Some("page-1"));
943        assert_eq!(cli.frame_id.as_deref(), Some("frame-1"));
944        assert!(matches!(
945            Cli::try_parse_from(["glass", "select-frame", "frame-1"])
946                .unwrap()
947                .command,
948            Some(Commands::SelectFrame { id }) if id == "frame-1"
949        ));
950    }
951
952    #[test]
953    fn complete_input_commands_are_explicit() {
954        assert!(matches!(
955            Cli::try_parse_from(["glass", "drag", "css=#from", "css=#to"])
956                .unwrap()
957                .command,
958            Some(Commands::Drag { source, destination, .. }) if source == "css=#from" && destination == "css=#to"
959        ));
960        assert!(matches!(
961            Cli::try_parse_from(["glass", "shortcut", "Control+A"])
962                .unwrap()
963                .command,
964            Some(Commands::Shortcut { shortcut, .. }) if shortcut == "Control+A"
965        ));
966        assert!(matches!(
967            Cli::try_parse_from([
968                "glass",
969                "fill-form",
970                "--fields",
971                "[]",
972                "--expected-revision",
973                "7"
974            ])
975            .unwrap()
976            .command,
977            Some(Commands::FillForm {
978                fields,
979                expected_revision: Some(7)
980            }) if fields == "[]"
981        ));
982    }
983
984    #[test]
985    fn rejects_unknown_interaction_modes() {
986        assert!(Cli::try_parse_from(["glass", "--interaction", "instant", "observe"]).is_err());
987    }
988
989    #[test]
990    fn attach_and_target_id_are_explicit_global_options() {
991        let cli = Cli::try_parse_from([
992            "glass",
993            "--attach",
994            "--port",
995            "9333",
996            "--target-id",
997            "page-2",
998            "observe",
999        ])
1000        .unwrap();
1001
1002        assert!(cli.attach);
1003        assert_eq!(cli.port, 9333);
1004        assert_eq!(cli.target_id.as_deref(), Some("page-2"));
1005    }
1006
1007    #[test]
1008    fn workflow_command_accepts_optional_json_input() {
1009        let cli = Cli::try_parse_from(["glass", "workflow", "workflow.json"]).unwrap();
1010        assert!(matches!(
1011            cli.command,
1012            Some(Commands::Workflow {
1013                action: None,
1014                input: Some(path)
1015            })
1016                if path.as_os_str() == "workflow.json"
1017        ));
1018        let cli = Cli::try_parse_from(["glass", "workflow", "validate", "workflow.yaml"]).unwrap();
1019        assert!(matches!(
1020            cli.command,
1021            Some(Commands::Workflow {
1022                action: Some(WorkflowAuthoringCommand::Validate { input }),
1023                input: None,
1024            }) if input.as_os_str() == "workflow.yaml"
1025        ));
1026        let cli = Cli::try_parse_from(["glass", "workflow", "preview", "workflow.yaml"]).unwrap();
1027        assert!(matches!(
1028            cli.command,
1029            Some(Commands::Workflow {
1030                action: Some(WorkflowAuthoringCommand::Preview { input }),
1031                input: None,
1032            }) if input.as_os_str() == "workflow.yaml"
1033        ));
1034        let cli = Cli::try_parse_from([
1035            "glass",
1036            "workflow",
1037            "record",
1038            "--input",
1039            "events.json",
1040            "--output",
1041            "draft.json",
1042        ])
1043        .unwrap();
1044        assert!(matches!(
1045            cli.command,
1046            Some(Commands::Workflow {
1047                action: Some(WorkflowAuthoringCommand::Record { input: Some(input), output: Some(output) }),
1048                input: None,
1049            }) if input.as_os_str() == "events.json" && output.as_os_str() == "draft.json"
1050        ));
1051    }
1052
1053    #[test]
1054    fn certify_release_command_accepts_versioned_evidence_paths() {
1055        let cli = Cli::try_parse_from([
1056            "glass",
1057            "certify",
1058            "release",
1059            "--version",
1060            "0.2.0",
1061            "--scenarios",
1062            "scenarios.json",
1063            "--observations",
1064            "observations.json",
1065        ])
1066        .unwrap();
1067        assert!(matches!(
1068            cli.command,
1069            Some(Commands::Certify {
1070                action: CertifyCommand::Release {
1071                    version,
1072                    scenarios,
1073                    observations,
1074                    replays: None,
1075                },
1076            }) if version == "0.2.0"
1077                && scenarios.as_os_str() == "scenarios.json"
1078                && observations.as_os_str() == "observations.json"
1079        ));
1080    }
1081
1082    #[test]
1083    fn certify_plan_command_accepts_scenario_and_fixture_paths() {
1084        let cli = Cli::try_parse_from([
1085            "glass",
1086            "certify",
1087            "plan",
1088            "--scenario",
1089            "scenario.json",
1090            "--fixture",
1091            "fixture.json",
1092        ])
1093        .unwrap();
1094        assert!(matches!(
1095            cli.command,
1096            Some(Commands::Certify {
1097                action: CertifyCommand::Plan { scenario, fixture },
1098            }) if scenario.as_os_str() == "scenario.json" && fixture.as_os_str() == "fixture.json"
1099        ));
1100    }
1101
1102    #[test]
1103    fn certify_replay_command_accepts_scenario_and_bundle_paths() {
1104        let cli = Cli::try_parse_from([
1105            "glass",
1106            "certify",
1107            "replay",
1108            "--scenario",
1109            "scenario.json",
1110            "--input",
1111            "replay.json",
1112        ])
1113        .unwrap();
1114        assert!(matches!(
1115            cli.command,
1116            Some(Commands::Certify {
1117                action: CertifyCommand::Replay { scenario, input },
1118            }) if scenario.as_os_str() == "scenario.json" && input.as_os_str() == "replay.json"
1119        ));
1120    }
1121
1122    #[test]
1123    fn certify_replay_diff_command_accepts_two_bundle_paths() {
1124        let cli = Cli::try_parse_from([
1125            "glass",
1126            "certify",
1127            "replay-diff",
1128            "--scenario",
1129            "scenario.json",
1130            "--before",
1131            "before.json",
1132            "--after",
1133            "after.json",
1134        ])
1135        .unwrap();
1136        assert!(matches!(
1137            cli.command,
1138            Some(Commands::Certify {
1139                action: CertifyCommand::ReplayDiff { scenario, before, after },
1140            }) if scenario.as_os_str() == "scenario.json"
1141                && before.as_os_str() == "before.json"
1142                && after.as_os_str() == "after.json"
1143        ));
1144    }
1145
1146    #[test]
1147    fn workflow_resume_command_accepts_checkpoint_and_inputs() {
1148        let cli = Cli::try_parse_from([
1149            "glass",
1150            "workflow-resume",
1151            "workflow.json",
1152            "checkpoint.json",
1153            "--inputs",
1154            "inputs.json",
1155        ])
1156        .unwrap();
1157        assert!(matches!(
1158            cli.command,
1159            Some(Commands::WorkflowResume {
1160                workflow,
1161                checkpoint,
1162                inputs: Some(inputs)
1163            }) if workflow.as_os_str() == "workflow.json"
1164                && checkpoint.as_os_str() == "checkpoint.json"
1165                && inputs.as_os_str() == "inputs.json"
1166        ));
1167    }
1168
1169    #[test]
1170    fn resolve_intent_command_accepts_optional_json_input() {
1171        let cli = Cli::try_parse_from(["glass", "resolve-intent", "intent.json"]).unwrap();
1172        assert!(matches!(
1173            cli.command,
1174            Some(Commands::ResolveIntent { input: Some(path) })
1175                if path.as_os_str() == "intent.json"
1176        ));
1177        assert!(Cli::try_parse_from(["glass", "resolve-intent"]).is_ok());
1178    }
1179
1180    #[test]
1181    fn execute_intent_command_accepts_optional_json_input() {
1182        let cli = Cli::try_parse_from(["glass", "execute-intent", "intent.json"]).unwrap();
1183        assert!(matches!(
1184            cli.command,
1185            Some(Commands::ExecuteIntent { input: Some(path) })
1186                if path.as_os_str() == "intent.json"
1187        ));
1188        assert!(Cli::try_parse_from(["glass", "execute-intent"]).is_ok());
1189    }
1190
1191    #[test]
1192    fn reliability_run_command_requires_fixture_url_and_sources() {
1193        let cli = Cli::try_parse_from([
1194            "glass",
1195            "certify",
1196            "run",
1197            "--scenario",
1198            "scenario.json",
1199            "--fixture",
1200            "fixture.json",
1201            "--url",
1202            "http://127.0.0.1:8000/fixture.html",
1203            "--workflow-root",
1204            "fixtures",
1205            "--inputs",
1206            "inputs.json",
1207            "--output",
1208            "evidence.json",
1209        ])
1210        .unwrap();
1211        assert!(matches!(
1212            cli.command,
1213            Some(Commands::Certify {
1214                action: CertifyCommand::Run {
1215                    scenario,
1216                    fixture,
1217                    url,
1218                    workflow_root,
1219                    inputs: Some(inputs),
1220                    output: Some(output),
1221                }
1222            }) if scenario.as_os_str() == "scenario.json"
1223                && fixture.as_os_str() == "fixture.json"
1224                && url == "http://127.0.0.1:8000/fixture.html"
1225                && workflow_root.as_os_str() == "fixtures"
1226                && inputs.as_os_str() == "inputs.json"
1227                && output.as_os_str() == "evidence.json"
1228        ));
1229    }
1230
1231    #[test]
1232    fn capabilities_command_is_explicitly_offline() {
1233        let cli = Cli::try_parse_from(["glass", "capabilities"]).unwrap();
1234        assert!(matches!(cli.command, Some(Commands::Capabilities)));
1235    }
1236
1237    #[test]
1238    fn experimental_extensions_require_an_explicit_global_opt_in() {
1239        let cli =
1240            Cli::try_parse_from(["glass", "--experimental-extensions", "capabilities"]).unwrap();
1241        assert!(cli.experimental_extensions);
1242    }
1243
1244    #[test]
1245    fn daemon_lifecycle_commands_accept_explicit_local_paths() {
1246        let cli = Cli::try_parse_from([
1247            "glass",
1248            "daemon",
1249            "start",
1250            "--socket",
1251            "/tmp/glass.sock",
1252            "--status",
1253            "/tmp/glass.json",
1254        ])
1255        .unwrap();
1256        assert!(matches!(
1257            cli.command,
1258            Some(Commands::Daemon {
1259                action: DaemonCommand::Start { socket: Some(socket), status: Some(status) }
1260            }) if socket.as_os_str() == "/tmp/glass.sock"
1261                && status.as_os_str() == "/tmp/glass.json"
1262        ));
1263    }
1264
1265    #[test]
1266    fn doctor_command_is_available_without_starting_a_browser() {
1267        let cli = Cli::try_parse_from(["glass", "doctor"]).unwrap();
1268        assert!(matches!(
1269            cli.command,
1270            Some(Commands::Doctor { json: false })
1271        ));
1272    }
1273
1274    #[test]
1275    fn knowledge_management_commands_parse_without_browser_startup() {
1276        let cli = Cli::try_parse_from(["glass", "knowledge", "list"]).unwrap();
1277        assert!(matches!(
1278            cli.command,
1279            Some(Commands::Knowledge {
1280                action: KnowledgeCommand::List
1281            })
1282        ));
1283        let cli = Cli::try_parse_from(["glass", "knowledge", "explain", "record-1"]).unwrap();
1284        assert!(matches!(
1285            cli.command,
1286            Some(Commands::Knowledge {
1287                action: KnowledgeCommand::Explain { .. }
1288            })
1289        ));
1290        let cli = Cli::try_parse_from([
1291            "glass",
1292            "--knowledge-store",
1293            "knowledge.json",
1294            "knowledge",
1295            "invalidate",
1296            "record-1",
1297            "stale",
1298        ])
1299        .unwrap();
1300        assert!(matches!(
1301            cli.command,
1302            Some(Commands::Knowledge {
1303                action: KnowledgeCommand::Invalidate { .. }
1304            })
1305        ));
1306    }
1307}