Skip to main content

macos_agent/
cli.rs

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    /// Output format.
49    #[arg(long, value_enum, default_value_t = OutputFormat::Text, global = true)]
50    pub format: OutputFormat,
51
52    /// Error output format.
53    #[arg(long, value_enum, default_value_t = ErrorFormat::Text, global = true)]
54    pub error_format: ErrorFormat,
55
56    /// Print planned actions without mutating desktop state.
57    #[arg(long, global = true)]
58    pub dry_run: bool,
59
60    /// Retry count for mutating actions.
61    #[arg(long, default_value_t = 0, global = true)]
62    pub retries: u8,
63
64    /// Delay between retries in milliseconds.
65    #[arg(long, default_value_t = 150, global = true)]
66    pub retry_delay_ms: u64,
67
68    /// Per-action timeout in milliseconds.
69    #[arg(long, default_value_t = 4000, global = true)]
70    pub timeout_ms: u64,
71
72    /// Emit per-command trace artifacts under CODEX_HOME/out.
73    #[arg(long, global = true)]
74    pub trace: bool,
75
76    /// Override trace output directory (requires --trace).
77    #[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    /// Check runtime dependencies and permissions.
88    Preflight(PreflightArgs),
89
90    /// List windows.
91    Windows {
92        #[command(subcommand)]
93        command: WindowsCommand,
94    },
95
96    /// List running apps.
97    Apps {
98        #[command(subcommand)]
99        command: AppsCommand,
100    },
101
102    /// Activate a target window/app.
103    Window {
104        #[command(subcommand)]
105        command: WindowCommand,
106    },
107
108    /// Execute pointer and keyboard input commands.
109    Input {
110        #[command(subcommand)]
111        command: InputCommand,
112    },
113
114    /// Query and switch macOS keyboard input sources.
115    InputSource {
116        #[command(subcommand)]
117        command: InputSourceCommand,
118    },
119
120    /// Query and interact with Accessibility (AX) nodes.
121    Ax {
122        #[command(subcommand)]
123        command: AxCommand,
124    },
125
126    /// Capture screenshots for observation.
127    Observe {
128        #[command(subcommand)]
129        command: ObserveCommand,
130    },
131
132    /// Collect one-shot debug artifacts for triage.
133    Debug {
134        #[command(subcommand)]
135        command: DebugCommand,
136    },
137
138    /// Wait primitives for UI stabilization.
139    Wait {
140        #[command(subcommand)]
141        command: WaitCommand,
142    },
143
144    /// Run declarative multi-step command chains.
145    Scenario {
146        #[command(subcommand)]
147        command: ScenarioCommand,
148    },
149
150    /// Validate and bootstrap coordinate profiles.
151    Profile {
152        #[command(subcommand)]
153        command: ProfileCommand,
154    },
155}
156
157#[derive(Debug, Clone, Args)]
158pub struct PreflightArgs {
159    /// Treat advisory warnings as readiness failures.
160    #[arg(long)]
161    pub strict: bool,
162
163    /// Run actionable probes (activate/input/screenshot) in addition to static checks.
164    #[arg(long)]
165    pub include_probes: bool,
166}
167
168#[derive(Debug, Clone, Subcommand)]
169pub enum WindowsCommand {
170    /// List windows.
171    List(ListWindowsArgs),
172}
173
174#[derive(Debug, Clone, Args)]
175pub struct ListWindowsArgs {
176    /// Filter by app/owner name.
177    #[arg(long)]
178    pub app: Option<String>,
179
180    /// Narrow app selection by window title substring.
181    #[arg(
182        long = "window-title-contains",
183        visible_alias = "window-name",
184        requires = "app"
185    )]
186    pub window_name: Option<String>,
187
188    /// Include only on-screen windows.
189    #[arg(long)]
190    pub on_screen_only: bool,
191}
192
193#[derive(Debug, Clone, Subcommand)]
194pub enum AppsCommand {
195    /// List running apps.
196    List(ListAppsArgs),
197}
198
199#[derive(Debug, Clone, Args, Default)]
200pub struct ListAppsArgs {}
201
202#[derive(Debug, Clone, Subcommand)]
203pub enum WindowCommand {
204    /// Activate a target window/app.
205    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    /// Select by window id.
219    #[arg(long)]
220    pub window_id: Option<u32>,
221
222    /// Select frontmost active window.
223    #[arg(long)]
224    pub active_window: bool,
225
226    /// Select by app name.
227    #[arg(long)]
228    pub app: Option<String>,
229
230    /// Narrow app selection by window title substring.
231    #[arg(
232        long = "window-title-contains",
233        visible_alias = "window-name",
234        requires = "app"
235    )]
236    pub window_name: Option<String>,
237
238    /// Select by bundle id.
239    #[arg(long)]
240    pub bundle_id: Option<String>,
241
242    /// Wait up to this many milliseconds for active confirmation.
243    #[arg(long)]
244    pub wait_ms: Option<u64>,
245
246    /// If activation/confirmation fails, quit and relaunch the target app once then retry.
247    #[arg(long, default_value_t = false)]
248    pub reopen_on_fail: bool,
249}
250
251#[derive(Debug, Clone, Subcommand)]
252pub enum InputCommand {
253    /// Click at x/y coordinates.
254    Click(InputClickArgs),
255
256    /// Type text.
257    Type(InputTypeArgs),
258
259    /// Send hotkey chord.
260    Hotkey(InputHotkeyArgs),
261}
262
263#[derive(Debug, Clone, Subcommand)]
264pub enum InputSourceCommand {
265    /// Show current keyboard input source id.
266    Current(InputSourceCurrentArgs),
267
268    /// Switch to a keyboard input source id.
269    Switch(InputSourceSwitchArgs),
270}
271
272#[derive(Debug, Clone, Args, Default)]
273pub struct InputSourceCurrentArgs {}
274
275#[derive(Debug, Clone, Args)]
276pub struct InputSourceSwitchArgs {
277    /// Input source id or alias (`abc`, `us`, or full source id).
278    #[arg(long)]
279    pub id: String,
280}
281
282#[derive(Debug, Clone, Subcommand)]
283pub enum AxCommand {
284    /// List AX nodes.
285    List(AxListArgs),
286
287    /// Click an AX node.
288    Click(AxClickArgs),
289
290    /// Type text into an AX node.
291    Type(AxTypeArgs),
292
293    /// Read or set arbitrary AX attributes.
294    Attr {
295        #[command(subcommand)]
296        command: AxAttrCommand,
297    },
298
299    /// Perform arbitrary AX actions.
300    Action {
301        #[command(subcommand)]
302        command: AxActionCommand,
303    },
304
305    /// Manage long-lived AX sessions.
306    Session {
307        #[command(subcommand)]
308        command: AxSessionCommand,
309    },
310
311    /// Start/poll/stop AX notification watchers.
312    Watch {
313        #[command(subcommand)]
314        command: AxWatchCommand,
315    },
316}
317
318#[derive(Debug, Clone, Subcommand)]
319pub enum AxAttrCommand {
320    /// Read an AX attribute value.
321    Get(AxAttrGetArgs),
322
323    /// Set an AX attribute value.
324    Set(AxAttrSetArgs),
325}
326
327#[derive(Debug, Clone, Subcommand)]
328pub enum AxActionCommand {
329    /// Perform an AX action.
330    Perform(AxActionPerformArgs),
331}
332
333#[derive(Debug, Clone, Subcommand)]
334pub enum AxSessionCommand {
335    /// Create or update an AX session.
336    Start(AxSessionStartArgs),
337
338    /// List active AX sessions.
339    List(AxSessionListArgs),
340
341    /// Stop an AX session.
342    Stop(AxSessionStopArgs),
343}
344
345#[derive(Debug, Clone, Subcommand)]
346pub enum AxWatchCommand {
347    /// Start a watcher bound to an AX session.
348    Start(AxWatchStartArgs),
349
350    /// Poll buffered watcher events.
351    Poll(AxWatchPollArgs),
352
353    /// Stop a watcher.
354    Stop(AxWatchStopArgs),
355}
356
357#[derive(Debug, Clone, Args, Default)]
358pub struct AxTargetArgs {
359    /// Select target by existing session id.
360    #[arg(long)]
361    pub session_id: Option<String>,
362
363    /// Select target app by name.
364    #[arg(long)]
365    pub app: Option<String>,
366
367    /// Select target app by bundle id.
368    #[arg(long)]
369    pub bundle_id: Option<String>,
370
371    /// Filter roots by window title substring.
372    #[arg(long)]
373    pub window_title_contains: Option<String>,
374}
375
376#[derive(Debug, Clone, Args, Default)]
377pub struct AxMatchFiltersArgs {
378    /// Select by AX role.
379    #[arg(long)]
380    pub role: Option<String>,
381
382    /// Select by title substring.
383    #[arg(long)]
384    pub title_contains: Option<String>,
385
386    /// Select by identifier substring.
387    #[arg(long)]
388    pub identifier_contains: Option<String>,
389
390    /// Select by value substring.
391    #[arg(long)]
392    pub value_contains: Option<String>,
393
394    /// Select by AX subrole.
395    #[arg(long)]
396    pub subrole: Option<String>,
397
398    /// Select by focused state.
399    #[arg(long)]
400    pub focused: Option<bool>,
401
402    /// Select by enabled state.
403    #[arg(long)]
404    pub enabled: Option<bool>,
405}
406
407#[derive(Debug, Clone, Args, Default)]
408pub struct AxSelectorArgs {
409    /// Select by node id from `ax list`.
410    #[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    /// Select the nth match from compound selector results.
429    #[arg(long)]
430    pub nth: Option<u32>,
431
432    /// Text match strategy for selector filters.
433    #[arg(long, value_enum, default_value_t = AxMatchStrategy::Contains)]
434    pub match_strategy: AxMatchStrategy,
435
436    /// Emit selector explain diagnostics in JSON output.
437    #[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    /// Limit traversal depth.
458    #[arg(long)]
459    pub max_depth: Option<u32>,
460
461    /// Limit number of returned nodes.
462    #[arg(long)]
463    pub limit: Option<u32>,
464}
465
466#[derive(Debug, Clone, Args)]
467pub struct AxActionGateArgs {
468    /// Require app active gate before mutation.
469    #[arg(long)]
470    pub gate_app_active: bool,
471
472    /// Require target window present gate before mutation.
473    #[arg(long)]
474    pub gate_window_present: bool,
475
476    /// Require AX selector to resolve to at least one node before mutation.
477    #[arg(long)]
478    pub gate_ax_present: bool,
479
480    /// Require AX selector to resolve to exactly one node before mutation.
481    #[arg(long)]
482    pub gate_ax_unique: bool,
483
484    /// Timeout for gate checks in milliseconds.
485    #[arg(long, default_value_t = 1500)]
486    pub gate_timeout_ms: u64,
487
488    /// Poll interval for gate checks in milliseconds.
489    #[arg(long, default_value_t = 50)]
490    pub gate_poll_ms: u64,
491}
492
493#[derive(Debug, Clone, Args)]
494pub struct AxPostconditionArgs {
495    /// Require focused state after mutation.
496    #[arg(long)]
497    pub postcondition_focused: Option<bool>,
498
499    /// Require an AX attribute/value pair after mutation.
500    #[arg(long, requires = "postcondition_attribute_value")]
501    pub postcondition_attribute: Option<String>,
502
503    /// Expected value for --postcondition-attribute.
504    #[arg(long, requires = "postcondition_attribute")]
505    pub postcondition_attribute_value: Option<String>,
506
507    /// Timeout for postcondition checks in milliseconds.
508    #[arg(long, default_value_t = 1500)]
509    pub postcondition_timeout_ms: u64,
510
511    /// Poll interval for postcondition checks in milliseconds.
512    #[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    /// Allow coordinate fallback when AX press is unavailable.
548    #[arg(long)]
549    pub allow_coordinate_fallback: bool,
550
551    /// Re-resolve selector to node id immediately before click execution.
552    #[arg(long)]
553    pub reselect_before_click: bool,
554
555    /// Configure click fallback stage order (`ax-press,ax-confirm,frame-center,coordinate`).
556    #[arg(long, value_enum, value_delimiter = ',', num_args = 1.., value_name = "STAGE")]
557    pub fallback_order: Vec<AxClickFallbackStage>,
558
559    /// Unified wait-policy timeout override for gate/postcondition checks.
560    #[arg(long)]
561    pub wait_timeout_ms: Option<u64>,
562
563    /// Unified wait-policy poll interval override for gate/postcondition checks.
564    #[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    /// Text to type.
606    #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
607    pub text: String,
608
609    /// Clear field value before typing.
610    #[arg(long)]
611    pub clear_first: bool,
612
613    /// Submit (Enter) after typing.
614    #[arg(long)]
615    pub submit: bool,
616
617    /// Use clipboard paste strategy.
618    #[arg(long)]
619    pub paste: bool,
620
621    /// Allow keyboard fallback when AX value set/focus is unavailable.
622    #[arg(long)]
623    pub allow_keyboard_fallback: bool,
624
625    /// Unified wait-policy timeout override for gate/postcondition checks.
626    #[arg(long)]
627    pub wait_timeout_ms: Option<u64>,
628
629    /// Unified wait-policy poll interval override for gate/postcondition checks.
630    #[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    /// AX attribute name.
672    #[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    /// AX attribute name.
717    #[arg(long)]
718    pub name: String,
719
720    /// AX attribute value.
721    #[arg(long)]
722    pub value: String,
723
724    /// Parse type for --value.
725    #[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    /// AX action name.
761    #[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    /// Select target app by name.
776    #[arg(long)]
777    pub app: Option<String>,
778
779    /// Select target app by bundle id.
780    #[arg(long)]
781    pub bundle_id: Option<String>,
782
783    /// Optionally name the session id.
784    #[arg(long)]
785    pub session_id: Option<String>,
786
787    /// Persist window title root filter in this session.
788    #[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    /// Session id to remove.
798    #[arg(long)]
799    pub session_id: String,
800}
801
802#[derive(Debug, Clone, Args)]
803pub struct AxWatchStartArgs {
804    /// Session id to bind watcher to.
805    #[arg(long)]
806    pub session_id: String,
807
808    /// Optional watcher id.
809    #[arg(long)]
810    pub watch_id: Option<String>,
811
812    /// Comma-separated AX notification names.
813    #[arg(
814        long,
815        value_delimiter = ',',
816        default_value = "AXFocusedUIElementChanged,AXTitleChanged"
817    )]
818    pub events: Vec<String>,
819
820    /// Maximum in-memory event buffer size.
821    #[arg(long, default_value_t = 256)]
822    pub max_buffer: usize,
823}
824
825#[derive(Debug, Clone, Args)]
826pub struct AxWatchPollArgs {
827    /// Watch id to poll.
828    #[arg(long)]
829    pub watch_id: String,
830
831    /// Max events to return.
832    #[arg(long, default_value_t = 50)]
833    pub limit: usize,
834
835    /// Drain returned events from watcher buffer.
836    #[arg(long, default_value_t = true)]
837    pub drain: bool,
838}
839
840#[derive(Debug, Clone, Args)]
841pub struct AxWatchStopArgs {
842    /// Watch id to stop.
843    #[arg(long)]
844    pub watch_id: String,
845}
846
847#[derive(Debug, Clone, Args)]
848pub struct InputClickArgs {
849    /// X coordinate in pixels.
850    #[arg(long)]
851    pub x: i32,
852
853    /// Y coordinate in pixels.
854    #[arg(long)]
855    pub y: i32,
856
857    /// Mouse button.
858    #[arg(long, value_enum, default_value_t = MouseButton::Left)]
859    pub button: MouseButton,
860
861    /// Number of clicks.
862    #[arg(long, default_value_t = 1)]
863    pub count: u8,
864
865    /// Wait before clicking.
866    #[arg(long, default_value_t = 0)]
867    pub pre_wait_ms: u64,
868
869    /// Wait after clicking.
870    #[arg(long, default_value_t = 0)]
871    pub post_wait_ms: u64,
872}
873
874#[derive(Debug, Clone, Args)]
875pub struct InputTypeArgs {
876    /// Text to type.
877    #[arg(long)]
878    pub text: String,
879
880    /// Delay between key events.
881    #[arg(long)]
882    pub delay_ms: Option<u64>,
883
884    /// Press Enter after typing.
885    #[arg(long = "submit", visible_alias = "enter")]
886    pub enter: bool,
887}
888
889#[derive(Debug, Clone, Args)]
890pub struct InputHotkeyArgs {
891    /// Modifier keys, comma-separated.
892    #[arg(long)]
893    pub mods: String,
894
895    /// Main key.
896    #[arg(long)]
897    pub key: String,
898}
899
900#[derive(Debug, Clone, Subcommand)]
901pub enum ObserveCommand {
902    /// Capture a screenshot.
903    Screenshot(ObserveScreenshotArgs),
904}
905
906#[derive(Debug, Clone, Subcommand)]
907pub enum DebugCommand {
908    /// Capture a deterministic triage artifact bundle.
909    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    /// Select by window id.
923    #[arg(long)]
924    pub window_id: Option<u32>,
925
926    /// Select frontmost active window.
927    #[arg(long)]
928    pub active_window: bool,
929
930    /// Select by app name.
931    #[arg(long)]
932    pub app: Option<String>,
933
934    /// Narrow app selection by window title substring.
935    #[arg(
936        long = "window-title-contains",
937        visible_alias = "window-name",
938        requires = "app"
939    )]
940    pub window_name: Option<String>,
941
942    /// Output directory for debug artifacts.
943    #[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    /// Select by window id.
958    #[arg(long)]
959    pub window_id: Option<u32>,
960
961    /// Select frontmost active window.
962    #[arg(long)]
963    pub active_window: bool,
964
965    /// Select by app name.
966    #[arg(long)]
967    pub app: Option<String>,
968
969    /// Narrow app selection by window title substring.
970    #[arg(
971        long = "window-title-contains",
972        visible_alias = "window-name",
973        requires = "app"
974    )]
975    pub window_name: Option<String>,
976
977    /// Output path.
978    #[arg(long)]
979    pub path: Option<PathBuf>,
980
981    /// Output image format.
982    #[arg(long, value_enum)]
983    pub image_format: Option<ImageFormat>,
984
985    #[command(flatten)]
986    pub ax_selector: AxSelectorArgs,
987
988    /// Expand selector frame by N pixels in each direction.
989    #[arg(long, default_value_t = 0)]
990    pub selector_padding: i32,
991
992    /// Skip writing a new screenshot when the capture hash stays within threshold.
993    #[arg(long)]
994    pub if_changed: bool,
995
996    /// Optional baseline image path used for hash comparison.
997    #[arg(long, value_name = "path", requires = "if_changed")]
998    pub if_changed_baseline: Option<PathBuf>,
999
1000    /// Maximum allowed hash-distance bits before capture is considered changed.
1001    #[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 for a fixed duration.
1013    Sleep(WaitSleepArgs),
1014
1015    /// Wait for app to become active.
1016    AppActive(WaitAppActiveArgs),
1017
1018    /// Wait for target window to appear.
1019    WindowPresent(WaitWindowPresentArgs),
1020
1021    /// Wait until AX selector resolves to at least one node.
1022    AxPresent(WaitAxPresentArgs),
1023
1024    /// Wait until AX selector resolves to exactly one node.
1025    AxUnique(WaitAxUniqueArgs),
1026}
1027
1028#[derive(Debug, Clone, Subcommand)]
1029pub enum ScenarioCommand {
1030    /// Run steps from a scenario JSON file.
1031    Run(ScenarioRunArgs),
1032}
1033
1034#[derive(Debug, Clone, Args)]
1035pub struct ScenarioRunArgs {
1036    /// Scenario JSON file path.
1037    #[arg(long)]
1038    pub file: PathBuf,
1039}
1040
1041#[derive(Debug, Clone, Subcommand)]
1042pub enum ProfileCommand {
1043    /// Validate profile schema and coordinate bounds.
1044    Validate(ProfileValidateArgs),
1045    /// Write a scaffold profile template.
1046    Init(ProfileInitArgs),
1047}
1048
1049#[derive(Debug, Clone, Args)]
1050pub struct ProfileValidateArgs {
1051    /// Profile JSON file path.
1052    #[arg(long)]
1053    pub file: PathBuf,
1054}
1055
1056#[derive(Debug, Clone, Args)]
1057pub struct ProfileInitArgs {
1058    /// Profile name to embed in generated scaffold.
1059    #[arg(long, default_value = "default-1440p")]
1060    pub name: String,
1061
1062    /// Output path for scaffold JSON.
1063    #[arg(long)]
1064    pub path: Option<PathBuf>,
1065}
1066
1067#[derive(Debug, Clone, Args)]
1068pub struct WaitSleepArgs {
1069    /// Sleep duration in milliseconds.
1070    #[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    /// App name.
1085    #[arg(long)]
1086    pub app: Option<String>,
1087
1088    /// Bundle id.
1089    #[arg(long)]
1090    pub bundle_id: Option<String>,
1091
1092    /// Timeout in milliseconds.
1093    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1094    pub timeout_ms: u64,
1095
1096    /// Poll interval in milliseconds.
1097    #[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    /// Select by window id.
1112    #[arg(long)]
1113    pub window_id: Option<u32>,
1114
1115    /// Select frontmost active window.
1116    #[arg(long)]
1117    pub active_window: bool,
1118
1119    /// Select by app name.
1120    #[arg(long)]
1121    pub app: Option<String>,
1122
1123    /// Narrow app selection by window title substring.
1124    #[arg(
1125        long = "window-title-contains",
1126        visible_alias = "window-name",
1127        requires = "app"
1128    )]
1129    pub window_name: Option<String>,
1130
1131    /// Timeout in milliseconds.
1132    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1133    pub timeout_ms: u64,
1134
1135    /// Poll interval in milliseconds.
1136    #[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    /// Timeout in milliseconds.
1172    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1173    pub timeout_ms: u64,
1174
1175    /// Poll interval in milliseconds.
1176    #[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    /// Timeout in milliseconds.
1212    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1213    pub timeout_ms: u64,
1214
1215    /// Poll interval in milliseconds.
1216    #[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}