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