@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.
Files changed (84) hide show
  1. package/.github/workflows/ci.yml +90 -0
  2. package/.github/workflows/release.yml +177 -0
  3. package/Cargo.lock +2662 -0
  4. package/Cargo.toml +38 -0
  5. package/PROTOCOL.md +1351 -0
  6. package/README.md +386 -0
  7. package/agents/ceo/AGENTS.md +24 -0
  8. package/agents/ceo/HEARTBEAT.md +72 -0
  9. package/agents/ceo/SOUL.md +33 -0
  10. package/agents/ceo/TOOLS.md +3 -0
  11. package/agents/founding-engineer/AGENTS.md +44 -0
  12. package/crates/wrightty/Cargo.toml +43 -0
  13. package/crates/wrightty/src/client_cmds.rs +366 -0
  14. package/crates/wrightty/src/discover.rs +78 -0
  15. package/crates/wrightty/src/main.rs +100 -0
  16. package/crates/wrightty/src/server.rs +100 -0
  17. package/crates/wrightty/src/term.rs +338 -0
  18. package/crates/wrightty-bridge-ghostty/Cargo.toml +27 -0
  19. package/crates/wrightty-bridge-ghostty/src/ghostty.rs +422 -0
  20. package/crates/wrightty-bridge-ghostty/src/lib.rs +2 -0
  21. package/crates/wrightty-bridge-ghostty/src/main.rs +146 -0
  22. package/crates/wrightty-bridge-ghostty/src/rpc.rs +307 -0
  23. package/crates/wrightty-bridge-kitty/Cargo.toml +26 -0
  24. package/crates/wrightty-bridge-kitty/src/kitty.rs +269 -0
  25. package/crates/wrightty-bridge-kitty/src/lib.rs +2 -0
  26. package/crates/wrightty-bridge-kitty/src/main.rs +124 -0
  27. package/crates/wrightty-bridge-kitty/src/rpc.rs +304 -0
  28. package/crates/wrightty-bridge-tmux/Cargo.toml +26 -0
  29. package/crates/wrightty-bridge-tmux/src/lib.rs +2 -0
  30. package/crates/wrightty-bridge-tmux/src/main.rs +119 -0
  31. package/crates/wrightty-bridge-tmux/src/rpc.rs +291 -0
  32. package/crates/wrightty-bridge-tmux/src/tmux.rs +215 -0
  33. package/crates/wrightty-bridge-wezterm/Cargo.toml +26 -0
  34. package/crates/wrightty-bridge-wezterm/src/lib.rs +2 -0
  35. package/crates/wrightty-bridge-wezterm/src/main.rs +119 -0
  36. package/crates/wrightty-bridge-wezterm/src/rpc.rs +339 -0
  37. package/crates/wrightty-bridge-wezterm/src/wezterm.rs +190 -0
  38. package/crates/wrightty-bridge-zellij/Cargo.toml +27 -0
  39. package/crates/wrightty-bridge-zellij/src/lib.rs +2 -0
  40. package/crates/wrightty-bridge-zellij/src/main.rs +125 -0
  41. package/crates/wrightty-bridge-zellij/src/rpc.rs +328 -0
  42. package/crates/wrightty-bridge-zellij/src/zellij.rs +199 -0
  43. package/crates/wrightty-client/Cargo.toml +16 -0
  44. package/crates/wrightty-client/src/client.rs +254 -0
  45. package/crates/wrightty-client/src/lib.rs +2 -0
  46. package/crates/wrightty-core/Cargo.toml +21 -0
  47. package/crates/wrightty-core/src/input.rs +212 -0
  48. package/crates/wrightty-core/src/lib.rs +4 -0
  49. package/crates/wrightty-core/src/screen.rs +325 -0
  50. package/crates/wrightty-core/src/session.rs +249 -0
  51. package/crates/wrightty-core/src/session_manager.rs +77 -0
  52. package/crates/wrightty-protocol/Cargo.toml +13 -0
  53. package/crates/wrightty-protocol/src/error.rs +8 -0
  54. package/crates/wrightty-protocol/src/events.rs +138 -0
  55. package/crates/wrightty-protocol/src/lib.rs +4 -0
  56. package/crates/wrightty-protocol/src/methods.rs +321 -0
  57. package/crates/wrightty-protocol/src/types.rs +201 -0
  58. package/crates/wrightty-server/Cargo.toml +23 -0
  59. package/crates/wrightty-server/src/lib.rs +2 -0
  60. package/crates/wrightty-server/src/main.rs +65 -0
  61. package/crates/wrightty-server/src/rpc.rs +455 -0
  62. package/crates/wrightty-server/src/state.rs +39 -0
  63. package/examples/basic_command.py +53 -0
  64. package/examples/interactive_tui.py +86 -0
  65. package/examples/record_session.py +96 -0
  66. package/install.sh +81 -0
  67. package/package.json +24 -0
  68. package/sdks/node/package-lock.json +85 -0
  69. package/sdks/node/package.json +44 -0
  70. package/sdks/node/src/client.ts +94 -0
  71. package/sdks/node/src/index.ts +19 -0
  72. package/sdks/node/src/terminal.ts +258 -0
  73. package/sdks/node/src/types.ts +105 -0
  74. package/sdks/node/tsconfig.json +17 -0
  75. package/sdks/python/README.md +96 -0
  76. package/sdks/python/pyproject.toml +42 -0
  77. package/sdks/python/wrightty/__init__.py +6 -0
  78. package/sdks/python/wrightty/cli.py +210 -0
  79. package/sdks/python/wrightty/client.py +136 -0
  80. package/sdks/python/wrightty/mcp_server.py +434 -0
  81. package/sdks/python/wrightty/terminal.py +333 -0
  82. package/skills/wrightty/SKILL.md +261 -0
  83. package/src/lib.rs +1 -0
  84. 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}")