1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::Instant;
3
4use crate::backend;
5use crate::backend::process::RealProcessRunner;
6use crate::cli::{
7 AppsCommand, AxActionCommand, AxAttrCommand, AxCommand, AxSessionCommand, AxWatchCommand, Cli,
8 CommandGroup, DebugCommand, InputCommand, InputSourceCommand, ObserveCommand, OutputFormat,
9 PreflightArgs, ProfileCommand, ScenarioCommand, WaitCommand, WindowCommand, WindowsCommand,
10};
11use crate::commands;
12use crate::error::CliError;
13use crate::model::{ActionMeta, ActionPolicyResult};
14use crate::preflight;
15use crate::retry::RetryPolicy;
16use crate::test_mode;
17
18static ACTION_COUNTER: AtomicU64 = AtomicU64::new(1);
19
20#[derive(Debug, Clone, Copy)]
21pub struct ActionPolicy {
22 pub dry_run: bool,
23 pub retries: u8,
24 pub retry_delay_ms: u64,
25 pub timeout_ms: u64,
26}
27
28impl ActionPolicy {
29 pub fn retry_policy(self) -> RetryPolicy {
30 RetryPolicy {
31 retries: self.retries,
32 retry_delay_ms: self.retry_delay_ms,
33 }
34 }
35}
36
37pub fn command_label(cli: &Cli) -> &'static str {
38 command_group_label(&cli.command)
39}
40
41pub fn command_group_label(command: &CommandGroup) -> &'static str {
42 match command {
43 CommandGroup::Preflight(_) => "preflight",
44 CommandGroup::Windows { .. } => "windows.list",
45 CommandGroup::Apps { .. } => "apps.list",
46 CommandGroup::Window { command } => match command {
47 WindowCommand::Activate(_) => "window.activate",
48 },
49 CommandGroup::Input { command } => match command {
50 InputCommand::Click(_) => "input.click",
51 InputCommand::Type(_) => "input.type",
52 InputCommand::Hotkey(_) => "input.hotkey",
53 },
54 CommandGroup::InputSource { command } => match command {
55 InputSourceCommand::Current(_) => "input-source.current",
56 InputSourceCommand::Switch(_) => "input-source.switch",
57 },
58 CommandGroup::Ax { command } => match command {
59 AxCommand::List(_) => "ax.list",
60 AxCommand::Click(_) => "ax.click",
61 AxCommand::Type(_) => "ax.type",
62 AxCommand::Attr { command } => match command {
63 AxAttrCommand::Get(_) => "ax.attr.get",
64 AxAttrCommand::Set(_) => "ax.attr.set",
65 },
66 AxCommand::Action { command } => match command {
67 AxActionCommand::Perform(_) => "ax.action.perform",
68 },
69 AxCommand::Session { command } => match command {
70 AxSessionCommand::Start(_) => "ax.session.start",
71 AxSessionCommand::List(_) => "ax.session.list",
72 AxSessionCommand::Stop(_) => "ax.session.stop",
73 },
74 AxCommand::Watch { command } => match command {
75 AxWatchCommand::Start(_) => "ax.watch.start",
76 AxWatchCommand::Poll(_) => "ax.watch.poll",
77 AxWatchCommand::Stop(_) => "ax.watch.stop",
78 },
79 },
80 CommandGroup::Observe { command } => match command {
81 ObserveCommand::Screenshot(_) => "observe.screenshot",
82 },
83 CommandGroup::Debug { command } => match command {
84 DebugCommand::Bundle(_) => "debug.bundle",
85 },
86 CommandGroup::Wait { command } => match command {
87 WaitCommand::Sleep(_) => "wait.sleep",
88 WaitCommand::AppActive(_) => "wait.app-active",
89 WaitCommand::WindowPresent(_) => "wait.window-present",
90 WaitCommand::AxPresent(_) => "wait.ax-present",
91 WaitCommand::AxUnique(_) => "wait.ax-unique",
92 },
93 CommandGroup::Scenario { command } => match command {
94 ScenarioCommand::Run(_) => "scenario.run",
95 },
96 CommandGroup::Profile { command } => match command {
97 ProfileCommand::Validate(_) => "profile.validate",
98 ProfileCommand::Init(_) => "profile.init",
99 },
100 CommandGroup::Completion(_) => "completion",
101 }
102}
103
104pub fn run(cli: Cli) -> Result<(), CliError> {
105 ensure_supported_platform()?;
106 validate_format_support(&cli)?;
107
108 let policy = ActionPolicy {
109 dry_run: cli.dry_run,
110 retries: cli.retries,
111 retry_delay_ms: cli.retry_delay_ms,
112 timeout_ms: cli.timeout_ms,
113 };
114 let runner = RealProcessRunner;
115
116 match cli.command {
117 CommandGroup::Preflight(args) => run_preflight(cli.format, args),
118 CommandGroup::Windows {
119 command: WindowsCommand::List(args),
120 } => commands::list::run_windows_list(cli.format, &args),
121 CommandGroup::Apps {
122 command: AppsCommand::List(args),
123 } => commands::list::run_apps_list(cli.format, &args),
124 CommandGroup::Observe {
125 command: ObserveCommand::Screenshot(args),
126 } => commands::observe::run_screenshot(cli.format, &args),
127 CommandGroup::Debug {
128 command: DebugCommand::Bundle(args),
129 } => commands::list::run_debug_bundle(cli.format, &args, policy, &runner),
130 CommandGroup::Wait {
131 command: WaitCommand::Sleep(args),
132 } => commands::wait::run_sleep(cli.format, &args),
133 CommandGroup::Wait {
134 command: WaitCommand::AppActive(args),
135 } => commands::wait::run_app_active(cli.format, &args),
136 CommandGroup::Wait {
137 command: WaitCommand::WindowPresent(args),
138 } => commands::wait::run_window_present(cli.format, &args),
139 CommandGroup::Wait {
140 command: WaitCommand::AxPresent(args),
141 } => commands::wait::run_ax_present(cli.format, &args, policy, &runner),
142 CommandGroup::Wait {
143 command: WaitCommand::AxUnique(args),
144 } => commands::wait::run_ax_unique(cli.format, &args, policy, &runner),
145 CommandGroup::Scenario {
146 command: ScenarioCommand::Run(args),
147 } => commands::scenario::run(cli.format, &args),
148 CommandGroup::Profile {
149 command: ProfileCommand::Validate(args),
150 } => commands::profile::run_validate(cli.format, &args),
151 CommandGroup::Profile {
152 command: ProfileCommand::Init(args),
153 } => commands::profile::run_init(cli.format, &args),
154 CommandGroup::Completion(_) => Ok(()),
155 CommandGroup::Window {
156 command: WindowCommand::Activate(args),
157 } => commands::window_activate::run(cli.format, &args, policy, &runner),
158 CommandGroup::Input {
159 command: InputCommand::Click(args),
160 } => commands::input_click::run(cli.format, &args, policy, &runner),
161 CommandGroup::Input {
162 command: InputCommand::Type(args),
163 } => commands::input_type::run(cli.format, &args, policy, &runner),
164 CommandGroup::Input {
165 command: InputCommand::Hotkey(args),
166 } => commands::input_hotkey::run(cli.format, &args, policy, &runner),
167 CommandGroup::InputSource { command } => match command {
168 InputSourceCommand::Current(args) => {
169 commands::input_source::run_current(cli.format, &args, policy, &runner)
170 }
171 InputSourceCommand::Switch(args) => {
172 commands::input_source::run_switch(cli.format, &args, policy, &runner)
173 }
174 },
175 CommandGroup::Ax { command } => match command {
176 AxCommand::List(args) => commands::ax_list::run(cli.format, &args, policy, &runner),
177 AxCommand::Click(args) => commands::ax_click::run(cli.format, &args, policy, &runner),
178 AxCommand::Type(args) => commands::ax_type::run(cli.format, &args, policy, &runner),
179 AxCommand::Attr { command } => match command {
180 AxAttrCommand::Get(args) => {
181 commands::ax_attr::run_get(cli.format, &args, policy, &runner)
182 }
183 AxAttrCommand::Set(args) => {
184 commands::ax_attr::run_set(cli.format, &args, policy, &runner)
185 }
186 },
187 AxCommand::Action { command } => match command {
188 AxActionCommand::Perform(args) => {
189 commands::ax_action::run_perform(cli.format, &args, policy, &runner)
190 }
191 },
192 AxCommand::Session { command } => match command {
193 AxSessionCommand::Start(args) => {
194 commands::ax_session::run_start(cli.format, &args, policy, &runner)
195 }
196 AxSessionCommand::List(args) => {
197 commands::ax_session::run_list(cli.format, &args, policy, &runner)
198 }
199 AxSessionCommand::Stop(args) => {
200 commands::ax_session::run_stop(cli.format, &args, policy, &runner)
201 }
202 },
203 AxCommand::Watch { command } => match command {
204 AxWatchCommand::Start(args) => {
205 commands::ax_watch::run_start(cli.format, &args, policy, &runner)
206 }
207 AxWatchCommand::Poll(args) => {
208 commands::ax_watch::run_poll(cli.format, &args, policy, &runner)
209 }
210 AxWatchCommand::Stop(args) => {
211 commands::ax_watch::run_stop(cli.format, &args, policy, &runner)
212 }
213 },
214 },
215 }
216}
217
218fn ensure_supported_platform() -> Result<(), CliError> {
219 if cfg!(target_os = "macos") || test_mode::enabled() {
220 Ok(())
221 } else {
222 Err(CliError::unsupported_platform())
223 }
224}
225
226fn validate_format_support(cli: &Cli) -> Result<(), CliError> {
227 if cli.format != OutputFormat::Tsv {
228 return Ok(());
229 }
230
231 let tsv_allowed = matches!(
232 &cli.command,
233 CommandGroup::Windows {
234 command: WindowsCommand::List(_),
235 } | CommandGroup::Apps {
236 command: AppsCommand::List(_),
237 }
238 );
239
240 if tsv_allowed {
241 Ok(())
242 } else {
243 Err(CliError::usage(
244 "--format tsv is only supported for `windows list` and `apps list`",
245 ))
246 }
247}
248
249fn run_preflight(format: OutputFormat, args: PreflightArgs) -> Result<(), CliError> {
250 let snapshot = preflight::collect_system_snapshot();
251 let probes = if args.include_probes {
252 preflight::run_live_probes()
253 } else {
254 Vec::new()
255 };
256 let mut report = preflight::build_report_with_probes(snapshot, args.strict, probes);
257 let backend_capability = backend::preflight_capability_check();
258 report.checks.push(preflight::CheckReport {
259 id: "ax_backend_capabilities",
260 label: "AX backend capabilities",
261 status: preflight::CheckStatus::Ok,
262 blocking: false,
263 message: backend_capability.message,
264 hint: backend_capability.hint,
265 });
266
267 match format {
268 OutputFormat::Text => println!("{}", preflight::render_text(&report)),
269 OutputFormat::Json => println!("{}", preflight::render_json(&report)),
270 OutputFormat::Tsv => {
271 return Err(CliError::usage(
272 "--format tsv is only supported for `windows list` and `apps list`",
273 ));
274 }
275 }
276
277 Ok(())
278}
279
280pub fn next_action_id(command: &str) -> String {
281 let sequence = ACTION_COUNTER.fetch_add(1, Ordering::SeqCst);
282 format!("{command}-{}-{sequence}", test_mode::timestamp_token())
283}
284
285pub fn build_action_meta(action_id: String, started: Instant, policy: ActionPolicy) -> ActionMeta {
286 build_action_meta_with_attempts(
287 action_id,
288 started,
289 policy,
290 if policy.dry_run { 0 } else { 1 },
291 )
292}
293
294pub fn build_action_meta_with_attempts(
295 action_id: String,
296 started: Instant,
297 policy: ActionPolicy,
298 attempts_used: u8,
299) -> ActionMeta {
300 ActionMeta {
301 action_id,
302 elapsed_ms: started.elapsed().as_millis() as u64,
303 dry_run: policy.dry_run,
304 retries: policy.retries,
305 attempts_used,
306 timeout_ms: policy.timeout_ms,
307 }
308}
309
310pub fn action_policy_result(policy: ActionPolicy) -> ActionPolicyResult {
311 ActionPolicyResult {
312 dry_run: policy.dry_run,
313 retries: policy.retries,
314 retry_delay_ms: policy.retry_delay_ms,
315 timeout_ms: policy.timeout_ms,
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use clap::Parser;
322 use nils_test_support::{EnvGuard, GlobalStateLock};
323 use pretty_assertions::assert_eq;
324
325 use crate::cli::{Cli, OutputFormat};
326
327 use super::{ensure_supported_platform, validate_format_support};
328
329 #[test]
330 fn rejects_tsv_for_non_list_commands() {
331 let cli = Cli::try_parse_from(["macos-agent", "--format", "tsv", "preflight"])
332 .expect("args should parse");
333 let err = validate_format_support(&cli).expect_err("tsv should fail for preflight");
334 assert_eq!(err.exit_code(), 2);
335 assert!(err.to_string().contains("windows list"));
336 }
337
338 #[test]
339 fn allows_tsv_for_windows_list() {
340 let cli = Cli::try_parse_from(["macos-agent", "--format", "tsv", "windows", "list"])
341 .expect("args should parse");
342 assert_eq!(cli.format, OutputFormat::Tsv);
343 validate_format_support(&cli).expect("tsv should be accepted for windows list");
344 }
345
346 #[test]
347 fn platform_gate_maps_non_macos_to_usage_error_unless_test_mode() {
348 let lock = GlobalStateLock::new();
349 let _mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
350 let result = ensure_supported_platform();
351 #[cfg(target_os = "macos")]
352 assert!(result.is_ok());
353
354 #[cfg(not(target_os = "macos"))]
355 {
356 let err = result.expect_err("non-macos should be rejected by default");
357 assert_eq!(err.exit_code(), 2);
358 assert!(err.to_string().contains("macOS"));
359 }
360 }
361}