1#![expect(clippy::print_stdout, reason = "CLI output is emitted here")]
2#![expect(clippy::print_stderr, reason = "CLI output is emitted here")]
3
4use 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
54mod 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;