reterm 0.1.0__py3-none-any.whl
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.
- reterm/__init__.py +3 -0
- reterm/__main__.py +6 -0
- reterm/cli.py +338 -0
- reterm/core/__init__.py +1 -0
- reterm/core/engine.py +876 -0
- reterm/core/events.py +172 -0
- reterm/core/pty_manager.py +216 -0
- reterm/core/terminal.py +333 -0
- reterm/mcp/__init__.py +1 -0
- reterm/mcp/server.py +459 -0
- reterm/output/__init__.py +1 -0
- reterm/output/models.py +220 -0
- reterm/play.py +184 -0
- reterm/redact.py +114 -0
- reterm/render/__init__.py +1 -0
- reterm/render/frame.py +232 -0
- reterm/render/from_log.py +83 -0
- reterm/render/gif.py +166 -0
- reterm/render/svg.py +322 -0
- reterm/render/themes.py +276 -0
- reterm/script/__init__.py +1 -0
- reterm/script/parser.py +180 -0
- reterm-0.1.0.dist-info/METADATA +416 -0
- reterm-0.1.0.dist-info/RECORD +27 -0
- reterm-0.1.0.dist-info/WHEEL +4 -0
- reterm-0.1.0.dist-info/entry_points.txt +2 -0
- reterm-0.1.0.dist-info/licenses/LICENSE +21 -0
reterm/__init__.py
ADDED
reterm/__main__.py
ADDED
reterm/cli.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""CLI interface for reterm."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from reterm import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(version=__version__)
|
|
10
|
+
def cli() -> None:
|
|
11
|
+
"""reterm - AI-native terminal recording tool."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cli.command()
|
|
16
|
+
@click.argument("script_file", type=click.Path(exists=True))
|
|
17
|
+
@click.option("--output", "-o", type=click.Path(), help="Visual output path (.gif or .svg)")
|
|
18
|
+
@click.option("--log", "-l", type=click.Path(), help="JSON log output path")
|
|
19
|
+
@click.option("--log-only", is_flag=True, help="Skip visual output, write the log only")
|
|
20
|
+
@click.option("--theme", "-t", default="dracula", help="Terminal theme")
|
|
21
|
+
@click.option("--shell", default=None, help="Shell to use (default: $SHELL)")
|
|
22
|
+
@click.option(
|
|
23
|
+
"--idle-limit",
|
|
24
|
+
"-i",
|
|
25
|
+
default=2.0,
|
|
26
|
+
type=float,
|
|
27
|
+
help="Cap any static frame at N seconds in the GIF/SVG so long waits don't drag (0 = uncapped)",
|
|
28
|
+
)
|
|
29
|
+
def run(
|
|
30
|
+
script_file: str,
|
|
31
|
+
output: str | None,
|
|
32
|
+
log: str | None,
|
|
33
|
+
log_only: bool,
|
|
34
|
+
theme: str,
|
|
35
|
+
shell: str | None,
|
|
36
|
+
idle_limit: float,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Execute a .reterm script and generate outputs.
|
|
39
|
+
|
|
40
|
+
The visual format is chosen from the -o extension: .gif (default) or .svg
|
|
41
|
+
(an animated SVG you can embed inline in a GitHub README).
|
|
42
|
+
"""
|
|
43
|
+
from reterm.core.engine import Engine
|
|
44
|
+
from reterm.script.parser import parse_script
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
script_path = Path(script_file)
|
|
48
|
+
script = parse_script(script_path)
|
|
49
|
+
|
|
50
|
+
engine = Engine(
|
|
51
|
+
shell=shell,
|
|
52
|
+
theme=theme,
|
|
53
|
+
generate_gif=not log_only,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
result = engine.run(script)
|
|
57
|
+
|
|
58
|
+
# Determine output paths
|
|
59
|
+
out_path = Path(output) if output else script_path.with_suffix(".gif")
|
|
60
|
+
log_path = Path(log) if log else script_path.with_suffix(".json")
|
|
61
|
+
|
|
62
|
+
# Write outputs (visual format from the -o extension)
|
|
63
|
+
if not log_only:
|
|
64
|
+
suffix = out_path.suffix.lower()
|
|
65
|
+
if suffix == ".svg":
|
|
66
|
+
result.save_svg(out_path, idle_limit=idle_limit)
|
|
67
|
+
elif suffix == ".gif":
|
|
68
|
+
result.save_gif(out_path, idle_limit=idle_limit)
|
|
69
|
+
else:
|
|
70
|
+
raise click.ClickException(
|
|
71
|
+
f"Unsupported output format '{suffix}'. Use .gif or .svg."
|
|
72
|
+
)
|
|
73
|
+
click.echo(f"Saved {suffix.lstrip('.').upper()} to: {out_path}")
|
|
74
|
+
|
|
75
|
+
result.save_log(log_path)
|
|
76
|
+
click.echo(f"Log saved to: {log_path}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cli.command()
|
|
80
|
+
@click.argument("script_file", type=click.Path(exists=True))
|
|
81
|
+
def validate(script_file: str) -> None:
|
|
82
|
+
"""Validate a .reterm script without executing."""
|
|
83
|
+
from reterm.script.parser import parse_script, ScriptError
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
script = parse_script(Path(script_file))
|
|
88
|
+
click.echo(f"Valid script: {len(script.steps)} steps")
|
|
89
|
+
except ScriptError as e:
|
|
90
|
+
click.echo(f"Invalid script: {e}", err=True)
|
|
91
|
+
raise SystemExit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@cli.command()
|
|
95
|
+
@click.argument("script_file", type=click.Path())
|
|
96
|
+
def new(script_file: str) -> None:
|
|
97
|
+
"""Create a new .reterm script from template."""
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
|
|
100
|
+
template = '''\
|
|
101
|
+
meta:
|
|
102
|
+
name: "My Recording"
|
|
103
|
+
description: "Description of what this recording demonstrates"
|
|
104
|
+
|
|
105
|
+
config:
|
|
106
|
+
shell: /bin/zsh
|
|
107
|
+
theme: dracula
|
|
108
|
+
size: [80, 24]
|
|
109
|
+
typing_speed: 50ms
|
|
110
|
+
|
|
111
|
+
output:
|
|
112
|
+
gif: output.gif
|
|
113
|
+
log: output.json
|
|
114
|
+
|
|
115
|
+
steps:
|
|
116
|
+
- run: echo "Hello, World!"
|
|
117
|
+
- sleep: 1s
|
|
118
|
+
'''
|
|
119
|
+
|
|
120
|
+
path = Path(script_file)
|
|
121
|
+
if path.exists():
|
|
122
|
+
click.echo(f"File already exists: {script_file}", err=True)
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
|
|
125
|
+
path.write_text(template)
|
|
126
|
+
click.echo(f"Created: {script_file}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.command()
|
|
130
|
+
@click.option("--transport", type=click.Choice(["stdio", "sse"]), default="stdio")
|
|
131
|
+
@click.option("--port", default=8080, help="Port for SSE transport")
|
|
132
|
+
def serve(transport: str, port: int) -> None:
|
|
133
|
+
"""Start MCP server for AI tool integration."""
|
|
134
|
+
from reterm.mcp.server import run_server
|
|
135
|
+
|
|
136
|
+
run_server(transport=transport, port=port)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@cli.command()
|
|
140
|
+
def themes() -> None:
|
|
141
|
+
"""List available terminal themes."""
|
|
142
|
+
from reterm.render.themes import list_themes
|
|
143
|
+
|
|
144
|
+
click.echo("Available themes:")
|
|
145
|
+
for theme_name in list_themes():
|
|
146
|
+
click.echo(f" - {theme_name}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@cli.command()
|
|
150
|
+
def schema() -> None:
|
|
151
|
+
"""Print the JSON schema for recording logs."""
|
|
152
|
+
from reterm.output.models import RecordingLog
|
|
153
|
+
|
|
154
|
+
click.echo(RecordingLog.model_json_schema())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@cli.command()
|
|
158
|
+
@click.argument("log_file", type=click.Path(exists=True))
|
|
159
|
+
@click.option("--pattern", "-p", multiple=True, help="Pattern to redact")
|
|
160
|
+
@click.option("--replace", "-r", multiple=True, help="Replacement text")
|
|
161
|
+
@click.option("--regex", is_flag=True, help="Treat patterns as regex")
|
|
162
|
+
@click.option("--seamless", is_flag=True, help="No visible redaction indicator")
|
|
163
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file (default: overwrites input)")
|
|
164
|
+
def redact(
|
|
165
|
+
log_file: str,
|
|
166
|
+
pattern: tuple[str, ...],
|
|
167
|
+
replace: tuple[str, ...],
|
|
168
|
+
regex: bool,
|
|
169
|
+
seamless: bool,
|
|
170
|
+
output: str | None,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Redact sensitive information from a recording log.
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
|
|
176
|
+
# Visible redaction (shows [HOME] in output)
|
|
177
|
+
reterm redact demo.json -p "/home/user" -r "HOME" -o redacted.json
|
|
178
|
+
|
|
179
|
+
# Seamless replacement (looks like original)
|
|
180
|
+
reterm redact demo.json -p "/home/user" -r "/home/alice" --seamless -o clean.json
|
|
181
|
+
|
|
182
|
+
# Regex pattern
|
|
183
|
+
reterm redact demo.json -p "sk-[a-zA-Z0-9]+" -r "API_KEY" --regex -o redacted.json
|
|
184
|
+
"""
|
|
185
|
+
from pathlib import Path
|
|
186
|
+
from reterm.output.models import RecordingLog
|
|
187
|
+
from reterm.redact import create_redactor
|
|
188
|
+
|
|
189
|
+
if len(pattern) != len(replace):
|
|
190
|
+
click.echo(
|
|
191
|
+
f"Error: Number of patterns ({len(pattern)}) must match "
|
|
192
|
+
f"number of replacements ({len(replace)})",
|
|
193
|
+
err=True,
|
|
194
|
+
)
|
|
195
|
+
raise SystemExit(1)
|
|
196
|
+
|
|
197
|
+
if not pattern:
|
|
198
|
+
click.echo("Error: At least one --pattern/-p is required", err=True)
|
|
199
|
+
raise SystemExit(1)
|
|
200
|
+
|
|
201
|
+
# Load log
|
|
202
|
+
log_path = Path(log_file)
|
|
203
|
+
log = RecordingLog.model_validate_json(log_path.read_text())
|
|
204
|
+
|
|
205
|
+
# Create redactor and apply
|
|
206
|
+
redactor = create_redactor(
|
|
207
|
+
patterns=list(pattern),
|
|
208
|
+
replacements=list(replace),
|
|
209
|
+
is_regex=regex,
|
|
210
|
+
seamless=seamless,
|
|
211
|
+
)
|
|
212
|
+
redacted_log = redactor.redact_log(log)
|
|
213
|
+
|
|
214
|
+
# Write output
|
|
215
|
+
output_path = Path(output) if output else log_path
|
|
216
|
+
output_path.write_text(redacted_log.model_dump_json(indent=2))
|
|
217
|
+
click.echo(f"Redacted log saved to: {output_path}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@cli.command()
|
|
221
|
+
@click.argument("log_file", type=click.Path(exists=True))
|
|
222
|
+
@click.option("--speed", "-s", default=1.0, help="Playback speed multiplier")
|
|
223
|
+
@click.option("--idle-limit", "-i", default=None, type=float, help="Cap pause duration at N seconds")
|
|
224
|
+
def play(log_file: str, speed: float, idle_limit: float | None) -> None:
|
|
225
|
+
"""Play back a recording in the terminal.
|
|
226
|
+
|
|
227
|
+
Examples:
|
|
228
|
+
|
|
229
|
+
reterm play recording.json
|
|
230
|
+
reterm play recording.json --speed 2
|
|
231
|
+
reterm play recording.json --idle-limit 2
|
|
232
|
+
"""
|
|
233
|
+
from pathlib import Path
|
|
234
|
+
from reterm.output.models import RecordingLog
|
|
235
|
+
from reterm.play import play_recording
|
|
236
|
+
|
|
237
|
+
log_path = Path(log_file)
|
|
238
|
+
log = RecordingLog.model_validate_json(log_path.read_text())
|
|
239
|
+
play_recording(log, speed=speed, idle_limit=idle_limit)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@cli.command()
|
|
243
|
+
@click.argument("log_file", type=click.Path(exists=True))
|
|
244
|
+
@click.option("--output", "-o", required=True, type=click.Path(), help="Output path (.gif or .svg)")
|
|
245
|
+
@click.option("--theme", "-t", default=None, help="Override theme from log")
|
|
246
|
+
@click.option("--fps", default=30, help="Frames per second")
|
|
247
|
+
@click.option(
|
|
248
|
+
"--idle-limit",
|
|
249
|
+
"-i",
|
|
250
|
+
default=2.0,
|
|
251
|
+
type=float,
|
|
252
|
+
help="Cap any static frame at N seconds so long waits don't drag (0 = uncapped)",
|
|
253
|
+
)
|
|
254
|
+
def render(
|
|
255
|
+
log_file: str,
|
|
256
|
+
output: str,
|
|
257
|
+
theme: str | None,
|
|
258
|
+
fps: int,
|
|
259
|
+
idle_limit: float,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Re-render a GIF or animated SVG from a (possibly redacted) log file.
|
|
262
|
+
|
|
263
|
+
The format is chosen from the -o extension.
|
|
264
|
+
|
|
265
|
+
Examples:
|
|
266
|
+
|
|
267
|
+
# Render a GIF from a log
|
|
268
|
+
reterm render recording.json -o output.gif
|
|
269
|
+
|
|
270
|
+
# Render an animated SVG (embeddable inline in a GitHub README)
|
|
271
|
+
reterm render recording.json -o output.svg
|
|
272
|
+
|
|
273
|
+
# Override theme
|
|
274
|
+
reterm render recording.json -o output.gif --theme monokai
|
|
275
|
+
"""
|
|
276
|
+
from pathlib import Path
|
|
277
|
+
from reterm.output.models import RecordingLog
|
|
278
|
+
|
|
279
|
+
# Load log
|
|
280
|
+
log_path = Path(log_file)
|
|
281
|
+
log = RecordingLog.model_validate_json(log_path.read_text())
|
|
282
|
+
|
|
283
|
+
output_path = Path(output)
|
|
284
|
+
suffix = output_path.suffix.lower()
|
|
285
|
+
if suffix == ".svg":
|
|
286
|
+
from reterm.render.svg import render_svg_from_log
|
|
287
|
+
|
|
288
|
+
render_svg_from_log(
|
|
289
|
+
log=log, output_path=output_path, theme=theme, fps=fps, idle_limit=idle_limit
|
|
290
|
+
)
|
|
291
|
+
click.echo(f"SVG rendered to: {output_path}")
|
|
292
|
+
elif suffix == ".gif":
|
|
293
|
+
from reterm.render.from_log import render_gif_from_log
|
|
294
|
+
|
|
295
|
+
render_gif_from_log(
|
|
296
|
+
log=log, output_path=output_path, theme=theme, fps=fps, idle_limit=idle_limit
|
|
297
|
+
)
|
|
298
|
+
click.echo(f"GIF rendered to: {output_path}")
|
|
299
|
+
else:
|
|
300
|
+
raise click.ClickException(
|
|
301
|
+
f"Unsupported output format '{suffix}'. Use .gif or .svg."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@cli.command()
|
|
306
|
+
@click.argument("poster", type=click.Path(), default="assets/demo.svg")
|
|
307
|
+
@click.option("--base", help="Base URL of the hosted player, e.g. https://you.github.io/reterm")
|
|
308
|
+
@click.option("--recording", "-r", "recording", default="demo", help="Recording name under <base>/recordings/<name>.json")
|
|
309
|
+
@click.option("--src", default=None, help="Direct recording URL for the player (?src=...)")
|
|
310
|
+
@click.option("--alt", default="terminal recording", help="Image alt text")
|
|
311
|
+
def embed(poster: str, base: str | None, recording: str, src: str | None, alt: str) -> None:
|
|
312
|
+
"""Print Markdown to embed a recording in a README.
|
|
313
|
+
|
|
314
|
+
GitHub READMEs can't run a JS player inline, so this produces an animated-SVG
|
|
315
|
+
(or GIF) poster that links to the hosted interactive player.
|
|
316
|
+
|
|
317
|
+
Examples:
|
|
318
|
+
|
|
319
|
+
# Inline animated SVG poster only
|
|
320
|
+
reterm embed assets/demo.svg
|
|
321
|
+
|
|
322
|
+
# SVG poster that links to the hosted player
|
|
323
|
+
reterm embed assets/demo.svg --base https://you.github.io/reterm -r demo
|
|
324
|
+
"""
|
|
325
|
+
if base:
|
|
326
|
+
base_url = base.rstrip("/")
|
|
327
|
+
target = f"{base_url}/play/?src={src}" if src else f"{base_url}/play/?r={recording}"
|
|
328
|
+
click.echo(f"[]({target})")
|
|
329
|
+
else:
|
|
330
|
+
click.echo(f"")
|
|
331
|
+
click.echo(
|
|
332
|
+
"tip: pass --base https://<you>.github.io/reterm to link an interactive player",
|
|
333
|
+
err=True,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
cli()
|
reterm/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core terminal emulation and execution components."""
|