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 AGENT_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    /// Print shell completion script.
157    Completion(CompletionArgs),
158}
159
160#[derive(Debug, Clone, Args)]
161pub struct CompletionArgs {
162    /// Shell to generate completion for.
163    #[arg(value_enum)]
164    pub shell: crate::completion::CompletionShell,
165}
166
167#[derive(Debug, Clone, Args)]
168pub struct PreflightArgs {
169    /// Treat advisory warnings as readiness failures.
170    #[arg(long)]
171    pub strict: bool,
172
173    /// Run actionable probes (activate/input/screenshot) in addition to static checks.
174    #[arg(long)]
175    pub include_probes: bool,
176}
177
178#[derive(Debug, Clone, Subcommand)]
179pub enum WindowsCommand {
180    /// List windows.
181    List(ListWindowsArgs),
182}
183
184#[derive(Debug, Clone, Args)]
185pub struct ListWindowsArgs {
186    /// Filter by app/owner name.
187    #[arg(long)]
188    pub app: Option<String>,
189
190    /// Narrow app selection by window title substring.
191    #[arg(long = "window-title-contains", requires = "app")]
192    pub window_name: Option<String>,
193
194    /// Include only on-screen windows.
195    #[arg(long)]
196    pub on_screen_only: bool,
197}
198
199#[derive(Debug, Clone, Subcommand)]
200pub enum AppsCommand {
201    /// List running apps.
202    List(ListAppsArgs),
203}
204
205#[derive(Debug, Clone, Args, Default)]
206pub struct ListAppsArgs {}
207
208#[derive(Debug, Clone, Subcommand)]
209pub enum WindowCommand {
210    /// Activate a target window/app.
211    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    /// Select by window id.
225    #[arg(long)]
226    pub window_id: Option<u32>,
227
228    /// Select frontmost active window.
229    #[arg(long)]
230    pub active_window: bool,
231
232    /// Select by app name.
233    #[arg(long)]
234    pub app: Option<String>,
235
236    /// Narrow app selection by window title substring.
237    #[arg(long = "window-title-contains", requires = "app")]
238    pub window_name: Option<String>,
239
240    /// Select by bundle id.
241    #[arg(long)]
242    pub bundle_id: Option<String>,
243
244    /// Wait up to this many milliseconds for active confirmation.
245    #[arg(long)]
246    pub wait_ms: Option<u64>,
247
248    /// If activation/confirmation fails, quit and relaunch the target app once then retry.
249    #[arg(long, default_value_t = false)]
250    pub reopen_on_fail: bool,
251}
252
253#[derive(Debug, Clone, Subcommand)]
254pub enum InputCommand {
255    /// Click at x/y coordinates.
256    Click(InputClickArgs),
257
258    /// Type text.
259    Type(InputTypeArgs),
260
261    /// Send hotkey chord.
262    Hotkey(InputHotkeyArgs),
263}
264
265#[derive(Debug, Clone, Subcommand)]
266pub enum InputSourceCommand {
267    /// Show current keyboard input source id.
268    Current(InputSourceCurrentArgs),
269
270    /// Switch to a keyboard input source id.
271    Switch(InputSourceSwitchArgs),
272}
273
274#[derive(Debug, Clone, Args, Default)]
275pub struct InputSourceCurrentArgs {}
276
277#[derive(Debug, Clone, Args)]
278pub struct InputSourceSwitchArgs {
279    /// Input source id or alias (`abc`, `us`, or full source id).
280    #[arg(long)]
281    pub id: String,
282}
283
284#[derive(Debug, Clone, Subcommand)]
285pub enum AxCommand {
286    /// List AX nodes.
287    List(AxListArgs),
288
289    /// Click an AX node.
290    Click(AxClickArgs),
291
292    /// Type text into an AX node.
293    Type(AxTypeArgs),
294
295    /// Read or set arbitrary AX attributes.
296    Attr {
297        #[command(subcommand)]
298        command: AxAttrCommand,
299    },
300
301    /// Perform arbitrary AX actions.
302    Action {
303        #[command(subcommand)]
304        command: AxActionCommand,
305    },
306
307    /// Manage long-lived AX sessions.
308    Session {
309        #[command(subcommand)]
310        command: AxSessionCommand,
311    },
312
313    /// Start/poll/stop AX notification watchers.
314    Watch {
315        #[command(subcommand)]
316        command: AxWatchCommand,
317    },
318}
319
320#[derive(Debug, Clone, Subcommand)]
321pub enum AxAttrCommand {
322    /// Read an AX attribute value.
323    Get(AxAttrGetArgs),
324
325    /// Set an AX attribute value.
326    Set(AxAttrSetArgs),
327}
328
329#[derive(Debug, Clone, Subcommand)]
330pub enum AxActionCommand {
331    /// Perform an AX action.
332    Perform(AxActionPerformArgs),
333}
334
335#[derive(Debug, Clone, Subcommand)]
336pub enum AxSessionCommand {
337    /// Create or update an AX session.
338    Start(AxSessionStartArgs),
339
340    /// List active AX sessions.
341    List(AxSessionListArgs),
342
343    /// Stop an AX session.
344    Stop(AxSessionStopArgs),
345}
346
347#[derive(Debug, Clone, Subcommand)]
348pub enum AxWatchCommand {
349    /// Start a watcher bound to an AX session.
350    Start(AxWatchStartArgs),
351
352    /// Poll buffered watcher events.
353    Poll(AxWatchPollArgs),
354
355    /// Stop a watcher.
356    Stop(AxWatchStopArgs),
357}
358
359#[derive(Debug, Clone, Args, Default)]
360pub struct AxTargetArgs {
361    /// Select target by existing session id.
362    #[arg(long)]
363    pub session_id: Option<String>,
364
365    /// Select target app by name.
366    #[arg(long)]
367    pub app: Option<String>,
368
369    /// Select target app by bundle id.
370    #[arg(long)]
371    pub bundle_id: Option<String>,
372
373    /// Filter roots by window title substring.
374    #[arg(long)]
375    pub window_title_contains: Option<String>,
376}
377
378#[derive(Debug, Clone, Args, Default)]
379pub struct AxMatchFiltersArgs {
380    /// Select by AX role.
381    #[arg(long)]
382    pub role: Option<String>,
383
384    /// Select by title substring.
385    #[arg(long)]
386    pub title_contains: Option<String>,
387
388    /// Select by identifier substring.
389    #[arg(long)]
390    pub identifier_contains: Option<String>,
391
392    /// Select by value substring.
393    #[arg(long)]
394    pub value_contains: Option<String>,
395
396    /// Select by AX subrole.
397    #[arg(long)]
398    pub subrole: Option<String>,
399
400    /// Select by focused state.
401    #[arg(long)]
402    pub focused: Option<bool>,
403
404    /// Select by enabled state.
405    #[arg(long)]
406    pub enabled: Option<bool>,
407}
408
409#[derive(Debug, Clone, Args, Default)]
410pub struct AxSelectorArgs {
411    /// Select by node id from `ax list`.
412    #[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    /// Select the nth match from compound selector results.
431    #[arg(long)]
432    pub nth: Option<u32>,
433
434    /// Text match strategy for selector filters.
435    #[arg(long, value_enum, default_value_t = AxMatchStrategy::Contains)]
436    pub match_strategy: AxMatchStrategy,
437
438    /// Emit selector explain diagnostics in JSON output.
439    #[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    /// Limit traversal depth.
460    #[arg(long)]
461    pub max_depth: Option<u32>,
462
463    /// Limit number of returned nodes.
464    #[arg(long)]
465    pub limit: Option<u32>,
466}
467
468#[derive(Debug, Clone, Args)]
469pub struct AxActionGateArgs {
470    /// Require app active gate before mutation.
471    #[arg(long)]
472    pub gate_app_active: bool,
473
474    /// Require target window present gate before mutation.
475    #[arg(long)]
476    pub gate_window_present: bool,
477
478    /// Require AX selector to resolve to at least one node before mutation.
479    #[arg(long)]
480    pub gate_ax_present: bool,
481
482    /// Require AX selector to resolve to exactly one node before mutation.
483    #[arg(long)]
484    pub gate_ax_unique: bool,
485
486    /// Timeout for gate checks in milliseconds.
487    #[arg(long, default_value_t = 1500)]
488    pub gate_timeout_ms: u64,
489
490    /// Poll interval for gate checks in milliseconds.
491    #[arg(long, default_value_t = 50)]
492    pub gate_poll_ms: u64,
493}
494
495#[derive(Debug, Clone, Args)]
496pub struct AxPostconditionArgs {
497    /// Require focused state after mutation.
498    #[arg(long)]
499    pub postcondition_focused: Option<bool>,
500
501    /// Require an AX attribute/value pair after mutation.
502    #[arg(long, requires = "postcondition_attribute_value")]
503    pub postcondition_attribute: Option<String>,
504
505    /// Expected value for --postcondition-attribute.
506    #[arg(long, requires = "postcondition_attribute")]
507    pub postcondition_attribute_value: Option<String>,
508
509    /// Timeout for postcondition checks in milliseconds.
510    #[arg(long, default_value_t = 1500)]
511    pub postcondition_timeout_ms: u64,
512
513    /// Poll interval for postcondition checks in milliseconds.
514    #[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    /// Allow coordinate fallback when AX press is unavailable.
550    #[arg(long)]
551    pub allow_coordinate_fallback: bool,
552
553    /// Re-resolve selector to node id immediately before click execution.
554    #[arg(long)]
555    pub reselect_before_click: bool,
556
557    /// Configure click fallback stage order (`ax-press,ax-confirm,frame-center,coordinate`).
558    #[arg(long, value_enum, value_delimiter = ',', num_args = 1.., value_name = "STAGE")]
559    pub fallback_order: Vec<AxClickFallbackStage>,
560
561    /// Unified wait-policy timeout override for gate/postcondition checks.
562    #[arg(long)]
563    pub wait_timeout_ms: Option<u64>,
564
565    /// Unified wait-policy poll interval override for gate/postcondition checks.
566    #[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    /// Text to type.
608    #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
609    pub text: String,
610
611    /// Clear field value before typing.
612    #[arg(long)]
613    pub clear_first: bool,
614
615    /// Submit (Enter) after typing.
616    #[arg(long)]
617    pub submit: bool,
618
619    /// Use clipboard paste strategy.
620    #[arg(long)]
621    pub paste: bool,
622
623    /// Allow keyboard fallback when AX value set/focus is unavailable.
624    #[arg(long)]
625    pub allow_keyboard_fallback: bool,
626
627    /// Unified wait-policy timeout override for gate/postcondition checks.
628    #[arg(long)]
629    pub wait_timeout_ms: Option<u64>,
630
631    /// Unified wait-policy poll interval override for gate/postcondition checks.
632    #[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    /// AX attribute name.
674    #[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    /// AX attribute name.
719    #[arg(long)]
720    pub name: String,
721
722    /// AX attribute value.
723    #[arg(long)]
724    pub value: String,
725
726    /// Parse type for --value.
727    #[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    /// AX action name.
763    #[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    /// Select target app by name.
778    #[arg(long)]
779    pub app: Option<String>,
780
781    /// Select target app by bundle id.
782    #[arg(long)]
783    pub bundle_id: Option<String>,
784
785    /// Optionally name the session id.
786    #[arg(long)]
787    pub session_id: Option<String>,
788
789    /// Persist window title root filter in this session.
790    #[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    /// Session id to remove.
800    #[arg(long)]
801    pub session_id: String,
802}
803
804#[derive(Debug, Clone, Args)]
805pub struct AxWatchStartArgs {
806    /// Session id to bind watcher to.
807    #[arg(long)]
808    pub session_id: String,
809
810    /// Optional watcher id.
811    #[arg(long)]
812    pub watch_id: Option<String>,
813
814    /// Comma-separated AX notification names.
815    #[arg(
816        long,
817        value_delimiter = ',',
818        default_value = "AXFocusedUIElementChanged,AXTitleChanged"
819    )]
820    pub events: Vec<String>,
821
822    /// Maximum in-memory event buffer size.
823    #[arg(long, default_value_t = 256)]
824    pub max_buffer: usize,
825}
826
827#[derive(Debug, Clone, Args)]
828pub struct AxWatchPollArgs {
829    /// Watch id to poll.
830    #[arg(long)]
831    pub watch_id: String,
832
833    /// Max events to return.
834    #[arg(long, default_value_t = 50)]
835    pub limit: usize,
836
837    /// Drain returned events from watcher buffer.
838    #[arg(long, default_value_t = true)]
839    pub drain: bool,
840}
841
842#[derive(Debug, Clone, Args)]
843pub struct AxWatchStopArgs {
844    /// Watch id to stop.
845    #[arg(long)]
846    pub watch_id: String,
847}
848
849#[derive(Debug, Clone, Args)]
850pub struct InputClickArgs {
851    /// X coordinate in pixels.
852    #[arg(long)]
853    pub x: i32,
854
855    /// Y coordinate in pixels.
856    #[arg(long)]
857    pub y: i32,
858
859    /// Mouse button.
860    #[arg(long, value_enum, default_value_t = MouseButton::Left)]
861    pub button: MouseButton,
862
863    /// Number of clicks.
864    #[arg(long, default_value_t = 1)]
865    pub count: u8,
866
867    /// Wait before clicking.
868    #[arg(long, default_value_t = 0)]
869    pub pre_wait_ms: u64,
870
871    /// Wait after clicking.
872    #[arg(long, default_value_t = 0)]
873    pub post_wait_ms: u64,
874}
875
876#[derive(Debug, Clone, Args)]
877pub struct InputTypeArgs {
878    /// Text to type.
879    #[arg(long)]
880    pub text: String,
881
882    /// Delay between key events.
883    #[arg(long)]
884    pub delay_ms: Option<u64>,
885
886    /// Press Enter after typing.
887    #[arg(long = "submit")]
888    pub enter: bool,
889}
890
891#[derive(Debug, Clone, Args)]
892pub struct InputHotkeyArgs {
893    /// Modifier keys, comma-separated.
894    #[arg(long)]
895    pub mods: String,
896
897    /// Main key.
898    #[arg(long)]
899    pub key: String,
900}
901
902#[derive(Debug, Clone, Subcommand)]
903pub enum ObserveCommand {
904    /// Capture a screenshot.
905    Screenshot(ObserveScreenshotArgs),
906}
907
908#[derive(Debug, Clone, Subcommand)]
909pub enum DebugCommand {
910    /// Capture a deterministic triage artifact bundle.
911    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    /// Select by window id.
925    #[arg(long)]
926    pub window_id: Option<u32>,
927
928    /// Select frontmost active window.
929    #[arg(long)]
930    pub active_window: bool,
931
932    /// Select by app name.
933    #[arg(long)]
934    pub app: Option<String>,
935
936    /// Narrow app selection by window title substring.
937    #[arg(long = "window-title-contains", requires = "app")]
938    pub window_name: Option<String>,
939
940    /// Output directory for debug artifacts.
941    #[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    /// Select by window id.
956    #[arg(long)]
957    pub window_id: Option<u32>,
958
959    /// Select frontmost active window.
960    #[arg(long)]
961    pub active_window: bool,
962
963    /// Select by app name.
964    #[arg(long)]
965    pub app: Option<String>,
966
967    /// Narrow app selection by window title substring.
968    #[arg(long = "window-title-contains", requires = "app")]
969    pub window_name: Option<String>,
970
971    /// Output path.
972    #[arg(long)]
973    pub path: Option<PathBuf>,
974
975    /// Output image format.
976    #[arg(long, value_enum)]
977    pub image_format: Option<ImageFormat>,
978
979    #[command(flatten)]
980    pub ax_selector: AxSelectorArgs,
981
982    /// Expand selector frame by N pixels in each direction.
983    #[arg(long, default_value_t = 0)]
984    pub selector_padding: i32,
985
986    /// Skip writing a new screenshot when the capture hash stays within threshold.
987    #[arg(long)]
988    pub if_changed: bool,
989
990    /// Optional baseline image path used for hash comparison.
991    #[arg(long, value_name = "path", requires = "if_changed")]
992    pub if_changed_baseline: Option<PathBuf>,
993
994    /// Maximum allowed hash-distance bits before capture is considered changed.
995    #[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 for a fixed duration.
1007    Sleep(WaitSleepArgs),
1008
1009    /// Wait for app to become active.
1010    AppActive(WaitAppActiveArgs),
1011
1012    /// Wait for target window to appear.
1013    WindowPresent(WaitWindowPresentArgs),
1014
1015    /// Wait until AX selector resolves to at least one node.
1016    AxPresent(WaitAxPresentArgs),
1017
1018    /// Wait until AX selector resolves to exactly one node.
1019    AxUnique(WaitAxUniqueArgs),
1020}
1021
1022#[derive(Debug, Clone, Subcommand)]
1023pub enum ScenarioCommand {
1024    /// Run steps from a scenario JSON file.
1025    Run(ScenarioRunArgs),
1026}
1027
1028#[derive(Debug, Clone, Args)]
1029pub struct ScenarioRunArgs {
1030    /// Scenario JSON file path.
1031    #[arg(long)]
1032    pub file: PathBuf,
1033}
1034
1035#[derive(Debug, Clone, Subcommand)]
1036pub enum ProfileCommand {
1037    /// Validate profile schema and coordinate bounds.
1038    Validate(ProfileValidateArgs),
1039    /// Write a scaffold profile template.
1040    Init(ProfileInitArgs),
1041}
1042
1043#[derive(Debug, Clone, Args)]
1044pub struct ProfileValidateArgs {
1045    /// Profile JSON file path.
1046    #[arg(long)]
1047    pub file: PathBuf,
1048}
1049
1050#[derive(Debug, Clone, Args)]
1051pub struct ProfileInitArgs {
1052    /// Profile name to embed in generated scaffold.
1053    #[arg(long, default_value = "default-1440p")]
1054    pub name: String,
1055
1056    /// Output path for scaffold JSON.
1057    #[arg(long)]
1058    pub path: Option<PathBuf>,
1059}
1060
1061#[derive(Debug, Clone, Args)]
1062pub struct WaitSleepArgs {
1063    /// Sleep duration in milliseconds.
1064    #[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    /// App name.
1079    #[arg(long)]
1080    pub app: Option<String>,
1081
1082    /// Bundle id.
1083    #[arg(long)]
1084    pub bundle_id: Option<String>,
1085
1086    /// Timeout in milliseconds.
1087    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1088    pub timeout_ms: u64,
1089
1090    /// Poll interval in milliseconds.
1091    #[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    /// Select by window id.
1106    #[arg(long)]
1107    pub window_id: Option<u32>,
1108
1109    /// Select frontmost active window.
1110    #[arg(long)]
1111    pub active_window: bool,
1112
1113    /// Select by app name.
1114    #[arg(long)]
1115    pub app: Option<String>,
1116
1117    /// Narrow app selection by window title substring.
1118    #[arg(long = "window-title-contains", requires = "app")]
1119    pub window_name: Option<String>,
1120
1121    /// Timeout in milliseconds.
1122    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1123    pub timeout_ms: u64,
1124
1125    /// Poll interval in milliseconds.
1126    #[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    /// Timeout in milliseconds.
1162    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1163    pub timeout_ms: u64,
1164
1165    /// Poll interval in milliseconds.
1166    #[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    /// Timeout in milliseconds.
1202    #[arg(long, default_value_t = 1500, visible_alias = "wait-timeout-ms")]
1203    pub timeout_ms: u64,
1204
1205    /// Poll interval in milliseconds.
1206    #[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}