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    /// Validate and compile a browser-free Task Protocol task.
488    Task {
489        #[command(subcommand)]
490        action: TaskCommand,
491    },
492
493    /// Inspect or diff browser-free Web IR draft JSON.
494    Ir {
495        #[command(subcommand)]
496        action: IrCommand,
497    },
498
499    /// Reconcile a workflow checkpoint and execute only its safe pending suffix.
500    WorkflowResume {
501        /// JSON file containing the workflow definition.
502        workflow: PathBuf,
503        /// JSON file containing a workflow checkpoint.
504        checkpoint: PathBuf,
505        /// Optional JSON file containing the workflow input map.
506        #[arg(long)]
507        inputs: Option<PathBuf>,
508    },
509
510    /// Resolve a declared intent from JSON or stdin without dispatching it.
511    ResolveIntent {
512        /// JSON file containing the versioned intent request; omit to read stdin.
513        input: Option<PathBuf>,
514    },
515
516    /// Resolve and execute one explicitly selected intent candidate.
517    ExecuteIntent {
518        /// JSON file containing the versioned execution request; omit to read stdin.
519        input: Option<PathBuf>,
520    },
521
522    /// Evaluate a bounded JSON verification predicate.
523    Verify {
524        /// JSON object such as `{"urlEquals":"https://example.com"}`.
525        predicate: String,
526        #[arg(long, default_value_t = 10_000)]
527        timeout_ms: u64,
528    },
529
530    /// Reconcile revisioned references against the current observation.
531    ReconcileRefs {
532        #[arg(long)]
533        from_revision: u64,
534        /// Stable locators tried positionally after backend identity is gone.
535        #[arg(long = "hint")]
536        hints: Vec<String>,
537        /// Current revisioned landmark/container ref used to narrow relocation.
538        #[arg(long)]
539        scope: Option<String>,
540        #[arg(required = true)]
541        refs: Vec<String>,
542    },
543
544    /// Report a bounded delta from the last compact observation.
545    ObserveDelta,
546
547    /// Export or import a bounded workflow checkpoint.
548    Checkpoint {
549        #[command(subcommand)]
550        action: CheckpointCommand,
551    },
552
553    /// Create, inspect, diff, or purge redacted local session snapshots.
554    Snapshot {
555        #[command(subcommand)]
556        action: SnapshotCommand,
557    },
558
559    /// Read text from the system clipboard.
560    ClipboardRead,
561
562    /// Write text to the system clipboard.
563    ClipboardWrite { text: String },
564
565    /// Launch the interactive TUI.
566    Tui,
567}
568
569fn parse_semantic_level(value: &str) -> Result<String, String> {
570    match value {
571        "summary" | "interactive" | "structured" | "detailed" | "raw" => Ok(value.into()),
572        _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
573    }
574}
575
576#[derive(Debug, Subcommand)]
577pub enum ProfileCommand {
578    List,
579    Create { name: String },
580    Delete { name: String },
581}
582
583#[derive(Debug, Subcommand)]
584pub enum KnowledgeCommand {
585    /// List all validated records.
586    List,
587    /// Show one record by ID.
588    Show { record_id: String },
589    /// Explain one record's provenance, lifecycle, and invalidation rules.
590    Explain { record_id: String },
591    /// Print lifecycle and serialized-size statistics.
592    Stats,
593    /// Export the validated snapshot to stdout or a file.
594    Export { output: Option<PathBuf> },
595    /// Import and replace the complete validated snapshot.
596    Import { input: PathBuf },
597    /// Move one record to a non-eligible state.
598    Invalidate {
599        record_id: String,
600        #[arg(value_enum)]
601        state: KnowledgeInvalidationState,
602        #[arg(long)]
603        reason: Option<String>,
604
605        #[arg(long)]
606        observed_at: Option<String>,
607    },
608    /// Remove every record for one exact origin.
609    Purge { origin: String },
610}
611
612#[derive(Debug, Subcommand)]
613pub enum SnapshotCommand {
614    Create,
615    List,
616    Inspect { snapshot_id: String },
617    Diff { from: String, to: String },
618    Purge,
619}
620
621#[derive(Debug, Subcommand)]
622pub enum ResultCommand {
623    /// Show a stored diagnostic artifact, optionally selecting one section.
624    Show {
625        result_id: String,
626        #[arg(long)]
627        section: Option<String>,
628    },
629    /// Purge artifacts older than a bounded duration such as 7d or 24h.
630    Purge {
631        #[arg(long = "older-than")]
632        older_than: String,
633    },
634}
635#[derive(Debug, Clone, Copy, ValueEnum)]
636pub enum KnowledgeInvalidationState {
637    Stale,
638    Contradicted,
639    Quarantined,
640}
641
642#[derive(Debug, Subcommand)]
643pub enum WorkflowAuthoringCommand {
644    /// Compile YAML or JSON source into canonical workflow JSON.
645    Compile {
646        input: PathBuf,
647        #[arg(short, long)]
648        output: Option<PathBuf>,
649    },
650    /// Format a YAML or JSON workflow as deterministic YAML.
651    Format {
652        input: PathBuf,
653        #[arg(short, long)]
654        output: Option<PathBuf>,
655    },
656    /// Show a redacted browser-free execution preview.
657    Preview { input: PathBuf },
658    /// Compare two workflow sources and print migration guidance.
659    Diff { before: PathBuf, after: PathBuf },
660    /// Import explicit semantic evidence into a reviewable draft.
661    Record {
662        /// JSON event envelope; omit to read stdin.
663        #[arg(long)]
664        input: Option<PathBuf>,
665        #[arg(short, long)]
666        output: Option<PathBuf>,
667    },
668    /// Validate authoring source against the canonical workflow contract.
669    Validate { input: PathBuf },
670    /// Run static workflow diagnostics without starting a browser.
671    Lint {
672        input: PathBuf,
673        #[arg(long)]
674        warnings_as_errors: bool,
675    },
676    /// List or initialize one of the reviewable workflow starter templates.
677    Templates {
678        /// Optional template name; omit to list available templates.
679        name: Option<String>,
680        #[arg(short, long)]
681        output: Option<PathBuf>,
682    },
683    /// Initialize one of the five reviewable issue 29 starter templates.
684    Init {
685        /// Template name: search, form-submit, paginated-extraction,
686        /// authenticated-session, or dialog-and-download.
687        name: String,
688        #[arg(short, long)]
689        output: Option<PathBuf>,
690    },
691}
692
693#[derive(Debug, Subcommand)]
694pub enum TaskCommand {
695    /// Validate strict Task Protocol JSON without starting Chrome.
696    Validate {
697        /// JSON file containing the authored task.
698        input: PathBuf,
699    },
700    /// Compile strict Task Protocol JSON into a deterministic execution plan.
701    Compile {
702        /// JSON file containing the authored task.
703        input: PathBuf,
704        /// Optional output file for the canonical execution plan.
705        #[arg(short, long)]
706        output: Option<PathBuf>,
707        /// Print a deterministic compilation explanation to stderr.
708        #[arg(long)]
709        explain: bool,
710    },
711}
712
713#[derive(Debug, Subcommand)]
714pub enum IrCommand {
715    /// Validate one Web IR draft without starting Chrome.
716    Validate { input: PathBuf },
717    /// Print a bounded summary of one validated Web IR draft.
718    Inspect { input: PathBuf },
719    /// Compute a deterministic diff between two validated Web IR drafts.
720    Diff {
721        before: PathBuf,
722        after: PathBuf,
723        /// Print the bounded canonical summary instead of detailed changes.
724        #[arg(long)]
725        summary: bool,
726    },
727    /// Classify one entity's continuity across two validated Web IR drafts.
728    Continuity {
729        before: PathBuf,
730        after: PathBuf,
731        entity_id: String,
732    },
733    /// Print one validated Web IR draft in canonical JSON form.
734    Canonical { input: PathBuf },
735}
736
737#[derive(Debug, Subcommand)]
738pub enum DaemonCommand {
739    /// Start the daemon in the background.
740    Start {
741        #[arg(long)]
742        socket: Option<PathBuf>,
743        #[arg(long)]
744        status: Option<PathBuf>,
745    },
746    /// Read the daemon status contract.
747    Status {
748        #[arg(long)]
749        socket: Option<PathBuf>,
750        #[arg(long)]
751        status: Option<PathBuf>,
752    },
753    /// Stop the daemon recorded by the status contract.
754    Stop {
755        #[arg(long)]
756        socket: Option<PathBuf>,
757        #[arg(long)]
758        status: Option<PathBuf>,
759    },
760    /// Check the daemon process, status, and local socket.
761    Doctor {
762        #[arg(long)]
763        socket: Option<PathBuf>,
764        #[arg(long)]
765        status: Option<PathBuf>,
766    },
767    /// Read the bounded local daemon log tail.
768    Logs {
769        #[arg(long)]
770        status: Option<PathBuf>,
771    },
772    /// Acknowledge that interrupted workflows were reconciled from checkpoints.
773    AcknowledgeRecovery {
774        #[arg(long)]
775        status: Option<PathBuf>,
776        /// Request ID for every recovery record reconciled from a checkpoint.
777        #[arg(long = "request-id", required = true)]
778        request_ids: Vec<String>,
779    },
780    /// Internal foreground server used by `daemon start`.
781    #[command(hide = true)]
782    Serve {
783        #[arg(long)]
784        socket: PathBuf,
785        #[arg(long)]
786        status: PathBuf,
787    },
788}
789
790#[derive(Debug, Subcommand)]
791pub enum CertifyCommand {
792    /// Run one scenario in a navigated browser fixture and emit evidence.
793    Run {
794        /// JSON scenario to execute.
795        #[arg(long)]
796        scenario: PathBuf,
797        /// JSON fixture manifest used to bind controls and faults.
798        #[arg(long)]
799        fixture: PathBuf,
800        /// Fixture URL to navigate before execution.
801        #[arg(long)]
802        url: String,
803        /// Directory containing workflow sources referenced by the scenario.
804        #[arg(long, default_value = ".")]
805        workflow_root: PathBuf,
806        /// Optional JSON object containing declared workflow inputs.
807        #[arg(long)]
808        inputs: Option<PathBuf>,
809        /// Optional path for the redacted evidence bundle.
810        #[arg(short, long)]
811        output: Option<PathBuf>,
812    },
813    /// Expand a scenario into its manifest-bound execution plan.
814    Plan {
815        /// JSON scenario to plan.
816        #[arg(long)]
817        scenario: PathBuf,
818        /// JSON fixture manifest used to bind controls and faults.
819        #[arg(long)]
820        fixture: PathBuf,
821    },
822    /// Evaluate a release-blocking reliability gate.
823    Release {
824        #[arg(long)]
825        version: String,
826        /// JSON array of validated reliability scenarios.
827        #[arg(long)]
828        scenarios: PathBuf,
829        /// JSON array of scenario observations and oracle evidence.
830        #[arg(long)]
831        observations: PathBuf,
832        /// Optional JSON array of redacted replay bundles to cross-check.
833        #[arg(long)]
834        replays: Option<PathBuf>,
835    },
836    /// Validate one redacted replay bundle against its versioned scenario.
837    Replay {
838        /// JSON scenario used to validate the replay binding.
839        #[arg(long)]
840        scenario: PathBuf,
841        /// JSON replay bundle to validate.
842        #[arg(long)]
843        input: PathBuf,
844    },
845    /// Compare two redacted replay bundles for one scenario.
846    ReplayDiff {
847        /// JSON scenario used to validate both replay bindings.
848        #[arg(long)]
849        scenario: PathBuf,
850        /// Baseline replay bundle.
851        #[arg(long)]
852        before: PathBuf,
853        /// Candidate replay bundle.
854        #[arg(long)]
855        after: PathBuf,
856    },
857}
858
859#[derive(Debug, Subcommand)]
860pub enum CheckpointCommand {
861    Export,
862    Import { input: Option<PathBuf> },
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    #[test]
870    fn observation_and_human_interaction_are_defaults() {
871        let cli = Cli::try_parse_from(["glass", "observe"]).unwrap();
872
873        assert_eq!(cli.interaction, InteractionMode::Human);
874        assert!(matches!(
875            cli.command,
876            Some(Commands::Observe {
877                deep_dom: false,
878                screenshot: false,
879                form_values: false,
880                semantic_level: None,
881                region: None,
882            })
883        ));
884    }
885
886    #[test]
887    fn screenshot_and_fast_interaction_require_explicit_flags() {
888        let cli =
889            Cli::try_parse_from(["glass", "--interaction", "fast", "observe", "--screenshot"])
890                .unwrap();
891
892        assert_eq!(cli.interaction, InteractionMode::Fast);
893        assert!(matches!(
894            cli.command,
895            Some(Commands::Observe {
896                deep_dom: false,
897                screenshot: true,
898                form_values: false,
899                semantic_level: None,
900                region: None,
901            })
902        ));
903    }
904
905    #[test]
906    fn deep_dom_requires_an_explicit_observation_flag() {
907        let cli = Cli::try_parse_from(["glass", "observe", "--deep-dom"]).unwrap();
908
909        assert!(matches!(
910            cli.command,
911            Some(Commands::Observe {
912                deep_dom: true,
913                screenshot: false,
914                form_values: false,
915                semantic_level: None,
916                region: None,
917            })
918        ));
919    }
920
921    #[test]
922    fn semantic_observation_level_and_region_are_explicit() {
923        let cli = Cli::try_parse_from([
924            "glass",
925            "observe",
926            "--level",
927            "interactive",
928            "--region",
929            "region_main",
930        ])
931        .unwrap();
932        assert!(matches!(
933            cli.command,
934            Some(Commands::Observe {
935                semantic_level: Some(level),
936                region: Some(region),
937                ..
938            }) if level == "interactive" && region == "region_main"
939        ));
940
941        assert!(Cli::try_parse_from(["glass", "observe", "--level", "verbose"]).is_err());
942    }
943
944    #[test]
945    fn screenshot_remains_a_separate_explicit_command() {
946        let cli = Cli::try_parse_from(["glass", "screenshot", "--output", "page.png"]).unwrap();
947
948        assert!(matches!(
949            cli.command,
950            Some(Commands::Screenshot { output, .. }) if output == "page.png"
951        ));
952    }
953
954    #[test]
955    fn double_click_is_an_explicit_action_command() {
956        let cli = Cli::try_parse_from(["glass", "double-click", "r7:b42"]).unwrap();
957
958        assert!(matches!(
959            cli.command,
960            Some(Commands::DoubleClick { target, .. }) if target == "r7:b42"
961        ));
962    }
963
964    #[test]
965    fn click_expect_popup_is_an_explicit_action_command() {
966        let cli = Cli::try_parse_from(["glass", "click-expect-popup", "css=#popup"]).unwrap();
967        assert!(matches!(
968            cli.command,
969            Some(Commands::ClickExpectPopup { target, .. }) if target == "css=#popup"
970        ));
971    }
972
973    #[test]
974    fn wait_has_an_explicit_condition_and_bounded_default() {
975        let cli = Cli::try_parse_from(["glass", "wait", "text=Ready"]).unwrap();
976        assert!(matches!(
977            cli.command,
978            Some(Commands::Wait { condition, timeout_ms: 10_000 }) if condition == "text=Ready"
979        ));
980    }
981
982    #[test]
983    fn topology_commands_are_explicit() {
984        assert!(matches!(
985            Cli::try_parse_from(["glass", "targets"]).unwrap().command,
986            Some(Commands::Targets)
987        ));
988        let cli = Cli::try_parse_from([
989            "glass",
990            "--target-id",
991            "page-1",
992            "--frame-id",
993            "frame-1",
994            "evaluate",
995            "document.title",
996        ])
997        .unwrap();
998        assert_eq!(cli.target_id.as_deref(), Some("page-1"));
999        assert_eq!(cli.frame_id.as_deref(), Some("frame-1"));
1000        assert!(matches!(
1001            Cli::try_parse_from(["glass", "select-frame", "frame-1"])
1002                .unwrap()
1003                .command,
1004            Some(Commands::SelectFrame { id }) if id == "frame-1"
1005        ));
1006    }
1007
1008    #[test]
1009    fn complete_input_commands_are_explicit() {
1010        assert!(matches!(
1011            Cli::try_parse_from(["glass", "drag", "css=#from", "css=#to"])
1012                .unwrap()
1013                .command,
1014            Some(Commands::Drag { source, destination, .. }) if source == "css=#from" && destination == "css=#to"
1015        ));
1016        assert!(matches!(
1017            Cli::try_parse_from(["glass", "shortcut", "Control+A"])
1018                .unwrap()
1019                .command,
1020            Some(Commands::Shortcut { shortcut, .. }) if shortcut == "Control+A"
1021        ));
1022        assert!(matches!(
1023            Cli::try_parse_from([
1024                "glass",
1025                "fill-form",
1026                "--fields",
1027                "[]",
1028                "--expected-revision",
1029                "7"
1030            ])
1031            .unwrap()
1032            .command,
1033            Some(Commands::FillForm {
1034                fields,
1035                expected_revision: Some(7)
1036            }) if fields == "[]"
1037        ));
1038    }
1039
1040    #[test]
1041    fn rejects_unknown_interaction_modes() {
1042        assert!(Cli::try_parse_from(["glass", "--interaction", "instant", "observe"]).is_err());
1043    }
1044
1045    #[test]
1046    fn attach_and_target_id_are_explicit_global_options() {
1047        let cli = Cli::try_parse_from([
1048            "glass",
1049            "--attach",
1050            "--port",
1051            "9333",
1052            "--target-id",
1053            "page-2",
1054            "observe",
1055        ])
1056        .unwrap();
1057
1058        assert!(cli.attach);
1059        assert_eq!(cli.port, 9333);
1060        assert_eq!(cli.target_id.as_deref(), Some("page-2"));
1061    }
1062
1063    #[test]
1064    fn workflow_command_accepts_optional_json_input() {
1065        let cli = Cli::try_parse_from(["glass", "workflow", "workflow.json"]).unwrap();
1066        assert!(matches!(
1067            cli.command,
1068            Some(Commands::Workflow {
1069                action: None,
1070                input: Some(path)
1071            })
1072                if path.as_os_str() == "workflow.json"
1073        ));
1074        let cli = Cli::try_parse_from(["glass", "workflow", "validate", "workflow.yaml"]).unwrap();
1075        assert!(matches!(
1076            cli.command,
1077            Some(Commands::Workflow {
1078                action: Some(WorkflowAuthoringCommand::Validate { input }),
1079                input: None,
1080            }) if input.as_os_str() == "workflow.yaml"
1081        ));
1082        let cli = Cli::try_parse_from(["glass", "workflow", "preview", "workflow.yaml"]).unwrap();
1083        assert!(matches!(
1084            cli.command,
1085            Some(Commands::Workflow {
1086                action: Some(WorkflowAuthoringCommand::Preview { input }),
1087                input: None,
1088            }) if input.as_os_str() == "workflow.yaml"
1089        ));
1090        let cli = Cli::try_parse_from([
1091            "glass",
1092            "workflow",
1093            "record",
1094            "--input",
1095            "events.json",
1096            "--output",
1097            "draft.json",
1098        ])
1099        .unwrap();
1100        assert!(matches!(
1101            cli.command,
1102            Some(Commands::Workflow {
1103                action: Some(WorkflowAuthoringCommand::Record { input: Some(input), output: Some(output) }),
1104                input: None,
1105            }) if input.as_os_str() == "events.json" && output.as_os_str() == "draft.json"
1106        ));
1107    }
1108
1109    #[test]
1110    fn certify_release_command_accepts_versioned_evidence_paths() {
1111        let cli = Cli::try_parse_from([
1112            "glass",
1113            "certify",
1114            "release",
1115            "--version",
1116            "0.2.0",
1117            "--scenarios",
1118            "scenarios.json",
1119            "--observations",
1120            "observations.json",
1121        ])
1122        .unwrap();
1123        assert!(matches!(
1124            cli.command,
1125            Some(Commands::Certify {
1126                action: CertifyCommand::Release {
1127                    version,
1128                    scenarios,
1129                    observations,
1130                    replays: None,
1131                },
1132            }) if version == "0.2.0"
1133                && scenarios.as_os_str() == "scenarios.json"
1134                && observations.as_os_str() == "observations.json"
1135        ));
1136    }
1137
1138    #[test]
1139    fn certify_plan_command_accepts_scenario_and_fixture_paths() {
1140        let cli = Cli::try_parse_from([
1141            "glass",
1142            "certify",
1143            "plan",
1144            "--scenario",
1145            "scenario.json",
1146            "--fixture",
1147            "fixture.json",
1148        ])
1149        .unwrap();
1150        assert!(matches!(
1151            cli.command,
1152            Some(Commands::Certify {
1153                action: CertifyCommand::Plan { scenario, fixture },
1154            }) if scenario.as_os_str() == "scenario.json" && fixture.as_os_str() == "fixture.json"
1155        ));
1156    }
1157
1158    #[test]
1159    fn certify_replay_command_accepts_scenario_and_bundle_paths() {
1160        let cli = Cli::try_parse_from([
1161            "glass",
1162            "certify",
1163            "replay",
1164            "--scenario",
1165            "scenario.json",
1166            "--input",
1167            "replay.json",
1168        ])
1169        .unwrap();
1170        assert!(matches!(
1171            cli.command,
1172            Some(Commands::Certify {
1173                action: CertifyCommand::Replay { scenario, input },
1174            }) if scenario.as_os_str() == "scenario.json" && input.as_os_str() == "replay.json"
1175        ));
1176    }
1177
1178    #[test]
1179    fn certify_replay_diff_command_accepts_two_bundle_paths() {
1180        let cli = Cli::try_parse_from([
1181            "glass",
1182            "certify",
1183            "replay-diff",
1184            "--scenario",
1185            "scenario.json",
1186            "--before",
1187            "before.json",
1188            "--after",
1189            "after.json",
1190        ])
1191        .unwrap();
1192        assert!(matches!(
1193            cli.command,
1194            Some(Commands::Certify {
1195                action: CertifyCommand::ReplayDiff { scenario, before, after },
1196            }) if scenario.as_os_str() == "scenario.json"
1197                && before.as_os_str() == "before.json"
1198                && after.as_os_str() == "after.json"
1199        ));
1200    }
1201
1202    #[test]
1203    fn workflow_resume_command_accepts_checkpoint_and_inputs() {
1204        let cli = Cli::try_parse_from([
1205            "glass",
1206            "workflow-resume",
1207            "workflow.json",
1208            "checkpoint.json",
1209            "--inputs",
1210            "inputs.json",
1211        ])
1212        .unwrap();
1213        assert!(matches!(
1214            cli.command,
1215            Some(Commands::WorkflowResume {
1216                workflow,
1217                checkpoint,
1218                inputs: Some(inputs)
1219            }) if workflow.as_os_str() == "workflow.json"
1220                && checkpoint.as_os_str() == "checkpoint.json"
1221                && inputs.as_os_str() == "inputs.json"
1222        ));
1223    }
1224
1225    #[test]
1226    fn resolve_intent_command_accepts_optional_json_input() {
1227        let cli = Cli::try_parse_from(["glass", "resolve-intent", "intent.json"]).unwrap();
1228        assert!(matches!(
1229            cli.command,
1230            Some(Commands::ResolveIntent { input: Some(path) })
1231                if path.as_os_str() == "intent.json"
1232        ));
1233        assert!(Cli::try_parse_from(["glass", "resolve-intent"]).is_ok());
1234    }
1235
1236    #[test]
1237    fn execute_intent_command_accepts_optional_json_input() {
1238        let cli = Cli::try_parse_from(["glass", "execute-intent", "intent.json"]).unwrap();
1239        assert!(matches!(
1240            cli.command,
1241            Some(Commands::ExecuteIntent { input: Some(path) })
1242                if path.as_os_str() == "intent.json"
1243        ));
1244        assert!(Cli::try_parse_from(["glass", "execute-intent"]).is_ok());
1245    }
1246
1247    #[test]
1248    fn reliability_run_command_requires_fixture_url_and_sources() {
1249        let cli = Cli::try_parse_from([
1250            "glass",
1251            "certify",
1252            "run",
1253            "--scenario",
1254            "scenario.json",
1255            "--fixture",
1256            "fixture.json",
1257            "--url",
1258            "http://127.0.0.1:8000/fixture.html",
1259            "--workflow-root",
1260            "fixtures",
1261            "--inputs",
1262            "inputs.json",
1263            "--output",
1264            "evidence.json",
1265        ])
1266        .unwrap();
1267        assert!(matches!(
1268            cli.command,
1269            Some(Commands::Certify {
1270                action: CertifyCommand::Run {
1271                    scenario,
1272                    fixture,
1273                    url,
1274                    workflow_root,
1275                    inputs: Some(inputs),
1276                    output: Some(output),
1277                }
1278            }) if scenario.as_os_str() == "scenario.json"
1279                && fixture.as_os_str() == "fixture.json"
1280                && url == "http://127.0.0.1:8000/fixture.html"
1281                && workflow_root.as_os_str() == "fixtures"
1282                && inputs.as_os_str() == "inputs.json"
1283                && output.as_os_str() == "evidence.json"
1284        ));
1285    }
1286
1287    #[test]
1288    fn capabilities_command_is_explicitly_offline() {
1289        let cli = Cli::try_parse_from(["glass", "capabilities"]).unwrap();
1290        assert!(matches!(cli.command, Some(Commands::Capabilities)));
1291    }
1292
1293    #[test]
1294    fn experimental_extensions_require_an_explicit_global_opt_in() {
1295        let cli =
1296            Cli::try_parse_from(["glass", "--experimental-extensions", "capabilities"]).unwrap();
1297        assert!(cli.experimental_extensions);
1298    }
1299
1300    #[test]
1301    fn daemon_lifecycle_commands_accept_explicit_local_paths() {
1302        let cli = Cli::try_parse_from([
1303            "glass",
1304            "daemon",
1305            "start",
1306            "--socket",
1307            "/tmp/glass.sock",
1308            "--status",
1309            "/tmp/glass.json",
1310        ])
1311        .unwrap();
1312        assert!(matches!(
1313            cli.command,
1314            Some(Commands::Daemon {
1315                action: DaemonCommand::Start { socket: Some(socket), status: Some(status) }
1316            }) if socket.as_os_str() == "/tmp/glass.sock"
1317                && status.as_os_str() == "/tmp/glass.json"
1318        ));
1319    }
1320
1321    #[test]
1322    fn doctor_command_is_available_without_starting_a_browser() {
1323        let cli = Cli::try_parse_from(["glass", "doctor"]).unwrap();
1324        assert!(matches!(
1325            cli.command,
1326            Some(Commands::Doctor { json: false })
1327        ));
1328    }
1329
1330    #[test]
1331    fn knowledge_management_commands_parse_without_browser_startup() {
1332        let cli = Cli::try_parse_from(["glass", "knowledge", "list"]).unwrap();
1333        assert!(matches!(
1334            cli.command,
1335            Some(Commands::Knowledge {
1336                action: KnowledgeCommand::List
1337            })
1338        ));
1339        let cli = Cli::try_parse_from(["glass", "knowledge", "explain", "record-1"]).unwrap();
1340        assert!(matches!(
1341            cli.command,
1342            Some(Commands::Knowledge {
1343                action: KnowledgeCommand::Explain { .. }
1344            })
1345        ));
1346        let cli = Cli::try_parse_from([
1347            "glass",
1348            "--knowledge-store",
1349            "knowledge.json",
1350            "knowledge",
1351            "invalidate",
1352            "record-1",
1353            "stale",
1354        ])
1355        .unwrap();
1356        assert!(matches!(
1357            cli.command,
1358            Some(Commands::Knowledge {
1359                action: KnowledgeCommand::Invalidate { .. }
1360            })
1361        ));
1362    }
1363    #[test]
1364    fn task_compile_command_is_explicitly_offline() {
1365        use clap::CommandFactory;
1366
1367        let cli = Cli::try_parse_from([
1368            "glass",
1369            "task",
1370            "compile",
1371            "task.json",
1372            "--output",
1373            "plan.json",
1374            "--explain",
1375        ])
1376        .unwrap();
1377        assert!(Cli::command().find_subcommand("task").is_some());
1378        assert!(matches!(
1379            cli.command,
1380            Some(Commands::Task {
1381                action: TaskCommand::Compile {
1382                    input,
1383                    output: Some(output),
1384                    explain
1385                }
1386            }) if input.as_os_str() == "task.json"
1387                && output.as_os_str() == "plan.json"
1388                && explain
1389        ));
1390    }
1391    #[test]
1392    fn task_validate_command_is_explicitly_offline() {
1393        let cli = Cli::try_parse_from(["glass", "task", "validate", "task.json"]).unwrap();
1394        assert!(matches!(
1395            cli.command,
1396            Some(Commands::Task {
1397                action: TaskCommand::Validate { input }
1398            }) if input.as_os_str() == "task.json"
1399        ));
1400    }
1401    #[test]
1402    fn ir_commands_are_explicitly_offline() {
1403        let cli = Cli::try_parse_from(["glass", "ir", "inspect", "draft.json"]).unwrap();
1404        assert!(matches!(
1405            cli.command,
1406            Some(Commands::Ir {
1407                action: IrCommand::Inspect { input }
1408            }) if input.as_os_str() == "draft.json"
1409        ));
1410
1411        let cli =
1412            Cli::try_parse_from(["glass", "ir", "diff", "before.json", "after.json"]).unwrap();
1413        assert!(matches!(
1414            cli.command,
1415            Some(Commands::Ir {
1416                action: IrCommand::Diff {
1417                    before,
1418                    after,
1419                    summary
1420                }
1421            }) if before.as_os_str() == "before.json"
1422                && after.as_os_str() == "after.json"
1423                && !summary
1424        ));
1425
1426        let cli = Cli::try_parse_from([
1427            "glass",
1428            "ir",
1429            "diff",
1430            "before.json",
1431            "after.json",
1432            "--summary",
1433        ])
1434        .unwrap();
1435        assert!(matches!(
1436            cli.command,
1437            Some(Commands::Ir {
1438                action: IrCommand::Diff {
1439                    before,
1440                    after,
1441                    summary
1442                }
1443            }) if before.as_os_str() == "before.json"
1444                && after.as_os_str() == "after.json"
1445                && summary
1446        ));
1447
1448        let cli = Cli::try_parse_from([
1449            "glass",
1450            "ir",
1451            "continuity",
1452            "before.json",
1453            "after.json",
1454            "field-1",
1455        ])
1456        .unwrap();
1457        assert!(matches!(
1458            cli.command,
1459            Some(Commands::Ir {
1460                action: IrCommand::Continuity {
1461                    before,
1462                    after,
1463                    entity_id
1464                }
1465            }) if before.as_os_str() == "before.json"
1466                && after.as_os_str() == "after.json"
1467                && entity_id == "field-1"
1468        ));
1469
1470        let cli = Cli::try_parse_from(["glass", "ir", "validate", "draft.json"]).unwrap();
1471        assert!(matches!(
1472            cli.command,
1473            Some(Commands::Ir {
1474                action: IrCommand::Validate { input }
1475            }) if input.as_os_str() == "draft.json"
1476        ));
1477
1478        let cli = Cli::try_parse_from(["glass", "ir", "canonical", "draft.json"]).unwrap();
1479        assert!(matches!(
1480            cli.command,
1481            Some(Commands::Ir {
1482                action: IrCommand::Canonical { input }
1483            }) if input.as_os_str() == "draft.json"
1484        ));
1485    }
1486}