agent_tui/
app.rs

1//! Application entry point and command dispatch.
2
3use clap::CommandFactory;
4use clap::Parser;
5use clap_complete::generate;
6
7use crate::common::{Colors, color_init};
8use crate::daemon::{DaemonError, start_daemon};
9use crate::ipc::{ClientError, DaemonClient, ensure_daemon};
10
11use crate::attach::AttachError;
12use crate::commands::{Cli, Commands, DaemonCommand, DebugCommand, RecordAction};
13use crate::handlers::{self, HandlerContext};
14
15/// Exit codes based on BSD sysexits.h
16mod exit_codes {
17    pub const SUCCESS: i32 = 0;
18    pub const GENERAL_ERROR: i32 = 1;
19    pub const USAGE: i32 = 64; // EX_USAGE: command line usage error
20    pub const UNAVAILABLE: i32 = 69; // EX_UNAVAILABLE: service unavailable
21    pub const CANTCREAT: i32 = 73; // EX_CANTCREAT: can't create output
22    pub const IOERR: i32 = 74; // EX_IOERR: input/output error
23    pub const TEMPFAIL: i32 = 75; // EX_TEMPFAIL: temporary failure
24}
25
26/// Application encapsulates the CLI runtime behavior.
27pub struct Application;
28
29impl Application {
30    /// Create a new Application instance.
31    pub fn new() -> Self {
32        Self
33    }
34
35    /// Run the application, returning the exit code.
36    pub fn run(&self) -> i32 {
37        match self.execute() {
38            Ok(()) => exit_codes::SUCCESS,
39            Err(e) => self.handle_error(e),
40        }
41    }
42
43    fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
44        let cli = Cli::parse();
45        color_init(cli.no_color);
46
47        // Handle commands that don't need a daemon connection
48        if self.handle_standalone_commands(&cli)? {
49            return Ok(());
50        }
51
52        // Connect to daemon
53        let mut client = self.connect_to_daemon()?;
54
55        // Check for version mismatch (skip for daemon commands and version command)
56        if !matches!(cli.command, Commands::Daemon(_) | Commands::Version) {
57            check_version_mismatch(&mut client);
58        }
59
60        // Execute command
61        let format = cli.effective_format();
62        let mut ctx = HandlerContext::new(&mut client, cli.session, format);
63        self.dispatch_command(&mut ctx, &cli.command)
64    }
65
66    /// Handle commands that don't require a daemon connection.
67    /// Returns true if the command was handled, false if it needs daemon.
68    fn handle_standalone_commands(&self, cli: &Cli) -> Result<bool, Box<dyn std::error::Error>> {
69        match &cli.command {
70            Commands::Daemon(DaemonCommand::Start { foreground: true }) => {
71                start_daemon()?;
72                Ok(true)
73            }
74            Commands::Daemon(DaemonCommand::Start { foreground: false }) => {
75                crate::ipc::start_daemon_background()?;
76                println!("Daemon started in background");
77                Ok(true)
78            }
79            Commands::Completions { shell } => {
80                let mut cmd = Cli::command();
81                generate(*shell, &mut cmd, "agent-tui", &mut std::io::stdout());
82                Ok(true)
83            }
84            _ => Ok(false),
85        }
86    }
87
88    fn connect_to_daemon(&self) -> Result<impl DaemonClient, Box<dyn std::error::Error>> {
89        ensure_daemon().map_err(|e| {
90            eprintln!(
91                "{} Failed to connect to daemon: {}",
92                Colors::error("Error:"),
93                e
94            );
95            eprintln!();
96            eprintln!("Troubleshooting:");
97            eprintln!("  1. Check if socket directory is writable (usually /tmp)");
98            eprintln!("  2. Try starting daemon manually: agent-tui daemon");
99            eprintln!("  3. Check current configuration: agent-tui env");
100            e.into()
101        })
102    }
103
104    fn dispatch_command<C: DaemonClient>(
105        &self,
106        ctx: &mut HandlerContext<C>,
107        command: &Commands,
108    ) -> Result<(), Box<dyn std::error::Error>> {
109        match command {
110            Commands::Daemon(daemon_cmd) => match daemon_cmd {
111                DaemonCommand::Start { .. } => unreachable!("Handled in standalone"),
112                DaemonCommand::Stop { force } => handlers::handle_daemon_stop(ctx, *force)?,
113                DaemonCommand::Status => handlers::handle_daemon_status(ctx)?,
114                DaemonCommand::Restart => handlers::handle_daemon_restart(ctx)?,
115            },
116            Commands::Completions { .. } => unreachable!("Handled in standalone"),
117
118            Commands::Run {
119                command,
120                args,
121                cwd,
122                cols,
123                rows,
124            } => handlers::handle_spawn(
125                ctx,
126                command.clone(),
127                args.clone(),
128                cwd.clone(),
129                *cols,
130                *rows,
131            )?,
132
133            Commands::Snap {
134                elements,
135                accessibility,
136                interactive_only,
137                region,
138                strip_ansi,
139                include_cursor,
140            } => {
141                if *accessibility {
142                    handlers::handle_accessibility_snapshot(ctx, *interactive_only)?
143                } else {
144                    handlers::handle_snapshot(
145                        ctx,
146                        *elements,
147                        region.clone(),
148                        *strip_ansi,
149                        *include_cursor,
150                    )?
151                }
152            }
153
154            Commands::Click {
155                element_ref,
156                double,
157            } => {
158                if *double {
159                    handlers::handle_dbl_click(ctx, element_ref.clone())?
160                } else {
161                    handlers::handle_click(ctx, element_ref.clone())?
162                }
163            }
164            Commands::Fill { element_ref, value } => {
165                handlers::handle_fill(ctx, element_ref.clone(), value.clone())?
166            }
167
168            Commands::Key {
169                key,
170                text,
171                hold,
172                release,
173            } => {
174                if let Some(text) = text {
175                    handlers::handle_type(ctx, text.clone())?
176                } else if let Some(key) = key {
177                    if *hold {
178                        handlers::handle_keydown(ctx, key.clone())?
179                    } else if *release {
180                        handlers::handle_keyup(ctx, key.clone())?
181                    } else {
182                        handlers::handle_press(ctx, key.clone())?
183                    }
184                }
185            }
186
187            Commands::Wait { params } => handlers::handle_wait(ctx, params.clone())?,
188            Commands::Kill => handlers::handle_kill(ctx)?,
189            Commands::Restart => handlers::handle_restart(ctx)?,
190            Commands::Ls => handlers::handle_sessions(ctx)?,
191            Commands::Status { verbose } => handlers::handle_health(ctx, *verbose)?,
192
193            Commands::Select {
194                element_ref,
195                option,
196            } => handlers::handle_select(ctx, element_ref.clone(), option.clone())?,
197            Commands::MultiSelect {
198                element_ref,
199                options,
200            } => handlers::handle_multiselect(ctx, element_ref.clone(), options.clone())?,
201
202            Commands::Scroll {
203                direction,
204                amount,
205                element: _,
206                to_ref,
207            } => {
208                if let Some(element_ref) = to_ref {
209                    handlers::handle_scroll_into_view(ctx, element_ref.clone())?
210                } else if let Some(dir) = direction {
211                    handlers::handle_scroll(ctx, *dir, *amount)?
212                }
213            }
214
215            Commands::Focus { element_ref } => handlers::handle_focus(ctx, element_ref.clone())?,
216            Commands::Clear { element_ref } => handlers::handle_clear(ctx, element_ref.clone())?,
217            Commands::SelectAll { element_ref } => {
218                handlers::handle_select_all(ctx, element_ref.clone())?
219            }
220
221            Commands::Count { role, name, text } => {
222                handlers::handle_count(ctx, role.clone(), name.clone(), text.clone())?
223            }
224
225            Commands::Toggle {
226                element_ref,
227                on,
228                off,
229            } => {
230                let state = if *on {
231                    Some(true)
232                } else if *off {
233                    Some(false)
234                } else {
235                    None
236                };
237                handlers::handle_toggle(ctx, element_ref.clone(), state)?
238            }
239
240            Commands::RecordStart => handlers::handle_record_start(ctx)?,
241            Commands::RecordStop {
242                output,
243                record_format,
244            } => handlers::handle_record_stop(ctx, output.clone(), *record_format)?,
245            Commands::RecordStatus => handlers::handle_record_status(ctx)?,
246
247            Commands::Trace { count, start, stop } => {
248                handlers::handle_trace(ctx, *count, *start, *stop)?
249            }
250            Commands::Console { lines, clear } => handlers::handle_console(ctx, *lines, *clear)?,
251            Commands::Errors { count, clear } => handlers::handle_errors(ctx, *count, *clear)?,
252
253            Commands::Resize { cols, rows } => handlers::handle_resize(ctx, *cols, *rows)?,
254            Commands::Attach {
255                session_id,
256                interactive,
257            } => handlers::handle_attach(ctx, session_id.clone(), *interactive)?,
258
259            Commands::Version => handlers::handle_version(ctx)?,
260            Commands::Env => handlers::handle_env(ctx)?,
261            Commands::Assert { condition } => handlers::handle_assert(ctx, condition.clone())?,
262            Commands::Cleanup { all } => handlers::handle_cleanup(ctx, *all)?,
263            Commands::Find { params } => handlers::handle_find(ctx, params.clone())?,
264
265            Commands::Debug(debug_cmd) => match debug_cmd {
266                DebugCommand::Record(action) => match action {
267                    RecordAction::Start => handlers::handle_record_start(ctx)?,
268                    RecordAction::Stop { output, format } => {
269                        handlers::handle_record_stop(ctx, output.clone(), *format)?
270                    }
271                    RecordAction::Status => handlers::handle_record_status(ctx)?,
272                },
273                DebugCommand::Trace { count, start, stop } => {
274                    handlers::handle_trace(ctx, *count, *start, *stop)?
275                }
276                DebugCommand::Console { lines, clear } => {
277                    handlers::handle_console(ctx, *lines, *clear)?
278                }
279                DebugCommand::Errors { count, clear } => {
280                    handlers::handle_errors(ctx, *count, *clear)?
281                }
282                DebugCommand::Env => handlers::handle_env(ctx)?,
283            },
284        }
285        Ok(())
286    }
287
288    fn handle_error(&self, e: Box<dyn std::error::Error>) -> i32 {
289        if let Some(client_error) = e.downcast_ref::<ClientError>() {
290            eprintln!("{} {}", Colors::error("Error:"), client_error);
291            if let Some(suggestion) = client_error.suggestion() {
292                eprintln!("{} {}", Colors::dim("Suggestion:"), suggestion);
293            }
294            if client_error.is_retryable() {
295                eprintln!(
296                    "{}",
297                    Colors::dim("(This error may be transient - retry may succeed)")
298                );
299            }
300            exit_code_for_client_error(client_error)
301        } else if let Some(attach_error) = e.downcast_ref::<AttachError>() {
302            eprintln!("{} {}", Colors::error("Error:"), attach_error);
303            eprintln!(
304                "{} {}",
305                Colors::dim("Suggestion:"),
306                attach_error.suggestion()
307            );
308            if attach_error.is_retryable() {
309                eprintln!(
310                    "{}",
311                    Colors::dim("(This error may be transient - retry may succeed)")
312                );
313            }
314            attach_error.exit_code()
315        } else if let Some(daemon_error) = e.downcast_ref::<DaemonError>() {
316            eprintln!("{} {}", Colors::error("Error:"), daemon_error);
317            eprintln!(
318                "{} {}",
319                Colors::dim("Suggestion:"),
320                daemon_error.suggestion()
321            );
322            if daemon_error.is_retryable() {
323                eprintln!(
324                    "{}",
325                    Colors::dim("(This error may be transient - retry may succeed)")
326                );
327            }
328            exit_codes::IOERR
329        } else {
330            eprintln!("{} {}", Colors::error("Error:"), e);
331            exit_codes::GENERAL_ERROR
332        }
333    }
334}
335
336impl Default for Application {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342/// Check for version mismatch between CLI and daemon, print warning if found.
343fn check_version_mismatch<C: DaemonClient>(client: &mut C) {
344    use crate::ipc::version::{VersionCheckResult, check_version};
345
346    match check_version(client, env!("CARGO_PKG_VERSION")) {
347        VersionCheckResult::Match => {}
348        VersionCheckResult::Mismatch(mismatch) => {
349            eprintln!(
350                "{} CLI version ({}) differs from daemon version ({})",
351                Colors::warning("Warning:"),
352                mismatch.cli_version,
353                mismatch.daemon_version
354            );
355            eprintln!(
356                "{} Run '{}' to update the daemon.",
357                Colors::dim("Hint:"),
358                Colors::info("agent-tui daemon restart")
359            );
360            eprintln!();
361        }
362        VersionCheckResult::CheckFailed(err) => {
363            eprintln!(
364                "{} Could not check daemon version: {}",
365                Colors::dim("Note:"),
366                err
367            );
368        }
369    }
370}
371
372fn exit_code_for_client_error(error: &ClientError) -> i32 {
373    use crate::ipc::error_codes::ErrorCategory;
374
375    match error.category() {
376        Some(ErrorCategory::InvalidInput) => exit_codes::USAGE,
377        Some(ErrorCategory::NotFound) => exit_codes::UNAVAILABLE,
378        Some(ErrorCategory::Busy) => exit_codes::CANTCREAT,
379        Some(ErrorCategory::External) => exit_codes::IOERR,
380        Some(ErrorCategory::Internal) => exit_codes::IOERR,
381        Some(ErrorCategory::Timeout) => exit_codes::TEMPFAIL,
382        None => exit_codes::GENERAL_ERROR,
383    }
384}