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.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # agentreel
2
+
3
+ Turn your Claude Code sessions into viral demo videos.
4
+
5
+ https://github.com/user-attachments/assets/070ee610-298c-4989-8d7e-369ca495469e
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx agentreel
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # After Claude builds something, just run:
17
+ agentreel
18
+
19
+ # It reads your session, detects what was built, records a demo,
20
+ # picks the highlights, and renders a video. One command.
21
+
22
+ # Manual mode:
23
+ agentreel --cmd "npx my-cli-tool" # CLI demo
24
+ agentreel --url http://localhost:3000 # browser demo
25
+ ```
26
+
27
+ ## How it works
28
+
29
+ 1. Reads your Claude Code session log
30
+ 2. Detects what was built — CLI tool or web app
31
+ 3. Claude plans and executes a demo (terminal or browser)
32
+ 4. Claude picks the 3-4 best highlight moments
33
+ 5. Renders a polished video with music, transitions, and overlays
34
+ 6. Prompts you to share on Twitter
35
+
36
+ ## What you get
37
+
38
+ A 15-20 second 1080x1080 video with:
39
+ - **Title card** with your project name
40
+ - **Highlight clips** — terminal or browser window on animated gradient
41
+ - **Text overlays** — bold captions that work on mute
42
+ - **Cursor + typing** — looks like someone's actually using it
43
+ - **Background music** with fade in/out
44
+ - **End CTA** — install command + URL
45
+
46
+ Ready for Twitter/X, LinkedIn, Reels.
47
+
48
+ ## Supports
49
+
50
+ - **CLI demos** — records your tool in a terminal, shows the highlights
51
+ - **Browser demos** — records your web app via Playwright, shows the key moments
52
+
53
+ ## Requirements
54
+
55
+ - Node.js 18+
56
+ - Python 3.10+
57
+ - Claude CLI (`claude`)
58
+
59
+ ## Credits
60
+
61
+ Default background music: ["Go Create"](https://uppbeat.io/track/all-good-folks/go-create) by All Good Folks (via [Uppbeat](https://uppbeat.io))
62
+
63
+ ## License
64
+
65
+ MIT
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, copyFileSync, createReadStream } from "node:fs";
5
+ import { join, dirname, basename, resolve } from "node:path";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createInterface } from "node:readline";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const ROOT = resolve(__dirname, "..");
12
+
13
+ // ── CLI flags ───────────────────────────────────────────────
14
+
15
+ function parseArgs() {
16
+ const args = process.argv.slice(2);
17
+ const flags = {};
18
+ for (let i = 0; i < args.length; i++) {
19
+ const arg = args[i];
20
+ if (arg === "--help" || arg === "-h") { printUsage(); process.exit(0); }
21
+ if (arg === "--version" || arg === "-v") { console.log("0.1.0"); process.exit(0); }
22
+ if (arg === "--cmd" || arg === "-c") flags.cmd = args[++i];
23
+ else if (arg === "--url" || arg === "-u") flags.url = args[++i];
24
+ else if (arg === "--prompt" || arg === "-p") flags.prompt = args[++i];
25
+ else if (arg === "--title" || arg === "-t") flags.title = args[++i];
26
+ else if (arg === "--output" || arg === "-o") flags.output = args[++i];
27
+ else if (arg === "--music") flags.music = args[++i];
28
+ else if (arg === "--session") flags.session = args[++i];
29
+ else if (arg === "--no-share") flags.noShare = true;
30
+ }
31
+ return flags;
32
+ }
33
+
34
+ function printUsage() {
35
+ console.log(`agentreel — Turn Claude Code sessions into viral demo videos
36
+
37
+ Usage:
38
+ agentreel # auto-detect from session
39
+ agentreel --cmd "npx @islo-labs/overtime" # manual CLI demo
40
+ agentreel --url http://localhost:3000 # manual browser demo
41
+
42
+ Flags:
43
+ -c, --cmd <command> CLI command to demo
44
+ -u, --url <url> URL to demo (browser mode)
45
+ -p, --prompt <text> description of what the tool does
46
+ -t, --title <text> video title
47
+ -o, --output <file> output file (default: agentreel.mp4)
48
+ --music <file> path to background music mp3
49
+ --session <file> path to Claude Code session .jsonl
50
+ --no-share skip the share prompt
51
+ -h, --help show help
52
+ -v, --version show version`);
53
+ }
54
+
55
+ // ── Session parser ──────────────────────────────────────────
56
+
57
+ function findLatestSession() {
58
+ const cwd = process.cwd();
59
+ const projectKey = cwd.replaceAll("/", "-");
60
+ const projectDir = join(homedir(), ".claude", "projects", projectKey);
61
+
62
+ if (!existsSync(projectDir)) return null;
63
+
64
+ let newest = null;
65
+ let newestTime = 0;
66
+ for (const entry of readdirSync(projectDir)) {
67
+ if (!entry.endsWith(".jsonl")) continue;
68
+ const full = join(projectDir, entry);
69
+ const mtime = statSync(full).mtimeMs;
70
+ if (mtime > newestTime) { newestTime = mtime; newest = full; }
71
+ }
72
+ return newest;
73
+ }
74
+
75
+ function parseSession(path) {
76
+ const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean);
77
+ const session = { prompt: "", title: "", actions: [], startTime: null, endTime: null };
78
+
79
+ for (const line of lines) {
80
+ let obj;
81
+ try { obj = JSON.parse(line); } catch { continue; }
82
+
83
+ const ts = obj.timestamp ? new Date(obj.timestamp) : null;
84
+ if (ts && !isNaN(ts)) {
85
+ if (!session.startTime || ts < session.startTime) session.startTime = ts;
86
+ if (!session.endTime || ts > session.endTime) session.endTime = ts;
87
+ }
88
+
89
+ if (obj.type === "user" && !session.prompt) {
90
+ session.prompt = extractPrompt(obj);
91
+ }
92
+ if (obj.type === "custom-title" && obj.customTitle) {
93
+ session.title = obj.customTitle;
94
+ }
95
+ if (obj.type === "assistant") {
96
+ const content = obj.message?.content;
97
+ if (!Array.isArray(content)) continue;
98
+ for (const block of content) {
99
+ if (block.type !== "tool_use") continue;
100
+ const action = parseToolUse(block.name, block.input, ts);
101
+ if (action) session.actions.push(action);
102
+ }
103
+ }
104
+ }
105
+
106
+ session.actions.sort((a, b) => (a.time || 0) - (b.time || 0));
107
+ if (session.startTime && session.endTime) {
108
+ session.durationMs = session.endTime - session.startTime;
109
+ }
110
+ return session;
111
+ }
112
+
113
+ function extractPrompt(obj) {
114
+ const content = obj.message?.content;
115
+ if (typeof content === "string") return cleanPrompt(content);
116
+ if (Array.isArray(content)) {
117
+ for (const block of content) {
118
+ if (block.type === "text" && block.text) return cleanPrompt(block.text);
119
+ }
120
+ }
121
+ return "";
122
+ }
123
+
124
+ function cleanPrompt(s) {
125
+ for (const line of s.split("\n")) {
126
+ const trimmed = line.trim();
127
+ if (!trimmed || /^[│├└─┌┐]/.test(trimmed)) continue;
128
+ return trimmed.slice(0, 200);
129
+ }
130
+ return s.slice(0, 200);
131
+ }
132
+
133
+ function parseToolUse(name, input, ts) {
134
+ if (!input) return null;
135
+ switch (name) {
136
+ case "Read": return { type: "read", filePath: input.file_path, time: ts };
137
+ case "Write": return { type: "write", filePath: input.file_path, size: input.content?.length || 0, time: ts };
138
+ case "Edit": return { type: "edit", filePath: input.file_path, time: ts };
139
+ case "Bash": return { type: "bash", command: input.command, time: ts };
140
+ case "Grep": case "Glob": return { type: "search", time: ts };
141
+ case "Agent": return { type: "agent", time: ts };
142
+ default: return null;
143
+ }
144
+ }
145
+
146
+ // ── Detection ───────────────────────────────────────────────
147
+
148
+ function detectResult(session) {
149
+ const containsAny = (s, ...subs) => subs.some(sub => s.includes(sub));
150
+
151
+ for (const a of session.actions) {
152
+ if (a.type === "bash") {
153
+ const cmd = (a.command || "").toLowerCase();
154
+ if (containsAny(cmd, "npm run dev", "npm start", "npx next", "npx vite", "yarn dev", "pnpm dev", "flask run", "uvicorn")) {
155
+ const url = extractURL(a.command) || "http://localhost:3000";
156
+ return { type: "browser", command: url };
157
+ }
158
+ }
159
+ }
160
+
161
+ for (const a of session.actions) {
162
+ if ((a.type === "write" || a.type === "edit") && a.filePath?.endsWith("package.json")) {
163
+ try {
164
+ const pkg = JSON.parse(readFileSync(a.filePath, "utf-8"));
165
+ if (pkg.bin && pkg.name) return { type: "cli", command: `npx ${pkg.name} --help` };
166
+ } catch { /* skip */ }
167
+ }
168
+ }
169
+
170
+ for (const a of session.actions) {
171
+ if (a.type === "bash" && a.command?.includes("go build")) {
172
+ const parts = a.command.split(/\s+/);
173
+ const oIdx = parts.indexOf("-o");
174
+ if (oIdx !== -1 && parts[oIdx + 1]) return { type: "cli", command: `${parts[oIdx + 1]} --help` };
175
+ }
176
+ }
177
+
178
+ for (let i = session.actions.length - 1; i >= 0; i--) {
179
+ const a = session.actions[i];
180
+ if (a.type !== "bash") continue;
181
+ const cmd = (a.command || "").trim();
182
+ if (containsAny(cmd, "go build", "go test", "npm install", "npm test", "git ", "mkdir", "ls ", "cat ")) continue;
183
+ if (containsAny(cmd, "npx ", "./bin/", "./dist/", "go run", "python ", "node ")) {
184
+ return { type: "cli", command: cmd };
185
+ }
186
+ }
187
+
188
+ return { type: "unknown" };
189
+ }
190
+
191
+ function extractURL(cmd) {
192
+ for (const part of (cmd || "").split(/\s+/)) {
193
+ if (part.includes("localhost:")) return part.startsWith("http") ? part : `http://${part}`;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ // ── Recording + Highlights ──────────────────────────────────
199
+
200
+ function findPython() {
201
+ const venvPython = join(ROOT, "scripts", ".venv", "bin", "python");
202
+ if (existsSync(venvPython)) return venvPython;
203
+ return "python3";
204
+ }
205
+
206
+ function recordCLI(command, workDir, context) {
207
+ const python = findPython();
208
+ const script = join(ROOT, "scripts", "cli_demo.py");
209
+ const outFile = join(tmpdir(), "agentreel-cli-demo.cast");
210
+
211
+ const args = [script, command, workDir, outFile];
212
+ if (context) args.push(context);
213
+
214
+ console.error(`Agent planning CLI demo for: ${command}`);
215
+ execFileSync(python, args, { stdio: ["ignore", "inherit", "inherit"], env: process.env });
216
+ return outFile;
217
+ }
218
+
219
+ function extractHighlightsFromCast(castPath, context) {
220
+ const python = findPython();
221
+ const script = join(ROOT, "scripts", "cli_demo.py");
222
+ const outFile = castPath + "-highlights.json";
223
+
224
+ const args = [script, "--highlights", castPath, outFile];
225
+ if (context) args.push(context);
226
+
227
+ execFileSync(python, args, { stdio: ["ignore", "inherit", "inherit"], env: process.env });
228
+ return outFile;
229
+ }
230
+
231
+ // ── Browser Recording ───────────────────────────────────────
232
+
233
+ function recordBrowser(url, task) {
234
+ const python = findPython();
235
+ const script = join(ROOT, "scripts", "browser_demo.py");
236
+ const outFile = join(tmpdir(), "agentreel-browser-demo.mp4");
237
+
238
+ console.error(`Agent demoing browser app: ${url}`);
239
+ execFileSync(python, [script, url, outFile, task], {
240
+ stdio: ["ignore", "inherit", "inherit"],
241
+ env: process.env,
242
+ timeout: 120000,
243
+ });
244
+ return outFile;
245
+ }
246
+
247
+ function extractBrowserHighlights(videoPath, task) {
248
+ const python = findPython();
249
+ const script = join(ROOT, "scripts", "browser_demo.py");
250
+ const outFile = videoPath + "-highlights.json";
251
+
252
+ execFileSync(python, [script, "--highlights", videoPath, outFile, task], {
253
+ stdio: ["ignore", "inherit", "inherit"],
254
+ env: process.env,
255
+ });
256
+ return outFile;
257
+ }
258
+
259
+ // ── Render ──────────────────────────────────────────────────
260
+
261
+ function renderVideo(props, output, musicPath) {
262
+ const publicDir = join(ROOT, "public");
263
+ if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
264
+ if (musicPath && existsSync(musicPath)) {
265
+ copyFileSync(musicPath, join(publicDir, "music.mp3"));
266
+ }
267
+
268
+ const absOutput = resolve(output);
269
+ const propsJSON = JSON.stringify(props);
270
+ const remotion = join(ROOT, "node_modules", ".bin", "remotion");
271
+
272
+ execFileSync(remotion, ["render", "CastVideo", absOutput, "--props", propsJSON], {
273
+ cwd: ROOT,
274
+ stdio: ["ignore", "inherit", "inherit"],
275
+ });
276
+
277
+ const size = statSync(absOutput).size;
278
+ console.error(`\nDone: ${output} (${Math.round(size / 1024)} KB)`);
279
+ }
280
+
281
+ // ── Upload + Share ──────────────────────────────────────────
282
+
283
+ async function uploadToStreamable(filePath) {
284
+ const { FormData, File } = await import("node:buffer")
285
+ .then(() => globalThis)
286
+ .catch(() => globalThis);
287
+
288
+ const fileBuffer = readFileSync(filePath);
289
+ const fileName = basename(filePath);
290
+
291
+ // Use multipart form upload via fetch
292
+ const boundary = "----agentreel" + Date.now();
293
+ const CRLF = "\r\n";
294
+
295
+ const header = [
296
+ `--${boundary}`,
297
+ `Content-Disposition: form-data; name="file"; filename="${fileName}"`,
298
+ "Content-Type: video/mp4",
299
+ "",
300
+ ].join(CRLF);
301
+
302
+ const footer = `${CRLF}--${boundary}--${CRLF}`;
303
+
304
+ const headerBuf = Buffer.from(header + CRLF);
305
+ const footerBuf = Buffer.from(footer);
306
+ const body = Buffer.concat([headerBuf, fileBuffer, footerBuf]);
307
+
308
+ const resp = await fetch("https://api.streamable.com/upload", {
309
+ method: "POST",
310
+ headers: {
311
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
312
+ },
313
+ body,
314
+ });
315
+
316
+ if (!resp.ok) {
317
+ const text = await resp.text();
318
+ throw new Error(`Streamable upload failed (${resp.status}): ${text}`);
319
+ }
320
+
321
+ const data = await resp.json();
322
+ return `https://streamable.com/${data.shortcode}`;
323
+ }
324
+
325
+ function openShareURL(videoURL, text) {
326
+ const tweetText = encodeURIComponent(text);
327
+ const encodedURL = encodeURIComponent(videoURL);
328
+ const intentURL = `https://twitter.com/intent/tweet?text=${tweetText}&url=${encodedURL}`;
329
+
330
+ console.error(`\n Share: ${videoURL}`);
331
+ console.error(` Tweet: ${intentURL}\n`);
332
+
333
+ // Open in browser
334
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
335
+ try {
336
+ execFileSync(cmd, [intentURL], { stdio: "ignore" });
337
+ } catch {
338
+ console.error(" (Could not open browser — copy the link above)");
339
+ }
340
+ }
341
+
342
+ function askYesNo(question) {
343
+ return new Promise((resolve) => {
344
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
345
+ rl.question(question, (answer) => {
346
+ rl.close();
347
+ resolve(answer.trim().toLowerCase() !== "n");
348
+ });
349
+ });
350
+ }
351
+
352
+ async function shareFlow(outputPath, title) {
353
+ const shouldShare = await askYesNo("Share to Twitter? [Y/n] ");
354
+ if (!shouldShare) return;
355
+
356
+ console.error("Uploading to Streamable...");
357
+ try {
358
+ const url = await uploadToStreamable(outputPath);
359
+ const text = `${title}\n\nMade with @agentreel`;
360
+ openShareURL(url, text);
361
+ } catch (err) {
362
+ console.error(`Upload failed: ${err.message}`);
363
+ console.error("You can manually upload the video and share it.");
364
+ }
365
+ }
366
+
367
+ // ── Main ────────────────────────────────────────────────────
368
+
369
+ async function main() {
370
+ const flags = parseArgs();
371
+ const output = flags.output || "agentreel.mp4";
372
+ const noShare = flags.noShare;
373
+
374
+ let demoCmd = flags.cmd;
375
+ let demoURL = flags.url;
376
+ let prompt = flags.prompt;
377
+
378
+ // Auto-detect from Claude session if no manual flags
379
+ if (!demoCmd && !demoURL) {
380
+ const sessionPath = flags.session || findLatestSession();
381
+ if (!sessionPath) {
382
+ console.error("No session found and no --cmd or --url provided.\n");
383
+ printUsage();
384
+ process.exit(1);
385
+ }
386
+
387
+ console.error(`Reading session: ${basename(sessionPath)}`);
388
+ const session = parseSession(sessionPath);
389
+ if (!prompt) prompt = session.prompt;
390
+
391
+ const detected = detectResult(session);
392
+ if (detected.type === "cli") {
393
+ demoCmd = detected.command;
394
+ console.error(`Detected CLI: ${demoCmd}`);
395
+ } else if (detected.type === "browser") {
396
+ demoURL = detected.command;
397
+ console.error(`Detected browser: ${demoURL}`);
398
+ } else {
399
+ console.error("Couldn't detect what was built. Use --cmd or --url.");
400
+ process.exit(1);
401
+ }
402
+ }
403
+
404
+ let videoTitle = flags.title || demoCmd || demoURL;
405
+
406
+ if (demoCmd) {
407
+ console.error("Step 1/3: Recording CLI demo...");
408
+ const castPath = recordCLI(demoCmd, process.cwd(), prompt);
409
+
410
+ console.error("Step 2/3: Extracting highlights...");
411
+ const highlightsPath = extractHighlightsFromCast(castPath, prompt);
412
+ const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
413
+ console.error(` ${highlights.length} highlights extracted`);
414
+
415
+ console.error("Step 3/3: Rendering video...");
416
+ renderVideo({
417
+ title: videoTitle,
418
+ subtitle: prompt,
419
+ highlights,
420
+ endText: demoCmd,
421
+ }, output, flags.music);
422
+
423
+ if (!noShare) {
424
+ await shareFlow(resolve(output), videoTitle);
425
+ }
426
+ return;
427
+ }
428
+
429
+ if (demoURL) {
430
+ const task = prompt || "Explore the main features of this app";
431
+
432
+ console.error("Step 1/3: Recording browser demo...");
433
+ const videoPath = recordBrowser(demoURL, task);
434
+
435
+ // Copy video to Remotion public dir so it can be served
436
+ const publicDir = join(ROOT, "public");
437
+ if (!existsSync(publicDir)) mkdirSync(publicDir, { recursive: true });
438
+ copyFileSync(videoPath, join(publicDir, "browser-demo.mp4"));
439
+
440
+ console.error("Step 2/3: Extracting highlights...");
441
+ const highlightsPath = extractBrowserHighlights(videoPath, task);
442
+ const highlights = JSON.parse(readFileSync(highlightsPath, "utf-8"));
443
+ console.error(` ${highlights.length} highlights extracted`);
444
+
445
+ console.error("Step 3/3: Rendering video...");
446
+ renderVideo({
447
+ title: videoTitle,
448
+ subtitle: prompt,
449
+ highlights,
450
+ endText: demoURL,
451
+ endUrl: demoURL,
452
+ }, output, flags.music);
453
+
454
+ if (!noShare) {
455
+ await shareFlow(resolve(output), videoTitle);
456
+ }
457
+ return;
458
+ }
459
+ }
460
+
461
+ main();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "agentreel",
3
+ "version": "0.1.0",
4
+ "description": "Turn Claude Code sessions into viral demo videos",
5
+ "bin": {
6
+ "agentreel": "./bin/agentreel.mjs"
7
+ },
8
+ "scripts": {
9
+ "dev": "remotion studio",
10
+ "render": "remotion render CastVideo out/cast.mp4"
11
+ },
12
+ "dependencies": {
13
+ "@remotion/cli": "^4",
14
+ "remotion": "^4",
15
+ "react": "^18",
16
+ "react-dom": "^18"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^18",
20
+ "typescript": "^5"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "src/",
25
+ "scripts/",
26
+ "public/",
27
+ "remotion.config.*",
28
+ "tsconfig.json"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/islo-labs/agentreel.git"
34
+ },
35
+ "keywords": ["cli", "demo", "video", "claude", "agent", "remotion"]
36
+ }
Binary file
Binary file
Binary file