@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,366 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
|
|
3
|
+
use clap::{Args, Subcommand};
|
|
4
|
+
use wrightty_client::WrighttyClient;
|
|
5
|
+
use wrightty_protocol::types::*;
|
|
6
|
+
|
|
7
|
+
use crate::server::{PORT_RANGE_START, PORT_RANGE_END};
|
|
8
|
+
|
|
9
|
+
/// Convert `Box<dyn Error>` from WrighttyClient into anyhow::Error.
|
|
10
|
+
fn e<T>(r: Result<T, Box<dyn std::error::Error>>) -> anyhow::Result<T> {
|
|
11
|
+
r.map_err(|e| anyhow::anyhow!("{e}"))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// --- Shared connection logic ---
|
|
15
|
+
|
|
16
|
+
#[derive(Args, Clone)]
|
|
17
|
+
pub struct ConnectOpts {
|
|
18
|
+
/// Server URL (default: auto-discover)
|
|
19
|
+
#[arg(long, global = true)]
|
|
20
|
+
url: Option<String>,
|
|
21
|
+
|
|
22
|
+
/// Session ID (default: first available)
|
|
23
|
+
#[arg(long, global = true)]
|
|
24
|
+
session: Option<String>,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async fn connect(opts: &ConnectOpts) -> anyhow::Result<(WrighttyClient, String)> {
|
|
28
|
+
let url = match &opts.url {
|
|
29
|
+
Some(u) => u.clone(),
|
|
30
|
+
None => discover_first().await?,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let client = e(WrighttyClient::connect(&url).await)
|
|
34
|
+
.map_err(|err| anyhow::anyhow!("Failed to connect to {url}: {err}"))?;
|
|
35
|
+
|
|
36
|
+
let session_id = match &opts.session {
|
|
37
|
+
Some(s) => s.clone(),
|
|
38
|
+
None => {
|
|
39
|
+
let sessions = e(client.session_list().await)?;
|
|
40
|
+
sessions
|
|
41
|
+
.first()
|
|
42
|
+
.map(|s| s.session_id.clone())
|
|
43
|
+
.unwrap_or_else(|| "0".to_string())
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
Ok((client, session_id))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async fn discover_first() -> anyhow::Result<String> {
|
|
51
|
+
use jsonrpsee::core::client::ClientT;
|
|
52
|
+
use jsonrpsee::core::params::ObjectParams;
|
|
53
|
+
use jsonrpsee::ws_client::WsClientBuilder;
|
|
54
|
+
|
|
55
|
+
for port in PORT_RANGE_START..=PORT_RANGE_END {
|
|
56
|
+
let url = format!("ws://127.0.0.1:{port}");
|
|
57
|
+
let Ok(client) = WsClientBuilder::default()
|
|
58
|
+
.connection_timeout(std::time::Duration::from_millis(100))
|
|
59
|
+
.build(&url)
|
|
60
|
+
.await
|
|
61
|
+
else {
|
|
62
|
+
continue;
|
|
63
|
+
};
|
|
64
|
+
let Ok(_): Result<serde_json::Value, _> =
|
|
65
|
+
client.request("Wrightty.getInfo", ObjectParams::new()).await
|
|
66
|
+
else {
|
|
67
|
+
continue;
|
|
68
|
+
};
|
|
69
|
+
return Ok(url);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
anyhow::bail!(
|
|
73
|
+
"No wrightty server found on ports {PORT_RANGE_START}-{PORT_RANGE_END}.\n\
|
|
74
|
+
Start one with:\n \
|
|
75
|
+
wrightty term --headless\n \
|
|
76
|
+
wrightty term --bridge-tmux\n \
|
|
77
|
+
wrightty term --bridge-wezterm"
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Commands ---
|
|
82
|
+
|
|
83
|
+
#[derive(Args)]
|
|
84
|
+
pub struct RunArgs {
|
|
85
|
+
/// The command to run
|
|
86
|
+
command: String,
|
|
87
|
+
|
|
88
|
+
/// Timeout in seconds
|
|
89
|
+
#[arg(long, default_value_t = 30)]
|
|
90
|
+
timeout: u64,
|
|
91
|
+
|
|
92
|
+
#[command(flatten)]
|
|
93
|
+
connect: ConnectOpts,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub async fn run_cmd(args: RunArgs) -> anyhow::Result<()> {
|
|
97
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
98
|
+
|
|
99
|
+
// Send command
|
|
100
|
+
e(client
|
|
101
|
+
.send_text(&session_id, &format!("{}\n", args.command))
|
|
102
|
+
.await)?;
|
|
103
|
+
|
|
104
|
+
// Wait for prompt
|
|
105
|
+
let _result = e(client
|
|
106
|
+
.wait_for_text(&session_id, r"[$#>%]\s*$", true, args.timeout * 1000)
|
|
107
|
+
.await)?;
|
|
108
|
+
|
|
109
|
+
// Read screen and extract output
|
|
110
|
+
let text = e(client.get_text(&session_id).await)?;
|
|
111
|
+
let lines: Vec<&str> = text.trim().split('\n').collect();
|
|
112
|
+
|
|
113
|
+
let mut output_lines = Vec::new();
|
|
114
|
+
let mut found_cmd = false;
|
|
115
|
+
for line in &lines {
|
|
116
|
+
if !found_cmd {
|
|
117
|
+
if line.contains(&args.command) {
|
|
118
|
+
found_cmd = true;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if is_prompt_line(line) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
output_lines.push(*line);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if output_lines.is_empty() && !found_cmd {
|
|
129
|
+
println!("{text}");
|
|
130
|
+
} else {
|
|
131
|
+
println!("{}", output_lines.join("\n"));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Ok(())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn is_prompt_line(text: &str) -> bool {
|
|
138
|
+
let t = text.trim_end();
|
|
139
|
+
t.ends_with('$') || t.ends_with('#') || t.ends_with('>') || t.ends_with('%')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[derive(Args)]
|
|
143
|
+
pub struct ReadArgs {
|
|
144
|
+
#[command(flatten)]
|
|
145
|
+
connect: ConnectOpts,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pub async fn read_cmd(args: ReadArgs) -> anyhow::Result<()> {
|
|
149
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
150
|
+
let text = e(client.get_text(&session_id).await)?;
|
|
151
|
+
println!("{text}");
|
|
152
|
+
Ok(())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[derive(Args)]
|
|
156
|
+
pub struct SendTextArgs {
|
|
157
|
+
/// Text to send (use \\n for newline)
|
|
158
|
+
text: String,
|
|
159
|
+
|
|
160
|
+
#[command(flatten)]
|
|
161
|
+
connect: ConnectOpts,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pub async fn send_text_cmd(args: SendTextArgs) -> anyhow::Result<()> {
|
|
165
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
166
|
+
let text = args.text.replace("\\n", "\n");
|
|
167
|
+
e(client.send_text(&session_id, &text).await)?;
|
|
168
|
+
Ok(())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[derive(Args)]
|
|
172
|
+
pub struct SendKeysArgs {
|
|
173
|
+
/// Keys to send (e.g. Ctrl+c Escape Enter)
|
|
174
|
+
keys: Vec<String>,
|
|
175
|
+
|
|
176
|
+
#[command(flatten)]
|
|
177
|
+
connect: ConnectOpts,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pub async fn send_keys_cmd(args: SendKeysArgs) -> anyhow::Result<()> {
|
|
181
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
182
|
+
let keys: Vec<KeyInput> = args
|
|
183
|
+
.keys
|
|
184
|
+
.iter()
|
|
185
|
+
.map(|k| KeyInput::Shorthand(k.clone()))
|
|
186
|
+
.collect();
|
|
187
|
+
e(client.send_keys(&session_id, keys).await)?;
|
|
188
|
+
Ok(())
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#[derive(Args)]
|
|
192
|
+
pub struct ScreenshotArgs {
|
|
193
|
+
/// Format: text, svg, png, json
|
|
194
|
+
#[arg(long, default_value = "text")]
|
|
195
|
+
format: String,
|
|
196
|
+
|
|
197
|
+
/// Output file (default: stdout)
|
|
198
|
+
#[arg(short, long)]
|
|
199
|
+
output: Option<String>,
|
|
200
|
+
|
|
201
|
+
#[command(flatten)]
|
|
202
|
+
connect: ConnectOpts,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
pub async fn screenshot_cmd(args: ScreenshotArgs) -> anyhow::Result<()> {
|
|
206
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
207
|
+
|
|
208
|
+
let format = match args.format.as_str() {
|
|
209
|
+
"text" => ScreenshotFormat::Text,
|
|
210
|
+
"svg" => ScreenshotFormat::Svg,
|
|
211
|
+
"png" => ScreenshotFormat::Png,
|
|
212
|
+
"json" => ScreenshotFormat::Json,
|
|
213
|
+
other => anyhow::bail!("Unknown format: {other}"),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
let result = e(client.screenshot(&session_id, format).await)?;
|
|
217
|
+
|
|
218
|
+
match args.output {
|
|
219
|
+
Some(path) => {
|
|
220
|
+
std::fs::write(&path, &result.data)?;
|
|
221
|
+
println!("Screenshot saved to {path}");
|
|
222
|
+
}
|
|
223
|
+
None => print!("{}", result.data),
|
|
224
|
+
}
|
|
225
|
+
Ok(())
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[derive(Args)]
|
|
229
|
+
pub struct WaitForArgs {
|
|
230
|
+
/// Pattern to wait for
|
|
231
|
+
pattern: String,
|
|
232
|
+
|
|
233
|
+
/// Timeout in seconds
|
|
234
|
+
#[arg(long, default_value_t = 30)]
|
|
235
|
+
timeout: u64,
|
|
236
|
+
|
|
237
|
+
/// Treat pattern as regex
|
|
238
|
+
#[arg(long)]
|
|
239
|
+
regex: bool,
|
|
240
|
+
|
|
241
|
+
#[command(flatten)]
|
|
242
|
+
connect: ConnectOpts,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
pub async fn wait_for_cmd(args: WaitForArgs) -> anyhow::Result<()> {
|
|
246
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
247
|
+
|
|
248
|
+
let result = e(client
|
|
249
|
+
.wait_for_text(
|
|
250
|
+
&session_id,
|
|
251
|
+
&args.pattern,
|
|
252
|
+
args.regex,
|
|
253
|
+
args.timeout * 1000,
|
|
254
|
+
)
|
|
255
|
+
.await)?;
|
|
256
|
+
|
|
257
|
+
if result.found {
|
|
258
|
+
let text = e(client.get_text(&session_id).await)?;
|
|
259
|
+
println!("{text}");
|
|
260
|
+
} else {
|
|
261
|
+
eprintln!(
|
|
262
|
+
"Timeout: '{}' not found within {}s",
|
|
263
|
+
args.pattern, args.timeout
|
|
264
|
+
);
|
|
265
|
+
std::process::exit(1);
|
|
266
|
+
}
|
|
267
|
+
Ok(())
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#[derive(Args)]
|
|
271
|
+
pub struct InfoArgs {
|
|
272
|
+
#[command(flatten)]
|
|
273
|
+
connect: ConnectOpts,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pub async fn info_cmd(args: InfoArgs) -> anyhow::Result<()> {
|
|
277
|
+
let (client, _) = connect(&args.connect).await?;
|
|
278
|
+
let info = e(client.get_info().await)?;
|
|
279
|
+
println!("{}", serde_json::to_string_pretty(&info)?);
|
|
280
|
+
Ok(())
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#[derive(Args)]
|
|
284
|
+
pub struct SizeArgs {
|
|
285
|
+
#[command(flatten)]
|
|
286
|
+
connect: ConnectOpts,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
pub async fn size_cmd(args: SizeArgs) -> anyhow::Result<()> {
|
|
290
|
+
let (client, session_id) = connect(&args.connect).await?;
|
|
291
|
+
let (cols, rows) = e(client.get_size(&session_id).await)?;
|
|
292
|
+
println!("{cols}x{rows}");
|
|
293
|
+
Ok(())
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#[derive(Args)]
|
|
297
|
+
pub struct SessionArgs {
|
|
298
|
+
#[command(subcommand)]
|
|
299
|
+
command: SessionCommand,
|
|
300
|
+
|
|
301
|
+
#[command(flatten)]
|
|
302
|
+
connect: ConnectOpts,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#[derive(Subcommand)]
|
|
306
|
+
pub enum SessionCommand {
|
|
307
|
+
/// List all sessions
|
|
308
|
+
List,
|
|
309
|
+
/// Create a new session (headless mode only)
|
|
310
|
+
Create {
|
|
311
|
+
/// Shell to use
|
|
312
|
+
#[arg(long)]
|
|
313
|
+
shell: Option<String>,
|
|
314
|
+
/// Columns
|
|
315
|
+
#[arg(long, default_value_t = 120)]
|
|
316
|
+
cols: u16,
|
|
317
|
+
/// Rows
|
|
318
|
+
#[arg(long, default_value_t = 40)]
|
|
319
|
+
rows: u16,
|
|
320
|
+
},
|
|
321
|
+
/// Destroy a session
|
|
322
|
+
Destroy {
|
|
323
|
+
/// Session ID to destroy
|
|
324
|
+
id: String,
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
pub async fn session_cmd(args: SessionArgs) -> anyhow::Result<()> {
|
|
329
|
+
let (client, _) = connect(&args.connect).await?;
|
|
330
|
+
|
|
331
|
+
match args.command {
|
|
332
|
+
SessionCommand::List => {
|
|
333
|
+
let sessions = e(client.session_list().await)?;
|
|
334
|
+
if sessions.is_empty() {
|
|
335
|
+
println!("No sessions.");
|
|
336
|
+
} else {
|
|
337
|
+
for s in sessions {
|
|
338
|
+
println!(
|
|
339
|
+
" {} {}x{} {}",
|
|
340
|
+
s.session_id,
|
|
341
|
+
s.cols,
|
|
342
|
+
s.rows,
|
|
343
|
+
s.title
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
SessionCommand::Create { shell, cols, rows } => {
|
|
349
|
+
let params = wrightty_protocol::methods::SessionCreateParams {
|
|
350
|
+
shell,
|
|
351
|
+
args: vec![],
|
|
352
|
+
cols,
|
|
353
|
+
rows,
|
|
354
|
+
env: HashMap::new(),
|
|
355
|
+
cwd: None,
|
|
356
|
+
};
|
|
357
|
+
let id = e(client.session_create(params).await)?;
|
|
358
|
+
println!("{id}");
|
|
359
|
+
}
|
|
360
|
+
SessionCommand::Destroy { id } => {
|
|
361
|
+
e(client.session_destroy(&id).await)?;
|
|
362
|
+
println!("Destroyed session {id}");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
Ok(())
|
|
366
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
use clap::Args;
|
|
2
|
+
|
|
3
|
+
use crate::server::{PORT_RANGE_START, PORT_RANGE_END};
|
|
4
|
+
|
|
5
|
+
#[derive(Args)]
|
|
6
|
+
pub struct DiscoverArgs {
|
|
7
|
+
/// Host to scan
|
|
8
|
+
#[arg(long, default_value = "127.0.0.1")]
|
|
9
|
+
host: String,
|
|
10
|
+
|
|
11
|
+
/// Output as JSON
|
|
12
|
+
#[arg(long)]
|
|
13
|
+
json: bool,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub async fn run(args: DiscoverArgs) -> anyhow::Result<()> {
|
|
17
|
+
let mut found = Vec::new();
|
|
18
|
+
|
|
19
|
+
for port in PORT_RANGE_START..=PORT_RANGE_END {
|
|
20
|
+
let url = format!("ws://{}:{}", args.host, port);
|
|
21
|
+
match try_connect(&url).await {
|
|
22
|
+
Some(info) => found.push(info),
|
|
23
|
+
None => continue,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if args.json {
|
|
28
|
+
println!("{}", serde_json::to_string_pretty(&found)?);
|
|
29
|
+
return Ok(());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if found.is_empty() {
|
|
33
|
+
println!("No wrightty servers found on ports {PORT_RANGE_START}-{PORT_RANGE_END}.");
|
|
34
|
+
println!();
|
|
35
|
+
println!("Start one with:");
|
|
36
|
+
println!(" wrightty term --headless");
|
|
37
|
+
println!(" wrightty term --bridge-tmux");
|
|
38
|
+
println!(" wrightty term --bridge-wezterm");
|
|
39
|
+
return Ok(());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for s in &found {
|
|
43
|
+
println!(
|
|
44
|
+
" {} {} v{}",
|
|
45
|
+
s["url"].as_str().unwrap_or(""),
|
|
46
|
+
s["implementation"].as_str().unwrap_or(""),
|
|
47
|
+
s["version"].as_str().unwrap_or(""),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Ok(())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async fn try_connect(url: &str) -> Option<serde_json::Value> {
|
|
55
|
+
use jsonrpsee::core::client::ClientT;
|
|
56
|
+
use jsonrpsee::core::params::ObjectParams;
|
|
57
|
+
use jsonrpsee::ws_client::WsClientBuilder;
|
|
58
|
+
|
|
59
|
+
let client = WsClientBuilder::default()
|
|
60
|
+
.connection_timeout(std::time::Duration::from_millis(200))
|
|
61
|
+
.build(url)
|
|
62
|
+
.await
|
|
63
|
+
.ok()?;
|
|
64
|
+
|
|
65
|
+
let info: serde_json::Value = client
|
|
66
|
+
.request("Wrightty.getInfo", ObjectParams::new())
|
|
67
|
+
.await
|
|
68
|
+
.ok()?;
|
|
69
|
+
|
|
70
|
+
let mut result = serde_json::json!({ "url": url });
|
|
71
|
+
if let Some(obj) = info.as_object() {
|
|
72
|
+
for (k, v) in obj {
|
|
73
|
+
result[k] = v.clone();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Some(result)
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
use clap::{Parser, Subcommand};
|
|
2
|
+
use tracing_subscriber::EnvFilter;
|
|
3
|
+
|
|
4
|
+
mod discover;
|
|
5
|
+
mod server;
|
|
6
|
+
mod term;
|
|
7
|
+
|
|
8
|
+
#[cfg(feature = "client")]
|
|
9
|
+
mod client_cmds;
|
|
10
|
+
|
|
11
|
+
#[derive(Parser)]
|
|
12
|
+
#[command(
|
|
13
|
+
name = "wrightty",
|
|
14
|
+
about = "Wrightty — Playwright for terminals",
|
|
15
|
+
version,
|
|
16
|
+
after_help = "Examples:\n wrightty term --headless Start headless terminal server\n wrightty term --bridge-tmux Bridge to a running tmux session\n wrightty run \"ls -la\" Run a command and print output\n wrightty discover Find running wrightty servers"
|
|
17
|
+
)]
|
|
18
|
+
struct Cli {
|
|
19
|
+
#[command(subcommand)]
|
|
20
|
+
command: Commands,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#[derive(Subcommand)]
|
|
24
|
+
enum Commands {
|
|
25
|
+
/// Start a wrightty terminal server or bridge
|
|
26
|
+
Term(term::TermArgs),
|
|
27
|
+
|
|
28
|
+
/// Discover running wrightty servers
|
|
29
|
+
Discover(discover::DiscoverArgs),
|
|
30
|
+
|
|
31
|
+
/// Run a command and print its output
|
|
32
|
+
#[cfg(feature = "client")]
|
|
33
|
+
Run(client_cmds::RunArgs),
|
|
34
|
+
|
|
35
|
+
/// Read the current terminal screen
|
|
36
|
+
#[cfg(feature = "client")]
|
|
37
|
+
Read(client_cmds::ReadArgs),
|
|
38
|
+
|
|
39
|
+
/// Send raw text to the terminal
|
|
40
|
+
#[cfg(feature = "client")]
|
|
41
|
+
SendText(client_cmds::SendTextArgs),
|
|
42
|
+
|
|
43
|
+
/// Send keystrokes to the terminal
|
|
44
|
+
#[cfg(feature = "client")]
|
|
45
|
+
SendKeys(client_cmds::SendKeysArgs),
|
|
46
|
+
|
|
47
|
+
/// Take a terminal screenshot
|
|
48
|
+
#[cfg(feature = "client")]
|
|
49
|
+
Screenshot(client_cmds::ScreenshotArgs),
|
|
50
|
+
|
|
51
|
+
/// Wait until text appears on screen
|
|
52
|
+
#[cfg(feature = "client")]
|
|
53
|
+
WaitFor(client_cmds::WaitForArgs),
|
|
54
|
+
|
|
55
|
+
/// Show server info and capabilities
|
|
56
|
+
#[cfg(feature = "client")]
|
|
57
|
+
Info(client_cmds::InfoArgs),
|
|
58
|
+
|
|
59
|
+
/// Get terminal dimensions
|
|
60
|
+
#[cfg(feature = "client")]
|
|
61
|
+
Size(client_cmds::SizeArgs),
|
|
62
|
+
|
|
63
|
+
/// Manage sessions
|
|
64
|
+
#[cfg(feature = "client")]
|
|
65
|
+
Session(client_cmds::SessionArgs),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[tokio::main]
|
|
69
|
+
async fn main() -> anyhow::Result<()> {
|
|
70
|
+
tracing_subscriber::fmt()
|
|
71
|
+
.with_env_filter(
|
|
72
|
+
EnvFilter::from_default_env().add_directive("wrightty=info".parse()?),
|
|
73
|
+
)
|
|
74
|
+
.init();
|
|
75
|
+
|
|
76
|
+
let cli = Cli::parse();
|
|
77
|
+
|
|
78
|
+
match cli.command {
|
|
79
|
+
Commands::Term(args) => term::run(args).await,
|
|
80
|
+
Commands::Discover(args) => discover::run(args).await,
|
|
81
|
+
#[cfg(feature = "client")]
|
|
82
|
+
Commands::Run(args) => client_cmds::run_cmd(args).await,
|
|
83
|
+
#[cfg(feature = "client")]
|
|
84
|
+
Commands::Read(args) => client_cmds::read_cmd(args).await,
|
|
85
|
+
#[cfg(feature = "client")]
|
|
86
|
+
Commands::SendText(args) => client_cmds::send_text_cmd(args).await,
|
|
87
|
+
#[cfg(feature = "client")]
|
|
88
|
+
Commands::SendKeys(args) => client_cmds::send_keys_cmd(args).await,
|
|
89
|
+
#[cfg(feature = "client")]
|
|
90
|
+
Commands::Screenshot(args) => client_cmds::screenshot_cmd(args).await,
|
|
91
|
+
#[cfg(feature = "client")]
|
|
92
|
+
Commands::WaitFor(args) => client_cmds::wait_for_cmd(args).await,
|
|
93
|
+
#[cfg(feature = "client")]
|
|
94
|
+
Commands::Info(args) => client_cmds::info_cmd(args).await,
|
|
95
|
+
#[cfg(feature = "client")]
|
|
96
|
+
Commands::Size(args) => client_cmds::size_cmd(args).await,
|
|
97
|
+
#[cfg(feature = "client")]
|
|
98
|
+
Commands::Session(args) => client_cmds::session_cmd(args).await,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//! Shared server infrastructure for all wrightty server modes.
|
|
2
|
+
|
|
3
|
+
use std::future::Future;
|
|
4
|
+
use std::net::{SocketAddr, TcpListener};
|
|
5
|
+
use std::time::Duration;
|
|
6
|
+
|
|
7
|
+
use jsonrpsee::server::Server;
|
|
8
|
+
use jsonrpsee::RpcModule;
|
|
9
|
+
|
|
10
|
+
/// Port range for wrightty servers (covers all modes).
|
|
11
|
+
pub const PORT_RANGE_START: u16 = 9420;
|
|
12
|
+
pub const PORT_RANGE_END: u16 = 9520;
|
|
13
|
+
|
|
14
|
+
/// Find the next available port in a range.
|
|
15
|
+
pub fn find_available_port(host: &str, start: u16, end: u16) -> Option<u16> {
|
|
16
|
+
for port in start..=end {
|
|
17
|
+
if TcpListener::bind(format!("{host}:{port}")).is_ok() {
|
|
18
|
+
return Some(port);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
None
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Start a JSON-RPC WebSocket server and block until it stops.
|
|
25
|
+
pub async fn start_server<S: Clone + Send + Sync + 'static>(
|
|
26
|
+
host: &str,
|
|
27
|
+
port: u16,
|
|
28
|
+
name: &str,
|
|
29
|
+
module: RpcModule<S>,
|
|
30
|
+
) -> anyhow::Result<()> {
|
|
31
|
+
let addr: SocketAddr = format!("{host}:{port}").parse()?;
|
|
32
|
+
let server = Server::builder().build(addr).await?;
|
|
33
|
+
let handle = server.start(module);
|
|
34
|
+
|
|
35
|
+
tracing::info!("{name} listening on ws://{addr}");
|
|
36
|
+
println!("{name} listening on ws://{addr}");
|
|
37
|
+
|
|
38
|
+
handle.stopped().await;
|
|
39
|
+
Ok(())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Start a server with a watchdog that periodically checks health.
|
|
43
|
+
///
|
|
44
|
+
/// If `health_check` fails 3 consecutive times, the server shuts down.
|
|
45
|
+
pub async fn start_server_with_watchdog<S, F, Fut>(
|
|
46
|
+
host: &str,
|
|
47
|
+
port: u16,
|
|
48
|
+
name: &str,
|
|
49
|
+
module: RpcModule<S>,
|
|
50
|
+
watchdog_interval: u64,
|
|
51
|
+
health_check: F,
|
|
52
|
+
) -> anyhow::Result<()>
|
|
53
|
+
where
|
|
54
|
+
S: Clone + Send + Sync + 'static,
|
|
55
|
+
F: Fn() -> Fut + Send + 'static,
|
|
56
|
+
Fut: Future<Output = Result<(), Box<dyn std::error::Error>>> + Send,
|
|
57
|
+
{
|
|
58
|
+
let addr: SocketAddr = format!("{host}:{port}").parse()?;
|
|
59
|
+
let server = Server::builder().build(addr).await?;
|
|
60
|
+
let handle = server.start(module);
|
|
61
|
+
|
|
62
|
+
tracing::info!("{name} listening on ws://{addr}");
|
|
63
|
+
println!("{name} listening on ws://{addr}");
|
|
64
|
+
|
|
65
|
+
if watchdog_interval > 0 {
|
|
66
|
+
let interval = Duration::from_secs(watchdog_interval);
|
|
67
|
+
let server_handle = handle.clone();
|
|
68
|
+
let bridge_name = name.to_string();
|
|
69
|
+
tokio::spawn(async move {
|
|
70
|
+
let mut consecutive_failures = 0u32;
|
|
71
|
+
loop {
|
|
72
|
+
tokio::time::sleep(interval).await;
|
|
73
|
+
match health_check().await {
|
|
74
|
+
Ok(()) => {
|
|
75
|
+
if consecutive_failures > 0 {
|
|
76
|
+
tracing::info!("{bridge_name} reconnected");
|
|
77
|
+
consecutive_failures = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
Err(e) => {
|
|
81
|
+
consecutive_failures += 1;
|
|
82
|
+
tracing::warn!(
|
|
83
|
+
"{bridge_name} health check failed ({consecutive_failures}): {e}"
|
|
84
|
+
);
|
|
85
|
+
if consecutive_failures >= 3 {
|
|
86
|
+
tracing::error!(
|
|
87
|
+
"{bridge_name} unreachable after {consecutive_failures} checks, shutting down"
|
|
88
|
+
);
|
|
89
|
+
server_handle.stop().unwrap();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
handle.stopped().await;
|
|
99
|
+
Ok(())
|
|
100
|
+
}
|