pr-docent 0.1.0 → 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.
@@ -0,0 +1,78 @@
1
+ // Running the user's own Claude Code (their subscription) on a pull request in a temporary
2
+ // folder. Shared by tour generation and shorts.
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { dirname, join, resolve, sep } from "node:path";
8
+
9
+ const TIMEOUT_MS = 15 * 60 * 1000;
10
+ const CONFIG = readConfig();
11
+ let child = null;
12
+
13
+ /** Written by the installer; without it, use whatever claude is on PATH. */
14
+ function readConfig() {
15
+ try {
16
+ return JSON.parse(readFileSync(join(homedir(), ".pr-docent", "config.json"), "utf8"));
17
+ } catch {
18
+ return { claude: "claude" };
19
+ }
20
+ }
21
+
22
+ /**
23
+ * A temporary folder with the PR's changed files under repo/ and its diff and details under out/.
24
+ * Real path: on macOS the temp folder is a symlink, and Claude's write rule must match exactly.
25
+ */
26
+ export function prepareWorkspace({ files, diff, title, body, headSha }) {
27
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
28
+ const repo = join(work, "repo");
29
+ const out = join(work, "out");
30
+ mkdirSync(repo);
31
+ mkdirSync(out);
32
+ for (const [path, content] of Object.entries(files)) {
33
+ const target = resolve(repo, path);
34
+ if (!target.startsWith(repo + sep)) continue; // never write outside the folder
35
+ mkdirSync(dirname(target), { recursive: true });
36
+ writeFileSync(target, content);
37
+ }
38
+ writeFileSync(join(out, "pr.diff"), diff);
39
+ writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
40
+ return { work, repo, out };
41
+ }
42
+
43
+ /**
44
+ * Why Claude Code failed, for the panel to say what to do: "claudeLoggedOut" when its output says
45
+ * it isn't signed in (Claude Code 2.1: "Not logged in · Please run /login"), otherwise undefined.
46
+ */
47
+ export function failureCode(output) {
48
+ return /not logged in|please run \/login|invalid api key|oauth token has expired/i.test(output) ? "claudeLoggedOut" : undefined;
49
+ }
50
+
51
+ /** Runs `claude -p [prompt]` in [cwd]; reports elapsed seconds every 5s through [onProgress]. */
52
+ export function runClaude(prompt, cwd, args, onProgress = () => {}) {
53
+ return new Promise((resolvePromise, reject) => {
54
+ const started = Date.now();
55
+ let output = "";
56
+ child = spawn(CONFIG.claude, ["-p", prompt, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
57
+ const keep = (chunk) => (output = (output + chunk).slice(-2000));
58
+ child.stdout.on("data", keep);
59
+ child.stderr.on("data", keep);
60
+ const progress = setInterval(() => onProgress(Math.round((Date.now() - started) / 1000)), 5000);
61
+ const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
62
+ child.on("error", (error) =>
63
+ reject(Object.assign(new Error(`Could not start Claude Code (${CONFIG.claude}): ${error.message}`), { code: "claudeMissing" })),
64
+ );
65
+ child.on("close", (code) => {
66
+ clearInterval(progress);
67
+ clearTimeout(timeout);
68
+ child = null;
69
+ if (code === 0) resolvePromise();
70
+ else reject(Object.assign(new Error(`Claude Code exited with code ${code}. ${output.trim().slice(-300)}`), { code: failureCode(output) }));
71
+ });
72
+ });
73
+ }
74
+
75
+ /** Stops a running Claude Code, e.g. when the extension disconnects. */
76
+ export function stopClaude() {
77
+ child?.kill();
78
+ }
package/host/native.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  import { createHash } from "node:crypto";
4
4
 
5
5
  export const HOST_NAME = "dev.prdocent.host";
6
- // Extensions the host accepts by default: the unpacked install, whose ID manifest.json's key fixes.
7
- export const EXTENSION_IDS = ["hleefogoiedjahnmjgmmgonckaeicppc"];
6
+ // Extensions the host accepts by default: the unpacked install, whose ID manifest.json's key fixes,
7
+ // and the Chrome Web Store release.
8
+ export const EXTENSION_IDS = ["hleefogoiedjahnmjgmmgonckaeicppc", "deejphpcfjilekklajdhdhfepjbenjii"];
8
9
 
9
10
  /** Chrome native messaging frames each message as a 32-bit little-endian length plus UTF-8 JSON. */
10
11
  export function encodeMessage(value) {
@@ -33,8 +34,3 @@ export function extensionIdFromKey(key) {
33
34
  const hex = createHash("sha256").update(Buffer.from(key, "base64")).digest("hex").slice(0, 32);
34
35
  return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join("");
35
36
  }
36
-
37
- /** Replaces {{NAME}} placeholders; unknown ones are left as they are. */
38
- export function fillTemplate(text, vars) {
39
- return text.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
40
- }
@@ -1,110 +1,101 @@
1
- // Local generator: Chrome starts this through native messaging when the panel asks for a tour.
2
- // It writes the PR's diff and changed files to a temporary folder, runs the user's own Claude
3
- // Code (their subscription, no API key) with the same prompt and checker as the GitHub Actions
4
- // workflow, and sends the checked tour back. Installed by `pr-docent install` (bin/pr-docent.mjs).
1
+ // Local generator: Chrome starts this through native messaging when the panel asks for a tour,
2
+ // a short script, or a video file. Tours: Claude Code (the user's own subscription, no API key)
3
+ // writes one with the same prompt and checker as the GitHub Actions workflow. Shorts: see
4
+ // short.mjs. Installed by `pr-docent install` (bin/pr-docent.mjs).
5
5
 
6
- import { spawn } from "node:child_process";
7
- import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
- import { homedir, tmpdir } from "node:os";
9
- import { dirname, join, resolve, sep } from "node:path";
6
+ import { execFile } from "node:child_process";
7
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
10
9
  import { fileURLToPath } from "node:url";
11
- import { checkTour, visibleLines } from "../src/diff.js";
10
+ import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
11
+ import { buildPrompt, checkBrevity, parseGuidelines } from "../src/guidelines.js";
12
12
  import { parseTour } from "../src/tour.js";
13
- import { createDecoder, encodeMessage, fillTemplate } from "./native.mjs";
13
+ import { prepareWorkspace, runClaude, stopClaude } from "./claude.mjs";
14
+ import { createDecoder, encodeMessage } from "./native.mjs";
15
+ import { stopRendering } from "./render-short.mjs";
16
+ import { saveShortVideo, writeShortScript } from "./short.mjs";
14
17
 
15
18
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
- const CONFIG = readConfig();
17
- const TIMEOUT_MS = 15 * 60 * 1000;
18
-
19
- let child = null;
20
19
  const send = (message) => process.stdout.write(encodeMessage(message));
20
+ // Writing after Chrome closed the pipe fails; there is no one left to tell.
21
+ process.stdout.on("error", () => process.exit(0));
22
+ let busy = false;
21
23
 
22
24
  process.stdin.on(
23
25
  "data",
24
26
  createDecoder((message) => {
25
- if (message.type !== "generate") return;
26
- generate(message)
27
- .then((tour) => send({ type: "done", tour }))
28
- .catch((error) => send({ type: "error", message: error.message }))
29
- .finally(() => process.exit(0));
27
+ busy = true;
28
+ const reply = (promise, toMessage) =>
29
+ promise
30
+ .then((value) => send(toMessage(value)))
31
+ .catch((error) => send({ type: "error", message: error.message, code: error.code }))
32
+ // Exit once stdout has flushed: on macOS pipe writes are asynchronous.
33
+ .finally(() => process.stdout.write("", () => process.exit(0)));
34
+ if (message.type === "generate") {
35
+ reply(generate(message), (tour) => ({ type: "done", tour }));
36
+ } else if (message.type === "script") {
37
+ const onProgress = (progress) => send({ type: "progress", ...progress });
38
+ reply(writeShortScript({ ...message, onProgress }), (short) => ({ type: "done", short }));
39
+ } else if (message.type === "render") {
40
+ const onProgress = (progress) => send({ type: "progress", ...progress });
41
+ reply(saveShortVideo({ short: message.short, onProgress }), (path) => ({ type: "done", path }));
42
+ } else if (message.type === "reveal") {
43
+ reply(reveal(message.path), () => ({ type: "done" }));
44
+ }
30
45
  }),
31
46
  );
32
- // Chrome closes stdin when the extension disconnects (panel closed, tab gone): stop Claude too.
47
+ // Chrome closes stdin when the extension disconnects (panel closed, tab gone): stop Claude or the
48
+ // render too, and let the request finish failing so its temporary folder is removed, then exit
49
+ // (from the reply above, or after a grace period).
33
50
  process.stdin.on("end", () => {
34
- child?.kill();
35
- process.exit(0);
51
+ stopClaude();
52
+ stopRendering();
53
+ if (!busy) process.exit(0);
54
+ setTimeout(() => process.exit(0), 5000);
36
55
  });
37
56
 
38
- async function generate({ pr, title, body, headSha, diff, files, model, language }) {
39
- // Real path: on macOS the temp folder is a symlink, and the write rule must match exactly.
40
- const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
57
+ /** Shows a saved video in Finder; only files this tool writes (pr-docent-*.mp4). */
58
+ function reveal(path) {
59
+ if (!/\/pr-docent-[^/]+\.mp4$/.test(String(path)) || !existsSync(path)) return Promise.reject(new Error("Not a PR Docent video"));
60
+ return new Promise((done) => execFile("open", ["-R", path], () => done()));
61
+ }
62
+
63
+ async function generate({ pr, title, body, headSha, diff, files, guidelines = "", model, language }) {
64
+ const { work, repo, out } = prepareWorkspace({ files, diff, title, body, headSha });
41
65
  try {
42
- const repo = join(work, "repo");
43
- const out = join(work, "out");
44
- mkdirSync(repo);
45
- mkdirSync(out);
46
- for (const [path, content] of Object.entries(files)) {
47
- const target = resolve(repo, path);
48
- if (!target.startsWith(repo + sep)) continue; // never write outside the folder
49
- mkdirSync(dirname(target), { recursive: true });
50
- writeFileSync(target, content);
51
- }
52
- writeFileSync(join(out, "pr.diff"), diff);
53
- writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
66
+ if (guidelines) writeFileSync(join(out, "guidelines.md"), guidelines);
67
+ const { limits, errors: guidelineErrors } = parseGuidelines(guidelines);
68
+ if (guidelineErrors.length) throw new Error(`.github/pr-docent.md: ${guidelineErrors.join(" / ")}`);
69
+ const changedLines = changedLineCount(diff);
54
70
 
55
71
  const prRef = `${pr.owner}/${pr.repo}#${pr.number}`;
56
- const prompt = fillTemplate(readFileSync(join(ROOT, "scripts", "tour-prompt.md"), "utf8"), {
57
- PR: prRef,
58
- OUT: out,
59
- TOOLS: ROOT,
60
- LANGUAGE: language || "한국어",
72
+ const prompt = buildPrompt(readFileSync(join(ROOT, "scripts", "tour-prompt.md"), "utf8"), {
73
+ pr: prRef,
74
+ out,
75
+ tools: ROOT,
76
+ language: language || "한국어",
77
+ changedLines,
78
+ guidelinesText: guidelines,
61
79
  });
62
80
  await runClaude(prompt, repo, [
63
81
  "--model", model || "claude-opus-5-5",
64
82
  "--permission-mode", "dontAsk",
65
83
  "--allowedTools", `Read,Glob,Grep,Edit(/${out}/tour.json),Bash(node ${ROOT}/scripts/check-tour.mjs:*)`,
66
- ]);
84
+ ], (seconds) => send({ type: "progress", seconds }));
67
85
 
68
86
  let raw;
69
87
  try {
70
88
  raw = JSON.parse(readFileSync(join(out, "tour.json"), "utf8"));
71
89
  } catch {
72
- throw new Error("Claude가 투어 파일을 만들지 못했습니다.");
90
+ throw new Error("Claude did not write a tour file.");
73
91
  }
74
92
  const { tour, errors } = parseTour(raw);
75
- const problems = errors.length ? errors : checkTour(tour, visibleLines(diff));
76
- if (problems.length) throw new Error(`투어 검사를 통과하지 못했습니다: ${problems.slice(0, 3).join(" / ")}`);
93
+ const problems = errors.length
94
+ ? errors
95
+ : [...checkTour(tour, visibleLines(diff)), ...checkBrevity(tour, { changedLines, limits })];
96
+ if (problems.length) throw new Error(`The tour did not pass the checks: ${problems.slice(0, 3).join(" / ")}`);
77
97
  return raw;
78
98
  } finally {
79
99
  rmSync(work, { recursive: true, force: true });
80
100
  }
81
101
  }
82
-
83
- /** Written by the installer; without it, use whatever claude is on PATH. */
84
- function readConfig() {
85
- try {
86
- return JSON.parse(readFileSync(join(homedir(), ".pr-docent", "config.json"), "utf8"));
87
- } catch {
88
- return { claude: "claude" };
89
- }
90
- }
91
-
92
- function runClaude(prompt, cwd, args) {
93
- return new Promise((resolvePromise, reject) => {
94
- const started = Date.now();
95
- let output = "";
96
- child = spawn(CONFIG.claude, ["-p", prompt, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
97
- const keep = (chunk) => (output = (output + chunk).slice(-2000));
98
- child.stdout.on("data", keep);
99
- child.stderr.on("data", keep);
100
- const progress = setInterval(() => send({ type: "progress", seconds: Math.round((Date.now() - started) / 1000) }), 5000);
101
- const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
102
- child.on("error", (error) => reject(new Error(`Claude Code를 실행하지 못했습니다 (${CONFIG.claude}): ${error.message}`)));
103
- child.on("close", (code) => {
104
- clearInterval(progress);
105
- clearTimeout(timeout);
106
- if (code === 0) resolvePromise();
107
- else reject(new Error(`Claude Code가 종료 코드 ${code}로 끝났습니다. ${output.trim().slice(-300)}`));
108
- });
109
- });
110
- }
@@ -0,0 +1,96 @@
1
+ // Renders a short (see src/short.js) to a vertical MP4 on macOS. Each scene is drawn as HTML
2
+ // diagrams captured by headless Chrome, one frame per element so the picture builds up while the
3
+ // sentence is read; narration comes from `say` (speechSynthesis audio can't be recorded), and
4
+ // ffmpeg joins everything.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { promisify } from "node:util";
10
+ import { FRAME_HEIGHT as HEIGHT, FRAME_WIDTH as WIDTH, SHORT_CSS, sceneList, sceneMarkup, sceneSteps } from "../src/short-scenes.js";
11
+
12
+ // Aborted when the extension disconnects, which kills the say, Chrome or ffmpeg run in progress.
13
+ const stopping = new AbortController();
14
+ const run = (command, args) => promisify(execFile)(command, args, { signal: stopping.signal });
15
+ const LEAD_IN = 0.3; // seconds of silence before each sentence
16
+ const TAIL = 0.8; // and after it
17
+ const CHROME_PATHS = [
18
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
19
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
20
+ ];
21
+ const PREFERRED_VOICES = { ko: ["Yuna (Premium)", "Yuna (Enhanced)", "Yuna"], en: ["Samantha (Premium)", "Samantha (Enhanced)", "Samantha"] };
22
+
23
+ /** Stops a render in progress: its current subprocess is killed and the render fails. */
24
+ export function stopRendering() {
25
+ stopping.abort();
26
+ }
27
+
28
+ /** Writes the MP4 for [short] to [outPath], working in [workDir]. Calls onProgress(text) per scene. */
29
+ export async function renderShort({ short, outPath, workDir, onProgress = () => {} }) {
30
+ const chrome = CHROME_PATHS.find(existsSync);
31
+ if (!chrome) throw new Error("Google Chrome was not found in /Applications.");
32
+ await requireTool("ffmpeg", ["-version"], "ffmpeg is required: brew install ffmpeg");
33
+ const voice = await pickSayVoice(/[가-힣]/.test(JSON.stringify(short)) ? "ko" : "en");
34
+ const scenes = sceneList(short);
35
+ mkdirSync(workDir, { recursive: true });
36
+
37
+ const clips = [];
38
+ for (const [i, scene] of scenes.entries()) {
39
+ onProgress(`${i + 1}/${scenes.length}`);
40
+ const base = join(workDir, `scene-${i}`);
41
+ await run("say", ["-v", voice, "-o", `${base}.aiff`, scene.narration]);
42
+ const audio = Number((await run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", `${base}.aiff`])).stdout);
43
+
44
+ // One frame per revealed element, spread across the sentence.
45
+ const steps = sceneSteps(scene);
46
+ const frames = [];
47
+ for (let shown = 1; shown <= steps; shown++) {
48
+ const png = `${base}-${shown}.png`;
49
+ writeFileSync(`${base}.html`, pageHtml(scene, { short, shown }));
50
+ await run(chrome, [
51
+ "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
52
+ `--user-data-dir=${join(workDir, "chrome-profile")}`, `--window-size=${WIDTH},${HEIGHT}`,
53
+ `--screenshot=${png}`, `file://${base}.html`,
54
+ ]);
55
+ const duration = audio / steps + (shown === 1 ? LEAD_IN : 0) + (shown === steps ? TAIL : 0);
56
+ frames.push(`file '${png}'\nduration ${duration.toFixed(3)}`);
57
+ }
58
+ frames.push(`file '${base}-${steps}.png'`); // the concat demuxer needs the last frame twice
59
+ writeFileSync(`${base}.txt`, frames.join("\n"));
60
+
61
+ const delay = Math.round(LEAD_IN * 1000);
62
+ await run("ffmpeg", [
63
+ "-y", "-f", "concat", "-safe", "0", "-i", `${base}.txt`, "-i", `${base}.aiff`,
64
+ "-filter_complex", `[0:v]fps=30,format=yuv420p[v];[1:a]adelay=${delay}|${delay},apad[a]`,
65
+ "-map", "[v]", "-map", "[a]", "-t", (LEAD_IN + audio + TAIL).toFixed(2),
66
+ "-c:v", "libx264", "-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2", `${base}.mp4`,
67
+ ]);
68
+ clips.push(`${base}.mp4`);
69
+ }
70
+
71
+ onProgress("…");
72
+ const list = join(workDir, "clips.txt");
73
+ writeFileSync(list, clips.map((clip) => `file '${clip}'`).join("\n"));
74
+ await run("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", outPath]);
75
+ return outPath;
76
+ }
77
+
78
+ async function requireTool(command, args, message) {
79
+ try {
80
+ await run(command, args);
81
+ } catch {
82
+ throw new Error(message);
83
+ }
84
+ }
85
+
86
+ async function pickSayVoice(language) {
87
+ const { stdout } = await run("say", ["-v", "?"]);
88
+ const names = stdout.split("\n").map((line) => line.split(/\s{2,}/)[0].trim());
89
+ return PREFERRED_VOICES[language].find((name) => names.includes(name)) ?? (language === "ko" ? "Yuna" : "Samantha");
90
+ }
91
+
92
+ /** A whole page for the screenshot: the shared frame with nothing around it. */
93
+ function pageHtml(scene, options) {
94
+ return `<!doctype html><html><head><meta charset="utf-8"><style>body { margin: 0; } ${SHORT_CSS}</style></head>
95
+ <body><div class="short-frame">${sceneMarkup(scene, options)}</div></body></html>`;
96
+ }
@@ -0,0 +1,56 @@
1
+ // `pr-docent short <PR>`: makes a short from the terminal with gh instead of the extension.
2
+ // Uses the tour posted in the PR's comments (or --tour <file>).
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { visibleLines } from "../src/diff.js";
7
+ import { findTourInBlocks, parseTour, tourLanguage } from "../src/tour.js";
8
+ import { generateShort } from "./short.mjs";
9
+
10
+ const gh = (...args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 100e6 });
11
+
12
+ /** "owner/repo#12" or a pull request URL → { owner, repo, number }, or null. */
13
+ export function parsePullRequestRef(text) {
14
+ const match = String(text).match(/^(?:https:\/\/github\.com\/)?([^/\s#]+)\/([^/\s#]+)(?:#|\/pull\/)(\d+)/);
15
+ return match ? { owner: match[1], repo: match[2], number: Number(match[3]) } : null;
16
+ }
17
+
18
+ export async function shortCommand(args) {
19
+ const pr = parsePullRequestRef(args[0] ?? "");
20
+ if (!pr) throw new Error("usage: pr-docent short <owner/repo#number | pull request URL> [--tour <file>] [--out <file.mp4>]");
21
+ const option = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
22
+ const repoArg = `${pr.owner}/${pr.repo}`;
23
+
24
+ const details = JSON.parse(gh("pr", "view", String(pr.number), "-R", repoArg, "--json", "title,body,headRefOid,comments"));
25
+ let tour;
26
+ if (option("--tour")) {
27
+ tour = JSON.parse(readFileSync(option("--tour"), "utf8"));
28
+ } else {
29
+ const blocks = details.comments.flatMap((c) => [...c.body.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => m[1]));
30
+ tour = findTourInBlocks(blocks, pr);
31
+ if (!tour) throw new Error("This PR has no tour comment. Generate a tour first, or pass --tour <file>.");
32
+ }
33
+ const parsed = parseTour(tour);
34
+ if (parsed.errors.length) throw new Error(`The tour is invalid: ${parsed.errors[0]}`);
35
+
36
+ console.log(`Fetching ${repoArg}#${pr.number}…`);
37
+ const diff = gh("pr", "diff", String(pr.number), "-R", repoArg);
38
+ const files = {};
39
+ for (const path of visibleLines(diff).keys()) {
40
+ try {
41
+ files[path] = gh("api", `repos/${repoArg}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${details.headRefOid}`, "-H", "Accept: application/vnd.github.raw");
42
+ } catch {
43
+ // deleted or binary at the head commit
44
+ }
45
+ }
46
+ const path = await generateShort({
47
+ pr, tour, title: details.title, body: details.body, headSha: details.headRefOid, diff, files,
48
+ language: tourLanguage(parsed.tour) === "ko" ? "한국어" : "English",
49
+ outPath: option("--out"),
50
+ onProgress: ({ stage, seconds, detail }) => {
51
+ if (stage === "script" && seconds % 15 === 0) console.log(`Claude Code is writing the script… ${seconds}s`);
52
+ if (stage === "render") console.log(`Rendering ${detail}`);
53
+ },
54
+ });
55
+ console.log(`Saved ${path}`);
56
+ }
package/host/short.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Shorts on this computer: Claude Code writes the script from the PR's tour (the panel plays it
2
+ // live), and render-short.mjs turns a script into an MP4 when someone wants a file. Used by the
3
+ // native host and by `pr-docent short` on the command line.
4
+
5
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { checkShort, parseShort } from "../src/short.js";
10
+ import { prepareWorkspace, runClaude } from "./claude.mjs";
11
+ import { renderShort } from "./render-short.mjs";
12
+
13
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14
+
15
+ /** Has Claude Code write and check a short script from the PR's tour; returns the raw JSON. */
16
+ export async function writeShortScript({ pr, tour, title, body, headSha, diff, files, language, model, onProgress = () => {} }) {
17
+ const { work, repo, out } = prepareWorkspace({ files, diff, title, body, headSha });
18
+ try {
19
+ writeFileSync(join(out, "tour.json"), JSON.stringify(tour, null, 2));
20
+ const prRef = `${pr.owner}/${pr.repo}#${pr.number}`;
21
+ const prompt = readFileSync(join(ROOT, "scripts", "short-prompt.md"), "utf8").replace(/{{(\w+)}}/g, (match, name) =>
22
+ ({ PR: prRef, OUT: out, TOOLS: ROOT, LANGUAGE: language || "한국어" })[name] ?? match);
23
+ onProgress({ stage: "script", seconds: 0 });
24
+ await runClaude(prompt, repo, [
25
+ "--model", model || "claude-opus-5-5",
26
+ "--permission-mode", "dontAsk",
27
+ "--allowedTools", `Read,Glob,Grep,Edit(/${out}/short.json),Bash(node ${ROOT}/scripts/check-short.mjs:*)`,
28
+ ], (seconds) => onProgress({ stage: "script", seconds }));
29
+
30
+ let raw;
31
+ try {
32
+ raw = JSON.parse(readFileSync(join(out, "short.json"), "utf8"));
33
+ } catch {
34
+ throw new Error("Claude did not write a short script.");
35
+ }
36
+ const { short, errors } = parseShort(raw);
37
+ const problems = errors.length ? errors : checkShort(short);
38
+ if (problems.length) throw new Error(`The short did not pass the checks: ${problems.slice(0, 3).join(" / ")}`);
39
+ return raw;
40
+ } finally {
41
+ rmSync(work, { recursive: true, force: true });
42
+ }
43
+ }
44
+
45
+ /** Renders a checked short script to an MP4 (Downloads unless [outPath]); returns its path. */
46
+ export async function saveShortVideo({ short: raw, outPath, onProgress = () => {} }) {
47
+ const { short, errors } = parseShort(raw);
48
+ if (!short) throw new Error(`Invalid short: ${errors[0]}`);
49
+ const { pr } = short;
50
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-render-")));
51
+ try {
52
+ const target = outPath ?? freePath(join(homedir(), "Downloads"), `pr-docent-${pr.owner}-${pr.repo}-${pr.number}`);
53
+ return await renderShort({ short, outPath: target, workDir: work, onProgress: (detail) => onProgress({ stage: "render", detail }) });
54
+ } finally {
55
+ rmSync(work, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ /** The script and then the video: what `pr-docent short` does. */
60
+ export async function generateShort(options) {
61
+ const short = await writeShortScript(options);
62
+ return saveShortVideo({ short, outPath: options.outPath, onProgress: options.onProgress });
63
+ }
64
+
65
+ /** dir/name.mp4, or dir/name-2.mp4 and so on when that exists. */
66
+ function freePath(dir, name) {
67
+ for (let i = 1; ; i++) {
68
+ const path = join(dir, `${name}${i === 1 ? "" : `-${i}`}.mp4`);
69
+ if (!existsSync(path)) return path;
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-docent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Guided, narrated review tours on top of GitHub pull request diffs. This package installs the local tour generator for the PR Docent Chrome extension.",
6
6
  "license": "MIT",
@@ -26,12 +26,18 @@
26
26
  "bin/",
27
27
  "host/",
28
28
  "src/diff.js",
29
+ "src/guidelines.js",
30
+ "src/short.js",
31
+ "src/short-scenes.js",
29
32
  "src/tour.js",
33
+ "scripts/check-short.mjs",
30
34
  "scripts/check-tour.mjs",
35
+ "scripts/short-prompt.md",
31
36
  "scripts/tour-prompt.md"
32
37
  ],
33
38
  "scripts": {
34
39
  "test": "node --test tests/*.test.mjs",
35
- "install-host": "node bin/pr-docent.mjs install"
40
+ "install-host": "node bin/pr-docent.mjs install",
41
+ "package": "node scripts/package-extension.mjs"
36
42
  }
37
43
  }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // Validates a short (video script).
3
+ //
4
+ // node scripts/check-short.mjs <short.json> [owner/repo#number]
5
+ //
6
+ // Prints every problem and exits 1, or prints a one-line summary and exits 0.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { checkShort, parseShort } from "../src/short.js";
10
+
11
+ const [shortPath, expectedPr] = process.argv.slice(2);
12
+ if (!shortPath) {
13
+ console.error("usage: node scripts/check-short.mjs <short.json> [owner/repo#number]");
14
+ process.exit(2);
15
+ }
16
+
17
+ const fail = (errors) => {
18
+ console.error(`Short has ${errors.length} problem(s):`);
19
+ for (const error of errors) console.error(`- ${error}`);
20
+ process.exit(1);
21
+ };
22
+
23
+ let raw;
24
+ try {
25
+ raw = JSON.parse(readFileSync(shortPath, "utf8"));
26
+ } catch (error) {
27
+ fail([`${shortPath} is not valid JSON: ${error.message}`]);
28
+ }
29
+ const { short, errors } = parseShort(raw);
30
+ if (errors.length) fail(errors);
31
+ if (expectedPr) {
32
+ const actual = `${short.pr.owner}/${short.pr.repo}#${short.pr.number}`;
33
+ if (actual !== expectedPr) fail([`pr is ${actual}, expected ${expectedPr}`]);
34
+ }
35
+ const problems = checkShort(short);
36
+ if (problems.length) fail(problems);
37
+ console.log(`OK: ${short.scenes.length + 2} scenes`);
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  // Validates a tour against a pull request diff.
3
3
  //
4
- // node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]
4
+ // node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]
5
5
  //
6
6
  // Prints every problem and exits 1, or prints a one-line summary and exits 0.
7
7
  // The diff must use 3 lines of context (`gh pr diff` or `git diff -U3`), as GitHub does.
8
+ // --guidelines points at a copy of the repository's .github/pr-docent.md; a missing file means
9
+ // the default limits.
8
10
 
9
- import { readFileSync } from "node:fs";
10
- import { checkTour, visibleLines } from "../src/diff.js";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
13
+ import { checkBrevity, parseGuidelines } from "../src/guidelines.js";
11
14
  import { flattenSteps, parseTour } from "../src/tour.js";
12
15
 
13
- const [tourPath, diffPath, expectedPr] = process.argv.slice(2);
16
+ const args = process.argv.slice(2);
17
+ const flag = args.indexOf("--guidelines");
18
+ const guidelinesPath = flag >= 0 ? args.splice(flag, 2)[1] : null;
19
+ const [tourPath, diffPath, expectedPr] = args;
14
20
  if (!tourPath || !diffPath) {
15
- console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]");
21
+ console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]");
16
22
  process.exit(2);
17
23
  }
18
24
 
@@ -37,7 +43,13 @@ if (expectedPr) {
37
43
  if (actual !== expectedPr) fail([`pr is ${actual}, expected ${expectedPr}`]);
38
44
  }
39
45
 
40
- const problems = checkTour(tour, visibleLines(readFileSync(diffPath, "utf8")));
46
+ const diff = readFileSync(diffPath, "utf8");
47
+ const guidelines = parseGuidelines(guidelinesPath && existsSync(guidelinesPath) ? readFileSync(guidelinesPath, "utf8") : "");
48
+ if (guidelines.errors.length) fail(guidelines.errors.map((error) => `.github/pr-docent.md: ${error}`));
49
+ const problems = [
50
+ ...checkTour(tour, visibleLines(diff)),
51
+ ...checkBrevity(tour, { changedLines: changedLineCount(diff), limits: guidelines.limits }),
52
+ ];
41
53
  if (problems.length) fail(problems);
42
54
 
43
55
  console.log(`OK: ${tour.chapters.length} chapters, ${flattenSteps(tour).length} steps, ${tour.skipped.length} skipped files`);