Skip to main content

agent_tui_app/app/
mod.rs

1#![expect(clippy::print_stdout, reason = "CLI output is emitted here")]
2#![expect(clippy::print_stderr, reason = "CLI output is emitted here")]
3
4//! CLI application layer and composition root wiring.
5
6use anyhow::Context;
7use anyhow::Result;
8use clap::CommandFactory;
9use clap::Parser;
10use clap_complete::generate;
11use serde::Serialize;
12use std::ffi::OsString;
13use std::fs;
14use std::io;
15use std::io::IsTerminal;
16use std::io::Write;
17use std::path::PathBuf;
18
19pub mod attach;
20pub mod commands;
21pub mod daemon;
22pub mod error;
23pub mod handlers;
24pub mod rpc_client;
25
26use crate::app::commands::OutputFormat;
27use crate::common::Colors;
28use crate::common::DaemonError;
29use crate::common::color_init;
30use crate::common::telemetry;
31use crate::infra::ipc::ClientError;
32use crate::infra::ipc::DaemonClient;
33use crate::infra::ipc::UnixSocketClient;
34use crate::infra::ipc::ensure_daemon;
35use tracing::debug;
36
37use crate::app::attach::AttachError;
38use crate::app::commands::Cli;
39use crate::app::commands::Commands;
40use crate::app::commands::CompletionShell;
41use crate::app::commands::DaemonCommand;
42use crate::app::commands::LegacyActionOperation;
43use crate::app::commands::LegacyActionParseError;
44use crate::app::commands::LiveCommand;
45use crate::app::commands::LiveStartArgs;
46use crate::app::commands::env_assignments_to_map;
47use crate::app::commands::parse_legacy_action_invocation;
48use crate::app::error::CliError;
49use crate::app::error::DaemonNotRunningError;
50use crate::app::handlers::HandlerContext;
51
52const PROGRAM_NAME: &str = "agent-tui";
53
54/// Exit codes following sysexits.h and LSB init script conventions.
55mod exit_codes {
56    pub const SUCCESS: i32 = 0;
57    pub const GENERAL_ERROR: i32 = 1;
58    pub const NOT_RUNNING: i32 = 3;
59    pub const USAGE: i32 = 64;
60    pub const UNAVAILABLE: i32 = 69;
61    pub const CANTCREAT: i32 = 73;
62    pub const IOERR: i32 = 74;
63    pub const TEMPFAIL: i32 = 75;
64}
65
66fn daemon_start_requests_foreground() -> bool {
67    std::env::var("AGENT_TUI_DAEMON_FOREGROUND")
68        .ok()
69        .is_some_and(|value| {
70            matches!(
71                value.trim().to_ascii_lowercase().as_str(),
72                "1" | "true" | "yes" | "on"
73            )
74        })
75}
76
77#[derive(Debug, PartialEq, Eq)]
78enum CompletionStatus {
79    Missing,
80    UpToDate,
81    OutOfDate,
82}
83
84#[derive(Debug)]
85enum InstallOutcome {
86    Installed(PathBuf),
87    Updated(PathBuf),
88    AlreadyUpToDate(PathBuf),
89}
90
91fn legacy_action_compatibility_error(
92    format: OutputFormat,
93    parse_error: LegacyActionParseError,
94) -> CliError {
95    let selector = parse_error.selector();
96    let operation = parse_error.operation();
97    let message =
98        format!("Legacy action `{operation}` for selector `{selector}` is not supported.");
99    let suggestion = "Use `agent-tui press`, `agent-tui type`, or `agent-tui scroll`.";
100    let json = if format == OutputFormat::Json {
101        let output = serde_json::json!({
102            "code": exit_codes::USAGE,
103            "message": message.as_str(),
104            "category": "invalid_input",
105            "retryable": false,
106            "suggestion": suggestion,
107            "context": {
108                "selector": selector,
109                "operation": operation,
110            }
111        });
112        Some(output.to_string())
113    } else {
114        None
115    };
116
117    CliError::new(
118        format,
119        format!("{message} {suggestion}"),
120        json,
121        exit_codes::USAGE,
122    )
123}
124
125fn legacy_action_compatibility_result(
126    format: OutputFormat,
127    form: &[String],
128) -> Result<crate::app::commands::LegacyActionInvocation> {
129    parse_legacy_action_invocation(form)
130        .map_err(|err| anyhow::Error::new(legacy_action_compatibility_error(format, err)))
131}
132
133fn parse_legacy_scroll_into_view_invocation(
134    format: OutputFormat,
135    form: &[String],
136) -> Result<String> {
137    match form {
138        [selector] => Ok(selector.clone()),
139        [selector, unsupported, ..] => {
140            Err(legacy_scroll_into_view_compatibility_error(format, selector, unsupported).into())
141        }
142        [] => {
143            Err(legacy_scroll_into_view_compatibility_error(format, "<missing>", "missing").into())
144        }
145    }
146}
147
148fn legacy_scroll_into_view_compatibility_error(
149    format: OutputFormat,
150    selector: &str,
151    unsupported: &str,
152) -> CliError {
153    let selector = if selector.is_empty() {
154        "<missing>"
155    } else {
156        selector
157    };
158    let unsupported = if unsupported.is_empty() {
159        "<missing>"
160    } else {
161        unsupported
162    };
163    let message = format!(
164        "Legacy scroll-into-view option `{unsupported}` for selector `{selector}` is not supported."
165    );
166    let suggestion = "Use `agent-tui scroll <direction> [amount]` or `agent-tui press`.";
167    let json = if format == OutputFormat::Json {
168        let output = serde_json::json!({
169            "code": exit_codes::USAGE,
170            "message": message.as_str(),
171            "category": "invalid_input",
172            "retryable": false,
173            "suggestion": suggestion,
174            "context": {
175                "selector": selector,
176                "unsupported": unsupported,
177            }
178        });
179        Some(output.to_string())
180    } else {
181        None
182    };
183
184    CliError::new(
185        format,
186        format!("{message} {suggestion}"),
187        json,
188        exit_codes::USAGE,
189    )
190}
191
192fn handle_legacy_scroll_into_view(format: OutputFormat, selector: &str) -> Result<()> {
193    #[derive(Serialize)]
194    struct LegacyScrollIntoViewOutput<'a> {
195        success: bool,
196        selector: &'a str,
197        scrolled: bool,
198        message: &'a str,
199    }
200
201    let message = "Legacy scroll-into-view compatibility did not send terminal input. Use `agent-tui scroll <direction> [amount]` or `agent-tui press`.";
202
203    match format {
204        OutputFormat::Json => {
205            let output = LegacyScrollIntoViewOutput {
206                success: true,
207                selector,
208                scrolled: false,
209                message,
210            };
211            println!("{}", serde_json::to_string_pretty(&output)?);
212        }
213        OutputFormat::Text => {
214            println!("{message}");
215        }
216    }
217
218    Ok(())
219}
220
221fn handle_completions_command(
222    format: OutputFormat,
223    shell: Option<CompletionShell>,
224    print: bool,
225    install: bool,
226    yes: bool,
227    no_input: bool,
228) -> Result<()> {
229    if format == OutputFormat::Json {
230        return handle_completions_command_json(shell, print, install);
231    }
232
233    if install {
234        let shell = resolve_shell(shell).ok_or_else(|| {
235            CliError::new(
236                format,
237                "Shell not specified. Re-run with `agent-tui completions --install <bash|zsh|fish|elvish>`."
238                    .to_string(),
239                None,
240                exit_codes::USAGE,
241            )
242        })?;
243        run_completions_wizard(shell, true, yes, no_input)?;
244        return Ok(());
245    }
246
247    let stdout_tty = io::stdout().is_terminal();
248    if print || !stdout_tty {
249        let shell = resolve_shell(shell).ok_or_else(|| {
250            CliError::new(
251                format,
252                "Shell not specified. Re-run with `agent-tui completions --print <bash|zsh|fish|elvish>`."
253                    .to_string(),
254                None,
255                exit_codes::USAGE,
256            )
257        })?;
258        let mut cmd = Cli::command();
259        generate(
260            shell.clap_shell(),
261            &mut cmd,
262            PROGRAM_NAME,
263            &mut io::stdout(),
264        );
265        return Ok(());
266    }
267
268    if no_input {
269        let shell = resolve_shell(shell).ok_or_else(|| {
270            CliError::new(
271                format,
272                "Shell not detected. Re-run with `agent-tui completions <bash|zsh|fish|elvish> --no-input` or `agent-tui completions --print <shell>`."
273                    .to_string(),
274                None,
275                exit_codes::USAGE,
276            )
277        })?;
278        run_completions_wizard(shell, install, yes, true)?;
279        return Ok(());
280    }
281
282    let shell = match resolve_shell(shell) {
283        Some(shell) => shell,
284        None => {
285            print_shell_detection_help();
286            return Ok(());
287        }
288    };
289
290    run_completions_wizard(shell, install, yes, false)?;
291    Ok(())
292}
293
294fn single_modifier_key(format: OutputFormat, flag: &str, keys: &[String]) -> Result<String> {
295    match keys {
296        [key] => Ok(key.clone()),
297        _ => Err(anyhow::Error::new(CliError::new(
298            format,
299            format!("Press {flag} requires exactly one key (Ctrl, Alt, Shift, Meta)"),
300            None,
301            exit_codes::USAGE,
302        ))),
303    }
304}
305
306fn completions_cli_error(
307    exit_code: i32,
308    message: impl Into<String>,
309    category: &'static str,
310    suggestion: Option<String>,
311    context: Option<serde_json::Value>,
312) -> CliError {
313    let message = message.into();
314    let mut output = serde_json::json!({
315        "code": exit_code,
316        "message": message.as_str(),
317        "category": category,
318        "retryable": false,
319    });
320    if let Some(context) = context {
321        output["context"] = context;
322    }
323    if let Some(suggestion) = suggestion.as_deref() {
324        output["suggestion"] = serde_json::json!(suggestion);
325    }
326
327    CliError::new(
328        OutputFormat::Json,
329        message,
330        Some(serde_json::to_string_pretty(&output).unwrap_or_default()),
331        exit_code,
332    )
333}
334
335fn completions_usage_error(
336    message: impl Into<String>,
337    suggestion: Option<String>,
338    context: Option<serde_json::Value>,
339) -> CliError {
340    completions_cli_error(
341        exit_codes::USAGE,
342        message,
343        "invalid_input",
344        suggestion,
345        context,
346    )
347}
348
349fn completions_unavailable_error(
350    message: impl Into<String>,
351    suggestion: Option<String>,
352    context: Option<serde_json::Value>,
353) -> CliError {
354    completions_cli_error(
355        exit_codes::UNAVAILABLE,
356        message,
357        "not_found",
358        suggestion,
359        context,
360    )
361}
362
363fn handle_completions_command_json(
364    shell: Option<CompletionShell>,
365    print: bool,
366    install: bool,
367) -> Result<()> {
368    #[derive(Serialize)]
369    struct CompletionJsonOutput {
370        action: &'static str,
371        shell: String,
372        install_supported: bool,
373        #[serde(skip_serializing_if = "Option::is_none")]
374        install_path: Option<String>,
375        #[serde(skip_serializing_if = "Option::is_none")]
376        status: Option<&'static str>,
377        #[serde(skip_serializing_if = "Option::is_none")]
378        result: Option<&'static str>,
379        #[serde(skip_serializing_if = "Option::is_none")]
380        install_recommended: Option<bool>,
381        #[serde(skip_serializing_if = "Option::is_none")]
382        suggested_install_command: Option<String>,
383        #[serde(skip_serializing_if = "Option::is_none")]
384        script: Option<String>,
385    }
386
387    let shell = resolve_shell(shell).ok_or_else(|| {
388        completions_usage_error(
389            "Shell not detected. Re-run with `agent-tui completions <bash|zsh|fish|elvish> --no-input` or `agent-tui completions --print <shell>`.",
390            Some("Specify one of bash, zsh, fish, or elvish.".to_string()),
391            Some(serde_json::json!({
392                "supported_shells": ["bash", "zsh", "fish", "elvish"]
393            })),
394        )
395    })?;
396
397    let script = generate_completions_bytes(shell)?;
398    let shell_name = shell_label(shell).to_string();
399
400    if print {
401        let output = CompletionJsonOutput {
402            action: "print",
403            shell: shell_name,
404            install_supported: default_completion_path(shell).is_some(),
405            install_path: default_completion_path(shell).map(|path| path.display().to_string()),
406            status: None,
407            result: None,
408            install_recommended: None,
409            suggested_install_command: None,
410            script: Some(
411                String::from_utf8(script).context("generated completions were not valid UTF-8")?,
412            ),
413        };
414        println!("{}", serde_json::to_string_pretty(&output)?);
415        return Ok(());
416    }
417
418    let install_path = default_completion_path(shell);
419    let install_path_string = install_path.as_ref().map(|path| path.display().to_string());
420
421    if install {
422        let path = install_path.ok_or_else(|| {
423            completions_unavailable_error(
424                "Automatic completion install is not available because the home directory could not be determined.",
425                Some("Set HOME or use `agent-tui completions --print <shell>` instead.".to_string()),
426                None,
427            )
428        })?;
429
430        let outcome = install_completions(&script, &path)?;
431        let result = install_outcome_label(&outcome);
432        let output = CompletionJsonOutput {
433            action: "install",
434            shell: shell_name,
435            install_supported: true,
436            install_path: Some(path.display().to_string()),
437            status: Some(result),
438            result: Some(result),
439            install_recommended: Some(false),
440            suggested_install_command: None,
441            script: None,
442        };
443        println!("{}", serde_json::to_string_pretty(&output)?);
444        return Ok(());
445    }
446
447    let status = install_path
448        .as_ref()
449        .map(|path| completion_status(&script, path))
450        .transpose()?;
451    let install_recommended = status
452        .as_ref()
453        .map(|status| !matches!(status, CompletionStatus::UpToDate));
454    let suggested_install_command = install_recommended
455        .filter(|recommended| *recommended)
456        .map(|_| format!("{PROGRAM_NAME} completions --install {shell_name}"));
457
458    let output = CompletionJsonOutput {
459        action: "status",
460        shell: shell_name,
461        install_supported: install_path.is_some(),
462        install_path: install_path_string,
463        status: status.as_ref().map(completion_status_label),
464        result: None,
465        install_recommended,
466        suggested_install_command,
467        script: None,
468    };
469    println!("{}", serde_json::to_string_pretty(&output)?);
470    Ok(())
471}
472
473fn run_completions_wizard(
474    shell: CompletionShell,
475    install: bool,
476    yes: bool,
477    no_input: bool,
478) -> Result<()> {
479    println!("{}", Colors::bold("Shell completions"));
480    println!("Detected shell: {}", shell_label(shell));
481    println!();
482
483    print_install_guidance(shell);
484
485    let Some(install_path) = default_completion_path(shell) else {
486        println!(
487            "{} Automatic install isn't supported for this shell.",
488            Colors::warning("Note:")
489        );
490        return Ok(());
491    };
492
493    if matches!(shell, CompletionShell::Bash | CompletionShell::Zsh) {
494        println!(
495            "{} install a static completion file (not required if you use the line above).",
496            Colors::dim("Optional:")
497        );
498    }
499
500    let script = generate_completions_bytes(shell)?;
501    let status = completion_status(&script, &install_path)?;
502    match status {
503        CompletionStatus::UpToDate => {
504            println!(
505                "{} Completions are up-to-date at {}",
506                Colors::success("✓"),
507                install_path.display()
508            );
509        }
510        CompletionStatus::OutOfDate => {
511            println!(
512                "{} Completions are out of date at {}",
513                Colors::warning("âš "),
514                install_path.display()
515            );
516        }
517        CompletionStatus::Missing => {
518            println!(
519                "{} No completion file found at {}",
520                Colors::warning("âš "),
521                install_path.display()
522            );
523        }
524    }
525
526    if install {
527        let outcome = install_completions(&script, &install_path)?;
528        print_install_outcome(outcome);
529        print_static_install_note(shell);
530        return Ok(());
531    }
532
533    if matches!(
534        status,
535        CompletionStatus::OutOfDate | CompletionStatus::Missing
536    ) {
537        let stdin_tty = io::stdin().is_terminal();
538        if yes
539            || (!no_input && stdin_tty && prompt_yes_no("Install/update completions now?", true)?)
540        {
541            let outcome = install_completions(&script, &install_path)?;
542            print_install_outcome(outcome);
543            print_static_install_note(shell);
544        } else {
545            println!(
546                "Run: {} completions --install {}",
547                PROGRAM_NAME,
548                shell_label(shell)
549            );
550        }
551    }
552
553    Ok(())
554}
555
556fn supported_shells() -> &'static str {
557    "bash, zsh, fish, elvish"
558}
559
560fn print_shell_detection_help() {
561    println!("{}", Colors::warning("Shell not detected."));
562    println!("Run: {PROGRAM_NAME} completions <shell>");
563    println!("Supported shells: {}", supported_shells());
564}
565
566fn print_install_guidance(shell: CompletionShell) {
567    println!("{}", Colors::bold("Recommended setup:"));
568    match shell {
569        CompletionShell::Bash => {
570            println!("Add this to ~/.bashrc:");
571            println!("  source <(agent-tui completions bash --print)");
572            println!(
573                "{}",
574                Colors::dim("This keeps completions in sync with your installed agent-tui.")
575            );
576        }
577        CompletionShell::Zsh => {
578            println!("Add this to ~/.zshrc:");
579            println!("  source <(agent-tui completions zsh --print)");
580            println!(
581                "{}",
582                Colors::dim("This keeps completions in sync with your installed agent-tui.")
583            );
584        }
585        CompletionShell::Fish => {
586            println!("Install a completion file (fish loads it automatically):");
587            println!(
588                "  agent-tui completions fish --print > ~/.config/fish/completions/agent-tui.fish"
589            );
590            println!(
591                "{}",
592                Colors::dim("Re-run this after upgrading agent-tui to refresh the file.")
593            );
594        }
595        CompletionShell::Elvish => {
596            println!("Install a completion file:");
597            println!("  agent-tui completions elvish --print > ~/.elvish/lib/agent-tui.elv");
598            println!(
599                "{}",
600                Colors::dim("Re-run this after upgrading agent-tui to refresh the file.")
601            );
602        }
603    }
604    println!();
605}
606
607fn resolve_shell(shell: Option<CompletionShell>) -> Option<CompletionShell> {
608    shell.or_else(detect_shell_from_env)
609}
610
611fn detect_shell_from_env() -> Option<CompletionShell> {
612    let env_shell = std::env::var("SHELL").ok()?;
613    let name = std::path::Path::new(&env_shell)
614        .file_name()
615        .and_then(|s| s.to_str())
616        .unwrap_or(&env_shell)
617        .to_ascii_lowercase();
618    shell_from_name(&name)
619}
620
621fn shell_from_name(name: &str) -> Option<CompletionShell> {
622    if name.contains("bash") {
623        Some(CompletionShell::Bash)
624    } else if name.contains("zsh") {
625        Some(CompletionShell::Zsh)
626    } else if name.contains("fish") {
627        Some(CompletionShell::Fish)
628    } else if name.contains("elvish") {
629        Some(CompletionShell::Elvish)
630    } else {
631        None
632    }
633}
634
635fn shell_label(shell: CompletionShell) -> &'static str {
636    match shell {
637        CompletionShell::Bash => "bash",
638        CompletionShell::Zsh => "zsh",
639        CompletionShell::Fish => "fish",
640        CompletionShell::Elvish => "elvish",
641    }
642}
643
644fn default_completion_path(shell: CompletionShell) -> Option<PathBuf> {
645    let home = home_dir()?;
646    match shell {
647        CompletionShell::Bash => Some(home.join(".bash_completion.d").join("agent-tui")),
648        CompletionShell::Zsh => Some(home.join(".zsh").join("completions").join("_agent-tui")),
649        CompletionShell::Fish => Some(
650            home.join(".config")
651                .join("fish")
652                .join("completions")
653                .join("agent-tui.fish"),
654        ),
655        CompletionShell::Elvish => Some(home.join(".elvish").join("lib").join("agent-tui.elv")),
656    }
657}
658
659fn home_dir() -> Option<PathBuf> {
660    std::env::var("HOME").ok().map(PathBuf::from)
661}
662
663fn generate_completions_bytes(shell: CompletionShell) -> Result<Vec<u8>> {
664    let mut cmd = Cli::command();
665    let mut out = Vec::new();
666    generate(shell.clap_shell(), &mut cmd, PROGRAM_NAME, &mut out);
667    Ok(out)
668}
669
670fn completion_status(expected: &[u8], path: &PathBuf) -> Result<CompletionStatus> {
671    match fs::read(path) {
672        Ok(existing) => {
673            if existing == expected {
674                Ok(CompletionStatus::UpToDate)
675            } else {
676                Ok(CompletionStatus::OutOfDate)
677            }
678        }
679        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(CompletionStatus::Missing),
680        Err(err) => {
681            Err(err).with_context(|| format!("failed to read completion file {}", path.display()))
682        }
683    }
684}
685
686fn install_completions(script: &[u8], path: &PathBuf) -> Result<InstallOutcome> {
687    let status = completion_status(script, path)?;
688    let path = path.clone();
689    if matches!(status, CompletionStatus::UpToDate) {
690        return Ok(InstallOutcome::AlreadyUpToDate(path));
691    }
692
693    if let Some(parent) = path.parent() {
694        fs::create_dir_all(parent).with_context(|| {
695            format!("failed to create completion directory {}", parent.display())
696        })?;
697    }
698    fs::write(&path, script)
699        .with_context(|| format!("failed to write completions to {}", path.display()))?;
700    Ok(match status {
701        CompletionStatus::Missing => InstallOutcome::Installed(path),
702        CompletionStatus::OutOfDate => InstallOutcome::Updated(path),
703        CompletionStatus::UpToDate => InstallOutcome::AlreadyUpToDate(path),
704    })
705}
706
707fn completion_status_label(status: &CompletionStatus) -> &'static str {
708    match status {
709        CompletionStatus::Missing => "missing",
710        CompletionStatus::UpToDate => "up_to_date",
711        CompletionStatus::OutOfDate => "out_of_date",
712    }
713}
714
715fn install_outcome_label(outcome: &InstallOutcome) -> &'static str {
716    match outcome {
717        InstallOutcome::Installed(_) => "installed",
718        InstallOutcome::Updated(_) => "updated",
719        InstallOutcome::AlreadyUpToDate(_) => "already_up_to_date",
720    }
721}
722
723fn print_install_outcome(outcome: InstallOutcome) {
724    match outcome {
725        InstallOutcome::Installed(path) => {
726            println!(
727                "{} Installed completions to {}",
728                Colors::success("✓"),
729                path.display()
730            );
731        }
732        InstallOutcome::Updated(path) => {
733            println!(
734                "{} Updated completions at {}",
735                Colors::success("✓"),
736                path.display()
737            );
738        }
739        InstallOutcome::AlreadyUpToDate(path) => {
740            println!(
741                "{} Completions already up-to-date at {}",
742                Colors::success("✓"),
743                path.display()
744            );
745        }
746    }
747}
748
749fn print_static_install_note(shell: CompletionShell) {
750    match shell {
751        CompletionShell::Bash => println!(
752            "{}",
753            Colors::dim(
754                "Note: ensure your shell loads ~/.bash_completion.d (or source the file in ~/.bashrc)."
755            )
756        ),
757        CompletionShell::Zsh => println!(
758            "{}",
759            Colors::dim("Note: ensure ~/.zsh/completions is in $fpath and compinit is enabled.")
760        ),
761        _ => {}
762    }
763}
764
765pub(crate) fn prompt_yes_no(prompt: &str, default_yes: bool) -> io::Result<bool> {
766    let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
767    let mut input = String::new();
768    loop {
769        print!("{prompt} {suffix} ");
770        io::stdout().flush()?;
771        input.clear();
772        if io::stdin().read_line(&mut input)? == 0 {
773            return Ok(default_yes);
774        }
775        let answer = input.trim().to_ascii_lowercase();
776        if answer.is_empty() {
777            return Ok(default_yes);
778        }
779        if matches!(answer.as_str(), "y" | "yes") {
780            return Ok(true);
781        }
782        if matches!(answer.as_str(), "n" | "no") {
783            return Ok(false);
784        }
785        println!("Please answer y or n.");
786    }
787}
788
789pub struct Application;
790
791enum ParsedCli {
792    Ready(Cli),
793    Exit(i32),
794}
795
796impl Application {
797    pub fn new() -> Self {
798        Self
799    }
800
801    pub fn run(&self) -> Result<i32> {
802        let cli = match self.parse_cli() {
803            ParsedCli::Ready(cli) => cli,
804            ParsedCli::Exit(code) => return Ok(code),
805        };
806
807        let exit_code = match self.execute_with_cli(cli) {
808            Ok(()) => exit_codes::SUCCESS,
809            Err(e) => self.handle_error(e),
810        };
811        Ok(exit_code)
812    }
813
814    fn parse_cli(&self) -> ParsedCli {
815        let raw_args: Vec<OsString> = std::env::args_os().collect();
816
817        match Cli::try_parse_from(raw_args.clone()) {
818            Ok(cli) => ParsedCli::Ready(cli),
819            Err(err) => ParsedCli::Exit(render_parse_error(err, &raw_args)),
820        }
821    }
822
823    fn execute_with_cli(&self, cli: Cli) -> Result<()> {
824        let _telemetry = telemetry::init_tracing("warn");
825        color_init(cli.no_color);
826        let format = cli.effective_format();
827        debug!(
828            command = ?cli.command,
829            session = ?cli.session,
830            format = ?format,
831            "CLI command parsed"
832        );
833
834        if self
835            .handle_standalone_commands(&cli)
836            .map_err(|e| self.wrap_error(e, format))
837            .context("failed to handle standalone command")?
838        {
839            return Ok(());
840        }
841
842        let mut client: UnixSocketClient = if Self::requires_daemon_autostart(&cli.command) {
843            self.connect_to_daemon_autostart()
844                .map_err(|e| self.wrap_error(e, format))
845                .context("failed to connect to daemon with autostart")?
846        } else {
847            self.connect_to_daemon_no_autostart()
848                .map_err(|e| self.wrap_error(e, format))
849                .context("failed to connect to daemon")?
850        };
851
852        let mut ctx = HandlerContext::new(&mut client, cli.session, format, cli.no_input);
853        self.dispatch_command(&mut ctx, cli.command)
854            .map_err(|e| self.wrap_error(e, format))
855            .context("failed to execute command")
856    }
857
858    fn handle_standalone_commands(&self, cli: &Cli) -> Result<bool> {
859        match &cli.command {
860            Commands::Daemon(DaemonCommand::Start {}) => {
861                if daemon_start_requests_foreground() {
862                    crate::app::daemon::start_daemon()?;
863                } else {
864                    handlers::handle_daemon_start_standalone(cli.effective_format())?;
865                }
866                Ok(true)
867            }
868            Commands::Daemon(DaemonCommand::Run) => {
869                crate::app::daemon::start_daemon()?;
870                Ok(true)
871            }
872            Commands::Daemon(DaemonCommand::Status) => {
873                handlers::handle_daemon_status_standalone(cli.effective_format())?;
874                Ok(true)
875            }
876            Commands::Daemon(DaemonCommand::Stop {
877                force,
878                dry_run,
879                yes,
880            }) => {
881                handlers::handle_daemon_stop_standalone(
882                    cli.effective_format(),
883                    *force,
884                    *dry_run,
885                    *yes,
886                    cli.no_input,
887                )?;
888                Ok(true)
889            }
890            Commands::Daemon(DaemonCommand::Restart { dry_run, yes }) => {
891                handlers::handle_daemon_restart_standalone(
892                    cli.effective_format(),
893                    *dry_run,
894                    *yes,
895                    cli.no_input,
896                )?;
897                Ok(true)
898            }
899            Commands::Live { command: None } => {
900                handlers::handle_live_start_standalone(
901                    cli.effective_format(),
902                    LiveStartArgs::default(),
903                )?;
904                Ok(true)
905            }
906            Commands::Live {
907                command: Some(LiveCommand::Start(args)),
908            } => {
909                handlers::handle_live_start_standalone(cli.effective_format(), args.clone())?;
910                Ok(true)
911            }
912            Commands::Live {
913                command: Some(LiveCommand::Stop),
914            } => {
915                handlers::handle_live_stop_standalone(cli.effective_format())?;
916                Ok(true)
917            }
918            Commands::Live {
919                command: Some(LiveCommand::Status),
920            } => {
921                handlers::handle_live_status_standalone(cli.effective_format())?;
922                Ok(true)
923            }
924            Commands::Completions {
925                shell,
926                print,
927                install,
928                yes,
929            } => {
930                handle_completions_command(
931                    cli.effective_format(),
932                    *shell,
933                    *print,
934                    *install,
935                    *yes,
936                    cli.no_input,
937                )?;
938                Ok(true)
939            }
940            Commands::Version => {
941                handlers::handle_version_standalone(cli.effective_format())?;
942                Ok(true)
943            }
944            Commands::Env => {
945                handlers::handle_env(cli.effective_format())?;
946                Ok(true)
947            }
948            Commands::Action { form } => {
949                match legacy_action_compatibility_result(cli.effective_format(), form) {
950                    Ok(_) => Ok(false),
951                    Err(error) => {
952                        warn_legacy_action_deprecation();
953                        Err(error)
954                    }
955                }
956            }
957            Commands::ScrollIntoView { form } => {
958                warn_legacy_scroll_into_view_deprecation();
959                let selector =
960                    parse_legacy_scroll_into_view_invocation(cli.effective_format(), form)?;
961                handle_legacy_scroll_into_view(cli.effective_format(), &selector)?;
962                Ok(true)
963            }
964            _ => Ok(false),
965        }
966    }
967
968    fn requires_daemon_autostart(command: &Commands) -> bool {
969        matches!(command, Commands::Run { .. })
970    }
971
972    fn connect_to_daemon_autostart(&self) -> Result<UnixSocketClient> {
973        ensure_daemon().map_err(Into::into)
974    }
975
976    fn connect_to_daemon_no_autostart(&self) -> Result<UnixSocketClient> {
977        match UnixSocketClient::connect() {
978            Ok(client) => Ok(client),
979            Err(ClientError::DaemonNotRunning) => Err(ClientError::DaemonNotRunning.into()),
980            Err(e) => Err(e.into()),
981        }
982    }
983
984    fn dispatch_command<C: DaemonClient>(
985        &self,
986        ctx: &mut HandlerContext<C>,
987        command: Commands,
988    ) -> Result<()> {
989        match command {
990            Commands::Daemon(daemon_cmd) => match daemon_cmd {
991                DaemonCommand::Start { .. } => unreachable!("Handled in standalone"),
992                DaemonCommand::Run => unreachable!("Handled in standalone"),
993                DaemonCommand::Status => unreachable!("Handled in standalone"),
994                DaemonCommand::Stop { .. } => unreachable!("Handled in standalone"),
995                DaemonCommand::Restart { .. } => unreachable!("Handled in standalone"),
996            },
997            Commands::Completions { .. } => unreachable!("Handled in standalone"),
998
999            Commands::Run {
1000                command,
1001                args,
1002                cwd,
1003                env,
1004                cols,
1005                rows,
1006            } => handlers::handle_spawn(
1007                ctx,
1008                command,
1009                args,
1010                cwd,
1011                env_assignments_to_map(env),
1012                cols,
1013                rows,
1014            )?,
1015
1016            Commands::Screenshot {
1017                region,
1018                strip_ansi,
1019                retain_ansi,
1020                include_cursor,
1021                legacy_element,
1022                legacy_accessibility,
1023                legacy_interactive_only,
1024            } => {
1025                if legacy_element {
1026                    handlers::warn_legacy_deprecation("screenshot -e", "screenshot");
1027                }
1028                if legacy_accessibility {
1029                    handlers::warn_legacy_deprecation("screenshot -a", "screenshot");
1030                }
1031                if legacy_interactive_only {
1032                    handlers::warn_legacy_deprecation(
1033                        "screenshot --interactive-only",
1034                        "screenshot",
1035                    );
1036                }
1037                handlers::handle_snapshot(ctx, region, strip_ansi, retain_ansi, include_cursor)?
1038            }
1039            Commands::Action { form } => {
1040                warn_legacy_action_deprecation();
1041                let action = legacy_action_compatibility_result(ctx.format, &form)?;
1042                match action.operation {
1043                    LegacyActionOperation::Click => {
1044                        handlers::handle_press(ctx, "Enter".to_string())?
1045                    }
1046                    LegacyActionOperation::Fill(text) => handlers::handle_type(ctx, text)?,
1047                }
1048            }
1049
1050            Commands::Resize { cols, rows } => handlers::handle_resize(ctx, cols, rows)?,
1051            Commands::Restart { dry_run, yes } => handlers::handle_restart(ctx, dry_run, yes)?,
1052
1053            Commands::Press {
1054                keys,
1055                hold,
1056                release,
1057            } => {
1058                const PRESS_INTER_KEY_DELAY_MS: u64 = 50;
1059                if hold {
1060                    let key = single_modifier_key(ctx.format, "--hold", &keys)?;
1061                    handlers::handle_keydown(ctx, key)?
1062                } else if release {
1063                    let key = single_modifier_key(ctx.format, "--release", &keys)?;
1064                    handlers::handle_keyup(ctx, key)?
1065                } else {
1066                    let key_count = keys.len();
1067                    for (idx, key) in keys.into_iter().enumerate() {
1068                        handlers::handle_press(ctx, key)?;
1069                        if idx + 1 < key_count {
1070                            std::thread::park_timeout(std::time::Duration::from_millis(
1071                                PRESS_INTER_KEY_DELAY_MS,
1072                            ));
1073                        }
1074                    }
1075                }
1076            }
1077
1078            Commands::Type { text } => handlers::handle_type(ctx, text)?,
1079            Commands::Input { text } => {
1080                handlers::warn_legacy_deprecation("input", "type");
1081                handlers::handle_type(ctx, text)?
1082            }
1083            Commands::Scroll { direction, amount } => {
1084                handlers::handle_scroll(ctx, direction, amount)?
1085            }
1086            Commands::ScrollIntoView { .. } => unreachable!("Handled in standalone"),
1087
1088            Commands::Wait { params } => {
1089                if params.legacy_element.is_some() {
1090                    handlers::warn_legacy_deprecation("wait -e", "wait <text>");
1091                }
1092                handlers::handle_wait(ctx, params)?
1093            }
1094            Commands::Kill { dry_run, yes } => handlers::handle_kill(ctx, dry_run, yes)?,
1095
1096            Commands::Sessions { command } => {
1097                use crate::app::commands::SessionsCommand;
1098
1099                match command {
1100                    None | Some(SessionsCommand::List) => handlers::handle_sessions(ctx)?,
1101                    Some(SessionsCommand::Show { session_id }) => {
1102                        handlers::handle_session_show(ctx, session_id)?
1103                    }
1104                    Some(SessionsCommand::Attach {
1105                        no_tty,
1106                        detach_keys,
1107                    }) => {
1108                        let attach_id = handlers::resolve_attach_session_id(ctx)?;
1109                        handlers::handle_attach(
1110                            ctx,
1111                            attach_id,
1112                            !no_tty && !ctx.no_input,
1113                            detach_keys,
1114                        )?
1115                    }
1116                    Some(SessionsCommand::Switch { session_id }) => {
1117                        handlers::handle_session_switch(ctx, session_id)?
1118                    }
1119                    Some(SessionsCommand::Cleanup { all, dry_run, yes }) => {
1120                        handlers::handle_cleanup(ctx, all, dry_run, yes)?
1121                    }
1122                }
1123            }
1124
1125            Commands::Live { command } => match command {
1126                None => handlers::handle_live_start(ctx, LiveStartArgs::default())?,
1127                Some(LiveCommand::Start(args)) => handlers::handle_live_start(ctx, args)?,
1128                Some(LiveCommand::Stop) => handlers::handle_live_stop(ctx)?,
1129                Some(LiveCommand::Status) => handlers::handle_live_status(ctx)?,
1130            },
1131
1132            Commands::Version => unreachable!("Handled in standalone"),
1133            Commands::Env => handlers::handle_env(ctx.format)?,
1134        }
1135        Ok(())
1136    }
1137
1138    fn handle_error(&self, e: anyhow::Error) -> i32 {
1139        if find_error::<DaemonNotRunningError>(&e).is_some() {
1140            return exit_codes::NOT_RUNNING;
1141        }
1142
1143        if let Some(cli_error) = find_error::<crate::app::error::CliError>(&e) {
1144            print_cli_error(cli_error);
1145            return cli_error.exit_code;
1146        }
1147
1148        if let Some(client_error) = find_error::<ClientError>(&e) {
1149            eprintln!(
1150                "{}: {} {}",
1151                PROGRAM_NAME,
1152                Colors::error("Error:"),
1153                client_error
1154            );
1155            if let Some(suggestion) = client_error.suggestion() {
1156                eprintln!("{} {}", Colors::dim("Suggestion:"), suggestion);
1157            }
1158            if client_error.is_retryable() {
1159                eprintln!(
1160                    "{}",
1161                    Colors::dim("(This error may be transient - retry may succeed)")
1162                );
1163            }
1164            exit_code_for_client_error(client_error)
1165        } else if let Some(attach_error) = find_error::<AttachError>(&e) {
1166            eprintln!(
1167                "{}: {} {}",
1168                PROGRAM_NAME,
1169                Colors::error("Error:"),
1170                attach_error
1171            );
1172            eprintln!(
1173                "{} {}",
1174                Colors::dim("Suggestion:"),
1175                attach_error.suggestion()
1176            );
1177            if attach_error.is_retryable() {
1178                eprintln!(
1179                    "{}",
1180                    Colors::dim("(This error may be transient - retry may succeed)")
1181                );
1182            }
1183            attach_error.exit_code()
1184        } else if let Some(daemon_error) = find_error::<DaemonError>(&e) {
1185            eprintln!(
1186                "{}: {} {}",
1187                PROGRAM_NAME,
1188                Colors::error("Error:"),
1189                daemon_error
1190            );
1191            eprintln!(
1192                "{} {}",
1193                Colors::dim("Suggestion:"),
1194                daemon_error.suggestion()
1195            );
1196            if daemon_error.is_retryable() {
1197                eprintln!(
1198                    "{}",
1199                    Colors::dim("(This error may be transient - retry may succeed)")
1200                );
1201            }
1202            exit_codes::IOERR
1203        } else {
1204            eprintln!("{}: {} {}", PROGRAM_NAME, Colors::error("Error:"), e);
1205            exit_codes::GENERAL_ERROR
1206        }
1207    }
1208}
1209
1210fn warn_legacy_action_deprecation() {
1211    handlers::warn_legacy_deprecation_with_replacement(
1212        "action",
1213        "`agent-tui press`, `agent-tui type`, or `agent-tui scroll`",
1214    );
1215}
1216
1217fn warn_legacy_scroll_into_view_deprecation() {
1218    handlers::warn_legacy_deprecation_with_replacement(
1219        "scroll-into-view",
1220        "`agent-tui scroll` or `agent-tui press`",
1221    );
1222}
1223
1224impl Application {
1225    fn wrap_error(&self, error: anyhow::Error, format: OutputFormat) -> anyhow::Error {
1226        if find_error::<DaemonNotRunningError>(&error).is_some() {
1227            return error;
1228        }
1229        if find_error::<crate::app::error::CliError>(&error).is_some() {
1230            return error;
1231        }
1232        if format != OutputFormat::Json {
1233            return error;
1234        }
1235
1236        if let Some(client_error) = find_error::<ClientError>(&error) {
1237            return anyhow::Error::new(crate::app::error::CliError::new(
1238                format,
1239                client_error.to_string(),
1240                Some(client_error.to_json_string()),
1241                exit_code_for_client_error(client_error),
1242            ));
1243        }
1244        if let Some(attach_error) = find_error::<AttachError>(&error) {
1245            return anyhow::Error::new(crate::app::error::CliError::new(
1246                format,
1247                attach_error.to_string(),
1248                Some(serde_json::to_string_pretty(&attach_error.to_payload()).unwrap_or_default()),
1249                attach_error.exit_code(),
1250            ));
1251        }
1252        if let Some(daemon_error) = find_error::<DaemonError>(&error) {
1253            return anyhow::Error::new(crate::app::error::CliError::new(
1254                format,
1255                daemon_error.to_string(),
1256                None,
1257                exit_codes::IOERR,
1258            ));
1259        }
1260
1261        anyhow::Error::new(crate::app::error::CliError::new(
1262            format,
1263            error.to_string(),
1264            None,
1265            exit_codes::GENERAL_ERROR,
1266        ))
1267    }
1268}
1269
1270fn find_error<T: std::error::Error + 'static>(error: &anyhow::Error) -> Option<&T> {
1271    error.chain().find_map(|source| source.downcast_ref::<T>())
1272}
1273
1274fn print_cli_error(error: &crate::app::error::CliError) {
1275    match error.format {
1276        OutputFormat::Json => {
1277            if let Some(json) = &error.json {
1278                eprintln!("{json}");
1279            } else {
1280                #[derive(Serialize)]
1281                struct ErrorOutput<'a> {
1282                    success: bool,
1283                    error: &'a str,
1284                }
1285                let output = ErrorOutput {
1286                    success: false,
1287                    error: &error.message,
1288                };
1289                eprintln!(
1290                    "{}",
1291                    serde_json::to_string_pretty(&output).unwrap_or_default()
1292                );
1293            }
1294        }
1295        OutputFormat::Text => {
1296            eprintln!(
1297                "{}: {} {}",
1298                PROGRAM_NAME,
1299                Colors::error("Error:"),
1300                error.message
1301            );
1302        }
1303    }
1304}
1305
1306impl Default for Application {
1307    fn default() -> Self {
1308        Self::new()
1309    }
1310}
1311
1312fn render_parse_error(err: clap::Error, raw_args: &[OsString]) -> i32 {
1313    let kind = err.kind();
1314    let uses_stderr = !matches!(
1315        kind,
1316        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
1317    );
1318    let exit_code = err.exit_code();
1319
1320    if uses_stderr {
1321        eprint!("{err}");
1322    } else {
1323        print!("{err}");
1324    }
1325
1326    if uses_stderr && parse_error_needs_example(kind) {
1327        eprintln!("Example:");
1328        eprintln!("  {}", parse_error_example(raw_args));
1329    }
1330
1331    exit_code
1332}
1333
1334fn parse_error_needs_example(kind: clap::error::ErrorKind) -> bool {
1335    matches!(
1336        kind,
1337        clap::error::ErrorKind::ArgumentConflict
1338            | clap::error::ErrorKind::InvalidSubcommand
1339            | clap::error::ErrorKind::InvalidValue
1340            | clap::error::ErrorKind::MissingRequiredArgument
1341            | clap::error::ErrorKind::MissingSubcommand
1342            | clap::error::ErrorKind::NoEquals
1343            | clap::error::ErrorKind::TooFewValues
1344            | clap::error::ErrorKind::TooManyValues
1345            | clap::error::ErrorKind::UnknownArgument
1346            | clap::error::ErrorKind::ValueValidation
1347            | clap::error::ErrorKind::WrongNumberOfValues
1348    )
1349}
1350
1351fn parse_error_example(raw_args: &[OsString]) -> String {
1352    let command_path = parse_error_command_path(raw_args);
1353    if command_path.is_empty() {
1354        format!("{PROGRAM_NAME} --help")
1355    } else {
1356        format!("{PROGRAM_NAME} {} --help", command_path.join(" "))
1357    }
1358}
1359
1360fn parse_error_command_path(raw_args: &[OsString]) -> Vec<String> {
1361    let mut command = Cli::command();
1362    let mut path = Vec::new();
1363
1364    for arg in raw_args.iter().skip(1) {
1365        let token = arg.to_string_lossy();
1366        if token.is_empty() || token == "--" || token.starts_with('-') {
1367            break;
1368        }
1369
1370        let Some(subcommand) = command.find_subcommand(token.as_ref()) else {
1371            break;
1372        };
1373
1374        path.push(token.into_owned());
1375        command = subcommand.clone();
1376    }
1377
1378    path
1379}
1380
1381fn exit_code_for_client_error(error: &ClientError) -> i32 {
1382    use crate::common::error_codes::ErrorCategory;
1383
1384    if matches!(error, ClientError::DaemonNotRunning) {
1385        return exit_codes::UNAVAILABLE;
1386    }
1387
1388    match error.category() {
1389        Some(ErrorCategory::InvalidInput) => exit_codes::USAGE,
1390        Some(ErrorCategory::NotFound) => exit_codes::UNAVAILABLE,
1391        Some(ErrorCategory::Busy) => exit_codes::CANTCREAT,
1392        Some(ErrorCategory::External) => exit_codes::IOERR,
1393        Some(ErrorCategory::Internal) => exit_codes::IOERR,
1394        Some(ErrorCategory::Timeout) => exit_codes::TEMPFAIL,
1395        None => exit_codes::GENERAL_ERROR,
1396    }
1397}
1398
1399#[cfg(test)]
1400#[path = "mod_tests.rs"]
1401mod tests;