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 ADDED
@@ -0,0 +1,3 @@
1
+ """reterm - AI-native terminal recording tool with structured output."""
2
+
3
+ __version__ = "0.1.0"
reterm/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m reterm."""
2
+
3
+ from reterm.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
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"[![{alt}]({poster})]({target})")
329
+ else:
330
+ click.echo(f"![{alt}]({poster})")
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()
@@ -0,0 +1 @@
1
+ """Core terminal emulation and execution components."""