agentreel 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.
@@ -0,0 +1,215 @@
1
+ """
2
+ Browser demo recorder. Uses claude CLI to generate a Playwright script,
3
+ runs it with video recording, then extracts highlight timestamps.
4
+
5
+ Usage:
6
+ python browser_demo.py <url> <output_video> [task]
7
+ python browser_demo.py --highlights <video_path> <output_json> [task]
8
+ """
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+
16
+
17
+ def find_claude():
18
+ """Find the claude CLI binary."""
19
+ import shutil
20
+ claude = shutil.which("claude")
21
+ if claude:
22
+ return claude
23
+ for path in [
24
+ os.path.expanduser("~/.local/bin/claude"),
25
+ "/usr/local/bin/claude",
26
+ ]:
27
+ if os.path.isfile(path):
28
+ return path
29
+ return "claude"
30
+
31
+
32
+ def generate_playwright_script(url, task):
33
+ """Use claude CLI to generate a Playwright demo script."""
34
+ prompt = (
35
+ f"Generate a Playwright Python async function that demos a web app at {url}. "
36
+ f"Task: {task}. "
37
+ f"The function signature is: async def demo(page). "
38
+ f"Navigate to the URL, wait for load, interact with key features — "
39
+ f"click buttons, fill forms, scroll. Take about 20 seconds total. "
40
+ f"Add page.wait_for_timeout(1500) between actions so the viewer can see each step. "
41
+ f"Return ONLY the Python function code, no imports, no markdown fences."
42
+ )
43
+
44
+ claude = find_claude()
45
+ print(f"Using claude at: {claude}", file=sys.stderr)
46
+
47
+ result = subprocess.run(
48
+ [claude, "-p", prompt, "--output-format", "text"],
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=120,
52
+ )
53
+
54
+ text = result.stdout.strip()
55
+ if "```" in text:
56
+ parts = text.split("```")
57
+ for part in parts:
58
+ part = part.strip()
59
+ if part.startswith("python"):
60
+ part = part[6:].strip()
61
+ if "async def demo" in part:
62
+ text = part
63
+ break
64
+ return text
65
+
66
+
67
+ def extract_highlights(video_path, task):
68
+ """Ask Claude to suggest highlight timestamps for a browser recording."""
69
+ claude = find_claude()
70
+
71
+ prompt = (
72
+ f"I recorded a browser demo video of a web app. The task was: {task}. "
73
+ f"The video is about 20 seconds long. "
74
+ f"Suggest 3-4 highlight moments as a JSON array. Each highlight has: "
75
+ f'"label" (1-2 words), "overlay" (short caption with **bold** for accent), '
76
+ f'"videoStartSec" (start time in seconds), "videoEndSec" (end time). '
77
+ f"Each clip should be 3-5 seconds. Cover: page load, key interaction, result. "
78
+ f"Return ONLY the JSON array."
79
+ )
80
+
81
+ result = subprocess.run(
82
+ [claude, "-p", prompt, "--output-format", "text"],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=120,
86
+ )
87
+
88
+ text = result.stdout.strip()
89
+ if "```" in text:
90
+ parts = text.split("```")
91
+ for part in parts:
92
+ part = part.strip()
93
+ if part.startswith("json"):
94
+ part = part[4:].strip()
95
+ if part.startswith("["):
96
+ text = part
97
+ break
98
+
99
+ highlights = json.loads(text)
100
+
101
+ # Add videoSrc to each highlight (the Go/JS CLI will set the actual path)
102
+ for h in highlights:
103
+ h["videoSrc"] = "browser-demo.mp4"
104
+
105
+ return highlights
106
+
107
+
108
+ async def record_browser_demo(url, task, output_path):
109
+ """Generate and run a Playwright demo with video recording."""
110
+ from playwright.async_api import async_playwright
111
+
112
+ print(f"Generating demo script for {url}...", file=sys.stderr)
113
+ script_code = generate_playwright_script(url, task)
114
+ print(f"Script ready ({len(script_code)} chars)", file=sys.stderr)
115
+
116
+ video_dir = tempfile.mkdtemp()
117
+
118
+ async with async_playwright() as p:
119
+ browser = await p.chromium.launch(headless=True)
120
+ context = await browser.new_context(
121
+ viewport={"width": 1280, "height": 800},
122
+ record_video_dir=video_dir,
123
+ record_video_size={"width": 1280, "height": 800},
124
+ )
125
+ page = await context.new_page()
126
+
127
+ # Navigate first
128
+ print(f"Loading {url}...", file=sys.stderr)
129
+ try:
130
+ await page.goto(url, wait_until="networkidle", timeout=15000)
131
+ except Exception as e:
132
+ print(f"Navigation warning: {e}", file=sys.stderr)
133
+ await page.goto(url, timeout=15000)
134
+
135
+ await page.wait_for_timeout(1000)
136
+
137
+ # Execute the generated demo
138
+ try:
139
+ local_ns = {}
140
+ full_code = f"import asyncio\n{script_code}"
141
+ compiled = compile(full_code, "<demo>", "exec")
142
+ exec(compiled, local_ns) # noqa: S102
143
+
144
+ if "demo" in local_ns:
145
+ print("Running demo script...", file=sys.stderr)
146
+ await local_ns["demo"](page)
147
+ else:
148
+ print("No demo() function found, waiting on page...", file=sys.stderr)
149
+ await page.wait_for_timeout(10000)
150
+ except Exception as e:
151
+ print(f"Demo script error: {e}", file=sys.stderr)
152
+ # Scroll around as fallback
153
+ await page.wait_for_timeout(2000)
154
+ await page.evaluate("window.scrollTo({ top: 500, behavior: 'smooth' })")
155
+ await page.wait_for_timeout(2000)
156
+ await page.evaluate("window.scrollTo({ top: 0, behavior: 'smooth' })")
157
+ await page.wait_for_timeout(2000)
158
+
159
+ # Get the video path before closing
160
+ video = page.video
161
+ await page.close()
162
+ await context.close()
163
+ await browser.close()
164
+
165
+ # Find the recorded video and convert to mp4
166
+ for f in os.listdir(video_dir):
167
+ if f.endswith(".webm"):
168
+ webm_path = os.path.join(video_dir, f)
169
+
170
+ # Convert webm to mp4 with ffmpeg
171
+ print(f"Converting to mp4...", file=sys.stderr)
172
+ try:
173
+ subprocess.run(
174
+ ["ffmpeg", "-y", "-i", webm_path, "-c:v", "libx264",
175
+ "-preset", "fast", "-crf", "23", output_path],
176
+ capture_output=True,
177
+ timeout=60,
178
+ )
179
+ print(f"Saved: {output_path}", file=sys.stderr)
180
+ except (subprocess.TimeoutExpired, FileNotFoundError):
181
+ # ffmpeg not available, use webm directly
182
+ import shutil
183
+ mp4_path = output_path.replace(".mp4", ".webm")
184
+ shutil.copy2(webm_path, mp4_path)
185
+ print(f"Saved (webm): {mp4_path}", file=sys.stderr)
186
+ return
187
+
188
+ print("Error: no video file recorded", file=sys.stderr)
189
+ sys.exit(1)
190
+
191
+
192
+ if __name__ == "__main__":
193
+ if len(sys.argv) < 3:
194
+ print(
195
+ "Usage:\n"
196
+ " python browser_demo.py <url> <output_video> [task]\n"
197
+ " python browser_demo.py --highlights <video_path> <output_json> [task]",
198
+ file=sys.stderr,
199
+ )
200
+ sys.exit(1)
201
+
202
+ if sys.argv[1] == "--highlights":
203
+ video_path = sys.argv[2]
204
+ output = sys.argv[3]
205
+ task = sys.argv[4] if len(sys.argv) > 4 else "Demo the web app"
206
+ print(f"Extracting highlights...", file=sys.stderr)
207
+ highlights = extract_highlights(video_path, task)
208
+ with open(output, "w") as f:
209
+ json.dump(highlights, f, indent=2)
210
+ print(f"Saved {len(highlights)} highlights to: {output}", file=sys.stderr)
211
+ else:
212
+ url = sys.argv[1]
213
+ output = sys.argv[2]
214
+ task = sys.argv[3] if len(sys.argv) > 3 else "Explore the main features"
215
+ asyncio.run(record_browser_demo(url, task, output))
@@ -0,0 +1,272 @@
1
+ """
2
+ CLI demo recorder. Uses `claude` CLI to plan the demo, then records it.
3
+
4
+ Usage:
5
+ python cli_demo.py <command> <workdir> <output_cast> [context]
6
+ """
7
+ import json
8
+ import os
9
+ import pty
10
+ import select
11
+ import subprocess
12
+ import sys
13
+ import time
14
+
15
+
16
+ def find_claude():
17
+ """Find the claude CLI binary."""
18
+ import shutil
19
+ claude = shutil.which("claude")
20
+ if claude:
21
+ return claude
22
+ # Common locations
23
+ for path in [
24
+ os.path.expanduser("~/.local/bin/claude"),
25
+ "/usr/local/bin/claude",
26
+ os.path.expanduser("~/.claude/bin/claude"),
27
+ ]:
28
+ if os.path.isfile(path):
29
+ return path
30
+ return "claude"
31
+
32
+
33
+ def generate_demo_plan(command: str, context: str) -> list[dict]:
34
+ """Use claude CLI to plan a demo sequence."""
35
+ prompt = f"""You are planning a terminal demo for a CLI tool. The tool is invoked with: {command}
36
+
37
+ Context about what this tool does:
38
+ {context}
39
+
40
+ Generate a JSON array of demo steps. Each step is an object with:
41
+ - "type": "command" (run a shell command)
42
+ - "value": the command string
43
+ - "delay": seconds to wait after (for dramatic effect)
44
+ - "description": one-line description
45
+
46
+ Make the demo:
47
+ - Show the most impressive features
48
+ - 5-8 steps max
49
+ - Start simple, build to the wow moment
50
+ - Use realistic commands
51
+
52
+ Return ONLY the JSON array, no markdown, no explanation."""
53
+
54
+ claude = find_claude()
55
+ print(f"Using claude at: {claude}", file=sys.stderr)
56
+
57
+ result = subprocess.run(
58
+ [claude, "-p", prompt, "--output-format", "text"],
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=120,
62
+ )
63
+
64
+ if result.returncode != 0:
65
+ print(f"claude stderr: {result.stderr}", file=sys.stderr)
66
+ raise RuntimeError(f"claude failed: {result.stderr}")
67
+
68
+ text = result.stdout.strip()
69
+ print(f"Claude output: {text[:200]}...", file=sys.stderr)
70
+
71
+ # Strip markdown fences if present
72
+ if "```" in text:
73
+ # Find JSON between fences
74
+ parts = text.split("```")
75
+ for part in parts:
76
+ part = part.strip()
77
+ if part.startswith("json"):
78
+ part = part[4:].strip()
79
+ if part.startswith("["):
80
+ text = part
81
+ break
82
+
83
+ return json.loads(text)
84
+
85
+
86
+ def record_demo(steps: list[dict], workdir: str, output_path: str):
87
+ """Execute demo steps in a PTY and record as asciicast v2."""
88
+ cols, rows = 80, 24
89
+ start_time = time.time()
90
+
91
+ with open(output_path, "w") as f:
92
+ header = {
93
+ "version": 2,
94
+ "width": cols,
95
+ "height": rows,
96
+ "timestamp": int(start_time),
97
+ }
98
+ f.write(json.dumps(header) + "\n")
99
+
100
+ def write_event(event_type: str, data: str):
101
+ elapsed = time.time() - start_time
102
+ f.write(json.dumps([round(elapsed, 6), event_type, data]) + "\n")
103
+ f.flush()
104
+
105
+ for step in steps:
106
+ if step["type"] != "command":
107
+ continue
108
+
109
+ cmd = step["value"]
110
+ desc = step.get("description", "")
111
+
112
+ # Show description as dim comment
113
+ if desc:
114
+ write_event("o", f"\x1b[38;5;245m# {desc}\x1b[0m\r\n")
115
+ time.sleep(0.3)
116
+
117
+ # Type the command character by character
118
+ write_event("o", "\x1b[38;5;76m$\x1b[0m ")
119
+ for char in cmd:
120
+ write_event("o", char)
121
+ time.sleep(0.04)
122
+ write_event("o", "\r\n")
123
+ time.sleep(0.2)
124
+
125
+ # Execute in PTY
126
+ pid, fd = pty.fork()
127
+ if pid == 0:
128
+ os.chdir(workdir)
129
+ os.execvp("/bin/sh", ["/bin/sh", "-c", cmd])
130
+ else:
131
+ deadline = time.time() + 15
132
+ while time.time() < deadline:
133
+ r, _, _ = select.select([fd], [], [], 0.1)
134
+ if r:
135
+ try:
136
+ data = os.read(fd, 4096)
137
+ if data:
138
+ write_event("o", data.decode("utf-8", errors="replace"))
139
+ else:
140
+ break
141
+ except OSError:
142
+ break
143
+ pid_result = os.waitpid(pid, os.WNOHANG)
144
+ if pid_result[0] != 0:
145
+ # Drain remaining output
146
+ try:
147
+ while True:
148
+ r, _, _ = select.select([fd], [], [], 0.1)
149
+ if r:
150
+ data = os.read(fd, 4096)
151
+ if data:
152
+ write_event("o", data.decode("utf-8", errors="replace"))
153
+ else:
154
+ break
155
+ else:
156
+ break
157
+ except OSError:
158
+ pass
159
+ break
160
+
161
+ try:
162
+ os.close(fd)
163
+ except OSError:
164
+ pass
165
+
166
+ delay = step.get("delay", 1.0)
167
+ time.sleep(delay)
168
+ write_event("o", "\r\n")
169
+
170
+ print(f"Saved: {output_path}", file=sys.stderr)
171
+
172
+
173
+ def extract_highlights(cast_path: str, context: str) -> list[dict]:
174
+ """Ask Claude to pick 3-4 highlight moments from the recorded session."""
175
+ # Read the asciicast and strip to just the text content
176
+ lines_output = []
177
+ with open(cast_path) as f:
178
+ for i, line in enumerate(f):
179
+ if i == 0:
180
+ continue # skip header
181
+ try:
182
+ event = json.loads(line)
183
+ if event[1] == "o":
184
+ lines_output.append(event[2])
185
+ except (json.JSONDecodeError, IndexError):
186
+ continue
187
+
188
+ raw_output = "".join(lines_output)
189
+ # Clean ANSI for Claude to read, but keep the raw for display
190
+ import re
191
+ clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_output)
192
+
193
+ prompt = f"""You are creating a highlights reel for a CLI tool demo video. Here is the full terminal output:
194
+
195
+ ---
196
+ {clean[:3000]}
197
+ ---
198
+
199
+ Context: {context}
200
+
201
+ Pick 3-4 highlight moments that would look impressive in a short video. For each highlight, return:
202
+ - "label": short label (1-2 words) like "Initialize", "Configure", "Run", "Results"
203
+ - "lines": array of objects, each with "text" (string), and optionally "color" (hex), "bold" (bool), "dim" (bool), "isPrompt" (bool if it's a shell command)
204
+ - "zoomLine": (optional) index of the most impressive line to zoom into
205
+
206
+ Each highlight should have 4-8 lines max. Keep the text concise.
207
+ Use these colors: green="#50fa7b", yellow="#f1fa8c", purple="#bd93f9", red="#ff5555", dim="#6272a4", white="#f8f8f2"
208
+
209
+ Return ONLY a JSON array of highlights. No markdown fences."""
210
+
211
+ claude = find_claude()
212
+ result = subprocess.run(
213
+ [claude, "-p", prompt, "--output-format", "text"],
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=120,
217
+ )
218
+
219
+ text = result.stdout.strip()
220
+ if "```" in text:
221
+ parts = text.split("```")
222
+ for part in parts:
223
+ part = part.strip()
224
+ if part.startswith("json"):
225
+ part = part[4:].strip()
226
+ if part.startswith("["):
227
+ text = part
228
+ break
229
+
230
+ return json.loads(text)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ if len(sys.argv) < 4:
235
+ print("Usage: python cli_demo.py <command> <workdir> <output> [context]", file=sys.stderr)
236
+ print(" python cli_demo.py --highlights <cast_file> <output_json> [context]", file=sys.stderr)
237
+ sys.exit(1)
238
+
239
+ # Highlights mode: extract highlights from existing recording
240
+ if sys.argv[1] == "--highlights":
241
+ cast_file = sys.argv[2]
242
+ output = sys.argv[3]
243
+ context = sys.argv[4] if len(sys.argv) > 4 else ""
244
+ print(f"Extracting highlights from: {cast_file}", file=sys.stderr)
245
+ highlights = extract_highlights(cast_file, context)
246
+ with open(output, "w") as f:
247
+ json.dump(highlights, f, indent=2)
248
+ print(f"Saved {len(highlights)} highlights to: {output}", file=sys.stderr)
249
+ sys.exit(0)
250
+
251
+ # Record mode: plan + record + extract highlights
252
+ command = sys.argv[1]
253
+ workdir = sys.argv[2]
254
+ output = sys.argv[3]
255
+ context = sys.argv[4] if len(sys.argv) > 4 else ""
256
+
257
+ print(f"Planning demo for: {command}", file=sys.stderr)
258
+ steps = generate_demo_plan(command, context)
259
+ print(f"Generated {len(steps)} steps:", file=sys.stderr)
260
+ for s in steps:
261
+ print(f" $ {s['value']} — {s.get('description', '')}", file=sys.stderr)
262
+
263
+ print("Recording...", file=sys.stderr)
264
+ record_demo(steps, workdir, output)
265
+
266
+ # Extract highlights from the recording
267
+ highlights_path = output.replace(".cast", "-highlights.json")
268
+ print("Extracting highlights...", file=sys.stderr)
269
+ highlights = extract_highlights(output, context)
270
+ with open(highlights_path, "w") as f:
271
+ json.dump(highlights, f, indent=2)
272
+ print(f"Saved {len(highlights)} highlights to: {highlights_path}", file=sys.stderr)