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 { before: PathBuf, after: PathBuf },
721 Continuity {
723 before: PathBuf,
724 after: PathBuf,
725 entity_id: String,
726 },
727 Canonical { input: PathBuf },
729}
730
731#[derive(Debug, Subcommand)]
732pub enum DaemonCommand {
733 Start {
735 #[arg(long)]
736 socket: Option<PathBuf>,
737 #[arg(long)]
738 status: Option<PathBuf>,
739 },
740 Status {
742 #[arg(long)]
743 socket: Option<PathBuf>,
744 #[arg(long)]
745 status: Option<PathBuf>,
746 },
747 Stop {
749 #[arg(long)]
750 socket: Option<PathBuf>,
751 #[arg(long)]
752 status: Option<PathBuf>,
753 },
754 Doctor {
756 #[arg(long)]
757 socket: Option<PathBuf>,
758 #[arg(long)]
759 status: Option<PathBuf>,
760 },
761 Logs {
763 #[arg(long)]
764 status: Option<PathBuf>,
765 },
766 AcknowledgeRecovery {
768 #[arg(long)]
769 status: Option<PathBuf>,
770 #[arg(long = "request-id", required = true)]
772 request_ids: Vec<String>,
773 },
774 #[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 {
788 #[arg(long)]
790 scenario: PathBuf,
791 #[arg(long)]
793 fixture: PathBuf,
794 #[arg(long)]
796 url: String,
797 #[arg(long, default_value = ".")]
799 workflow_root: PathBuf,
800 #[arg(long)]
802 inputs: Option<PathBuf>,
803 #[arg(short, long)]
805 output: Option<PathBuf>,
806 },
807 Plan {
809 #[arg(long)]
811 scenario: PathBuf,
812 #[arg(long)]
814 fixture: PathBuf,
815 },
816 Release {
818 #[arg(long)]
819 version: String,
820 #[arg(long)]
822 scenarios: PathBuf,
823 #[arg(long)]
825 observations: PathBuf,
826 #[arg(long)]
828 replays: Option<PathBuf>,
829 },
830 Replay {
832 #[arg(long)]
834 scenario: PathBuf,
835 #[arg(long)]
837 input: PathBuf,
838 },
839 ReplayDiff {
841 #[arg(long)]
843 scenario: PathBuf,
844 #[arg(long)]
846 before: PathBuf,
847 #[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}