wrightty 0.1.0__tar.gz

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.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: wrightty
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Wrightty terminal automation protocol
5
+ Author: Moe Jay
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/moejay/wrightty
8
+ Project-URL: Repository, https://github.com/moejay/wrightty
9
+ Project-URL: Issues, https://github.com/moejay/wrightty/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Terminals
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Classifier: Intended Audience :: Developers
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: websockets>=12.0
23
+ Provides-Extra: cli
24
+ Requires-Dist: click>=8.0; extra == "cli"
25
+ Provides-Extra: mcp
26
+ Requires-Dist: mcp>=1.0; extra == "mcp"
27
+
28
+ # wrightty (Python SDK)
29
+
30
+ Python SDK for the [Wrightty](https://github.com/moejay/wrightty) terminal automation protocol.
31
+
32
+ Control any terminal emulator programmatically — send keystrokes, read the screen, take screenshots, and run commands over WebSocket JSON-RPC.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install wrightty
38
+ # with MCP server:
39
+ pip install wrightty[mcp]
40
+ ```
41
+
42
+ You also need a wrightty server running. Install it via:
43
+
44
+ ```bash
45
+ curl -fsSL https://raw.githubusercontent.com/moejay/wrightty/main/install.sh | sh
46
+ # or
47
+ cargo install wrightty
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ from wrightty import Terminal
54
+
55
+ # Connect to a running wrightty server (auto-discovers)
56
+ term = Terminal.connect()
57
+
58
+ # Run a command and get its output
59
+ output = term.run("cargo test")
60
+ print(output)
61
+
62
+ # Read the screen
63
+ screen = term.read_screen()
64
+
65
+ # Send keystrokes
66
+ term.send_keys("Ctrl+c")
67
+ term.send_keys("Escape", ":", "w", "q", "Enter")
68
+
69
+ # Wait for text to appear
70
+ term.wait_for("BUILD SUCCESS", timeout=60)
71
+
72
+ # Screenshots
73
+ svg = term.screenshot("svg")
74
+
75
+ # Recording
76
+ rec_id = term.start_session_recording()
77
+ term.run("make build")
78
+ result = term.stop_session_recording(rec_id)
79
+ open("build.cast", "w").write(result["data"]) # asciinema play
80
+
81
+ term.close()
82
+ ```
83
+
84
+ ## Starting a server
85
+
86
+ First start a wrightty terminal server:
87
+
88
+ ```bash
89
+ wrightty term --headless # virtual PTY, no GUI
90
+ wrightty term --bridge-tmux # attach to tmux
91
+ wrightty term --bridge-wezterm # attach to WezTerm
92
+ wrightty term --bridge-kitty # attach to Kitty
93
+ wrightty term --bridge-zellij # attach to Zellij
94
+ wrightty term --bridge-ghostty # attach to Ghostty
95
+ ```
96
+
97
+ Then connect from Python as shown above.
98
+
99
+ ## MCP server (for Claude, Cursor, etc.)
100
+
101
+ ```bash
102
+ pip install wrightty[mcp]
103
+ ```
104
+
105
+ Add to your MCP config:
106
+
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "wrightty": {
111
+ "command": "python3",
112
+ "args": ["-m", "wrightty.mcp_server"],
113
+ "env": { "WRIGHTTY_SOCKET": "ws://127.0.0.1:9420" }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ See the [main repository](https://github.com/moejay/wrightty) for full docs.
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,96 @@
1
+ # wrightty (Python SDK)
2
+
3
+ Python SDK for the [Wrightty](https://github.com/moejay/wrightty) terminal automation protocol.
4
+
5
+ Control any terminal emulator programmatically — send keystrokes, read the screen, take screenshots, and run commands over WebSocket JSON-RPC.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install wrightty
11
+ # with MCP server:
12
+ pip install wrightty[mcp]
13
+ ```
14
+
15
+ You also need a wrightty server running. Install it via:
16
+
17
+ ```bash
18
+ curl -fsSL https://raw.githubusercontent.com/moejay/wrightty/main/install.sh | sh
19
+ # or
20
+ cargo install wrightty
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ from wrightty import Terminal
27
+
28
+ # Connect to a running wrightty server (auto-discovers)
29
+ term = Terminal.connect()
30
+
31
+ # Run a command and get its output
32
+ output = term.run("cargo test")
33
+ print(output)
34
+
35
+ # Read the screen
36
+ screen = term.read_screen()
37
+
38
+ # Send keystrokes
39
+ term.send_keys("Ctrl+c")
40
+ term.send_keys("Escape", ":", "w", "q", "Enter")
41
+
42
+ # Wait for text to appear
43
+ term.wait_for("BUILD SUCCESS", timeout=60)
44
+
45
+ # Screenshots
46
+ svg = term.screenshot("svg")
47
+
48
+ # Recording
49
+ rec_id = term.start_session_recording()
50
+ term.run("make build")
51
+ result = term.stop_session_recording(rec_id)
52
+ open("build.cast", "w").write(result["data"]) # asciinema play
53
+
54
+ term.close()
55
+ ```
56
+
57
+ ## Starting a server
58
+
59
+ First start a wrightty terminal server:
60
+
61
+ ```bash
62
+ wrightty term --headless # virtual PTY, no GUI
63
+ wrightty term --bridge-tmux # attach to tmux
64
+ wrightty term --bridge-wezterm # attach to WezTerm
65
+ wrightty term --bridge-kitty # attach to Kitty
66
+ wrightty term --bridge-zellij # attach to Zellij
67
+ wrightty term --bridge-ghostty # attach to Ghostty
68
+ ```
69
+
70
+ Then connect from Python as shown above.
71
+
72
+ ## MCP server (for Claude, Cursor, etc.)
73
+
74
+ ```bash
75
+ pip install wrightty[mcp]
76
+ ```
77
+
78
+ Add to your MCP config:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "wrightty": {
84
+ "command": "python3",
85
+ "args": ["-m", "wrightty.mcp_server"],
86
+ "env": { "WRIGHTTY_SOCKET": "ws://127.0.0.1:9420" }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ See the [main repository](https://github.com/moejay/wrightty) for full docs.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wrightty"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the Wrightty terminal automation protocol"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Moe Jay"}]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Terminals",
22
+ "Topic :: Software Development :: Testing",
23
+ "Intended Audience :: Developers",
24
+ ]
25
+ dependencies = [
26
+ "websockets>=12.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ cli = ["click>=8.0"]
31
+ mcp = ["mcp>=1.0"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/moejay/wrightty"
35
+ Repository = "https://github.com/moejay/wrightty"
36
+ Issues = "https://github.com/moejay/wrightty/issues"
37
+
38
+ [project.scripts]
39
+ wrightty-py = "wrightty.cli:main"
40
+
41
+ [project.entry-points."mcp.servers"]
42
+ wrightty = "wrightty.mcp_server:serve"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ from wrightty.terminal import Terminal
2
+
3
+ discover = Terminal.discover
4
+
5
+ __all__ = ["Terminal", "discover"]
6
+ __version__ = "0.1.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}")