retake-demos 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * retake — demo-as-code.
4
+ *
5
+ * retake install set up Claude Code / Codex + this folder as the workspace
6
+ * retake init just the workspace: demos/, outputs/, .env, .gitignore
7
+ * retake doctor is everything in place?
8
+ * retake ui the review window on :4310
9
+ * retake run demos/x.yaml record + render → outputs/x/
10
+ * retake run demos/x.yaml --headed --no-render watch the browser, keep only the raw take
11
+ * retake render outputs/x re-render from the existing take (--preset to switch)
12
+ * retake check outputs/x pass/fail on resolution, fps, duration, files
13
+ * retake dry demos/x.yaml every selector and wait, no camera
14
+ * retake validate demos/x.yaml schema check only
15
+ * retake presets list quality presets
16
+ * retake list manifests in ./demos
17
+ * retake describe <name> <url> "<what>" scout + model → demos/<name>.yaml
18
+ *
19
+ * Run from a clone, the `.ts` source goes through tsx (`npm run retake -- …`);
20
+ * installed from npm, dist/cli.js is the `retake` binary. Same file.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { createRequire } from "node:module";
25
+ import { spawnSync } from "node:child_process";
26
+ import { Command } from "commander";
27
+ import { loadManifest, warnings } from "./manifest.js";
28
+ import { acquireLock, captureHash, record, releaseLock } from "./record.js";
29
+ import { check, render } from "./render.js";
30
+ import { presetNames } from "./presets.js";
31
+ import { PKG_ROOT, VERSION, entry } from "./paths.js";
32
+ // The manifest may reference ${VARS}; load .env before any command reads one.
33
+ // (`describe` and the UI used to do this individually, so a plain `run` from
34
+ // the terminal silently saw empty values — which is how a whole take can fail
35
+ // at a login without saying why.)
36
+ try {
37
+ const { loadDotenv } = await import("./describe.js");
38
+ loadDotenv(process.cwd());
39
+ }
40
+ catch { /* .env is optional */ }
41
+ const program = new Command();
42
+ program.name("retake").description("Rerun the demo instead of re-recording it.").version(VERSION);
43
+ const say = (l) => process.stdout.write(l + "\n");
44
+ program
45
+ .command("run")
46
+ .argument("<manifest>", "demo .yaml / .json")
47
+ .option("-o, --out <dir>", "output root", "outputs")
48
+ .option("--headed", "show the browser", false)
49
+ .option("--skip-seed", "do not seed (reuse current app state)", false)
50
+ .option("--no-render", "stop after the raw take")
51
+ .option("--keep-raw", "keep testreel's intermediate files", false)
52
+ .option("--preset <name>", `override the manifest's preset (${presetNames().join(", ")})`)
53
+ .option("--reuse", "reuse the last raw recording if nothing that shapes it changed (re-render only)", false)
54
+ .option("--gif", "also produce a GIF (overrides the manifest)", false)
55
+ .option("--until <scene>", "record up to the end of this scene, then stop (iterate on one beat without paying for the whole take)")
56
+ .action(async (file, opts) => {
57
+ const loaded = loadManifest(file);
58
+ let manifest = opts.preset ? { ...loaded.manifest, preset: opts.preset } : loaded.manifest;
59
+ if (opts.gif)
60
+ manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
61
+ if (opts.preset && !presetNames().includes(opts.preset))
62
+ throw new Error(`unknown preset ${opts.preset} — one of ${presetNames().join(", ")}`);
63
+ const dir = loaded.dir;
64
+ const outDir = path.resolve(opts.out, manifest.name);
65
+ acquireLock(outDir); // throws if another run owns this folder
66
+ // Reuse: same capture hash + raw video present → skip the browser.
67
+ let take;
68
+ const takePath = path.join(outDir, "take.json");
69
+ if (opts.reuse && fs.existsSync(takePath)) {
70
+ try {
71
+ const prev = JSON.parse(fs.readFileSync(takePath, "utf8"));
72
+ if (prev.captureHash && prev.captureHash === captureHash(manifest) && prev.video && fs.existsSync(prev.video) && !prev.partial)
73
+ take = prev;
74
+ }
75
+ catch { /* record fresh */ }
76
+ }
77
+ for (const w of warnings(manifest))
78
+ say(`⚠ ${w}`);
79
+ say(`▶ ${manifest.title ?? manifest.name} → ${path.relative(process.cwd(), outDir)}${take ? " · reusing last recording" : ""}`);
80
+ say(`[stage] capture ${take ? "skip" : "start"}`);
81
+ const t0 = Date.now();
82
+ if (!take) {
83
+ // Wipe everything except the lock.
84
+ for (const f of fs.readdirSync(outDir))
85
+ if (f !== ".retake-lock")
86
+ fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
87
+ try {
88
+ take = await record(manifest, { until: opts.until, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
89
+ }
90
+ catch (e) {
91
+ releaseLock(outDir);
92
+ throw e;
93
+ }
94
+ }
95
+ say(`■ take done in ${((Date.now() - t0) / 1000).toFixed(1)}s · ${take.ok ? "all steps ok" : "SOME STEPS FAILED"} · ${take.video ?? "no video"}`);
96
+ try {
97
+ if (opts.render) {
98
+ say(`[stage] render start`);
99
+ const tr = Date.now();
100
+ const a = await render(manifest, take, outDir, { log: (l) => { if (!l.startsWith("$"))
101
+ say(l); } });
102
+ if (!opts.keepRaw)
103
+ cleanRaw(outDir, take, a.mp4);
104
+ for (const f of [a.master, a.mp4, a.gif, a.thumbnail, a.proofLog])
105
+ if (f)
106
+ say(`✓ ${path.relative(process.cwd(), f)}`);
107
+ if (a.facts)
108
+ say(` ${a.facts.width}×${a.facts.height} @ ${a.facts.fps}fps · ${a.facts.duration.toFixed(1)}s · ${a.facts.encoder} · mp4 ${(a.facts.sizes["demo.mp4"] / 1e6).toFixed(1)} MB${a.gif ? ` · gif ${(a.facts.sizes["demo.gif"] / 1e6).toFixed(1)} MB (${a.facts.gifTool})` : ""} · camera on ${a.facts.cameraScenes} scenes${a.facts.cached ? " · cached" : ""} · render ${((Date.now() - tr) / 1000).toFixed(1)}s`);
109
+ const c = check(outDir, manifest);
110
+ say(c.ok ? "check: pass" : "check: FAIL\n " + c.lines.filter((l) => l.startsWith("FAIL")).join("\n "));
111
+ }
112
+ say(`[stage] done`);
113
+ }
114
+ finally {
115
+ releaseLock(outDir);
116
+ }
117
+ if (take.partial)
118
+ say(`⚠ partial take — ${take.partial}`);
119
+ if (!take.ok || !take.video)
120
+ process.exitCode = 2;
121
+ });
122
+ program
123
+ .command("render")
124
+ .argument("<dir>", "an outputs/<name> dir containing take.json")
125
+ .argument("[manifest]", "manifest to use (default: from take.json's sibling manifest path or demos/<name>.yaml)")
126
+ .option("--preset <name>", "override the manifest's preset for this render")
127
+ .option("--scene <label>", "render just one scene to scene-<label>.mp4 (fast, hardware encode)")
128
+ .option("--gif", "also produce a GIF", false)
129
+ .option("--force", "ignore the render cache", false)
130
+ .action(async (dir, manifestFile, opts) => {
131
+ const takePath = path.join(dir, "take.json");
132
+ const take = JSON.parse(fs.readFileSync(takePath, "utf8"));
133
+ const name = path.basename(path.resolve(dir));
134
+ const mf = manifestFile ?? guessManifest(name);
135
+ const loaded = loadManifest(mf);
136
+ let manifest = opts.preset ? { ...loaded.manifest, preset: opts.preset } : loaded.manifest;
137
+ if (opts.gif)
138
+ manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
139
+ acquireLock(path.resolve(dir));
140
+ try {
141
+ const a = await render(manifest, take, path.resolve(dir), { log: say, force: opts.force, scene: opts.scene });
142
+ for (const f of [a.master, a.mp4, a.gif, a.thumbnail])
143
+ if (f)
144
+ say(`✓ ${path.relative(process.cwd(), f)}`);
145
+ if (a.facts)
146
+ say(` ${Object.entries(a.facts.timings).map(([k, v]) => `${k} ${v}s`).join(" · ")}${a.facts.cached ? " (cached)" : ""}`);
147
+ if (!opts.scene)
148
+ say(check(path.resolve(dir), manifest).lines.join("\n"));
149
+ }
150
+ finally {
151
+ releaseLock(path.resolve(dir));
152
+ }
153
+ });
154
+ program
155
+ .command("mcp")
156
+ .description("run Retake as an MCP server, so your own coding agent can drive it")
157
+ .action(async () => {
158
+ // stdio belongs to the protocol from here on; the module talks and exits.
159
+ await import("./operator/tools.js");
160
+ });
161
+ /** Files a workspace needs. Never overwrites; says what it did. */
162
+ function initWorkspace(root, say) {
163
+ const made = [];
164
+ for (const d of ["demos", "outputs"]) {
165
+ const p = path.join(root, d);
166
+ if (!fs.existsSync(p)) {
167
+ fs.mkdirSync(p, { recursive: true });
168
+ made.push(d + "/");
169
+ }
170
+ }
171
+ const example = path.join(root, "demos", "example.yaml");
172
+ if (!fs.existsSync(example) && fs.readdirSync(path.join(root, "demos")).length === 0) {
173
+ fs.copyFileSync(path.join(PKG_ROOT, "demos", "example.yaml"), example);
174
+ made.push("demos/example.yaml");
175
+ }
176
+ const env = path.join(root, ".env");
177
+ if (!fs.existsSync(env)) {
178
+ fs.copyFileSync(path.join(PKG_ROOT, ".env.example"), env);
179
+ made.push(".env");
180
+ }
181
+ // Outputs are big and sessions are secrets: keep both out of the user's git.
182
+ const gi = path.join(root, ".gitignore");
183
+ const lines = ["outputs/", ".auth/", ".env", ".drafts/", ".trash/", "ideas/"];
184
+ const have = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
185
+ const missing = lines.filter((l) => !have.split(/\r?\n/).some((h) => h.trim() === l || h.trim() === l.replace(/\/$/, "")));
186
+ if (missing.length) {
187
+ fs.writeFileSync(gi, (have ? have.replace(/\n*$/, "\n\n") : "") + "# retake\n" + missing.join("\n") + "\n");
188
+ made.push(`.gitignore (+${missing.length})`);
189
+ }
190
+ say(made.length ? `✓ workspace ${root}: ${made.join(", ")}` : `✓ workspace ${root}: already set up`);
191
+ }
192
+ function chromiumPath() {
193
+ try {
194
+ const require = createRequire(import.meta.url);
195
+ const { chromium } = require("playwright");
196
+ const p = chromium.executablePath();
197
+ return fs.existsSync(p) ? p : null;
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ }
203
+ /** `npx playwright install chromium`, but from wherever playwright actually is. */
204
+ function installChromium(say) {
205
+ const require = createRequire(import.meta.url);
206
+ let cli;
207
+ try {
208
+ cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ say("… downloading Chromium for Playwright (one time, ~150 MB)");
214
+ const r = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
215
+ return r.status === 0;
216
+ }
217
+ /** The MCP server entry, for every config format. */
218
+ function mcpServer(ui) {
219
+ const tools = entry("operator/tools");
220
+ return { command: tools.command, args: tools.args, env: { RETAKE_ROOT: process.cwd(), RETAKE_UI: ui } };
221
+ }
222
+ const toml = (server) => [
223
+ " [mcp_servers.retake]",
224
+ ` command = ${JSON.stringify(server.command)}`,
225
+ ` args = ${JSON.stringify(server.args)}`,
226
+ ` env = { ${Object.entries(server.env).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ")} }`,
227
+ ].join("\n");
228
+ program
229
+ .command("init")
230
+ .description("make this folder a Retake workspace: demos/, outputs/, .env, .gitignore entries")
231
+ .action(() => initWorkspace(process.cwd(), say));
232
+ program
233
+ .command("doctor")
234
+ .description("check that everything Retake needs is in place")
235
+ .action(() => {
236
+ let bad = 0;
237
+ const ok = (l) => say(`✓ ${l}`);
238
+ const no = (l) => { bad++; say(`✗ ${l}`); };
239
+ const major = Number(process.versions.node.split(".")[0]);
240
+ (major >= 20 ? ok : no)(`node ${process.versions.node}${major < 20 ? " — Retake needs 20 or newer" : ""}`);
241
+ const chrome = chromiumPath();
242
+ chrome ? ok(`chromium ${chrome}`) : no("chromium not downloaded — run: retake install (or: npx playwright install chromium)");
243
+ try {
244
+ const require = createRequire(import.meta.url);
245
+ const ff = require("ffmpeg-static");
246
+ fs.existsSync(ff) ? ok(`ffmpeg ${ff}`) : no("ffmpeg-static is missing its binary — reinstall retake-demos");
247
+ }
248
+ catch {
249
+ no("ffmpeg-static not found — reinstall retake-demos");
250
+ }
251
+ const which = (bin) => spawnSync("which", [bin], { encoding: "utf8" }).stdout.trim();
252
+ say(which("gifski") ? `✓ gifski (better GIFs)` : `· gifski not installed — GIFs fall back to ffmpeg (brew install gifski)`);
253
+ say(which("claude") ? `✓ claude CLI — \`retake install\` can register the tools` : `· claude CLI not found — paste the config from \`retake agent\` into your agent`);
254
+ say(which("codex") ? `✓ codex CLI` : `· codex CLI not found (fine if you don't use Codex)`);
255
+ const root = process.cwd();
256
+ fs.existsSync(path.join(root, "demos")) ? ok(`workspace ${root}`) : say(`· ${root} is not a workspace yet — run: retake init`);
257
+ if (bad)
258
+ process.exitCode = 1;
259
+ });
260
+ program
261
+ .command("install")
262
+ .description("set up this folder as the workspace, download Chromium, register the tools with Claude Code (and print the Codex config)")
263
+ .option("--ui <url>", "the Retake window agents report into", "http://localhost:4310")
264
+ .action(async (o) => {
265
+ const os = await import("node:os");
266
+ const { execFileSync } = await import("node:child_process");
267
+ // 1. the workspace: demos/ and outputs/ live where the command was run
268
+ initWorkspace(process.cwd(), say);
269
+ // 2. the browser
270
+ if (chromiumPath())
271
+ say("✓ chromium present");
272
+ else if (!installChromium(say))
273
+ say("✗ could not download Chromium — run `npx playwright install chromium` and try again");
274
+ // 3. the tools, registered with Claude Code for every project
275
+ const server = mcpServer(o.ui);
276
+ try {
277
+ execFileSync("claude", ["mcp", "add-json", "retake", JSON.stringify(server), "--scope", "user"], { stdio: "pipe" });
278
+ say("✓ Claude Code: retake tools registered (user scope)");
279
+ }
280
+ catch {
281
+ say("✗ Claude Code CLI not found or refused — run `retake agent` and paste the config yourself");
282
+ }
283
+ // 4. the skill: when to reach for the tools, and in what order
284
+ const skillSrc = path.join(PKG_ROOT, "skill", "SKILL.md");
285
+ const skillDst = path.join(os.homedir(), ".claude", "skills", "recording-product-demos");
286
+ if (fs.existsSync(skillSrc)) {
287
+ fs.mkdirSync(skillDst, { recursive: true });
288
+ fs.copyFileSync(skillSrc, path.join(skillDst, "SKILL.md"));
289
+ say("✓ Claude Code: recording-product-demos skill installed");
290
+ }
291
+ say("");
292
+ say("Codex — add to ~/.codex/config.toml (Codex has no skill store; the tool descriptions carry the method):");
293
+ say(toml(server));
294
+ say("");
295
+ say("One thing only you can do: RESTART Claude Code / Codex — they load new tools at the start of a session, not mid-way.");
296
+ say("Then, in any project: “record a demo of my app showing the sign-up flow”.");
297
+ say(`Start the window with \`retake ui\` and keep ${o.ui} open to watch. Starting your app from an agent needs RETAKE_ALLOW_START=1 in the env above.`);
298
+ });
299
+ program
300
+ .command("agent")
301
+ .description("print the config to paste into Claude Code, Codex, or Cursor")
302
+ .option("--ui <url>", "the Retake window to report into", "http://localhost:4310")
303
+ .action((o) => {
304
+ // RETAKE_UI is what makes the app a window onto the work rather than a
305
+ // folder you check afterwards: the tools report progress there as they go.
306
+ const server = mcpServer(o.ui);
307
+ const cfg = { mcpServers: { retake: server } };
308
+ say("Add Retake to your agent, then ask it for a demo in plain English.\n");
309
+ say("Claude Code — run this once:");
310
+ say(` claude mcp add-json retake '${JSON.stringify(server)}'\n`);
311
+ say("Codex — add to ~/.codex/config.toml:");
312
+ say(toml(server) + "\n");
313
+ say("Cursor / anything else that speaks MCP — .cursor/mcp.json or equivalent:");
314
+ say(JSON.stringify(cfg, null, 2) + "\n");
315
+ say("Then just say: “record a demo of my app at localhost:3000 showing the sign-up flow”.");
316
+ say(`Keep ${o.ui} open while it works — the plan, the run and the video appear there.`);
317
+ say("Retake will not start your app from outside its own window unless you add RETAKE_ALLOW_START=1 above.");
318
+ });
319
+ program
320
+ .command("dry")
321
+ .description("run the manifest with no camera and report what would fail")
322
+ .argument("<manifest>")
323
+ .option("--no-seed", "do not run seeds first (by default dry seeds, so it sees the state run will see)")
324
+ .action(async (file, opts) => {
325
+ const { manifest, dir } = loadManifest(file);
326
+ const { dryRun } = await import("./dryrun.js");
327
+ const r = await dryRun(manifest, dir, say, { seed: opts.seed });
328
+ if (!r.ok)
329
+ process.exitCode = 3;
330
+ });
331
+ program
332
+ .command("check")
333
+ .argument("<dir>", "an outputs/<name> dir")
334
+ .description("verify the artifacts are post-worthy: resolution, fps, duration, files, pass/fail")
335
+ .action((dir) => {
336
+ const name = path.basename(path.resolve(dir));
337
+ let manifest;
338
+ try {
339
+ manifest = loadManifest(guessManifest(name)).manifest;
340
+ }
341
+ catch { /* optional */ }
342
+ const c = check(path.resolve(dir), manifest);
343
+ say(c.lines.join("\n"));
344
+ say(c.ok ? "check: pass" : "check: FAIL");
345
+ if (!c.ok)
346
+ process.exitCode = 3;
347
+ });
348
+ program
349
+ .command("ideas")
350
+ .description("scan an app (and its source) and propose demos worth recording")
351
+ .argument("<url>")
352
+ .option("-P, --project <dir>", "also read the app's source")
353
+ .option("-o, --out <file>", "where to write the markdown (default ideas/<host>.md)")
354
+ .action(async (url, opts) => {
355
+ const { loadDotenv, pickProvider, scout, suggestIdeas } = await import("./describe.js");
356
+ loadDotenv(process.cwd());
357
+ const provider = pickProvider();
358
+ if (!provider)
359
+ throw new Error("no model configured — set one in the UI's Settings, or RETAKE_MODEL in .env");
360
+ say(`scouting ${url} …`);
361
+ const sc = await scout(url);
362
+ say(`drafting ideas with ${provider.name} (${provider.model}) …`);
363
+ const { markdown, ideas } = await suggestIdeas({ url, scout: sc, provider, project: opts.project });
364
+ const file = path.resolve(opts.out ?? path.join("ideas", `${new URL(url).host.replace(/[^a-z0-9]+/gi, "-")}.md`));
365
+ fs.mkdirSync(path.dirname(file), { recursive: true });
366
+ fs.writeFileSync(file, `# Demo ideas — ${url}\n\n_Suggested by ${provider.name} (${provider.model})${opts.project ? ` after reading ${opts.project}` : ""}._\n\n${markdown}\n`);
367
+ for (const i of ideas)
368
+ say(`· ${i.title}${i.length ? ` (${i.length})` : ""}`);
369
+ say(`✓ ${path.relative(process.cwd(), file)}`);
370
+ });
371
+ program
372
+ .command("gif")
373
+ .argument("<dir>", "an outputs/<name> dir")
374
+ .option("-w, --width <px>", "width", "900")
375
+ .option("-f, --fps <n>", "frames per second", "18")
376
+ .description("make a GIF from the finished demo.mp4 (gifski when installed)")
377
+ .action(async (dir, opts) => {
378
+ const { makeGif } = await import("./render.js");
379
+ const out = makeGif(path.resolve(dir), Number(opts.width), Number(opts.fps), say);
380
+ say(`✓ ${path.relative(process.cwd(), out)} (${(fs.statSync(out).size / 1e6).toFixed(1)} MB)`);
381
+ });
382
+ program
383
+ .command("presets")
384
+ .description("list quality presets")
385
+ .action(async () => {
386
+ const { PRESETS } = await import("./presets.js");
387
+ for (const p of Object.values(PRESETS))
388
+ say(`${p.name.padEnd(16)} ${String(p.width + "×" + p.height).padEnd(10)} ${p.fps}fps scale ${p.scale}× ${p.layout.padEnd(5)} — ${p.description}`);
389
+ });
390
+ program
391
+ .command("validate")
392
+ .argument("<manifest>")
393
+ .action((file) => {
394
+ const { manifest } = loadManifest(file);
395
+ say(`ok: ${manifest.name} · ${manifest.steps.length} steps · ${manifest.steps.filter((s) => s.action === "scene").length} scenes`);
396
+ for (const w of warnings(manifest))
397
+ say(`⚠ ${w}`);
398
+ });
399
+ program
400
+ .command("describe")
401
+ .description("scout a URL and let a model draft the manifest")
402
+ .argument("<name>", "kebab-case demo name")
403
+ .argument("<url>")
404
+ .argument("<what>", "one sentence: what to record")
405
+ .option("-P, --project <dir>", "also read the app's source: routes, sign-in, real selectors, flaky bits")
406
+ .action(async (name, url, what, opts) => {
407
+ const { draftManifest, loadDotenv, pickProvider, scout } = await import("./describe.js");
408
+ loadDotenv(process.cwd());
409
+ const provider = pickProvider();
410
+ if (!provider)
411
+ throw new Error("no model configured — set GROQ_API_KEY, MISTRAL_API_KEY, or RETAKE_LOCAL_URL");
412
+ say(`scouting ${url} …`);
413
+ const sc = await scout(url);
414
+ if (opts.project) {
415
+ const { digest } = await import("./digest.js");
416
+ const dg = digest(opts.project);
417
+ say(`read ${dg.files} files in ${dg.name}: ${dg.routes.length} routes, ${dg.selectors.length} selectors, ${dg.flaky.length} risks`);
418
+ }
419
+ say(`found ${sc.elements.length} interactive elements · drafting with ${provider.name} (${provider.model}) …`);
420
+ const d = await draftManifest({ name, url, describe: what, scout: sc, provider, project: opts.project, demosDir: path.join(process.cwd(), "demos") });
421
+ const file = path.resolve("demos", `${name}.yaml`);
422
+ fs.mkdirSync(path.dirname(file), { recursive: true });
423
+ fs.writeFileSync(file, d.yaml);
424
+ say(`✓ ${path.relative(process.cwd(), file)}${d.retried ? " (after one retry)" : ""} — read it, then: retake run ${path.relative(process.cwd(), file)}`);
425
+ });
426
+ program
427
+ .command("ui")
428
+ .option("-p, --port <port>", "port", "4310")
429
+ .action(async (opts) => {
430
+ const { serve } = await import("./ui/server.js");
431
+ serve(Number(opts.port));
432
+ });
433
+ program.command("list").action(() => {
434
+ const dir = path.resolve("demos");
435
+ for (const f of fs.readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f))) {
436
+ try {
437
+ const { manifest } = loadManifest(path.join(dir, f));
438
+ say(`${f.padEnd(28)} ${manifest.title ?? ""}`);
439
+ }
440
+ catch (e) {
441
+ say(`${f.padEnd(28)} ✗ ${e.message.split("\n")[0]}`);
442
+ }
443
+ }
444
+ });
445
+ function guessManifest(name) {
446
+ for (const ext of ["yaml", "yml", "json"]) {
447
+ const p = path.resolve("demos", `${name}.${ext}`);
448
+ if (fs.existsSync(p))
449
+ return p;
450
+ }
451
+ throw new Error(`no demos/${name}.yaml found — pass the manifest path`);
452
+ }
453
+ /** Drop playwright's raw .webm and testreel debug files. The raw take mp4
454
+ (take.video) stays — `retake render` re-renders from it. */
455
+ function cleanRaw(outDir, take, finalMp4) {
456
+ for (const f of fs.readdirSync(outDir)) {
457
+ const p = path.join(outDir, f);
458
+ if (p === finalMp4 || (take.video && path.resolve(take.video) === p))
459
+ continue;
460
+ if (/cursor.*\.json$/.test(f) || /^debug-filter\.txt$/.test(f) || /-final\.png$/.test(f))
461
+ fs.rmSync(p, { force: true });
462
+ }
463
+ }
464
+ program.parseAsync(process.argv).catch((e) => {
465
+ process.stderr.write(`✗ ${e.message}\n`);
466
+ process.exit(1);
467
+ });