@moejay/wrightty 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +90 -0
- package/.github/workflows/release.yml +177 -0
- package/Cargo.lock +2662 -0
- package/Cargo.toml +38 -0
- package/PROTOCOL.md +1351 -0
- package/README.md +386 -0
- package/agents/ceo/AGENTS.md +24 -0
- package/agents/ceo/HEARTBEAT.md +72 -0
- package/agents/ceo/SOUL.md +33 -0
- package/agents/ceo/TOOLS.md +3 -0
- package/agents/founding-engineer/AGENTS.md +44 -0
- package/crates/wrightty/Cargo.toml +43 -0
- package/crates/wrightty/src/client_cmds.rs +366 -0
- package/crates/wrightty/src/discover.rs +78 -0
- package/crates/wrightty/src/main.rs +100 -0
- package/crates/wrightty/src/server.rs +100 -0
- package/crates/wrightty/src/term.rs +338 -0
- package/crates/wrightty-bridge-ghostty/Cargo.toml +27 -0
- package/crates/wrightty-bridge-ghostty/src/ghostty.rs +422 -0
- package/crates/wrightty-bridge-ghostty/src/lib.rs +2 -0
- package/crates/wrightty-bridge-ghostty/src/main.rs +146 -0
- package/crates/wrightty-bridge-ghostty/src/rpc.rs +307 -0
- package/crates/wrightty-bridge-kitty/Cargo.toml +26 -0
- package/crates/wrightty-bridge-kitty/src/kitty.rs +269 -0
- package/crates/wrightty-bridge-kitty/src/lib.rs +2 -0
- package/crates/wrightty-bridge-kitty/src/main.rs +124 -0
- package/crates/wrightty-bridge-kitty/src/rpc.rs +304 -0
- package/crates/wrightty-bridge-tmux/Cargo.toml +26 -0
- package/crates/wrightty-bridge-tmux/src/lib.rs +2 -0
- package/crates/wrightty-bridge-tmux/src/main.rs +119 -0
- package/crates/wrightty-bridge-tmux/src/rpc.rs +291 -0
- package/crates/wrightty-bridge-tmux/src/tmux.rs +215 -0
- package/crates/wrightty-bridge-wezterm/Cargo.toml +26 -0
- package/crates/wrightty-bridge-wezterm/src/lib.rs +2 -0
- package/crates/wrightty-bridge-wezterm/src/main.rs +119 -0
- package/crates/wrightty-bridge-wezterm/src/rpc.rs +339 -0
- package/crates/wrightty-bridge-wezterm/src/wezterm.rs +190 -0
- package/crates/wrightty-bridge-zellij/Cargo.toml +27 -0
- package/crates/wrightty-bridge-zellij/src/lib.rs +2 -0
- package/crates/wrightty-bridge-zellij/src/main.rs +125 -0
- package/crates/wrightty-bridge-zellij/src/rpc.rs +328 -0
- package/crates/wrightty-bridge-zellij/src/zellij.rs +199 -0
- package/crates/wrightty-client/Cargo.toml +16 -0
- package/crates/wrightty-client/src/client.rs +254 -0
- package/crates/wrightty-client/src/lib.rs +2 -0
- package/crates/wrightty-core/Cargo.toml +21 -0
- package/crates/wrightty-core/src/input.rs +212 -0
- package/crates/wrightty-core/src/lib.rs +4 -0
- package/crates/wrightty-core/src/screen.rs +325 -0
- package/crates/wrightty-core/src/session.rs +249 -0
- package/crates/wrightty-core/src/session_manager.rs +77 -0
- package/crates/wrightty-protocol/Cargo.toml +13 -0
- package/crates/wrightty-protocol/src/error.rs +8 -0
- package/crates/wrightty-protocol/src/events.rs +138 -0
- package/crates/wrightty-protocol/src/lib.rs +4 -0
- package/crates/wrightty-protocol/src/methods.rs +321 -0
- package/crates/wrightty-protocol/src/types.rs +201 -0
- package/crates/wrightty-server/Cargo.toml +23 -0
- package/crates/wrightty-server/src/lib.rs +2 -0
- package/crates/wrightty-server/src/main.rs +65 -0
- package/crates/wrightty-server/src/rpc.rs +455 -0
- package/crates/wrightty-server/src/state.rs +39 -0
- package/examples/basic_command.py +53 -0
- package/examples/interactive_tui.py +86 -0
- package/examples/record_session.py +96 -0
- package/install.sh +81 -0
- package/package.json +24 -0
- package/sdks/node/package-lock.json +85 -0
- package/sdks/node/package.json +44 -0
- package/sdks/node/src/client.ts +94 -0
- package/sdks/node/src/index.ts +19 -0
- package/sdks/node/src/terminal.ts +258 -0
- package/sdks/node/src/types.ts +105 -0
- package/sdks/node/tsconfig.json +17 -0
- package/sdks/python/README.md +96 -0
- package/sdks/python/pyproject.toml +42 -0
- package/sdks/python/wrightty/__init__.py +6 -0
- package/sdks/python/wrightty/cli.py +210 -0
- package/sdks/python/wrightty/client.py +136 -0
- package/sdks/python/wrightty/mcp_server.py +434 -0
- package/sdks/python/wrightty/terminal.py +333 -0
- package/skills/wrightty/SKILL.md +261 -0
- package/src/lib.rs +1 -0
- package/tests/integration_test.rs +618 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
//! Ghostty IPC client.
|
|
2
|
+
//!
|
|
3
|
+
//! Ghostty exposes a Unix domain socket for inter-process communication.
|
|
4
|
+
//! The socket path can be configured via the `GHOSTTY_SOCKET` environment variable,
|
|
5
|
+
//! defaulting to `$XDG_RUNTIME_DIR/ghostty/sock` on Linux and
|
|
6
|
+
//! `$TMPDIR/ghostty-<uid>.sock` on macOS.
|
|
7
|
+
//!
|
|
8
|
+
//! Messages are exchanged as newline-delimited JSON (one JSON object per line).
|
|
9
|
+
//! Requests use `{"type":"<command>", ...fields}` and responses mirror the
|
|
10
|
+
//! same structure with a `"result"` field.
|
|
11
|
+
//!
|
|
12
|
+
//! Input (text / key injection) is delegated to `xdotool` on Linux (X11) or
|
|
13
|
+
//! `osascript` on macOS because Ghostty does not yet expose a send-text IPC
|
|
14
|
+
//! call. Set `GHOSTTY_INPUT_BACKEND=xdotool|osascript|none` to override.
|
|
15
|
+
|
|
16
|
+
use std::env;
|
|
17
|
+
|
|
18
|
+
use serde::{Deserialize, Serialize};
|
|
19
|
+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
20
|
+
use tokio::net::UnixStream;
|
|
21
|
+
use tokio::process::Command;
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/// A Ghostty window as returned by the `list_windows` IPC call.
|
|
28
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
29
|
+
pub struct GhosttyWindow {
|
|
30
|
+
pub id: u64,
|
|
31
|
+
pub title: String,
|
|
32
|
+
pub is_focused: bool,
|
|
33
|
+
pub cols: u16,
|
|
34
|
+
pub rows: u16,
|
|
35
|
+
#[serde(default)]
|
|
36
|
+
pub pid: Option<u32>,
|
|
37
|
+
#[serde(default)]
|
|
38
|
+
pub cwd: Option<String>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Error type
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, thiserror::Error)]
|
|
46
|
+
pub enum GhosttyError {
|
|
47
|
+
#[error("ghostty socket not found at {0}; is Ghostty running?")]
|
|
48
|
+
SocketNotFound(String),
|
|
49
|
+
#[error("ghostty IPC call failed: {0}")]
|
|
50
|
+
IpcFailed(String),
|
|
51
|
+
#[error("failed to parse ghostty response: {0}")]
|
|
52
|
+
ParseError(String),
|
|
53
|
+
#[error("window {0} not found")]
|
|
54
|
+
WindowNotFound(u64),
|
|
55
|
+
#[error("input backend error: {0}")]
|
|
56
|
+
InputBackend(String),
|
|
57
|
+
#[error("io error: {0}")]
|
|
58
|
+
Io(#[from] std::io::Error),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Socket helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
fn socket_path() -> String {
|
|
66
|
+
if let Ok(p) = env::var("GHOSTTY_SOCKET") {
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[cfg(target_os = "macos")]
|
|
71
|
+
{
|
|
72
|
+
let uid = unsafe { libc::getuid() };
|
|
73
|
+
let tmp = env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
|
|
74
|
+
return format!("{tmp}/ghostty-{uid}.sock");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Linux default: $XDG_RUNTIME_DIR/ghostty/sock
|
|
78
|
+
let runtime_dir = env::var("XDG_RUNTIME_DIR")
|
|
79
|
+
.unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
|
|
80
|
+
format!("{runtime_dir}/ghostty/sock")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Open a connection to the Ghostty IPC socket.
|
|
84
|
+
async fn connect() -> Result<UnixStream, GhosttyError> {
|
|
85
|
+
let path = socket_path();
|
|
86
|
+
UnixStream::connect(&path)
|
|
87
|
+
.await
|
|
88
|
+
.map_err(|_| GhosttyError::SocketNotFound(path))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// IPC request / response
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
#[derive(Serialize)]
|
|
96
|
+
struct IpcRequest<'a, T: Serialize> {
|
|
97
|
+
#[serde(rename = "type")]
|
|
98
|
+
kind: &'a str,
|
|
99
|
+
#[serde(flatten)]
|
|
100
|
+
payload: T,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[derive(Deserialize, Debug)]
|
|
104
|
+
struct IpcResponse {
|
|
105
|
+
#[serde(default)]
|
|
106
|
+
error: Option<String>,
|
|
107
|
+
#[serde(flatten)]
|
|
108
|
+
fields: serde_json::Value,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Send one IPC request and read the response line.
|
|
112
|
+
async fn ipc_call<T: Serialize>(
|
|
113
|
+
kind: &str,
|
|
114
|
+
payload: T,
|
|
115
|
+
) -> Result<serde_json::Value, GhosttyError> {
|
|
116
|
+
let mut stream = connect().await?;
|
|
117
|
+
|
|
118
|
+
let req = IpcRequest { kind, payload };
|
|
119
|
+
let mut line = serde_json::to_string(&req)
|
|
120
|
+
.map_err(|e| GhosttyError::ParseError(e.to_string()))?;
|
|
121
|
+
line.push('\n');
|
|
122
|
+
|
|
123
|
+
stream
|
|
124
|
+
.write_all(line.as_bytes())
|
|
125
|
+
.await
|
|
126
|
+
.map_err(GhosttyError::Io)?;
|
|
127
|
+
|
|
128
|
+
let mut reader = BufReader::new(stream);
|
|
129
|
+
let mut resp_line = String::new();
|
|
130
|
+
reader
|
|
131
|
+
.read_line(&mut resp_line)
|
|
132
|
+
.await
|
|
133
|
+
.map_err(GhosttyError::Io)?;
|
|
134
|
+
|
|
135
|
+
let resp: IpcResponse = serde_json::from_str(resp_line.trim())
|
|
136
|
+
.map_err(|e| GhosttyError::ParseError(format!("{e}: {resp_line}")))?;
|
|
137
|
+
|
|
138
|
+
if let Some(err) = resp.error {
|
|
139
|
+
return Err(GhosttyError::IpcFailed(err));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Ok(resp.fields)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Public API
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/// Check that Ghostty is reachable by listing windows.
|
|
150
|
+
pub async fn health_check() -> Result<(), GhosttyError> {
|
|
151
|
+
list_windows().await?;
|
|
152
|
+
Ok(())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// List all Ghostty windows.
|
|
156
|
+
pub async fn list_windows() -> Result<Vec<GhosttyWindow>, GhosttyError> {
|
|
157
|
+
#[derive(Serialize)]
|
|
158
|
+
struct Empty {}
|
|
159
|
+
let value = ipc_call("list_windows", Empty {}).await?;
|
|
160
|
+
|
|
161
|
+
let windows: Vec<GhosttyWindow> = serde_json::from_value(
|
|
162
|
+
value
|
|
163
|
+
.get("windows")
|
|
164
|
+
.cloned()
|
|
165
|
+
.unwrap_or(serde_json::Value::Array(vec![])),
|
|
166
|
+
)
|
|
167
|
+
.map_err(|e| GhosttyError::ParseError(e.to_string()))?;
|
|
168
|
+
|
|
169
|
+
Ok(windows)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Find a window by its numeric ID.
|
|
173
|
+
pub async fn find_window(window_id: u64) -> Result<GhosttyWindow, GhosttyError> {
|
|
174
|
+
let windows = list_windows().await?;
|
|
175
|
+
windows
|
|
176
|
+
.into_iter()
|
|
177
|
+
.find(|w| w.id == window_id)
|
|
178
|
+
.ok_or(GhosttyError::WindowNotFound(window_id))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Open a new Ghostty window and return its ID.
|
|
182
|
+
pub async fn new_window() -> Result<u64, GhosttyError> {
|
|
183
|
+
#[derive(Serialize)]
|
|
184
|
+
struct NewWindowReq {
|
|
185
|
+
action: &'static str,
|
|
186
|
+
}
|
|
187
|
+
let value = ipc_call("action", NewWindowReq { action: "new_window" }).await?;
|
|
188
|
+
|
|
189
|
+
let window_id = value
|
|
190
|
+
.get("window_id")
|
|
191
|
+
.and_then(|v| v.as_u64())
|
|
192
|
+
.ok_or_else(|| GhosttyError::ParseError("missing window_id in new_window response".into()))?;
|
|
193
|
+
|
|
194
|
+
Ok(window_id)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Close a Ghostty window.
|
|
198
|
+
pub async fn close_window(window_id: u64) -> Result<(), GhosttyError> {
|
|
199
|
+
#[derive(Serialize)]
|
|
200
|
+
struct CloseReq {
|
|
201
|
+
action: &'static str,
|
|
202
|
+
window_id: u64,
|
|
203
|
+
}
|
|
204
|
+
ipc_call(
|
|
205
|
+
"action",
|
|
206
|
+
CloseReq {
|
|
207
|
+
action: "close_window",
|
|
208
|
+
window_id,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
.await?;
|
|
212
|
+
Ok(())
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Focus a Ghostty window (bring it to front).
|
|
216
|
+
pub async fn focus_window(window_id: u64) -> Result<(), GhosttyError> {
|
|
217
|
+
#[derive(Serialize)]
|
|
218
|
+
struct FocusReq {
|
|
219
|
+
action: &'static str,
|
|
220
|
+
window_id: u64,
|
|
221
|
+
}
|
|
222
|
+
ipc_call(
|
|
223
|
+
"action",
|
|
224
|
+
FocusReq {
|
|
225
|
+
action: "focus_window",
|
|
226
|
+
window_id,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
.await?;
|
|
230
|
+
Ok(())
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Input injection
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/// The backend to use for injecting keystrokes / text.
|
|
238
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
239
|
+
pub enum InputBackend {
|
|
240
|
+
/// `xdotool type` / `xdotool key` (Linux X11)
|
|
241
|
+
Xdotool,
|
|
242
|
+
/// `osascript` System Events keystroke (macOS)
|
|
243
|
+
Osascript,
|
|
244
|
+
/// No-op (useful for testing or headless envs)
|
|
245
|
+
None,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
impl InputBackend {
|
|
249
|
+
pub fn detect() -> Self {
|
|
250
|
+
if let Ok(val) = env::var("GHOSTTY_INPUT_BACKEND") {
|
|
251
|
+
return match val.to_lowercase().as_str() {
|
|
252
|
+
"xdotool" => Self::Xdotool,
|
|
253
|
+
"osascript" => Self::Osascript,
|
|
254
|
+
"none" => Self::None,
|
|
255
|
+
_ => Self::detect_auto(),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
Self::detect_auto()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fn detect_auto() -> Self {
|
|
262
|
+
#[cfg(target_os = "macos")]
|
|
263
|
+
return Self::Osascript;
|
|
264
|
+
|
|
265
|
+
// Linux: probe xdotool
|
|
266
|
+
if std::process::Command::new("xdotool")
|
|
267
|
+
.arg("version")
|
|
268
|
+
.output()
|
|
269
|
+
.map(|o| o.status.success())
|
|
270
|
+
.unwrap_or(false)
|
|
271
|
+
{
|
|
272
|
+
return Self::Xdotool;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
Self::None
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// Send literal text to the focused Ghostty window.
|
|
280
|
+
///
|
|
281
|
+
/// The caller is responsible for focusing the target window first.
|
|
282
|
+
pub async fn send_text(window_id: u64, text: &str) -> Result<(), GhosttyError> {
|
|
283
|
+
focus_window(window_id).await?;
|
|
284
|
+
// Small yield to let the WM process the focus event before we inject
|
|
285
|
+
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
|
286
|
+
|
|
287
|
+
match InputBackend::detect() {
|
|
288
|
+
InputBackend::Xdotool => xdotool_type(text).await,
|
|
289
|
+
InputBackend::Osascript => osascript_keystroke(text).await,
|
|
290
|
+
InputBackend::None => Err(GhosttyError::InputBackend(
|
|
291
|
+
"no input backend available; install xdotool or set GHOSTTY_INPUT_BACKEND".into(),
|
|
292
|
+
)),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Send a key name (e.g. `"ctrl+c"`, `"Return"`) to the focused window.
|
|
297
|
+
pub async fn send_key(window_id: u64, key: &str) -> Result<(), GhosttyError> {
|
|
298
|
+
focus_window(window_id).await?;
|
|
299
|
+
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
|
300
|
+
|
|
301
|
+
match InputBackend::detect() {
|
|
302
|
+
InputBackend::Xdotool => xdotool_key(key).await,
|
|
303
|
+
InputBackend::Osascript => osascript_key(key).await,
|
|
304
|
+
InputBackend::None => Err(GhosttyError::InputBackend(
|
|
305
|
+
"no input backend available; install xdotool or set GHOSTTY_INPUT_BACKEND".into(),
|
|
306
|
+
)),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// xdotool helpers
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
async fn xdotool_type(text: &str) -> Result<(), GhosttyError> {
|
|
315
|
+
let output = Command::new("xdotool")
|
|
316
|
+
.args(["type", "--clearmodifiers", "--delay", "0", "--", text])
|
|
317
|
+
.output()
|
|
318
|
+
.await
|
|
319
|
+
.map_err(GhosttyError::Io)?;
|
|
320
|
+
|
|
321
|
+
if !output.status.success() {
|
|
322
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
323
|
+
return Err(GhosttyError::InputBackend(format!(
|
|
324
|
+
"xdotool type failed: {stderr}"
|
|
325
|
+
)));
|
|
326
|
+
}
|
|
327
|
+
Ok(())
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async fn xdotool_key(key: &str) -> Result<(), GhosttyError> {
|
|
331
|
+
let output = Command::new("xdotool")
|
|
332
|
+
.args(["key", "--clearmodifiers", "--", key])
|
|
333
|
+
.output()
|
|
334
|
+
.await
|
|
335
|
+
.map_err(GhosttyError::Io)?;
|
|
336
|
+
|
|
337
|
+
if !output.status.success() {
|
|
338
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
339
|
+
return Err(GhosttyError::InputBackend(format!(
|
|
340
|
+
"xdotool key failed: {stderr}"
|
|
341
|
+
)));
|
|
342
|
+
}
|
|
343
|
+
Ok(())
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// osascript helpers (macOS)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
async fn osascript_keystroke(text: &str) -> Result<(), GhosttyError> {
|
|
351
|
+
// Escape the string for AppleScript
|
|
352
|
+
let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
|
|
353
|
+
let script = format!(
|
|
354
|
+
r#"tell application "System Events" to keystroke "{escaped}""#
|
|
355
|
+
);
|
|
356
|
+
let output = Command::new("osascript")
|
|
357
|
+
.args(["-e", &script])
|
|
358
|
+
.output()
|
|
359
|
+
.await
|
|
360
|
+
.map_err(GhosttyError::Io)?;
|
|
361
|
+
|
|
362
|
+
if !output.status.success() {
|
|
363
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
364
|
+
return Err(GhosttyError::InputBackend(format!(
|
|
365
|
+
"osascript keystroke failed: {stderr}"
|
|
366
|
+
)));
|
|
367
|
+
}
|
|
368
|
+
Ok(())
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async fn osascript_key(key: &str) -> Result<(), GhosttyError> {
|
|
372
|
+
// Map wrightty key names to osascript key code names
|
|
373
|
+
let key_code = map_key_to_applescript(key);
|
|
374
|
+
let script = format!(
|
|
375
|
+
r#"tell application "System Events" to key code {key_code}"#
|
|
376
|
+
);
|
|
377
|
+
let output = Command::new("osascript")
|
|
378
|
+
.args(["-e", &script])
|
|
379
|
+
.output()
|
|
380
|
+
.await
|
|
381
|
+
.map_err(GhosttyError::Io)?;
|
|
382
|
+
|
|
383
|
+
if !output.status.success() {
|
|
384
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
385
|
+
return Err(GhosttyError::InputBackend(format!(
|
|
386
|
+
"osascript key code failed: {stderr}"
|
|
387
|
+
)));
|
|
388
|
+
}
|
|
389
|
+
Ok(())
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// Map a key name (xdotool-style) to an AppleScript key code number.
|
|
393
|
+
fn map_key_to_applescript(key: &str) -> &'static str {
|
|
394
|
+
match key {
|
|
395
|
+
"Return" | "return" | "KP_Return" => "36",
|
|
396
|
+
"Tab" | "tab" => "48",
|
|
397
|
+
"BackSpace" | "backspace" => "51",
|
|
398
|
+
"Delete" | "delete" => "117",
|
|
399
|
+
"Escape" | "escape" => "53",
|
|
400
|
+
"Up" | "KP_Up" => "126",
|
|
401
|
+
"Down" | "KP_Down" => "125",
|
|
402
|
+
"Left" | "KP_Left" => "123",
|
|
403
|
+
"Right" | "KP_Right" => "124",
|
|
404
|
+
"Home" => "115",
|
|
405
|
+
"End" => "119",
|
|
406
|
+
"Page_Up" => "116",
|
|
407
|
+
"Page_Down" => "121",
|
|
408
|
+
"F1" => "122",
|
|
409
|
+
"F2" => "120",
|
|
410
|
+
"F3" => "99",
|
|
411
|
+
"F4" => "118",
|
|
412
|
+
"F5" => "96",
|
|
413
|
+
"F6" => "97",
|
|
414
|
+
"F7" => "98",
|
|
415
|
+
"F8" => "100",
|
|
416
|
+
"F9" => "101",
|
|
417
|
+
"F10" => "109",
|
|
418
|
+
"F11" => "103",
|
|
419
|
+
"F12" => "111",
|
|
420
|
+
_ => "36", // fall back to Return
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
use std::net::{SocketAddr, TcpListener};
|
|
2
|
+
use std::process;
|
|
3
|
+
use std::time::Duration;
|
|
4
|
+
|
|
5
|
+
use clap::Parser;
|
|
6
|
+
use jsonrpsee::server::Server;
|
|
7
|
+
use tracing_subscriber::EnvFilter;
|
|
8
|
+
|
|
9
|
+
use wrightty_bridge_ghostty::ghostty;
|
|
10
|
+
use wrightty_bridge_ghostty::rpc::build_rpc_module;
|
|
11
|
+
|
|
12
|
+
const PORT_RANGE_START: u16 = 9501;
|
|
13
|
+
const PORT_RANGE_END: u16 = 9520;
|
|
14
|
+
|
|
15
|
+
#[derive(Parser)]
|
|
16
|
+
#[command(
|
|
17
|
+
name = "wrightty-bridge-ghostty",
|
|
18
|
+
about = "Bridge that translates wrightty protocol calls into Ghostty IPC commands",
|
|
19
|
+
long_about = "\
|
|
20
|
+
Connects to a running Ghostty terminal emulator via its Unix IPC socket and \
|
|
21
|
+
exposes the wrightty WebSocket JSON-RPC 2.0 interface.\n\n\
|
|
22
|
+
REQUIREMENTS:\n\
|
|
23
|
+
- Ghostty must be running (the IPC socket is created on startup).\n\
|
|
24
|
+
- For text/key injection, xdotool must be installed on Linux (X11) or\n\
|
|
25
|
+
Accessibility must be enabled on macOS.\n\n\
|
|
26
|
+
ENVIRONMENT:\n\
|
|
27
|
+
GHOSTTY_SOCKET Override the IPC socket path.\n\
|
|
28
|
+
GHOSTTY_INPUT_BACKEND Force input backend: xdotool | osascript | none."
|
|
29
|
+
)]
|
|
30
|
+
struct Cli {
|
|
31
|
+
#[arg(long, default_value = "127.0.0.1")]
|
|
32
|
+
host: String,
|
|
33
|
+
|
|
34
|
+
/// Port to listen on. If not specified, auto-selects the next available
|
|
35
|
+
/// port starting at 9481.
|
|
36
|
+
#[arg(long)]
|
|
37
|
+
port: Option<u16>,
|
|
38
|
+
|
|
39
|
+
/// Interval in seconds to check if Ghostty is still running. 0 to disable.
|
|
40
|
+
#[arg(long, default_value_t = 10)]
|
|
41
|
+
watchdog_interval: u64,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn find_available_port(host: &str, start: u16, end: u16) -> Option<u16> {
|
|
45
|
+
for port in start..=end {
|
|
46
|
+
if TcpListener::bind(format!("{host}:{port}")).is_ok() {
|
|
47
|
+
return Some(port);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
None
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[tokio::main]
|
|
54
|
+
async fn main() -> anyhow::Result<()> {
|
|
55
|
+
tracing_subscriber::fmt()
|
|
56
|
+
.with_env_filter(
|
|
57
|
+
EnvFilter::from_default_env()
|
|
58
|
+
.add_directive("wrightty_bridge_ghostty=info".parse()?),
|
|
59
|
+
)
|
|
60
|
+
.init();
|
|
61
|
+
|
|
62
|
+
let cli = Cli::parse();
|
|
63
|
+
|
|
64
|
+
// --- Startup health check ---
|
|
65
|
+
tracing::info!("Checking Ghostty connectivity...");
|
|
66
|
+
match ghostty::health_check().await {
|
|
67
|
+
Ok(()) => tracing::info!("Ghostty IPC socket is reachable"),
|
|
68
|
+
Err(e) => {
|
|
69
|
+
eprintln!("error: Cannot connect to Ghostty: {e}");
|
|
70
|
+
eprintln!();
|
|
71
|
+
eprintln!("Make sure Ghostty is running. The bridge connects to:");
|
|
72
|
+
eprintln!(" $XDG_RUNTIME_DIR/ghostty/sock (Linux)");
|
|
73
|
+
eprintln!(" $TMPDIR/ghostty-<uid>.sock (macOS)");
|
|
74
|
+
eprintln!("Override with: GHOSTTY_SOCKET=/path/to/sock");
|
|
75
|
+
process::exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Report which input backend will be used
|
|
80
|
+
let backend = ghostty::InputBackend::detect();
|
|
81
|
+
if backend == ghostty::InputBackend::None {
|
|
82
|
+
tracing::warn!(
|
|
83
|
+
"No input backend detected. \
|
|
84
|
+
Install xdotool (Linux/X11) or enable Accessibility (macOS) for \
|
|
85
|
+
Input.sendText / Input.sendKeys support."
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
tracing::info!("Input backend: {:?}", backend);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let port = match cli.port {
|
|
92
|
+
Some(p) => p,
|
|
93
|
+
None => find_available_port(&cli.host, PORT_RANGE_START, PORT_RANGE_END)
|
|
94
|
+
.ok_or_else(|| {
|
|
95
|
+
anyhow::anyhow!(
|
|
96
|
+
"No available port in range {PORT_RANGE_START}-{PORT_RANGE_END}"
|
|
97
|
+
)
|
|
98
|
+
})?,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let addr: SocketAddr = format!("{}:{}", cli.host, port).parse()?;
|
|
102
|
+
|
|
103
|
+
let module = build_rpc_module()?;
|
|
104
|
+
let server = Server::builder().build(addr).await?;
|
|
105
|
+
let handle = server.start(module);
|
|
106
|
+
|
|
107
|
+
tracing::info!("wrightty-bridge-ghostty listening on ws://{addr}");
|
|
108
|
+
println!("wrightty-bridge-ghostty listening on ws://{addr}");
|
|
109
|
+
|
|
110
|
+
// --- Watchdog: periodically check Ghostty is still running ---
|
|
111
|
+
if cli.watchdog_interval > 0 {
|
|
112
|
+
let interval = Duration::from_secs(cli.watchdog_interval);
|
|
113
|
+
let server_handle = handle.clone();
|
|
114
|
+
tokio::spawn(async move {
|
|
115
|
+
let mut consecutive_failures = 0u32;
|
|
116
|
+
loop {
|
|
117
|
+
tokio::time::sleep(interval).await;
|
|
118
|
+
match ghostty::health_check().await {
|
|
119
|
+
Ok(()) => {
|
|
120
|
+
if consecutive_failures > 0 {
|
|
121
|
+
tracing::info!("Ghostty reconnected");
|
|
122
|
+
consecutive_failures = 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
Err(e) => {
|
|
126
|
+
consecutive_failures += 1;
|
|
127
|
+
tracing::warn!(
|
|
128
|
+
"Ghostty health check failed ({consecutive_failures}): {e}"
|
|
129
|
+
);
|
|
130
|
+
if consecutive_failures >= 3 {
|
|
131
|
+
tracing::error!(
|
|
132
|
+
"Ghostty unreachable after {consecutive_failures} checks, shutting down"
|
|
133
|
+
);
|
|
134
|
+
server_handle.stop().unwrap();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
handle.stopped().await;
|
|
144
|
+
|
|
145
|
+
Ok(())
|
|
146
|
+
}
|