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/mcp_server.py ADDED
@@ -0,0 +1,205 @@
1
+ """MCP server: lets AI agents write, validate, rehearse, export and render showrun demos.
2
+
3
+ Run it with `showrun mcp` (stdio). Needs the `mcp` extra: pip install 'showrun-cli[mcp]'.
4
+ Note that check_demo and render_demo run the demo's shell commands on this machine, just as
5
+ `showrun check` does, so an MCP client should ask before calling them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ try: # mcp >= 2
18
+ from mcp.server.mcpserver import MCPServer as _Server
19
+ except ImportError: # mcp 1.x
20
+ from mcp.server.fastmcp import FastMCP as _Server
21
+
22
+ from . import __version__
23
+ from .model import Demo, DemoError, load_demo, parse_demo
24
+
25
+ INSTRUCTIONS = """\
26
+ showrun turns a YAML file of shell steps into a keypress-driven terminal presentation.
27
+ Typical agent workflow:
28
+ 1. read the format with `demo_format` (or start from `starter_demo`),
29
+ 2. write the YAML with `write_demo` (it is validated before it is saved),
30
+ 3. rehearse it with `check_demo` (runs every command and reports failures),
31
+ 4. optionally `render_demo` to produce an asciinema .cast, and `read_cast` to see it as text.
32
+ The human then presents it with `showrun play <file>` (Enter advances).
33
+ Most tools accept either `path` (a YAML file) or `yaml_text` (inline YAML)."""
34
+
35
+ server = _Server("showrun", instructions=INSTRUCTIONS)
36
+
37
+ _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r")
38
+
39
+
40
+ def _demo(path: Optional[str], yaml_text: Optional[str], variables: Optional[Dict[str, str]]) -> Demo:
41
+ if bool(path) == bool(yaml_text):
42
+ raise ValueError("pass exactly one of `path` or `yaml_text`")
43
+ if path:
44
+ return load_demo(os.path.expanduser(path), variables)
45
+ return parse_demo(yaml_text or "", variables, base_dir=Path.cwd())
46
+
47
+
48
+ def _kind(st) -> str:
49
+ return ("hidden" if st.hidden else "background" if st.background else "canned" if not st.execute
50
+ else "run" if st.run else "wait" if st.wait_for else "say" if st.say else "pause")
51
+
52
+
53
+ def _steps(demo: Demo) -> List[Dict[str, Any]]:
54
+ return [{"n": n, "kind": _kind(st), "label": st.label()} for n, st in enumerate(demo.steps, 1)]
55
+
56
+
57
+ @server.tool()
58
+ def demo_format() -> str:
59
+ """Reference for the demo YAML format: settings, vars, setup/cleanup and every step option."""
60
+ from .model import Settings, Step
61
+
62
+ def fields(model) -> Dict[str, str]:
63
+ return {name: str(f.annotation).replace("typing.", "") + (f" — {f.description}" if f.description else "")
64
+ for name, f in model.model_fields.items()}
65
+
66
+ return json.dumps({
67
+ "top_level": {"title": "str", "settings": "mapping (see settings)", "vars": "mapping; use as {{name}}",
68
+ "setup": "list of commands run silently before step 1",
69
+ "cleanup": "list of commands that always run at the end", "steps": "list of steps"},
70
+ "settings": fields(Settings),
71
+ "step": fields(Step),
72
+ "notes": ["A step can be a plain string, which means `run: <string>`.",
73
+ "execute: false + output: shows canned output without running the command.",
74
+ "background: true + wait_for: <cmd> starts a service and waits until <cmd> exits 0.",
75
+ "expect_output is a regex checked by check_demo; expect_exit may be an int or 'any'."],
76
+ "example": files("showrun").joinpath("starter.yaml").read_text(),
77
+ }, indent=2)
78
+
79
+
80
+ @server.tool()
81
+ def starter_demo() -> str:
82
+ """A complete starter demo (YAML) that uses most features; a good template to edit."""
83
+ return files("showrun").joinpath("starter.yaml").read_text()
84
+
85
+
86
+ @server.tool()
87
+ def validate_demo(path: Optional[str] = None, yaml_text: Optional[str] = None,
88
+ variables: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
89
+ """Parse a demo (from `path` or `yaml_text`) and list its steps. Runs nothing."""
90
+ try:
91
+ d = _demo(path, yaml_text, variables)
92
+ except DemoError as e:
93
+ return {"ok": False, "error": str(e)}
94
+ return {"ok": True, "title": d.title, "steps": _steps(d), "setup": len(d.setup), "cleanup": len(d.cleanup)}
95
+
96
+
97
+ @server.tool()
98
+ def write_demo(path: str, yaml_text: str, overwrite: bool = False) -> Dict[str, Any]:
99
+ """Validate `yaml_text` and save it to `path`. Refuses to replace an existing file unless overwrite=true."""
100
+ p = Path(os.path.expanduser(path))
101
+ if p.exists() and not overwrite:
102
+ return {"ok": False, "error": f"{p} exists; pass overwrite=true to replace it"}
103
+ try:
104
+ d = parse_demo(yaml_text, base_dir=p.parent)
105
+ except DemoError as e:
106
+ return {"ok": False, "error": str(e)}
107
+ p.parent.mkdir(parents=True, exist_ok=True)
108
+ p.write_text(yaml_text)
109
+ return {"ok": True, "path": str(p.resolve()), "steps": _steps(d),
110
+ "next": f"check it with check_demo, then present with: showrun play {p}"}
111
+
112
+
113
+ @server.tool()
114
+ def check_demo(path: Optional[str] = None, yaml_text: Optional[str] = None,
115
+ variables: Optional[Dict[str, str]] = None, fail_fast: bool = False) -> Dict[str, Any]:
116
+ """Rehearse a demo: RUNS every command in a real shell and reports failures, timeouts and
117
+ missing expected output (same as `showrun check`). Canned steps are skipped."""
118
+ from .check import rehearse
119
+
120
+ try:
121
+ d = _demo(path, yaml_text, variables)
122
+ except DemoError as e:
123
+ return {"ok": False, "error": str(e)}
124
+ results = rehearse(d, stop_on_fail=fail_fast)
125
+ rows = [{"n": r.n, "step": r.label, "status": r.status, "seconds": round(r.duration, 2),
126
+ **({"detail": r.detail} if r.detail else {})} for r in results]
127
+ fails = sum(r.status == "fail" for r in results)
128
+ return {"ok": fails == 0, "failing": fails, "results": rows}
129
+
130
+
131
+ @server.tool()
132
+ def export_demo(path: Optional[str] = None, yaml_text: Optional[str] = None, format: str = "md",
133
+ include_hidden: bool = False, variables: Optional[Dict[str, str]] = None) -> str:
134
+ """Export a demo as a Markdown runbook (format='md') or a plain bash script (format='sh')."""
135
+ from .export import to_markdown, to_script
136
+
137
+ d = _demo(path, yaml_text, variables)
138
+ if format == "md":
139
+ return to_markdown(d, include_hidden=include_hidden)
140
+ if format == "sh":
141
+ return to_script(d)
142
+ raise ValueError("format must be 'md' or 'sh'")
143
+
144
+
145
+ @server.tool()
146
+ def render_demo(output: str, path: Optional[str] = None, yaml_text: Optional[str] = None,
147
+ offline: bool = False, delay: float = 1.0, width: int = 100, height: int = 30,
148
+ variables: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
149
+ """Present the demo unattended and save it as an asciinema v2 .cast at `output`. RUNS the
150
+ commands unless offline=true (then recorded/canned output is shown instead)."""
151
+ from .render import render_cast
152
+
153
+ d = _demo(path, yaml_text, variables)
154
+ out = Path(os.path.expanduser(output))
155
+ out.parent.mkdir(parents=True, exist_ok=True)
156
+ r = render_cast(d, str(out), offline=offline, delay=delay, width=width, height=height)
157
+ return {"ok": r.failures == 0, "path": str(out.resolve()), "steps": r.steps, "failures": r.failures,
158
+ "warnings": r.warnings, "replay": f"showrun replay {out}"}
159
+
160
+
161
+ @server.tool()
162
+ def read_cast(path: str, max_chars: int = 20000) -> str:
163
+ """Read a .cast recording as plain text, with a `── step: … ──` line at each step marker."""
164
+ lines = Path(os.path.expanduser(path)).read_text(encoding="utf-8").splitlines()
165
+ out: List[str] = []
166
+ for line in lines[1:]:
167
+ if not line.strip():
168
+ continue
169
+ _, kind, data = json.loads(line)
170
+ if kind == "m":
171
+ out.append(f"\n── step: {data} ──\n")
172
+ elif kind == "o":
173
+ out.append(_ANSI.sub("", data))
174
+ text = "".join(out)
175
+ return text if len(text) <= max_chars else text[:max_chars] + f"\n… ({len(text) - max_chars} more chars)"
176
+
177
+
178
+ def guide_text() -> str:
179
+ """The project README (shipped as the package's long description)."""
180
+ from importlib.metadata import PackageNotFoundError, metadata
181
+
182
+ try:
183
+ return metadata("showrun-cli").get_payload() or metadata("showrun-cli")["Description"] or ""
184
+ except PackageNotFoundError:
185
+ return "showrun guide: https://github.com/honnuanand/showrun#readme"
186
+
187
+
188
+ @server.resource("showrun://guide", name="showrun guide", mime_type="text/markdown",
189
+ description="The one-page showrun guide: commands, keys, demo format, MCP tools.")
190
+ def guide() -> str:
191
+ return guide_text()
192
+
193
+
194
+ @server.tool()
195
+ def showrun_version() -> str:
196
+ """The installed showrun version."""
197
+ return __version__
198
+
199
+
200
+ def main() -> None:
201
+ server.run()
202
+
203
+
204
+ if __name__ == "__main__": # pragma: no cover
205
+ main()
showrun/model.py ADDED
@@ -0,0 +1,203 @@
1
+ """Demo script format: YAML -> validated pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Union
9
+
10
+ import yaml
11
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
12
+
13
+ VAR_PATTERN = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
14
+
15
+
16
+ class DemoError(Exception):
17
+ """Raised for invalid demo files."""
18
+
19
+
20
+ class Settings(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ prompt: str = "[bold green]demo[/] [bold blue]{cwd}[/] [bold]$[/] "
24
+ typing_speed: float = Field(0.04, ge=0, description="Seconds per character.")
25
+ jitter: float = Field(0.5, ge=0, le=1, description="Random +/- fraction of typing_speed.")
26
+ wait_before_typing: bool = False
27
+ shell: str = "bash"
28
+ cwd: Optional[str] = None
29
+ env: Dict[str, str] = Field(default_factory=dict)
30
+ syntax_highlight: bool = True
31
+ theme: str = "monokai"
32
+ typo_rate: float = Field(0.0, ge=0, le=0.3, description="Chance per letter of a fake typo.")
33
+ timeout: Optional[float] = Field(None, gt=0, description="Default per-step timeout (s).")
34
+ highlight_style: str = "bold black on yellow"
35
+
36
+
37
+ class Step(BaseModel):
38
+ """One demo step.
39
+
40
+ Kinds of step:
41
+ - run a command shown to the audience (default)
42
+ - run a command silently (hidden: true)
43
+ - show a command and canned output without running it (execute: false + output)
44
+ - narration only (say without run)
45
+ - pause until a keypress (pause: true)
46
+ """
47
+
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ run: Optional[str] = None
51
+ say: Optional[str] = None
52
+ hidden: bool = False
53
+ type: bool = True
54
+ wait: bool = True
55
+ execute: bool = True
56
+ output: Optional[str] = None
57
+ expect_exit: Union[int, str] = 0 # int, or "any"
58
+ timeout: Optional[float] = Field(None, gt=0)
59
+ speed: Optional[float] = Field(None, ge=0)
60
+ clear: bool = False
61
+ pause: bool = False
62
+ title: Optional[str] = None
63
+ notes: Optional[str] = None # presenter-only notes
64
+ typos: Optional[bool] = None # override settings.typo_rate on/off
65
+ highlight: List[str] = Field(default_factory=list) # regexes to highlight in output
66
+ expect_output: Optional[str] = None # regex checked in rehearsal (and flagged live)
67
+ background: bool = False # start command in background (servers etc.)
68
+ wait_for: Optional[str] = None # poll until this command exits 0
69
+ wait_timeout: float = Field(30, gt=0)
70
+ recorded_output: Optional[str] = None # captured by `showrun record`; used by --offline
71
+
72
+ @model_validator(mode="before")
73
+ @classmethod
74
+ def _coerce(cls, data: Any) -> Any:
75
+ if isinstance(data, dict) and isinstance(data.get("highlight"), str):
76
+ data = dict(data)
77
+ data["highlight"] = [data["highlight"]]
78
+ return data
79
+
80
+ @model_validator(mode="after")
81
+ def _check(self) -> "Step":
82
+ if not (self.run or self.say or self.pause or self.clear or self.title or self.wait_for):
83
+ raise ValueError("step needs at least one of: run, say, pause, clear, title, wait_for")
84
+ if self.background and not self.run:
85
+ raise ValueError("background steps need a 'run' command")
86
+ if self.background and self.output is not None:
87
+ raise ValueError("background steps can't have canned output")
88
+ for pat in self.highlight + ([self.expect_output] if self.expect_output else []):
89
+ try:
90
+ re.compile(pat)
91
+ except re.error as e:
92
+ raise ValueError(f"bad regex {pat!r}: {e}")
93
+ if isinstance(self.expect_exit, str) and self.expect_exit != "any":
94
+ raise ValueError("expect_exit must be an integer or 'any'")
95
+ if self.output is not None and self.execute:
96
+ raise ValueError("'output' (canned output) requires execute: false")
97
+ if self.hidden and not self.run:
98
+ raise ValueError("hidden steps need a 'run' command")
99
+ return self
100
+
101
+ @property
102
+ def visible(self) -> bool:
103
+ return not self.hidden
104
+
105
+ def exit_ok(self, code: int) -> bool:
106
+ return self.expect_exit == "any" or code == self.expect_exit
107
+
108
+ def label(self) -> str:
109
+ if self.title:
110
+ return self.title
111
+ if self.run:
112
+ first = self.run.strip().splitlines()[0]
113
+ return first if len(first) <= 60 else first[:57] + "..."
114
+ if self.say:
115
+ return "say: " + self.say.strip().splitlines()[0][:50]
116
+ return "pause"
117
+
118
+
119
+ class Demo(BaseModel):
120
+ model_config = ConfigDict(extra="forbid")
121
+
122
+ title: Optional[str] = None
123
+ settings: Settings = Field(default_factory=Settings)
124
+ vars: Dict[str, Any] = Field(default_factory=dict)
125
+ setup: List[str] = Field(default_factory=list)
126
+ cleanup: List[str] = Field(default_factory=list)
127
+ steps: List[Step]
128
+
129
+ @model_validator(mode="before")
130
+ @classmethod
131
+ def _normalise_steps(cls, data: Any) -> Any:
132
+ # Allow plain strings as shorthand for {run: "..."}.
133
+ if isinstance(data, dict) and isinstance(data.get("steps"), list):
134
+ data = dict(data)
135
+ data["steps"] = [{"run": s} if isinstance(s, str) else s for s in data["steps"]]
136
+ return data
137
+
138
+
139
+ def substitute(text: str, variables: Dict[str, Any]) -> str:
140
+ def repl(m: re.Match) -> str:
141
+ name = m.group(1)
142
+ # Unknown names are left alone so Go/Jinja templates in commands
143
+ # (e.g. docker --format '{{ json . }}') pass through untouched.
144
+ return str(variables[name]) if name in variables else m.group(0)
145
+
146
+ return VAR_PATTERN.sub(repl, text)
147
+
148
+
149
+ def _sub_all(obj: Any, variables: Dict[str, Any]) -> Any:
150
+ if isinstance(obj, str):
151
+ return substitute(obj, variables)
152
+ if isinstance(obj, list):
153
+ return [_sub_all(x, variables) for x in obj]
154
+ if isinstance(obj, dict):
155
+ return {k: _sub_all(v, variables) for k, v in obj.items()}
156
+ return obj
157
+
158
+
159
+ def parse_demo(text: str, overrides: Optional[Dict[str, str]] = None, base_dir: Optional[Path] = None) -> Demo:
160
+ try:
161
+ raw = yaml.safe_load(text)
162
+ except yaml.YAMLError as e:
163
+ raise DemoError(f"YAML error: {e}") from e
164
+ if not isinstance(raw, dict):
165
+ raise DemoError("demo file must be a mapping with a 'steps' list")
166
+
167
+ # Resolve variables: file defaults < SHOWRUN_VAR_<NAME> env < CLI overrides.
168
+ variables: Dict[str, Any] = dict(raw.get("vars") or {})
169
+ for key in list(variables):
170
+ env_val = os.environ.get(f"SHOWRUN_VAR_{key.upper()}")
171
+ if env_val is not None:
172
+ variables[key] = env_val
173
+ variables.update(overrides or {})
174
+ raw["vars"] = variables
175
+
176
+ for section in ("steps", "setup", "cleanup", "settings", "title"):
177
+ if section in raw:
178
+ raw[section] = _sub_all(raw[section], variables)
179
+
180
+ try:
181
+ demo = Demo.model_validate(raw)
182
+ except ValidationError as e:
183
+ raise DemoError(_format_validation(e)) from e
184
+
185
+ if base_dir is not None:
186
+ cwd = demo.settings.cwd
187
+ demo.settings.cwd = str((base_dir / os.path.expanduser(cwd)).resolve()) if cwd else str(base_dir.resolve())
188
+ return demo
189
+
190
+
191
+ def load_demo(path: Union[str, Path], overrides: Optional[Dict[str, str]] = None) -> Demo:
192
+ p = Path(path)
193
+ if not p.exists():
194
+ raise DemoError(f"file not found: {p}")
195
+ return parse_demo(p.read_text(), overrides, base_dir=p.parent)
196
+
197
+
198
+ def _format_validation(e: ValidationError) -> str:
199
+ lines = ["invalid demo file:"]
200
+ for err in e.errors():
201
+ loc = ".".join(str(x) for x in err["loc"])
202
+ lines.append(f" {loc or '<root>'}: {err['msg']}")
203
+ return "\n".join(lines)
showrun/notes.py ADDED
@@ -0,0 +1,96 @@
1
+ """Presenter notes: the player publishes its position to a small JSON file and
2
+ `showrun notes` renders it live in a second terminal (or on a second screen)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import tempfile
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from rich.console import Console, Group
14
+ from rich.live import Live
15
+ from rich.panel import Panel
16
+ from rich.syntax import Syntax
17
+ from rich.text import Text
18
+
19
+ from .model import Demo
20
+
21
+
22
+ def default_notes_path(demo_path: str) -> str:
23
+ stem = Path(demo_path).resolve().as_posix().replace("/", "_").strip("_")
24
+ return os.path.join(tempfile.gettempdir(), f"showrun-notes-{stem}.json")
25
+
26
+
27
+ class NotesPublisher:
28
+ def __init__(self, path: Optional[str], demo: Demo):
29
+ self.path = path
30
+ self.demo = demo
31
+ self.started = time.time()
32
+
33
+ def publish(self, index: int, state: str = "ready") -> None:
34
+ if not self.path:
35
+ return
36
+ steps = self.demo.steps
37
+
38
+ def info(i: int) -> Optional[dict]:
39
+ if 0 <= i < len(steps):
40
+ s = steps[i]
41
+ return {"n": i + 1, "label": s.label(), "run": s.run, "say": s.say,
42
+ "notes": s.notes, "hidden": s.hidden}
43
+ return None
44
+
45
+ nxt = next((j for j in range(index + 1, len(steps)) if not steps[j].hidden), -1)
46
+ payload = {"title": self.demo.title, "total": len(steps), "state": state,
47
+ "started": self.started, "current": info(index), "next": info(nxt)}
48
+ tmp = self.path + ".tmp"
49
+ with open(tmp, "w") as f:
50
+ json.dump(payload, f)
51
+ os.replace(tmp, self.path)
52
+
53
+ def finish(self) -> None:
54
+ self.publish(len(self.demo.steps), state="finished")
55
+
56
+
57
+ def _render(data: dict) -> Panel:
58
+ elapsed = int(time.time() - data.get("started", time.time()))
59
+ cur = data.get("current")
60
+ parts = []
61
+ header = Text()
62
+ header.append(data.get("title") or "showrun", style="bold")
63
+ header.append(f" ⏱ {elapsed // 60:02d}:{elapsed % 60:02d}", style="cyan")
64
+ if data.get("state") == "finished":
65
+ header.append(" ✓ finished", style="bold green")
66
+ parts.append(header)
67
+ if cur:
68
+ parts.append(Text(f"\nNOW step {cur['n']}/{data['total']}: {cur['label']}", style="bold yellow"))
69
+ if cur.get("run"):
70
+ parts.append(Syntax(cur["run"], "bash", theme="monokai", background_color="default"))
71
+ if cur.get("say"):
72
+ parts.append(Text("Say: " + cur["say"], style="italic"))
73
+ if cur.get("notes"):
74
+ parts.append(Panel(cur["notes"], title="notes", border_style="magenta"))
75
+ nxt = data.get("next")
76
+ if nxt:
77
+ parts.append(Text(f"\nNEXT step {nxt['n']}: {nxt['label']}", style="dim"))
78
+ if nxt.get("notes"):
79
+ parts.append(Text(" " + nxt["notes"].splitlines()[0], style="dim magenta"))
80
+ return Panel(Group(*parts), title="presenter notes", border_style="blue")
81
+
82
+
83
+ def watch(path: str, console: Optional[Console] = None) -> None:
84
+ console = console or Console()
85
+ console.print(f"[dim]watching {path} — start `showrun play` in another terminal. Ctrl-C to quit.[/]")
86
+ last = None
87
+ with Live(Panel("waiting for the demo to start…"), console=console, refresh_per_second=4) as live:
88
+ while True:
89
+ try:
90
+ data = json.loads(Path(path).read_text())
91
+ last = data
92
+ except (FileNotFoundError, json.JSONDecodeError):
93
+ data = last
94
+ if data:
95
+ live.update(_render(data))
96
+ time.sleep(0.25)