agentreel 0.4.4 → 0.5.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.
- package/README.md +1 -2
- package/bin/agentreel.mjs +364 -578
- package/package.json +2 -3
- package/scripts/browser_demo.py +0 -286
- package/scripts/cli_demo.py +0 -370
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentreel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn your
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Turn your apps into demo videos",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agentreel": "./bin/agentreel.mjs"
|
|
7
7
|
},
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"bin/",
|
|
26
26
|
"src/",
|
|
27
|
-
"scripts/",
|
|
28
27
|
"public/",
|
|
29
28
|
"remotion.config.*",
|
|
30
29
|
"tsconfig.json"
|
package/scripts/browser_demo.py
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
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
|
-
import time
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def find_claude():
|
|
19
|
-
"""Find the claude CLI binary."""
|
|
20
|
-
import shutil
|
|
21
|
-
claude = shutil.which("claude")
|
|
22
|
-
if claude:
|
|
23
|
-
return claude
|
|
24
|
-
for path in [
|
|
25
|
-
os.path.expanduser("~/.local/bin/claude"),
|
|
26
|
-
"/usr/local/bin/claude",
|
|
27
|
-
]:
|
|
28
|
-
if os.path.isfile(path):
|
|
29
|
-
return path
|
|
30
|
-
return "claude"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def generate_playwright_script(url, task, guidelines=""):
|
|
34
|
-
"""Use claude CLI to generate a Playwright demo script."""
|
|
35
|
-
guidelines_part = f"IMPORTANT guidelines: {guidelines}. " if guidelines else ""
|
|
36
|
-
prompt = (
|
|
37
|
-
f"Generate a Playwright Python async function that demos a web app at {url}. "
|
|
38
|
-
f"Task: {task}. "
|
|
39
|
-
f"{guidelines_part}"
|
|
40
|
-
f"The function signature is: async def demo(page). "
|
|
41
|
-
f"Navigate to the URL, wait for load, interact with key features — "
|
|
42
|
-
f"click buttons, fill forms, scroll. Take about 20 seconds total. "
|
|
43
|
-
f"Add page.wait_for_timeout(1500) between actions so the viewer can see each step. "
|
|
44
|
-
f"IMPORTANT rules for robust scripts: "
|
|
45
|
-
f"- Use timeout=5000 on every click/fill/action so failures are fast. "
|
|
46
|
-
f"- Use force=True on all click() calls to bypass overlapping labels/overlays. "
|
|
47
|
-
f"- Click visible labels and buttons, never hidden inputs (like sr-only radio buttons). "
|
|
48
|
-
f"- Wrap each action in try/except and continue on failure — the demo must finish. "
|
|
49
|
-
f"Return ONLY the Python function code, no imports, no markdown fences."
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
claude = find_claude()
|
|
53
|
-
print(f"Using claude at: {claude}", file=sys.stderr)
|
|
54
|
-
|
|
55
|
-
result = subprocess.run(
|
|
56
|
-
[claude, "-p", prompt, "--output-format", "text"],
|
|
57
|
-
capture_output=True,
|
|
58
|
-
text=True,
|
|
59
|
-
timeout=300,
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
text = result.stdout.strip()
|
|
63
|
-
if "```" in text:
|
|
64
|
-
parts = text.split("```")
|
|
65
|
-
for part in parts:
|
|
66
|
-
part = part.strip()
|
|
67
|
-
if part.startswith("python"):
|
|
68
|
-
part = part[6:].strip()
|
|
69
|
-
if "async def demo" in part:
|
|
70
|
-
text = part
|
|
71
|
-
break
|
|
72
|
-
return text
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def extract_highlights(video_path, task):
|
|
76
|
-
"""Ask Claude to suggest highlight timestamps for a browser recording."""
|
|
77
|
-
claude = find_claude()
|
|
78
|
-
|
|
79
|
-
prompt = (
|
|
80
|
-
f"I recorded a browser demo video of a web app. The task was: {task}. "
|
|
81
|
-
f"The video is about 20 seconds long. "
|
|
82
|
-
f"Suggest 3-4 highlight moments as a JSON array. Each highlight has: "
|
|
83
|
-
f'"label" (1-2 words), "overlay" (short caption with **bold** for accent), '
|
|
84
|
-
f'"videoStartSec" (start time in seconds), "videoEndSec" (end time). '
|
|
85
|
-
f"Each clip should be 5-8 seconds to show the full interaction. Cover: page load, key interaction, result. "
|
|
86
|
-
f"Return ONLY the JSON array."
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
result = subprocess.run(
|
|
90
|
-
[claude, "-p", prompt, "--output-format", "text"],
|
|
91
|
-
capture_output=True,
|
|
92
|
-
text=True,
|
|
93
|
-
timeout=300,
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
text = result.stdout.strip()
|
|
97
|
-
if result.returncode != 0 or not text:
|
|
98
|
-
print(f"Claude returned no output (exit {result.returncode}), using default highlights", file=sys.stderr)
|
|
99
|
-
if result.stderr:
|
|
100
|
-
print(f" stderr: {result.stderr[:200]}", file=sys.stderr)
|
|
101
|
-
text = ""
|
|
102
|
-
|
|
103
|
-
if "```" in text:
|
|
104
|
-
parts = text.split("```")
|
|
105
|
-
for part in parts:
|
|
106
|
-
part = part.strip()
|
|
107
|
-
if part.startswith("json"):
|
|
108
|
-
part = part[4:].strip()
|
|
109
|
-
if part.startswith("["):
|
|
110
|
-
text = part
|
|
111
|
-
break
|
|
112
|
-
|
|
113
|
-
try:
|
|
114
|
-
highlights = json.loads(text)
|
|
115
|
-
except (json.JSONDecodeError, ValueError):
|
|
116
|
-
print(f"Could not parse highlights, using defaults", file=sys.stderr)
|
|
117
|
-
highlights = [
|
|
118
|
-
{"label": "Overview", "overlay": "**Quick look**", "videoStartSec": 1, "videoEndSec": 7},
|
|
119
|
-
{"label": "Features", "overlay": "**Key features**", "videoStartSec": 7, "videoEndSec": 14},
|
|
120
|
-
{"label": "Result", "overlay": "**See it work**", "videoStartSec": 14, "videoEndSec": 20},
|
|
121
|
-
]
|
|
122
|
-
|
|
123
|
-
for h in highlights:
|
|
124
|
-
h["videoSrc"] = "browser-demo.mp4"
|
|
125
|
-
|
|
126
|
-
return highlights
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
async def record_browser_demo(url, task, output_path, auth_state=None, guidelines=""):
|
|
130
|
-
"""Generate and run a Playwright demo with video recording."""
|
|
131
|
-
from playwright.async_api import async_playwright
|
|
132
|
-
|
|
133
|
-
print(f"Generating demo script for {url}...", file=sys.stderr)
|
|
134
|
-
script_code = generate_playwright_script(url, task, guidelines)
|
|
135
|
-
print(f"Script ready ({len(script_code)} chars)", file=sys.stderr)
|
|
136
|
-
|
|
137
|
-
video_dir = tempfile.mkdtemp()
|
|
138
|
-
recording_start_ms = int(time.time() * 1000)
|
|
139
|
-
|
|
140
|
-
async with async_playwright() as p:
|
|
141
|
-
browser = await p.chromium.launch(headless=True)
|
|
142
|
-
ctx_opts = dict(
|
|
143
|
-
viewport={"width": 1280, "height": 800},
|
|
144
|
-
record_video_dir=video_dir,
|
|
145
|
-
record_video_size={"width": 1280, "height": 800},
|
|
146
|
-
)
|
|
147
|
-
if auth_state and os.path.isfile(auth_state):
|
|
148
|
-
ctx_opts["storage_state"] = auth_state
|
|
149
|
-
print(f"Using auth state: {auth_state}", file=sys.stderr)
|
|
150
|
-
context = await browser.new_context(**ctx_opts)
|
|
151
|
-
|
|
152
|
-
# Inject click tracker — persists across navigations
|
|
153
|
-
click_tracker_js = (
|
|
154
|
-
"if (!window.__agentreel_clicks) {"
|
|
155
|
-
" window.__agentreel_clicks = [];"
|
|
156
|
-
" document.addEventListener('click', function(e) {"
|
|
157
|
-
" window.__agentreel_clicks.push({"
|
|
158
|
-
" x: e.clientX,"
|
|
159
|
-
" y: e.clientY,"
|
|
160
|
-
f" timestamp: Date.now() - {recording_start_ms}"
|
|
161
|
-
" });"
|
|
162
|
-
" }, true);"
|
|
163
|
-
"}"
|
|
164
|
-
)
|
|
165
|
-
await context.add_init_script(click_tracker_js)
|
|
166
|
-
|
|
167
|
-
page = await context.new_page()
|
|
168
|
-
|
|
169
|
-
# Navigate first
|
|
170
|
-
print(f"Loading {url}...", file=sys.stderr)
|
|
171
|
-
try:
|
|
172
|
-
await page.goto(url, wait_until="networkidle", timeout=15000)
|
|
173
|
-
except Exception as e:
|
|
174
|
-
print(f"Navigation warning: {e}", file=sys.stderr)
|
|
175
|
-
await page.goto(url, timeout=15000)
|
|
176
|
-
|
|
177
|
-
await page.wait_for_timeout(1000)
|
|
178
|
-
|
|
179
|
-
# Run the generated demo
|
|
180
|
-
try:
|
|
181
|
-
local_ns = {}
|
|
182
|
-
full_code = "import asyncio\n" + script_code
|
|
183
|
-
compiled = compile(full_code, "<demo>", "exec") # noqa: S102
|
|
184
|
-
exec(compiled, local_ns) # noqa: S102
|
|
185
|
-
|
|
186
|
-
if "demo" in local_ns:
|
|
187
|
-
print("Running demo script...", file=sys.stderr)
|
|
188
|
-
await local_ns["demo"](page)
|
|
189
|
-
else:
|
|
190
|
-
print("No demo() function found, waiting on page...", file=sys.stderr)
|
|
191
|
-
await page.wait_for_timeout(10000)
|
|
192
|
-
except Exception as e:
|
|
193
|
-
print(f"Demo script error: {e}", file=sys.stderr)
|
|
194
|
-
# Scroll around as fallback
|
|
195
|
-
await page.wait_for_timeout(2000)
|
|
196
|
-
await page.evaluate("window.scrollTo({ top: 500, behavior: 'smooth' })")
|
|
197
|
-
await page.wait_for_timeout(2000)
|
|
198
|
-
await page.evaluate("window.scrollTo({ top: 0, behavior: 'smooth' })")
|
|
199
|
-
await page.wait_for_timeout(2000)
|
|
200
|
-
|
|
201
|
-
# Extract click data before closing
|
|
202
|
-
try:
|
|
203
|
-
clicks_raw = await page.evaluate("window.__agentreel_clicks || []")
|
|
204
|
-
except Exception:
|
|
205
|
-
clicks_raw = []
|
|
206
|
-
|
|
207
|
-
clicks = [
|
|
208
|
-
{"x": c["x"], "y": c["y"], "timeSec": round(c["timestamp"] / 1000.0, 3)}
|
|
209
|
-
for c in clicks_raw
|
|
210
|
-
]
|
|
211
|
-
clicks_path = output_path.replace(".mp4", "-clicks.json")
|
|
212
|
-
with open(clicks_path, "w") as f:
|
|
213
|
-
json.dump(clicks, f, indent=2)
|
|
214
|
-
print(f"Captured {len(clicks)} clicks -> {clicks_path}", file=sys.stderr)
|
|
215
|
-
|
|
216
|
-
# Get the video path before closing
|
|
217
|
-
video = page.video
|
|
218
|
-
await page.close()
|
|
219
|
-
await context.close()
|
|
220
|
-
await browser.close()
|
|
221
|
-
|
|
222
|
-
# Find the recorded video and convert to mp4
|
|
223
|
-
for f in os.listdir(video_dir):
|
|
224
|
-
if f.endswith(".webm"):
|
|
225
|
-
webm_path = os.path.join(video_dir, f)
|
|
226
|
-
|
|
227
|
-
# Convert webm to mp4 with ffmpeg
|
|
228
|
-
print(f"Converting to mp4...", file=sys.stderr)
|
|
229
|
-
try:
|
|
230
|
-
subprocess.run(
|
|
231
|
-
["ffmpeg", "-y", "-i", webm_path, "-c:v", "libx264",
|
|
232
|
-
"-preset", "fast", "-crf", "23", output_path],
|
|
233
|
-
capture_output=True,
|
|
234
|
-
timeout=60,
|
|
235
|
-
)
|
|
236
|
-
print(f"Saved: {output_path}", file=sys.stderr)
|
|
237
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
238
|
-
# ffmpeg not available, use webm directly
|
|
239
|
-
import shutil
|
|
240
|
-
mp4_path = output_path.replace(".mp4", ".webm")
|
|
241
|
-
shutil.copy2(webm_path, mp4_path)
|
|
242
|
-
print(f"Saved (webm): {mp4_path}", file=sys.stderr)
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
print("Error: no video file recorded", file=sys.stderr)
|
|
246
|
-
sys.exit(1)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if __name__ == "__main__":
|
|
250
|
-
if len(sys.argv) < 3:
|
|
251
|
-
print(
|
|
252
|
-
"Usage:\n"
|
|
253
|
-
" python browser_demo.py <url> <output_video> [task]\n"
|
|
254
|
-
" python browser_demo.py --highlights <video_path> <output_json> [task]",
|
|
255
|
-
file=sys.stderr,
|
|
256
|
-
)
|
|
257
|
-
sys.exit(1)
|
|
258
|
-
|
|
259
|
-
if sys.argv[1] == "--highlights":
|
|
260
|
-
video_path = sys.argv[2]
|
|
261
|
-
output = sys.argv[3]
|
|
262
|
-
task = sys.argv[4] if len(sys.argv) > 4 else "Demo the web app"
|
|
263
|
-
print(f"Extracting highlights...", file=sys.stderr)
|
|
264
|
-
highlights = extract_highlights(video_path, task)
|
|
265
|
-
with open(output, "w") as f:
|
|
266
|
-
json.dump(highlights, f, indent=2)
|
|
267
|
-
print(f"Saved {len(highlights)} highlights to: {output}", file=sys.stderr)
|
|
268
|
-
else:
|
|
269
|
-
url = sys.argv[1]
|
|
270
|
-
output = sys.argv[2]
|
|
271
|
-
# Parse remaining args: [task] [--auth <state_file>]
|
|
272
|
-
task = "Explore the main features"
|
|
273
|
-
auth_state = None
|
|
274
|
-
guidelines = ""
|
|
275
|
-
i = 3
|
|
276
|
-
while i < len(sys.argv):
|
|
277
|
-
if sys.argv[i] == "--auth" and i + 1 < len(sys.argv):
|
|
278
|
-
auth_state = sys.argv[i + 1]
|
|
279
|
-
i += 2
|
|
280
|
-
elif sys.argv[i] == "--guidelines" and i + 1 < len(sys.argv):
|
|
281
|
-
guidelines = sys.argv[i + 1]
|
|
282
|
-
i += 2
|
|
283
|
-
else:
|
|
284
|
-
task = sys.argv[i]
|
|
285
|
-
i += 1
|
|
286
|
-
asyncio.run(record_browser_demo(url, task, output, auth_state=auth_state, guidelines=guidelines))
|
package/scripts/cli_demo.py
DELETED
|
@@ -1,370 +0,0 @@
|
|
|
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 getpass
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import pty
|
|
11
|
-
import re
|
|
12
|
-
import select
|
|
13
|
-
import socket
|
|
14
|
-
import subprocess
|
|
15
|
-
import sys
|
|
16
|
-
import time
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def find_claude():
|
|
20
|
-
"""Find the claude CLI binary."""
|
|
21
|
-
import shutil
|
|
22
|
-
claude = shutil.which("claude")
|
|
23
|
-
if claude:
|
|
24
|
-
return claude
|
|
25
|
-
# Common locations
|
|
26
|
-
for path in [
|
|
27
|
-
os.path.expanduser("~/.local/bin/claude"),
|
|
28
|
-
"/usr/local/bin/claude",
|
|
29
|
-
os.path.expanduser("~/.claude/bin/claude"),
|
|
30
|
-
]:
|
|
31
|
-
if os.path.isfile(path):
|
|
32
|
-
return path
|
|
33
|
-
return "claude"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def generate_demo_plan(command: str, context: str, guidelines: str = "") -> list[dict]:
|
|
37
|
-
"""Use claude CLI to plan a demo sequence."""
|
|
38
|
-
guidelines_block = f"\n\nIMPORTANT guidelines you MUST follow:\n{guidelines}" if guidelines else ""
|
|
39
|
-
|
|
40
|
-
prompt = f"""You are planning a terminal demo for a CLI tool. The tool is invoked with: {command}
|
|
41
|
-
|
|
42
|
-
Context about what this tool does:
|
|
43
|
-
{context}{guidelines_block}
|
|
44
|
-
|
|
45
|
-
Generate a JSON array of demo steps. Each step is an object with:
|
|
46
|
-
- "type": "command" (run a shell command)
|
|
47
|
-
- "value": the command string
|
|
48
|
-
- "delay": seconds to wait after (for dramatic effect)
|
|
49
|
-
- "description": one-line description
|
|
50
|
-
|
|
51
|
-
Make the demo:
|
|
52
|
-
- Show the most impressive features
|
|
53
|
-
- 5-8 steps max
|
|
54
|
-
- Start simple, build to the wow moment
|
|
55
|
-
- Use realistic commands
|
|
56
|
-
|
|
57
|
-
Return ONLY the JSON array, no markdown, no explanation."""
|
|
58
|
-
|
|
59
|
-
claude = find_claude()
|
|
60
|
-
print(f"Using claude at: {claude}", file=sys.stderr)
|
|
61
|
-
|
|
62
|
-
result = subprocess.run(
|
|
63
|
-
[claude, "-p", prompt, "--output-format", "text"],
|
|
64
|
-
capture_output=True,
|
|
65
|
-
text=True,
|
|
66
|
-
timeout=300,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
if result.returncode != 0:
|
|
70
|
-
print(f"claude stderr: {result.stderr}", file=sys.stderr)
|
|
71
|
-
raise RuntimeError(f"claude failed: {result.stderr}")
|
|
72
|
-
|
|
73
|
-
text = result.stdout.strip()
|
|
74
|
-
print(f"Claude output: {text[:200]}...", file=sys.stderr)
|
|
75
|
-
|
|
76
|
-
# Strip markdown fences if present
|
|
77
|
-
if "```" in text:
|
|
78
|
-
# Find JSON between fences
|
|
79
|
-
parts = text.split("```")
|
|
80
|
-
for part in parts:
|
|
81
|
-
part = part.strip()
|
|
82
|
-
if part.startswith("json"):
|
|
83
|
-
part = part[4:].strip()
|
|
84
|
-
if part.startswith("["):
|
|
85
|
-
text = part
|
|
86
|
-
break
|
|
87
|
-
|
|
88
|
-
return json.loads(text)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def record_demo(steps: list[dict], workdir: str, output_path: str):
|
|
92
|
-
"""Execute demo steps in a PTY and record as asciicast v2."""
|
|
93
|
-
cols, rows = 80, 24
|
|
94
|
-
start_time = time.time()
|
|
95
|
-
|
|
96
|
-
with open(output_path, "w") as f:
|
|
97
|
-
header = {
|
|
98
|
-
"version": 2,
|
|
99
|
-
"width": cols,
|
|
100
|
-
"height": rows,
|
|
101
|
-
"timestamp": int(start_time),
|
|
102
|
-
}
|
|
103
|
-
f.write(json.dumps(header) + "\n")
|
|
104
|
-
|
|
105
|
-
# Build patterns to strip user identity from output
|
|
106
|
-
_user = getpass.getuser()
|
|
107
|
-
_host = socket.gethostname().split(".")[0]
|
|
108
|
-
_home = os.path.expanduser("~")
|
|
109
|
-
_title_seq = re.compile(r'\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)')
|
|
110
|
-
_identity = re.compile(
|
|
111
|
-
r'|'.join(re.escape(s) for s in {_user, _host, _home} if s),
|
|
112
|
-
re.IGNORECASE,
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
def _sanitize(text: str) -> str:
|
|
116
|
-
text = _title_seq.sub('', text)
|
|
117
|
-
text = _identity.sub('user', text)
|
|
118
|
-
return text
|
|
119
|
-
|
|
120
|
-
def write_event(event_type: str, data: str, sanitize: bool = False):
|
|
121
|
-
if sanitize:
|
|
122
|
-
data = _sanitize(data)
|
|
123
|
-
elapsed = time.time() - start_time
|
|
124
|
-
f.write(json.dumps([round(elapsed, 6), event_type, data]) + "\n")
|
|
125
|
-
f.flush()
|
|
126
|
-
|
|
127
|
-
for step in steps:
|
|
128
|
-
if step["type"] != "command":
|
|
129
|
-
continue
|
|
130
|
-
|
|
131
|
-
cmd = step["value"]
|
|
132
|
-
desc = step.get("description", "")
|
|
133
|
-
|
|
134
|
-
# Show description as dim comment
|
|
135
|
-
if desc:
|
|
136
|
-
write_event("o", f"\x1b[38;5;245m# {desc}\x1b[0m\r\n")
|
|
137
|
-
time.sleep(0.3)
|
|
138
|
-
|
|
139
|
-
# Type the command character by character (no $ prompt — the renderer adds one)
|
|
140
|
-
write_event("o", "")
|
|
141
|
-
for char in cmd:
|
|
142
|
-
write_event("o", char)
|
|
143
|
-
time.sleep(0.04)
|
|
144
|
-
write_event("o", "\r\n")
|
|
145
|
-
time.sleep(0.2)
|
|
146
|
-
|
|
147
|
-
# Execute in PTY with sanitized env (hide username/hostname)
|
|
148
|
-
pid, fd = pty.fork()
|
|
149
|
-
if pid == 0:
|
|
150
|
-
os.chdir(workdir)
|
|
151
|
-
os.environ["PS1"] = "$ "
|
|
152
|
-
os.environ["PROMPT_COMMAND"] = ""
|
|
153
|
-
os.environ.pop("BASH_COMMAND", None)
|
|
154
|
-
# Suppress terminal title sequences (user@host)
|
|
155
|
-
os.environ["TERM"] = "dumb"
|
|
156
|
-
os.execvp("/bin/sh", ["/bin/sh", "-c", cmd])
|
|
157
|
-
else:
|
|
158
|
-
deadline = time.time() + 15
|
|
159
|
-
while time.time() < deadline:
|
|
160
|
-
r, _, _ = select.select([fd], [], [], 0.1)
|
|
161
|
-
if r:
|
|
162
|
-
try:
|
|
163
|
-
data = os.read(fd, 4096)
|
|
164
|
-
if data:
|
|
165
|
-
write_event("o", data.decode("utf-8", errors="replace"), sanitize=True)
|
|
166
|
-
else:
|
|
167
|
-
break
|
|
168
|
-
except OSError:
|
|
169
|
-
break
|
|
170
|
-
pid_result = os.waitpid(pid, os.WNOHANG)
|
|
171
|
-
if pid_result[0] != 0:
|
|
172
|
-
# Drain remaining output
|
|
173
|
-
try:
|
|
174
|
-
while True:
|
|
175
|
-
r, _, _ = select.select([fd], [], [], 0.1)
|
|
176
|
-
if r:
|
|
177
|
-
data = os.read(fd, 4096)
|
|
178
|
-
if data:
|
|
179
|
-
write_event("o", data.decode("utf-8", errors="replace"), sanitize=True)
|
|
180
|
-
else:
|
|
181
|
-
break
|
|
182
|
-
else:
|
|
183
|
-
break
|
|
184
|
-
except OSError:
|
|
185
|
-
pass
|
|
186
|
-
break
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
os.close(fd)
|
|
190
|
-
except OSError:
|
|
191
|
-
pass
|
|
192
|
-
|
|
193
|
-
delay = step.get("delay", 1.0)
|
|
194
|
-
time.sleep(delay)
|
|
195
|
-
write_event("o", "\r\n")
|
|
196
|
-
|
|
197
|
-
print(f"Saved: {output_path}", file=sys.stderr)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def extract_highlights(cast_path: str, context: str, guidelines: str = "") -> list[dict]:
|
|
201
|
-
"""Ask Claude to pick highlight moments from the recorded session."""
|
|
202
|
-
# Read the asciicast and strip to just the text content
|
|
203
|
-
lines_output = []
|
|
204
|
-
with open(cast_path) as f:
|
|
205
|
-
for i, line in enumerate(f):
|
|
206
|
-
if i == 0:
|
|
207
|
-
continue # skip header
|
|
208
|
-
try:
|
|
209
|
-
event = json.loads(line)
|
|
210
|
-
if event[1] == "o":
|
|
211
|
-
lines_output.append(event[2])
|
|
212
|
-
except (json.JSONDecodeError, IndexError):
|
|
213
|
-
continue
|
|
214
|
-
|
|
215
|
-
raw_output = "".join(lines_output)
|
|
216
|
-
# Clean ANSI escape codes
|
|
217
|
-
clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_output)
|
|
218
|
-
# Strip other common escape sequences (title sets, OSC, etc.)
|
|
219
|
-
clean = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', clean)
|
|
220
|
-
# Collapse carriage-return overwrites (spinners, progress bars).
|
|
221
|
-
# \r means "go back to line start" — keep only the final version of each line.
|
|
222
|
-
collapsed_lines = []
|
|
223
|
-
for line in clean.split('\n'):
|
|
224
|
-
parts = line.split('\r')
|
|
225
|
-
final = parts[-1].strip() if parts else ""
|
|
226
|
-
if final:
|
|
227
|
-
# Deduplicate consecutive identical lines (spinner frames)
|
|
228
|
-
if not collapsed_lines or final != collapsed_lines[-1]:
|
|
229
|
-
collapsed_lines.append(final)
|
|
230
|
-
clean = '\n'.join(collapsed_lines)
|
|
231
|
-
|
|
232
|
-
guidelines_block = f"\n\nAdditional guidelines: {guidelines}" if guidelines else ""
|
|
233
|
-
|
|
234
|
-
# Demo mode: more chapters, more lines, show full flows
|
|
235
|
-
is_demo = "demo" in guidelines.lower() if guidelines else False
|
|
236
|
-
|
|
237
|
-
# If terminal output is empty (e.g. tmux sessions, interactive tools),
|
|
238
|
-
# tell Claude to generate representative highlights from context alone
|
|
239
|
-
output_block = f"\nTerminal output:\n---\n{clean[:6000 if is_demo else 3000]}\n---" if clean.strip() else "\n(No terminal output captured — generate representative highlights from the context below.)"
|
|
240
|
-
|
|
241
|
-
if is_demo:
|
|
242
|
-
prompt = f"""You are creating chapter-based highlights for a demo walkthrough video.
|
|
243
|
-
{output_block}
|
|
244
|
-
|
|
245
|
-
Context: {context}{guidelines_block}
|
|
246
|
-
|
|
247
|
-
Create 4-6 chapters that walk through the demo. For each chapter, return:
|
|
248
|
-
- "label": chapter name (1-3 words)
|
|
249
|
-
- "lines": array of objects with "text" (string), optionally "color" (hex), "bold" (bool), "dim" (bool), "isPrompt" (bool)
|
|
250
|
-
|
|
251
|
-
Each chapter: 12-20 lines. Include the prompt (isPrompt: true) and realistic output.
|
|
252
|
-
Colors: green="#50fa7b", yellow="#f1fa8c", purple="#bd93f9", red="#ff5555", dim="#6272a4", white="#f8f8f2"
|
|
253
|
-
|
|
254
|
-
Return ONLY a JSON array. No markdown fences."""
|
|
255
|
-
else:
|
|
256
|
-
prompt = f"""You are creating a highlights reel for a CLI demo video.
|
|
257
|
-
{output_block}
|
|
258
|
-
|
|
259
|
-
Context: {context}{guidelines_block}
|
|
260
|
-
|
|
261
|
-
Pick 3-4 highlight moments. For each, return:
|
|
262
|
-
- "label": 1-2 word label
|
|
263
|
-
- "lines": array of objects with "text", optionally "color" (hex), "bold", "dim", "isPrompt"
|
|
264
|
-
- "zoomLine": (optional) index of the most impressive line
|
|
265
|
-
|
|
266
|
-
Each highlight: 4-8 lines max.
|
|
267
|
-
Colors: green="#50fa7b", yellow="#f1fa8c", purple="#bd93f9", red="#ff5555", dim="#6272a4", white="#f8f8f2"
|
|
268
|
-
|
|
269
|
-
Return ONLY a JSON array. No markdown fences."""
|
|
270
|
-
|
|
271
|
-
claude = find_claude()
|
|
272
|
-
result = subprocess.run(
|
|
273
|
-
[claude, "-p", prompt, "--output-format", "text"],
|
|
274
|
-
capture_output=True,
|
|
275
|
-
text=True,
|
|
276
|
-
timeout=300,
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
text = result.stdout.strip()
|
|
280
|
-
if "```" in text:
|
|
281
|
-
parts = text.split("```")
|
|
282
|
-
for part in parts:
|
|
283
|
-
part = part.strip()
|
|
284
|
-
if part.startswith("json"):
|
|
285
|
-
part = part[4:].strip()
|
|
286
|
-
if part.startswith("["):
|
|
287
|
-
text = part
|
|
288
|
-
break
|
|
289
|
-
|
|
290
|
-
try:
|
|
291
|
-
return json.loads(text)
|
|
292
|
-
except (json.JSONDecodeError, ValueError):
|
|
293
|
-
print(f"Could not parse highlights from Claude, retrying with simpler prompt...", file=sys.stderr)
|
|
294
|
-
|
|
295
|
-
# Retry with a minimal prompt
|
|
296
|
-
retry_prompt = f"""Generate a JSON array of {4 if is_demo else 3} terminal highlights for a demo video.
|
|
297
|
-
Context: {context}
|
|
298
|
-
|
|
299
|
-
Each highlight: {{"label": "Name", "lines": [{{"text": "command", "isPrompt": true}}, {{"text": "output line", "color": "#50fa7b"}}]}}
|
|
300
|
-
Use 8-15 lines per highlight. Make the output realistic for this tool.
|
|
301
|
-
Return ONLY a JSON array."""
|
|
302
|
-
|
|
303
|
-
try:
|
|
304
|
-
retry = subprocess.run(
|
|
305
|
-
[claude, "-p", retry_prompt, "--output-format", "text"],
|
|
306
|
-
capture_output=True, text=True, timeout=120,
|
|
307
|
-
)
|
|
308
|
-
retry_text = retry.stdout.strip()
|
|
309
|
-
if "```" in retry_text:
|
|
310
|
-
for part in retry_text.split("```"):
|
|
311
|
-
part = part.strip()
|
|
312
|
-
if part.startswith("json"):
|
|
313
|
-
part = part[4:].strip()
|
|
314
|
-
if part.startswith("["):
|
|
315
|
-
retry_text = part
|
|
316
|
-
break
|
|
317
|
-
return json.loads(retry_text)
|
|
318
|
-
except Exception:
|
|
319
|
-
print(f"Retry also failed, using defaults", file=sys.stderr)
|
|
320
|
-
return [
|
|
321
|
-
{"label": "Run", "lines": [
|
|
322
|
-
{"text": context or "demo", "isPrompt": True},
|
|
323
|
-
{"text": "", },
|
|
324
|
-
{"text": " Done.", "color": "#50fa7b"},
|
|
325
|
-
]},
|
|
326
|
-
]
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if __name__ == "__main__":
|
|
330
|
-
if len(sys.argv) < 4:
|
|
331
|
-
print("Usage: python cli_demo.py <command> <workdir> <output> [context]", file=sys.stderr)
|
|
332
|
-
print(" python cli_demo.py --highlights <cast_file> <output_json> [context]", file=sys.stderr)
|
|
333
|
-
sys.exit(1)
|
|
334
|
-
|
|
335
|
-
# Highlights mode: extract highlights from existing recording
|
|
336
|
-
if sys.argv[1] == "--highlights":
|
|
337
|
-
cast_file = sys.argv[2]
|
|
338
|
-
output = sys.argv[3]
|
|
339
|
-
context = sys.argv[4] if len(sys.argv) > 4 else ""
|
|
340
|
-
guidelines = sys.argv[5] if len(sys.argv) > 5 else ""
|
|
341
|
-
print(f"Extracting highlights from: {cast_file}", file=sys.stderr)
|
|
342
|
-
highlights = extract_highlights(cast_file, context, guidelines)
|
|
343
|
-
with open(output, "w") as f:
|
|
344
|
-
json.dump(highlights, f, indent=2)
|
|
345
|
-
print(f"Saved {len(highlights)} highlights to: {output}", file=sys.stderr)
|
|
346
|
-
sys.exit(0)
|
|
347
|
-
|
|
348
|
-
# Record mode: plan + record + extract highlights
|
|
349
|
-
command = sys.argv[1]
|
|
350
|
-
workdir = sys.argv[2]
|
|
351
|
-
output = sys.argv[3]
|
|
352
|
-
context = sys.argv[4] if len(sys.argv) > 4 else ""
|
|
353
|
-
guidelines = sys.argv[5] if len(sys.argv) > 5 else ""
|
|
354
|
-
|
|
355
|
-
print(f"Planning demo for: {command}", file=sys.stderr)
|
|
356
|
-
steps = generate_demo_plan(command, context, guidelines)
|
|
357
|
-
print(f"Generated {len(steps)} steps:", file=sys.stderr)
|
|
358
|
-
for s in steps:
|
|
359
|
-
print(f" $ {s['value']} — {s.get('description', '')}", file=sys.stderr)
|
|
360
|
-
|
|
361
|
-
print("Recording...", file=sys.stderr)
|
|
362
|
-
record_demo(steps, workdir, output)
|
|
363
|
-
|
|
364
|
-
# Extract highlights from the recording
|
|
365
|
-
highlights_path = output.replace(".cast", "-highlights.json")
|
|
366
|
-
print("Extracting highlights...", file=sys.stderr)
|
|
367
|
-
highlights = extract_highlights(output, context, guidelines)
|
|
368
|
-
with open(highlights_path, "w") as f:
|
|
369
|
-
json.dump(highlights, f, indent=2)
|
|
370
|
-
print(f"Saved {len(highlights)} highlights to: {highlights_path}", file=sys.stderr)
|