showrun-cli 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.
- showrun/__init__.py +6 -0
- showrun/cast.py +86 -0
- showrun/check.py +95 -0
- showrun/cli.py +236 -0
- showrun/export.py +50 -0
- showrun/keys.py +149 -0
- showrun/mcp_server.py +205 -0
- showrun/model.py +203 -0
- showrun/notes.py +96 -0
- showrun/player.py +467 -0
- showrun/record.py +163 -0
- showrun/render.py +51 -0
- showrun/shell.py +238 -0
- showrun/starter.yaml +65 -0
- showrun_cli-0.1.0.dist-info/METADATA +247 -0
- showrun_cli-0.1.0.dist-info/RECORD +19 -0
- showrun_cli-0.1.0.dist-info/WHEEL +4 -0
- showrun_cli-0.1.0.dist-info/entry_points.txt +2 -0
- showrun_cli-0.1.0.dist-info/licenses/LICENSE +9 -0
showrun/shell.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""A persistent shell session driven through a pty.
|
|
2
|
+
|
|
3
|
+
All steps run in the same shell process, so `cd`, `export`, functions and
|
|
4
|
+
aliases carry over between steps. Completion of each command is detected by
|
|
5
|
+
a random sentinel printed together with the command's exit status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Callable, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import pexpect
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Result:
|
|
25
|
+
exit_code: int
|
|
26
|
+
output: str
|
|
27
|
+
duration: float
|
|
28
|
+
timed_out: bool = False
|
|
29
|
+
interrupted: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ShellError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _kill_tree(pid: int) -> None:
|
|
37
|
+
"""Terminate *pid* and its descendants (background groups run in a subshell)."""
|
|
38
|
+
import subprocess
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
kids = subprocess.run(["pgrep", "-P", str(pid)], capture_output=True, text=True).stdout.split()
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
kids = []
|
|
44
|
+
for kid in kids:
|
|
45
|
+
_kill_tree(int(kid))
|
|
46
|
+
try:
|
|
47
|
+
os.kill(pid, 15)
|
|
48
|
+
except (ProcessLookupError, PermissionError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Shell:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
shell: str = "bash",
|
|
56
|
+
cwd: Optional[str] = None,
|
|
57
|
+
env: Optional[Dict[str, str]] = None,
|
|
58
|
+
size: Optional[tuple] = None,
|
|
59
|
+
):
|
|
60
|
+
self.token = secrets.token_hex(6)
|
|
61
|
+
self._marker_prefix = "__SR_" + self.token
|
|
62
|
+
full_env = dict(os.environ)
|
|
63
|
+
full_env.update(env or {})
|
|
64
|
+
full_env.setdefault("TERM", "xterm-256color")
|
|
65
|
+
# A pager (git log, man, systemctl) would wait for keys the audience never sends.
|
|
66
|
+
# The demo's own settings.env can still choose a pager.
|
|
67
|
+
for var in ("PAGER", "GIT_PAGER", "SYSTEMD_PAGER"):
|
|
68
|
+
if var not in (env or {}):
|
|
69
|
+
full_env[var] = "cat"
|
|
70
|
+
# Keep users' rc files out of the way; demos should be reproducible.
|
|
71
|
+
full_env["PS1"] = ""
|
|
72
|
+
full_env["PS2"] = ""
|
|
73
|
+
full_env.pop("PROMPT_COMMAND", None)
|
|
74
|
+
|
|
75
|
+
args = ["--norc", "--noprofile"] if os.path.basename(shell) == "bash" else []
|
|
76
|
+
if os.path.basename(shell) == "zsh":
|
|
77
|
+
args = ["-f"]
|
|
78
|
+
cols, rows = size or shutil.get_terminal_size((100, 30))
|
|
79
|
+
try:
|
|
80
|
+
self.child = pexpect.spawn(
|
|
81
|
+
shell, args, cwd=cwd, env=full_env, encoding="utf-8",
|
|
82
|
+
codec_errors="replace", dimensions=(rows, cols), echo=False,
|
|
83
|
+
)
|
|
84
|
+
except pexpect.ExceptionPexpect as e:
|
|
85
|
+
raise ShellError(f"could not start shell '{shell}': {e}") from e
|
|
86
|
+
|
|
87
|
+
self._raw("stty -echo 2>/dev/null; PS1=''; PS2=''; unset PROMPT_COMMAND; set +m 2>/dev/null; bind 'set enable-bracketed-paste off' 2>/dev/null")
|
|
88
|
+
self.run("true") # sync: swallow any startup noise
|
|
89
|
+
self.background: list = [] # (pid, logfile, command)
|
|
90
|
+
|
|
91
|
+
# -- low level -----------------------------------------------------
|
|
92
|
+
def _raw(self, line: str) -> None:
|
|
93
|
+
self.child.sendline(line)
|
|
94
|
+
|
|
95
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
96
|
+
self.child.setwinsize(rows, cols)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def alive(self) -> bool:
|
|
100
|
+
return self.child.isalive()
|
|
101
|
+
|
|
102
|
+
# -- running -------------------------------------------------------
|
|
103
|
+
def run(
|
|
104
|
+
self,
|
|
105
|
+
command: str,
|
|
106
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
107
|
+
timeout: Optional[float] = None,
|
|
108
|
+
) -> Result:
|
|
109
|
+
"""Run *command* to completion, streaming output to *on_output*."""
|
|
110
|
+
if not self.alive:
|
|
111
|
+
raise ShellError("shell has exited")
|
|
112
|
+
# The status line is sent on its own line so multi-line commands work.
|
|
113
|
+
# Each run gets its own marker id, so a stale marker can never end a later run.
|
|
114
|
+
self._seq = getattr(self, "_seq", 0) + 1
|
|
115
|
+
seq = self._seq
|
|
116
|
+
marker_re = re.compile(r"\r?\n?__SR_" + self.token + "_" + str(seq) + r"_(\d+)__\r?\n")
|
|
117
|
+
marker_cmd = f"printf '\\n__SR_{self.token}_{seq}_%d__\\n' \"$?\"\n"
|
|
118
|
+
self.child.send(command.rstrip("\n") + "\n")
|
|
119
|
+
self.child.send(marker_cmd)
|
|
120
|
+
|
|
121
|
+
def interrupt() -> None:
|
|
122
|
+
# Ctrl-C makes the tty discard typed-ahead input, including our
|
|
123
|
+
# marker line, so send it again afterwards.
|
|
124
|
+
self.child.sendintr()
|
|
125
|
+
time.sleep(0.1)
|
|
126
|
+
self.child.send(marker_cmd)
|
|
127
|
+
|
|
128
|
+
start = time.monotonic()
|
|
129
|
+
buf = ""
|
|
130
|
+
emitted = []
|
|
131
|
+
hold = len(self._marker_prefix) + 16 # >= full marker length
|
|
132
|
+
timed_out = interrupted = False
|
|
133
|
+
|
|
134
|
+
def emit(text: str) -> None:
|
|
135
|
+
if text:
|
|
136
|
+
emitted.append(text)
|
|
137
|
+
if on_output:
|
|
138
|
+
on_output(text)
|
|
139
|
+
|
|
140
|
+
while True:
|
|
141
|
+
try:
|
|
142
|
+
chunk = self.child.read_nonblocking(4096, timeout=0.05)
|
|
143
|
+
buf += chunk
|
|
144
|
+
except pexpect.TIMEOUT:
|
|
145
|
+
pass
|
|
146
|
+
except pexpect.EOF:
|
|
147
|
+
emit(buf)
|
|
148
|
+
raise ShellError("shell exited while running a command (did the step run `exit`?)")
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
interrupt()
|
|
151
|
+
interrupted = True
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
m = marker_re.search(buf)
|
|
155
|
+
if m:
|
|
156
|
+
emit(buf[: m.start()])
|
|
157
|
+
rest = buf[m.end():]
|
|
158
|
+
if rest.strip():
|
|
159
|
+
emit(rest)
|
|
160
|
+
code = int(m.group(1))
|
|
161
|
+
break
|
|
162
|
+
# Emit everything except a tail that might hold a partial marker.
|
|
163
|
+
safe = len(buf) - hold
|
|
164
|
+
if safe > 0:
|
|
165
|
+
emit(buf[:safe])
|
|
166
|
+
buf = buf[safe:]
|
|
167
|
+
|
|
168
|
+
if timeout and not timed_out and time.monotonic() - start > timeout:
|
|
169
|
+
timed_out = True
|
|
170
|
+
interrupt()
|
|
171
|
+
|
|
172
|
+
return Result(code, "".join(emitted), time.monotonic() - start, timed_out, interrupted)
|
|
173
|
+
|
|
174
|
+
def start_background(self, command: str) -> tuple:
|
|
175
|
+
"""Start *command* in the background inside the session; returns (pid, logfile)."""
|
|
176
|
+
log = os.path.join(tempfile.gettempdir(), f"showrun-{self.token}-bg{len(self.background)}.log")
|
|
177
|
+
# A subshell keeps bash from printing job-control noise like "[1] 1234".
|
|
178
|
+
out = self.capture(f"( {{ {command.rstrip().rstrip(';')}\n}} > {shlex.quote(log)} 2>&1 & echo $! )")
|
|
179
|
+
try:
|
|
180
|
+
pid = int(out.strip().splitlines()[-1])
|
|
181
|
+
except (ValueError, IndexError):
|
|
182
|
+
raise ShellError(f"could not start background command: {out}")
|
|
183
|
+
self.background.append((pid, log, command))
|
|
184
|
+
return pid, log
|
|
185
|
+
|
|
186
|
+
def stop_background(self) -> None:
|
|
187
|
+
for pid, _, _ in self.background:
|
|
188
|
+
_kill_tree(pid)
|
|
189
|
+
self.background.clear()
|
|
190
|
+
|
|
191
|
+
def wait_for(self, command: str, timeout: float = 30, interval: float = 0.5) -> bool:
|
|
192
|
+
"""Poll *command* (silently) until it exits 0, or give up after *timeout* seconds."""
|
|
193
|
+
deadline = time.monotonic() + timeout
|
|
194
|
+
while True:
|
|
195
|
+
if self.run(f"{{ {command}\n}} >/dev/null 2>&1", timeout=max(1.0, min(10, timeout))).exit_code == 0:
|
|
196
|
+
return True
|
|
197
|
+
if time.monotonic() > deadline:
|
|
198
|
+
return False
|
|
199
|
+
time.sleep(interval)
|
|
200
|
+
|
|
201
|
+
def capture(self, command: str, timeout: float = 10) -> str:
|
|
202
|
+
out = self.run(command, timeout=timeout).output
|
|
203
|
+
return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\r", "", out).strip()
|
|
204
|
+
|
|
205
|
+
def cwd(self) -> str:
|
|
206
|
+
path = self.capture("pwd")
|
|
207
|
+
home = os.path.expanduser("~")
|
|
208
|
+
if home != "/" and (path == home or path.startswith(home + "/")):
|
|
209
|
+
path = "~" + path[len(home):]
|
|
210
|
+
return path
|
|
211
|
+
|
|
212
|
+
# -- live interaction -----------------------------------------------
|
|
213
|
+
def interact(self, prompt: str = "(live) \\w $ ", escape: str = "\x1d") -> None:
|
|
214
|
+
"""Hand the session to the user until they press the escape key (Ctrl-])."""
|
|
215
|
+
self.run(f"stty echo 2>/dev/null; PS1='{prompt}'")
|
|
216
|
+
try:
|
|
217
|
+
self.child.interact(escape_character=escape)
|
|
218
|
+
finally:
|
|
219
|
+
self.child.sendcontrol("c") # discard any half-typed line
|
|
220
|
+
time.sleep(0.05)
|
|
221
|
+
self._raw("stty -echo 2>/dev/null; PS1=''")
|
|
222
|
+
self.run("true")
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
self.stop_background()
|
|
226
|
+
if self.alive:
|
|
227
|
+
try:
|
|
228
|
+
self._raw("exit")
|
|
229
|
+
self.child.expect(pexpect.EOF, timeout=2)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
self.child.close(force=True)
|
|
233
|
+
|
|
234
|
+
def __enter__(self) -> "Shell":
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def __exit__(self, *exc) -> None:
|
|
238
|
+
self.close()
|
showrun/starter.yaml
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# showrun demo — press Enter to advance, ? for all keys.
|
|
2
|
+
title: showrun tour
|
|
3
|
+
|
|
4
|
+
settings:
|
|
5
|
+
prompt: "[bold green]anand@demo[/] [bold blue]{cwd}[/] [bold]$[/] "
|
|
6
|
+
typing_speed: 0.035 # seconds per character
|
|
7
|
+
jitter: 0.5 # human-ish timing
|
|
8
|
+
typo_rate: 0.015 # the occasional fat-fingered key, corrected live
|
|
9
|
+
|
|
10
|
+
vars:
|
|
11
|
+
port: 8765 # override: showrun play demo.yaml -v port=9000
|
|
12
|
+
workdir: /tmp/showrun-tour
|
|
13
|
+
|
|
14
|
+
setup: # runs silently before the first step
|
|
15
|
+
- rm -rf {{workdir}} && mkdir -p {{workdir}}
|
|
16
|
+
|
|
17
|
+
cleanup: # always runs, even if you quit with q
|
|
18
|
+
- rm -rf {{workdir}}
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- title: Getting set up
|
|
22
|
+
say: Every step shares one shell, so cd and export stick.
|
|
23
|
+
run: cd {{workdir}}
|
|
24
|
+
notes: Mention that this is a real shell, not a recording.
|
|
25
|
+
|
|
26
|
+
- run: export GREETING="hello from showrun" # shorthand also works: - echo hi
|
|
27
|
+
hidden: true
|
|
28
|
+
|
|
29
|
+
- run: echo "$GREETING"
|
|
30
|
+
expect_output: hello
|
|
31
|
+
|
|
32
|
+
- run: |
|
|
33
|
+
cat > app.txt <<'EOF'
|
|
34
|
+
status: ok
|
|
35
|
+
version: 1.4.2
|
|
36
|
+
warning: cache is cold
|
|
37
|
+
EOF
|
|
38
|
+
type: false # appears instantly, no typing
|
|
39
|
+
|
|
40
|
+
- run: cat app.txt
|
|
41
|
+
highlight: ["warning.*"] # draw the audience's eye
|
|
42
|
+
notes: Point at the highlighted warning line.
|
|
43
|
+
|
|
44
|
+
- title: Services
|
|
45
|
+
run: python3 -m http.server {{port}} --bind 127.0.0.1
|
|
46
|
+
background: true
|
|
47
|
+
wait_for: curl -sf http://127.0.0.1:{{port}}/ -o /dev/null
|
|
48
|
+
|
|
49
|
+
- run: curl -s http://127.0.0.1:{{port}}/ | grep -m1 -o 'app.txt'
|
|
50
|
+
expect_output: app.txt
|
|
51
|
+
|
|
52
|
+
- title: Safety nets
|
|
53
|
+
say: Slow or flaky? Show canned output instead of running it.
|
|
54
|
+
run: kubectl rollout status deploy/web
|
|
55
|
+
execute: false
|
|
56
|
+
output: |
|
|
57
|
+
Waiting for deployment "web" rollout to finish: 2 of 3 updated replicas are available...
|
|
58
|
+
deployment "web" successfully rolled out
|
|
59
|
+
highlight: successfully
|
|
60
|
+
|
|
61
|
+
- run: ls /does-not-exist
|
|
62
|
+
expect_exit: any # failures you meant to show
|
|
63
|
+
|
|
64
|
+
- say: That's the tour. Try `showrun record my-demo.yaml` to capture your own.
|
|
65
|
+
pause: true
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: showrun-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scripted, keypress-driven terminal demos: record once, present with Enter, replay offline.
|
|
5
|
+
Project-URL: Homepage, https://showrun.clues.apps.clue2.app
|
|
6
|
+
Project-URL: Repository, https://github.com/honnuanand/showrun
|
|
7
|
+
Project-URL: Issues, https://github.com/honnuanand/showrun/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/honnuanand/showrun/tree/main/docs
|
|
9
|
+
Author-email: Anand Rao <anand@clue2solve.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asciinema,cli,demo,demo-magic,live-coding,mcp,presentation,terminal
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
21
|
+
Classifier: Topic :: Terminals
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: pexpect>=4.8
|
|
24
|
+
Requires-Dist: pydantic>=2
|
|
25
|
+
Requires-Dist: pyyaml>=6
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Requires-Dist: typer>=0.9
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine; extra == 'dev'
|
|
32
|
+
Provides-Extra: mcp
|
|
33
|
+
Requires-Dist: mcp>=1.2; (python_version >= '3.10') and extra == 'mcp'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# showrun
|
|
37
|
+
|
|
38
|
+
[](https://github.com/honnuanand/showrun/actions/workflows/ci.yml)
|
|
39
|
+
[](https://github.com/honnuanand/showrun/actions/workflows/publish.yml)
|
|
40
|
+
[](https://pypi.org/p/showrun-cli)
|
|
41
|
+
[](https://pypi.org/p/showrun-cli)
|
|
42
|
+
[](https://github.com/honnuanand/showrun/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
Scripted, keypress-driven terminal demos. You write (or **record**) the demo once, then
|
|
45
|
+
present it by just hitting **Enter**: commands are typed out like a human would, run in a
|
|
46
|
+
real shell, and stream live output. Hidden setup, canned output, presenter notes, rehearsal,
|
|
47
|
+
offline playback and recordings are built in.
|
|
48
|
+
|
|
49
|
+
It's made for anyone who shows a command line to other people: conference talks, team demos,
|
|
50
|
+
and especially **how-tos for CLI tools**. Write the steps once, rehearse them in CI, present
|
|
51
|
+
them live, and publish the same file as a replay and a Markdown runbook.
|
|
52
|
+
|
|
53
|
+
**Website and live replays: [showrun.clues.apps.clue2.app](https://showrun.clues.apps.clue2.app)**
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install showrun-cli # the command is `showrun`
|
|
59
|
+
showrun init demo.yaml # starter demo that shows off every feature
|
|
60
|
+
showrun check demo.yaml # rehearse unattended
|
|
61
|
+
showrun play demo.yaml # present it
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Sample replays
|
|
65
|
+
|
|
66
|
+
Each of these is a YAML file in this repo, rendered with `showrun render`. The `.cast` files
|
|
67
|
+
play in [asciinema-player](https://github.com/asciinema/asciinema-player) or with
|
|
68
|
+
`showrun replay docs/casts/<name>.cast`.
|
|
69
|
+
|
|
70
|
+
| Demo | Replay |
|
|
71
|
+
|---|---|
|
|
72
|
+
| **How-to: undo your last git commit** ([`docs/demos/git-undo.yaml`](https://github.com/honnuanand/showrun/blob/main/docs/demos/git-undo.yaml)): narration, highlights, a real git repo set up silently | [GIF](https://raw.githubusercontent.com/honnuanand/showrun/main/docs/media/git-undo.gif) · [cast](https://github.com/honnuanand/showrun/blob/main/docs/casts/git-undo.cast) |
|
|
73
|
+
| **The feature tour** ([`examples/tour.yaml`](https://github.com/honnuanand/showrun/blob/main/examples/tour.yaml)): shared shell state, hidden steps, a background server with `wait_for`, canned output |  |
|
|
74
|
+
| **showrun rehearsing itself** ([`docs/demos/showrun-cli.yaml`](https://github.com/honnuanand/showrun/blob/main/docs/demos/showrun-cli.yaml)): `validate`, `check` and `export` |  |
|
|
75
|
+
| **Offline playback** ([`examples/recorded.yaml`](https://github.com/honnuanand/showrun/blob/main/examples/recorded.yaml) with `--offline`): recorded output, nothing executed |  |
|
|
76
|
+
|
|
77
|
+
The full guide, with every option and workflow, is in [docs/](https://github.com/honnuanand/showrun/blob/main/docs/README.md).
|
|
78
|
+
|
|
79
|
+
## Commands
|
|
80
|
+
|
|
81
|
+
| Command | What it does |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `showrun play demo.yaml` | Present. `--offline` replays recorded output, `--fallback` runs live but swaps in the recording if a step fails, `--auto 3` advances by itself, `--start 7`, `--speed 2`, `--cast talk.cast`, `-v port=9000` |
|
|
84
|
+
| `showrun record demo.yaml` | Do the demo once in a shell; every command and its output becomes a step |
|
|
85
|
+
| `showrun check demo.yaml` | Rehearsal: runs every step, checks exit codes and expected output, flags slow steps. Exit code 1 on failure, so it works in CI |
|
|
86
|
+
| `showrun notes demo.yaml` | Presenter view for a second terminal: current step, talking points, what's next, timer |
|
|
87
|
+
| `showrun render demo.yaml -o demo.cast` | Present unattended and save an asciinema cast, no terminal needed (`--offline`, `--delay 1.5`, `--width`, `--height`). Good for docs and CI |
|
|
88
|
+
| `showrun replay talk.cast` | Play back a recording (`--speed 2 --max-idle 1`). Casts also work with asciinema / asciinema-player |
|
|
89
|
+
| `showrun export demo.yaml -f md` | Markdown runbook (`-f sh` for a plain bash script) |
|
|
90
|
+
| `showrun validate demo.yaml` | Parse and list steps |
|
|
91
|
+
| `showrun mcp` | MCP server for AI agents (see below) |
|
|
92
|
+
|
|
93
|
+
## Keys while presenting
|
|
94
|
+
|
|
95
|
+
| Key | Action |
|
|
96
|
+
|---|---|
|
|
97
|
+
| Enter / Space / → | run the typed command, or continue |
|
|
98
|
+
| any key while typing | finish typing instantly |
|
|
99
|
+
| `s` | skip this step |
|
|
100
|
+
| `b` / ← | back one step |
|
|
101
|
+
| `r` | retype the command |
|
|
102
|
+
| `g` + number + Enter | jump to step N |
|
|
103
|
+
| `l` | list all steps |
|
|
104
|
+
| `i` | live shell in the same session (Ctrl-] returns to the demo) |
|
|
105
|
+
| `?` | help |
|
|
106
|
+
| `q` / Ctrl-C | quit (cleanup still runs) |
|
|
107
|
+
|
|
108
|
+
## Recording
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
showrun record kafka-demo.yaml
|
|
112
|
+
● rec ~/work $ #title Producing events
|
|
113
|
+
● rec ~/work $ #say Let's start the broker first
|
|
114
|
+
● rec ~/work $ #bg docker compose up kafka
|
|
115
|
+
● rec ~/work $ #wait nc -z localhost 9092
|
|
116
|
+
● rec ~/work $ kcat -b localhost:9092 -L
|
|
117
|
+
● rec ~/work $ #note mention partitions here
|
|
118
|
+
● rec ~/work $ ./produce.sh 100
|
|
119
|
+
● rec ~/work $ #done
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Directives: `#say`, `#title`, `#note` (attach to the next command), `#hide cmd`, `#bg cmd`,
|
|
123
|
+
`#wait cmd`, `#fake` (turn the last step into canned output), `#pause`, `#undo`, `#list`,
|
|
124
|
+
`#done`. The file is saved after every step, so nothing is lost if something crashes.
|
|
125
|
+
|
|
126
|
+
Each recorded step keeps its output in `recorded_output`, which gives you three ways to
|
|
127
|
+
present:
|
|
128
|
+
|
|
129
|
+
- `showrun play demo.yaml` runs everything live.
|
|
130
|
+
- `showrun play demo.yaml --offline` shows the recordings and runs nothing that has one
|
|
131
|
+
(background services and `wait_for` are skipped). It's for bad wifi or no cluster.
|
|
132
|
+
- `showrun play demo.yaml --fallback` runs live, but if a step fails, times out or misses
|
|
133
|
+
`expect_output`, it says so and shows the recorded run instead.
|
|
134
|
+
|
|
135
|
+
## Demo file reference
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
title: My demo
|
|
139
|
+
settings:
|
|
140
|
+
prompt: "[bold green]me@demo[/] [blue]{cwd}[/] $ " # Rich markup; {cwd} is live
|
|
141
|
+
typing_speed: 0.04 # s per char
|
|
142
|
+
jitter: 0.5 # timing randomness
|
|
143
|
+
typo_rate: 0.01 # occasional fake typo, corrected on screen
|
|
144
|
+
wait_before_typing: false
|
|
145
|
+
theme: monokai # syntax highlighting theme
|
|
146
|
+
timeout: 60 # default per-step timeout
|
|
147
|
+
shell: bash # or zsh
|
|
148
|
+
cwd: ./project # relative to the YAML file
|
|
149
|
+
env: {KUBECONFIG: ./kc}
|
|
150
|
+
vars: # {{name}}; override with -v name=x or SHOWRUN_VAR_NAME
|
|
151
|
+
ns: demo
|
|
152
|
+
setup: [kubectl create ns {{ns}}] # silent, before step 1
|
|
153
|
+
cleanup: [kubectl delete ns {{ns}}] # always runs
|
|
154
|
+
steps:
|
|
155
|
+
- kubectl get pods -n {{ns}} # plain string = run it
|
|
156
|
+
- run: export TOKEN=$(./get-token)
|
|
157
|
+
hidden: true # runs silently
|
|
158
|
+
- title: Deploying # section rule
|
|
159
|
+
say: Now we roll out v2 # narration shown as a comment
|
|
160
|
+
notes: Stress zero downtime # presenter view only
|
|
161
|
+
run: kubectl apply -f v2.yaml
|
|
162
|
+
highlight: ["configured", "created"] # regexes highlighted in output
|
|
163
|
+
expect_output: configured # checked by `check`, warned live
|
|
164
|
+
expect_exit: 0 # or "any"
|
|
165
|
+
timeout: 30
|
|
166
|
+
- run: ./server --port 8080
|
|
167
|
+
background: true
|
|
168
|
+
wait_for: curl -sf localhost:8080/health
|
|
169
|
+
wait_timeout: 30
|
|
170
|
+
- run: terraform apply # too slow to run on stage?
|
|
171
|
+
execute: false
|
|
172
|
+
output: "Apply complete! Resources: 3 added"
|
|
173
|
+
- run: cat big.json
|
|
174
|
+
type: false # appears instantly
|
|
175
|
+
speed: 0.01 # per-step typing speed
|
|
176
|
+
typos: false
|
|
177
|
+
clear: true # clear screen first
|
|
178
|
+
- pause: true # wait for Enter
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## How it works
|
|
182
|
+
|
|
183
|
+
- **One persistent shell** (`pexpect` on a pty) runs every step, so `cd`, `export` and
|
|
184
|
+
functions carry over. A unique marker after each command gives the exit code and tells
|
|
185
|
+
showrun when the command has finished, without guessing at prompts.
|
|
186
|
+
- **Rich** draws the prompt, the syntax-highlighted typing, highlights and the notes view.
|
|
187
|
+
- **Recordings** are plain asciinema v2 files with a marker at each step, written by
|
|
188
|
+
showrun itself, so asciinema doesn't need to be installed.
|
|
189
|
+
- **Background jobs** run inside the session, and their process trees are killed at the end.
|
|
190
|
+
|
|
191
|
+
## Limits
|
|
192
|
+
|
|
193
|
+
Full-screen apps (vim, top, k9s) don't stream well inside a step. Use `i` to drop into the
|
|
194
|
+
live shell for those. Commands that read from stdin need their input piped in.
|
|
195
|
+
|
|
196
|
+
## Use it from AI agents (MCP)
|
|
197
|
+
|
|
198
|
+
`showrun mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over
|
|
199
|
+
stdio, so an agent such as Claude Code, Claude Desktop or Cursor can write, rehearse and
|
|
200
|
+
render demos for you.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
pip install 'showrun-cli[mcp]' # needs Python 3.10+
|
|
204
|
+
claude mcp add showrun -- showrun mcp # Claude Code
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
There's also a hosted, read-only server at `https://showrun.clues.apps.clue2.app/mcp`. It can explain the format, validate
|
|
208
|
+
and export demos, and never runs commands. Use it when you don't want to install anything:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
claude mcp add --transport http showrun https://showrun.clues.apps.clue2.app/mcp
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
For other clients, add the local server to the client's MCP config:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{ "mcpServers": { "showrun": { "command": "showrun", "args": ["mcp"] } } }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Tool | What it does |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `demo_format`, `starter_demo` | The YAML format reference and a starter file to edit |
|
|
223
|
+
| `validate_demo` | Parse a demo (file or inline YAML) and list its steps |
|
|
224
|
+
| `write_demo` | Validate and save a demo; won't overwrite unless asked |
|
|
225
|
+
| `check_demo` | Rehearse: **runs** every command and reports failures |
|
|
226
|
+
| `export_demo` | Markdown runbook or bash script |
|
|
227
|
+
| `render_demo` | Present unattended into a `.cast` (**runs** commands unless `offline`) |
|
|
228
|
+
| `read_cast` | A `.cast` as plain text with step markers, so the agent can see the result |
|
|
229
|
+
|
|
230
|
+
The server also publishes the one-page guide as the resource `showrun://guide`. Ask your
|
|
231
|
+
agent something like *"write a showrun demo that shows how to use `jq` to filter JSON,
|
|
232
|
+
check it, and render it"*. `check_demo` and `render_demo` run shell commands on your
|
|
233
|
+
machine, so leave your client's tool approval on.
|
|
234
|
+
|
|
235
|
+
## Credits
|
|
236
|
+
|
|
237
|
+
showrun is inspired by [demo-magic](https://github.com/paxtonhare/demo-magic) by Paxton
|
|
238
|
+
Hare, the little bash script that types your commands for you while you talk. I've used
|
|
239
|
+
demo-magic for my own talks and demos for more than a decade, and showrun is my attempt at
|
|
240
|
+
the same idea with rehearsal, recording, offline playback and presenter notes built in.
|
|
241
|
+
Thank you, Paxton.
|
|
242
|
+
|
|
243
|
+
## Development
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
pip install -e '.[dev]' && pytest
|
|
247
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
showrun/__init__.py,sha256=LvoOF17ZQSvNnFVJX4f2V_KEpmmNwuGvyEI_1Y0J9b8,184
|
|
2
|
+
showrun/cast.py,sha256=3ZGKfCq1jtCMvjshnhtJvxYck6pCcC8nOdJMfNzlS8g,2830
|
|
3
|
+
showrun/check.py,sha256=47d1_Schqn_Hx3dA1EUHSA7AHo6vMpxsRh5h_J0WeoU,3814
|
|
4
|
+
showrun/cli.py,sha256=21oc1aNHKMXtTRSsDk-rAnEHPgamKNZIe8E8ceZyQh0,8931
|
|
5
|
+
showrun/export.py,sha256=zBQkbJqROpp1XYAJglqbrHguWvOFBh4rUdGYNxeuYXQ,2006
|
|
6
|
+
showrun/keys.py,sha256=z8SHKg_--BwabGbGaLXGYQgDhK4B6SPmgut1hhZu5NU,4020
|
|
7
|
+
showrun/mcp_server.py,sha256=jof91ajo6XfEfVWgADm57JrdjFu3Ci-7sgh2MYoaX8w,8828
|
|
8
|
+
showrun/model.py,sha256=9IzLAsToGCNtkPi9iUCkXwaTTBXi45vmb5HdOU09dAU,7741
|
|
9
|
+
showrun/notes.py,sha256=cyPQvFKkIJHLtL4V1wWIcpCBx52xWpRCf7tS_V8mRQ0,3607
|
|
10
|
+
showrun/player.py,sha256=rU67IMO9vd0OY7WrELv5rzYcBhpuQGaIGqZNpfUGHS4,17557
|
|
11
|
+
showrun/record.py,sha256=aNNc2ZJaGWY-AMUKuvF9mHroa5v1sMBDhbxlsOROUhM,6770
|
|
12
|
+
showrun/render.py,sha256=ghWNZE0Klge-wxJA5kC6f5_aw0mHCiDbGeQI4WvwQuk,1626
|
|
13
|
+
showrun/shell.py,sha256=2zYK25204Il1r3ar-OrTxn7vroYMJyiRq7Kscf5ZgNw,8613
|
|
14
|
+
showrun/starter.yaml,sha256=JrmTwDyOJ6mh8ChA2Bu0IHclg7sWnoaH9ASlosMuszs,2062
|
|
15
|
+
showrun_cli-0.1.0.dist-info/METADATA,sha256=rpIaz6s3FpPYYHPZa-CVrVnQ-cwdmc_Xa-77olI7DjM,12320
|
|
16
|
+
showrun_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
17
|
+
showrun_cli-0.1.0.dist-info/entry_points.txt,sha256=mwf9MCaDGAqNnDJmF8hTp5olapHQzoYDEISrKdL0NPc,44
|
|
18
|
+
showrun_cli-0.1.0.dist-info/licenses/LICENSE,sha256=_sMEFpXIlvRW0vJhpbJ5Xpi0LQOp7YTKz_4_LCUpOm0,1066
|
|
19
|
+
showrun_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anand Rao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|