1use std::path::PathBuf;
2
3use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
4
5use crate::model::{AxClickFallbackStage, AxMatchStrategy};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
8pub enum OutputFormat {
9 Text,
10 Json,
11 Tsv,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
15pub enum ErrorFormat {
16 Text,
17 Json,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
21pub enum MouseButton {
22 Left,
23 Right,
24 Middle,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
28pub enum ImageFormat {
29 Png,
30 #[value(alias = "jpeg")]
31 Jpg,
32 Webp,
33}
34
35#[derive(Debug, Clone, Parser)]
36#[command(
37 name = "macos-agent",
38 version,
39 about = "Automate macOS desktop actions for agent workflows.",
40 after_help = "Decision guide (AX-first):\n\
411) Prefer `ax` commands for element-targeted interaction.\n\
422) If AX is flaky, enable fallback flags (`--allow-coordinate-fallback`, `--allow-keyboard-fallback`).\n\
433) If AX is unavailable, use `window activate` + `input` commands.\n\
444) Add `wait` commands around mutating steps for stability.",
45 disable_help_subcommand = true
46)]
47pub struct Cli {
48 #[arg(long, value_enum, default_value_t = OutputFormat::Text, global = true)]
50 pub format: OutputFormat,
51
52 #[arg(long, value_enum, default_value_t = ErrorFormat::Text, global = true)]
54 pub error_format: ErrorFormat,
55
56 #[arg(long, global = true)]
58 pub dry_run: bool,
59
60 #[arg(long, default_value_t = 0, global = true)]
62 pub retries: u8,
63
64 #[arg(long, default_value_t = 150, global = true)]
66 pub retry_delay_ms: u64,
67
68 #[arg(long, default_value_t = 4000, global = true)]
70 pub timeout_ms: u64,
71
72 #[arg(long, global = true)]
74 pub trace: bool,
75
76 #[arg(long, global = true)]
78 pub trace_dir: Option<PathBuf>,
79
80 #[command(subcommand)]
81 pub command: CommandGroup,
82}
83
84#[derive(Debug, Clone, Subcommand)]
85#[allow(clippy::large_enum_variant)]
86pub enum CommandGroup {
87 Preflight(PreflightArgs),
89
90 Windows {
92 #[command(subcommand)]
93 command: WindowsCommand,
94 },
95
96 Apps {
98 #[command(subcommand)]
99 command: AppsCommand,
100 },
101
102 Window {
104 #[command(subcommand)]
105 command: WindowCommand,
106 },
107
108 Input {
110 #[command(subcommand)]
111 command: InputCommand,
112 },
113
114 InputSource {
116 #[command(subcommand)]
117 command: InputSourceCommand,
118 },
119
120 Ax {
122 #[command(subcommand)]
123 command: AxCommand,
124 },
125
126 Observe {
128 #[command(subcommand)]
129 command: ObserveCommand,
130 },
131
132 Debug {
134 #[command(subcommand)]
135 command: DebugCommand,
136 },
137
138 Wait {
140 #[command(subcommand)]
141 command: WaitCommand,
142 },
143
144 Scenario {
146 #[command(subcommand)]
147 command: ScenarioCommand,
148 },
149
150 Profile {
152 #[command(subcommand)]
153 command: ProfileCommand,
154 },
155}
156
157#[derive(Debug, Clone, Args)]
158pub struct PreflightArgs {
159 #[arg(long)]
161 pub strict: bool,
162
163 #[arg(long)]
165 pub include_probes: bool,
166}
167
168#[derive(Debug, Clone, Subcommand)]
169pub enum WindowsCommand {
170 List(ListWindowsArgs),
172}
173
174#[derive(Debug, Clone, Args)]
175pub struct ListWindowsArgs {
176 #[arg(long)]
178 pub app: Option<String>,
179
180 #[arg(
182 long = "window-title-contains",
183 visible_alias = "window-name",
184 requires = "app"
185 )]
186 pub window_name: Option<String>,
187
188 #[arg(long)]
190 pub on_screen_only: bool,
191}
192
193#[derive(Debug, Clone, Subcommand)]
194pub enum AppsCommand {
195 List(ListAppsArgs),
197}
198
199#[derive(Debug, Clone, Args, Default)]
200pub struct ListAppsArgs {}
201
202#[derive(Debug, Clone, Subcommand)]
203pub enum WindowCommand {
204 Activate(WindowActivateArgs),
206}
207
208#[derive(Debug, Clone, Args)]
209#[command(
210 group(
211 ArgGroup::new("selector")
212 .required(true)
213 .multiple(false)
214 .args(["window_id", "active_window", "app", "bundle_id"])
215 )
216)]
217pub struct WindowActivateArgs {
218 #[arg(long)]
220 pub window_id: Option<u32>,
221
222 #[arg(long)]
224 pub active_window: bool,
225
226 #[arg(long)]
228 pub app: Option<String>,
229
230 #[arg(
232 long = "window-title-contains",
233 visible_alias = "window-name",
234 requires = "app"
235 )]
236 pub window_name: Option<String>,
237
238 #[arg(long)]
240 pub bundle_id: Option<String>,
241
242 #[arg(long)]
244 pub wait_ms: Option<u64>,
245
246 #[arg(long, default_value_t = false)]
248 pub reopen_on_fail: bool,
249}
250
251#[derive(Debug, Clone, Subcommand)]
252pub enum InputCommand {
253 Click(InputClickArgs),
255
256 Type(InputTypeArgs),
258
259 Hotkey(InputHotkeyArgs),
261}
262
263#[derive(Debug, Clone, Subcommand)]
264pub enum InputSourceCommand {
265 Current(InputSourceCurrentArgs),
267
268 Switch(InputSourceSwitchArgs),
270}
271
272#[derive(Debug, Clone, Args, Default)]
273pub struct InputSourceCurrentArgs {}
274
275#[derive(Debug, Clone, Args)]
276pub struct InputSourceSwitchArgs {
277 #[arg(long)]
279 pub id: String,
280}
281
282#[derive(Debug, Clone, Subcommand)]
283pub enum AxCommand {
284 List(AxListArgs),
286
287 Click(AxClickArgs),
289
290 Type(AxTypeArgs),
292
293 Attr {
295 #[command(subcommand)]
296 command: AxAttrCommand,
297 },
298
299 Action {
301 #[command(subcommand)]
302 command: AxActionCommand,
303 },
304
305 Session {
307 #[command(subcommand)]
308 command: AxSessionCommand,
309 },
310
311 Watch {
313 #[command(subcommand)]
314 command: AxWatchCommand,
315 },
316}
317
318#[derive(Debug, Clone, Subcommand)]
319pub enum AxAttrCommand {
320 Get(AxAttrGetArgs),
322
323 Set(AxAttrSetArgs),
325}
326
327#[derive(Debug, Clone, Subcommand)]
328pub enum AxActionCommand {
329 Perform(AxActionPerformArgs),
331}
332
333#[derive(Debug, Clone, Subcommand)]
334pub enum AxSessionCommand {
335 Start(AxSessionStartArgs),
337
338 List(AxSessionListArgs),
340
341 Stop(AxSessionStopArgs),
343}
344
345#[derive(Debug, Clone, Subcommand)]
346pub enum AxWatchCommand {
347 Start(AxWatchStartArgs),
349
350 Poll(AxWatchPollArgs),
352
353 Stop(AxWatchStopArgs),
355}
356
357#[derive(Debug, Clone, Args, Default)]
358pub struct AxTargetArgs {
359 #[arg(long)]
361 pub session_id: Option<String>,
362
363 #[arg(long)]
365 pub app: Option<String>,
366
367 #[arg(long)]
369 pub bundle_id: Option<String>,
370
371 #[arg(long)]
373 pub window_title_contains: Option<String>,
374}
375
376#[derive(Debug, Clone, Args, Default)]
377pub struct AxMatchFiltersArgs {
378 #[arg(long)]
380 pub role: Option<String>,
381
382 #[arg(long)]
384 pub title_contains: Option<String>,
385
386 #[arg(long)]
388 pub identifier_contains: Option<String>,
389
390 #[arg(long)]
392 pub value_contains: Option<String>,
393
394 #[arg(long)]
396 pub subrole: Option<String>,
397
398 #[arg(long)]
400 pub focused: Option<bool>,
401
402 #[arg(long)]
404 pub enabled: Option<bool>,
405}
406
407#[derive(Debug, Clone, Args, Default)]
408pub struct AxSelectorArgs {
409 #[arg(
411 long,
412 conflicts_with_all = [
413 "role",
414 "title_contains",
415 "identifier_contains",
416 "value_contains",
417 "subrole",
418 "focused",
419 "enabled",
420 "nth"
421 ]
422 )]
423 pub node_id: Option<String>,
424
425 #[command(flatten)]
426 pub filters: AxMatchFiltersArgs,
427
428 #[arg(long)]
430 pub nth: Option<u32>,
431
432 #[arg(long, value_enum, default_value_t = AxMatchStrategy::Contains)]
434 pub match_strategy: AxMatchStrategy,
435
436 #[arg(long)]
438 pub selector_explain: bool,
439}
440
441#[derive(Debug, Clone, Args)]
442#[command(
443 group(
444 ArgGroup::new("target")
445 .required(false)
446 .multiple(false)
447 .args(["session_id", "app", "bundle_id"])
448 )
449)]
450pub struct AxListArgs {
451 #[command(flatten)]
452 pub target: AxTargetArgs,
453
454 #[command(flatten)]
455 pub filters: AxMatchFiltersArgs,
456
457 #[arg(long)]
459 pub max_depth: Option<u32>,
460
461 #[arg(long)]
463 pub limit: Option<u32>,
464}
465
466#[derive(Debug, Clone, Args)]
467pub struct AxActionGateArgs {
468 #[arg(long)]
470 pub gate_app_active: bool,
471
472 #[arg(long)]
474 pub gate_window_present: bool,
475
476 #[arg(long)]
478 pub gate_ax_present: bool,
479
480 #[arg(long)]
482 pub gate_ax_unique: bool,
483
484 #[arg(long, default_value_t = 1500)]
486 pub gate_timeout_ms: u64,
487
488 #[arg(long, default_value_t = 50)]
490 pub gate_poll_ms: u64,
491}
492
493#[derive(Debug, Clone, Args)]
494pub struct AxPostconditionArgs {
495 #[arg(long)]
497 pub postcondition_focused: Option<bool>,
498
499 #[arg(long, requires = "postcondition_attribute_value")]
501 pub postcondition_attribute: Option<String>,
502
503 #[arg(long, requires = "postcondition_attribute")]
505 pub postcondition_attribute_value: Option<String>,
506
507 #[arg(long, default_value_t = 1500)]
509 pub postcondition_timeout_ms: u64,
510
511 #[arg(long, default_value_t = 50)]
513 pub postcondition_poll_ms: u64,
514}
515
516#[derive(Debug, Clone, Args)]
517#[command(
518 group(
519 ArgGroup::new("selector")
520 .required(true)
521 .multiple(true)
522 .args([
523 "node_id",
524 "role",
525 "title_contains",
526 "identifier_contains",
527 "value_contains",
528 "subrole",
529 "focused",
530 "enabled",
531 ])
532 ),
533 group(
534 ArgGroup::new("target")
535 .required(false)
536 .multiple(false)
537 .args(["session_id", "app", "bundle_id"])
538 )
539)]
540pub struct AxClickArgs {
541 #[command(flatten)]
542 pub selector: AxSelectorArgs,
543
544 #[command(flatten)]
545 pub target: AxTargetArgs,
546
547 #[arg(long)]
549 pub allow_coordinate_fallback: bool,
550
551 #[arg(long)]
553 pub reselect_before_click: bool,
554
555 #[arg(long, value_enum, value_delimiter = ',', num_args = 1.., value_name = "STAGE")]
557 pub fallback_order: Vec<AxClickFallbackStage>,
558
559 #[arg(long)]
561 pub wait_timeout_ms: Option<u64>,
562
563 #[arg(long)]
565 pub wait_poll_ms: Option<u64>,
566
567 #[command(flatten)]
568 pub gate: AxActionGateArgs,
569
570 #[command(flatten)]
571 pub postcondition: AxPostconditionArgs,
572}
573
574#[derive(Debug, Clone, Args)]
575#[command(
576 group(
577 ArgGroup::new("selector")
578 .required(true)
579 .multiple(true)
580 .args([
581 "node_id",
582 "role",
583 "title_contains",
584 "identifier_contains",
585 "value_contains",
586 "subrole",
587 "focused",
588 "enabled",
589 ])
590 ),
591 group(
592 ArgGroup::new("target")
593 .required(false)
594 .multiple(false)
595 .args(["session_id", "app", "bundle_id"])
596 )
597)]
598pub struct AxTypeArgs {
599 #[command(flatten)]
600 pub selector: AxSelectorArgs,
601
602 #[command(flatten)]
603 pub target: AxTargetArgs,
604
605 #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
607 pub text: String,
608
609 #[arg(long)]
611 pub clear_first: bool,
612
613 #[arg(long)]
615 pub submit: bool,
616
617 #[arg(long)]
619 pub paste: bool,
620
621 #[arg(long)]
623 pub allow_keyboard_fallback: bool,
624
625 #[arg(long)]
627 pub wait_timeout_ms: Option<u64>,
628
629 #[arg(long)]
631 pub wait_poll_ms: Option<u64>,
632
633 #[command(flatten)]
634 pub gate: AxActionGateArgs,
635
636 #[command(flatten)]
637 pub postcondition: AxPostconditionArgs,
638}
639
640#[derive(Debug, Clone, Args)]
641#[command(
642 group(
643 ArgGroup::new("selector")
644 .required(true)
645 .multiple(true)
646 .args([
647 "node_id",
648 "role",
649 "title_contains",
650 "identifier_contains",
651 "value_contains",
652 "subrole",
653 "focused",
654 "enabled",
655 ])
656 ),
657 group(
658 ArgGroup::new("target")
659 .required(false)
660 .multiple(false)
661 .args(["session_id", "app", "bundle_id"])
662 )
663)]
664pub struct AxAttrGetArgs {
665 #[command(flatten)]
666 pub selector: AxSelectorArgs,
667
668 #[command(flatten)]
669 pub target: AxTargetArgs,
670
671 #[arg(long)]
673 pub name: String,
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
677pub enum AxValueType {
678 String,
679 Number,
680 Bool,
681 Json,
682 Null,
683}
684
685#[derive(Debug, Clone, Args)]
686#[command(
687 group(
688 ArgGroup::new("selector")
689 .required(true)
690 .multiple(true)
691 .args([
692 "node_id",
693 "role",
694 "title_contains",
695 "identifier_contains",
696 "value_contains",
697 "subrole",
698 "focused",
699 "enabled",
700 ])
701 ),
702 group(
703 ArgGroup::new("target")
704 .required(false)
705 .multiple(false)
706 .args(["session_id", "app", "bundle_id"])
707 )
708)]
709pub struct AxAttrSetArgs {
710 #[command(flatten)]
711 pub selector: AxSelectorArgs,
712
713 #[command(flatten)]
714 pub target: AxTargetArgs,
715
716 #[arg(long)]
718 pub name: String,
719
720 #[arg(long)]
722 pub value: String,
723
724 #[arg(long, value_enum, default_value_t = AxValueType::String)]
726 pub value_type: AxValueType,
727}
728
729#[derive(Debug, Clone, Args)]
730#[command(
731 group(
732 ArgGroup::new("selector")
733 .required(true)
734 .multiple(true)
735 .args([
736 "node_id",
737 "role",
738 "title_contains",
739 "identifier_contains",
740 "value_contains",
741 "subrole",
742 "focused",
743 "enabled",
744 ])
745 ),
746 group(
747 ArgGroup::new("target")
748 .required(false)
749 .multiple(false)
750 .args(["session_id", "app", "bundle_id"])
751 )
752)]
753pub struct AxActionPerformArgs {
754 #[command(flatten)]
755 pub selector: AxSelectorArgs,
756
757 #[command(flatten)]
758 pub target: AxTargetArgs,
759
760 #[arg(long)]
762 pub name: String,
763}
764
765#[derive(Debug, Clone, Args)]
766#[command(
767 group(
768 ArgGroup::new("target")
769 .required(false)
770 .multiple(false)
771 .args(["app", "bundle_id"])
772 )
773)]
774pub struct AxSessionStartArgs {
775 #[arg(long)]
777 pub app: Option<String>,
778
779 #[arg(long)]
781 pub bundle_id: Option<String>,
782
783 #[arg(long)]
785 pub session_id: Option<String>,
786
787 #[arg(long)]
789 pub window_title_contains: Option<String>,
790}
791
792#[derive(Debug, Clone, Args, Default)]
793pub struct AxSessionListArgs {}
794
795#[derive(Debug, Clone, Args)]
796pub struct AxSessionStopArgs {
797 #[arg(long)]
799 pub session_id: String,
800}
801
802#[derive(Debug, Clone, Args)]
803pub struct AxWatchStartArgs {
804 #[arg(long)]
806 pub session_id: String,
807
808 #[arg(long)]
810 pub watch_id: Option<String>,
811
812 #[arg(
814 long,
815 value_delimiter = ',',
816 default_value = "AXFocusedUIElementChanged,AXTitleChanged"
817 )]
818 pub events: Vec<String>,
819
820 #[arg(long, default_value_t = 256)]
822 pub max_buffer: usize,
823}
824
825#[derive(Debug, Clone, Args)]
826pub struct AxWatchPollArgs {
827 #[arg(long)]
829 pub watch_id: String,
830
831 #[arg(long, default_value_t = 50)]
833 pub limit: usize,
834
835 #[arg(long, default_value_t = true)]
837 pub drain: bool,
838}
839
840#[derive(Debug, Clone, Args)]
841pub struct AxWatchStopArgs {
842 #[arg(long)]
844 pub watch_id: String,
845}
846
847#[derive(Debug, Clone, Args)]
848pub struct InputClickArgs {
849 #[arg(long)]
851 pub x: i32,
852
853 #[arg(long)]
855 pub y: i32,
856
857 #[arg(long, value_enum, default_value_t = MouseButton::Left)]
859 pub button: MouseButton,
860
861 #[arg(long, default_value_t = 1)]
863 pub count: u8,
864
865 #[arg(long, default_value_t = 0)]
867 pub pre_wait_ms: u64,
868
869 #[arg(long, default_value_t = 0)]
871 pub post_wait_ms: u64,
872}
873
874#[derive(Debug, Clone, Args)]
875pub struct InputTypeArgs {
876 #[arg(long)]
878 pub text: String,
879
880 #[arg(long)]
882 pub delay_ms: Option<u64>,
883
884 #[arg(long = "submit", visible_alias = "enter")]
886 pub enter: bool,
887}
888
889#[derive(Debug, Clone, Args)]
890pub struct InputHotkeyArgs {
891 #[arg(long)]
893 pub mods: String,
894
895 #[arg(long)]
897 pub key: String,
898}
899
900#[derive(Debug, Clone, Subcommand)]
901pub enum ObserveCommand {
902 Screenshot(ObserveScreenshotArgs),
904}
905
906#[derive(Debug, Clone, Subcommand)]
907pub enum DebugCommand {
908 Bundle(DebugBundleArgs),
910}
911
912#[derive(Debug, Clone, Args)]
913#[command(
914 group(
915 ArgGroup::new("selector")
916 .required(false)
917 .multiple(false)
918 .args(["window_id", "active_window", "app"])
919 )
920)]
921pub struct DebugBundleArgs {
922 #[arg(long)]
924 pub window_id: Option<u32>,
925
926 #[arg(long)]
928 pub active_window: bool,
929
930 #[arg(long)]
932 pub app: Option<String>,
933
934 #[arg(
936 long = "window-title-contains",
937 visible_alias = "window-name",
938 requires = "app"
939 )]
940 pub window_name: Option<String>,
941
942 #[arg(long)]
944 pub output_dir: Option<PathBuf>,
945}
946
947#[derive(Debug, Clone, Args)]
948#[command(
949 group(
950 ArgGroup::new("selector")
951 .required(true)
952 .multiple(false)
953 .args(["window_id", "active_window", "app"])
954 )
955)]
956pub struct ObserveScreenshotArgs {
957 #[arg(long)]
959 pub window_id: Option<u32>,
960
961 #[arg(long)]
963 pub active_window: bool,
964
965 #[arg(long)]
967 pub app: Option<String>,
968
969 #[arg(
971 long = "window-title-contains",
972 visible_alias = "window-name",
973 requires = "app"
974 )]
975 pub window_name: Option<String>,
976
977 #[arg(long)]
979 pub path: Option<PathBuf>,
980
981 #[arg(long, value_enum)]
983 pub image_format: Option<ImageFormat>,
984
985 #[command(flatten)]
986 pub ax_selector: AxSelectorArgs,
987
988 #[arg(long, default_value_t = 0)]
990 pub selector_padding: i32,
991
992 #[arg(long)]
994 pub if_changed: bool,
995
996 #[arg(long, value_name = "path", requires = "if_changed")]
998 pub if_changed_baseline: Option<PathBuf>,
999
1000 #[arg(
1002 long,
1003 value_name = "bits",
1004 value_parser = clap::value_parser!(u32).range(0..=64),
1005 requires = "if_changed"
1006 )]
1007 pub if_changed_threshold: Option<u32>,
1008}
1009
1010#[derive(Debug, Clone, Subcommand)]
1011pub enum WaitCommand {
1012 Sleep(WaitSleepArgs),
1014
1015 AppActive(WaitAppActiveArgs),
1017
1018 WindowPresent(WaitWindowPresentArgs),
1020
1021 AxPresent(WaitAxPresentArgs),
1023
1024 AxUnique(WaitAxUniqueArgs),
1026}
1027
1028#[derive(Debug, Clone, Subcommand)]
1029pub enum ScenarioCommand {
1030 Run(ScenarioRunArgs),
1032}
1033
1034#[derive(Debug, Clone, Args)]
1035pub struct ScenarioRunArgs {
1036 #[arg(long)]
1038 pub file: PathBuf,
1039}
1040
1041#[derive(Debug, Clone, Subcommand)]
1042pub enum ProfileCommand {
1043 Validate(ProfileValidateArgs),
1045 Init(ProfileInitArgs),
1047}
1048
1049#[derive(Debug, Clone, Args)]
1050pub struct ProfileValidateArgs {
1051 #[arg(long)]
1053 pub file: PathBuf,
1054}
1055
1056#[derive(Debug, Clone, Args)]
1057pub struct ProfileInitArgs {
1058 #[arg(long, default_value = "default-1440p")]
1060 pub name: String,
1061
1062 #[arg(long)]
1064 pub path: Option<PathBuf>,
1065}
1066
1067#[derive(Debug, Clone, Args)]
1068pub struct WaitSleepArgs {
1069 #[arg(long)]
1071 pub ms: u64,
1072}
1073
1074#[derive(Debug, Clone, Args)]
1075#[command(
1076 group(
1077 ArgGroup::new("selector")
1078 .required(true)
1079 .multiple(false)
1080 .args(["app", "bundle_id"])
1081 )
1082)]
1083pub struct WaitAppActiveArgs {
1084 #[arg(long)]
1086 pub app: Option<String>,
1087
1088 #[arg(long)]
1090 pub bundle_id: Option<String>,
1091
1092 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1094 pub timeout_ms: u64,
1095
1096 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1098 pub poll_ms: u64,
1099}
1100
1101#[derive(Debug, Clone, Args)]
1102#[command(
1103 group(
1104 ArgGroup::new("selector")
1105 .required(true)
1106 .multiple(false)
1107 .args(["window_id", "active_window", "app"])
1108 )
1109)]
1110pub struct WaitWindowPresentArgs {
1111 #[arg(long)]
1113 pub window_id: Option<u32>,
1114
1115 #[arg(long)]
1117 pub active_window: bool,
1118
1119 #[arg(long)]
1121 pub app: Option<String>,
1122
1123 #[arg(
1125 long = "window-title-contains",
1126 visible_alias = "window-name",
1127 requires = "app"
1128 )]
1129 pub window_name: Option<String>,
1130
1131 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1133 pub timeout_ms: u64,
1134
1135 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1137 pub poll_ms: u64,
1138}
1139
1140#[derive(Debug, Clone, Args)]
1141#[command(
1142 group(
1143 ArgGroup::new("selector")
1144 .required(true)
1145 .multiple(true)
1146 .args([
1147 "node_id",
1148 "role",
1149 "title_contains",
1150 "identifier_contains",
1151 "value_contains",
1152 "subrole",
1153 "focused",
1154 "enabled",
1155 ])
1156 ),
1157 group(
1158 ArgGroup::new("target")
1159 .required(false)
1160 .multiple(false)
1161 .args(["session_id", "app", "bundle_id"])
1162 )
1163)]
1164pub struct WaitAxPresentArgs {
1165 #[command(flatten)]
1166 pub selector: AxSelectorArgs,
1167
1168 #[command(flatten)]
1169 pub target: AxTargetArgs,
1170
1171 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1173 pub timeout_ms: u64,
1174
1175 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1177 pub poll_ms: u64,
1178}
1179
1180#[derive(Debug, Clone, Args)]
1181#[command(
1182 group(
1183 ArgGroup::new("selector")
1184 .required(true)
1185 .multiple(true)
1186 .args([
1187 "node_id",
1188 "role",
1189 "title_contains",
1190 "identifier_contains",
1191 "value_contains",
1192 "subrole",
1193 "focused",
1194 "enabled",
1195 ])
1196 ),
1197 group(
1198 ArgGroup::new("target")
1199 .required(false)
1200 .multiple(false)
1201 .args(["session_id", "app", "bundle_id"])
1202 )
1203)]
1204pub struct WaitAxUniqueArgs {
1205 #[command(flatten)]
1206 pub selector: AxSelectorArgs,
1207
1208 #[command(flatten)]
1209 pub target: AxTargetArgs,
1210
1211 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1213 pub timeout_ms: u64,
1214
1215 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1217 pub poll_ms: u64,
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use std::path::PathBuf;
1223
1224 use clap::Parser;
1225 use pretty_assertions::assert_eq;
1226
1227 use super::{
1228 AxActionCommand, AxAttrCommand, AxCommand, AxSessionCommand, AxWatchCommand, Cli,
1229 CommandGroup, DebugCommand, ErrorFormat, InputSourceCommand, ObserveCommand, OutputFormat,
1230 WaitCommand, WindowCommand,
1231 };
1232
1233 #[test]
1234 fn parses_window_activate_command_tree() {
1235 let cli = Cli::try_parse_from([
1236 "macos-agent",
1237 "--format",
1238 "json",
1239 "--retries",
1240 "2",
1241 "window",
1242 "activate",
1243 "--app",
1244 "Terminal",
1245 "--wait-ms",
1246 "1500",
1247 ])
1248 .expect("window activate should parse");
1249
1250 assert_eq!(cli.format, OutputFormat::Json);
1251 assert_eq!(cli.error_format, ErrorFormat::Text);
1252 assert_eq!(cli.retries, 2);
1253 match cli.command {
1254 CommandGroup::Window {
1255 command: WindowCommand::Activate(args),
1256 } => {
1257 assert_eq!(args.app.as_deref(), Some("Terminal"));
1258 assert_eq!(args.wait_ms, Some(1500));
1259 assert!(!args.reopen_on_fail);
1260 }
1261 other => panic!("unexpected command variant: {other:?}"),
1262 }
1263 }
1264
1265 #[test]
1266 fn parses_window_activate_reopen_on_fail_flag() {
1267 let cli = Cli::try_parse_from([
1268 "macos-agent",
1269 "window",
1270 "activate",
1271 "--app",
1272 "Arc",
1273 "--reopen-on-fail",
1274 ])
1275 .expect("window activate reopen-on-fail should parse");
1276
1277 match cli.command {
1278 CommandGroup::Window {
1279 command: WindowCommand::Activate(args),
1280 } => {
1281 assert_eq!(args.app.as_deref(), Some("Arc"));
1282 assert!(args.reopen_on_fail);
1283 }
1284 other => panic!("unexpected command variant: {other:?}"),
1285 }
1286 }
1287
1288 #[test]
1289 fn parses_wait_window_present() {
1290 let cli = Cli::try_parse_from([
1291 "macos-agent",
1292 "wait",
1293 "window-present",
1294 "--app",
1295 "Terminal",
1296 "--window-title-contains",
1297 "Inbox",
1298 "--timeout-ms",
1299 "2000",
1300 "--poll-ms",
1301 "100",
1302 ])
1303 .expect("wait window-present should parse");
1304
1305 match cli.command {
1306 CommandGroup::Wait {
1307 command: WaitCommand::WindowPresent(args),
1308 } => {
1309 assert_eq!(args.app.as_deref(), Some("Terminal"));
1310 assert_eq!(args.window_name.as_deref(), Some("Inbox"));
1311 assert_eq!(args.timeout_ms, 2000);
1312 assert_eq!(args.poll_ms, 100);
1313 }
1314 other => panic!("unexpected command variant: {other:?}"),
1315 }
1316 }
1317
1318 #[test]
1319 fn parses_wait_ax_present_with_match_strategy_and_explain() {
1320 let cli = Cli::try_parse_from([
1321 "macos-agent",
1322 "wait",
1323 "ax-present",
1324 "--app",
1325 "Arc",
1326 "--role",
1327 "AXButton",
1328 "--title-contains",
1329 "^Run$",
1330 "--match-strategy",
1331 "regex",
1332 "--selector-explain",
1333 "--timeout-ms",
1334 "1800",
1335 "--poll-ms",
1336 "25",
1337 ])
1338 .expect("wait ax-present should parse");
1339
1340 match cli.command {
1341 CommandGroup::Wait {
1342 command: WaitCommand::AxPresent(args),
1343 } => {
1344 assert_eq!(args.target.app.as_deref(), Some("Arc"));
1345 assert_eq!(args.selector.filters.role.as_deref(), Some("AXButton"));
1346 assert_eq!(
1347 args.selector.filters.title_contains.as_deref(),
1348 Some("^Run$")
1349 );
1350 assert_eq!(
1351 args.selector.match_strategy,
1352 crate::model::AxMatchStrategy::Regex
1353 );
1354 assert!(args.selector.selector_explain);
1355 assert_eq!(args.timeout_ms, 1800);
1356 assert_eq!(args.poll_ms, 25);
1357 }
1358 other => panic!("unexpected command variant: {other:?}"),
1359 }
1360 }
1361
1362 #[test]
1363 fn parses_ax_click_gate_and_postcondition_flags() {
1364 let cli = Cli::try_parse_from([
1365 "macos-agent",
1366 "ax",
1367 "click",
1368 "--app",
1369 "Terminal",
1370 "--node-id",
1371 "1.1",
1372 "--gate-app-active",
1373 "--gate-window-present",
1374 "--gate-ax-present",
1375 "--gate-ax-unique",
1376 "--gate-timeout-ms",
1377 "2100",
1378 "--gate-poll-ms",
1379 "25",
1380 "--postcondition-focused",
1381 "true",
1382 "--postcondition-attribute",
1383 "AXRole",
1384 "--postcondition-attribute-value",
1385 "AXButton",
1386 "--postcondition-timeout-ms",
1387 "1800",
1388 "--postcondition-poll-ms",
1389 "20",
1390 ])
1391 .expect("ax click gate/postcondition flags should parse");
1392
1393 match cli.command {
1394 CommandGroup::Ax {
1395 command: AxCommand::Click(args),
1396 } => {
1397 assert!(args.gate.gate_app_active);
1398 assert!(args.gate.gate_window_present);
1399 assert!(args.gate.gate_ax_present);
1400 assert!(args.gate.gate_ax_unique);
1401 assert_eq!(args.gate.gate_timeout_ms, 2100);
1402 assert_eq!(args.gate.gate_poll_ms, 25);
1403 assert_eq!(args.postcondition.postcondition_focused, Some(true));
1404 assert_eq!(
1405 args.postcondition.postcondition_attribute.as_deref(),
1406 Some("AXRole")
1407 );
1408 assert_eq!(
1409 args.postcondition.postcondition_attribute_value.as_deref(),
1410 Some("AXButton")
1411 );
1412 assert_eq!(args.postcondition.postcondition_timeout_ms, 1800);
1413 assert_eq!(args.postcondition.postcondition_poll_ms, 20);
1414 }
1415 other => panic!("unexpected command variant: {other:?}"),
1416 }
1417 }
1418
1419 #[test]
1420 fn parses_ax_wait_policy_overrides() {
1421 let cli = Cli::try_parse_from([
1422 "macos-agent",
1423 "ax",
1424 "type",
1425 "--app",
1426 "Terminal",
1427 "--node-id",
1428 "1.1",
1429 "--text",
1430 "hello",
1431 "--wait-timeout-ms",
1432 "2200",
1433 "--wait-poll-ms",
1434 "40",
1435 ])
1436 .expect("ax type wait policy flags should parse");
1437
1438 match cli.command {
1439 CommandGroup::Ax {
1440 command: AxCommand::Type(args),
1441 } => {
1442 assert_eq!(args.wait_timeout_ms, Some(2200));
1443 assert_eq!(args.wait_poll_ms, Some(40));
1444 }
1445 other => panic!("unexpected command variant: {other:?}"),
1446 }
1447 }
1448
1449 #[test]
1450 fn parses_debug_bundle_and_observe_selector_padding_flags() {
1451 let debug_cli = Cli::try_parse_from([
1452 "macos-agent",
1453 "debug",
1454 "bundle",
1455 "--active-window",
1456 "--output-dir",
1457 "/tmp/debug-bundle",
1458 ])
1459 .expect("debug bundle should parse");
1460 match debug_cli.command {
1461 CommandGroup::Debug {
1462 command: DebugCommand::Bundle(args),
1463 } => {
1464 assert!(args.active_window);
1465 assert_eq!(
1466 args.output_dir.expect("output-dir should parse"),
1467 PathBuf::from("/tmp/debug-bundle")
1468 );
1469 }
1470 other => panic!("unexpected command variant: {other:?}"),
1471 }
1472
1473 let observe_cli = Cli::try_parse_from([
1474 "macos-agent",
1475 "observe",
1476 "screenshot",
1477 "--active-window",
1478 "--role",
1479 "AXButton",
1480 "--title-contains",
1481 "Run",
1482 "--selector-padding",
1483 "12",
1484 "--if-changed",
1485 "--if-changed-threshold",
1486 "4",
1487 ])
1488 .expect("observe screenshot selector flags should parse");
1489 match observe_cli.command {
1490 CommandGroup::Observe {
1491 command: ObserveCommand::Screenshot(args),
1492 } => {
1493 assert!(args.active_window);
1494 assert_eq!(args.ax_selector.filters.role.as_deref(), Some("AXButton"));
1495 assert_eq!(
1496 args.ax_selector.filters.title_contains.as_deref(),
1497 Some("Run")
1498 );
1499 assert_eq!(args.selector_padding, 12);
1500 assert!(args.if_changed);
1501 assert_eq!(args.if_changed_threshold, Some(4));
1502 assert_eq!(args.if_changed_baseline, None);
1503 }
1504 other => panic!("unexpected command variant: {other:?}"),
1505 }
1506 }
1507
1508 #[test]
1509 fn rejects_multiple_window_activate_selectors() {
1510 let err = Cli::try_parse_from([
1511 "macos-agent",
1512 "window",
1513 "activate",
1514 "--window-id",
1515 "10",
1516 "--app",
1517 "Terminal",
1518 ])
1519 .expect_err("multiple selectors must be rejected");
1520 let rendered = err.to_string();
1521 assert!(
1522 rendered.contains("cannot be used with")
1523 || rendered.contains("required arguments were not provided")
1524 );
1525 }
1526
1527 #[test]
1528 fn rejects_window_title_contains_without_app() {
1529 let err = Cli::try_parse_from([
1530 "macos-agent",
1531 "wait",
1532 "window-present",
1533 "--window-title-contains",
1534 "Inbox",
1535 ])
1536 .expect_err("window-title-contains requires app");
1537 let rendered = err.to_string();
1538 assert!(
1539 rendered.contains("requires")
1540 || rendered.contains("required arguments were not provided")
1541 );
1542 }
1543
1544 #[test]
1545 fn supports_window_name_alias_for_backward_compatibility() {
1546 let cli = Cli::try_parse_from([
1547 "macos-agent",
1548 "wait",
1549 "window-present",
1550 "--app",
1551 "Terminal",
1552 "--window-name",
1553 "Inbox",
1554 ])
1555 .expect("legacy --window-name alias should parse");
1556
1557 match cli.command {
1558 CommandGroup::Wait {
1559 command: WaitCommand::WindowPresent(args),
1560 } => {
1561 assert_eq!(args.window_name.as_deref(), Some("Inbox"));
1562 }
1563 other => panic!("unexpected command variant: {other:?}"),
1564 }
1565 }
1566
1567 #[test]
1568 fn parses_input_source_switch_command() {
1569 let cli = Cli::try_parse_from(["macos-agent", "input-source", "switch", "--id", "abc"])
1570 .expect("input-source switch should parse");
1571
1572 match cli.command {
1573 CommandGroup::InputSource {
1574 command: InputSourceCommand::Switch(args),
1575 } => {
1576 assert_eq!(args.id, "abc".to_string());
1577 }
1578 other => panic!("unexpected command variant: {other:?}"),
1579 }
1580 }
1581
1582 #[test]
1583 fn parses_input_type_submit_and_enter_alias() {
1584 let canonical = Cli::try_parse_from([
1585 "macos-agent",
1586 "input",
1587 "type",
1588 "--text",
1589 "hello",
1590 "--submit",
1591 ])
1592 .expect("input type --submit should parse");
1593 match canonical.command {
1594 CommandGroup::Input {
1595 command: super::InputCommand::Type(args),
1596 } => assert!(args.enter),
1597 other => panic!("unexpected command variant: {other:?}"),
1598 }
1599
1600 let alias =
1601 Cli::try_parse_from(["macos-agent", "input", "type", "--text", "hello", "--enter"])
1602 .expect("input type --enter alias should parse");
1603 match alias.command {
1604 CommandGroup::Input {
1605 command: super::InputCommand::Type(args),
1606 } => assert!(args.enter),
1607 other => panic!("unexpected command variant: {other:?}"),
1608 }
1609 }
1610
1611 #[test]
1612 fn parses_ax_list_with_filters() {
1613 let cli = Cli::try_parse_from([
1614 "macos-agent",
1615 "ax",
1616 "list",
1617 "--app",
1618 "Arc",
1619 "--role",
1620 "AXButton",
1621 "--title-contains",
1622 "New tab",
1623 "--max-depth",
1624 "4",
1625 "--limit",
1626 "20",
1627 ])
1628 .expect("ax list should parse");
1629
1630 match cli.command {
1631 CommandGroup::Ax {
1632 command: AxCommand::List(args),
1633 } => {
1634 assert_eq!(args.target.app.as_deref(), Some("Arc"));
1635 assert_eq!(args.filters.role.as_deref(), Some("AXButton"));
1636 assert_eq!(args.filters.title_contains.as_deref(), Some("New tab"));
1637 assert_eq!(args.max_depth, Some(4));
1638 assert_eq!(args.limit, Some(20));
1639 }
1640 other => panic!("unexpected command variant: {other:?}"),
1641 }
1642 }
1643
1644 #[test]
1645 fn parses_ax_click_node_id_selector() {
1646 let cli = Cli::try_parse_from([
1647 "macos-agent",
1648 "--dry-run",
1649 "ax",
1650 "click",
1651 "--node-id",
1652 "node-17",
1653 "--allow-coordinate-fallback",
1654 ])
1655 .expect("ax click should parse");
1656
1657 match cli.command {
1658 CommandGroup::Ax {
1659 command: AxCommand::Click(args),
1660 } => {
1661 assert_eq!(args.selector.node_id.as_deref(), Some("node-17"));
1662 assert!(args.allow_coordinate_fallback);
1663 }
1664 other => panic!("unexpected command variant: {other:?}"),
1665 }
1666 }
1667
1668 #[test]
1669 fn parses_ax_click_reselect_and_fallback_order() {
1670 let cli = Cli::try_parse_from([
1671 "macos-agent",
1672 "ax",
1673 "click",
1674 "--role",
1675 "AXButton",
1676 "--title-contains",
1677 "Run",
1678 "--reselect-before-click",
1679 "--allow-coordinate-fallback",
1680 "--fallback-order",
1681 "ax-press,ax-confirm,frame-center,coordinate",
1682 ])
1683 .expect("ax click reselect/fallback-order should parse");
1684
1685 match cli.command {
1686 CommandGroup::Ax {
1687 command: AxCommand::Click(args),
1688 } => {
1689 assert!(args.reselect_before_click);
1690 assert_eq!(
1691 args.fallback_order,
1692 vec![
1693 crate::model::AxClickFallbackStage::AxPress,
1694 crate::model::AxClickFallbackStage::AxConfirm,
1695 crate::model::AxClickFallbackStage::FrameCenter,
1696 crate::model::AxClickFallbackStage::Coordinate,
1697 ]
1698 );
1699 }
1700 other => panic!("unexpected command variant: {other:?}"),
1701 }
1702 }
1703
1704 #[test]
1705 fn parses_ax_type_compound_selector() {
1706 let cli = Cli::try_parse_from([
1707 "macos-agent",
1708 "ax",
1709 "type",
1710 "--role",
1711 "AXTextField",
1712 "--title-contains",
1713 "Search",
1714 "--nth",
1715 "2",
1716 "--text",
1717 "hello",
1718 "--clear-first",
1719 "--submit",
1720 "--paste",
1721 "--allow-keyboard-fallback",
1722 ])
1723 .expect("ax type should parse");
1724
1725 match cli.command {
1726 CommandGroup::Ax {
1727 command: AxCommand::Type(args),
1728 } => {
1729 assert_eq!(args.selector.filters.role.as_deref(), Some("AXTextField"));
1730 assert_eq!(
1731 args.selector.filters.title_contains.as_deref(),
1732 Some("Search")
1733 );
1734 assert_eq!(args.selector.nth, Some(2));
1735 assert_eq!(args.text, "hello");
1736 assert!(args.clear_first);
1737 assert!(args.submit);
1738 assert!(args.paste);
1739 assert!(args.allow_keyboard_fallback);
1740 }
1741 other => panic!("unexpected command variant: {other:?}"),
1742 }
1743 }
1744
1745 #[test]
1746 fn rejects_ax_click_mixed_selectors() {
1747 let err = Cli::try_parse_from([
1748 "macos-agent",
1749 "ax",
1750 "click",
1751 "--node-id",
1752 "node-17",
1753 "--role",
1754 "AXButton",
1755 "--title-contains",
1756 "Save",
1757 ])
1758 .expect_err("selector mix should be rejected");
1759 let rendered = err.to_string();
1760 assert!(
1761 rendered.contains("cannot be used with")
1762 || rendered.contains("required arguments were not provided")
1763 );
1764 }
1765
1766 #[test]
1767 fn parses_ax_type_role_without_title_contains() {
1768 let cli = Cli::try_parse_from([
1769 "macos-agent",
1770 "ax",
1771 "type",
1772 "--role",
1773 "AXTextField",
1774 "--text",
1775 "hello",
1776 ])
1777 .expect("role-only selector should parse");
1778 match cli.command {
1779 CommandGroup::Ax {
1780 command: AxCommand::Type(args),
1781 } => {
1782 assert_eq!(args.selector.filters.role.as_deref(), Some("AXTextField"));
1783 assert!(args.selector.filters.title_contains.is_none());
1784 }
1785 other => panic!("unexpected command variant: {other:?}"),
1786 }
1787 }
1788
1789 #[test]
1790 fn rejects_ax_type_nth_without_selector_filter() {
1791 let err =
1792 Cli::try_parse_from(["macos-agent", "ax", "type", "--nth", "2", "--text", "hello"])
1793 .expect_err("nth alone should be rejected by selector group");
1794 let rendered = err.to_string();
1795 assert!(rendered.contains("required arguments were not provided"));
1796 }
1797
1798 #[test]
1799 fn rejects_ax_list_multiple_target_selectors() {
1800 let err = Cli::try_parse_from([
1801 "macos-agent",
1802 "ax",
1803 "list",
1804 "--app",
1805 "Arc",
1806 "--bundle-id",
1807 "com.apple.Safari",
1808 ])
1809 .expect_err("app and bundle-id should be mutually exclusive");
1810 let rendered = err.to_string();
1811 assert!(rendered.contains("cannot be used with"));
1812 }
1813
1814 #[test]
1815 fn parses_ax_attr_get_and_set_commands() {
1816 let get_cli = Cli::try_parse_from([
1817 "macos-agent",
1818 "ax",
1819 "attr",
1820 "get",
1821 "--node-id",
1822 "1.2",
1823 "--name",
1824 "AXRole",
1825 ])
1826 .expect("ax attr get should parse");
1827 match get_cli.command {
1828 CommandGroup::Ax {
1829 command:
1830 AxCommand::Attr {
1831 command: AxAttrCommand::Get(args),
1832 },
1833 } => {
1834 assert_eq!(args.selector.node_id.as_deref(), Some("1.2"));
1835 assert_eq!(args.name, "AXRole");
1836 }
1837 other => panic!("unexpected command variant: {other:?}"),
1838 }
1839
1840 let set_cli = Cli::try_parse_from([
1841 "macos-agent",
1842 "ax",
1843 "attr",
1844 "set",
1845 "--role",
1846 "AXTextField",
1847 "--title-contains",
1848 "Search",
1849 "--name",
1850 "AXValue",
1851 "--value",
1852 "hello",
1853 "--value-type",
1854 "string",
1855 ])
1856 .expect("ax attr set should parse");
1857 match set_cli.command {
1858 CommandGroup::Ax {
1859 command:
1860 AxCommand::Attr {
1861 command: AxAttrCommand::Set(args),
1862 },
1863 } => {
1864 assert_eq!(args.selector.filters.role.as_deref(), Some("AXTextField"));
1865 assert_eq!(
1866 args.selector.filters.title_contains.as_deref(),
1867 Some("Search")
1868 );
1869 assert_eq!(args.name, "AXValue");
1870 assert_eq!(args.value, "hello");
1871 }
1872 other => panic!("unexpected command variant: {other:?}"),
1873 }
1874 }
1875
1876 #[test]
1877 fn parses_ax_action_session_and_watch_commands() {
1878 let action_cli = Cli::try_parse_from([
1879 "macos-agent",
1880 "ax",
1881 "action",
1882 "perform",
1883 "--node-id",
1884 "1.1",
1885 "--name",
1886 "AXPress",
1887 ])
1888 .expect("ax action perform should parse");
1889 match action_cli.command {
1890 CommandGroup::Ax {
1891 command:
1892 AxCommand::Action {
1893 command: AxActionCommand::Perform(args),
1894 },
1895 } => {
1896 assert_eq!(args.selector.node_id.as_deref(), Some("1.1"));
1897 assert_eq!(args.name, "AXPress");
1898 }
1899 other => panic!("unexpected command variant: {other:?}"),
1900 }
1901
1902 let session_cli = Cli::try_parse_from([
1903 "macos-agent",
1904 "ax",
1905 "session",
1906 "start",
1907 "--app",
1908 "Arc",
1909 "--session-id",
1910 "axs-demo",
1911 ])
1912 .expect("ax session start should parse");
1913 match session_cli.command {
1914 CommandGroup::Ax {
1915 command:
1916 AxCommand::Session {
1917 command: AxSessionCommand::Start(args),
1918 },
1919 } => {
1920 assert_eq!(args.app.as_deref(), Some("Arc"));
1921 assert_eq!(args.session_id.as_deref(), Some("axs-demo"));
1922 }
1923 other => panic!("unexpected command variant: {other:?}"),
1924 }
1925
1926 let watch_cli = Cli::try_parse_from([
1927 "macos-agent",
1928 "ax",
1929 "watch",
1930 "start",
1931 "--session-id",
1932 "axs-demo",
1933 "--events",
1934 "AXTitleChanged,AXFocusedUIElementChanged",
1935 ])
1936 .expect("ax watch start should parse");
1937 match watch_cli.command {
1938 CommandGroup::Ax {
1939 command:
1940 AxCommand::Watch {
1941 command: AxWatchCommand::Start(args),
1942 },
1943 } => {
1944 assert_eq!(args.session_id, "axs-demo");
1945 assert_eq!(args.events.len(), 2);
1946 }
1947 other => panic!("unexpected command variant: {other:?}"),
1948 }
1949 }
1950}