Rust · 10335 bytes Raw Blame History
1 //! gartermctl - Control garterm terminal emulator
2 //!
3 //! Supports multiple garterm windows via PID-based sockets.
4 //! By default, targets the last focused window.
5
6 use anyhow::{Context, Result};
7 use clap::{Parser, Subcommand};
8 use garterm_ipc::{Command, Response, WindowInfo};
9 use std::io::{BufRead, BufReader, Write};
10 use std::os::unix::net::UnixStream;
11 use std::path::PathBuf;
12
13 #[derive(Parser)]
14 #[command(name = "gartermctl")]
15 #[command(about = "Control garterm terminal emulator")]
16 #[command(version)]
17 struct Cli {
18 /// Target a specific window by PID
19 #[arg(long, short = 'w')]
20 window: Option<u32>,
21
22 /// Send command to all running garterm instances
23 #[arg(long, short = 'a')]
24 all: bool,
25
26 #[command(subcommand)]
27 command: Commands,
28 }
29
30 #[derive(Subcommand)]
31 enum Commands {
32 /// List all running garterm instances
33 List,
34 /// Create a new tab
35 NewTab {
36 /// Working directory
37 #[arg(long)]
38 cwd: Option<String>,
39 /// Command to run after shell starts
40 #[arg(long, short = 'e')]
41 exec: Option<String>,
42 /// Tab title
43 #[arg(long, short = 't')]
44 title: Option<String>,
45 },
46 /// Close the current tab
47 CloseTab,
48 /// Switch to next tab
49 NextTab,
50 /// Switch to previous tab
51 PrevTab,
52 /// Switch to a specific tab (1-indexed)
53 Tab {
54 /// Tab number (1-indexed)
55 index: usize,
56 },
57 /// Split the focused pane
58 Split {
59 /// Split horizontally (side-by-side)
60 #[arg(long, short = 'H')]
61 horizontal: bool,
62 /// Working directory
63 #[arg(long)]
64 cwd: Option<String>,
65 /// Command to run after shell starts
66 #[arg(long, short = 'e')]
67 exec: Option<String>,
68 },
69 /// Load a named session from config
70 LoadSession {
71 /// Session name
72 name: String,
73 },
74 /// Close the focused pane
75 ClosePane,
76 /// Focus pane in direction
77 Focus {
78 /// Direction: up, down, left, right
79 direction: String,
80 },
81 /// Send text to the focused pane
82 Send {
83 /// Text to send
84 text: String,
85 },
86 /// Get terminal info
87 Info,
88 /// Reload configuration
89 Reload,
90 /// Quit garterm
91 Quit,
92 }
93
94 /// Get the runtime directory for garterm sockets
95 fn runtime_dir() -> PathBuf {
96 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
97 PathBuf::from(runtime_dir).join("garterm")
98 } else {
99 PathBuf::from("/tmp").join("garterm")
100 }
101 }
102
103 /// Get socket path for a specific PID
104 fn socket_path_for_pid(pid: u32) -> PathBuf {
105 runtime_dir().join(format!("garterm-{}.sock", pid))
106 }
107
108 /// Get the path to the focus tracking file
109 fn focus_file_path() -> PathBuf {
110 runtime_dir().join("focused")
111 }
112
113 /// Check if a process exists
114 fn process_exists(pid: u32) -> bool {
115 unsafe { libc::kill(pid as i32, 0) == 0 }
116 }
117
118 /// List all running garterm instances by checking socket files
119 fn list_instances() -> Vec<u32> {
120 let runtime_dir = runtime_dir();
121 let mut pids = Vec::new();
122
123 if let Ok(entries) = std::fs::read_dir(&runtime_dir) {
124 for entry in entries.flatten() {
125 let name = entry.file_name();
126 let name = name.to_string_lossy();
127 if name.starts_with("garterm-") && name.ends_with(".sock") {
128 if let Some(pid_str) = name.strip_prefix("garterm-").and_then(|s| s.strip_suffix(".sock")) {
129 if let Ok(pid) = pid_str.parse::<u32>() {
130 // Verify the process still exists
131 if process_exists(pid) {
132 pids.push(pid);
133 } else {
134 // Clean up stale socket
135 let _ = std::fs::remove_file(entry.path());
136 }
137 }
138 }
139 }
140 }
141 }
142
143 pids
144 }
145
146 /// Get the PID of the currently focused garterm window
147 fn get_focused_pid() -> Option<u32> {
148 let focus_path = focus_file_path();
149 std::fs::read_to_string(&focus_path)
150 .ok()
151 .and_then(|s| s.trim().parse().ok())
152 .filter(|&pid| process_exists(pid))
153 }
154
155 /// Send a command to a specific PID and return the response
156 fn send_command_to_pid(pid: u32, cmd: &Command) -> Result<Response> {
157 let socket = socket_path_for_pid(pid);
158 let mut stream = UnixStream::connect(&socket)
159 .with_context(|| format!("Failed to connect to garterm {} at {}", pid, socket.display()))?;
160
161 // Send command as JSON
162 let json = serde_json::to_string(&cmd)?;
163 writeln!(stream, "{}", json)?;
164
165 // Read response
166 let mut reader = BufReader::new(&stream);
167 let mut line = String::new();
168 reader.read_line(&mut line)?;
169
170 let response: Response = serde_json::from_str(&line)
171 .with_context(|| format!("Invalid response: {}", line.trim()))?;
172
173 Ok(response)
174 }
175
176 /// Get info from a garterm instance (tabs, focused status)
177 fn get_window_info(pid: u32, focused_pid: Option<u32>) -> Option<WindowInfo> {
178 let cmd = Command::Ping;
179 if send_command_to_pid(pid, &cmd).is_ok() {
180 // Get actual tab count via GetInfo
181 let info_cmd = Command::GetInfo;
182 let tabs = if let Ok(response) = send_command_to_pid(pid, &info_cmd) {
183 response.data
184 .and_then(|d| d.get("tabs").and_then(|v| v.as_u64()))
185 .unwrap_or(1) as usize
186 } else {
187 1
188 };
189
190 Some(WindowInfo {
191 pid,
192 tabs,
193 focused: focused_pid == Some(pid),
194 })
195 } else {
196 None
197 }
198 }
199
200 fn main() -> Result<()> {
201 let cli = Cli::parse();
202
203 // Handle list command specially - doesn't target any window
204 if matches!(cli.command, Commands::List) {
205 let instances = list_instances();
206 let focused = get_focused_pid();
207
208 if instances.is_empty() {
209 println!("No garterm instances running");
210 } else {
211 println!("PID\tTABS\tFOCUSED");
212 for pid in instances {
213 if let Some(info) = get_window_info(pid, focused) {
214 println!("{}\t{}\t{}", info.pid, info.tabs, if info.focused { "*" } else { "" });
215 }
216 }
217 }
218 return Ok(());
219 }
220
221 // Convert command enum to IPC Command
222 let cmd = match &cli.command {
223 Commands::List => unreachable!(),
224 Commands::NewTab { cwd, exec, title } => Command::NewTab {
225 cwd: cwd.clone(),
226 startup_cmd: exec.clone(),
227 title: title.clone(),
228 },
229 Commands::CloseTab => Command::CloseTab,
230 Commands::NextTab => Command::NextTab,
231 Commands::PrevTab => Command::PrevTab,
232 Commands::Tab { index } => Command::SwitchTab { index: *index },
233 Commands::Split { horizontal, cwd, exec } => Command::Split {
234 direction: if *horizontal { "horizontal".into() } else { "vertical".into() },
235 cwd: cwd.clone(),
236 startup_cmd: exec.clone(),
237 },
238 Commands::ClosePane => Command::ClosePane,
239 Commands::Focus { direction } => Command::FocusPaneDirection { direction: direction.clone() },
240 Commands::Send { text } => Command::SendText { text: text.clone() },
241 Commands::LoadSession { name } => Command::LoadSession { name: name.clone() },
242 Commands::Info => Command::GetInfo,
243 Commands::Reload => Command::Reload,
244 Commands::Quit => Command::Quit,
245 };
246
247 // Determine target(s)
248 let targets: Vec<u32> = if cli.all {
249 list_instances()
250 } else if let Some(pid) = cli.window {
251 if !process_exists(pid) {
252 eprintln!("Error: No garterm instance with PID {}", pid);
253 std::process::exit(1);
254 }
255 vec![pid]
256 } else {
257 // Default: target last focused window
258 match get_focused_pid() {
259 Some(pid) => vec![pid],
260 None => {
261 // Fall back to first available instance
262 let instances = list_instances();
263 if instances.is_empty() {
264 eprintln!("Error: No garterm instances running");
265 std::process::exit(1);
266 }
267 vec![instances[0]]
268 }
269 }
270 };
271
272 if targets.is_empty() {
273 eprintln!("Error: No garterm instances running");
274 std::process::exit(1);
275 }
276
277 // Send command to all targets
278 let mut any_success = false;
279 let mut errors = Vec::new();
280
281 for pid in &targets {
282 match send_command_to_pid(*pid, &cmd) {
283 Ok(response) => {
284 if response.success {
285 any_success = true;
286 if targets.len() > 1 {
287 // Multi-target: prefix output with PID
288 if let Some(data) = response.data {
289 println!("[{}] {}", pid, serde_json::to_string_pretty(&data)?);
290 } else if let Some(msg) = response.message {
291 println!("[{}] {}", pid, msg);
292 }
293 } else {
294 // Single target: normal output
295 if let Some(data) = response.data {
296 println!("{}", serde_json::to_string_pretty(&data)?);
297 } else if let Some(msg) = response.message {
298 println!("{}", msg);
299 }
300 }
301 } else {
302 let msg = response.message.unwrap_or_else(|| "Unknown error".into());
303 if targets.len() > 1 {
304 errors.push(format!("[{}] {}", pid, msg));
305 } else {
306 errors.push(msg);
307 }
308 }
309 }
310 Err(e) => {
311 if targets.len() > 1 {
312 errors.push(format!("[{}] {}", pid, e));
313 } else {
314 errors.push(e.to_string());
315 }
316 }
317 }
318 }
319
320 // Report errors
321 for error in &errors {
322 eprintln!("Error: {}", error);
323 }
324
325 if !any_success && !errors.is_empty() {
326 std::process::exit(1);
327 }
328
329 Ok(())
330 }
331