@moejay/wrightty 0.0.0 → 0.1.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/dist/client.d.ts +14 -0
- package/dist/client.js +83 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/terminal.d.ts +48 -0
- package/dist/terminal.js +210 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +3 -0
- package/package.json +35 -15
- package/.github/workflows/ci.yml +0 -90
- package/.github/workflows/release.yml +0 -177
- package/Cargo.lock +0 -2662
- package/Cargo.toml +0 -38
- package/PROTOCOL.md +0 -1351
- package/README.md +0 -386
- package/agents/ceo/AGENTS.md +0 -24
- package/agents/ceo/HEARTBEAT.md +0 -72
- package/agents/ceo/SOUL.md +0 -33
- package/agents/ceo/TOOLS.md +0 -3
- package/agents/founding-engineer/AGENTS.md +0 -44
- package/crates/wrightty/Cargo.toml +0 -43
- package/crates/wrightty/src/client_cmds.rs +0 -366
- package/crates/wrightty/src/discover.rs +0 -78
- package/crates/wrightty/src/main.rs +0 -100
- package/crates/wrightty/src/server.rs +0 -100
- package/crates/wrightty/src/term.rs +0 -338
- package/crates/wrightty-bridge-ghostty/Cargo.toml +0 -27
- package/crates/wrightty-bridge-ghostty/src/ghostty.rs +0 -422
- package/crates/wrightty-bridge-ghostty/src/lib.rs +0 -2
- package/crates/wrightty-bridge-ghostty/src/main.rs +0 -146
- package/crates/wrightty-bridge-ghostty/src/rpc.rs +0 -307
- package/crates/wrightty-bridge-kitty/Cargo.toml +0 -26
- package/crates/wrightty-bridge-kitty/src/kitty.rs +0 -269
- package/crates/wrightty-bridge-kitty/src/lib.rs +0 -2
- package/crates/wrightty-bridge-kitty/src/main.rs +0 -124
- package/crates/wrightty-bridge-kitty/src/rpc.rs +0 -304
- package/crates/wrightty-bridge-tmux/Cargo.toml +0 -26
- package/crates/wrightty-bridge-tmux/src/lib.rs +0 -2
- package/crates/wrightty-bridge-tmux/src/main.rs +0 -119
- package/crates/wrightty-bridge-tmux/src/rpc.rs +0 -291
- package/crates/wrightty-bridge-tmux/src/tmux.rs +0 -215
- package/crates/wrightty-bridge-wezterm/Cargo.toml +0 -26
- package/crates/wrightty-bridge-wezterm/src/lib.rs +0 -2
- package/crates/wrightty-bridge-wezterm/src/main.rs +0 -119
- package/crates/wrightty-bridge-wezterm/src/rpc.rs +0 -339
- package/crates/wrightty-bridge-wezterm/src/wezterm.rs +0 -190
- package/crates/wrightty-bridge-zellij/Cargo.toml +0 -27
- package/crates/wrightty-bridge-zellij/src/lib.rs +0 -2
- package/crates/wrightty-bridge-zellij/src/main.rs +0 -125
- package/crates/wrightty-bridge-zellij/src/rpc.rs +0 -328
- package/crates/wrightty-bridge-zellij/src/zellij.rs +0 -199
- package/crates/wrightty-client/Cargo.toml +0 -16
- package/crates/wrightty-client/src/client.rs +0 -254
- package/crates/wrightty-client/src/lib.rs +0 -2
- package/crates/wrightty-core/Cargo.toml +0 -21
- package/crates/wrightty-core/src/input.rs +0 -212
- package/crates/wrightty-core/src/lib.rs +0 -4
- package/crates/wrightty-core/src/screen.rs +0 -325
- package/crates/wrightty-core/src/session.rs +0 -249
- package/crates/wrightty-core/src/session_manager.rs +0 -77
- package/crates/wrightty-protocol/Cargo.toml +0 -13
- package/crates/wrightty-protocol/src/error.rs +0 -8
- package/crates/wrightty-protocol/src/events.rs +0 -138
- package/crates/wrightty-protocol/src/lib.rs +0 -4
- package/crates/wrightty-protocol/src/methods.rs +0 -321
- package/crates/wrightty-protocol/src/types.rs +0 -201
- package/crates/wrightty-server/Cargo.toml +0 -23
- package/crates/wrightty-server/src/lib.rs +0 -2
- package/crates/wrightty-server/src/main.rs +0 -65
- package/crates/wrightty-server/src/rpc.rs +0 -455
- package/crates/wrightty-server/src/state.rs +0 -39
- package/examples/basic_command.py +0 -53
- package/examples/interactive_tui.py +0 -86
- package/examples/record_session.py +0 -96
- package/install.sh +0 -81
- package/sdks/node/package-lock.json +0 -85
- package/sdks/node/package.json +0 -44
- package/sdks/node/src/client.ts +0 -94
- package/sdks/node/src/index.ts +0 -19
- package/sdks/node/src/terminal.ts +0 -258
- package/sdks/node/src/types.ts +0 -105
- package/sdks/node/tsconfig.json +0 -17
- package/sdks/python/README.md +0 -96
- package/sdks/python/pyproject.toml +0 -42
- package/sdks/python/wrightty/__init__.py +0 -6
- package/sdks/python/wrightty/cli.py +0 -210
- package/sdks/python/wrightty/client.py +0 -136
- package/sdks/python/wrightty/mcp_server.py +0 -434
- package/sdks/python/wrightty/terminal.py +0 -333
- package/skills/wrightty/SKILL.md +0 -261
- package/src/lib.rs +0 -1
- package/tests/integration_test.rs +0 -618
package/sdks/node/src/types.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/** Wrightty protocol types — mirrors wrightty-protocol Rust crate. */
|
|
2
|
-
|
|
3
|
-
export interface ServerInfo {
|
|
4
|
-
version: string;
|
|
5
|
-
implementation: string;
|
|
6
|
-
capabilities: Capabilities;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface Capabilities {
|
|
10
|
-
screenshot: ScreenshotFormat[];
|
|
11
|
-
maxSessions: number;
|
|
12
|
-
supportsResize: boolean;
|
|
13
|
-
supportsScrollback: boolean;
|
|
14
|
-
supportsMouse: boolean;
|
|
15
|
-
supportsSessionCreate: boolean;
|
|
16
|
-
supportsColorPalette: boolean;
|
|
17
|
-
supportsRawOutput: boolean;
|
|
18
|
-
supportsShellIntegration: boolean;
|
|
19
|
-
events: string[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type ScreenshotFormat = "text" | "svg" | "png" | "json";
|
|
23
|
-
|
|
24
|
-
export interface SessionInfo {
|
|
25
|
-
sessionId: string;
|
|
26
|
-
title: string;
|
|
27
|
-
cwd?: string;
|
|
28
|
-
cols: number;
|
|
29
|
-
rows: number;
|
|
30
|
-
pid?: number;
|
|
31
|
-
running: boolean;
|
|
32
|
-
alternateScreen: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type KeyInput = string | KeyEvent;
|
|
36
|
-
|
|
37
|
-
export interface KeyEvent {
|
|
38
|
-
key: string;
|
|
39
|
-
char?: string;
|
|
40
|
-
n?: number;
|
|
41
|
-
modifiers: string[];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface TextMatch {
|
|
45
|
-
text: string;
|
|
46
|
-
row: number;
|
|
47
|
-
col: number;
|
|
48
|
-
length: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface WaitForTextResult {
|
|
52
|
-
found: boolean;
|
|
53
|
-
matches: TextMatch[];
|
|
54
|
-
elapsed: number;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface ScreenshotResult {
|
|
58
|
-
format: ScreenshotFormat;
|
|
59
|
-
data: string;
|
|
60
|
-
width?: number;
|
|
61
|
-
height?: number;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface RecordingResult {
|
|
65
|
-
recordingId: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface SessionRecordingData {
|
|
69
|
-
format: string;
|
|
70
|
-
data: string;
|
|
71
|
-
duration: number;
|
|
72
|
-
events: number;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface ActionRecordingData {
|
|
76
|
-
format: string;
|
|
77
|
-
data: string;
|
|
78
|
-
actions: number;
|
|
79
|
-
duration: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface DiscoveredServer {
|
|
83
|
-
url: string;
|
|
84
|
-
port: number;
|
|
85
|
-
version: string;
|
|
86
|
-
implementation: string;
|
|
87
|
-
capabilities: Capabilities;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface ConnectOptions {
|
|
91
|
-
/** Server URL (default: auto-discover) */
|
|
92
|
-
url?: string;
|
|
93
|
-
/** Session ID (default: first available) */
|
|
94
|
-
sessionId?: string;
|
|
95
|
-
/** Connection timeout in ms (default: 5000) */
|
|
96
|
-
timeout?: number;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface SpawnOptions {
|
|
100
|
-
shell?: string;
|
|
101
|
-
cols?: number;
|
|
102
|
-
rows?: number;
|
|
103
|
-
cwd?: string;
|
|
104
|
-
serverUrl?: string;
|
|
105
|
-
}
|
package/sdks/node/tsconfig.json
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "commonjs",
|
|
5
|
-
"lib": ["ES2022"],
|
|
6
|
-
"declaration": true,
|
|
7
|
-
"strict": true,
|
|
8
|
-
"outDir": "dist",
|
|
9
|
-
"rootDir": "src",
|
|
10
|
-
"esModuleInterop": true,
|
|
11
|
-
"skipLibCheck": true,
|
|
12
|
-
"forceConsistentCasingInFileNames": true,
|
|
13
|
-
"resolveJsonModule": true
|
|
14
|
-
},
|
|
15
|
-
"include": ["src"],
|
|
16
|
-
"exclude": ["node_modules", "dist"]
|
|
17
|
-
}
|
package/sdks/python/README.md
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
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
|
|
@@ -1,42 +0,0 @@
|
|
|
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"
|
|
@@ -1,210 +0,0 @@
|
|
|
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()
|
|
@@ -1,136 +0,0 @@
|
|
|
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}")
|