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 Completion(CompletionArgs),
158}
159
160#[derive(Debug, Clone, Args)]
161pub struct CompletionArgs {
162 #[arg(value_enum)]
164 pub shell: crate::completion::CompletionShell,
165}
166
167#[derive(Debug, Clone, Args)]
168pub struct PreflightArgs {
169 #[arg(long)]
171 pub strict: bool,
172
173 #[arg(long)]
175 pub include_probes: bool,
176}
177
178#[derive(Debug, Clone, Subcommand)]
179pub enum WindowsCommand {
180 List(ListWindowsArgs),
182}
183
184#[derive(Debug, Clone, Args)]
185pub struct ListWindowsArgs {
186 #[arg(long)]
188 pub app: Option<String>,
189
190 #[arg(long = "window-title-contains", requires = "app")]
192 pub window_name: Option<String>,
193
194 #[arg(long)]
196 pub on_screen_only: bool,
197}
198
199#[derive(Debug, Clone, Subcommand)]
200pub enum AppsCommand {
201 List(ListAppsArgs),
203}
204
205#[derive(Debug, Clone, Args, Default)]
206pub struct ListAppsArgs {}
207
208#[derive(Debug, Clone, Subcommand)]
209pub enum WindowCommand {
210 Activate(WindowActivateArgs),
212}
213
214#[derive(Debug, Clone, Args)]
215#[command(
216 group(
217 ArgGroup::new("selector")
218 .required(true)
219 .multiple(false)
220 .args(["window_id", "active_window", "app", "bundle_id"])
221 )
222)]
223pub struct WindowActivateArgs {
224 #[arg(long)]
226 pub window_id: Option<u32>,
227
228 #[arg(long)]
230 pub active_window: bool,
231
232 #[arg(long)]
234 pub app: Option<String>,
235
236 #[arg(long = "window-title-contains", requires = "app")]
238 pub window_name: Option<String>,
239
240 #[arg(long)]
242 pub bundle_id: Option<String>,
243
244 #[arg(long)]
246 pub wait_ms: Option<u64>,
247
248 #[arg(long, default_value_t = false)]
250 pub reopen_on_fail: bool,
251}
252
253#[derive(Debug, Clone, Subcommand)]
254pub enum InputCommand {
255 Click(InputClickArgs),
257
258 Type(InputTypeArgs),
260
261 Hotkey(InputHotkeyArgs),
263}
264
265#[derive(Debug, Clone, Subcommand)]
266pub enum InputSourceCommand {
267 Current(InputSourceCurrentArgs),
269
270 Switch(InputSourceSwitchArgs),
272}
273
274#[derive(Debug, Clone, Args, Default)]
275pub struct InputSourceCurrentArgs {}
276
277#[derive(Debug, Clone, Args)]
278pub struct InputSourceSwitchArgs {
279 #[arg(long)]
281 pub id: String,
282}
283
284#[derive(Debug, Clone, Subcommand)]
285pub enum AxCommand {
286 List(AxListArgs),
288
289 Click(AxClickArgs),
291
292 Type(AxTypeArgs),
294
295 Attr {
297 #[command(subcommand)]
298 command: AxAttrCommand,
299 },
300
301 Action {
303 #[command(subcommand)]
304 command: AxActionCommand,
305 },
306
307 Session {
309 #[command(subcommand)]
310 command: AxSessionCommand,
311 },
312
313 Watch {
315 #[command(subcommand)]
316 command: AxWatchCommand,
317 },
318}
319
320#[derive(Debug, Clone, Subcommand)]
321pub enum AxAttrCommand {
322 Get(AxAttrGetArgs),
324
325 Set(AxAttrSetArgs),
327}
328
329#[derive(Debug, Clone, Subcommand)]
330pub enum AxActionCommand {
331 Perform(AxActionPerformArgs),
333}
334
335#[derive(Debug, Clone, Subcommand)]
336pub enum AxSessionCommand {
337 Start(AxSessionStartArgs),
339
340 List(AxSessionListArgs),
342
343 Stop(AxSessionStopArgs),
345}
346
347#[derive(Debug, Clone, Subcommand)]
348pub enum AxWatchCommand {
349 Start(AxWatchStartArgs),
351
352 Poll(AxWatchPollArgs),
354
355 Stop(AxWatchStopArgs),
357}
358
359#[derive(Debug, Clone, Args, Default)]
360pub struct AxTargetArgs {
361 #[arg(long)]
363 pub session_id: Option<String>,
364
365 #[arg(long)]
367 pub app: Option<String>,
368
369 #[arg(long)]
371 pub bundle_id: Option<String>,
372
373 #[arg(long)]
375 pub window_title_contains: Option<String>,
376}
377
378#[derive(Debug, Clone, Args, Default)]
379pub struct AxMatchFiltersArgs {
380 #[arg(long)]
382 pub role: Option<String>,
383
384 #[arg(long)]
386 pub title_contains: Option<String>,
387
388 #[arg(long)]
390 pub identifier_contains: Option<String>,
391
392 #[arg(long)]
394 pub value_contains: Option<String>,
395
396 #[arg(long)]
398 pub subrole: Option<String>,
399
400 #[arg(long)]
402 pub focused: Option<bool>,
403
404 #[arg(long)]
406 pub enabled: Option<bool>,
407}
408
409#[derive(Debug, Clone, Args, Default)]
410pub struct AxSelectorArgs {
411 #[arg(
413 long,
414 conflicts_with_all = [
415 "role",
416 "title_contains",
417 "identifier_contains",
418 "value_contains",
419 "subrole",
420 "focused",
421 "enabled",
422 "nth"
423 ]
424 )]
425 pub node_id: Option<String>,
426
427 #[command(flatten)]
428 pub filters: AxMatchFiltersArgs,
429
430 #[arg(long)]
432 pub nth: Option<u32>,
433
434 #[arg(long, value_enum, default_value_t = AxMatchStrategy::Contains)]
436 pub match_strategy: AxMatchStrategy,
437
438 #[arg(long)]
440 pub selector_explain: bool,
441}
442
443#[derive(Debug, Clone, Args)]
444#[command(
445 group(
446 ArgGroup::new("target")
447 .required(false)
448 .multiple(false)
449 .args(["session_id", "app", "bundle_id"])
450 )
451)]
452pub struct AxListArgs {
453 #[command(flatten)]
454 pub target: AxTargetArgs,
455
456 #[command(flatten)]
457 pub filters: AxMatchFiltersArgs,
458
459 #[arg(long)]
461 pub max_depth: Option<u32>,
462
463 #[arg(long)]
465 pub limit: Option<u32>,
466}
467
468#[derive(Debug, Clone, Args)]
469pub struct AxActionGateArgs {
470 #[arg(long)]
472 pub gate_app_active: bool,
473
474 #[arg(long)]
476 pub gate_window_present: bool,
477
478 #[arg(long)]
480 pub gate_ax_present: bool,
481
482 #[arg(long)]
484 pub gate_ax_unique: bool,
485
486 #[arg(long, default_value_t = 1500)]
488 pub gate_timeout_ms: u64,
489
490 #[arg(long, default_value_t = 50)]
492 pub gate_poll_ms: u64,
493}
494
495#[derive(Debug, Clone, Args)]
496pub struct AxPostconditionArgs {
497 #[arg(long)]
499 pub postcondition_focused: Option<bool>,
500
501 #[arg(long, requires = "postcondition_attribute_value")]
503 pub postcondition_attribute: Option<String>,
504
505 #[arg(long, requires = "postcondition_attribute")]
507 pub postcondition_attribute_value: Option<String>,
508
509 #[arg(long, default_value_t = 1500)]
511 pub postcondition_timeout_ms: u64,
512
513 #[arg(long, default_value_t = 50)]
515 pub postcondition_poll_ms: u64,
516}
517
518#[derive(Debug, Clone, Args)]
519#[command(
520 group(
521 ArgGroup::new("selector")
522 .required(true)
523 .multiple(true)
524 .args([
525 "node_id",
526 "role",
527 "title_contains",
528 "identifier_contains",
529 "value_contains",
530 "subrole",
531 "focused",
532 "enabled",
533 ])
534 ),
535 group(
536 ArgGroup::new("target")
537 .required(false)
538 .multiple(false)
539 .args(["session_id", "app", "bundle_id"])
540 )
541)]
542pub struct AxClickArgs {
543 #[command(flatten)]
544 pub selector: AxSelectorArgs,
545
546 #[command(flatten)]
547 pub target: AxTargetArgs,
548
549 #[arg(long)]
551 pub allow_coordinate_fallback: bool,
552
553 #[arg(long)]
555 pub reselect_before_click: bool,
556
557 #[arg(long, value_enum, value_delimiter = ',', num_args = 1.., value_name = "STAGE")]
559 pub fallback_order: Vec<AxClickFallbackStage>,
560
561 #[arg(long)]
563 pub wait_timeout_ms: Option<u64>,
564
565 #[arg(long)]
567 pub wait_poll_ms: Option<u64>,
568
569 #[command(flatten)]
570 pub gate: AxActionGateArgs,
571
572 #[command(flatten)]
573 pub postcondition: AxPostconditionArgs,
574}
575
576#[derive(Debug, Clone, Args)]
577#[command(
578 group(
579 ArgGroup::new("selector")
580 .required(true)
581 .multiple(true)
582 .args([
583 "node_id",
584 "role",
585 "title_contains",
586 "identifier_contains",
587 "value_contains",
588 "subrole",
589 "focused",
590 "enabled",
591 ])
592 ),
593 group(
594 ArgGroup::new("target")
595 .required(false)
596 .multiple(false)
597 .args(["session_id", "app", "bundle_id"])
598 )
599)]
600pub struct AxTypeArgs {
601 #[command(flatten)]
602 pub selector: AxSelectorArgs,
603
604 #[command(flatten)]
605 pub target: AxTargetArgs,
606
607 #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
609 pub text: String,
610
611 #[arg(long)]
613 pub clear_first: bool,
614
615 #[arg(long)]
617 pub submit: bool,
618
619 #[arg(long)]
621 pub paste: bool,
622
623 #[arg(long)]
625 pub allow_keyboard_fallback: bool,
626
627 #[arg(long)]
629 pub wait_timeout_ms: Option<u64>,
630
631 #[arg(long)]
633 pub wait_poll_ms: Option<u64>,
634
635 #[command(flatten)]
636 pub gate: AxActionGateArgs,
637
638 #[command(flatten)]
639 pub postcondition: AxPostconditionArgs,
640}
641
642#[derive(Debug, Clone, Args)]
643#[command(
644 group(
645 ArgGroup::new("selector")
646 .required(true)
647 .multiple(true)
648 .args([
649 "node_id",
650 "role",
651 "title_contains",
652 "identifier_contains",
653 "value_contains",
654 "subrole",
655 "focused",
656 "enabled",
657 ])
658 ),
659 group(
660 ArgGroup::new("target")
661 .required(false)
662 .multiple(false)
663 .args(["session_id", "app", "bundle_id"])
664 )
665)]
666pub struct AxAttrGetArgs {
667 #[command(flatten)]
668 pub selector: AxSelectorArgs,
669
670 #[command(flatten)]
671 pub target: AxTargetArgs,
672
673 #[arg(long)]
675 pub name: String,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
679pub enum AxValueType {
680 String,
681 Number,
682 Bool,
683 Json,
684 Null,
685}
686
687#[derive(Debug, Clone, Args)]
688#[command(
689 group(
690 ArgGroup::new("selector")
691 .required(true)
692 .multiple(true)
693 .args([
694 "node_id",
695 "role",
696 "title_contains",
697 "identifier_contains",
698 "value_contains",
699 "subrole",
700 "focused",
701 "enabled",
702 ])
703 ),
704 group(
705 ArgGroup::new("target")
706 .required(false)
707 .multiple(false)
708 .args(["session_id", "app", "bundle_id"])
709 )
710)]
711pub struct AxAttrSetArgs {
712 #[command(flatten)]
713 pub selector: AxSelectorArgs,
714
715 #[command(flatten)]
716 pub target: AxTargetArgs,
717
718 #[arg(long)]
720 pub name: String,
721
722 #[arg(long)]
724 pub value: String,
725
726 #[arg(long, value_enum, default_value_t = AxValueType::String)]
728 pub value_type: AxValueType,
729}
730
731#[derive(Debug, Clone, Args)]
732#[command(
733 group(
734 ArgGroup::new("selector")
735 .required(true)
736 .multiple(true)
737 .args([
738 "node_id",
739 "role",
740 "title_contains",
741 "identifier_contains",
742 "value_contains",
743 "subrole",
744 "focused",
745 "enabled",
746 ])
747 ),
748 group(
749 ArgGroup::new("target")
750 .required(false)
751 .multiple(false)
752 .args(["session_id", "app", "bundle_id"])
753 )
754)]
755pub struct AxActionPerformArgs {
756 #[command(flatten)]
757 pub selector: AxSelectorArgs,
758
759 #[command(flatten)]
760 pub target: AxTargetArgs,
761
762 #[arg(long)]
764 pub name: String,
765}
766
767#[derive(Debug, Clone, Args)]
768#[command(
769 group(
770 ArgGroup::new("target")
771 .required(false)
772 .multiple(false)
773 .args(["app", "bundle_id"])
774 )
775)]
776pub struct AxSessionStartArgs {
777 #[arg(long)]
779 pub app: Option<String>,
780
781 #[arg(long)]
783 pub bundle_id: Option<String>,
784
785 #[arg(long)]
787 pub session_id: Option<String>,
788
789 #[arg(long)]
791 pub window_title_contains: Option<String>,
792}
793
794#[derive(Debug, Clone, Args, Default)]
795pub struct AxSessionListArgs {}
796
797#[derive(Debug, Clone, Args)]
798pub struct AxSessionStopArgs {
799 #[arg(long)]
801 pub session_id: String,
802}
803
804#[derive(Debug, Clone, Args)]
805pub struct AxWatchStartArgs {
806 #[arg(long)]
808 pub session_id: String,
809
810 #[arg(long)]
812 pub watch_id: Option<String>,
813
814 #[arg(
816 long,
817 value_delimiter = ',',
818 default_value = "AXFocusedUIElementChanged,AXTitleChanged"
819 )]
820 pub events: Vec<String>,
821
822 #[arg(long, default_value_t = 256)]
824 pub max_buffer: usize,
825}
826
827#[derive(Debug, Clone, Args)]
828pub struct AxWatchPollArgs {
829 #[arg(long)]
831 pub watch_id: String,
832
833 #[arg(long, default_value_t = 50)]
835 pub limit: usize,
836
837 #[arg(long, default_value_t = true)]
839 pub drain: bool,
840}
841
842#[derive(Debug, Clone, Args)]
843pub struct AxWatchStopArgs {
844 #[arg(long)]
846 pub watch_id: String,
847}
848
849#[derive(Debug, Clone, Args)]
850pub struct InputClickArgs {
851 #[arg(long)]
853 pub x: i32,
854
855 #[arg(long)]
857 pub y: i32,
858
859 #[arg(long, value_enum, default_value_t = MouseButton::Left)]
861 pub button: MouseButton,
862
863 #[arg(long, default_value_t = 1)]
865 pub count: u8,
866
867 #[arg(long, default_value_t = 0)]
869 pub pre_wait_ms: u64,
870
871 #[arg(long, default_value_t = 0)]
873 pub post_wait_ms: u64,
874}
875
876#[derive(Debug, Clone, Args)]
877pub struct InputTypeArgs {
878 #[arg(long)]
880 pub text: String,
881
882 #[arg(long)]
884 pub delay_ms: Option<u64>,
885
886 #[arg(long = "submit")]
888 pub enter: bool,
889}
890
891#[derive(Debug, Clone, Args)]
892pub struct InputHotkeyArgs {
893 #[arg(long)]
895 pub mods: String,
896
897 #[arg(long)]
899 pub key: String,
900}
901
902#[derive(Debug, Clone, Subcommand)]
903pub enum ObserveCommand {
904 Screenshot(ObserveScreenshotArgs),
906}
907
908#[derive(Debug, Clone, Subcommand)]
909pub enum DebugCommand {
910 Bundle(DebugBundleArgs),
912}
913
914#[derive(Debug, Clone, Args)]
915#[command(
916 group(
917 ArgGroup::new("selector")
918 .required(false)
919 .multiple(false)
920 .args(["window_id", "active_window", "app"])
921 )
922)]
923pub struct DebugBundleArgs {
924 #[arg(long)]
926 pub window_id: Option<u32>,
927
928 #[arg(long)]
930 pub active_window: bool,
931
932 #[arg(long)]
934 pub app: Option<String>,
935
936 #[arg(long = "window-title-contains", requires = "app")]
938 pub window_name: Option<String>,
939
940 #[arg(long)]
942 pub output_dir: Option<PathBuf>,
943}
944
945#[derive(Debug, Clone, Args)]
946#[command(
947 group(
948 ArgGroup::new("selector")
949 .required(true)
950 .multiple(false)
951 .args(["window_id", "active_window", "app"])
952 )
953)]
954pub struct ObserveScreenshotArgs {
955 #[arg(long)]
957 pub window_id: Option<u32>,
958
959 #[arg(long)]
961 pub active_window: bool,
962
963 #[arg(long)]
965 pub app: Option<String>,
966
967 #[arg(long = "window-title-contains", requires = "app")]
969 pub window_name: Option<String>,
970
971 #[arg(long)]
973 pub path: Option<PathBuf>,
974
975 #[arg(long, value_enum)]
977 pub image_format: Option<ImageFormat>,
978
979 #[command(flatten)]
980 pub ax_selector: AxSelectorArgs,
981
982 #[arg(long, default_value_t = 0)]
984 pub selector_padding: i32,
985
986 #[arg(long)]
988 pub if_changed: bool,
989
990 #[arg(long, value_name = "path", requires = "if_changed")]
992 pub if_changed_baseline: Option<PathBuf>,
993
994 #[arg(
996 long,
997 value_name = "bits",
998 value_parser = clap::value_parser!(u32).range(0..=64),
999 requires = "if_changed"
1000 )]
1001 pub if_changed_threshold: Option<u32>,
1002}
1003
1004#[derive(Debug, Clone, Subcommand)]
1005pub enum WaitCommand {
1006 Sleep(WaitSleepArgs),
1008
1009 AppActive(WaitAppActiveArgs),
1011
1012 WindowPresent(WaitWindowPresentArgs),
1014
1015 AxPresent(WaitAxPresentArgs),
1017
1018 AxUnique(WaitAxUniqueArgs),
1020}
1021
1022#[derive(Debug, Clone, Subcommand)]
1023pub enum ScenarioCommand {
1024 Run(ScenarioRunArgs),
1026}
1027
1028#[derive(Debug, Clone, Args)]
1029pub struct ScenarioRunArgs {
1030 #[arg(long)]
1032 pub file: PathBuf,
1033}
1034
1035#[derive(Debug, Clone, Subcommand)]
1036pub enum ProfileCommand {
1037 Validate(ProfileValidateArgs),
1039 Init(ProfileInitArgs),
1041}
1042
1043#[derive(Debug, Clone, Args)]
1044pub struct ProfileValidateArgs {
1045 #[arg(long)]
1047 pub file: PathBuf,
1048}
1049
1050#[derive(Debug, Clone, Args)]
1051pub struct ProfileInitArgs {
1052 #[arg(long, default_value = "default-1440p")]
1054 pub name: String,
1055
1056 #[arg(long)]
1058 pub path: Option<PathBuf>,
1059}
1060
1061#[derive(Debug, Clone, Args)]
1062pub struct WaitSleepArgs {
1063 #[arg(long)]
1065 pub ms: u64,
1066}
1067
1068#[derive(Debug, Clone, Args)]
1069#[command(
1070 group(
1071 ArgGroup::new("selector")
1072 .required(true)
1073 .multiple(false)
1074 .args(["app", "bundle_id"])
1075 )
1076)]
1077pub struct WaitAppActiveArgs {
1078 #[arg(long)]
1080 pub app: Option<String>,
1081
1082 #[arg(long)]
1084 pub bundle_id: Option<String>,
1085
1086 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1088 pub timeout_ms: u64,
1089
1090 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1092 pub poll_ms: u64,
1093}
1094
1095#[derive(Debug, Clone, Args)]
1096#[command(
1097 group(
1098 ArgGroup::new("selector")
1099 .required(true)
1100 .multiple(false)
1101 .args(["window_id", "active_window", "app"])
1102 )
1103)]
1104pub struct WaitWindowPresentArgs {
1105 #[arg(long)]
1107 pub window_id: Option<u32>,
1108
1109 #[arg(long)]
1111 pub active_window: bool,
1112
1113 #[arg(long)]
1115 pub app: Option<String>,
1116
1117 #[arg(long = "window-title-contains", requires = "app")]
1119 pub window_name: Option<String>,
1120
1121 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1123 pub timeout_ms: u64,
1124
1125 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1127 pub poll_ms: u64,
1128}
1129
1130#[derive(Debug, Clone, Args)]
1131#[command(
1132 group(
1133 ArgGroup::new("selector")
1134 .required(true)
1135 .multiple(true)
1136 .args([
1137 "node_id",
1138 "role",
1139 "title_contains",
1140 "identifier_contains",
1141 "value_contains",
1142 "subrole",
1143 "focused",
1144 "enabled",
1145 ])
1146 ),
1147 group(
1148 ArgGroup::new("target")
1149 .required(false)
1150 .multiple(false)
1151 .args(["session_id", "app", "bundle_id"])
1152 )
1153)]
1154pub struct WaitAxPresentArgs {
1155 #[command(flatten)]
1156 pub selector: AxSelectorArgs,
1157
1158 #[command(flatten)]
1159 pub target: AxTargetArgs,
1160
1161 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1163 pub timeout_ms: u64,
1164
1165 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1167 pub poll_ms: u64,
1168}
1169
1170#[derive(Debug, Clone, Args)]
1171#[command(
1172 group(
1173 ArgGroup::new("selector")
1174 .required(true)
1175 .multiple(true)
1176 .args([
1177 "node_id",
1178 "role",
1179 "title_contains",
1180 "identifier_contains",
1181 "value_contains",
1182 "subrole",
1183 "focused",
1184 "enabled",
1185 ])
1186 ),
1187 group(
1188 ArgGroup::new("target")
1189 .required(false)
1190 .multiple(false)
1191 .args(["session_id", "app", "bundle_id"])
1192 )
1193)]
1194pub struct WaitAxUniqueArgs {
1195 #[command(flatten)]
1196 pub selector: AxSelectorArgs,
1197
1198 #[command(flatten)]
1199 pub target: AxTargetArgs,
1200
1201 #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1203 pub timeout_ms: u64,
1204
1205 #[arg(long, default_value_t = 50, visible_alias = "wait-poll-ms")]
1207 pub poll_ms: u64,
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use std::path::PathBuf;
1213
1214 use clap::Parser;
1215 use pretty_assertions::assert_eq;
1216
1217 use super::{
1218 AxActionCommand, AxAttrCommand, AxCommand, AxSessionCommand, AxWatchCommand, Cli,
1219 CommandGroup, DebugCommand, ErrorFormat, InputSourceCommand, ObserveCommand, OutputFormat,
1220 WaitCommand, WindowCommand,
1221 };
1222
1223 #[test]
1224 fn parses_window_activate_command_tree() {
1225 let cli = Cli::try_parse_from([
1226 "macos-agent",
1227 "--format",
1228 "json",
1229 "--retries",
1230 "2",
1231 "window",
1232 "activate",
1233 "--app",
1234 "Terminal",
1235 "--wait-ms",
1236 "1500",
1237 ])
1238 .expect("window activate should parse");
1239
1240 assert_eq!(cli.format, OutputFormat::Json);
1241 assert_eq!(cli.error_format, ErrorFormat::Text);
1242 assert_eq!(cli.retries, 2);
1243 match cli.command {
1244 CommandGroup::Window {
1245 command: WindowCommand::Activate(args),
1246 } => {
1247 assert_eq!(args.app.as_deref(), Some("Terminal"));
1248 assert_eq!(args.wait_ms, Some(1500));
1249 assert!(!args.reopen_on_fail);
1250 }
1251 other => panic!("unexpected command variant: {other:?}"),
1252 }
1253 }
1254
1255 #[test]
1256 fn parses_window_activate_reopen_on_fail_flag() {
1257 let cli = Cli::try_parse_from([
1258 "macos-agent",
1259 "window",
1260 "activate",
1261 "--app",
1262 "Arc",
1263 "--reopen-on-fail",
1264 ])
1265 .expect("window activate reopen-on-fail should parse");
1266
1267 match cli.command {
1268 CommandGroup::Window {
1269 command: WindowCommand::Activate(args),
1270 } => {
1271 assert_eq!(args.app.as_deref(), Some("Arc"));
1272 assert!(args.reopen_on_fail);
1273 }
1274 other => panic!("unexpected command variant: {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn parses_wait_window_present() {
1280 let cli = Cli::try_parse_from([
1281 "macos-agent",
1282 "wait",
1283 "window-present",
1284 "--app",
1285 "Terminal",
1286 "--window-title-contains",
1287 "Inbox",
1288 "--timeout-ms",
1289 "2000",
1290 "--poll-ms",
1291 "100",
1292 ])
1293 .expect("wait window-present should parse");
1294
1295 match cli.command {
1296 CommandGroup::Wait {
1297 command: WaitCommand::WindowPresent(args),
1298 } => {
1299 assert_eq!(args.app.as_deref(), Some("Terminal"));
1300 assert_eq!(args.window_name.as_deref(), Some("Inbox"));
1301 assert_eq!(args.timeout_ms, 2000);
1302 assert_eq!(args.poll_ms, 100);
1303 }
1304 other => panic!("unexpected command variant: {other:?}"),
1305 }
1306 }
1307
1308 #[test]
1309 fn parses_wait_ax_present_with_match_strategy_and_explain() {
1310 let cli = Cli::try_parse_from([
1311 "macos-agent",
1312 "wait",
1313 "ax-present",
1314 "--app",
1315 "Arc",
1316 "--role",
1317 "AXButton",
1318 "--title-contains",
1319 "^Run$",
1320 "--match-strategy",
1321 "regex",
1322 "--selector-explain",
1323 "--timeout-ms",
1324 "1800",
1325 "--poll-ms",
1326 "25",
1327 ])
1328 .expect("wait ax-present should parse");
1329
1330 match cli.command {
1331 CommandGroup::Wait {
1332 command: WaitCommand::AxPresent(args),
1333 } => {
1334 assert_eq!(args.target.app.as_deref(), Some("Arc"));
1335 assert_eq!(args.selector.filters.role.as_deref(), Some("AXButton"));
1336 assert_eq!(
1337 args.selector.filters.title_contains.as_deref(),
1338 Some("^Run$")
1339 );
1340 assert_eq!(
1341 args.selector.match_strategy,
1342 crate::model::AxMatchStrategy::Regex
1343 );
1344 assert!(args.selector.selector_explain);
1345 assert_eq!(args.timeout_ms, 1800);
1346 assert_eq!(args.poll_ms, 25);
1347 }
1348 other => panic!("unexpected command variant: {other:?}"),
1349 }
1350 }
1351
1352 #[test]
1353 fn parses_ax_click_gate_and_postcondition_flags() {
1354 let cli = Cli::try_parse_from([
1355 "macos-agent",
1356 "ax",
1357 "click",
1358 "--app",
1359 "Terminal",
1360 "--node-id",
1361 "1.1",
1362 "--gate-app-active",
1363 "--gate-window-present",
1364 "--gate-ax-present",
1365 "--gate-ax-unique",
1366 "--gate-timeout-ms",
1367 "2100",
1368 "--gate-poll-ms",
1369 "25",
1370 "--postcondition-focused",
1371 "true",
1372 "--postcondition-attribute",
1373 "AXRole",
1374 "--postcondition-attribute-value",
1375 "AXButton",
1376 "--postcondition-timeout-ms",
1377 "1800",
1378 "--postcondition-poll-ms",
1379 "20",
1380 ])
1381 .expect("ax click gate/postcondition flags should parse");
1382
1383 match cli.command {
1384 CommandGroup::Ax {
1385 command: AxCommand::Click(args),
1386 } => {
1387 assert!(args.gate.gate_app_active);
1388 assert!(args.gate.gate_window_present);
1389 assert!(args.gate.gate_ax_present);
1390 assert!(args.gate.gate_ax_unique);
1391 assert_eq!(args.gate.gate_timeout_ms, 2100);
1392 assert_eq!(args.gate.gate_poll_ms, 25);
1393 assert_eq!(args.postcondition.postcondition_focused, Some(true));
1394 assert_eq!(
1395 args.postcondition.postcondition_attribute.as_deref(),
1396 Some("AXRole")
1397 );
1398 assert_eq!(
1399 args.postcondition.postcondition_attribute_value.as_deref(),
1400 Some("AXButton")
1401 );
1402 assert_eq!(args.postcondition.postcondition_timeout_ms, 1800);
1403 assert_eq!(args.postcondition.postcondition_poll_ms, 20);
1404 }
1405 other => panic!("unexpected command variant: {other:?}"),
1406 }
1407 }
1408
1409 #[test]
1410 fn parses_ax_wait_policy_overrides() {
1411 let cli = Cli::try_parse_from([
1412 "macos-agent",
1413 "ax",
1414 "type",
1415 "--app",
1416 "Terminal",
1417 "--node-id",
1418 "1.1",
1419 "--text",
1420 "hello",
1421 "--wait-timeout-ms",
1422 "2200",
1423 "--wait-poll-ms",
1424 "40",
1425 ])
1426 .expect("ax type wait policy flags should parse");
1427
1428 match cli.command {
1429 CommandGroup::Ax {
1430 command: AxCommand::Type(args),
1431 } => {
1432 assert_eq!(args.wait_timeout_ms, Some(2200));
1433 assert_eq!(args.wait_poll_ms, Some(40));
1434 }
1435 other => panic!("unexpected command variant: {other:?}"),
1436 }
1437 }
1438
1439 #[test]
1440 fn parses_debug_bundle_and_observe_selector_padding_flags() {
1441 let debug_cli = Cli::try_parse_from([
1442 "macos-agent",
1443 "debug",
1444 "bundle",
1445 "--active-window",
1446 "--output-dir",
1447 "/tmp/debug-bundle",
1448 ])
1449 .expect("debug bundle should parse");
1450 match debug_cli.command {
1451 CommandGroup::Debug {
1452 command: DebugCommand::Bundle(args),
1453 } => {
1454 assert!(args.active_window);
1455 assert_eq!(
1456 args.output_dir.expect("output-dir should parse"),
1457 PathBuf::from("/tmp/debug-bundle")
1458 );
1459 }
1460 other => panic!("unexpected command variant: {other:?}"),
1461 }
1462
1463 let observe_cli = Cli::try_parse_from([
1464 "macos-agent",
1465 "observe",
1466 "screenshot",
1467 "--active-window",
1468 "--role",
1469 "AXButton",
1470 "--title-contains",
1471 "Run",
1472 "--selector-padding",
1473 "12",
1474 "--if-changed",
1475 "--if-changed-threshold",
1476 "4",
1477 ])
1478 .expect("observe screenshot selector flags should parse");
1479 match observe_cli.command {
1480 CommandGroup::Observe {
1481 command: ObserveCommand::Screenshot(args),
1482 } => {
1483 assert!(args.active_window);
1484 assert_eq!(args.ax_selector.filters.role.as_deref(), Some("AXButton"));
1485 assert_eq!(
1486 args.ax_selector.filters.title_contains.as_deref(),
1487 Some("Run")
1488 );
1489 assert_eq!(args.selector_padding, 12);
1490 assert!(args.if_changed);
1491 assert_eq!(args.if_changed_threshold, Some(4));
1492 assert_eq!(args.if_changed_baseline, None);
1493 }
1494 other => panic!("unexpected command variant: {other:?}"),
1495 }
1496 }
1497
1498 #[test]
1499 fn rejects_multiple_window_activate_selectors() {
1500 let err = Cli::try_parse_from([
1501 "macos-agent",
1502 "window",
1503 "activate",
1504 "--window-id",
1505 "10",
1506 "--app",
1507 "Terminal",
1508 ])
1509 .expect_err("multiple selectors must be rejected");
1510 let rendered = err.to_string();
1511 assert!(
1512 rendered.contains("cannot be used with")
1513 || rendered.contains("required arguments were not provided")
1514 );
1515 }
1516
1517 #[test]
1518 fn rejects_window_title_contains_without_app() {
1519 let err = Cli::try_parse_from([
1520 "macos-agent",
1521 "wait",
1522 "window-present",
1523 "--window-title-contains",
1524 "Inbox",
1525 ])
1526 .expect_err("window-title-contains requires app");
1527 let rendered = err.to_string();
1528 assert!(
1529 rendered.contains("requires")
1530 || rendered.contains("required arguments were not provided")
1531 );
1532 }
1533
1534 #[test]
1535 fn parses_wait_window_present_with_window_title_contains() {
1536 let cli = Cli::try_parse_from([
1537 "macos-agent",
1538 "wait",
1539 "window-present",
1540 "--app",
1541 "Terminal",
1542 "--window-title-contains",
1543 "Inbox",
1544 ])
1545 .expect("wait window-present with window-title-contains should parse");
1546
1547 match cli.command {
1548 CommandGroup::Wait {
1549 command: WaitCommand::WindowPresent(args),
1550 } => {
1551 assert_eq!(args.window_name.as_deref(), Some("Inbox"));
1552 }
1553 other => panic!("unexpected command variant: {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1558 fn parses_input_source_switch_command() {
1559 let cli = Cli::try_parse_from(["macos-agent", "input-source", "switch", "--id", "abc"])
1560 .expect("input-source switch should parse");
1561
1562 match cli.command {
1563 CommandGroup::InputSource {
1564 command: InputSourceCommand::Switch(args),
1565 } => {
1566 assert_eq!(args.id, "abc".to_string());
1567 }
1568 other => panic!("unexpected command variant: {other:?}"),
1569 }
1570 }
1571
1572 #[test]
1573 fn parses_input_type_submit() {
1574 let cli = Cli::try_parse_from([
1575 "macos-agent",
1576 "input",
1577 "type",
1578 "--text",
1579 "hello",
1580 "--submit",
1581 ])
1582 .expect("input type --submit should parse");
1583 match cli.command {
1584 CommandGroup::Input {
1585 command: super::InputCommand::Type(args),
1586 } => assert!(args.enter),
1587 other => panic!("unexpected command variant: {other:?}"),
1588 }
1589 }
1590
1591 #[test]
1592 fn parses_ax_list_with_filters() {
1593 let cli = Cli::try_parse_from([
1594 "macos-agent",
1595 "ax",
1596 "list",
1597 "--app",
1598 "Arc",
1599 "--role",
1600 "AXButton",
1601 "--title-contains",
1602 "New tab",
1603 "--max-depth",
1604 "4",
1605 "--limit",
1606 "20",
1607 ])
1608 .expect("ax list should parse");
1609
1610 match cli.command {
1611 CommandGroup::Ax {
1612 command: AxCommand::List(args),
1613 } => {
1614 assert_eq!(args.target.app.as_deref(), Some("Arc"));
1615 assert_eq!(args.filters.role.as_deref(), Some("AXButton"));
1616 assert_eq!(args.filters.title_contains.as_deref(), Some("New tab"));
1617 assert_eq!(args.max_depth, Some(4));
1618 assert_eq!(args.limit, Some(20));
1619 }
1620 other => panic!("unexpected command variant: {other:?}"),
1621 }
1622 }
1623
1624 #[test]
1625 fn parses_ax_click_node_id_selector() {
1626 let cli = Cli::try_parse_from([
1627 "macos-agent",
1628 "--dry-run",
1629 "ax",
1630 "click",
1631 "--node-id",
1632 "node-17",
1633 "--allow-coordinate-fallback",
1634 ])
1635 .expect("ax click should parse");
1636
1637 match cli.command {
1638 CommandGroup::Ax {
1639 command: AxCommand::Click(args),
1640 } => {
1641 assert_eq!(args.selector.node_id.as_deref(), Some("node-17"));
1642 assert!(args.allow_coordinate_fallback);
1643 }
1644 other => panic!("unexpected command variant: {other:?}"),
1645 }
1646 }
1647
1648 #[test]
1649 fn parses_ax_click_reselect_and_fallback_order() {
1650 let cli = Cli::try_parse_from([
1651 "macos-agent",
1652 "ax",
1653 "click",
1654 "--role",
1655 "AXButton",
1656 "--title-contains",
1657 "Run",
1658 "--reselect-before-click",
1659 "--allow-coordinate-fallback",
1660 "--fallback-order",
1661 "ax-press,ax-confirm,frame-center,coordinate",
1662 ])
1663 .expect("ax click reselect/fallback-order should parse");
1664
1665 match cli.command {
1666 CommandGroup::Ax {
1667 command: AxCommand::Click(args),
1668 } => {
1669 assert!(args.reselect_before_click);
1670 assert_eq!(
1671 args.fallback_order,
1672 vec![
1673 crate::model::AxClickFallbackStage::AxPress,
1674 crate::model::AxClickFallbackStage::AxConfirm,
1675 crate::model::AxClickFallbackStage::FrameCenter,
1676 crate::model::AxClickFallbackStage::Coordinate,
1677 ]
1678 );
1679 }
1680 other => panic!("unexpected command variant: {other:?}"),
1681 }
1682 }
1683
1684 #[test]
1685 fn parses_ax_type_compound_selector() {
1686 let cli = Cli::try_parse_from([
1687 "macos-agent",
1688 "ax",
1689 "type",
1690 "--role",
1691 "AXTextField",
1692 "--title-contains",
1693 "Search",
1694 "--nth",
1695 "2",
1696 "--text",
1697 "hello",
1698 "--clear-first",
1699 "--submit",
1700 "--paste",
1701 "--allow-keyboard-fallback",
1702 ])
1703 .expect("ax type should parse");
1704
1705 match cli.command {
1706 CommandGroup::Ax {
1707 command: AxCommand::Type(args),
1708 } => {
1709 assert_eq!(args.selector.filters.role.as_deref(), Some("AXTextField"));
1710 assert_eq!(
1711 args.selector.filters.title_contains.as_deref(),
1712 Some("Search")
1713 );
1714 assert_eq!(args.selector.nth, Some(2));
1715 assert_eq!(args.text, "hello");
1716 assert!(args.clear_first);
1717 assert!(args.submit);
1718 assert!(args.paste);
1719 assert!(args.allow_keyboard_fallback);
1720 }
1721 other => panic!("unexpected command variant: {other:?}"),
1722 }
1723 }
1724
1725 #[test]
1726 fn rejects_ax_click_mixed_selectors() {
1727 let err = Cli::try_parse_from([
1728 "macos-agent",
1729 "ax",
1730 "click",
1731 "--node-id",
1732 "node-17",
1733 "--role",
1734 "AXButton",
1735 "--title-contains",
1736 "Save",
1737 ])
1738 .expect_err("selector mix should be rejected");
1739 let rendered = err.to_string();
1740 assert!(
1741 rendered.contains("cannot be used with")
1742 || rendered.contains("required arguments were not provided")
1743 );
1744 }
1745
1746 #[test]
1747 fn parses_ax_type_role_without_title_contains() {
1748 let cli = Cli::try_parse_from([
1749 "macos-agent",
1750 "ax",
1751 "type",
1752 "--role",
1753 "AXTextField",
1754 "--text",
1755 "hello",
1756 ])
1757 .expect("role-only selector should parse");
1758 match cli.command {
1759 CommandGroup::Ax {
1760 command: AxCommand::Type(args),
1761 } => {
1762 assert_eq!(args.selector.filters.role.as_deref(), Some("AXTextField"));
1763 assert!(args.selector.filters.title_contains.is_none());
1764 }
1765 other => panic!("unexpected command variant: {other:?}"),
1766 }
1767 }
1768
1769 #[test]
1770 fn rejects_ax_type_nth_without_selector_filter() {
1771 let err =
1772 Cli::try_parse_from(["macos-agent", "ax", "type", "--nth", "2", "--text", "hello"])
1773 .expect_err("nth alone should be rejected by selector group");
1774 let rendered = err.to_string();
1775 assert!(rendered.contains("required arguments were not provided"));
1776 }
1777
1778 #[test]
1779 fn rejects_ax_list_multiple_target_selectors() {
1780 let err = Cli::try_parse_from([
1781 "macos-agent",
1782 "ax",
1783 "list",
1784 "--app",
1785 "Arc",
1786 "--bundle-id",
1787 "com.apple.Safari",
1788 ])
1789 .expect_err("app and bundle-id should be mutually exclusive");
1790 let rendered = err.to_string();
1791 assert!(rendered.contains("cannot be used with"));
1792 }
1793
1794 #[test]
1795 fn parses_ax_attr_get_and_set_commands() {
1796 let get_cli = Cli::try_parse_from([
1797 "macos-agent",
1798 "ax",
1799 "attr",
1800 "get",
1801 "--node-id",
1802 "1.2",
1803 "--name",
1804 "AXRole",
1805 ])
1806 .expect("ax attr get should parse");
1807 match get_cli.command {
1808 CommandGroup::Ax {
1809 command:
1810 AxCommand::Attr {
1811 command: AxAttrCommand::Get(args),
1812 },
1813 } => {
1814 assert_eq!(args.selector.node_id.as_deref(), Some("1.2"));
1815 assert_eq!(args.name, "AXRole");
1816 }
1817 other => panic!("unexpected command variant: {other:?}"),
1818 }
1819
1820 let set_cli = Cli::try_parse_from([
1821 "macos-agent",
1822 "ax",
1823 "attr",
1824 "set",
1825 "--role",
1826 "AXTextField",
1827 "--title-contains",
1828 "Search",
1829 "--name",
1830 "AXValue",
1831 "--value",
1832 "hello",
1833 "--value-type",
1834 "string",
1835 ])
1836 .expect("ax attr set should parse");
1837 match set_cli.command {
1838 CommandGroup::Ax {
1839 command:
1840 AxCommand::Attr {
1841 command: AxAttrCommand::Set(args),
1842 },
1843 } => {
1844 assert_eq!(args.selector.filters.role.as_deref(), Some("AXTextField"));
1845 assert_eq!(
1846 args.selector.filters.title_contains.as_deref(),
1847 Some("Search")
1848 );
1849 assert_eq!(args.name, "AXValue");
1850 assert_eq!(args.value, "hello");
1851 }
1852 other => panic!("unexpected command variant: {other:?}"),
1853 }
1854 }
1855
1856 #[test]
1857 fn parses_ax_action_session_and_watch_commands() {
1858 let action_cli = Cli::try_parse_from([
1859 "macos-agent",
1860 "ax",
1861 "action",
1862 "perform",
1863 "--node-id",
1864 "1.1",
1865 "--name",
1866 "AXPress",
1867 ])
1868 .expect("ax action perform should parse");
1869 match action_cli.command {
1870 CommandGroup::Ax {
1871 command:
1872 AxCommand::Action {
1873 command: AxActionCommand::Perform(args),
1874 },
1875 } => {
1876 assert_eq!(args.selector.node_id.as_deref(), Some("1.1"));
1877 assert_eq!(args.name, "AXPress");
1878 }
1879 other => panic!("unexpected command variant: {other:?}"),
1880 }
1881
1882 let session_cli = Cli::try_parse_from([
1883 "macos-agent",
1884 "ax",
1885 "session",
1886 "start",
1887 "--app",
1888 "Arc",
1889 "--session-id",
1890 "axs-demo",
1891 ])
1892 .expect("ax session start should parse");
1893 match session_cli.command {
1894 CommandGroup::Ax {
1895 command:
1896 AxCommand::Session {
1897 command: AxSessionCommand::Start(args),
1898 },
1899 } => {
1900 assert_eq!(args.app.as_deref(), Some("Arc"));
1901 assert_eq!(args.session_id.as_deref(), Some("axs-demo"));
1902 }
1903 other => panic!("unexpected command variant: {other:?}"),
1904 }
1905
1906 let watch_cli = Cli::try_parse_from([
1907 "macos-agent",
1908 "ax",
1909 "watch",
1910 "start",
1911 "--session-id",
1912 "axs-demo",
1913 "--events",
1914 "AXTitleChanged,AXFocusedUIElementChanged",
1915 ])
1916 .expect("ax watch start should parse");
1917 match watch_cli.command {
1918 CommandGroup::Ax {
1919 command:
1920 AxCommand::Watch {
1921 command: AxWatchCommand::Start(args),
1922 },
1923 } => {
1924 assert_eq!(args.session_id, "axs-demo");
1925 assert_eq!(args.events.len(), 2);
1926 }
1927 other => panic!("unexpected command variant: {other:?}"),
1928 }
1929 }
1930}