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 { before: PathBuf, after: PathBuf },
721    /// Classify one entity's continuity across two validated Web IR drafts.
722    Continuity {
723        before: PathBuf,
724        after: PathBuf,
725        entity_id: String,
726    },
727    /// Print one validated Web IR draft in canonical JSON form.
728    Canonical { input: PathBuf },
729}
730
731#[derive(Debug, Subcommand)]
732pub enum DaemonCommand {
733    /// Start the daemon in the background.
734    Start {
735        #[arg(long)]
736        socket: Option<PathBuf>,
737        #[arg(long)]
738        status: Option<PathBuf>,
739    },
740    /// Read the daemon status contract.
741    Status {
742        #[arg(long)]
743        socket: Option<PathBuf>,
744        #[arg(long)]
745        status: Option<PathBuf>,
746    },
747    /// Stop the daemon recorded by the status contract.
748    Stop {
749        #[arg(long)]
750        socket: Option<PathBuf>,
751        #[arg(long)]
752        status: Option<PathBuf>,
753    },
754    /// Check the daemon process, status, and local socket.
755    Doctor {
756        #[arg(long)]
757        socket: Option<PathBuf>,
758        #[arg(long)]
759        status: Option<PathBuf>,
760    },
761    /// Read the bounded local daemon log tail.
762    Logs {
763        #[arg(long)]
764        status: Option<PathBuf>,
765    },
766    /// Acknowledge that interrupted workflows were reconciled from checkpoints.
767    AcknowledgeRecovery {
768        #[arg(long)]
769        status: Option<PathBuf>,
770        /// Request ID for every recovery record reconciled from a checkpoint.
771        #[arg(long = "request-id", required = true)]
772        request_ids: Vec<String>,
773    },
774    /// Internal foreground server used by `daemon start`.
775    #[command(hide = true)]
776    Serve {
777        #[arg(long)]
778        socket: PathBuf,
779        #[arg(long)]
780        status: PathBuf,
781    },
782}
783
784#[derive(Debug, Subcommand)]
785pub enum CertifyCommand {
786    /// Run one scenario in a navigated browser fixture and emit evidence.
787    Run {
788        /// JSON scenario to execute.
789        #[arg(long)]
790        scenario: PathBuf,
791        /// JSON fixture manifest used to bind controls and faults.
792        #[arg(long)]
793        fixture: PathBuf,
794        /// Fixture URL to navigate before execution.
795        #[arg(long)]
796        url: String,
797        /// Directory containing workflow sources referenced by the scenario.
798        #[arg(long, default_value = ".")]
799        workflow_root: PathBuf,
800        /// Optional JSON object containing declared workflow inputs.
801        #[arg(long)]
802        inputs: Option<PathBuf>,
803        /// Optional path for the redacted evidence bundle.
804        #[arg(short, long)]
805        output: Option<PathBuf>,
806    },
807    /// Expand a scenario into its manifest-bound execution plan.
808    Plan {
809        /// JSON scenario to plan.
810        #[arg(long)]
811        scenario: PathBuf,
812        /// JSON fixture manifest used to bind controls and faults.
813        #[arg(long)]
814        fixture: PathBuf,
815    },
816    /// Evaluate a release-blocking reliability gate.
817    Release {
818        #[arg(long)]
819        version: String,
820        /// JSON array of validated reliability scenarios.
821        #[arg(long)]
822        scenarios: PathBuf,
823        /// JSON array of scenario observations and oracle evidence.
824        #[arg(long)]
825        observations: PathBuf,
826        /// Optional JSON array of redacted replay bundles to cross-check.
827        #[arg(long)]
828        replays: Option<PathBuf>,
829    },
830    /// Validate one redacted replay bundle against its versioned scenario.
831    Replay {
832        /// JSON scenario used to validate the replay binding.
833        #[arg(long)]
834        scenario: PathBuf,
835        /// JSON replay bundle to validate.
836        #[arg(long)]
837        input: PathBuf,
838    },
839    /// Compare two redacted replay bundles for one scenario.
840    ReplayDiff {
841        /// JSON scenario used to validate both replay bindings.
842        #[arg(long)]
843        scenario: PathBuf,
844        /// Baseline replay bundle.
845        #[arg(long)]
846        before: PathBuf,
847        /// Candidate replay bundle.
848        #[arg(long)]
849        after: PathBuf,
850    },
851}
852
853#[derive(Debug, Subcommand)]
854pub enum CheckpointCommand {
855    Export,
856    Import { input: Option<PathBuf> },
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    #[test]
864    fn observation_and_human_interaction_are_defaults() {
865        let cli = Cli::try_parse_from(["glass", "observe"]).unwrap();
866
867        assert_eq!(cli.interaction, InteractionMode::Human);
868        assert!(matches!(
869            cli.command,
870            Some(Commands::Observe {
871                deep_dom: false,
872                screenshot: false,
873                form_values: false,
874                semantic_level: None,
875                region: None,
876            })
877        ));
878    }
879
880    #[test]
881    fn screenshot_and_fast_interaction_require_explicit_flags() {
882        let cli =
883            Cli::try_parse_from(["glass", "--interaction", "fast", "observe", "--screenshot"])
884                .unwrap();
885
886        assert_eq!(cli.interaction, InteractionMode::Fast);
887        assert!(matches!(
888            cli.command,
889            Some(Commands::Observe {
890                deep_dom: false,
891                screenshot: true,
892                form_values: false,
893                semantic_level: None,
894                region: None,
895            })
896        ));
897    }
898
899    #[test]
900    fn deep_dom_requires_an_explicit_observation_flag() {
901        let cli = Cli::try_parse_from(["glass", "observe", "--deep-dom"]).unwrap();
902
903        assert!(matches!(
904            cli.command,
905            Some(Commands::Observe {
906                deep_dom: true,
907                screenshot: false,
908                form_values: false,
909                semantic_level: None,
910                region: None,
911            })
912        ));
913    }
914
915    #[test]
916    fn semantic_observation_level_and_region_are_explicit() {
917        let cli = Cli::try_parse_from([
918            "glass",
919            "observe",
920            "--level",
921            "interactive",
922            "--region",
923            "region_main",
924        ])
925        .unwrap();
926        assert!(matches!(
927            cli.command,
928            Some(Commands::Observe {
929                semantic_level: Some(level),
930                region: Some(region),
931                ..
932            }) if level == "interactive" && region == "region_main"
933        ));
934
935        assert!(Cli::try_parse_from(["glass", "observe", "--level", "verbose"]).is_err());
936    }
937
938    #[test]
939    fn screenshot_remains_a_separate_explicit_command() {
940        let cli = Cli::try_parse_from(["glass", "screenshot", "--output", "page.png"]).unwrap();
941
942        assert!(matches!(
943            cli.command,
944            Some(Commands::Screenshot { output, .. }) if output == "page.png"
945        ));
946    }
947
948    #[test]
949    fn double_click_is_an_explicit_action_command() {
950        let cli = Cli::try_parse_from(["glass", "double-click", "r7:b42"]).unwrap();
951
952        assert!(matches!(
953            cli.command,
954            Some(Commands::DoubleClick { target, .. }) if target == "r7:b42"
955        ));
956    }
957
958    #[test]
959    fn click_expect_popup_is_an_explicit_action_command() {
960        let cli = Cli::try_parse_from(["glass", "click-expect-popup", "css=#popup"]).unwrap();
961        assert!(matches!(
962            cli.command,
963            Some(Commands::ClickExpectPopup { target, .. }) if target == "css=#popup"
964        ));
965    }
966
967    #[test]
968    fn wait_has_an_explicit_condition_and_bounded_default() {
969        let cli = Cli::try_parse_from(["glass", "wait", "text=Ready"]).unwrap();
970        assert!(matches!(
971            cli.command,
972            Some(Commands::Wait { condition, timeout_ms: 10_000 }) if condition == "text=Ready"
973        ));
974    }
975
976    #[test]
977    fn topology_commands_are_explicit() {
978        assert!(matches!(
979            Cli::try_parse_from(["glass", "targets"]).unwrap().command,
980            Some(Commands::Targets)
981        ));
982        let cli = Cli::try_parse_from([
983            "glass",
984            "--target-id",
985            "page-1",
986            "--frame-id",
987            "frame-1",
988            "evaluate",
989            "document.title",
990        ])
991        .unwrap();
992        assert_eq!(cli.target_id.as_deref(), Some("page-1"));
993        assert_eq!(cli.frame_id.as_deref(), Some("frame-1"));
994        assert!(matches!(
995            Cli::try_parse_from(["glass", "select-frame", "frame-1"])
996                .unwrap()
997                .command,
998            Some(Commands::SelectFrame { id }) if id == "frame-1"
999        ));
1000    }
1001
1002    #[test]
1003    fn complete_input_commands_are_explicit() {
1004        assert!(matches!(
1005            Cli::try_parse_from(["glass", "drag", "css=#from", "css=#to"])
1006                .unwrap()
1007                .command,
1008            Some(Commands::Drag { source, destination, .. }) if source == "css=#from" && destination == "css=#to"
1009        ));
1010        assert!(matches!(
1011            Cli::try_parse_from(["glass", "shortcut", "Control+A"])
1012                .unwrap()
1013                .command,
1014            Some(Commands::Shortcut { shortcut, .. }) if shortcut == "Control+A"
1015        ));
1016        assert!(matches!(
1017            Cli::try_parse_from([
1018                "glass",
1019                "fill-form",
1020                "--fields",
1021                "[]",
1022                "--expected-revision",
1023                "7"
1024            ])
1025            .unwrap()
1026            .command,
1027            Some(Commands::FillForm {
1028                fields,
1029                expected_revision: Some(7)
1030            }) if fields == "[]"
1031        ));
1032    }
1033
1034    #[test]
1035    fn rejects_unknown_interaction_modes() {
1036        assert!(Cli::try_parse_from(["glass", "--interaction", "instant", "observe"]).is_err());
1037    }
1038
1039    #[test]
1040    fn attach_and_target_id_are_explicit_global_options() {
1041        let cli = Cli::try_parse_from([
1042            "glass",
1043            "--attach",
1044            "--port",
1045            "9333",
1046            "--target-id",
1047            "page-2",
1048            "observe",
1049        ])
1050        .unwrap();
1051
1052        assert!(cli.attach);
1053        assert_eq!(cli.port, 9333);
1054        assert_eq!(cli.target_id.as_deref(), Some("page-2"));
1055    }
1056
1057    #[test]
1058    fn workflow_command_accepts_optional_json_input() {
1059        let cli = Cli::try_parse_from(["glass", "workflow", "workflow.json"]).unwrap();
1060        assert!(matches!(
1061            cli.command,
1062            Some(Commands::Workflow {
1063                action: None,
1064                input: Some(path)
1065            })
1066                if path.as_os_str() == "workflow.json"
1067        ));
1068        let cli = Cli::try_parse_from(["glass", "workflow", "validate", "workflow.yaml"]).unwrap();
1069        assert!(matches!(
1070            cli.command,
1071            Some(Commands::Workflow {
1072                action: Some(WorkflowAuthoringCommand::Validate { input }),
1073                input: None,
1074            }) if input.as_os_str() == "workflow.yaml"
1075        ));
1076        let cli = Cli::try_parse_from(["glass", "workflow", "preview", "workflow.yaml"]).unwrap();
1077        assert!(matches!(
1078            cli.command,
1079            Some(Commands::Workflow {
1080                action: Some(WorkflowAuthoringCommand::Preview { input }),
1081                input: None,
1082            }) if input.as_os_str() == "workflow.yaml"
1083        ));
1084        let cli = Cli::try_parse_from([
1085            "glass",
1086            "workflow",
1087            "record",
1088            "--input",
1089            "events.json",
1090            "--output",
1091            "draft.json",
1092        ])
1093        .unwrap();
1094        assert!(matches!(
1095            cli.command,
1096            Some(Commands::Workflow {
1097                action: Some(WorkflowAuthoringCommand::Record { input: Some(input), output: Some(output) }),
1098                input: None,
1099            }) if input.as_os_str() == "events.json" && output.as_os_str() == "draft.json"
1100        ));
1101    }
1102
1103    #[test]
1104    fn certify_release_command_accepts_versioned_evidence_paths() {
1105        let cli = Cli::try_parse_from([
1106            "glass",
1107            "certify",
1108            "release",
1109            "--version",
1110            "0.2.0",
1111            "--scenarios",
1112            "scenarios.json",
1113            "--observations",
1114            "observations.json",
1115        ])
1116        .unwrap();
1117        assert!(matches!(
1118            cli.command,
1119            Some(Commands::Certify {
1120                action: CertifyCommand::Release {
1121                    version,
1122                    scenarios,
1123                    observations,
1124                    replays: None,
1125                },
1126            }) if version == "0.2.0"
1127                && scenarios.as_os_str() == "scenarios.json"
1128                && observations.as_os_str() == "observations.json"
1129        ));
1130    }
1131
1132    #[test]
1133    fn certify_plan_command_accepts_scenario_and_fixture_paths() {
1134        let cli = Cli::try_parse_from([
1135            "glass",
1136            "certify",
1137            "plan",
1138            "--scenario",
1139            "scenario.json",
1140            "--fixture",
1141            "fixture.json",
1142        ])
1143        .unwrap();
1144        assert!(matches!(
1145            cli.command,
1146            Some(Commands::Certify {
1147                action: CertifyCommand::Plan { scenario, fixture },
1148            }) if scenario.as_os_str() == "scenario.json" && fixture.as_os_str() == "fixture.json"
1149        ));
1150    }
1151
1152    #[test]
1153    fn certify_replay_command_accepts_scenario_and_bundle_paths() {
1154        let cli = Cli::try_parse_from([
1155            "glass",
1156            "certify",
1157            "replay",
1158            "--scenario",
1159            "scenario.json",
1160            "--input",
1161            "replay.json",
1162        ])
1163        .unwrap();
1164        assert!(matches!(
1165            cli.command,
1166            Some(Commands::Certify {
1167                action: CertifyCommand::Replay { scenario, input },
1168            }) if scenario.as_os_str() == "scenario.json" && input.as_os_str() == "replay.json"
1169        ));
1170    }
1171
1172    #[test]
1173    fn certify_replay_diff_command_accepts_two_bundle_paths() {
1174        let cli = Cli::try_parse_from([
1175            "glass",
1176            "certify",
1177            "replay-diff",
1178            "--scenario",
1179            "scenario.json",
1180            "--before",
1181            "before.json",
1182            "--after",
1183            "after.json",
1184        ])
1185        .unwrap();
1186        assert!(matches!(
1187            cli.command,
1188            Some(Commands::Certify {
1189                action: CertifyCommand::ReplayDiff { scenario, before, after },
1190            }) if scenario.as_os_str() == "scenario.json"
1191                && before.as_os_str() == "before.json"
1192                && after.as_os_str() == "after.json"
1193        ));
1194    }
1195
1196    #[test]
1197    fn workflow_resume_command_accepts_checkpoint_and_inputs() {
1198        let cli = Cli::try_parse_from([
1199            "glass",
1200            "workflow-resume",
1201            "workflow.json",
1202            "checkpoint.json",
1203            "--inputs",
1204            "inputs.json",
1205        ])
1206        .unwrap();
1207        assert!(matches!(
1208            cli.command,
1209            Some(Commands::WorkflowResume {
1210                workflow,
1211                checkpoint,
1212                inputs: Some(inputs)
1213            }) if workflow.as_os_str() == "workflow.json"
1214                && checkpoint.as_os_str() == "checkpoint.json"
1215                && inputs.as_os_str() == "inputs.json"
1216        ));
1217    }
1218
1219    #[test]
1220    fn resolve_intent_command_accepts_optional_json_input() {
1221        let cli = Cli::try_parse_from(["glass", "resolve-intent", "intent.json"]).unwrap();
1222        assert!(matches!(
1223            cli.command,
1224            Some(Commands::ResolveIntent { input: Some(path) })
1225                if path.as_os_str() == "intent.json"
1226        ));
1227        assert!(Cli::try_parse_from(["glass", "resolve-intent"]).is_ok());
1228    }
1229
1230    #[test]
1231    fn execute_intent_command_accepts_optional_json_input() {
1232        let cli = Cli::try_parse_from(["glass", "execute-intent", "intent.json"]).unwrap();
1233        assert!(matches!(
1234            cli.command,
1235            Some(Commands::ExecuteIntent { input: Some(path) })
1236                if path.as_os_str() == "intent.json"
1237        ));
1238        assert!(Cli::try_parse_from(["glass", "execute-intent"]).is_ok());
1239    }
1240
1241    #[test]
1242    fn reliability_run_command_requires_fixture_url_and_sources() {
1243        let cli = Cli::try_parse_from([
1244            "glass",
1245            "certify",
1246            "run",
1247            "--scenario",
1248            "scenario.json",
1249            "--fixture",
1250            "fixture.json",
1251            "--url",
1252            "http://127.0.0.1:8000/fixture.html",
1253            "--workflow-root",
1254            "fixtures",
1255            "--inputs",
1256            "inputs.json",
1257            "--output",
1258            "evidence.json",
1259        ])
1260        .unwrap();
1261        assert!(matches!(
1262            cli.command,
1263            Some(Commands::Certify {
1264                action: CertifyCommand::Run {
1265                    scenario,
1266                    fixture,
1267                    url,
1268                    workflow_root,
1269                    inputs: Some(inputs),
1270                    output: Some(output),
1271                }
1272            }) if scenario.as_os_str() == "scenario.json"
1273                && fixture.as_os_str() == "fixture.json"
1274                && url == "http://127.0.0.1:8000/fixture.html"
1275                && workflow_root.as_os_str() == "fixtures"
1276                && inputs.as_os_str() == "inputs.json"
1277                && output.as_os_str() == "evidence.json"
1278        ));
1279    }
1280
1281    #[test]
1282    fn capabilities_command_is_explicitly_offline() {
1283        let cli = Cli::try_parse_from(["glass", "capabilities"]).unwrap();
1284        assert!(matches!(cli.command, Some(Commands::Capabilities)));
1285    }
1286
1287    #[test]
1288    fn experimental_extensions_require_an_explicit_global_opt_in() {
1289        let cli =
1290            Cli::try_parse_from(["glass", "--experimental-extensions", "capabilities"]).unwrap();
1291        assert!(cli.experimental_extensions);
1292    }
1293
1294    #[test]
1295    fn daemon_lifecycle_commands_accept_explicit_local_paths() {
1296        let cli = Cli::try_parse_from([
1297            "glass",
1298            "daemon",
1299            "start",
1300            "--socket",
1301            "/tmp/glass.sock",
1302            "--status",
1303            "/tmp/glass.json",
1304        ])
1305        .unwrap();
1306        assert!(matches!(
1307            cli.command,
1308            Some(Commands::Daemon {
1309                action: DaemonCommand::Start { socket: Some(socket), status: Some(status) }
1310            }) if socket.as_os_str() == "/tmp/glass.sock"
1311                && status.as_os_str() == "/tmp/glass.json"
1312        ));
1313    }
1314
1315    #[test]
1316    fn doctor_command_is_available_without_starting_a_browser() {
1317        let cli = Cli::try_parse_from(["glass", "doctor"]).unwrap();
1318        assert!(matches!(
1319            cli.command,
1320            Some(Commands::Doctor { json: false })
1321        ));
1322    }
1323
1324    #[test]
1325    fn knowledge_management_commands_parse_without_browser_startup() {
1326        let cli = Cli::try_parse_from(["glass", "knowledge", "list"]).unwrap();
1327        assert!(matches!(
1328            cli.command,
1329            Some(Commands::Knowledge {
1330                action: KnowledgeCommand::List
1331            })
1332        ));
1333        let cli = Cli::try_parse_from(["glass", "knowledge", "explain", "record-1"]).unwrap();
1334        assert!(matches!(
1335            cli.command,
1336            Some(Commands::Knowledge {
1337                action: KnowledgeCommand::Explain { .. }
1338            })
1339        ));
1340        let cli = Cli::try_parse_from([
1341            "glass",
1342            "--knowledge-store",
1343            "knowledge.json",
1344            "knowledge",
1345            "invalidate",
1346            "record-1",
1347            "stale",
1348        ])
1349        .unwrap();
1350        assert!(matches!(
1351            cli.command,
1352            Some(Commands::Knowledge {
1353                action: KnowledgeCommand::Invalidate { .. }
1354            })
1355        ));
1356    }
1357    #[test]
1358    fn task_compile_command_is_explicitly_offline() {
1359        use clap::CommandFactory;
1360
1361        let cli = Cli::try_parse_from([
1362            "glass",
1363            "task",
1364            "compile",
1365            "task.json",
1366            "--output",
1367            "plan.json",
1368            "--explain",
1369        ])
1370        .unwrap();
1371        assert!(Cli::command().find_subcommand("task").is_some());
1372        assert!(matches!(
1373            cli.command,
1374            Some(Commands::Task {
1375                action: TaskCommand::Compile {
1376                    input,
1377                    output: Some(output),
1378                    explain
1379                }
1380            }) if input.as_os_str() == "task.json"
1381                && output.as_os_str() == "plan.json"
1382                && explain
1383        ));
1384    }
1385    #[test]
1386    fn task_validate_command_is_explicitly_offline() {
1387        let cli = Cli::try_parse_from(["glass", "task", "validate", "task.json"]).unwrap();
1388        assert!(matches!(
1389            cli.command,
1390            Some(Commands::Task {
1391                action: TaskCommand::Validate { input }
1392            }) if input.as_os_str() == "task.json"
1393        ));
1394    }
1395    #[test]
1396    fn ir_commands_are_explicitly_offline() {
1397        let cli = Cli::try_parse_from(["glass", "ir", "inspect", "draft.json"]).unwrap();
1398        assert!(matches!(
1399            cli.command,
1400            Some(Commands::Ir {
1401                action: IrCommand::Inspect { input }
1402            }) if input.as_os_str() == "draft.json"
1403        ));
1404
1405        let cli =
1406            Cli::try_parse_from(["glass", "ir", "diff", "before.json", "after.json"]).unwrap();
1407        assert!(matches!(
1408            cli.command,
1409            Some(Commands::Ir {
1410                action: IrCommand::Diff { before, after }
1411            }) if before.as_os_str() == "before.json" && after.as_os_str() == "after.json"
1412        ));
1413
1414        let cli = Cli::try_parse_from([
1415            "glass",
1416            "ir",
1417            "continuity",
1418            "before.json",
1419            "after.json",
1420            "field-1",
1421        ])
1422        .unwrap();
1423        assert!(matches!(
1424            cli.command,
1425            Some(Commands::Ir {
1426                action: IrCommand::Continuity {
1427                    before,
1428                    after,
1429                    entity_id
1430                }
1431            }) if before.as_os_str() == "before.json"
1432                && after.as_os_str() == "after.json"
1433                && entity_id == "field-1"
1434        ));
1435
1436        let cli = Cli::try_parse_from(["glass", "ir", "validate", "draft.json"]).unwrap();
1437        assert!(matches!(
1438            cli.command,
1439            Some(Commands::Ir {
1440                action: IrCommand::Validate { input }
1441            }) if input.as_os_str() == "draft.json"
1442        ));
1443
1444        let cli = Cli::try_parse_from(["glass", "ir", "canonical", "draft.json"]).unwrap();
1445        assert!(matches!(
1446            cli.command,
1447            Some(Commands::Ir {
1448                action: IrCommand::Canonical { input }
1449            }) if input.as_os_str() == "draft.json"
1450        ));
1451    }
1452}