1#[cfg(target_os = "macos")]
2mod apple;
3#[cfg(target_os = "macos")]
4mod ghostty;
5mod gnome_terminal;
6#[cfg(target_os = "macos")]
7mod iterm2;
8mod kitty;
9mod tmux;
10#[cfg(target_os = "macos")]
11mod warp;
12mod wezterm;
13mod windows_terminal;
14
15pub use tmux::{open_in_new_window as open_in_tmux_window, pane_targets as tmux_pane_targets};
16
17use crate::session::ClaudeSession;
18use std::path::PathBuf;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21enum TerminalAction {
22 Launch,
23 Switch,
24 Input,
25 Approve,
26}
27
28impl TerminalAction {
29 fn label(&self) -> &'static str {
30 match self {
31 TerminalAction::Launch => "Launch new session",
32 TerminalAction::Switch => "Switch to session terminal",
33 TerminalAction::Input => "Send input to session",
34 TerminalAction::Approve => "Approve prompt",
35 }
36 }
37
38 fn summary_name(&self) -> &'static str {
39 match self {
40 TerminalAction::Launch => "launch",
41 TerminalAction::Switch => "switch",
42 TerminalAction::Input => "input",
43 TerminalAction::Approve => "approve",
44 }
45 }
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum DoctorStatus {
50 Ready,
51 Blocked,
52 Unsupported,
53}
54
55impl DoctorStatus {
56 fn label(&self) -> &'static str {
57 match self {
58 DoctorStatus::Ready => "ok",
59 DoctorStatus::Blocked => "blocked",
60 DoctorStatus::Unsupported => "n/a",
61 }
62 }
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct DoctorCheck {
67 pub name: &'static str,
68 pub status: DoctorStatus,
69 pub detail: String,
70 pub fix: Option<String>,
71}
72
73impl DoctorCheck {
74 fn ready(name: &'static str, detail: impl Into<String>) -> Self {
75 Self {
76 name,
77 status: DoctorStatus::Ready,
78 detail: detail.into(),
79 fix: None,
80 }
81 }
82
83 fn blocked(
84 name: &'static str,
85 detail: impl Into<String>,
86 fix: impl Into<Option<String>>,
87 ) -> Self {
88 Self {
89 name,
90 status: DoctorStatus::Blocked,
91 detail: detail.into(),
92 fix: fix.into(),
93 }
94 }
95
96 fn unsupported(name: &'static str, detail: impl Into<String>) -> Self {
97 Self {
98 name,
99 status: DoctorStatus::Unsupported,
100 detail: detail.into(),
101 fix: None,
102 }
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct DoctorReport {
108 pub terminal: String,
109 pub platform: String,
110 pub actions: Vec<DoctorCheck>,
111 pub prerequisites: Vec<DoctorCheck>,
112 pub notes: Vec<String>,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub enum Terminal {
117 Gnome,
118 Ghostty,
119 Warp,
120 ITerm2,
121 Kitty,
122 WezTerm,
123 WindowsTerm,
124 Apple,
125 Tmux,
126 Unknown(String),
127}
128
129fn terminal_name(t: &Terminal) -> &str {
130 match t {
131 Terminal::Gnome => "GNOME Terminal",
132 Terminal::Ghostty => "Ghostty",
133 Terminal::Warp => "Warp",
134 Terminal::ITerm2 => "iTerm2",
135 Terminal::Kitty => "Kitty",
136 Terminal::WezTerm => "WezTerm",
137 Terminal::WindowsTerm => "Windows Terminal",
138 Terminal::Apple => "Apple Terminal",
139 Terminal::Tmux => "tmux",
140 Terminal::Unknown(name) => name,
141 }
142}
143
144fn platform_label(os: &str, is_wsl: bool) -> String {
145 if is_wsl && os == "linux" {
146 "linux (WSL)".to_string()
147 } else {
148 os.to_string()
149 }
150}
151
152fn platform_name() -> String {
153 platform_label(std::env::consts::OS, is_wsl())
154}
155
156fn environment_notes(is_wsl: bool, has_windows_terminal_bridge: bool) -> Vec<String> {
157 if !is_wsl {
158 return Vec::new();
159 }
160
161 let mut notes = vec![
162 "WSL detected. Linux session discovery should work normally inside the distro."
163 .to_string(),
164 "For reliable switch, input, and approval automation in WSL today, prefer tmux or Kitty inside WSL."
165 .to_string(),
166 ];
167
168 if has_windows_terminal_bridge {
169 notes.push(
170 "Windows Terminal launch is available from WSL through `cmd.exe /c wt.exe`, but tab switching and input automation still rely on tmux or Kitty."
171 .to_string(),
172 );
173 } else {
174 notes.push(
175 "Windows Terminal launch is not available from this WSL shell, so claudectl currently relies on Linux-native terminals inside WSL."
176 .to_string(),
177 );
178 }
179
180 notes
181}
182
183fn windows_terminal_bridge_ready() -> bool {
184 command_ready("cmd.exe") && command_ready("wt.exe")
185}
186
187fn wsl_interop_check(is_wsl: bool) -> Option<DoctorCheck> {
188 if !is_wsl {
189 return None;
190 }
191
192 if windows_terminal_bridge_ready() {
193 Some(DoctorCheck::ready(
194 "Windows Terminal interop",
195 "`cmd.exe /c wt.exe` is reachable from WSL.",
196 ))
197 } else if !command_ready("cmd.exe") {
198 Some(DoctorCheck::blocked(
199 "Windows Terminal interop",
200 "`cmd.exe` is not on PATH from this WSL environment.",
201 Some(
202 "Enable WSL Windows interop or reopen this distro from a normal WSL shell."
203 .to_string(),
204 ),
205 ))
206 } else {
207 Some(DoctorCheck::blocked(
208 "Windows Terminal interop",
209 "`wt.exe` is not on PATH from this WSL environment.",
210 Some(
211 "Install Windows Terminal or enable WSL interop, then reopen the shell."
212 .to_string(),
213 ),
214 ))
215 }
216}
217
218fn is_wsl() -> bool {
219 #[cfg(target_os = "linux")]
220 {
221 if std::env::var_os("WSL_DISTRO_NAME").is_some()
222 || std::env::var_os("WSL_INTEROP").is_some()
223 {
224 return true;
225 }
226
227 for path in ["/proc/sys/kernel/osrelease", "/proc/version"] {
228 let Ok(contents) = std::fs::read_to_string(path) else {
229 continue;
230 };
231
232 if contents.to_ascii_lowercase().contains("microsoft") {
233 return true;
234 }
235 }
236 }
237
238 false
239}
240
241fn supported_actions(terminal: &Terminal) -> Vec<TerminalAction> {
242 match terminal {
243 Terminal::Gnome | Terminal::WindowsTerm => vec![TerminalAction::Launch],
244 Terminal::Kitty | Terminal::Tmux => vec![
245 TerminalAction::Launch,
246 TerminalAction::Switch,
247 TerminalAction::Input,
248 TerminalAction::Approve,
249 ],
250 Terminal::WezTerm => vec![TerminalAction::Launch, TerminalAction::Switch],
251 #[cfg(target_os = "macos")]
252 Terminal::Ghostty | Terminal::Warp | Terminal::ITerm2 | Terminal::Apple => vec![
253 TerminalAction::Switch,
254 TerminalAction::Input,
255 TerminalAction::Approve,
256 ],
257 Terminal::Unknown(_) => Vec::new(),
258 #[cfg(not(target_os = "macos"))]
259 _ => Vec::new(),
260 }
261}
262
263pub(crate) fn build_claude_args(prompt: Option<&str>, resume: Option<&str>) -> Vec<String> {
264 let mut args = Vec::new();
265 if let Some(resume_id) = resume {
266 args.push("--resume".to_string());
267 args.push(resume_id.to_string());
268 }
269 if let Some(prompt_text) = prompt {
270 args.push("-p".to_string());
271 args.push(prompt_text.to_string());
272 }
273 args
274}
275
276pub(crate) fn shell_escape(arg: &str) -> String {
277 format!("'{}'", arg.replace('\'', "'\"'\"'"))
278}
279
280pub fn detect_terminal() -> Terminal {
281 if std::env::var("TMUX").is_ok() {
282 return Terminal::Tmux;
283 }
284
285 if std::env::var("GNOME_TERMINAL_SERVICE").is_ok()
286 || std::env::var("GNOME_TERMINAL_SCREEN").is_ok()
287 || ancestor_process_contains("gnome-terminal")
288 {
289 return Terminal::Gnome;
290 }
291
292 if is_wsl() && std::env::var_os("WT_SESSION").is_some() {
293 return Terminal::WindowsTerm;
294 }
295
296 if let Some(term) = detect_by_native_env() {
299 return term;
300 }
301
302 match std::env::var("TERM_PROGRAM").as_deref() {
303 Ok("ghostty") => Terminal::Ghostty,
304 Ok("WarpTerminal") => Terminal::Warp,
305 Ok("iTerm.app") => Terminal::ITerm2,
306 Ok("kitty") => Terminal::Kitty,
307 Ok("WezTerm") => Terminal::WezTerm,
308 Ok("Apple_Terminal") => Terminal::Apple,
309 Ok(other) => Terminal::Unknown(other.to_string()),
310 Err(_) => Terminal::Unknown("unknown".to_string()),
311 }
312}
313
314fn detect_by_native_env() -> Option<Terminal> {
317 if std::env::var_os("KITTY_WINDOW_ID").is_some() {
320 return Some(Terminal::Kitty);
321 }
322
323 if std::env::var_os("WEZTERM_EXECUTABLE").is_some() {
325 return Some(Terminal::WezTerm);
326 }
327
328 if std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some() {
330 return Some(Terminal::Ghostty);
331 }
332
333 if std::env::var("TERM").as_deref() == Ok("xterm-kitty") {
335 return Some(Terminal::Kitty);
336 }
337
338 None
339}
340
341fn ancestor_process_contains(needle: &str) -> bool {
342 let mut pid = unsafe { libc::getppid() } as u32;
343 let needle = needle.to_ascii_lowercase();
344
345 for _ in 0..8 {
346 if pid == 0 {
347 break;
348 }
349
350 let output = match std::process::Command::new("ps")
351 .args(["-o", "ppid=,comm=", "-p", &pid.to_string()])
352 .output()
353 {
354 Ok(output) => output,
355 Err(_) => return false,
356 };
357
358 let stdout = String::from_utf8_lossy(&output.stdout);
359 let line = stdout.trim();
360 if line.is_empty() {
361 break;
362 }
363
364 let mut parts = line.split_whitespace();
365 let parent = parts
366 .next()
367 .and_then(|value| value.parse::<u32>().ok())
368 .unwrap_or(0);
369 let command = parts.collect::<Vec<_>>().join(" ").to_ascii_lowercase();
370 if command.contains(&needle) {
371 return true;
372 }
373 pid = parent;
374 }
375
376 false
377}
378
379pub fn can_launch_session() -> bool {
380 supported_actions(&detect_terminal()).contains(&TerminalAction::Launch)
381}
382
383pub fn help_capability_summary() -> String {
384 help_capability_summary_for(&detect_terminal())
385}
386
387fn help_capability_summary_for(terminal: &Terminal) -> String {
388 let actions = supported_actions(terminal);
389 if actions.is_empty() {
390 format!(
391 "Current terminal: {} (monitor-only)",
392 terminal_name(terminal)
393 )
394 } else {
395 let summary = actions
396 .iter()
397 .map(TerminalAction::summary_name)
398 .collect::<Vec<_>>()
399 .join(", ");
400 format!("Current terminal: {} ({summary})", terminal_name(terminal))
401 }
402}
403
404fn find_command_path(name: &str) -> Option<PathBuf> {
405 if name.contains(std::path::MAIN_SEPARATOR) {
406 let path = PathBuf::from(name);
407 return path.is_file().then_some(path);
408 }
409
410 let path_var = std::env::var_os("PATH")?;
411 std::env::split_paths(&path_var)
412 .map(|dir| dir.join(name))
413 .find(|candidate| candidate.is_file())
414}
415
416fn binary_check(name: &'static str) -> DoctorCheck {
417 match find_command_path(name) {
418 Some(path) => DoctorCheck::ready(name, format!("Found at {}", path.display())),
419 None => DoctorCheck::blocked(
420 name,
421 format!("`{name}` is not on PATH."),
422 Some(format!("Install `{name}` or add it to PATH.")),
423 ),
424 }
425}
426
427fn command_ready(name: &'static str) -> bool {
428 find_command_path(name).is_some()
429}
430
431fn output_message(output: &std::process::Output) -> String {
432 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
433 if !stderr.is_empty() {
434 return stderr;
435 }
436 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
437 if !stdout.is_empty() {
438 return stdout;
439 }
440 format!("Command exited with status {}", output.status)
441}
442
443fn probe_kitty_remote_control() -> Result<(), String> {
444 let output = std::process::Command::new("kitty")
445 .args(["@", "ls"])
446 .output()
447 .map_err(|e| format!("kitty @ ls failed: {e}"))?;
448
449 if output.status.success() {
450 Ok(())
451 } else {
452 Err(output_message(&output))
453 }
454}
455
456fn probe_tmux_connectivity() -> Result<(), String> {
457 let output = std::process::Command::new("tmux")
458 .args(["list-panes", "-a", "-F", "#{pane_tty}"])
459 .output()
460 .map_err(|e| format!("tmux list-panes failed: {e}"))?;
461
462 if output.status.success() {
463 Ok(())
464 } else {
465 Err(output_message(&output))
466 }
467}
468
469fn probe_wezterm_cli() -> Result<(), String> {
470 let output = std::process::Command::new("wezterm")
471 .args(["cli", "list", "--format", "json"])
472 .output()
473 .map_err(|e| format!("wezterm cli list failed: {e}"))?;
474
475 if output.status.success() {
476 Ok(())
477 } else {
478 Err(output_message(&output))
479 }
480}
481
482#[cfg(target_os = "macos")]
483fn probe_system_events_access() -> Result<(), String> {
484 let script = r#"tell application "System Events" to return UI elements enabled"#;
485 let output = std::process::Command::new("osascript")
486 .args(["-e", script])
487 .output()
488 .map_err(|e| format!("osascript probe failed: {e}"))?;
489
490 if output.status.success() {
491 Ok(())
492 } else {
493 Err(output_message(&output))
494 }
495}
496
497fn action_check(
498 action: TerminalAction,
499 status: DoctorStatus,
500 detail: impl Into<String>,
501 fix: impl Into<Option<String>>,
502) -> DoctorCheck {
503 match status {
504 DoctorStatus::Ready => DoctorCheck::ready(action.label(), detail),
505 DoctorStatus::Blocked => DoctorCheck::blocked(action.label(), detail, fix),
506 DoctorStatus::Unsupported => DoctorCheck::unsupported(action.label(), detail.into()),
507 }
508}
509
510pub fn doctor_report() -> DoctorReport {
511 doctor_report_for(detect_terminal())
512}
513
514fn doctor_report_for(terminal: Terminal) -> DoctorReport {
515 let terminal_label = terminal_name(&terminal).to_string();
516 let is_wsl = is_wsl();
517 let mut prerequisites = vec![binary_check("claude")];
518 if let Some(wsl_check) = wsl_interop_check(is_wsl) {
519 prerequisites.push(wsl_check);
520 }
521 let mut actions = Vec::new();
522 let mut notes = vec![
523 "Run `claudectl --doctor` inside the same terminal family that launches Claude."
524 .to_string(),
525 "`n` and `--new` use the same launch capability shown here.".to_string(),
526 ];
527 notes.extend(environment_notes(is_wsl, windows_terminal_bridge_ready()));
528
529 match terminal {
530 Terminal::Gnome => {
531 let gnome_check = binary_check("gnome-terminal");
532 let gnome_ready = gnome_check.status == DoctorStatus::Ready;
533 prerequisites.push(gnome_check);
534
535 let launch_status = if gnome_ready {
536 DoctorStatus::Ready
537 } else {
538 DoctorStatus::Blocked
539 };
540 let launch_detail = if gnome_ready {
541 "GNOME Terminal can launch visible Claude sessions with `gnome-terminal --window`."
542 } else {
543 "GNOME Terminal CLI is unavailable, so visible launch cannot run."
544 };
545 let launch_fix =
546 Some("Install GNOME Terminal and ensure `gnome-terminal` is on PATH.".to_string());
547 actions.push(action_check(
548 TerminalAction::Launch,
549 launch_status,
550 launch_detail,
551 launch_fix.clone(),
552 ));
553
554 for action in [
555 TerminalAction::Switch,
556 TerminalAction::Input,
557 TerminalAction::Approve,
558 ] {
559 actions.push(action_check(
560 action,
561 DoctorStatus::Unsupported,
562 "GNOME Terminal launch is supported, but reliable remote focus/input automation is not currently available.",
563 Some(
564 "Use tmux or Kitty when you need remote switching, input, or approval from claudectl."
565 .to_string(),
566 ),
567 ));
568 }
569
570 notes.push(
571 "GNOME Terminal launch works on Linux and was smoke-tested under Docker/X11. Remote focus/input automation is intentionally disabled until window targeting is reliable."
572 .to_string(),
573 );
574 }
575 Terminal::WindowsTerm => {
576 let cmd_check = binary_check("cmd.exe");
577 let cmd_ready = cmd_check.status == DoctorStatus::Ready;
578 prerequisites.push(cmd_check);
579
580 let wt_check = binary_check("wt.exe");
581 let wt_ready = wt_check.status == DoctorStatus::Ready;
582 prerequisites.push(wt_check);
583
584 let launch_status = if cmd_ready && wt_ready {
585 DoctorStatus::Ready
586 } else {
587 DoctorStatus::Blocked
588 };
589 let launch_detail = if launch_status == DoctorStatus::Ready {
590 "Windows Terminal can open a new WSL tab in the current window and run `claude` there."
591 } else {
592 "Windows Terminal launch needs both `cmd.exe` and `wt.exe` reachable from this WSL shell."
593 };
594 let launch_fix = Some(
595 "Enable WSL Windows interop, ensure Windows Terminal is installed, then rerun `claudectl --doctor`."
596 .to_string(),
597 );
598 actions.push(action_check(
599 TerminalAction::Launch,
600 launch_status,
601 launch_detail,
602 launch_fix.clone(),
603 ));
604
605 for action in [
606 TerminalAction::Switch,
607 TerminalAction::Input,
608 TerminalAction::Approve,
609 ] {
610 actions.push(action_check(
611 action,
612 DoctorStatus::Unsupported,
613 "Windows Terminal launch works from WSL, but remote tab switching and input automation are not implemented there yet.",
614 Some(
615 "Use tmux or Kitty inside WSL when you need switch/input/approve automation."
616 .to_string(),
617 ),
618 ));
619 }
620
621 notes.push(
622 "Windows Terminal support is WSL-only and currently covers visible launch into a new tab, not remote control of existing tabs."
623 .to_string(),
624 );
625 }
626 Terminal::Kitty => {
627 let kitty_check = binary_check("kitty");
628 let kitty_ready = kitty_check.status == DoctorStatus::Ready;
629 prerequisites.push(kitty_check);
630
631 let remote_check = if kitty_ready {
632 match probe_kitty_remote_control() {
633 Ok(()) => DoctorCheck::ready(
634 "kitty remote control",
635 "`kitty @` is reachable from this shell.",
636 ),
637 Err(err) => DoctorCheck::blocked(
638 "kitty remote control",
639 format!("`kitty @` is unavailable: {err}"),
640 Some(
641 "Set `allow_remote_control yes` or `allow_remote_control socket-only` in kitty.conf, then restart Kitty."
642 .to_string(),
643 ),
644 ),
645 }
646 } else {
647 DoctorCheck::blocked(
648 "kitty remote control",
649 "Kitty CLI is unavailable, so `kitty @` cannot be used.",
650 Some("Install Kitty and ensure `kitty` is on PATH.".to_string()),
651 )
652 };
653 let remote_ready = remote_check.status == DoctorStatus::Ready;
654 prerequisites.push(remote_check);
655
656 let action_status = if kitty_ready && remote_ready {
657 DoctorStatus::Ready
658 } else {
659 DoctorStatus::Blocked
660 };
661 let detail = if action_status == DoctorStatus::Ready {
662 "Kitty can focus tabs and send text through `kitty @`."
663 } else {
664 "Kitty support is configured, but remote control is not currently available."
665 };
666 let fix = Some(
667 "Enable Kitty remote control in kitty.conf and rerun `claudectl --doctor`."
668 .to_string(),
669 );
670
671 for action in supported_actions(&Terminal::Kitty) {
672 actions.push(action_check(action, action_status, detail, fix.clone()));
673 }
674 }
675 Terminal::Tmux => {
676 let tmux_check = binary_check("tmux");
677 let tmux_ready = tmux_check.status == DoctorStatus::Ready;
678 prerequisites.push(tmux_check);
679
680 let session_check = if tmux_ready {
681 match probe_tmux_connectivity() {
682 Ok(()) => DoctorCheck::ready(
683 "tmux session access",
684 "`tmux list-panes` can see the active server.",
685 ),
686 Err(err) => DoctorCheck::blocked(
687 "tmux session access",
688 format!("tmux is installed, but pane discovery failed: {err}"),
689 Some("Run claudectl from inside the tmux session that owns the Claude panes.".to_string()),
690 ),
691 }
692 } else {
693 DoctorCheck::blocked(
694 "tmux session access",
695 "tmux is unavailable, so pane discovery cannot run.",
696 Some("Install tmux and rerun `claudectl --doctor`.".to_string()),
697 )
698 };
699 let session_ready = session_check.status == DoctorStatus::Ready;
700 prerequisites.push(session_check);
701
702 let action_status = if tmux_ready && session_ready {
703 DoctorStatus::Ready
704 } else {
705 DoctorStatus::Blocked
706 };
707 let detail = if action_status == DoctorStatus::Ready {
708 "tmux can open windows, locate panes by TTY, and send keys."
709 } else {
710 "tmux support needs a reachable tmux server from this shell."
711 };
712 let fix = Some(
713 "Run claudectl inside tmux or connect it to the same tmux server.".to_string(),
714 );
715
716 for action in supported_actions(&Terminal::Tmux) {
717 actions.push(action_check(action, action_status, detail, fix.clone()));
718 }
719 }
720 Terminal::WezTerm => {
721 let wezterm_check = binary_check("wezterm");
722 let wezterm_ready = wezterm_check.status == DoctorStatus::Ready;
723 prerequisites.push(wezterm_check);
724
725 let cli_check = if wezterm_ready {
726 match probe_wezterm_cli() {
727 Ok(()) => DoctorCheck::ready(
728 "wezterm cli",
729 "`wezterm cli` can query panes from this shell.",
730 ),
731 Err(err) => DoctorCheck::blocked(
732 "wezterm cli",
733 format!("WezTerm CLI is installed, but pane discovery failed: {err}"),
734 Some(
735 "Run claudectl inside WezTerm with a reachable mux server.".to_string(),
736 ),
737 ),
738 }
739 } else {
740 DoctorCheck::blocked(
741 "wezterm cli",
742 "WezTerm CLI is unavailable, so pane discovery cannot run.",
743 Some("Install WezTerm and ensure `wezterm` is on PATH.".to_string()),
744 )
745 };
746 let cli_ready = cli_check.status == DoctorStatus::Ready;
747 prerequisites.push(cli_check);
748
749 let action_status = if wezterm_ready && cli_ready {
750 DoctorStatus::Ready
751 } else {
752 DoctorStatus::Blocked
753 };
754 let detail = if action_status == DoctorStatus::Ready {
755 "WezTerm supports visible launch and pane activation through `wezterm cli`."
756 } else {
757 "WezTerm support needs a reachable mux server from this shell."
758 };
759 let fix = Some(
760 "Start claudectl from the same WezTerm environment that owns the Claude panes."
761 .to_string(),
762 );
763
764 for action in [TerminalAction::Launch, TerminalAction::Switch] {
765 actions.push(action_check(action, action_status, detail, fix.clone()));
766 }
767 for action in [TerminalAction::Input, TerminalAction::Approve] {
768 actions.push(action_check(
769 action,
770 DoctorStatus::Unsupported,
771 "WezTerm integration currently supports launch and pane focus only.",
772 None::<String>,
773 ));
774 }
775 notes.push("WezTerm input injection is not implemented yet.".to_string());
776 }
777 #[cfg(target_os = "macos")]
778 Terminal::Ghostty => {
779 let apple_script_check = binary_check("osascript");
780 let apple_script_ready = apple_script_check.status == DoctorStatus::Ready;
781 prerequisites.push(apple_script_check);
782
783 let detail = if apple_script_ready {
784 "Ghostty exposes switch/input/approve through its AppleScript API."
785 } else {
786 "Ghostty support requires `osascript`."
787 };
788 let status = if apple_script_ready {
789 DoctorStatus::Ready
790 } else {
791 DoctorStatus::Blocked
792 };
793 let fix = Some(
794 "Ensure macOS automation tools are available and Ghostty is running normally."
795 .to_string(),
796 );
797
798 for action in supported_actions(&Terminal::Ghostty) {
799 actions.push(action_check(action, status, detail, fix.clone()));
800 }
801 actions.push(action_check(
802 TerminalAction::Launch,
803 DoctorStatus::Unsupported,
804 "Visible launch is only implemented for tmux, Kitty, and WezTerm.",
805 None::<String>,
806 ));
807 notes.push("Ghostty does not need Kitty-style remote control setup, but macOS may still prompt for automation access.".to_string());
808 }
809 #[cfg(target_os = "macos")]
810 Terminal::Warp | Terminal::ITerm2 | Terminal::Apple => {
811 let apple_script_check = binary_check("osascript");
812 let apple_script_ready = apple_script_check.status == DoctorStatus::Ready;
813 prerequisites.push(apple_script_check);
814
815 let system_events_check = if apple_script_ready {
816 match probe_system_events_access() {
817 Ok(()) => DoctorCheck::ready(
818 "System Events access",
819 "AppleScript can talk to System Events from this shell.",
820 ),
821 Err(err) => DoctorCheck::blocked(
822 "System Events access",
823 format!("macOS UI scripting is not currently available: {err}"),
824 Some(
825 "Grant Automation/Accessibility access in System Settings > Privacy & Security, then rerun `claudectl --doctor`."
826 .to_string(),
827 ),
828 ),
829 }
830 } else {
831 DoctorCheck::blocked(
832 "System Events access",
833 "`osascript` is unavailable, so macOS UI scripting cannot run.",
834 Some(
835 "Ensure `/usr/bin/osascript` is available and rerun the doctor."
836 .to_string(),
837 ),
838 )
839 };
840 let system_events_ready = system_events_check.status == DoctorStatus::Ready;
841 prerequisites.push(system_events_check);
842
843 actions.push(action_check(
844 TerminalAction::Launch,
845 DoctorStatus::Unsupported,
846 "Visible launch is only implemented for tmux, Kitty, and WezTerm.",
847 None::<String>,
848 ));
849
850 let status = if apple_script_ready && system_events_ready {
851 DoctorStatus::Ready
852 } else {
853 DoctorStatus::Blocked
854 };
855 let detail = format!(
856 "{} uses AppleScript and System Events for focus and input control.",
857 terminal_name(&terminal)
858 );
859 let fix = Some(
860 "Grant Automation/Accessibility permissions to the terminal and rerun `claudectl --doctor`."
861 .to_string(),
862 );
863 for action in [
864 TerminalAction::Switch,
865 TerminalAction::Input,
866 TerminalAction::Approve,
867 ] {
868 actions.push(action_check(action, status, &detail, fix.clone()));
869 }
870 }
871 Terminal::Unknown(name) => {
872 for action in [
873 TerminalAction::Launch,
874 TerminalAction::Switch,
875 TerminalAction::Input,
876 TerminalAction::Approve,
877 ] {
878 actions.push(action_check(
879 action,
880 DoctorStatus::Unsupported,
881 format!(
882 "No integration is configured for `{name}`. Supported terminals: GNOME Terminal, Windows Terminal on WSL, tmux, Kitty, WezTerm, Ghostty, Warp, iTerm2, Terminal.app."
883 ),
884 None::<String>,
885 ));
886 }
887 notes.push(
888 "Monitoring still works in unsupported terminals, but control actions stay manual."
889 .to_string(),
890 );
891 }
892 #[cfg(not(target_os = "macos"))]
893 Terminal::Ghostty | Terminal::Warp | Terminal::ITerm2 | Terminal::Apple => {
894 for action in [
895 TerminalAction::Launch,
896 TerminalAction::Switch,
897 TerminalAction::Input,
898 TerminalAction::Approve,
899 ] {
900 actions.push(action_check(
901 action,
902 DoctorStatus::Unsupported,
903 format!(
904 "{} control hooks are currently only implemented on macOS.",
905 terminal_name(&terminal)
906 ),
907 None::<String>,
908 ));
909 }
910 notes.push(
911 "Monitoring still works in unsupported terminals, but control actions stay manual."
912 .to_string(),
913 );
914 }
915 }
916
917 if !command_ready("claude") {
918 notes.push("Launching a new session will fail until `claude` is on PATH.".to_string());
919 }
920
921 DoctorReport {
922 terminal: terminal_label,
923 platform: platform_name(),
924 actions,
925 prerequisites,
926 notes,
927 }
928}
929
930pub fn format_doctor_report(report: &DoctorReport) -> String {
931 let mut lines = vec![
932 "claudectl doctor".to_string(),
933 String::new(),
934 format!("Platform: {}", report.platform),
935 format!("Detected terminal: {}", report.terminal),
936 String::new(),
937 "Prerequisites".to_string(),
938 ];
939
940 for check in &report.prerequisites {
941 lines.push(format!(
942 " [{}] {}: {}",
943 check.status.label(),
944 check.name,
945 check.detail
946 ));
947 if let Some(fix) = &check.fix {
948 lines.push(format!(" fix: {fix}"));
949 }
950 }
951
952 lines.push(String::new());
953 lines.push("Capabilities".to_string());
954 for action in &report.actions {
955 lines.push(format!(
956 " [{}] {}: {}",
957 action.status.label(),
958 action.name,
959 action.detail
960 ));
961 if let Some(fix) = &action.fix {
962 lines.push(format!(" fix: {fix}"));
963 }
964 }
965
966 if !report.notes.is_empty() {
967 lines.push(String::new());
968 lines.push("Notes".to_string());
969 for note in &report.notes {
970 lines.push(format!(" - {note}"));
971 }
972 }
973
974 lines.join("\n")
975}
976
977pub fn launch_session(
978 cwd: &str,
979 prompt: Option<&str>,
980 resume: Option<&str>,
981) -> Result<String, String> {
982 let terminal = detect_terminal();
983 match terminal {
984 Terminal::Gnome => gnome_terminal::launch(cwd, prompt, resume),
985 Terminal::Kitty => kitty::launch(cwd, prompt, resume),
986 Terminal::Tmux => tmux::launch(cwd, prompt, resume),
987 Terminal::WezTerm => wezterm::launch(cwd, prompt, resume),
988 Terminal::WindowsTerm => windows_terminal::launch(cwd, prompt, resume),
989 other => Err(format!(
990 "Visible session launch is not supported in {}. Start `claude` manually, use tmux/Kitty/WezTerm/GNOME Terminal/Windows Terminal on WSL, or run `claudectl --doctor` for setup guidance.",
991 terminal_name(&other)
992 )),
993 }
994}
995
996pub fn remote_attach_argv(session: &ClaudeSession) -> Result<Vec<String>, String> {
1000 let peer = session.worker_origin.as_deref().unwrap_or("peer");
1001 let target = session.tmux_target.as_deref().ok_or_else(|| {
1002 if session.remote_user.is_none() {
1003 format!("{peer} runs an older claudectl that doesn't report tmux panes — upgrade it")
1004 } else {
1005 format!("Not in tmux on {peer} — start it under tmux there to attach from here")
1006 }
1007 })?;
1008 let host = session
1009 .remote_host
1010 .as_deref()
1011 .ok_or_else(|| format!("No address known for {peer} — is `relay serve` connected?"))?;
1012 let destination = match session.remote_user.as_deref() {
1013 Some(user) => format!("{user}@{host}"),
1014 None => host.to_string(),
1015 };
1016 Ok(tmux::remote_attach_argv(&destination, target))
1017}
1018
1019pub fn switch_to_terminal(session: &ClaudeSession) -> Result<(), String> {
1020 let terminal = detect_terminal();
1021
1022 let needs_tty = matches!(
1025 terminal,
1026 Terminal::Tmux | Terminal::WezTerm | Terminal::Apple | Terminal::ITerm2
1027 );
1028 if needs_tty && session.tty.is_empty() {
1029 return Err("No TTY associated with this session".into());
1030 }
1031 crate::logger::log(
1032 "DEBUG",
1033 &format!(
1034 "terminal switch: {} (tty={}) via {:?}",
1035 session.display_name(),
1036 session.tty,
1037 terminal_name(&terminal)
1038 ),
1039 );
1040
1041 match terminal {
1042 Terminal::Gnome => gnome_terminal::switch(session),
1043 Terminal::Kitty => kitty::switch(session),
1044 Terminal::WezTerm => wezterm::switch(session),
1045 Terminal::Tmux => tmux::switch(session),
1046 Terminal::WindowsTerm => Err(
1047 "Windows Terminal currently supports WSL launch only. Use tmux or Kitty inside WSL for session switching."
1048 .into(),
1049 ),
1050 #[cfg(target_os = "macos")]
1051 Terminal::Ghostty => ghostty::switch(session),
1052 #[cfg(target_os = "macos")]
1053 Terminal::Warp => warp::switch(session),
1054 #[cfg(target_os = "macos")]
1055 Terminal::ITerm2 => iterm2::switch(session),
1056 #[cfg(target_os = "macos")]
1057 Terminal::Apple => apple::switch(session),
1058 Terminal::Unknown(name) => Err(format!(
1059 "Unsupported terminal: {name}. Supported: GNOME Terminal, Windows Terminal on WSL (launch only), Ghostty, Warp, iTerm2, Kitty, WezTerm, Terminal.app, tmux. Run `claudectl --doctor` for details."
1060 )),
1061 #[cfg(not(target_os = "macos"))]
1062 _ => Err("Terminal switching not supported on this platform. Run `claudectl --doctor` for details.".into()),
1063 }
1064}
1065
1066pub fn send_input(session: &ClaudeSession, text: &str) -> Result<(), String> {
1067 match detect_terminal() {
1068 Terminal::Gnome => gnome_terminal::send_input(session, text),
1069 #[cfg(target_os = "macos")]
1070 Terminal::Ghostty => ghostty::send_input(session, text),
1071 Terminal::Kitty => kitty::send_input(session, text),
1072 Terminal::Tmux => tmux::send_input(session, text),
1073 Terminal::WindowsTerm => Err(
1074 "Windows Terminal currently supports WSL launch only. Use tmux or Kitty inside WSL for session input automation."
1075 .into(),
1076 ),
1077 #[cfg(target_os = "macos")]
1078 Terminal::Warp => warp::send_input(session, text),
1079 #[cfg(target_os = "macos")]
1080 _ => {
1081 switch_to_terminal(session)?;
1083 std::thread::sleep(std::time::Duration::from_millis(300));
1084 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
1085 run_osascript(&format!(
1086 r#"tell application "System Events" to keystroke "{escaped}""#,
1087 ))
1088 }
1089 #[cfg(not(target_os = "macos"))]
1090 _ => Err("Input injection not supported for this terminal. Run `claudectl --doctor` for details.".into()),
1091 }
1092}
1093
1094pub fn approve_session(session: &ClaudeSession) -> Result<(), String> {
1095 match detect_terminal() {
1096 Terminal::Gnome => gnome_terminal::approve(session),
1097 #[cfg(target_os = "macos")]
1098 Terminal::Ghostty => ghostty::approve(session),
1099 Terminal::Kitty => kitty::approve(session),
1100 Terminal::Tmux => tmux::send_input(session, "\r"),
1101 Terminal::WindowsTerm => Err(
1102 "Windows Terminal currently supports WSL launch only. Use tmux or Kitty inside WSL for approval automation."
1103 .into(),
1104 ),
1105 #[cfg(target_os = "macos")]
1106 Terminal::Warp => warp::approve(session),
1107 #[cfg(target_os = "macos")]
1108 _ => {
1109 switch_to_terminal(session)?;
1111 std::thread::sleep(std::time::Duration::from_millis(300));
1112 run_osascript(r#"tell application "System Events" to key code 36"#)
1113 }
1114 #[cfg(not(target_os = "macos"))]
1115 _ => Err("Input injection not supported for this terminal. Run `claudectl --doctor` for details.".into()),
1116 }
1117}
1118
1119#[cfg(target_os = "macos")]
1120pub fn run_osascript(script: &str) -> Result<(), String> {
1121 let output = std::process::Command::new("osascript")
1122 .args(["-e", script])
1123 .output()
1124 .map_err(|e| format!("Failed to run osascript: {e}"))?;
1125
1126 if output.status.success() {
1127 Ok(())
1128 } else {
1129 let stderr = String::from_utf8_lossy(&output.stderr);
1130 Err(format!("AppleScript error: {}", stderr.trim()))
1131 }
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137
1138 fn remote(target: Option<&str>, user: Option<&str>, host: Option<&str>) -> ClaudeSession {
1139 let json = serde_json::json!({"pid": 1, "project": "staffai", "status": "Idle"});
1140 let mut s = ClaudeSession::from_remote_json("mini", &json).unwrap();
1141 s.tmux_target = target.map(str::to_string);
1142 s.remote_user = user.map(str::to_string);
1143 s.remote_host = host.map(str::to_string);
1144 s
1145 }
1146
1147 #[test]
1148 fn remote_attach_sshes_as_the_advertised_user() {
1149 let argv = remote_attach_argv(&remote(
1150 Some("claude-staffai:0.0"),
1151 Some("baradas"),
1152 Some("100.109.212.89"),
1153 ))
1154 .unwrap();
1155 assert_eq!(argv[3], "baradas@100.109.212.89");
1156 }
1157
1158 #[test]
1159 fn remote_attach_refuses_a_session_outside_tmux() {
1160 let err = remote_attach_argv(&remote(None, Some("baradas"), Some("h"))).unwrap_err();
1161 assert!(err.contains("Not in tmux on mini"), "{err}");
1162 }
1163
1164 #[test]
1165 fn remote_attach_names_an_old_peer() {
1166 let err = remote_attach_argv(&remote(None, None, Some("h"))).unwrap_err();
1167 assert!(err.contains("older claudectl"), "{err}");
1168 }
1169
1170 #[test]
1171 fn remote_attach_needs_an_address() {
1172 let err = remote_attach_argv(&remote(Some("s:0.0"), Some("u"), None)).unwrap_err();
1173 assert!(err.contains("No address known for mini"), "{err}");
1174 }
1175
1176 #[test]
1177 fn help_summary_lists_kitty_actions() {
1178 let summary = help_capability_summary_for(&Terminal::Kitty);
1179 assert_eq!(
1180 summary,
1181 "Current terminal: Kitty (launch, switch, input, approve)"
1182 );
1183 }
1184
1185 #[test]
1186 fn help_summary_marks_unknown_terminal_monitor_only() {
1187 let summary = help_capability_summary_for(&Terminal::Unknown("foot".into()));
1188 assert_eq!(summary, "Current terminal: foot (monitor-only)");
1189 }
1190
1191 #[test]
1192 fn help_summary_mentions_gnome_terminal() {
1193 let summary = help_capability_summary_for(&Terminal::Gnome);
1194 assert!(summary.starts_with("Current terminal: GNOME Terminal"));
1195 }
1196
1197 #[test]
1198 fn help_summary_lists_windows_terminal_launch() {
1199 let summary = help_capability_summary_for(&Terminal::WindowsTerm);
1200 assert_eq!(summary, "Current terminal: Windows Terminal (launch)");
1201 }
1202
1203 #[test]
1204 fn doctor_report_for_unknown_terminal_marks_actions_unsupported() {
1205 let report = doctor_report_for(Terminal::Unknown("foot".into()));
1206 assert_eq!(report.actions.len(), 4);
1207 assert!(
1208 report
1209 .actions
1210 .iter()
1211 .all(|action| action.status == DoctorStatus::Unsupported)
1212 );
1213 }
1214
1215 #[test]
1216 fn platform_label_marks_wsl_explicitly() {
1217 assert_eq!(platform_label("linux", true), "linux (WSL)");
1218 assert_eq!(platform_label("macos", false), "macos");
1219 }
1220
1221 #[test]
1222 fn environment_notes_describe_wsl_interop_state() {
1223 let notes = environment_notes(true, true);
1224 assert!(notes.iter().any(|note| note.contains("WSL detected")));
1225 assert!(notes.iter().any(|note| note.contains("cmd.exe /c wt.exe")));
1226 }
1227
1228 #[test]
1229 fn wsl_interop_check_reports_when_available() {
1230 let check = wsl_interop_check(true).unwrap();
1231 assert_eq!(check.name, "Windows Terminal interop");
1232 }
1233
1234 use std::sync::Mutex;
1237 static ENV_LOCK: Mutex<()> = Mutex::new(());
1238
1239 fn with_clean_env<F: FnOnce() -> R, R>(f: F) -> R {
1241 let _guard = ENV_LOCK.lock().unwrap();
1242
1243 let keys = [
1244 "KITTY_WINDOW_ID",
1245 "KITTY_PID",
1246 "WEZTERM_EXECUTABLE",
1247 "GHOSTTY_RESOURCES_DIR",
1248 "TERM",
1249 "TERM_PROGRAM",
1250 "TMUX",
1251 "GNOME_TERMINAL_SERVICE",
1252 "GNOME_TERMINAL_SCREEN",
1253 "WT_SESSION",
1254 ];
1255 let saved: Vec<(&str, Option<String>)> =
1256 keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
1257
1258 for key in &keys {
1259 unsafe { std::env::remove_var(key) };
1260 }
1261
1262 let result = f();
1263
1264 for (key, val) in saved {
1265 match val {
1266 Some(v) => unsafe { std::env::set_var(key, v) },
1267 None => unsafe { std::env::remove_var(key) },
1268 }
1269 }
1270
1271 result
1272 }
1273
1274 #[test]
1275 fn detect_kitty_via_kitty_window_id() {
1276 with_clean_env(|| {
1277 unsafe { std::env::set_var("KITTY_WINDOW_ID", "49") };
1278 assert_eq!(detect_by_native_env(), Some(Terminal::Kitty));
1279 });
1280 }
1281
1282 #[test]
1283 fn detect_kitty_via_term_xterm_kitty() {
1284 with_clean_env(|| {
1285 unsafe { std::env::set_var("TERM", "xterm-kitty") };
1286 assert_eq!(detect_by_native_env(), Some(Terminal::Kitty));
1287 });
1288 }
1289
1290 #[test]
1291 fn detect_wezterm_via_wezterm_executable() {
1292 with_clean_env(|| {
1293 unsafe { std::env::set_var("WEZTERM_EXECUTABLE", "/usr/bin/wezterm") };
1294 assert_eq!(detect_by_native_env(), Some(Terminal::WezTerm));
1295 });
1296 }
1297
1298 #[test]
1299 fn detect_ghostty_via_ghostty_resources_dir() {
1300 with_clean_env(|| {
1301 unsafe { std::env::set_var("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty") };
1302 assert_eq!(detect_by_native_env(), Some(Terminal::Ghostty));
1303 });
1304 }
1305
1306 #[test]
1307 fn detect_native_env_returns_none_when_clean() {
1308 with_clean_env(|| {
1309 assert_eq!(detect_by_native_env(), None);
1310 });
1311 }
1312
1313 #[test]
1314 fn kitty_window_id_takes_priority_over_term_xterm_kitty() {
1315 with_clean_env(|| {
1317 unsafe {
1318 std::env::set_var("KITTY_WINDOW_ID", "1");
1319 std::env::set_var("TERM", "xterm-kitty");
1320 }
1321 assert_eq!(detect_by_native_env(), Some(Terminal::Kitty));
1322 });
1323 }
1324}