claude-plan-review 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pavlo.p@mate.academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # claude-plan-review
2
+
3
+ Review Claude Code **plans** in a local, GitHub-style web UI β€” leave persistent
4
+ comments, approve or send changes back to Claude, and keep every plan iteration
5
+ with its comments and diffs. All from the browser; no console needed.
6
+
7
+ When Claude finishes a plan in **plan mode**, a `PreToolUse` hook on the
8
+ `ExitPlanMode` tool intercepts it, opens the plan in your browser, and **blocks**
9
+ until you decide:
10
+
11
+ - **Approve** β†’ Claude exits plan mode and starts implementing.
12
+ - **Request changes** β†’ your line + general comments are sent back as the denial
13
+ reason; Claude stays in plan mode and revises, producing a new version.
14
+
15
+ Everything is stored **outside your repo** (`~/.claude/plan-review/`), keyed by
16
+ the worktree path β€” so no `.gitignore` churn and no risk of committing review data.
17
+ Git worktrees are scoped separately automatically.
18
+
19
+ ## Features
20
+
21
+ - πŸ“„ GitHub-style **rendered preview** of the plan markdown
22
+ - πŸ’¬ **Line comments** + general comments, **persistent** across reloads
23
+ - πŸ•‘ **Version history** β€” every `ExitPlanMode` is an immutable snapshot
24
+ - πŸ”€ **Diff** current vs any previous version β€” **side-by-side or unified** (toggle)
25
+ - βœ… **Approve / request-changes** straight from the UI, fed back to Claude
26
+ - πŸ—‚ Per-worktree scoping; per-project opt-in (only runs where you enable the hook)
27
+ - ⚑ Tiny, zero-framework UI; Bun server; one dependency (`marked`)
28
+
29
+ ## Requirements
30
+
31
+ - **Either** [Bun](https://bun.sh) β‰₯ 1.1 **or** [Node](https://nodejs.org) β‰₯ 18 β€” the
32
+ tool is plain ESM JavaScript and runs unchanged on both. `init` auto-detects which
33
+ you have (preferring Bun) and writes the matching hook command; override with `--runtime`.
34
+ - Claude Code β‰₯ 2.1 (verified against 2.1.185)
35
+
36
+ ## Setup
37
+
38
+ Two ways to install, depending on whether it's been published to npm.
39
+
40
+ ### Option A β€” published (recommended for end users)
41
+
42
+ No clone, no checkout. From inside the project you want to enable:
43
+
44
+ ```bash
45
+ cd /path/to/your/project
46
+ bunx claude-plan-review init --published # if you have Bun
47
+ npx claude-plan-review init --published # if you have Node
48
+ # add --local to write .claude/settings.local.json instead (personal, gitignored)
49
+ ```
50
+
51
+ `--published` makes the hook run via the package runner (`bunx`/`npx`), so it works on
52
+ any machine β€” nothing to keep in your repo but the one settings entry.
53
+
54
+ ### Option B β€” from a local clone (for hacking on it)
55
+
56
+ ```bash
57
+ git clone https://github.com/pavlo-petrychenko/claude-plan-preview-local
58
+ cd claude-plan-review
59
+ bun install # or: npm install
60
+ bun src/cli.js init /path/to/your/project # …or…
61
+ node src/cli.js init /path/to/your/project # writes an absolute-path hook command
62
+ ```
63
+
64
+ ### Either way
65
+
66
+ **Reopen the `/hooks` menu once (or restart Claude Code)** in that project so the
67
+ new hook is picked up. Next time you finish a plan in plan mode, your browser opens
68
+ to the review. The server auto-starts on first use; nothing else to run.
69
+
70
+ ## Usage
71
+
72
+ Use `bun` or `node` interchangeably (or `bunx`/`npx claude-plan-review …` when published):
73
+
74
+ | Command | What it does |
75
+ | --- | --- |
76
+ | `… cli.js init [dir] [--local] [--published] [--runtime bun\|node]` | Wire the `ExitPlanMode` hook into a project |
77
+ | `… cli.js serve [port]` | Start the review server manually (default `4607`) |
78
+ | `… cli.js stop` | Stop the running server |
79
+
80
+ The server auto-starts on the first plan and stays up, so you can browse history
81
+ anytime at `http://localhost:4607`.
82
+
83
+ ## Configuration (env vars)
84
+
85
+ | Var | Default | Meaning |
86
+ | --- | --- | --- |
87
+ | `PLAN_REVIEW_HOME` | `~/.claude/plan-review` | Where versions + comments are stored |
88
+ | `PLAN_REVIEW_PORT` | `4607` | Server port |
89
+ | `PLAN_REVIEW_TIMEOUT` | `1800` | Seconds the hook blocks waiting for your decision before falling back to Claude's normal approval prompt |
90
+
91
+ > The hook entry sets `timeout: 1800` so Claude Code waits while you review.
92
+
93
+ ## How it works
94
+
95
+ ```
96
+ Claude finishes plan ──> PreToolUse hook (ExitPlanMode)
97
+ β”‚ reads {plan, planFilePath, cwd, session_id} from stdin
98
+ β”‚ stores a new version under ~/.claude/plan-review/<cwd-key>/
99
+ β”‚ ensures the server is up, opens the browser
100
+ β”‚ BLOCKS, polling for your decision
101
+ browser (you) β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
102
+ approve ──> hook emits {permissionDecision:"allow"} ──> Claude implements
103
+ request ──> hook emits {permissionDecision:"deny", reason:...} ──> Claude revises (new version)
104
+ ```
105
+
106
+ ## Storage layout
107
+
108
+ ```
109
+ ~/.claude/plan-review/
110
+ projects/<sanitized-cwd>/
111
+ meta.json
112
+ versions/0001.md 0001.json 0002.md 0002.json # immutable snapshots
113
+ comments/0001.json 0002.json # comments per version
114
+ reviews/<id>.json # pending/resolved review requests
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "claude-plan-review",
3
+ "version": "0.1.0",
4
+ "description": "Review, comment on, and approve/reject Claude Code plans in a local GitHub-style web UI β€” with persistent comments, version history, and diffs.",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-plan-review": "src/cli.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "serve": "node src/server.js",
16
+ "hook": "node src/hook.js",
17
+ "stop": "node src/cli.js stop"
18
+ },
19
+ "dependencies": {
20
+ "marked": "^14.1.3"
21
+ },
22
+ "engines": {
23
+ "node": ">=18",
24
+ "bun": ">=1.1.0"
25
+ },
26
+ "keywords": [
27
+ "claude",
28
+ "claude-code",
29
+ "plan",
30
+ "plan-mode",
31
+ "code-review",
32
+ "hooks",
33
+ "exitplanmode"
34
+ ],
35
+ "homepage": "https://github.com/pavlo-petrychenko/claude-plan-preview-local#readme",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/pavlo-petrychenko/claude-plan-preview-local.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/pavlo-petrychenko/claude-plan-preview-local/issues"
42
+ },
43
+ "author": "pavlo.p@mate.academy",
44
+ "license": "MIT"
45
+ }
package/src/cli.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
7
+
8
+ const HERE = dirname(fileURLToPath(import.meta.url));
9
+ const HOOK_SCRIPT = join(HERE, "hook.js");
10
+
11
+ function canRun(bin) {
12
+ try {
13
+ return spawnSync(bin, ["--version"], { stdio: "ignore" }).status === 0;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function resolveBin(bin) {
20
+ const finder = process.platform === "win32" ? "where" : "which";
21
+ try {
22
+ const r = spawnSync(finder, [bin], { encoding: "utf8" });
23
+ if (r.status === 0) return r.stdout.split(/\r?\n/)[0].trim() || bin;
24
+ } catch {}
25
+ return bin; // fall back to bare name (resolved via PATH at hook time)
26
+ }
27
+
28
+ function chooseRuntime(args) {
29
+ const i = args.indexOf("--runtime");
30
+ if (i >= 0 && args[i + 1]) return args[i + 1];
31
+ // prefer Bun when present, else Node
32
+ return canRun("bun") ? "bun" : "node";
33
+ }
34
+
35
+ function hookCommand(args) {
36
+ const runtime = chooseRuntime(args);
37
+ if (args.includes("--published") || args.includes("--bunx")) {
38
+ // portable command using the runtime's package runner
39
+ return runtime === "node" ? "npx claude-plan-review hook" : "bunx claude-plan-review hook";
40
+ }
41
+ return `${resolveBin(runtime)} ${HOOK_SCRIPT}`;
42
+ }
43
+
44
+ function readJSON(path, fallback) {
45
+ try {
46
+ return JSON.parse(readFileSync(path, "utf8"));
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+
52
+ function cmdInit(args) {
53
+ const local = args.includes("--local");
54
+ const dirArg = args.find((a) => !a.startsWith("--") && a !== chooseRuntime(args));
55
+ const projectDir = resolve(dirArg || process.cwd());
56
+ const settingsFile = join(projectDir, ".claude", local ? "settings.local.json" : "settings.json");
57
+ mkdirSync(dirname(settingsFile), { recursive: true });
58
+
59
+ const settings = readJSON(settingsFile, {});
60
+ settings.hooks ??= {};
61
+ settings.hooks.PreToolUse ??= [];
62
+
63
+ const command = hookCommand(args);
64
+ const already = settings.hooks.PreToolUse.some(
65
+ (entry) =>
66
+ entry?.matcher === "ExitPlanMode" &&
67
+ (entry.hooks || []).some((h) => h?.command?.includes("hook.js") || h?.command === command),
68
+ );
69
+
70
+ if (already) {
71
+ console.log(`βœ“ ExitPlanMode plan-review hook already present in ${settingsFile}`);
72
+ } else {
73
+ settings.hooks.PreToolUse.push({
74
+ matcher: "ExitPlanMode",
75
+ hooks: [{ type: "command", command, timeout: 1800 }],
76
+ });
77
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
78
+ console.log(`βœ“ Wrote ExitPlanMode plan-review hook to ${settingsFile}`);
79
+ }
80
+ console.log(`\nRuntime: ${chooseRuntime(args)}\nCommand: ${command}`);
81
+ console.log(
82
+ `\nReopen the /hooks menu once (or restart Claude Code) in this project so the new hook is picked up.\n` +
83
+ `Then finish a plan in plan mode β€” your browser will open the review.`,
84
+ );
85
+ }
86
+
87
+ async function cmdServe(args) {
88
+ const port = Number(args.find((a) => /^\d+$/.test(a))) || DEFAULT_PORT;
89
+ const { startServer } = await import("./server.js");
90
+ startServer(port);
91
+ console.log(`Open http://localhost:${port} (Ctrl+C to stop)`);
92
+ }
93
+
94
+ function cmdStop() {
95
+ const info = readJSON(SERVER_FILE, null);
96
+ if (!info?.pid) {
97
+ console.log("No running plan-review server recorded.");
98
+ return;
99
+ }
100
+ try {
101
+ process.kill(info.pid, "SIGTERM");
102
+ console.log(`Stopped plan-review server (pid ${info.pid}).`);
103
+ } catch {
104
+ console.log("Server not running; clearing stale record.");
105
+ }
106
+ rmSync(SERVER_FILE, { force: true });
107
+ }
108
+
109
+ function usage() {
110
+ console.log(`claude-plan-review β€” review Claude Code plans in your browser (runs on Bun or Node β‰₯18)
111
+
112
+ Usage:
113
+ claude-plan-review init [dir] [--local] [--published] [--runtime bun|node]
114
+ Wire the ExitPlanMode hook into a project.
115
+ Runtime auto-detects (Bun if installed, else Node).
116
+ --local β†’ .claude/settings.local.json
117
+ --published β†’ portable bunx/npx command (after npm publish)
118
+ --runtime β†’ force a runtime
119
+ claude-plan-review serve [port] Start the review server (default ${DEFAULT_PORT})
120
+ claude-plan-review stop Stop the running server
121
+ claude-plan-review hook (internal) the PreToolUse hook entry
122
+ `);
123
+ }
124
+
125
+ const [sub, ...rest] = process.argv.slice(2);
126
+ switch (sub) {
127
+ case "init":
128
+ cmdInit(rest);
129
+ break;
130
+ case "serve":
131
+ await cmdServe(rest);
132
+ break;
133
+ case "stop":
134
+ cmdStop();
135
+ break;
136
+ case "hook":
137
+ await import("./hook.js");
138
+ break;
139
+ default:
140
+ usage();
141
+ }
package/src/hook.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Claude Code PreToolUse hook for the ExitPlanMode tool. Runs on Bun or Node β‰₯18.
3
+ *
4
+ * Verified mechanics (Claude Code 2.1.185):
5
+ * stdin = { tool_name:"ExitPlanMode", tool_input:{ plan, planFilePath }, cwd, session_id, tool_use_id, ... }
6
+ * stdout = { hookSpecificOutput:{ hookEventName:"PreToolUse",
7
+ * permissionDecision:"allow"|"deny",
8
+ * permissionDecisionReason: string } }
9
+ * deny + reason β†’ reason is fed back to Claude, which stays in plan mode and revises.
10
+ * allow β†’ plan approved, exits plan mode.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { DEFAULT_PORT, SERVER_FILE } from "./paths.js";
17
+ import { getReview, recordPlan } from "./store.js";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const SERVER_SCRIPT = join(HERE, "server.js");
21
+ const TIMEOUT_MS = Number(process.env.PLAN_REVIEW_TIMEOUT || 1800) * 1000;
22
+ const POLL_MS = 700;
23
+
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+
26
+ function out(decision, reason, sys) {
27
+ process.stdout.write(
28
+ JSON.stringify({
29
+ hookSpecificOutput: {
30
+ hookEventName: "PreToolUse",
31
+ permissionDecision: decision,
32
+ permissionDecisionReason: reason,
33
+ },
34
+ systemMessage: sys,
35
+ }),
36
+ );
37
+ }
38
+
39
+ function serverPort() {
40
+ try {
41
+ return JSON.parse(readFileSync(SERVER_FILE, "utf8")).port || DEFAULT_PORT;
42
+ } catch {
43
+ return DEFAULT_PORT;
44
+ }
45
+ }
46
+
47
+ async function isUp(port) {
48
+ try {
49
+ const res = await fetch(`http://localhost:${port}/api/health`, {
50
+ signal: AbortSignal.timeout(500),
51
+ });
52
+ return res.ok;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ async function ensureServer() {
59
+ const port = serverPort();
60
+ if (await isUp(port)) return port;
61
+ // spawn detached with the SAME runtime that's running this hook (node or bun)
62
+ const child = spawn(process.execPath, [SERVER_SCRIPT, String(port)], {
63
+ detached: true,
64
+ stdio: "ignore",
65
+ });
66
+ child.unref();
67
+ for (let i = 0; i < 40; i++) {
68
+ if (await isUp(port)) return port;
69
+ await sleep(150);
70
+ }
71
+ return port; // best effort
72
+ }
73
+
74
+ function openBrowser(targetUrl) {
75
+ const cmd =
76
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
77
+ const args = process.platform === "win32" ? ["/c", "start", "", targetUrl] : [targetUrl];
78
+ try {
79
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
80
+ } catch {
81
+ /* non-fatal */
82
+ }
83
+ }
84
+
85
+ async function main() {
86
+ let input = {};
87
+ try {
88
+ input = JSON.parse(readFileSync(0, "utf8")); // fd 0 = stdin (works on node + bun)
89
+ } catch {
90
+ process.exit(0); // can't parse β†’ don't interfere
91
+ }
92
+
93
+ if (input.tool_name !== "ExitPlanMode") process.exit(0);
94
+
95
+ const plan = input.tool_input?.plan ?? "";
96
+ if (!plan.trim()) process.exit(0);
97
+
98
+ const { key, version, reviewId } = recordPlan({
99
+ cwd: input.cwd || process.cwd(),
100
+ plan,
101
+ planFilePath: input.tool_input?.planFilePath,
102
+ sessionId: input.session_id,
103
+ toolUseId: input.tool_use_id,
104
+ });
105
+
106
+ const port = await ensureServer();
107
+ const reviewUrl = `http://localhost:${port}/?project=${encodeURIComponent(
108
+ key,
109
+ )}&version=${version}&review=${reviewId}`;
110
+ openBrowser(reviewUrl);
111
+
112
+ // block until the UI resolves the review (or we time out)
113
+ const deadline = Date.now() + TIMEOUT_MS;
114
+ while (Date.now() < deadline) {
115
+ const review = getReview(reviewId);
116
+ if (review && review.status === "approved") {
117
+ out("allow", "Approved in the plan-review UI.", "βœ… Plan approved via plan-review");
118
+ process.exit(0);
119
+ }
120
+ if (review && review.status === "rejected") {
121
+ out(
122
+ "deny",
123
+ review.reason || "Changes requested in the plan-review UI.",
124
+ "πŸ“ Changes requested via plan-review",
125
+ );
126
+ process.exit(0);
127
+ }
128
+ await sleep(POLL_MS);
129
+ }
130
+
131
+ process.stderr.write(`plan-review: timed out after ${TIMEOUT_MS / 1000}s β€” falling back\n`);
132
+ process.exit(0);
133
+ }
134
+
135
+ main();
package/src/paths.js ADDED
@@ -0,0 +1,25 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /** Top-level storage root β€” NOT inside any project repo (no gitignore needed). */
5
+ export const ROOT =
6
+ process.env.PLAN_REVIEW_HOME || join(homedir(), ".claude", "plan-review");
7
+
8
+ export const PROJECTS_DIR = join(ROOT, "projects");
9
+ export const REVIEWS_DIR = join(ROOT, "reviews");
10
+ export const SERVER_FILE = join(ROOT, "server.json");
11
+
12
+ export const DEFAULT_PORT = Number(process.env.PLAN_REVIEW_PORT || 4607);
13
+
14
+ /**
15
+ * Derive a stable per-worktree key from an absolute cwd, mirroring the
16
+ * sanitized-path convention Claude Code itself uses for ~/.claude/projects/.
17
+ * Distinct git worktrees have distinct paths β†’ distinct keys automatically.
18
+ */
19
+ export function projectKey(cwd) {
20
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
21
+ }
22
+
23
+ export function projectDir(key) {
24
+ return join(PROJECTS_DIR, key);
25
+ }
package/src/server.js ADDED
@@ -0,0 +1,187 @@
1
+ import http from "node:http";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, realpathSync } from "node:fs";
3
+ import { dirname, extname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { marked } from "marked";
6
+ import { DEFAULT_PORT, ROOT, SERVER_FILE } from "./paths.js";
7
+ import {
8
+ addComment,
9
+ deleteComment,
10
+ getProjectMeta,
11
+ getReview,
12
+ getVersion,
13
+ listComments,
14
+ listProjects,
15
+ listReviews,
16
+ listVersions,
17
+ pruneResolvedReviews,
18
+ resolveReview,
19
+ } from "./store.js";
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const UI_DIR = join(HERE, "ui");
23
+ const pkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
24
+
25
+ marked.setOptions({ gfm: true, breaks: false });
26
+
27
+ function renderMarkdown(md) {
28
+ const html = marked.parse(md, { async: false });
29
+ // light sanitization β€” local tool, content authored by your own Claude session
30
+ return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
31
+ }
32
+
33
+ const CONTENT_TYPES = {
34
+ ".html": "text/html; charset=utf-8",
35
+ ".js": "text/javascript; charset=utf-8",
36
+ ".css": "text/css; charset=utf-8",
37
+ };
38
+
39
+ function sendJSON(res, data, status = 200) {
40
+ const body = JSON.stringify(data);
41
+ res.writeHead(status, { "content-type": "application/json" });
42
+ res.end(body);
43
+ }
44
+
45
+ function sendStatic(res, name) {
46
+ const file = join(UI_DIR, name);
47
+ if (!existsSync(file)) {
48
+ res.writeHead(404);
49
+ res.end("Not found");
50
+ return;
51
+ }
52
+ res.writeHead(200, { "content-type": CONTENT_TYPES[extname(name)] || "application/octet-stream" });
53
+ res.end(readFileSync(file));
54
+ }
55
+
56
+ function readBody(req) {
57
+ return new Promise((resolve) => {
58
+ let data = "";
59
+ req.on("data", (c) => (data += c));
60
+ req.on("end", () => resolve(data));
61
+ });
62
+ }
63
+
64
+ export function startServer(port = DEFAULT_PORT) {
65
+ mkdirSync(ROOT, { recursive: true });
66
+ pruneResolvedReviews();
67
+
68
+ const server = http.createServer(async (req, res) => {
69
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
70
+ const p = url.pathname;
71
+ const seg = p.split("/").filter(Boolean);
72
+ try {
73
+ if (p === "/") return sendStatic(res, "index.html");
74
+ if (p === "/app.js") return sendStatic(res, "app.js");
75
+ if (p === "/style.css") return sendStatic(res, "style.css");
76
+ if (seg[0] === "api") return await handleApi(req, res, seg.slice(1), url);
77
+ res.writeHead(404);
78
+ res.end("Not found");
79
+ } catch (e) {
80
+ sendJSON(res, { error: String(e?.message || e) }, 500);
81
+ }
82
+ });
83
+
84
+ server.on("error", (e) => {
85
+ if (e.code === "EADDRINUSE") {
86
+ // another instance already owns the port β€” fine.
87
+ console.log(`plan-review server already running on :${port}`);
88
+ process.exit(0);
89
+ }
90
+ throw e;
91
+ });
92
+
93
+ server.listen(port, () => {
94
+ writeFileSync(
95
+ SERVER_FILE,
96
+ JSON.stringify({ port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2),
97
+ );
98
+ console.log(`plan-review server on http://localhost:${port}`);
99
+ });
100
+
101
+ const cleanup = () => {
102
+ try {
103
+ rmSync(SERVER_FILE, { force: true });
104
+ } catch {}
105
+ process.exit(0);
106
+ };
107
+ process.on("SIGINT", cleanup);
108
+ process.on("SIGTERM", cleanup);
109
+ return server;
110
+ }
111
+
112
+ async function handleApi(req, res, seg, url) {
113
+ const [a, b, c, d, e] = seg;
114
+ const method = req.method;
115
+
116
+ if (a === "health") return sendJSON(res, { ok: true });
117
+ if (a === "version") return sendJSON(res, { version: pkg.version });
118
+
119
+ if (a === "projects" && !b) return sendJSON(res, listProjects());
120
+
121
+ if (a === "projects" && b && !c) {
122
+ const meta = getProjectMeta(b);
123
+ if (!meta) return sendJSON(res, { error: "no such project" }, 404);
124
+ return sendJSON(res, { meta, versions: listVersions(b) });
125
+ }
126
+
127
+ // /api/projects/:key/diff?from=&to=
128
+ if (a === "projects" && b && c === "diff") {
129
+ const from = Number(url.searchParams.get("from"));
130
+ const to = Number(url.searchParams.get("to"));
131
+ return sendJSON(res, {
132
+ from: { n: from, markdown: getVersion(b, from)?.markdown ?? "" },
133
+ to: { n: to, markdown: getVersion(b, to)?.markdown ?? "" },
134
+ });
135
+ }
136
+
137
+ // /api/projects/:key/versions/:n ...
138
+ if (a === "projects" && b && c === "versions" && d) {
139
+ const key = b;
140
+ const n = Number(d);
141
+ if (!e) {
142
+ const v = getVersion(key, n);
143
+ if (!v) return sendJSON(res, { error: "no such version" }, 404);
144
+ return sendJSON(res, { n, markdown: v.markdown, html: renderMarkdown(v.markdown), meta: v.meta });
145
+ }
146
+ if (e === "comments" && !seg[5]) {
147
+ if (method === "GET") return sendJSON(res, listComments(key, n));
148
+ if (method === "POST") {
149
+ const body = JSON.parse((await readBody(req)) || "{}");
150
+ if (!body.body?.trim()) return sendJSON(res, { error: "empty comment" }, 400);
151
+ return sendJSON(
152
+ res,
153
+ addComment(key, n, {
154
+ line: body.line ?? null,
155
+ lineEnd: body.lineEnd ?? null,
156
+ body: body.body.trim(),
157
+ }),
158
+ );
159
+ }
160
+ }
161
+ // /api/projects/:key/versions/:n/comments/:id (DELETE)
162
+ if (e === "comments" && seg[5] && method === "DELETE") {
163
+ return sendJSON(res, { ok: deleteComment(key, n, seg[5]) });
164
+ }
165
+ }
166
+
167
+ // /api/reviews ...
168
+ if (a === "reviews" && !b) return sendJSON(res, listReviews());
169
+ if (a === "reviews" && b === "pending")
170
+ return sendJSON(res, listReviews().filter((r) => r.status === "pending"));
171
+ if (a === "reviews" && b && !c) {
172
+ const r = getReview(b);
173
+ return r ? sendJSON(res, r) : sendJSON(res, { error: "no such review" }, 404);
174
+ }
175
+ if (a === "reviews" && b && c === "decision" && method === "POST") {
176
+ const body = JSON.parse((await readBody(req)) || "{}");
177
+ const r = resolveReview(b, body.decision);
178
+ return r ? sendJSON(res, r) : sendJSON(res, { error: "no such review" }, 404);
179
+ }
180
+
181
+ return sendJSON(res, { error: "unknown endpoint" }, 404);
182
+ }
183
+
184
+ // run directly: `node src/server.js [port]` (or bun)
185
+ const invokedDirectly =
186
+ process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
187
+ if (invokedDirectly) startServer(Number(process.argv[2]) || DEFAULT_PORT);