1use 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#[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 #[arg(long, global = true, value_enum, default_value_t = PolicyPreset::Development)]
27 pub policy: PolicyPreset,
28
29 #[arg(long = "policy-allow", global = true, value_enum)]
31 pub policy_allow: Vec<PolicyCapability>,
32
33 #[arg(long = "policy-confirm", global = true, value_enum)]
35 pub policy_confirm: Vec<PolicyCapability>,
36
37 #[arg(long = "policy-confirm-once", global = true, value_enum)]
39 pub policy_confirm_once: Vec<PolicyCapability>,
40
41 #[arg(long, global = true)]
43 pub experimental_extensions: bool,
44
45 #[arg(long = "policy-allow-host", global = true)]
47 pub policy_allow_host: Vec<String>,
48
49 #[arg(long = "policy-deny-host", global = true)]
51 pub policy_deny_host: Vec<String>,
52
53 #[arg(long, global = true, default_value = "default")]
55 pub profile: String,
56
57 #[arg(long, global = true)]
59 pub incognito: bool,
60
61 #[arg(long, global = true)]
64 pub attach: bool,
65
66 #[arg(long = "target-id", global = true)]
69 pub target_id: Option<String>,
70
71 #[arg(long = "frame-id", global = true)]
73 pub frame_id: Option<String>,
74
75 #[arg(long, global = true, default_value_t = 9222)]
77 pub port: u16,
78
79 #[arg(long, global = true)]
81 pub headed: bool,
82
83 #[arg(long, global = true, value_enum, default_value_t = InteractionMode::Human)]
85 pub interaction: InteractionMode,
86
87 #[arg(long, global = true)]
89 pub audit: bool,
90
91 #[arg(long, global = true)]
93 pub trace_on_error: bool,
94
95 #[arg(long = "chrome-path", alias = "chrome", global = true)]
97 pub chrome_path: Option<PathBuf>,
98
99 #[arg(long)]
101 pub mcp: bool,
102
103 #[arg(long, global = true)]
105 pub knowledge_store: Option<PathBuf>,
106
107 #[arg(long, global = true, value_enum, default_value_t = ResponseMode::Minimal)]
109 pub response_mode: ResponseMode,
110
111 #[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 InstallChromium {
130 #[arg(long)]
132 update: bool,
133 },
134
135 Certify {
137 #[command(subcommand)]
138 action: CertifyCommand,
139 },
140
141 Capabilities,
143
144 Daemon {
146 #[command(subcommand)]
147 action: DaemonCommand,
148 },
149
150 Doctor {
152 #[arg(long)]
154 json: bool,
155 },
156
157 McpConfig {
159 #[arg(long, value_enum, default_value_t = McpClient::Generic)]
160 client: McpClient,
161 #[arg(long)]
163 print: bool,
164 },
165
166 Profiles {
168 #[command(subcommand)]
169 action: Option<ProfileCommand>,
170 },
171
172 Knowledge {
174 #[command(subcommand)]
175 action: KnowledgeCommand,
176 },
177
178 Result {
180 #[command(subcommand)]
181 action: ResultCommand,
182 },
183
184 DeleteProfile { name: String },
186
187 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 {
198 target: String,
199 #[arg(long)]
200 expected_revision: Option<u64>,
201 },
202
203 Preflight {
205 target: String,
206 #[arg(long, value_enum, default_value_t = PreflightAction::Click)]
207 action: PreflightAction,
208 },
209
210 ClickAt { x: f64, y: f64 },
212
213 ClickExpectPopup {
215 target: String,
216 #[arg(long)]
217 expected_revision: Option<u64>,
218 },
219
220 DoubleClick {
222 target: String,
223 #[arg(long)]
224 expected_revision: Option<u64>,
225 },
226
227 Hover { target: String },
229
230 Drag {
232 source: String,
233 destination: String,
234 #[arg(long)]
235 expected_revision: Option<u64>,
236 },
237
238 Type {
240 text: String,
241 #[arg(long)]
242 target: Option<String>,
243 #[arg(long)]
244 expected_revision: Option<u64>,
245 },
246
247 Key {
249 key: String,
250 #[arg(long)]
251 expected_revision: Option<u64>,
252 },
253
254 KeyDown {
256 key: String,
257 #[arg(long)]
258 expected_revision: Option<u64>,
259 },
260
261 KeyUp {
263 key: String,
264 #[arg(long)]
265 expected_revision: Option<u64>,
266 },
267
268 Shortcut {
270 shortcut: String,
271 #[arg(long)]
272 expected_revision: Option<u64>,
273 },
274
275 Clear {
277 target: String,
278 #[arg(long)]
279 expected_revision: Option<u64>,
280 },
281
282 Check {
284 target: String,
285 #[arg(long)]
286 expected_revision: Option<u64>,
287 },
288
289 Uncheck {
291 target: String,
292 #[arg(long)]
293 expected_revision: Option<u64>,
294 },
295
296 Select {
298 target: String,
299 value: String,
300 #[arg(long)]
301 expected_revision: Option<u64>,
302 },
303
304 Upload {
306 target: String,
307 #[arg(required = true)]
308 files: Vec<PathBuf>,
309 #[arg(long)]
310 expected_revision: Option<u64>,
311 },
312
313 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 Text,
333
334 Dom,
336
337 Observe {
339 #[arg(long)]
341 deep_dom: bool,
342 #[arg(long)]
344 screenshot: bool,
345 #[arg(long)]
347 form_values: bool,
348 #[arg(long = "level", alias = "semantic-level", value_parser = parse_semantic_level)]
350 semantic_level: Option<String>,
351 #[arg(long, requires = "semantic_level")]
353 region: Option<String>,
354 },
355
356 InspectPage,
358
359 FindTarget { input: PathBuf },
361
362 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 ExtractStructured { input: PathBuf },
373
374 RecoverRun { execution_id: String },
376
377 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 {
389 condition: String,
390 #[arg(long, default_value_t = 10_000)]
391 timeout_ms: u64,
392 },
393
394 Diagnostics {
396 #[arg(long, default_value_t = 1_000)]
397 duration_ms: u64,
398 },
399
400 AcceptDialog,
402
403 DismissDialog,
405
406 DismissConsent,
408
409 Download {
411 destination: PathBuf,
412 #[arg(long, default_value_t = 30_000)]
413 timeout_ms: u64,
414 },
415
416 Targets,
418
419 NewTarget { url: String },
421
422 SelectTarget { id: String },
424
425 CloseTarget { id: String },
427
428 Frames,
430
431 SelectFrame { id: String },
433
434 Evaluate { expression: String },
436
437 Cookies,
439
440 ExportCookies { output: PathBuf },
442
443 ImportCookies { input: PathBuf },
445
446 Pdf {
448 #[arg(short, long, default_value = "page.pdf")]
449 output: String,
450 #[arg(long)]
451 background: bool,
452 },
453
454 FillForm {
456 #[arg(long)]
458 fields: String,
459 #[arg(long)]
461 expected_revision: Option<u64>,
462 },
463
464 Batch {
466 input: Option<PathBuf>,
468 #[arg(long)]
469 atomic: bool,
470 #[arg(long, value_enum, default_value_t = BatchMode::Unguarded)]
472 mode: BatchMode,
473 #[arg(long)]
475 expected_revision: Option<u64>,
476 },
477
478 #[command(subcommand_precedence_over_arg = true)]
479 Workflow {
480 #[command(subcommand)]
482 action: Option<WorkflowAuthoringCommand>,
483 input: Option<PathBuf>,
485 },
486
487 WorkflowResume {
489 workflow: PathBuf,
491 checkpoint: PathBuf,
493 #[arg(long)]
495 inputs: Option<PathBuf>,
496 },
497
498 ResolveIntent {
500 input: Option<PathBuf>,
502 },
503
504 ExecuteIntent {
506 input: Option<PathBuf>,
508 },
509
510 Verify {
512 predicate: String,
514 #[arg(long, default_value_t = 10_000)]
515 timeout_ms: u64,
516 },
517
518 ReconcileRefs {
520 #[arg(long)]
521 from_revision: u64,
522 #[arg(long = "hint")]
524 hints: Vec<String>,
525 #[arg(long)]
527 scope: Option<String>,
528 #[arg(required = true)]
529 refs: Vec<String>,
530 },
531
532 ObserveDelta,
534
535 Checkpoint {
537 #[command(subcommand)]
538 action: CheckpointCommand,
539 },
540
541 Snapshot {
543 #[command(subcommand)]
544 action: SnapshotCommand,
545 },
546
547 ClipboardRead,
549
550 ClipboardWrite { text: String },
552
553 Tui,
555}
556
557fn parse_semantic_level(value: &str) -> Result<String, String> {
558 match value {
559 "summary" | "interactive" | "structured" | "detailed" | "raw" => Ok(value.into()),
560 _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
561 }
562}
563
564#[derive(Debug, Subcommand)]
565pub enum ProfileCommand {
566 List,
567 Create { name: String },
568 Delete { name: String },
569}
570
571#[derive(Debug, Subcommand)]
572pub enum KnowledgeCommand {
573 List,
575 Show { record_id: String },
577 Explain { record_id: String },
579 Stats,
581 Export { output: Option<PathBuf> },
583 Import { input: PathBuf },
585 Invalidate {
587 record_id: String,
588 #[arg(value_enum)]
589 state: KnowledgeInvalidationState,
590 #[arg(long)]
591 reason: Option<String>,
592
593 #[arg(long)]
594 observed_at: Option<String>,
595 },
596 Purge { origin: String },
598}
599
600#[derive(Debug, Subcommand)]
601pub enum SnapshotCommand {
602 Create,
603 List,
604 Inspect { snapshot_id: String },
605 Diff { from: String, to: String },
606 Purge,
607}
608
609#[derive(Debug, Subcommand)]
610pub enum ResultCommand {
611 Show {
613 result_id: String,
614 #[arg(long)]
615 section: Option<String>,
616 },
617 Purge {
619 #[arg(long = "older-than")]
620 older_than: String,
621 },
622}
623#[derive(Debug, Clone, Copy, ValueEnum)]
624pub enum KnowledgeInvalidationState {
625 Stale,
626 Contradicted,
627 Quarantined,
628}
629
630#[derive(Debug, Subcommand)]
631pub enum WorkflowAuthoringCommand {
632 Compile {
634 input: PathBuf,
635 #[arg(short, long)]
636 output: Option<PathBuf>,
637 },
638 Format {
640 input: PathBuf,
641 #[arg(short, long)]
642 output: Option<PathBuf>,
643 },
644 Preview { input: PathBuf },
646 Diff { before: PathBuf, after: PathBuf },
648 Record {
650 #[arg(long)]
652 input: Option<PathBuf>,
653 #[arg(short, long)]
654 output: Option<PathBuf>,
655 },
656 Validate { input: PathBuf },
658 Lint {
660 input: PathBuf,
661 #[arg(long)]
662 warnings_as_errors: bool,
663 },
664 Templates {
666 name: Option<String>,
668 #[arg(short, long)]
669 output: Option<PathBuf>,
670 },
671 Init {
673 name: String,
676 #[arg(short, long)]
677 output: Option<PathBuf>,
678 },
679}
680
681#[derive(Debug, Subcommand)]
682pub enum DaemonCommand {
683 Start {
685 #[arg(long)]
686 socket: Option<PathBuf>,
687 #[arg(long)]
688 status: Option<PathBuf>,
689 },
690 Status {
692 #[arg(long)]
693 socket: Option<PathBuf>,
694 #[arg(long)]
695 status: Option<PathBuf>,
696 },
697 Stop {
699 #[arg(long)]
700 socket: Option<PathBuf>,
701 #[arg(long)]
702 status: Option<PathBuf>,
703 },
704 Doctor {
706 #[arg(long)]
707 socket: Option<PathBuf>,
708 #[arg(long)]
709 status: Option<PathBuf>,
710 },
711 Logs {
713 #[arg(long)]
714 status: Option<PathBuf>,
715 },
716 AcknowledgeRecovery {
718 #[arg(long)]
719 status: Option<PathBuf>,
720 #[arg(long = "request-id", required = true)]
722 request_ids: Vec<String>,
723 },
724 #[command(hide = true)]
726 Serve {
727 #[arg(long)]
728 socket: PathBuf,
729 #[arg(long)]
730 status: PathBuf,
731 },
732}
733
734#[derive(Debug, Subcommand)]
735pub enum CertifyCommand {
736 Run {
738 #[arg(long)]
740 scenario: PathBuf,
741 #[arg(long)]
743 fixture: PathBuf,
744 #[arg(long)]
746 url: String,
747 #[arg(long, default_value = ".")]
749 workflow_root: PathBuf,
750 #[arg(long)]
752 inputs: Option<PathBuf>,
753 #[arg(short, long)]
755 output: Option<PathBuf>,
756 },
757 Plan {
759 #[arg(long)]
761 scenario: PathBuf,
762 #[arg(long)]
764 fixture: PathBuf,
765 },
766 Release {
768 #[arg(long)]
769 version: String,
770 #[arg(long)]
772 scenarios: PathBuf,
773 #[arg(long)]
775 observations: PathBuf,
776 #[arg(long)]
778 replays: Option<PathBuf>,
779 },
780 Replay {
782 #[arg(long)]
784 scenario: PathBuf,
785 #[arg(long)]
787 input: PathBuf,
788 },
789 ReplayDiff {
791 #[arg(long)]
793 scenario: PathBuf,
794 #[arg(long)]
796 before: PathBuf,
797 #[arg(long)]
799 after: PathBuf,
800 },
801}
802
803#[derive(Debug, Subcommand)]
804pub enum CheckpointCommand {
805 Export,
806 Import { input: Option<PathBuf> },
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 #[test]
814 fn observation_and_human_interaction_are_defaults() {
815 let cli = Cli::try_parse_from(["glass", "observe"]).unwrap();
816
817 assert_eq!(cli.interaction, InteractionMode::Human);
818 assert!(matches!(
819 cli.command,
820 Some(Commands::Observe {
821 deep_dom: false,
822 screenshot: false,
823 form_values: false,
824 semantic_level: None,
825 region: None,
826 })
827 ));
828 }
829
830 #[test]
831 fn screenshot_and_fast_interaction_require_explicit_flags() {
832 let cli =
833 Cli::try_parse_from(["glass", "--interaction", "fast", "observe", "--screenshot"])
834 .unwrap();
835
836 assert_eq!(cli.interaction, InteractionMode::Fast);
837 assert!(matches!(
838 cli.command,
839 Some(Commands::Observe {
840 deep_dom: false,
841 screenshot: true,
842 form_values: false,
843 semantic_level: None,
844 region: None,
845 })
846 ));
847 }
848
849 #[test]
850 fn deep_dom_requires_an_explicit_observation_flag() {
851 let cli = Cli::try_parse_from(["glass", "observe", "--deep-dom"]).unwrap();
852
853 assert!(matches!(
854 cli.command,
855 Some(Commands::Observe {
856 deep_dom: true,
857 screenshot: false,
858 form_values: false,
859 semantic_level: None,
860 region: None,
861 })
862 ));
863 }
864
865 #[test]
866 fn semantic_observation_level_and_region_are_explicit() {
867 let cli = Cli::try_parse_from([
868 "glass",
869 "observe",
870 "--level",
871 "interactive",
872 "--region",
873 "region_main",
874 ])
875 .unwrap();
876 assert!(matches!(
877 cli.command,
878 Some(Commands::Observe {
879 semantic_level: Some(level),
880 region: Some(region),
881 ..
882 }) if level == "interactive" && region == "region_main"
883 ));
884
885 assert!(Cli::try_parse_from(["glass", "observe", "--level", "verbose"]).is_err());
886 }
887
888 #[test]
889 fn screenshot_remains_a_separate_explicit_command() {
890 let cli = Cli::try_parse_from(["glass", "screenshot", "--output", "page.png"]).unwrap();
891
892 assert!(matches!(
893 cli.command,
894 Some(Commands::Screenshot { output, .. }) if output == "page.png"
895 ));
896 }
897
898 #[test]
899 fn double_click_is_an_explicit_action_command() {
900 let cli = Cli::try_parse_from(["glass", "double-click", "r7:b42"]).unwrap();
901
902 assert!(matches!(
903 cli.command,
904 Some(Commands::DoubleClick { target, .. }) if target == "r7:b42"
905 ));
906 }
907
908 #[test]
909 fn click_expect_popup_is_an_explicit_action_command() {
910 let cli = Cli::try_parse_from(["glass", "click-expect-popup", "css=#popup"]).unwrap();
911 assert!(matches!(
912 cli.command,
913 Some(Commands::ClickExpectPopup { target, .. }) if target == "css=#popup"
914 ));
915 }
916
917 #[test]
918 fn wait_has_an_explicit_condition_and_bounded_default() {
919 let cli = Cli::try_parse_from(["glass", "wait", "text=Ready"]).unwrap();
920 assert!(matches!(
921 cli.command,
922 Some(Commands::Wait { condition, timeout_ms: 10_000 }) if condition == "text=Ready"
923 ));
924 }
925
926 #[test]
927 fn topology_commands_are_explicit() {
928 assert!(matches!(
929 Cli::try_parse_from(["glass", "targets"]).unwrap().command,
930 Some(Commands::Targets)
931 ));
932 let cli = Cli::try_parse_from([
933 "glass",
934 "--target-id",
935 "page-1",
936 "--frame-id",
937 "frame-1",
938 "evaluate",
939 "document.title",
940 ])
941 .unwrap();
942 assert_eq!(cli.target_id.as_deref(), Some("page-1"));
943 assert_eq!(cli.frame_id.as_deref(), Some("frame-1"));
944 assert!(matches!(
945 Cli::try_parse_from(["glass", "select-frame", "frame-1"])
946 .unwrap()
947 .command,
948 Some(Commands::SelectFrame { id }) if id == "frame-1"
949 ));
950 }
951
952 #[test]
953 fn complete_input_commands_are_explicit() {
954 assert!(matches!(
955 Cli::try_parse_from(["glass", "drag", "css=#from", "css=#to"])
956 .unwrap()
957 .command,
958 Some(Commands::Drag { source, destination, .. }) if source == "css=#from" && destination == "css=#to"
959 ));
960 assert!(matches!(
961 Cli::try_parse_from(["glass", "shortcut", "Control+A"])
962 .unwrap()
963 .command,
964 Some(Commands::Shortcut { shortcut, .. }) if shortcut == "Control+A"
965 ));
966 assert!(matches!(
967 Cli::try_parse_from([
968 "glass",
969 "fill-form",
970 "--fields",
971 "[]",
972 "--expected-revision",
973 "7"
974 ])
975 .unwrap()
976 .command,
977 Some(Commands::FillForm {
978 fields,
979 expected_revision: Some(7)
980 }) if fields == "[]"
981 ));
982 }
983
984 #[test]
985 fn rejects_unknown_interaction_modes() {
986 assert!(Cli::try_parse_from(["glass", "--interaction", "instant", "observe"]).is_err());
987 }
988
989 #[test]
990 fn attach_and_target_id_are_explicit_global_options() {
991 let cli = Cli::try_parse_from([
992 "glass",
993 "--attach",
994 "--port",
995 "9333",
996 "--target-id",
997 "page-2",
998 "observe",
999 ])
1000 .unwrap();
1001
1002 assert!(cli.attach);
1003 assert_eq!(cli.port, 9333);
1004 assert_eq!(cli.target_id.as_deref(), Some("page-2"));
1005 }
1006
1007 #[test]
1008 fn workflow_command_accepts_optional_json_input() {
1009 let cli = Cli::try_parse_from(["glass", "workflow", "workflow.json"]).unwrap();
1010 assert!(matches!(
1011 cli.command,
1012 Some(Commands::Workflow {
1013 action: None,
1014 input: Some(path)
1015 })
1016 if path.as_os_str() == "workflow.json"
1017 ));
1018 let cli = Cli::try_parse_from(["glass", "workflow", "validate", "workflow.yaml"]).unwrap();
1019 assert!(matches!(
1020 cli.command,
1021 Some(Commands::Workflow {
1022 action: Some(WorkflowAuthoringCommand::Validate { input }),
1023 input: None,
1024 }) if input.as_os_str() == "workflow.yaml"
1025 ));
1026 let cli = Cli::try_parse_from(["glass", "workflow", "preview", "workflow.yaml"]).unwrap();
1027 assert!(matches!(
1028 cli.command,
1029 Some(Commands::Workflow {
1030 action: Some(WorkflowAuthoringCommand::Preview { input }),
1031 input: None,
1032 }) if input.as_os_str() == "workflow.yaml"
1033 ));
1034 let cli = Cli::try_parse_from([
1035 "glass",
1036 "workflow",
1037 "record",
1038 "--input",
1039 "events.json",
1040 "--output",
1041 "draft.json",
1042 ])
1043 .unwrap();
1044 assert!(matches!(
1045 cli.command,
1046 Some(Commands::Workflow {
1047 action: Some(WorkflowAuthoringCommand::Record { input: Some(input), output: Some(output) }),
1048 input: None,
1049 }) if input.as_os_str() == "events.json" && output.as_os_str() == "draft.json"
1050 ));
1051 }
1052
1053 #[test]
1054 fn certify_release_command_accepts_versioned_evidence_paths() {
1055 let cli = Cli::try_parse_from([
1056 "glass",
1057 "certify",
1058 "release",
1059 "--version",
1060 "0.2.0",
1061 "--scenarios",
1062 "scenarios.json",
1063 "--observations",
1064 "observations.json",
1065 ])
1066 .unwrap();
1067 assert!(matches!(
1068 cli.command,
1069 Some(Commands::Certify {
1070 action: CertifyCommand::Release {
1071 version,
1072 scenarios,
1073 observations,
1074 replays: None,
1075 },
1076 }) if version == "0.2.0"
1077 && scenarios.as_os_str() == "scenarios.json"
1078 && observations.as_os_str() == "observations.json"
1079 ));
1080 }
1081
1082 #[test]
1083 fn certify_plan_command_accepts_scenario_and_fixture_paths() {
1084 let cli = Cli::try_parse_from([
1085 "glass",
1086 "certify",
1087 "plan",
1088 "--scenario",
1089 "scenario.json",
1090 "--fixture",
1091 "fixture.json",
1092 ])
1093 .unwrap();
1094 assert!(matches!(
1095 cli.command,
1096 Some(Commands::Certify {
1097 action: CertifyCommand::Plan { scenario, fixture },
1098 }) if scenario.as_os_str() == "scenario.json" && fixture.as_os_str() == "fixture.json"
1099 ));
1100 }
1101
1102 #[test]
1103 fn certify_replay_command_accepts_scenario_and_bundle_paths() {
1104 let cli = Cli::try_parse_from([
1105 "glass",
1106 "certify",
1107 "replay",
1108 "--scenario",
1109 "scenario.json",
1110 "--input",
1111 "replay.json",
1112 ])
1113 .unwrap();
1114 assert!(matches!(
1115 cli.command,
1116 Some(Commands::Certify {
1117 action: CertifyCommand::Replay { scenario, input },
1118 }) if scenario.as_os_str() == "scenario.json" && input.as_os_str() == "replay.json"
1119 ));
1120 }
1121
1122 #[test]
1123 fn certify_replay_diff_command_accepts_two_bundle_paths() {
1124 let cli = Cli::try_parse_from([
1125 "glass",
1126 "certify",
1127 "replay-diff",
1128 "--scenario",
1129 "scenario.json",
1130 "--before",
1131 "before.json",
1132 "--after",
1133 "after.json",
1134 ])
1135 .unwrap();
1136 assert!(matches!(
1137 cli.command,
1138 Some(Commands::Certify {
1139 action: CertifyCommand::ReplayDiff { scenario, before, after },
1140 }) if scenario.as_os_str() == "scenario.json"
1141 && before.as_os_str() == "before.json"
1142 && after.as_os_str() == "after.json"
1143 ));
1144 }
1145
1146 #[test]
1147 fn workflow_resume_command_accepts_checkpoint_and_inputs() {
1148 let cli = Cli::try_parse_from([
1149 "glass",
1150 "workflow-resume",
1151 "workflow.json",
1152 "checkpoint.json",
1153 "--inputs",
1154 "inputs.json",
1155 ])
1156 .unwrap();
1157 assert!(matches!(
1158 cli.command,
1159 Some(Commands::WorkflowResume {
1160 workflow,
1161 checkpoint,
1162 inputs: Some(inputs)
1163 }) if workflow.as_os_str() == "workflow.json"
1164 && checkpoint.as_os_str() == "checkpoint.json"
1165 && inputs.as_os_str() == "inputs.json"
1166 ));
1167 }
1168
1169 #[test]
1170 fn resolve_intent_command_accepts_optional_json_input() {
1171 let cli = Cli::try_parse_from(["glass", "resolve-intent", "intent.json"]).unwrap();
1172 assert!(matches!(
1173 cli.command,
1174 Some(Commands::ResolveIntent { input: Some(path) })
1175 if path.as_os_str() == "intent.json"
1176 ));
1177 assert!(Cli::try_parse_from(["glass", "resolve-intent"]).is_ok());
1178 }
1179
1180 #[test]
1181 fn execute_intent_command_accepts_optional_json_input() {
1182 let cli = Cli::try_parse_from(["glass", "execute-intent", "intent.json"]).unwrap();
1183 assert!(matches!(
1184 cli.command,
1185 Some(Commands::ExecuteIntent { input: Some(path) })
1186 if path.as_os_str() == "intent.json"
1187 ));
1188 assert!(Cli::try_parse_from(["glass", "execute-intent"]).is_ok());
1189 }
1190
1191 #[test]
1192 fn reliability_run_command_requires_fixture_url_and_sources() {
1193 let cli = Cli::try_parse_from([
1194 "glass",
1195 "certify",
1196 "run",
1197 "--scenario",
1198 "scenario.json",
1199 "--fixture",
1200 "fixture.json",
1201 "--url",
1202 "http://127.0.0.1:8000/fixture.html",
1203 "--workflow-root",
1204 "fixtures",
1205 "--inputs",
1206 "inputs.json",
1207 "--output",
1208 "evidence.json",
1209 ])
1210 .unwrap();
1211 assert!(matches!(
1212 cli.command,
1213 Some(Commands::Certify {
1214 action: CertifyCommand::Run {
1215 scenario,
1216 fixture,
1217 url,
1218 workflow_root,
1219 inputs: Some(inputs),
1220 output: Some(output),
1221 }
1222 }) if scenario.as_os_str() == "scenario.json"
1223 && fixture.as_os_str() == "fixture.json"
1224 && url == "http://127.0.0.1:8000/fixture.html"
1225 && workflow_root.as_os_str() == "fixtures"
1226 && inputs.as_os_str() == "inputs.json"
1227 && output.as_os_str() == "evidence.json"
1228 ));
1229 }
1230
1231 #[test]
1232 fn capabilities_command_is_explicitly_offline() {
1233 let cli = Cli::try_parse_from(["glass", "capabilities"]).unwrap();
1234 assert!(matches!(cli.command, Some(Commands::Capabilities)));
1235 }
1236
1237 #[test]
1238 fn experimental_extensions_require_an_explicit_global_opt_in() {
1239 let cli =
1240 Cli::try_parse_from(["glass", "--experimental-extensions", "capabilities"]).unwrap();
1241 assert!(cli.experimental_extensions);
1242 }
1243
1244 #[test]
1245 fn daemon_lifecycle_commands_accept_explicit_local_paths() {
1246 let cli = Cli::try_parse_from([
1247 "glass",
1248 "daemon",
1249 "start",
1250 "--socket",
1251 "/tmp/glass.sock",
1252 "--status",
1253 "/tmp/glass.json",
1254 ])
1255 .unwrap();
1256 assert!(matches!(
1257 cli.command,
1258 Some(Commands::Daemon {
1259 action: DaemonCommand::Start { socket: Some(socket), status: Some(status) }
1260 }) if socket.as_os_str() == "/tmp/glass.sock"
1261 && status.as_os_str() == "/tmp/glass.json"
1262 ));
1263 }
1264
1265 #[test]
1266 fn doctor_command_is_available_without_starting_a_browser() {
1267 let cli = Cli::try_parse_from(["glass", "doctor"]).unwrap();
1268 assert!(matches!(
1269 cli.command,
1270 Some(Commands::Doctor { json: false })
1271 ));
1272 }
1273
1274 #[test]
1275 fn knowledge_management_commands_parse_without_browser_startup() {
1276 let cli = Cli::try_parse_from(["glass", "knowledge", "list"]).unwrap();
1277 assert!(matches!(
1278 cli.command,
1279 Some(Commands::Knowledge {
1280 action: KnowledgeCommand::List
1281 })
1282 ));
1283 let cli = Cli::try_parse_from(["glass", "knowledge", "explain", "record-1"]).unwrap();
1284 assert!(matches!(
1285 cli.command,
1286 Some(Commands::Knowledge {
1287 action: KnowledgeCommand::Explain { .. }
1288 })
1289 ));
1290 let cli = Cli::try_parse_from([
1291 "glass",
1292 "--knowledge-store",
1293 "knowledge.json",
1294 "knowledge",
1295 "invalidate",
1296 "record-1",
1297 "stale",
1298 ])
1299 .unwrap();
1300 assert!(matches!(
1301 cli.command,
1302 Some(Commands::Knowledge {
1303 action: KnowledgeCommand::Invalidate { .. }
1304 })
1305 ));
1306 }
1307}