@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,210 @@
|
|
|
1
|
+
"""Wrightty CLI — control terminals from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from wrightty.terminal import Terminal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.option("--url", default=None, help="Wrightty server URL (default: auto-discover)")
|
|
15
|
+
@click.option("--session", default=None, help="Session ID (default: auto-detect)")
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def main(ctx, url, session):
|
|
18
|
+
"""Wrightty — Playwright for terminals."""
|
|
19
|
+
ctx.ensure_object(dict)
|
|
20
|
+
ctx.obj["url"] = url
|
|
21
|
+
ctx.obj["session"] = session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _connect(ctx) -> Terminal:
|
|
25
|
+
return Terminal.connect(ctx.obj["url"], ctx.obj["session"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@main.command()
|
|
29
|
+
@click.argument("command")
|
|
30
|
+
@click.option("--timeout", default=30, type=float, help="Timeout in seconds")
|
|
31
|
+
@click.pass_context
|
|
32
|
+
def run(ctx, command, timeout):
|
|
33
|
+
"""Run a command and print its output."""
|
|
34
|
+
term = _connect(ctx)
|
|
35
|
+
try:
|
|
36
|
+
output = term.run(command, timeout=timeout)
|
|
37
|
+
click.echo(output)
|
|
38
|
+
finally:
|
|
39
|
+
term.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@main.command("read")
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def read_screen(ctx):
|
|
45
|
+
"""Read the current terminal screen."""
|
|
46
|
+
term = _connect(ctx)
|
|
47
|
+
try:
|
|
48
|
+
click.echo(term.read_screen())
|
|
49
|
+
finally:
|
|
50
|
+
term.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@main.command("send-text")
|
|
54
|
+
@click.argument("text")
|
|
55
|
+
@click.pass_context
|
|
56
|
+
def send_text(ctx, text):
|
|
57
|
+
"""Send raw text to the terminal."""
|
|
58
|
+
term = _connect(ctx)
|
|
59
|
+
try:
|
|
60
|
+
# Interpret \\n as actual newlines.
|
|
61
|
+
text = text.replace("\\n", "\n")
|
|
62
|
+
term.send_text(text)
|
|
63
|
+
finally:
|
|
64
|
+
term.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command("send-keys")
|
|
68
|
+
@click.argument("keys", nargs=-1, required=True)
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def send_keys(ctx, keys):
|
|
71
|
+
"""Send keystrokes to the terminal.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
wrightty send-keys Ctrl+c
|
|
75
|
+
wrightty send-keys Escape : w q Enter
|
|
76
|
+
"""
|
|
77
|
+
term = _connect(ctx)
|
|
78
|
+
try:
|
|
79
|
+
term.send_keys(*keys)
|
|
80
|
+
finally:
|
|
81
|
+
term.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@main.command("wait-for")
|
|
85
|
+
@click.argument("pattern")
|
|
86
|
+
@click.option("--timeout", default=30, type=float, help="Timeout in seconds")
|
|
87
|
+
@click.option("--regex", is_flag=True, help="Treat pattern as regex")
|
|
88
|
+
@click.pass_context
|
|
89
|
+
def wait_for(ctx, pattern, timeout, regex):
|
|
90
|
+
"""Wait until text appears on screen."""
|
|
91
|
+
term = _connect(ctx)
|
|
92
|
+
try:
|
|
93
|
+
screen = term.wait_for(pattern, timeout=timeout, regex=regex)
|
|
94
|
+
click.echo(screen)
|
|
95
|
+
except TimeoutError as e:
|
|
96
|
+
click.echo(str(e), err=True)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
finally:
|
|
99
|
+
term.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@main.command()
|
|
103
|
+
@click.option("--format", "fmt", default="svg", type=click.Choice(["text", "svg", "png"]))
|
|
104
|
+
@click.option("--output", "-o", default=None, help="Output file (default: stdout)")
|
|
105
|
+
@click.pass_context
|
|
106
|
+
def screenshot(ctx, fmt, output):
|
|
107
|
+
"""Take a terminal screenshot."""
|
|
108
|
+
term = _connect(ctx)
|
|
109
|
+
try:
|
|
110
|
+
data = term.screenshot(fmt)
|
|
111
|
+
if output:
|
|
112
|
+
mode = "wb" if fmt == "png" else "w"
|
|
113
|
+
with open(output, mode) as f:
|
|
114
|
+
f.write(data)
|
|
115
|
+
click.echo(f"Screenshot saved to {output}")
|
|
116
|
+
else:
|
|
117
|
+
if fmt == "png":
|
|
118
|
+
sys.stdout.buffer.write(data)
|
|
119
|
+
else:
|
|
120
|
+
click.echo(data)
|
|
121
|
+
finally:
|
|
122
|
+
term.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@main.command()
|
|
126
|
+
@click.pass_context
|
|
127
|
+
def info(ctx):
|
|
128
|
+
"""Show server info and capabilities."""
|
|
129
|
+
term = _connect(ctx)
|
|
130
|
+
try:
|
|
131
|
+
info = term.get_info()
|
|
132
|
+
click.echo(json.dumps(info, indent=2))
|
|
133
|
+
finally:
|
|
134
|
+
term.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@main.command()
|
|
138
|
+
@click.pass_context
|
|
139
|
+
def size(ctx):
|
|
140
|
+
"""Get terminal dimensions."""
|
|
141
|
+
term = _connect(ctx)
|
|
142
|
+
try:
|
|
143
|
+
cols, rows = term.get_size()
|
|
144
|
+
click.echo(f"{cols}x{rows}")
|
|
145
|
+
finally:
|
|
146
|
+
term.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@main.command()
|
|
150
|
+
def discover():
|
|
151
|
+
"""Discover running wrightty servers on ports 9420-9520."""
|
|
152
|
+
servers = Terminal.discover()
|
|
153
|
+
if not servers:
|
|
154
|
+
click.echo("No wrightty servers found.")
|
|
155
|
+
return
|
|
156
|
+
for s in servers:
|
|
157
|
+
click.echo(f" {s['url']} {s['implementation']} v{s['version']}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@main.command()
|
|
161
|
+
@click.option("--output", "-o", default=None, help="Output file (default: recording.cast)")
|
|
162
|
+
@click.option("--include-input", is_flag=True, help="Also record input keystrokes")
|
|
163
|
+
@click.pass_context
|
|
164
|
+
def record(ctx, output, include_input):
|
|
165
|
+
"""Record a terminal session (asciicast format). Press Ctrl+C to stop."""
|
|
166
|
+
output = output or "recording.cast"
|
|
167
|
+
term = _connect(ctx)
|
|
168
|
+
try:
|
|
169
|
+
rec_id = term.start_session_recording(include_input=include_input)
|
|
170
|
+
click.echo(f"Recording... (press Ctrl+C to stop, saving to {output})")
|
|
171
|
+
try:
|
|
172
|
+
import signal
|
|
173
|
+
signal.pause()
|
|
174
|
+
except KeyboardInterrupt:
|
|
175
|
+
pass
|
|
176
|
+
result = term.stop_session_recording(rec_id)
|
|
177
|
+
with open(output, "w") as f:
|
|
178
|
+
f.write(result["data"])
|
|
179
|
+
click.echo(f"Saved {result['events']} events, {result['duration']:.1f}s to {output}")
|
|
180
|
+
finally:
|
|
181
|
+
term.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@main.command("record-actions")
|
|
185
|
+
@click.option("--output", "-o", default=None, help="Output file")
|
|
186
|
+
@click.option("--format", "fmt", default="python", type=click.Choice(["python", "json", "cli"]))
|
|
187
|
+
@click.pass_context
|
|
188
|
+
def record_actions(ctx, output, fmt):
|
|
189
|
+
"""Record wrightty actions as a replayable script. Press Ctrl+C to stop."""
|
|
190
|
+
ext = {"python": ".py", "json": ".json", "cli": ".sh"}[fmt]
|
|
191
|
+
output = output or f"recording{ext}"
|
|
192
|
+
term = _connect(ctx)
|
|
193
|
+
try:
|
|
194
|
+
rec_id = term.start_action_recording(format=fmt)
|
|
195
|
+
click.echo(f"Recording actions... (press Ctrl+C to stop, saving to {output})")
|
|
196
|
+
try:
|
|
197
|
+
import signal
|
|
198
|
+
signal.pause()
|
|
199
|
+
except KeyboardInterrupt:
|
|
200
|
+
pass
|
|
201
|
+
result = term.stop_action_recording(rec_id)
|
|
202
|
+
with open(output, "w") as f:
|
|
203
|
+
f.write(result["data"] if isinstance(result["data"], str) else json.dumps(result["data"], indent=2))
|
|
204
|
+
click.echo(f"Saved {result['actions']} actions, {result['duration']:.1f}s to {output}")
|
|
205
|
+
finally:
|
|
206
|
+
term.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Low-level WebSocket JSON-RPC client for the Wrightty protocol.
|
|
2
|
+
|
|
3
|
+
Uses a raw socket WebSocket implementation to avoid version issues
|
|
4
|
+
with the `websockets` library. Zero external dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WrighttyClient:
|
|
20
|
+
"""Raw JSON-RPC client over WebSocket. No async, no dependencies."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, sock: socket.socket):
|
|
23
|
+
self._sock = sock
|
|
24
|
+
self._next_id = 1
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def connect(cls, url: str = "ws://127.0.0.1:9420") -> WrighttyClient:
|
|
28
|
+
parsed = urlparse(url)
|
|
29
|
+
host = parsed.hostname or "127.0.0.1"
|
|
30
|
+
port = parsed.port or 9420
|
|
31
|
+
|
|
32
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
33
|
+
sock.connect((host, port))
|
|
34
|
+
sock.settimeout(30)
|
|
35
|
+
|
|
36
|
+
# WebSocket handshake.
|
|
37
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
38
|
+
request = (
|
|
39
|
+
f"GET / HTTP/1.1\r\n"
|
|
40
|
+
f"Host: {host}:{port}\r\n"
|
|
41
|
+
f"Connection: Upgrade\r\n"
|
|
42
|
+
f"Upgrade: websocket\r\n"
|
|
43
|
+
f"Sec-WebSocket-Version: 13\r\n"
|
|
44
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
45
|
+
f"\r\n"
|
|
46
|
+
)
|
|
47
|
+
sock.sendall(request.encode())
|
|
48
|
+
|
|
49
|
+
# Read response headers.
|
|
50
|
+
response = b""
|
|
51
|
+
while b"\r\n\r\n" not in response:
|
|
52
|
+
response += sock.recv(4096)
|
|
53
|
+
|
|
54
|
+
if b"101" not in response:
|
|
55
|
+
raise ConnectionError(f"WebSocket handshake failed: {response.decode()}")
|
|
56
|
+
|
|
57
|
+
return cls(sock)
|
|
58
|
+
|
|
59
|
+
def close(self):
|
|
60
|
+
try:
|
|
61
|
+
self._sock.close()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
66
|
+
req_id = self._next_id
|
|
67
|
+
self._next_id += 1
|
|
68
|
+
|
|
69
|
+
msg = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params or {}}
|
|
70
|
+
self._send_frame(json.dumps(msg))
|
|
71
|
+
raw = self._recv_frame()
|
|
72
|
+
resp = json.loads(raw)
|
|
73
|
+
|
|
74
|
+
if "error" in resp:
|
|
75
|
+
err = resp["error"]
|
|
76
|
+
raise WrighttyError(err.get("code", -1), err.get("message", "Unknown error"))
|
|
77
|
+
|
|
78
|
+
return resp.get("result")
|
|
79
|
+
|
|
80
|
+
def _send_frame(self, msg: str):
|
|
81
|
+
"""Send a masked WebSocket text frame."""
|
|
82
|
+
payload = msg.encode()
|
|
83
|
+
mask = os.urandom(4)
|
|
84
|
+
frame = bytearray([0x81]) # FIN + text opcode
|
|
85
|
+
|
|
86
|
+
length = len(payload)
|
|
87
|
+
if length < 126:
|
|
88
|
+
frame.append(0x80 | length)
|
|
89
|
+
elif length < 65536:
|
|
90
|
+
frame.append(0x80 | 126)
|
|
91
|
+
frame.extend(struct.pack(">H", length))
|
|
92
|
+
else:
|
|
93
|
+
frame.append(0x80 | 127)
|
|
94
|
+
frame.extend(struct.pack(">Q", length))
|
|
95
|
+
|
|
96
|
+
frame.extend(mask)
|
|
97
|
+
for i, b in enumerate(payload):
|
|
98
|
+
frame.append(b ^ mask[i % 4])
|
|
99
|
+
self._sock.sendall(bytes(frame))
|
|
100
|
+
|
|
101
|
+
def _recv_frame(self) -> str:
|
|
102
|
+
"""Receive a WebSocket text frame."""
|
|
103
|
+
header = self._recv_exact(2)
|
|
104
|
+
length = header[1] & 0x7F
|
|
105
|
+
|
|
106
|
+
if length == 126:
|
|
107
|
+
length = struct.unpack(">H", self._recv_exact(2))[0]
|
|
108
|
+
elif length == 127:
|
|
109
|
+
length = struct.unpack(">Q", self._recv_exact(8))[0]
|
|
110
|
+
|
|
111
|
+
# Server frames are not masked.
|
|
112
|
+
payload = self._recv_exact(length)
|
|
113
|
+
return payload.decode()
|
|
114
|
+
|
|
115
|
+
def _recv_exact(self, n: int) -> bytes:
|
|
116
|
+
"""Read exactly n bytes."""
|
|
117
|
+
data = b""
|
|
118
|
+
while len(data) < n:
|
|
119
|
+
chunk = self._sock.recv(n - len(data))
|
|
120
|
+
if not chunk:
|
|
121
|
+
raise ConnectionError("Connection closed")
|
|
122
|
+
data += chunk
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
def __enter__(self):
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def __exit__(self, *args):
|
|
129
|
+
self.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class WrighttyError(Exception):
|
|
133
|
+
def __init__(self, code: int, message: str):
|
|
134
|
+
self.code = code
|
|
135
|
+
self.message = message
|
|
136
|
+
super().__init__(f"[{code}] {message}")
|