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