@qwertybit/pr-preview 0.1.3 → 0.1.4

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/index.js CHANGED
@@ -6,12 +6,13 @@ import { Command } from "commander";
6
6
 
7
7
  // src/cli/commands/init.ts
8
8
  import { existsSync } from "fs";
9
- import { readFile, writeFile, appendFile } from "fs/promises";
9
+ import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
10
10
  import path from "path";
11
11
 
12
12
  // src/cli/ui/logger.ts
13
13
  import pc from "picocolors";
14
14
  import ora from "ora";
15
+ import { createInterface } from "readline/promises";
15
16
  var log = {
16
17
  info(msg) {
17
18
  console.log(`${pc.cyan("\u25CF")} ${msg}`);
@@ -30,6 +31,20 @@ var log = {
30
31
  },
31
32
  spinner(text) {
32
33
  return ora({ text, color: "cyan" }).start();
34
+ },
35
+ /**
36
+ * Ask a yes/no question. Defaults to "no". In a non-interactive shell
37
+ * (no TTY, e.g. CI) it resolves to `false` without blocking.
38
+ */
39
+ async confirm(question) {
40
+ if (!process.stdin.isTTY) return false;
41
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
42
+ try {
43
+ const answer = await rl.question(`${pc.yellow("?")} ${question} ${pc.dim("(y/N)")} `);
44
+ return /^y(es)?$/i.test(answer.trim());
45
+ } finally {
46
+ rl.close();
47
+ }
33
48
  }
34
49
  };
35
50
 
@@ -77,10 +92,105 @@ var GITIGNORE_BLOCK = `
77
92
  # pr-preview (worktrees, recordings, output)
78
93
  .pr-preview/
79
94
  `;
95
+ async function writeMcpConfig(root) {
96
+ const mcpPath = path.join(root, ".mcp.json");
97
+ const entry = { command: "npx", args: ["pr-preview", "mcp"] };
98
+ let doc = {};
99
+ if (existsSync(mcpPath)) {
100
+ try {
101
+ doc = JSON.parse(await readFile(mcpPath, "utf8"));
102
+ } catch {
103
+ log.warn(".mcp.json exists but isn't valid JSON \u2014 leaving it untouched.");
104
+ return;
105
+ }
106
+ }
107
+ doc.mcpServers ??= {};
108
+ if (doc.mcpServers["pr-preview"]) {
109
+ const overwrite = await log.confirm(
110
+ ".mcp.json already has a pr-preview server. Overwrite it?"
111
+ );
112
+ if (!overwrite) {
113
+ log.warn("Left the existing pr-preview MCP server untouched.");
114
+ return;
115
+ }
116
+ }
117
+ doc.mcpServers["pr-preview"] = entry;
118
+ await writeFile(mcpPath, JSON.stringify(doc, null, 2) + "\n");
119
+ log.success(`${existsSync(mcpPath) ? "Updated" : "Created"} .mcp.json (pr-preview MCP server for Claude Code)`);
120
+ }
121
+ var RECORD_SKILL = `---
122
+ name: record
123
+ description: Record a video of a user journey through a web app, automatically, using PR Preview (@qwertybit/pr-preview). Claude drives the app itself and produces an MP4 \u2014 no human clicking. Usage: /record [url] [journey in plain English]. Use when the user wants to record, film, or capture a walkthrough or before/after video of an app for a pull request or demo.
124
+ ---
125
+
126
+ # /record \u2014 record an app journey automatically with PR Preview
127
+
128
+ You record a real video walkthrough of a web app by **driving it yourself** through the
129
+ PR Preview MCP server \u2014 not by asking the user to click. PR Preview opens its harness (a
130
+ visible Chrome window with the app in an iframe and a step sidebar); you perform the
131
+ journey through its tools and it captures a clean, PR-ready MP4.
132
+
133
+ ## Arguments
134
+ \`/record [url] [journey]\`
135
+ - **url** (optional): the app to record, e.g. \`http://localhost:3000\` or \`https://staging.example.com\`. The first URL-looking token is the URL; the rest is the journey.
136
+ - **journey** (optional): what to do, in plain English (e.g. "add 3 books to the cart, then go to checkout").
137
+
138
+ ## Preconditions
139
+ The PR Preview MCP tools must be connected: \`start_recording\`, \`snapshot\`, \`act\`,
140
+ \`next_pass\`, \`finish_recording\`, \`open_pr\`, \`detect_localhost\`.
141
+ - If they are NOT available, tell the user to install and connect it:
142
+ \`npm i -D @qwertybit/pr-preview\` \u2192 \`npx pr-preview init\` (writes \`.mcp.json\`) \u2192 reload Claude Code.
143
+ Stop until it's connected.
144
+
145
+ ## Steps
146
+ 1. **Resolve the URL.** If a URL was given, use it. Otherwise call \`detect_localhost\`; if apps
147
+ are running, ask the user which to record; if none are, ask for a local, staging, or
148
+ production URL. **Never guess.**
149
+ 2. **Start.** Call \`start_recording\` with \`{ url }\`. A Chrome window opens with the harness \u2014
150
+ the app runs in the iframe and the sidebar records each step. Do NOT click "Start recording";
151
+ \`start_recording\` already began it. (For a base-vs-branch PR comparison, use
152
+ \`{ mode: "before-after" }\` with a managed dev server instead.)
153
+ 3. **Drive the journey.** Read the returned accessibility snapshot. For each step in the journey,
154
+ call \`act\` (\`click\` / \`fill\` / \`press\` / \`hover\` / \`navigate\` / \`scroll\`), targeting elements
155
+ by their \`[ref=\u2026]\` handle. Take a fresh snapshot after any step that changes the page. Perform
156
+ the journey faithfully \u2014 **you drive it; never ask the user to click.**
157
+ 4. **Finish.** Call \`finish_recording\` and report the output MP4 path.
158
+ 5. **PR (optional).** If the user asked, call \`open_pr\` with the produced file(s).
159
+
160
+ ## Important
161
+ - This is agent-driven but a REAL capture of the real app \u2014 nothing is synthesized.
162
+ - Do NOT use the \`pr-preview run\` CLI for this \u2014 that is the manual, human-in-the-loop flow that
163
+ waits for a person to click. Always drive via the MCP tools above so recording is automatic.
164
+ `;
165
+ async function writeRecordSkill(root) {
166
+ const skillDir = path.join(root, ".claude", "skills", "record");
167
+ const skillPath = path.join(skillDir, "SKILL.md");
168
+ if (existsSync(skillPath)) {
169
+ const overwrite = await log.confirm(
170
+ ".claude/skills/record/SKILL.md already exists. Overwrite it?"
171
+ );
172
+ if (!overwrite) {
173
+ log.warn("Left the existing /record skill untouched.");
174
+ return;
175
+ }
176
+ }
177
+ await mkdir(skillDir, { recursive: true });
178
+ await writeFile(skillPath, RECORD_SKILL);
179
+ log.success("Installed the /record slash command (.claude/skills/record/SKILL.md)");
180
+ }
80
181
  async function initCommand(root) {
81
182
  const configPath = path.join(root, "pr-preview.config.js");
82
- if (existsSync(configPath) || existsSync(path.join(root, "pr-preview.config.ts")) || existsSync(path.join(root, "pr-preview.config.json"))) {
83
- log.warn("A pr-preview config already exists \u2014 leaving it untouched.");
183
+ const existingConfig = [".js", ".ts", ".json"].map((ext) => path.join(root, `pr-preview.config${ext}`)).find(existsSync);
184
+ if (existingConfig) {
185
+ const overwrite = await log.confirm(
186
+ `A pr-preview config already exists (${path.basename(existingConfig)}). Overwrite it?`
187
+ );
188
+ if (overwrite) {
189
+ await writeFile(existingConfig, CONFIG_TEMPLATE);
190
+ log.success(`Overwrote ${path.basename(existingConfig)}`);
191
+ } else {
192
+ log.warn("Left the existing pr-preview config untouched.");
193
+ }
84
194
  } else {
85
195
  await writeFile(configPath, CONFIG_TEMPLATE);
86
196
  log.success(`Created ${path.basename(configPath)}`);
@@ -91,12 +201,22 @@ async function initCommand(root) {
91
201
  await appendFile(gitignorePath, GITIGNORE_BLOCK);
92
202
  log.success("Added .pr-preview/ to .gitignore");
93
203
  }
204
+ await writeMcpConfig(root);
205
+ await writeRecordSkill(root);
94
206
  log.info("Edit the config to match your dev command, then run `pr-preview run` on a PR branch.");
207
+ log.info("Or ask Claude Code to record a flow for you (e.g. \u201Crecord my add-to-cart flow\u201D),");
208
+ log.info("or run the /record slash command: `/record localhost:3000 add a book, then checkout`.");
95
209
  }
96
210
 
97
- // src/cli/commands/record.ts
98
- import path11 from "path";
99
- import pc2 from "picocolors";
211
+ // src/mcp/server.ts
212
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
213
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
214
+ import { z as z2 } from "zod";
215
+ import path14 from "path";
216
+
217
+ // src/mcp/recorder.ts
218
+ import { existsSync as existsSync8 } from "fs";
219
+ import path12 from "path";
100
220
 
101
221
  // src/config/load.ts
102
222
  import { existsSync as existsSync2 } from "fs";
@@ -147,7 +267,22 @@ var configSchema = z.object({
147
267
  // 24fps keeps the synthetic cursor's motion fluid in the clip.
148
268
  fps: z.number().positive().max(60).default(24),
149
269
  quality: z.enum(["high", "max"]).default("high"),
150
- maxColors: z.number().int().min(2).max(256).default(256)
270
+ maxColors: z.number().int().min(2).max(256).default(256),
271
+ /**
272
+ * Motion smoothing for the MP4. The screencast delivers a low, variable
273
+ * frame rate, so the raw capture can look choppy. Interpolation synthesises
274
+ * intermediate frames up to `smoothFps` from the real captured frames:
275
+ * - "blend" (default): cross-dissolves frames — natural motion-blur, never
276
+ * warps text/geometry. Safe for any site.
277
+ * - "mci": motion-compensated — sharper moving cursor, but can warp fast-
278
+ * scrolling dense text on some pages.
279
+ * - "off": no interpolation (raw capture rate).
280
+ */
281
+ interpolate: z.enum(["blend", "mci", "off"]).default("blend"),
282
+ /** Target fps for interpolation (only used when interpolate ≠ "off").
283
+ * 60 = display-refresh smoothness; interpolation fills every gap between
284
+ * the real captured frames up to this rate. */
285
+ smoothFps: z.number().int().positive().max(120).default(60)
151
286
  }).default({}),
152
287
  /**
153
288
  * Start-of-pass reset choice default: true (default) offers "reset" (clear
@@ -425,8 +560,8 @@ function harnessDistDir() {
425
560
  async function startHarnessServer(opts) {
426
561
  const dist = harnessDistDir();
427
562
  let appUrl = opts.appUrl;
428
- const server = http.createServer(async (req, res) => {
429
- const urlPath = (req.url ?? "/").split("?")[0];
563
+ const server = http.createServer(async (req2, res) => {
564
+ const urlPath = (req2.url ?? "/").split("?")[0];
430
565
  if (urlPath === "/runtime.json") {
431
566
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
432
567
  res.end(JSON.stringify({ appUrl, mode: opts.mode, viewport: opts.viewport }));
@@ -544,10 +679,10 @@ async function playwrightDisabledFeatures() {
544
679
  if (cachedBaseDisabledFeatures !== null) return cachedBaseDisabledFeatures;
545
680
  cachedBaseDisabledFeatures = "";
546
681
  try {
547
- const probe = await chromium.launchServer({ headless: true });
548
- const arg = probe.process().spawnargs.find((a) => a.startsWith("--disable-features="));
682
+ const probe2 = await chromium.launchServer({ headless: true });
683
+ const arg = probe2.process().spawnargs.find((a) => a.startsWith("--disable-features="));
549
684
  cachedBaseDisabledFeatures = arg ? arg.slice("--disable-features=".length) : "";
550
- await probe.close();
685
+ await probe2.close();
551
686
  } catch {
552
687
  }
553
688
  return cachedBaseDisabledFeatures;
@@ -852,7 +987,9 @@ async function startScreencast(page, opts = {}) {
852
987
  await cdp.send("Page.startScreencast", {
853
988
  format: "jpeg",
854
989
  quality: opts.quality ?? 90,
855
- everyNthFrame: opts.everyNthFrame ?? 1
990
+ everyNthFrame: opts.everyNthFrame ?? 1,
991
+ ...opts.maxWidth ? { maxWidth: opts.maxWidth } : {},
992
+ ...opts.maxHeight ? { maxHeight: opts.maxHeight } : {}
856
993
  });
857
994
  return {
858
995
  frameCount() {
@@ -918,14 +1055,14 @@ function resampleToFps(frames, fps) {
918
1055
  }
919
1056
 
920
1057
  // src/encode/gif.ts
921
- import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1058
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
922
1059
  import path8 from "path";
923
1060
  import sharp2 from "sharp";
924
1061
  import * as gifencNs from "gifenc";
925
1062
 
926
1063
  // src/encode/ffmpeg.ts
927
1064
  import { execFile, spawn as spawn2 } from "child_process";
928
- import { mkdtemp, rm, writeFile as writeFile2, mkdir } from "fs/promises";
1065
+ import { mkdtemp, rm, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
929
1066
  import { tmpdir } from "os";
930
1067
  import path6 from "path";
931
1068
  var available = null;
@@ -953,14 +1090,14 @@ async function withFrameDir(pngFrames, fn) {
953
1090
  }
954
1091
  async function encodeWithFfmpeg(pngFrames, fps, outFile) {
955
1092
  await withFrameDir(pngFrames, async (pattern) => {
956
- await mkdir(path6.dirname(outFile), { recursive: true });
1093
+ await mkdir2(path6.dirname(outFile), { recursive: true });
957
1094
  const filter = `[0:v]fps=${fps},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
958
1095
  await run("ffmpeg", ["-y", "-framerate", String(fps), "-i", pattern, "-filter_complex", filter, outFile]);
959
1096
  });
960
1097
  }
961
1098
  async function encodeMp4(pngFrames, fps, outFile) {
962
1099
  await withFrameDir(pngFrames, async (pattern) => {
963
- await mkdir(path6.dirname(outFile), { recursive: true });
1100
+ await mkdir2(path6.dirname(outFile), { recursive: true });
964
1101
  await run("ffmpeg", [
965
1102
  "-y",
966
1103
  "-framerate",
@@ -983,6 +1120,39 @@ async function encodeMp4(pngFrames, fps, outFile) {
983
1120
  ]);
984
1121
  });
985
1122
  }
1123
+ async function encodeMp4Interpolated(pngFrames, durationsSec, outFps, mode, outFile) {
1124
+ const dir = await mkdtemp(path6.join(tmpdir(), "pr-preview-frames-"));
1125
+ try {
1126
+ await Promise.all(
1127
+ pngFrames.map(
1128
+ (buf, i) => writeFile2(path6.join(dir, `frame${String(i).padStart(5, "0")}.png`), buf)
1129
+ )
1130
+ );
1131
+ const lines = [];
1132
+ pngFrames.forEach((_, i) => {
1133
+ lines.push(`file 'frame${String(i).padStart(5, "0")}.png'`);
1134
+ lines.push(`duration ${Math.max(1e-3, durationsSec[i] ?? 1 / outFps).toFixed(4)}`);
1135
+ });
1136
+ lines.push(`file 'frame${String(pngFrames.length - 1).padStart(5, "0")}.png'`);
1137
+ await writeFile2(path6.join(dir, "list.txt"), lines.join("\n") + "\n");
1138
+ await mkdir2(path6.dirname(outFile), { recursive: true });
1139
+ const interp = mode === "mci" ? `minterpolate=fps=${outFps}:mi_mode=mci:me_mode=bidir:mc_mode=aobmc:vsbmc=1:scd=fdiff` : `minterpolate=fps=${outFps}:mi_mode=blend:scd=fdiff`;
1140
+ const common = ["-c:v", "libx264", "-preset", "slow", "-crf", "19", "-pix_fmt", "yuv420p", "-movflags", "+faststart", outFile];
1141
+ const input = ["-f", "concat", "-safe", "0", "-i", path6.join(dir, "list.txt")];
1142
+ const scale = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
1143
+ try {
1144
+ await run("ffmpeg", ["-y", ...input, "-vf", `${interp},${scale}`, "-r", String(outFps), ...common]);
1145
+ } catch (err) {
1146
+ console.warn(
1147
+ `[pr-preview] motion smoothing (${mode}) failed; encoding without interpolation \u2014 the clip will look choppier.
1148
+ ${err.message?.split("\n")[0] ?? err}`
1149
+ );
1150
+ await run("ffmpeg", ["-y", ...input, "-vf", scale, "-r", String(outFps), ...common]);
1151
+ }
1152
+ } finally {
1153
+ await rm(dir, { recursive: true, force: true });
1154
+ }
1155
+ }
986
1156
  function run(cmd, args) {
987
1157
  return new Promise((resolve, reject) => {
988
1158
  const child = spawn2(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
@@ -1207,7 +1377,7 @@ async function encodeGif(rawFrames, opts) {
1207
1377
  opts.onProgress?.("Encoding GIF", ++done, frames.length);
1208
1378
  }
1209
1379
  gif.finish();
1210
- await mkdir2(path8.dirname(opts.outFile), { recursive: true });
1380
+ await mkdir3(path8.dirname(opts.outFile), { recursive: true });
1211
1381
  await writeFile3(opts.outFile, gif.bytes());
1212
1382
  return { path: opts.outFile, frameCount: frames.length };
1213
1383
  }
@@ -1222,7 +1392,9 @@ async function encodeMedia(rawFrames, opts) {
1222
1392
  const paths = [];
1223
1393
  let frameCount = 0;
1224
1394
  if (doMp4) {
1225
- const frames = resampleToFps(rawFrames, opts.fps);
1395
+ const interpolate = opts.interpolate ?? "off";
1396
+ const smooth = interpolate !== "off";
1397
+ const frames = smooth ? rawFrames : resampleToFps(rawFrames, opts.fps);
1226
1398
  frameCount = frames.length;
1227
1399
  const meta = await sharp3(frames[0].data).metadata();
1228
1400
  const crop = cssRectToFramePixels(
@@ -1248,7 +1420,16 @@ async function encodeMedia(rawFrames, opts) {
1248
1420
  }
1249
1421
  const file = `${opts.outBase}.mp4`;
1250
1422
  opts.onProgress?.("Encoding MP4", frames.length, frames.length);
1251
- await encodeMp4(pngs, opts.fps, file);
1423
+ if (smooth) {
1424
+ const MAX_HOLD = 0.5;
1425
+ const durations = frames.map(
1426
+ (f, i) => i < frames.length - 1 ? Math.min(MAX_HOLD, Math.max(1e-3, (frames[i + 1].t - f.t) / 1e3)) : 1 / opts.fps
1427
+ );
1428
+ const outFps = Math.max(opts.smoothFps ?? 30, opts.fps);
1429
+ await encodeMp4Interpolated(pngs, durations, outFps, interpolate, file);
1430
+ } else {
1431
+ await encodeMp4(pngs, opts.fps, file);
1432
+ }
1252
1433
  paths.push(file);
1253
1434
  }
1254
1435
  if (doGif) {
@@ -1513,8 +1694,12 @@ var Session = class {
1513
1694
  }
1514
1695
  const page = this.page;
1515
1696
  if (!page) return;
1697
+ const { maxWidth, maxHeight } = await page.evaluate(() => ({
1698
+ maxWidth: Math.min(window.innerWidth, 1920),
1699
+ maxHeight: Math.min(window.innerHeight, 1200)
1700
+ })).catch(() => ({ maxWidth: 1920, maxHeight: 1200 }));
1516
1701
  try {
1517
- this.recordingFeed = await startScreencast(page, { quality: 80 });
1702
+ this.recordingFeed = await startScreencast(page, { quality: 80, maxWidth, maxHeight });
1518
1703
  } catch {
1519
1704
  this.recordingFeed = null;
1520
1705
  }
@@ -1586,6 +1771,8 @@ var Session = class {
1586
1771
  fps: this.config.gif.fps,
1587
1772
  quality: this.config.gif.quality,
1588
1773
  maxColors: this.config.gif.maxColors,
1774
+ interpolate: this.config.gif.interpolate,
1775
+ smoothFps: this.config.gif.smoothFps,
1589
1776
  format: this.config.format,
1590
1777
  outBase,
1591
1778
  label: label ? { pass: this.singleClip ? "single" : which, ...label } : void 0,
@@ -1732,82 +1919,59 @@ async function bootstrap(repoRoot, config, mode, opts = {}) {
1732
1919
  };
1733
1920
  }
1734
1921
 
1735
- // src/recorder/journey.ts
1736
- import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
1737
- import path10 from "path";
1738
- import { z as z2 } from "zod";
1739
- var stepSchema = z2.object({
1740
- id: z2.string(),
1741
- type: z2.enum(["click", "fill", "select", "press", "scroll", "navigate", "wait"]),
1742
- selectors: z2.array(z2.string()).optional(),
1743
- coordinates: z2.object({ xNorm: z2.number(), yNorm: z2.number() }).optional(),
1744
- frameUrl: z2.string().optional(),
1745
- value: z2.string().optional(),
1746
- key: z2.string().optional(),
1747
- scrollTarget: z2.string().optional(),
1748
- scroll: z2.object({ xNorm: z2.number(), yNorm: z2.number() }).optional(),
1749
- url: z2.string().optional(),
1750
- causesNavigation: z2.boolean().optional(),
1751
- label: z2.string().optional(),
1752
- timestamp: z2.number(),
1753
- thumbnail: z2.string().optional()
1754
- });
1755
- var journeySchema = z2.object({
1756
- version: z2.literal(1),
1757
- createdAt: z2.string(),
1758
- baseRef: z2.string().optional(),
1759
- viewport: z2.object({
1760
- width: z2.number(),
1761
- height: z2.number(),
1762
- deviceScaleFactor: z2.number()
1763
- }),
1764
- startUrl: z2.string(),
1765
- steps: z2.array(stepSchema)
1766
- });
1767
- async function saveJourney(file, journey) {
1768
- const clean = {
1769
- ...journey,
1770
- steps: journey.steps.map((s) => {
1771
- const { thumbnail: _thumbnail, ...rest } = s;
1772
- return rest;
1773
- })
1774
- };
1775
- await mkdir3(path10.dirname(file), { recursive: true });
1776
- await writeFile4(file, JSON.stringify(clean, null, 2) + "\n");
1922
+ // src/mcp/motion.ts
1923
+ var MOTION = {
1924
+ glideMs: 750,
1925
+ // ceiling; the cursor scales the actual glide by distance
1926
+ scrollMs: 600,
1927
+ typeDelayMs: 35,
1928
+ settleMs: 140,
1929
+ holdMs: 80
1930
+ // brief pause after a glide lands, before the click, so the
1931
+ // arrival + click-pulse frames are captured
1932
+ };
1933
+ async function glideCursor(frame, x, y, ms = MOTION.glideMs) {
1934
+ await frame.evaluate(
1935
+ ({ x: x2, y: y2, ms: ms2 }) => window.__prPreviewCursor?.moveTo(x2, y2, ms2) ?? Promise.resolve(),
1936
+ { x, y, ms }
1937
+ ).catch(() => {
1938
+ });
1777
1939
  }
1778
-
1779
- // src/cli/commands/record.ts
1780
- async function recordCommand(repoRoot, opts) {
1781
- const { config } = await loadConfig(repoRoot);
1782
- const journeyFile = path11.resolve(repoRoot, opts.out ?? ".pr-preview/journey.json");
1783
- try {
1784
- const boot = await bootstrap(repoRoot, config, "record");
1785
- await boot.startApp("after", repoRoot);
1786
- await boot.openBrowser("after");
1787
- log.info(`Harness open at ${pc2.bold(boot.harness.url)}`);
1788
- log.step("Click Record in the sidebar, perform the journey, then Confirm.");
1789
- const steps = await boot.session.recordUntilConfirmed();
1790
- if (steps.length === 0) {
1791
- log.warn("No steps recorded \u2014 nothing saved.");
1792
- return;
1793
- }
1794
- await saveJourney(journeyFile, {
1795
- version: 1,
1796
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1797
- viewport: { ...config.viewport, deviceScaleFactor: 2 },
1798
- startUrl: boot.session.startUrl,
1799
- steps
1800
- });
1801
- log.success(`Saved ${steps.length} steps to ${path11.relative(repoRoot, journeyFile)}`);
1802
- } finally {
1803
- await runCleanups();
1804
- }
1940
+ async function showCursor(frame, x, y) {
1941
+ await frame.evaluate(
1942
+ ({ x: x2, y: y2 }) => {
1943
+ const c = window.__prPreviewCursor;
1944
+ if (!c) return;
1945
+ c.show(x2 ?? (window.innerWidth || 1280) / 2, y2 ?? (window.innerHeight || 800) / 2);
1946
+ },
1947
+ { x: x ?? null, y: y ?? null }
1948
+ ).catch(() => {
1949
+ });
1950
+ }
1951
+ async function pulseCursor(frame) {
1952
+ await frame.evaluate(
1953
+ () => window.__prPreviewCursor?.clickPulse() ?? Promise.resolve()
1954
+ ).catch(() => {
1955
+ });
1956
+ }
1957
+ async function smoothScrollBy(frame, deltaY, ms = MOTION.scrollMs) {
1958
+ await frame.evaluate(
1959
+ ({ deltaY: deltaY2, ms: ms2 }) => new Promise((resolve) => {
1960
+ const startY = window.scrollY;
1961
+ const t0 = performance.now();
1962
+ const ease = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
1963
+ const tick = (now) => {
1964
+ const t = Math.min(1, (now - t0) / ms2);
1965
+ window.scrollTo(0, startY + deltaY2 * ease(t));
1966
+ if (t < 1) requestAnimationFrame(tick);
1967
+ else resolve();
1968
+ };
1969
+ requestAnimationFrame(tick);
1970
+ }),
1971
+ { deltaY, ms }
1972
+ ).catch(() => {
1973
+ });
1805
1974
  }
1806
-
1807
- // src/cli/commands/run.ts
1808
- import { existsSync as existsSync8 } from "fs";
1809
- import path14 from "path";
1810
- import pc3 from "picocolors";
1811
1975
 
1812
1976
  // src/git/exec.ts
1813
1977
  import { execFile as execFile2 } from "child_process";
@@ -1886,7 +2050,7 @@ async function detectBase(repoRoot, override) {
1886
2050
  // src/git/worktree.ts
1887
2051
  import { existsSync as existsSync6 } from "fs";
1888
2052
  import { rm as rm2 } from "fs/promises";
1889
- import path12 from "path";
2053
+ import path10 from "path";
1890
2054
  var WORKTREE_DIR = ".pr-preview/worktrees";
1891
2055
  function explainGitCrash(err, repoRoot) {
1892
2056
  const msg = err instanceof Error ? err.message : String(err);
@@ -1903,7 +2067,7 @@ Original error: ${msg}`
1903
2067
  throw err;
1904
2068
  }
1905
2069
  async function createBaseWorktree(repoRoot, sha) {
1906
- const dir = path12.join(repoRoot, WORKTREE_DIR, `base-${sha.slice(0, 12)}`);
2070
+ const dir = path10.join(repoRoot, WORKTREE_DIR, `base-${sha.slice(0, 12)}`);
1907
2071
  if (existsSync6(dir) && await tryGit(dir, ["rev-parse", "HEAD"]) === sha) {
1908
2072
  return { dir, remove: () => removeWorktree(repoRoot, dir) };
1909
2073
  }
@@ -1929,12 +2093,12 @@ async function pruneStaleWorktrees(repoRoot) {
1929
2093
 
1930
2094
  // src/server/pkgManager.ts
1931
2095
  import { existsSync as existsSync7 } from "fs";
1932
- import path13 from "path";
2096
+ import path11 from "path";
1933
2097
  import { spawn as spawn3 } from "child_process";
1934
2098
  function detectPackageManager(dir) {
1935
- if (existsSync7(path13.join(dir, "pnpm-lock.yaml"))) return "pnpm";
1936
- if (existsSync7(path13.join(dir, "yarn.lock"))) return "yarn";
1937
- if (existsSync7(path13.join(dir, "bun.lockb")) || existsSync7(path13.join(dir, "bun.lock"))) {
2099
+ if (existsSync7(path11.join(dir, "pnpm-lock.yaml"))) return "pnpm";
2100
+ if (existsSync7(path11.join(dir, "yarn.lock"))) return "yarn";
2101
+ if (existsSync7(path11.join(dir, "bun.lockb")) || existsSync7(path11.join(dir, "bun.lock"))) {
1938
2102
  return "bun";
1939
2103
  }
1940
2104
  return "npm";
@@ -1961,6 +2125,737 @@ ${tail}`));
1961
2125
  });
1962
2126
  }
1963
2127
 
2128
+ // src/mcp/recorder.ts
2129
+ function stamp() {
2130
+ const d = /* @__PURE__ */ new Date();
2131
+ const p = (n) => String(n).padStart(2, "0");
2132
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
2133
+ }
2134
+ function fileStamp() {
2135
+ const d = /* @__PURE__ */ new Date();
2136
+ const p = (n) => String(n).padStart(2, "0");
2137
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
2138
+ }
2139
+ var shortRef = (ref) => ref.replace(/^origin\//, "");
2140
+ var slug = (s) => shortRef(s).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "clip";
2141
+ var PreviewRecorder = class {
2142
+ constructor(repoRoot) {
2143
+ this.repoRoot = repoRoot;
2144
+ }
2145
+ repoRoot;
2146
+ boot = null;
2147
+ page = null;
2148
+ config = null;
2149
+ outDir = ".pr-preview/output";
2150
+ mode = "single";
2151
+ pass = "before";
2152
+ started = false;
2153
+ // before/after bookkeeping
2154
+ beforeServer = null;
2155
+ worktree = null;
2156
+ baseLabel = "";
2157
+ headLabel = "";
2158
+ fstamp = "";
2159
+ timestamp = "";
2160
+ beforePaths = [];
2161
+ fellBackToGif = false;
2162
+ get isActive() {
2163
+ return this.started;
2164
+ }
2165
+ /** Launch the browser + app and begin recording. Returns the first snapshot. */
2166
+ async start(opts = {}) {
2167
+ if (this.started) throw new Error("A recording is already in progress \u2014 call finish_recording first.");
2168
+ this.mode = opts.mode ?? "single";
2169
+ if (this.mode === "before-after") return this.startBeforeAfter();
2170
+ return this.startSingle(opts.url);
2171
+ }
2172
+ async startSingle(url) {
2173
+ let config;
2174
+ try {
2175
+ config = (await loadConfig(this.repoRoot)).config;
2176
+ } catch (err) {
2177
+ if (url && err instanceof ConfigError) config = configSchema.parse({ devCommand: "external", url });
2178
+ else throw err;
2179
+ }
2180
+ this.config = config;
2181
+ this.outDir = path12.resolve(this.repoRoot, config.output);
2182
+ this.headLabel = await this.currentBranch();
2183
+ this.timestamp = stamp();
2184
+ this.fstamp = fileStamp();
2185
+ const boot = url ? await bootstrap(this.repoRoot, config, "run", { fixedUrl: url }) : await bootstrap(this.repoRoot, config, "run");
2186
+ this.boot = boot;
2187
+ boot.session.setPasses(1);
2188
+ if (!url) await boot.startApp("before", this.repoRoot);
2189
+ const browser = await boot.openBrowser("before");
2190
+ this.page = browser.page;
2191
+ await this.beginRecording();
2192
+ this.started = true;
2193
+ return { snapshot: await this.snapshot(), startUrl: boot.session.startUrl, mode: this.mode };
2194
+ }
2195
+ async startBeforeAfter() {
2196
+ const config = (await loadConfig(this.repoRoot)).config;
2197
+ this.config = config;
2198
+ this.outDir = path12.resolve(this.repoRoot, config.output);
2199
+ const currentBranch = await tryGit(this.repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
2200
+ if (!currentBranch) {
2201
+ throw new GitError(
2202
+ "before/after recording needs you to be on a branch (detached HEAD).\nCheck out your PR branch first, or use single mode."
2203
+ );
2204
+ }
2205
+ const base = await detectBase(this.repoRoot, config.baseBranch);
2206
+ this.baseLabel = shortRef(base.ref);
2207
+ this.headLabel = shortRef(currentBranch);
2208
+ this.timestamp = stamp();
2209
+ this.fstamp = fileStamp();
2210
+ const worktree = await createBaseWorktree(this.repoRoot, base.sha);
2211
+ this.worktree = worktree;
2212
+ if (!config.keepWorktree) registerCleanup(() => worktree.remove());
2213
+ if (config.install !== "never") {
2214
+ const appDir = path12.join(worktree.dir, config.cwd);
2215
+ const hasModules = existsSync8(path12.join(appDir, "node_modules"));
2216
+ if (config.install === "always" || !hasModules) {
2217
+ await installDependencies(appDir, detectPackageManager(appDir));
2218
+ }
2219
+ }
2220
+ const boot = await bootstrap(this.repoRoot, config, "run");
2221
+ this.boot = boot;
2222
+ boot.session.setPasses(2);
2223
+ boot.session.setBranches(base.ref, currentBranch);
2224
+ this.beforeServer = await boot.startApp("before", worktree.dir);
2225
+ const browser = await boot.openBrowser("before");
2226
+ this.page = browser.page;
2227
+ this.pass = "before";
2228
+ await this.beginRecording();
2229
+ this.started = true;
2230
+ return { snapshot: await this.snapshot(), startUrl: boot.session.startUrl, mode: this.mode };
2231
+ }
2232
+ /** before/after only: finish the BEFORE clip, switch to the branch app, start AFTER. */
2233
+ async nextPass() {
2234
+ if (!this.started) throw new Error("No active recording \u2014 call start_recording first.");
2235
+ if (this.mode !== "before-after") throw new Error("next_pass is only valid in before-after mode.");
2236
+ if (this.pass !== "before") throw new Error("Already recording the AFTER pass.");
2237
+ const boot = this.boot;
2238
+ boot.session.setPhase("idle");
2239
+ const before = await boot.session.encodeClip("before", this.outPath("before", this.baseLabel), {
2240
+ branch: this.baseLabel,
2241
+ baseBranch: this.baseLabel,
2242
+ timestamp: this.timestamp
2243
+ });
2244
+ this.beforePaths = before.paths;
2245
+ this.fellBackToGif ||= before.fellBackToGif;
2246
+ await this.beforeServer?.stop();
2247
+ await boot.startApp("after", this.repoRoot);
2248
+ boot.session.switchPass("after");
2249
+ await boot.switchApp("after");
2250
+ this.pass = "after";
2251
+ await this.beginRecording();
2252
+ return { snapshot: await this.snapshot() };
2253
+ }
2254
+ /** Accessibility tree of the app (with [ref=eN] handles) for Claude to target. */
2255
+ async snapshot() {
2256
+ const frame = await this.frame();
2257
+ await frame.waitForLoadState("domcontentloaded").catch(() => {
2258
+ });
2259
+ return frame.locator("body").ariaSnapshot({ mode: "ai" });
2260
+ }
2261
+ /** Perform one action in the app, then return the fresh snapshot. Motions are
2262
+ * animated in-page (synthetic-cursor glide, rAF scroll, per-character typing)
2263
+ * so the live screencast captures continuous video, not a jumpy slideshow. */
2264
+ async act(a) {
2265
+ if (!this.started) throw new Error("No active recording \u2014 call start_recording first.");
2266
+ const frame = await this.frame();
2267
+ switch (a.action) {
2268
+ case "click": {
2269
+ const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
2270
+ await this.glideToLocator(loc);
2271
+ await pulseCursor(frame);
2272
+ await loc.click({ timeout: 15e3 });
2273
+ break;
2274
+ }
2275
+ case "fill": {
2276
+ const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
2277
+ await this.glideToLocator(loc);
2278
+ await loc.click({ timeout: 15e3 });
2279
+ await loc.fill("", { timeout: 15e3 });
2280
+ await loc.pressSequentially(req(a.text, "text"), { delay: MOTION.typeDelayMs, timeout: 15e3 });
2281
+ break;
2282
+ }
2283
+ case "press": {
2284
+ const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
2285
+ const key = req(a.key, "key");
2286
+ await this.glideToLocator(loc);
2287
+ if (key === "Enter" || key === " " || key === "Spacebar") await pulseCursor(frame);
2288
+ await loc.press(key, { timeout: 15e3 });
2289
+ break;
2290
+ }
2291
+ case "hover": {
2292
+ const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
2293
+ await this.glideToLocator(loc);
2294
+ await loc.hover({ timeout: 15e3 });
2295
+ break;
2296
+ }
2297
+ case "scroll":
2298
+ if (a.ref) await this.smoothScrollToLocator(frame.locator(`aria-ref=${a.ref}`));
2299
+ else {
2300
+ const delta = await frame.evaluate(() => window.innerHeight * 0.8);
2301
+ await smoothScrollBy(frame, delta);
2302
+ }
2303
+ break;
2304
+ case "navigate": {
2305
+ const target = new URL(req(a.url, "url"), this.boot.session.targetOrigin + "/").toString();
2306
+ await frame.goto(target, { waitUntil: "domcontentloaded" });
2307
+ await showCursor(await this.frame());
2308
+ break;
2309
+ }
2310
+ case "wait":
2311
+ await this.page.waitForTimeout(Math.min(a.ms ?? 1e3, 15e3));
2312
+ break;
2313
+ default:
2314
+ throw new Error(`Unknown action: ${String(a.action)}`);
2315
+ }
2316
+ await this.page.waitForTimeout(MOTION.settleMs);
2317
+ return { snapshot: await this.snapshot() };
2318
+ }
2319
+ /** Bring a target on-screen, then glide the synthetic cursor to its centre.
2320
+ * Coordinates are frame-local (what the in-page cursor uses). */
2321
+ async glideToLocator(loc) {
2322
+ await loc.scrollIntoViewIfNeeded({ timeout: 15e3 }).catch(() => {
2323
+ });
2324
+ const c = await loc.evaluate((el) => {
2325
+ const r = el.getBoundingClientRect();
2326
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
2327
+ }).catch(() => null);
2328
+ if (c) {
2329
+ await glideCursor(await this.frame(), c.x, c.y);
2330
+ await this.page.waitForTimeout(MOTION.holdMs);
2331
+ }
2332
+ }
2333
+ /** Smoothly scroll a target to the vertical centre of the viewport. The delta
2334
+ * is measured inside the frame (frame-local coords). */
2335
+ async smoothScrollToLocator(loc) {
2336
+ const delta = await loc.evaluate((el) => {
2337
+ const r = el.getBoundingClientRect();
2338
+ return r.top + r.height / 2 - window.innerHeight / 2;
2339
+ }).catch(() => null);
2340
+ if (delta === null) {
2341
+ await loc.scrollIntoViewIfNeeded({ timeout: 15e3 }).catch(() => {
2342
+ });
2343
+ return;
2344
+ }
2345
+ if (Math.abs(delta) < 8) return;
2346
+ await smoothScrollBy(await this.frame(), delta);
2347
+ }
2348
+ /** Stop recording, encode the final clip(s), and return the output file paths. */
2349
+ async finish(opts = {}) {
2350
+ if (!this.started) throw new Error("No active recording \u2014 call start_recording first.");
2351
+ if (this.mode === "before-after" && this.pass !== "after") {
2352
+ throw new Error("Record the AFTER pass first: call next_pass, redo the journey, then finish_recording.");
2353
+ }
2354
+ const boot = this.boot;
2355
+ boot.session.setPhase("idle");
2356
+ let files;
2357
+ if (this.mode === "before-after") {
2358
+ const after = await boot.session.encodeClip("after", this.outPath("after", this.headLabel), {
2359
+ branch: this.headLabel,
2360
+ baseBranch: this.baseLabel,
2361
+ timestamp: this.timestamp
2362
+ });
2363
+ this.fellBackToGif ||= after.fellBackToGif;
2364
+ files = [...this.beforePaths, ...after.paths];
2365
+ } else {
2366
+ const clip = await boot.session.encodeClip("before", this.outPath("single", opts.name ?? this.headLabel), {
2367
+ branch: this.headLabel,
2368
+ baseBranch: this.headLabel,
2369
+ timestamp: this.timestamp
2370
+ });
2371
+ this.fellBackToGif ||= clip.fellBackToGif;
2372
+ files = clip.paths;
2373
+ }
2374
+ const fellBack = this.fellBackToGif;
2375
+ await this.dispose();
2376
+ return { files, fellBackToGif: fellBack };
2377
+ }
2378
+ /** Tear everything down (browser, harness, dev servers, worktree) without encoding. */
2379
+ async dispose() {
2380
+ this.started = false;
2381
+ this.boot = null;
2382
+ this.page = null;
2383
+ this.beforeServer = null;
2384
+ this.worktree = null;
2385
+ await runCleanups();
2386
+ }
2387
+ // ── helpers ────────────────────────────────────────────────────────────────
2388
+ outPath(which, label) {
2389
+ return path12.join(this.outDir, `${which === "single" ? slug(label) : `${which}-${slug(label)}`}-${this.fstamp}`);
2390
+ }
2391
+ /** Capture the start path, then begin the live screencast recording. */
2392
+ async beginRecording() {
2393
+ const boot = this.boot;
2394
+ const frame = await getAppFrame(this.page, boot.session.targetOrigin);
2395
+ await frame.waitForLoadState("domcontentloaded").catch(() => {
2396
+ });
2397
+ boot.session.startUrl = await frame.evaluate(() => location.pathname + location.search).catch(() => "/");
2398
+ boot.session.setPhase("recording");
2399
+ await showCursor(frame);
2400
+ }
2401
+ async frame() {
2402
+ if (!this.page || !this.boot) throw new Error("No active recording.");
2403
+ return getAppFrame(this.page, this.boot.session.targetOrigin);
2404
+ }
2405
+ async currentBranch() {
2406
+ const ref = await tryGit(this.repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
2407
+ return ref ? shortRef(ref) : "app";
2408
+ }
2409
+ };
2410
+ function req(v, name) {
2411
+ if (v === void 0 || v === null || v === "") throw new Error(`Missing required "${name}" for this action.`);
2412
+ return v;
2413
+ }
2414
+
2415
+ // src/mcp/publish.ts
2416
+ import { execFile as execFile3 } from "child_process";
2417
+ import { promisify } from "util";
2418
+ import { copyFile, mkdir as mkdir4 } from "fs/promises";
2419
+ import { existsSync as existsSync9 } from "fs";
2420
+ import path13 from "path";
2421
+ var pexec = promisify(execFile3);
2422
+ async function run2(cmd, args, cwd) {
2423
+ try {
2424
+ const { stdout } = await pexec(cmd, args, { cwd, maxBuffer: 16 * 1024 * 1024 });
2425
+ return stdout.trim();
2426
+ } catch (e) {
2427
+ const err = e;
2428
+ throw new Error(`\`${cmd} ${args.slice(0, 2).join(" ")}\` failed: ${(err.stderr || err.message || String(e)).trim()}`);
2429
+ }
2430
+ }
2431
+ async function openPr(repoRoot, files, opts = {}) {
2432
+ if (files.length === 0) {
2433
+ throw new Error("No clip files provided \u2014 run finish_recording first and pass its output paths.");
2434
+ }
2435
+ try {
2436
+ await run2("gh", ["--version"], repoRoot);
2437
+ } catch {
2438
+ throw new Error("GitHub CLI (gh) not found. Install it from https://cli.github.com, then `gh auth login`.");
2439
+ }
2440
+ try {
2441
+ await run2("gh", ["auth", "status"], repoRoot);
2442
+ } catch {
2443
+ throw new Error("GitHub CLI isn't authenticated. Run `gh auth login`, then try again.");
2444
+ }
2445
+ const branch = await tryGit(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
2446
+ if (!branch) throw new Error("You're on a detached HEAD. Check out your PR branch first.");
2447
+ if (!await tryGit(repoRoot, ["remote", "get-url", "origin"])) {
2448
+ throw new Error("No `origin` remote found. Add one (and push the branch), then retry.");
2449
+ }
2450
+ const slug3 = await repoSlug(repoRoot);
2451
+ const abs = files.map((f) => path13.isAbsolute(f) ? f : path13.resolve(repoRoot, f));
2452
+ for (const f of abs) if (!existsSync9(f)) throw new Error(`Clip file not found: ${f}`);
2453
+ const destDir = path13.join(repoRoot, "pr-preview");
2454
+ await mkdir4(destDir, { recursive: true });
2455
+ const committed = [];
2456
+ let embedGifRel = null;
2457
+ const mp4Rel = [];
2458
+ for (const f of abs) {
2459
+ const base = path13.basename(f);
2460
+ await copyFile(f, path13.join(destDir, base));
2461
+ committed.push(`pr-preview/${base}`);
2462
+ if (base.endsWith(".gif") && !embedGifRel) embedGifRel = `pr-preview/${base}`;
2463
+ if (base.endsWith(".mp4")) mp4Rel.push(`pr-preview/${base}`);
2464
+ }
2465
+ if (!embedGifRel && mp4Rel.length && await ffmpegAvailable()) {
2466
+ const src = path13.join(repoRoot, mp4Rel[0]);
2467
+ const gifName = path13.basename(mp4Rel[0]).replace(/\.mp4$/, ".gif");
2468
+ await run2(
2469
+ "ffmpeg",
2470
+ ["-y", "-i", src, "-vf", "fps=15,scale=900:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse", path13.join(destDir, gifName)],
2471
+ repoRoot
2472
+ );
2473
+ committed.push(`pr-preview/${gifName}`);
2474
+ embedGifRel = `pr-preview/${gifName}`;
2475
+ }
2476
+ await git(repoRoot, ["add", "--", ...committed]);
2477
+ try {
2478
+ await git(repoRoot, ["commit", "-m", "Add PR preview clip"]);
2479
+ } catch (e) {
2480
+ if (!/nothing to commit/i.test(String(e))) throw e;
2481
+ }
2482
+ await run2("git", ["push", "-u", "origin", branch], repoRoot);
2483
+ const rawBase = `https://github.com/${slug3}/raw/${branch}`;
2484
+ const body = buildBody(embedGifRel, mp4Rel, rawBase);
2485
+ const args = ["pr", "create", "--title", opts.title ?? `Preview: ${branch}`, "--body", body, "--head", branch];
2486
+ if (opts.base) args.push("--base", opts.base);
2487
+ const prUrl = await run2("gh", args, repoRoot);
2488
+ return { prUrl, committed, embedded: embedGifRel ? "gif" : "mp4-link" };
2489
+ }
2490
+ function buildBody(gifRel, mp4Rel, rawBase) {
2491
+ const lines = ["## PR Preview", ""];
2492
+ if (gifRel) lines.push(`![PR preview](${rawBase}/${gifRel})`, "");
2493
+ if (mp4Rel.length) {
2494
+ lines.push("Full-quality MP4:");
2495
+ for (const m of mp4Rel) lines.push(`- [${path13.basename(m)}](${rawBase}/${m})`);
2496
+ lines.push("");
2497
+ }
2498
+ lines.push("<sub>Recorded with [PR Preview](https://pr-preview.com) via Claude Code.</sub>");
2499
+ return lines.join("\n");
2500
+ }
2501
+ async function repoSlug(repoRoot) {
2502
+ try {
2503
+ return await run2("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], repoRoot);
2504
+ } catch {
2505
+ }
2506
+ const url = await tryGit(repoRoot, ["remote", "get-url", "origin"]) ?? "";
2507
+ const m = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
2508
+ if (!m) throw new Error("Couldn't determine the GitHub owner/repo from the origin remote.");
2509
+ return m[1];
2510
+ }
2511
+
2512
+ // src/mcp/detect.ts
2513
+ var CANDIDATE_PORTS = [
2514
+ 3e3,
2515
+ 3001,
2516
+ 3002,
2517
+ 3003,
2518
+ // Next.js / CRA / Node
2519
+ 5173,
2520
+ 5174,
2521
+ 4321,
2522
+ // Vite / Astro
2523
+ 4200,
2524
+ // Angular
2525
+ 8080,
2526
+ 8e3,
2527
+ 5e3,
2528
+ 4e3,
2529
+ 3333,
2530
+ // misc dev servers
2531
+ 1420,
2532
+ // Tauri
2533
+ 6006
2534
+ // Storybook
2535
+ ];
2536
+ async function probe(url, timeoutMs = 1200) {
2537
+ const controller = new AbortController();
2538
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2539
+ try {
2540
+ const res = await fetch(url, { signal: controller.signal, redirect: "manual" });
2541
+ if (res.status >= 500) return null;
2542
+ let title;
2543
+ try {
2544
+ const html = await res.text();
2545
+ title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || void 0;
2546
+ } catch {
2547
+ }
2548
+ return { url, title };
2549
+ } catch {
2550
+ return null;
2551
+ } finally {
2552
+ clearTimeout(timer);
2553
+ }
2554
+ }
2555
+ async function detectLocalApps(preferred) {
2556
+ const urls = /* @__PURE__ */ new Set();
2557
+ if (preferred && /^https?:\/\//.test(preferred)) urls.add(preferred);
2558
+ for (const port of CANDIDATE_PORTS) urls.add(`http://localhost:${port}`);
2559
+ const results = await Promise.all([...urls].map((u) => probe(u)));
2560
+ return results.filter((r) => r !== null);
2561
+ }
2562
+
2563
+ // src/mcp/server.ts
2564
+ function urlAsk(found, hasConfig) {
2565
+ const lines = ["No URL was specified \u2014 I won't guess which app to record."];
2566
+ if (found.length) {
2567
+ lines.push("", "Apps currently running on localhost:");
2568
+ for (const f of found) lines.push(`- ${f.url}${f.title ? ` \u2014 ${f.title}` : ""}`);
2569
+ lines.push("", "Ask the user which of these to record (confirm the exact URL), then call start_recording again with that `url`.");
2570
+ } else {
2571
+ lines.push(
2572
+ "",
2573
+ "Nothing is responding on common localhost ports \u2014 the project may not be running locally.",
2574
+ "Ask the user for the URL to record: their local dev server URL, or a staging / production URL. Then call start_recording with that `url`."
2575
+ );
2576
+ }
2577
+ if (hasConfig) {
2578
+ lines.push(
2579
+ "",
2580
+ "Alternatively, if the user wants PR Preview to start the project's own dev server (from pr-preview.config.js), call start_recording with `useDevServer: true`."
2581
+ );
2582
+ }
2583
+ return lines.join("\n");
2584
+ }
2585
+ async function startMcpServer(repoRoot) {
2586
+ const recorder = new PreviewRecorder(repoRoot);
2587
+ const server = new McpServer({
2588
+ name: "pr-preview",
2589
+ version: "0.1.0"
2590
+ });
2591
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
2592
+ const fail = (e) => ({
2593
+ content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
2594
+ isError: true
2595
+ });
2596
+ const SNAPSHOT_HINT = "\n\nEach line is an element with a [ref=eN] handle. To act on one, call `act` with that ref. Always take a fresh snapshot after any action that changes the page (refs are only valid for the most recent snapshot).";
2597
+ server.tool(
2598
+ "detect_localhost",
2599
+ "Probe common localhost ports and list the apps currently running. Use this when the user hasn't given a URL, to offer them the running app(s) to record.",
2600
+ {},
2601
+ async () => {
2602
+ try {
2603
+ const found = await detectLocalApps();
2604
+ if (!found.length) {
2605
+ return text(
2606
+ "No app is responding on common localhost ports. Ask the user for a URL to record \u2014 their local dev server, or a staging / production URL."
2607
+ );
2608
+ }
2609
+ return text(
2610
+ "Apps running on localhost:\n" + found.map((f) => `- ${f.url}${f.title ? ` \u2014 ${f.title}` : ""}`).join("\n") + "\n\nAsk the user which one to record, then call start_recording with that url."
2611
+ );
2612
+ } catch (e) {
2613
+ return fail(e);
2614
+ }
2615
+ }
2616
+ );
2617
+ server.tool(
2618
+ "start_recording",
2619
+ "Open the user's app in the PR Preview harness \u2014 a real, VISIBLE Chrome window where the app runs inside the harness iframe and the sidebar records each step \u2014 and begin recording. Returns the app's accessibility snapshot so you can pick the first action.\n\nURL handling \u2014 do NOT guess: if the user gave a URL, pass it as `url`. If they did NOT specify one, call this with no `url`; it detects apps on localhost and returns them so you can ASK the user which URL to record. If nothing is running locally, ask the user for a staging or production URL. Only pass `useDevServer: true` when the user explicitly wants PR Preview to start the project's own dev server (from pr-preview.config.js).\n\nmode 'single' (default) records one clip. mode 'before-after' records the SAME journey twice \u2014 first on the PR's base branch, then on your branch \u2014 for a true before/after (uses a managed dev server and requires being on a git branch; `url` is ignored). In before-after mode: drive the journey with `act`, call `next_pass`, redo the SAME journey, then finish.",
2620
+ {
2621
+ url: z2.string().optional().describe("URL of an already-running app to record (ask the user if unsure)"),
2622
+ mode: z2.enum(["single", "before-after"]).optional(),
2623
+ useDevServer: z2.boolean().optional().describe("Start the project's own dev server from pr-preview.config.js instead of using a URL")
2624
+ },
2625
+ async ({ url, mode, useDevServer }) => {
2626
+ try {
2627
+ const m = mode ?? "single";
2628
+ if (m === "single" && !url && !useDevServer) {
2629
+ const found = await detectLocalApps();
2630
+ let hasConfig = false;
2631
+ try {
2632
+ await loadConfig(repoRoot);
2633
+ hasConfig = true;
2634
+ } catch {
2635
+ }
2636
+ return text(urlAsk(found, hasConfig));
2637
+ }
2638
+ const r = await recorder.start({ url, mode });
2639
+ const win = "A Chrome window has opened with the PR Preview harness \u2014 the app is running in the iframe and the sidebar records each step.";
2640
+ const hint = r.mode === "before-after" ? "\n\nThis is the BEFORE pass (base branch). Perform the journey, then call `next_pass`." : "";
2641
+ return text(`${win}
2642
+ Recording started at ${r.startUrl}. Current page:
2643
+
2644
+ ${r.snapshot}${SNAPSHOT_HINT}${hint}`);
2645
+ } catch (e) {
2646
+ return fail(e);
2647
+ }
2648
+ }
2649
+ );
2650
+ server.tool(
2651
+ "next_pass",
2652
+ "before-after mode only: finish the BEFORE clip, switch the app to your branch, and begin the AFTER recording. After calling this, redo the SAME journey with `act`, then call `finish_recording`.",
2653
+ {},
2654
+ async () => {
2655
+ try {
2656
+ const { snapshot } = await recorder.nextPass();
2657
+ return text(
2658
+ `BEFORE clip captured. Now recording the AFTER pass (your branch) \u2014 redo the same journey.
2659
+
2660
+ ${snapshot}${SNAPSHOT_HINT}`
2661
+ );
2662
+ } catch (e) {
2663
+ return fail(e);
2664
+ }
2665
+ }
2666
+ );
2667
+ server.tool(
2668
+ "snapshot",
2669
+ "Return the current accessibility snapshot of the app (elements with [ref=eN] handles). Use this to see the page before choosing the next action.",
2670
+ {},
2671
+ async () => {
2672
+ try {
2673
+ return text(await recorder.snapshot() + SNAPSHOT_HINT);
2674
+ } catch (e) {
2675
+ return fail(e);
2676
+ }
2677
+ }
2678
+ );
2679
+ server.tool(
2680
+ "act",
2681
+ "Perform one action in the app, then return the fresh snapshot. Actions: `click` (ref), `fill` (ref + text), `press` (ref + key, e.g. Enter), `hover` (ref), `navigate` (url or path), `scroll` (optional ref to scroll into view), `wait` (ms). The action is recorded into the live clip.",
2682
+ {
2683
+ action: z2.enum(["click", "fill", "press", "hover", "navigate", "scroll", "wait"]),
2684
+ ref: z2.string().optional().describe("Element ref from the latest snapshot, e.g. e14"),
2685
+ text: z2.string().optional().describe("Text to type (for fill)"),
2686
+ key: z2.string().optional().describe("Key to press, e.g. Enter (for press)"),
2687
+ url: z2.string().optional().describe("URL or path (for navigate)"),
2688
+ ms: z2.number().optional().describe("Milliseconds to pause (for wait)")
2689
+ },
2690
+ async (args) => {
2691
+ try {
2692
+ const { snapshot } = await recorder.act(args);
2693
+ return text(`Done: ${args.action}. Current page:
2694
+
2695
+ ${snapshot}${SNAPSHOT_HINT}`);
2696
+ } catch (e) {
2697
+ return fail(e);
2698
+ }
2699
+ }
2700
+ );
2701
+ server.tool(
2702
+ "finish_recording",
2703
+ "Stop recording, encode the clip, and return the output file path(s). Call this when the journey is complete. `name` optionally sets the output filename.",
2704
+ { name: z2.string().optional() },
2705
+ async ({ name }) => {
2706
+ try {
2707
+ const { files, fellBackToGif } = await recorder.finish({ name });
2708
+ const rel = files.map((f) => path14.relative(repoRoot, f));
2709
+ const note = fellBackToGif ? "\n(ffmpeg was not found, so a GIF was produced instead of MP4 \u2014 `brew install ffmpeg` for MP4.)" : "";
2710
+ return text(
2711
+ `Recording complete. Output:
2712
+ ${rel.map((r) => ` - ${r}`).join("\n")}${note}
2713
+
2714
+ Drag the file into your PR description to embed it, or commit it and open the PR.`
2715
+ );
2716
+ } catch (e) {
2717
+ return fail(e);
2718
+ }
2719
+ }
2720
+ );
2721
+ server.tool(
2722
+ "open_pr",
2723
+ "Commit the recorded clip into the repo, push the current branch, and open a pull request with the preview embedded in the body. Pass the file path(s) returned by finish_recording. Requires the GitHub CLI (gh) authenticated and an `origin` remote. Note: GitHub only plays inline video from its own CDN, so this embeds an animated GIF (rendered inline) and links the full MP4. Side effects: it creates a commit on your branch and pushes it.",
2724
+ {
2725
+ files: z2.array(z2.string()).describe("Clip file path(s) from finish_recording"),
2726
+ title: z2.string().optional().describe("PR title (defaults to 'Preview: <branch>')"),
2727
+ base: z2.string().optional().describe("Base branch for the PR (e.g. main)")
2728
+ },
2729
+ async ({ files, title, base }) => {
2730
+ try {
2731
+ const { prUrl, committed, embedded } = await openPr(repoRoot, files, { title, base });
2732
+ const how = embedded === "gif" ? "an inline GIF preview" : "a link to the MP4";
2733
+ return text(
2734
+ `Pull request opened: ${prUrl}
2735
+ Committed ${committed.length} file(s) under pr-preview/ and embedded ${how}.`
2736
+ );
2737
+ } catch (e) {
2738
+ return fail(e);
2739
+ }
2740
+ }
2741
+ );
2742
+ server.tool(
2743
+ "cancel_recording",
2744
+ "Abort the current recording and close the browser without producing a clip.",
2745
+ {},
2746
+ async () => {
2747
+ try {
2748
+ await recorder.dispose();
2749
+ return text("Recording cancelled \u2014 browser closed, no clip produced.");
2750
+ } catch (e) {
2751
+ return fail(e);
2752
+ }
2753
+ }
2754
+ );
2755
+ const transport = new StdioServerTransport();
2756
+ await server.connect(transport);
2757
+ await new Promise((resolve) => {
2758
+ transport.onclose = () => {
2759
+ void recorder.dispose().finally(resolve);
2760
+ };
2761
+ });
2762
+ }
2763
+
2764
+ // src/cli/commands/mcp.ts
2765
+ async function mcpCommand(repoRoot) {
2766
+ const toStderr = (...args) => {
2767
+ process.stderr.write(
2768
+ args.map((a) => typeof a === "string" ? a : String(a)).join(" ") + "\n"
2769
+ );
2770
+ };
2771
+ console.log = toStderr;
2772
+ console.info = toStderr;
2773
+ console.warn = toStderr;
2774
+ console.debug = toStderr;
2775
+ await startMcpServer(repoRoot);
2776
+ }
2777
+
2778
+ // src/cli/commands/record.ts
2779
+ import path16 from "path";
2780
+ import pc2 from "picocolors";
2781
+
2782
+ // src/recorder/journey.ts
2783
+ import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir5 } from "fs/promises";
2784
+ import path15 from "path";
2785
+ import { z as z3 } from "zod";
2786
+ var stepSchema = z3.object({
2787
+ id: z3.string(),
2788
+ type: z3.enum(["click", "fill", "select", "press", "scroll", "navigate", "wait"]),
2789
+ selectors: z3.array(z3.string()).optional(),
2790
+ coordinates: z3.object({ xNorm: z3.number(), yNorm: z3.number() }).optional(),
2791
+ frameUrl: z3.string().optional(),
2792
+ value: z3.string().optional(),
2793
+ key: z3.string().optional(),
2794
+ scrollTarget: z3.string().optional(),
2795
+ scroll: z3.object({ xNorm: z3.number(), yNorm: z3.number() }).optional(),
2796
+ url: z3.string().optional(),
2797
+ causesNavigation: z3.boolean().optional(),
2798
+ label: z3.string().optional(),
2799
+ timestamp: z3.number(),
2800
+ thumbnail: z3.string().optional()
2801
+ });
2802
+ var journeySchema = z3.object({
2803
+ version: z3.literal(1),
2804
+ createdAt: z3.string(),
2805
+ baseRef: z3.string().optional(),
2806
+ viewport: z3.object({
2807
+ width: z3.number(),
2808
+ height: z3.number(),
2809
+ deviceScaleFactor: z3.number()
2810
+ }),
2811
+ startUrl: z3.string(),
2812
+ steps: z3.array(stepSchema)
2813
+ });
2814
+ async function saveJourney(file, journey) {
2815
+ const clean = {
2816
+ ...journey,
2817
+ steps: journey.steps.map((s) => {
2818
+ const { thumbnail: _thumbnail, ...rest } = s;
2819
+ return rest;
2820
+ })
2821
+ };
2822
+ await mkdir5(path15.dirname(file), { recursive: true });
2823
+ await writeFile4(file, JSON.stringify(clean, null, 2) + "\n");
2824
+ }
2825
+
2826
+ // src/cli/commands/record.ts
2827
+ async function recordCommand(repoRoot, opts) {
2828
+ const { config } = await loadConfig(repoRoot);
2829
+ const journeyFile = path16.resolve(repoRoot, opts.out ?? ".pr-preview/journey.json");
2830
+ try {
2831
+ const boot = await bootstrap(repoRoot, config, "record");
2832
+ await boot.startApp("after", repoRoot);
2833
+ await boot.openBrowser("after");
2834
+ log.info(`Harness open at ${pc2.bold(boot.harness.url)}`);
2835
+ log.step("Click Record in the sidebar, perform the journey, then Confirm.");
2836
+ const steps = await boot.session.recordUntilConfirmed();
2837
+ if (steps.length === 0) {
2838
+ log.warn("No steps recorded \u2014 nothing saved.");
2839
+ return;
2840
+ }
2841
+ await saveJourney(journeyFile, {
2842
+ version: 1,
2843
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2844
+ viewport: { ...config.viewport, deviceScaleFactor: 2 },
2845
+ startUrl: boot.session.startUrl,
2846
+ steps
2847
+ });
2848
+ log.success(`Saved ${steps.length} steps to ${path16.relative(repoRoot, journeyFile)}`);
2849
+ } finally {
2850
+ await runCleanups();
2851
+ }
2852
+ }
2853
+
2854
+ // src/cli/commands/run.ts
2855
+ import { existsSync as existsSync10 } from "fs";
2856
+ import path17 from "path";
2857
+ import pc3 from "picocolors";
2858
+
1964
2859
  // src/cli/util/openPath.ts
1965
2860
  import { spawn as spawn4 } from "child_process";
1966
2861
  function openPath(target) {
@@ -1992,25 +2887,25 @@ function revealFiles(files, dir) {
1992
2887
  function captureClip(session, which, outBase, label) {
1993
2888
  return session.encodeClip(which, outBase, label);
1994
2889
  }
1995
- function stamp() {
2890
+ function stamp2() {
1996
2891
  const d = /* @__PURE__ */ new Date();
1997
2892
  const p = (n) => String(n).padStart(2, "0");
1998
2893
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
1999
2894
  }
2000
- function shortRef(ref) {
2895
+ function shortRef2(ref) {
2001
2896
  return ref.replace(/^origin\//, "");
2002
2897
  }
2003
- function slug(ref) {
2004
- return shortRef(ref).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
2898
+ function slug2(ref) {
2899
+ return shortRef2(ref).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
2005
2900
  }
2006
- function fileStamp() {
2901
+ function fileStamp2() {
2007
2902
  const d = /* @__PURE__ */ new Date();
2008
2903
  const p = (n) => String(n).padStart(2, "0");
2009
2904
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
2010
2905
  }
2011
2906
  async function branchName(repoRoot, fallback) {
2012
2907
  const ref = await tryGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
2013
- return ref ? shortRef(ref) : fallback;
2908
+ return ref ? shortRef2(ref) : fallback;
2014
2909
  }
2015
2910
  async function runCommand(repoRoot, opts) {
2016
2911
  let config;
@@ -2030,7 +2925,7 @@ async function runCommand(repoRoot, opts) {
2030
2925
  keepWorktree: opts.keepWorktree ?? config.keepWorktree,
2031
2926
  single: opts.single
2032
2927
  };
2033
- const outDir = path14.resolve(repoRoot, config.output);
2928
+ const outDir = path17.resolve(repoRoot, config.output);
2034
2929
  const passes = opts.single ? 1 : config.passes;
2035
2930
  if (passes === 1) return runSingleClip(repoRoot, config, outDir, opts);
2036
2931
  if (opts.url) return runWithExternalApp(repoRoot, config, outDir, opts);
@@ -2048,11 +2943,11 @@ async function runCommand(repoRoot, opts) {
2048
2943
  }
2049
2944
  const worktree = await createBaseWorktree(repoRoot, base.sha);
2050
2945
  if (!opts.keepWorktree) registerCleanup(() => worktree.remove());
2051
- log.success(`Base worktree at ${path14.relative(repoRoot, worktree.dir)}`);
2946
+ log.success(`Base worktree at ${path17.relative(repoRoot, worktree.dir)}`);
2052
2947
  if (config.install !== "never") {
2053
- const appDir = path14.join(worktree.dir, config.cwd);
2948
+ const appDir = path17.join(worktree.dir, config.cwd);
2054
2949
  const pm = detectPackageManager(appDir);
2055
- const hasModules = existsSync8(path14.join(appDir, "node_modules"));
2950
+ const hasModules = existsSync10(path17.join(appDir, "node_modules"));
2056
2951
  if (config.install === "always" || !hasModules) {
2057
2952
  const spinner = log.spinner(`Installing dependencies in worktree (${pm}) \u2026`);
2058
2953
  try {
@@ -2075,21 +2970,21 @@ async function runCommand(repoRoot, opts) {
2075
2970
  const beforeSteps = await boot.session.recordUntilConfirmed();
2076
2971
  if (beforeSteps.length === 0) throw new Error("No steps recorded for BEFORE \u2014 aborting.");
2077
2972
  await saveJourneyFile(repoRoot, "journey-before.json", config, boot.session.startUrl, beforeSteps, base.sha);
2078
- const timestamp = stamp();
2079
- const fstamp = fileStamp();
2080
- const baseLabel = shortRef(base.ref);
2081
- const headLabel = shortRef(currentBranch);
2973
+ const timestamp = stamp2();
2974
+ const fstamp = fileStamp2();
2975
+ const baseLabel = shortRef2(base.ref);
2976
+ const headLabel = shortRef2(currentBranch);
2082
2977
  log.info("Capturing BEFORE \u2026");
2083
2978
  const before = await captureClip(
2084
2979
  boot.session,
2085
2980
  "before",
2086
- path14.join(outDir, `before-${slug(base.ref)}-${fstamp}`),
2981
+ path17.join(outDir, `before-${slug2(base.ref)}-${fstamp}`),
2087
2982
  { branch: baseLabel, baseBranch: baseLabel, timestamp }
2088
2983
  );
2089
2984
  if (before.fellBackToGif) {
2090
2985
  log.warn("ffmpeg not found \u2014 produced a GIF instead of MP4 (brew install ffmpeg).");
2091
2986
  }
2092
- log.success(`${before.paths.map((p) => path14.basename(p)).join(" + ")} (${before.frameCount} frames)`);
2987
+ log.success(`${before.paths.map((p) => path17.basename(p)).join(" + ")} (${before.frameCount} frames)`);
2093
2988
  await beforeServer.stop();
2094
2989
  await boot.startApp("after", repoRoot);
2095
2990
  boot.session.switchPass("after");
@@ -2106,10 +3001,10 @@ async function runCommand(repoRoot, opts) {
2106
3001
  const after = await captureClip(
2107
3002
  boot.session,
2108
3003
  "after",
2109
- path14.join(outDir, `after-${slug(currentBranch)}-${fstamp}`),
3004
+ path17.join(outDir, `after-${slug2(currentBranch)}-${fstamp}`),
2110
3005
  { branch: headLabel, baseBranch: baseLabel, timestamp }
2111
3006
  );
2112
- log.success(`${after.paths.map((p) => path14.basename(p)).join(" + ")} (${after.frameCount} frames)`);
3007
+ log.success(`${after.paths.map((p) => path17.basename(p)).join(" + ")} (${after.frameCount} frames)`);
2113
3008
  boot.session.setPhase("done");
2114
3009
  boot.harness.bus.send({
2115
3010
  type: "DONE",
@@ -2118,7 +3013,7 @@ async function runCommand(repoRoot, opts) {
2118
3013
  console.log();
2119
3014
  log.success(pc3.bold("Done \u2014 drag these into your PR description:"));
2120
3015
  for (const p of [...before.paths, ...after.paths]) {
2121
- log.step(path14.relative(repoRoot, p));
3016
+ log.step(path17.relative(repoRoot, p));
2122
3017
  }
2123
3018
  revealFiles([...before.paths, ...after.paths], outDir);
2124
3019
  } finally {
@@ -2143,12 +3038,12 @@ async function runSingleClip(repoRoot, config, outDir, opts) {
2143
3038
  const steps = await boot.session.recordUntilConfirmed();
2144
3039
  if (steps.length === 0) throw new Error("No steps recorded \u2014 aborting.");
2145
3040
  await saveJourneyFile(repoRoot, "journey.json", config, boot.session.startUrl, steps);
2146
- const timestamp = stamp();
3041
+ const timestamp = stamp2();
2147
3042
  log.info("Capturing the clip \u2026");
2148
3043
  const clip = await captureClip(
2149
3044
  boot.session,
2150
3045
  "before",
2151
- path14.join(outDir, `${slug(branch)}-${fileStamp()}`),
3046
+ path17.join(outDir, `${slug2(branch)}-${fileStamp2()}`),
2152
3047
  { branch, baseBranch: branch, timestamp }
2153
3048
  );
2154
3049
  if (clip.fellBackToGif) {
@@ -2159,7 +3054,7 @@ async function runSingleClip(repoRoot, config, outDir, opts) {
2159
3054
  boot.harness.bus.send({ type: "DONE", outputs: { before: clip.paths[0] } });
2160
3055
  console.log();
2161
3056
  log.success(pc3.bold("Done \u2014 your clip:"));
2162
- for (const p of clip.paths) log.step(path14.relative(repoRoot, p));
3057
+ for (const p of clip.paths) log.step(path17.relative(repoRoot, p));
2163
3058
  revealFiles(clip.paths, outDir);
2164
3059
  } finally {
2165
3060
  await runCleanups();
@@ -2178,17 +3073,17 @@ async function runWithExternalApp(repoRoot, config, outDir, opts) {
2178
3073
  await boot.session.promptResetChoice();
2179
3074
  const beforeSteps = await boot.session.recordUntilConfirmed();
2180
3075
  if (beforeSteps.length === 0) throw new Error("No steps recorded for BEFORE \u2014 aborting.");
2181
- const timestamp = stamp();
2182
- const fstamp = fileStamp();
3076
+ const timestamp = stamp2();
3077
+ const fstamp = fileStamp2();
2183
3078
  await saveJourneyFile(repoRoot, "journey-before.json", config, boot.session.startUrl, beforeSteps);
2184
3079
  log.info("Capturing BEFORE \u2026");
2185
3080
  const before = await captureClip(
2186
3081
  boot.session,
2187
3082
  "before",
2188
- path14.join(outDir, `before-${slug(beforeBranch)}-${fstamp}`),
3083
+ path17.join(outDir, `before-${slug2(beforeBranch)}-${fstamp}`),
2189
3084
  { branch: beforeBranch, baseBranch: beforeBranch, timestamp }
2190
3085
  );
2191
- log.success(`${before.paths.map((p) => path14.basename(p)).join(" + ")} (${before.frameCount} frames)`);
3086
+ log.success(`${before.paths.map((p) => path17.basename(p)).join(" + ")} (${before.frameCount} frames)`);
2192
3087
  log.info(pc3.bold("\u2192 Switch your app to the PR branch and restart it on the same URL, then Continue in the harness."));
2193
3088
  boot.harness.bus.send({
2194
3089
  type: "MANUAL_PAUSE",
@@ -2211,22 +3106,22 @@ async function runWithExternalApp(repoRoot, config, outDir, opts) {
2211
3106
  const after = await captureClip(
2212
3107
  boot.session,
2213
3108
  "after",
2214
- path14.join(outDir, `after-${slug(afterBranch)}-${fstamp}`),
3109
+ path17.join(outDir, `after-${slug2(afterBranch)}-${fstamp}`),
2215
3110
  { branch: afterBranch, baseBranch: beforeBranch, timestamp }
2216
3111
  );
2217
- log.success(`${after.paths.map((p) => path14.basename(p)).join(" + ")} (${after.frameCount} frames)`);
3112
+ log.success(`${after.paths.map((p) => path17.basename(p)).join(" + ")} (${after.frameCount} frames)`);
2218
3113
  boot.session.setPhase("done");
2219
3114
  boot.harness.bus.send({ type: "DONE", outputs: { before: before.paths[0], after: after.paths[0] } });
2220
3115
  console.log();
2221
3116
  log.success(pc3.bold("Done \u2014 drag these into your PR description:"));
2222
- for (const p of [...before.paths, ...after.paths]) log.step(path14.relative(repoRoot, p));
3117
+ for (const p of [...before.paths, ...after.paths]) log.step(path17.relative(repoRoot, p));
2223
3118
  revealFiles([...before.paths, ...after.paths], outDir);
2224
3119
  } finally {
2225
3120
  await runCleanups();
2226
3121
  }
2227
3122
  }
2228
3123
  async function saveJourneyFile(repoRoot, name, config, startUrl, steps, baseRef) {
2229
- await saveJourney(path14.join(repoRoot, ".pr-preview", name), {
3124
+ await saveJourney(path17.join(repoRoot, ".pr-preview", name), {
2230
3125
  version: 1,
2231
3126
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2232
3127
  baseRef,
@@ -2273,6 +3168,9 @@ Notes:
2273
3168
  \u2022 Without --url, a before/after run needs a git repo and to be on a branch with commits
2274
3169
  beyond its base (it records the base in a throwaway worktree, your branch in place).`
2275
3170
  ).action((opts) => wrap(() => runCommand(process.cwd(), opts)));
3171
+ program.command("mcp").description(
3172
+ "Run the Model Context Protocol server (stdio) so Claude Code can drive a recording from a prompt"
3173
+ ).action(() => wrap(() => mcpCommand(process.cwd())));
2276
3174
  async function wrap(fn) {
2277
3175
  try {
2278
3176
  await fn();