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 Task {
489 #[command(subcommand)]
490 action: TaskCommand,
491 },
492
493 Ir {
495 #[command(subcommand)]
496 action: IrCommand,
497 },
498
499 WorkflowResume {
501 workflow: PathBuf,
503 checkpoint: PathBuf,
505 #[arg(long)]
507 inputs: Option<PathBuf>,
508 },
509
510 ResolveIntent {
512 input: Option<PathBuf>,
514 },
515
516 ExecuteIntent {
518 input: Option<PathBuf>,
520 },
521
522 Verify {
524 predicate: String,
526 #[arg(long, default_value_t = 10_000)]
527 timeout_ms: u64,
528 },
529
530 ReconcileRefs {
532 #[arg(long)]
533 from_revision: u64,
534 #[arg(long = "hint")]
536 hints: Vec<String>,
537 #[arg(long)]
539 scope: Option<String>,
540 #[arg(required = true)]
541 refs: Vec<String>,
542 },
543
544 ObserveDelta,
546
547 Checkpoint {
549 #[command(subcommand)]
550 action: CheckpointCommand,
551 },
552
553 Snapshot {
555 #[command(subcommand)]
556 action: SnapshotCommand,
557 },
558
559 ClipboardRead,
561
562 ClipboardWrite { text: String },
564
565 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,
587 Show { record_id: String },
589 Explain { record_id: String },
591 Stats,
593 Export { output: Option<PathBuf> },
595 Import { input: PathBuf },
597 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 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 {
625 result_id: String,
626 #[arg(long)]
627 section: Option<String>,
628 },
629 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 {
646 input: PathBuf,
647 #[arg(short, long)]
648 output: Option<PathBuf>,
649 },
650 Format {
652 input: PathBuf,
653 #[arg(short, long)]
654 output: Option<PathBuf>,
655 },
656 Preview { input: PathBuf },
658 Diff { before: PathBuf, after: PathBuf },
660 Record {
662 #[arg(long)]
664 input: Option<PathBuf>,
665 #[arg(short, long)]
666 output: Option<PathBuf>,
667 },
668 Validate { input: PathBuf },
670 Lint {
672 input: PathBuf,
673 #[arg(long)]
674 warnings_as_errors: bool,
675 },
676 Templates {
678 name: Option<String>,
680 #[arg(short, long)]
681 output: Option<PathBuf>,
682 },
683 Init {
685 name: String,
688 #[arg(short, long)]
689 output: Option<PathBuf>,
690 },
691}
692
693#[derive(Debug, Subcommand)]
694pub enum TaskCommand {
695 Validate {
697 input: PathBuf,
699 },
700 Compile {
702 input: PathBuf,
704 #[arg(short, long)]
706 output: Option<PathBuf>,
707 #[arg(long)]
709 explain: bool,
710 },
711}
712
713#[derive(Debug, Subcommand)]
714pub enum IrCommand {
715 Validate { input: PathBuf },
717 Inspect { input: PathBuf },
719 Diff {
721 before: PathBuf,
722 after: PathBuf,
723 #[arg(long)]
725 summary: bool,
726 },
727 Continuity {
729 before: PathBuf,
730 after: PathBuf,
731 entity_id: String,
732 },
733 Canonical { input: PathBuf },
735}
736
737#[derive(Debug, Subcommand)]
738pub enum DaemonCommand {
739 Start {
741 #[arg(long)]
742 socket: Option<PathBuf>,
743 #[arg(long)]
744 status: Option<PathBuf>,
745 },
746 Status {
748 #[arg(long)]
749 socket: Option<PathBuf>,
750 #[arg(long)]
751 status: Option<PathBuf>,
752 },
753 Stop {
755 #[arg(long)]
756 socket: Option<PathBuf>,
757 #[arg(long)]
758 status: Option<PathBuf>,
759 },
760 Doctor {
762 #[arg(long)]
763 socket: Option<PathBuf>,
764 #[arg(long)]
765 status: Option<PathBuf>,
766 },
767 Logs {
769 #[arg(long)]
770 status: Option<PathBuf>,
771 },
772 AcknowledgeRecovery {
774 #[arg(long)]
775 status: Option<PathBuf>,
776 #[arg(long = "request-id", required = true)]
778 request_ids: Vec<String>,
779 },
780 #[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 {
794 #[arg(long)]
796 scenario: PathBuf,
797 #[arg(long)]
799 fixture: PathBuf,
800 #[arg(long)]
802 url: String,
803 #[arg(long, default_value = ".")]
805 workflow_root: PathBuf,
806 #[arg(long)]
808 inputs: Option<PathBuf>,
809 #[arg(short, long)]
811 output: Option<PathBuf>,
812 },
813 Plan {
815 #[arg(long)]
817 scenario: PathBuf,
818 #[arg(long)]
820 fixture: PathBuf,
821 },
822 Release {
824 #[arg(long)]
825 version: String,
826 #[arg(long)]
828 scenarios: PathBuf,
829 #[arg(long)]
831 observations: PathBuf,
832 #[arg(long)]
834 replays: Option<PathBuf>,
835 },
836 Replay {
838 #[arg(long)]
840 scenario: PathBuf,
841 #[arg(long)]
843 input: PathBuf,
844 },
845 ReplayDiff {
847 #[arg(long)]
849 scenario: PathBuf,
850 #[arg(long)]
852 before: PathBuf,
853 #[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}