diffprism 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@ DiffPrism gives you a visual review step for AI-written code — stage your chan
15
15
  - **Keyboard shortcuts** — `j`/`k` navigate files, `s` cycles file status
16
16
  - **Three-way decisions** — approve, request changes, or approve with comments
17
17
  - **Branch display** — current git branch shown in the review header
18
+ - **Global server mode** — `diffprism server` runs a persistent multi-session review server, multiple agents post reviews to one browser tab
19
+ - **Multi-session UI** — session list with status badges, branch info, file counts, and change stats when running in server mode
18
20
 
19
21
  ## Quick Start
20
22
 
@@ -85,6 +87,32 @@ When `diffprism watch` is running:
85
87
 
86
88
  Stop the watcher with `Ctrl+C`.
87
89
 
90
+ ### Global Server Mode (multi-session)
91
+
92
+ Run a persistent server that accepts reviews from multiple Claude Code sessions and displays them in one browser tab:
93
+
94
+ ```bash
95
+ # Start the global server (auto-runs global setup if needed)
96
+ diffprism server
97
+
98
+ # Check status and list active sessions
99
+ diffprism server status
100
+
101
+ # Stop the server
102
+ diffprism server stop
103
+ ```
104
+
105
+ When the global server is running, MCP tools automatically detect it and route reviews there instead of opening ephemeral browser tabs. Each review appears as a session in the multi-session UI — click to switch between them.
106
+
107
+ **Global setup** (optional, `diffprism server` runs this automatically):
108
+
109
+ ```bash
110
+ # Configure skill + permissions globally (no git repo required)
111
+ diffprism setup --global
112
+ ```
113
+
114
+ Per-project MCP registration (`.mcp.json`) is still needed via `diffprism setup` in each project.
115
+
88
116
  ## MCP Tool Reference
89
117
 
90
118
  The MCP server exposes three tools:
@@ -162,12 +190,12 @@ npx tsc --noEmit -p packages/core/tsconfig.json # Type-check a package
162
190
  ### Project Structure
163
191
 
164
192
  ```
165
- packages/core — Shared types, pipeline orchestrator, WebSocket bridge
193
+ packages/core — Shared types, pipeline orchestrator, WebSocket bridge, global server
166
194
  packages/git — Git diff extraction + unified diff parser
167
195
  packages/analysis — Deterministic review briefing (complexity, test gaps, patterns)
168
- packages/ui — React 19 + Vite 6 + Tailwind + Zustand diff viewer
169
- packages/mcp-server — MCP tool server (open_review)
170
- cli/ — Commander CLI entry point
196
+ packages/ui — React 19 + Vite 6 + Tailwind + Zustand diff viewer + session list
197
+ packages/mcp-server — MCP tool server, auto-routes to global server when available
198
+ cli/ — Commander CLI (review, serve, setup, server commands)
171
199
  ```
172
200
 
173
201
  ### Requirements
package/dist/bin.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  startGlobalServer,
7
7
  startReview,
8
8
  startWatch
9
- } from "./chunk-NGHUHDAM.js";
9
+ } from "./chunk-4WN4FIY4.js";
10
10
 
11
11
  // cli/src/index.ts
12
12
  import { Command } from "commander";
@@ -111,7 +111,13 @@ The tool blocks until the user submits their review in the browser. When it retu
111
111
  ### 5. Error Handling
112
112
 
113
113
  If the \`mcp__diffprism__open_review\` tool is not available:
114
- - Tell the user: "The DiffPrism MCP server isn't configured. Run \`npx diffprism start\` to set it up, then restart Claude Code."
114
+ - Tell the user: "The DiffPrism MCP server isn't configured. Run \`npx diffprism setup\` to set it up, then restart Claude Code."
115
+
116
+ ## Global Server Mode
117
+
118
+ When a global DiffPrism server is running (\`diffprism server\`), the MCP tools automatically detect it and route reviews there instead of opening a new browser tab each time. The review appears in the server's multi-session UI at the existing browser tab.
119
+
120
+ This is transparent \u2014 the same \`open_review\`, \`update_review_context\`, and \`get_review_result\` tools work the same way. No changes to the workflow are needed.
115
121
 
116
122
  ## Watch Mode: Waiting for Review Feedback
117
123
 
@@ -179,8 +185,8 @@ function setupMcpJson(gitRoot, force) {
179
185
  writeJsonFile(filePath, { ...existing, mcpServers: servers });
180
186
  return { action, filePath };
181
187
  }
182
- function setupClaudeSettings(gitRoot, force) {
183
- const filePath = path.join(gitRoot, ".claude", "settings.json");
188
+ function setupClaudeSettings(baseDir, force) {
189
+ const filePath = path.join(baseDir, ".claude", "settings.json");
184
190
  const existing = readJsonFile(filePath);
185
191
  const permissions = existing.permissions ?? {};
186
192
  const allow = permissions.allow ?? [];
@@ -324,54 +330,59 @@ async function setupGitignore(gitRoot) {
324
330
  return { action: "created", filePath };
325
331
  }
326
332
  async function setup(flags) {
333
+ const force = flags.force ?? false;
334
+ const global = flags.global ?? false;
335
+ const quiet = flags.quiet ?? false;
336
+ const result = { created: [], updated: [], skipped: [] };
337
+ const home = os.homedir();
338
+ if (global) {
339
+ if (!quiet) {
340
+ console.log("Setting up DiffPrism globally...\n");
341
+ }
342
+ const skill2 = setupSkill("", true, force);
343
+ result[skill2.action].push(skill2.filePath);
344
+ const settings2 = setupClaudeSettings(home, force);
345
+ result[settings2.action].push(settings2.filePath);
346
+ if (!quiet) {
347
+ printSummary(result, home);
348
+ console.log("\n\u2713 DiffPrism configured globally.\n");
349
+ console.log("Next steps:");
350
+ console.log(" 1. Run `diffprism server` to start the global review server");
351
+ console.log(" 2. In each project, run `diffprism setup` to register the MCP server");
352
+ console.log(" 3. Use /review in Claude Code to review your changes\n");
353
+ }
354
+ return result;
355
+ }
327
356
  const gitRoot = findGitRoot(process.cwd());
328
357
  if (!gitRoot) {
329
358
  console.error(
330
359
  "Error: Not in a git repository. Run this command from inside a git project."
331
360
  );
361
+ console.error(
362
+ "Tip: Use `diffprism setup --global` to configure DiffPrism globally without a git repo."
363
+ );
332
364
  process.exit(1);
333
365
  return { created: [], updated: [], skipped: [] };
334
366
  }
335
- const force = flags.force ?? false;
336
- const global = flags.global ?? false;
337
- const quiet = flags.quiet ?? false;
338
367
  if (!quiet) {
339
368
  console.log("Setting up DiffPrism for Claude Code...\n");
340
369
  }
341
- const result = { created: [], updated: [], skipped: [] };
342
370
  const gitignore = await setupGitignore(gitRoot);
343
371
  result[gitignore.action].push(gitignore.filePath);
344
372
  const mcp = setupMcpJson(gitRoot, force);
345
- result[mcp.action === "skipped" ? "skipped" : mcp.action === "created" ? "created" : "updated"].push(mcp.filePath);
373
+ result[mcp.action].push(mcp.filePath);
346
374
  const settings = setupClaudeSettings(gitRoot, force);
347
- result[settings.action === "skipped" ? "skipped" : settings.action === "created" ? "created" : "updated"].push(settings.filePath);
375
+ result[settings.action].push(settings.filePath);
348
376
  const cleaned = cleanDiffprismHooks(gitRoot);
349
377
  if (cleaned.removed > 0 && !quiet) {
350
378
  console.log(` Cleaned ${cleaned.removed} stale hook(s)`);
351
379
  }
352
380
  const hook = setupStopHook(gitRoot, force);
353
- result[hook.action === "skipped" ? "skipped" : hook.action === "created" ? "created" : "updated"].push(hook.filePath);
354
- const skill = setupSkill(gitRoot, global, force);
355
- result[skill.action === "skipped" ? "skipped" : skill.action === "created" ? "created" : "updated"].push(skill.filePath);
381
+ result[hook.action].push(hook.filePath);
382
+ const skill = setupSkill(gitRoot, false, force);
383
+ result[skill.action].push(skill.filePath);
356
384
  if (!quiet) {
357
- if (result.created.length > 0) {
358
- console.log("Created:");
359
- for (const f of result.created) {
360
- console.log(` + ${path.relative(gitRoot, f)}`);
361
- }
362
- }
363
- if (result.updated.length > 0) {
364
- console.log("Updated:");
365
- for (const f of result.updated) {
366
- console.log(` ~ ${path.relative(gitRoot, f)}`);
367
- }
368
- }
369
- if (result.skipped.length > 0) {
370
- console.log("Skipped (already configured):");
371
- for (const f of result.skipped) {
372
- console.log(` - ${path.relative(gitRoot, f)}`);
373
- }
374
- }
385
+ printSummary(result, gitRoot);
375
386
  console.log("\n\u2713 DiffPrism configured for Claude Code.\n");
376
387
  console.log("Next steps:");
377
388
  console.log(" 1. Restart Claude Code to pick up the MCP configuration");
@@ -380,6 +391,41 @@ async function setup(flags) {
380
391
  }
381
392
  return result;
382
393
  }
394
+ function printSummary(result, baseDir) {
395
+ if (result.created.length > 0) {
396
+ console.log("Created:");
397
+ for (const f of result.created) {
398
+ console.log(` + ${path.relative(baseDir, f) || f}`);
399
+ }
400
+ }
401
+ if (result.updated.length > 0) {
402
+ console.log("Updated:");
403
+ for (const f of result.updated) {
404
+ console.log(` ~ ${path.relative(baseDir, f) || f}`);
405
+ }
406
+ }
407
+ if (result.skipped.length > 0) {
408
+ console.log("Skipped (already configured):");
409
+ for (const f of result.skipped) {
410
+ console.log(` - ${path.relative(baseDir, f) || f}`);
411
+ }
412
+ }
413
+ }
414
+ function isGlobalSetupDone() {
415
+ const home = os.homedir();
416
+ const skillPath = path.join(home, ".claude", "skills", "review", "SKILL.md");
417
+ const settingsPath = path.join(home, ".claude", "settings.json");
418
+ if (!fs.existsSync(skillPath)) return false;
419
+ const settings = readJsonFile(settingsPath);
420
+ const permissions = settings.permissions ?? {};
421
+ const allow = permissions.allow ?? [];
422
+ const toolNames = [
423
+ "mcp__diffprism__open_review",
424
+ "mcp__diffprism__update_review_context",
425
+ "mcp__diffprism__get_review_result"
426
+ ];
427
+ return toolNames.every((t) => allow.includes(t));
428
+ }
383
429
 
384
430
  // cli/src/commands/start.ts
385
431
  async function start(ref, flags) {
@@ -502,6 +548,11 @@ async function server(flags) {
502
548
  process.exit(1);
503
549
  return;
504
550
  }
551
+ if (!isGlobalSetupDone()) {
552
+ console.log("Running global setup...\n");
553
+ await setup({ global: true, quiet: false });
554
+ console.log("");
555
+ }
505
556
  const httpPort = flags.port ? parseInt(flags.port, 10) : void 0;
506
557
  const wsPort = flags.wsPort ? parseInt(flags.wsPort, 10) : void 0;
507
558
  try {
@@ -578,13 +629,13 @@ async function serverStop() {
578
629
 
579
630
  // cli/src/index.ts
580
631
  var program = new Command();
581
- program.name("diffprism").description("Local-first code review tool for agent-generated changes").version(true ? "0.15.0" : "0.0.0-dev");
632
+ program.name("diffprism").description("Local-first code review tool for agent-generated changes").version(true ? "0.17.0" : "0.0.0-dev");
582
633
  program.command("review [ref]").description("Open a browser-based diff review").option("--staged", "Review staged changes").option("--unstaged", "Review unstaged changes").option("-t, --title <title>", "Review title").option("--dev", "Use Vite dev server with HMR instead of static files").action(review);
583
634
  program.command("start [ref]").description("Set up DiffPrism and start watching for changes").option("--staged", "Watch staged changes").option("--unstaged", "Watch unstaged changes").option("-t, --title <title>", "Review title").option("--interval <ms>", "Poll interval in milliseconds (default: 1000)").option("--dev", "Use Vite dev server with HMR instead of static files").option("--global", "Install skill globally (~/.claude/skills/)").option("--force", "Overwrite existing configuration files").action(start);
584
635
  program.command("watch [ref]").description("Start a persistent diff watcher with live-updating browser UI").option("--staged", "Watch staged changes").option("--unstaged", "Watch unstaged changes").option("-t, --title <title>", "Review title").option("--interval <ms>", "Poll interval in milliseconds (default: 1000)").option("--dev", "Use Vite dev server with HMR instead of static files").action(watch);
585
636
  program.command("notify-stop").description("Signal the watch server to refresh (used by Claude Code hooks)").action(notifyStop);
586
637
  program.command("serve").description("Start the MCP server for Claude Code integration").action(serve);
587
- program.command("setup").description("Configure DiffPrism for Claude Code integration").option("--global", "Install skill globally (~/.claude/skills/)").option("--force", "Overwrite existing configuration files").action((flags) => {
638
+ program.command("setup").description("Configure DiffPrism for Claude Code integration").option("--global", "Configure globally (skill + permissions, no git repo required)").option("--force", "Overwrite existing configuration files").action((flags) => {
588
639
  setup(flags);
589
640
  });
590
641
  var serverCmd = program.command("server").description("Start the global DiffPrism server for multi-session reviews").option("-p, --port <port>", "HTTP API port (default: 24680)").option("--ws-port <port>", "WebSocket port (default: 24681)").option("--dev", "Use Vite dev server with HMR instead of static files").action(server);
@@ -1,7 +1,3 @@
1
- // packages/core/src/pipeline.ts
2
- import getPort from "get-port";
3
- import open from "open";
4
-
5
1
  // packages/git/src/local.ts
6
2
  import { execSync } from "child_process";
7
3
  import { readFileSync } from "fs";
@@ -717,6 +713,10 @@ function analyze(diffSet) {
717
713
  };
718
714
  }
719
715
 
716
+ // packages/core/src/pipeline.ts
717
+ import getPort from "get-port";
718
+ import open from "open";
719
+
720
720
  // packages/core/src/ws-bridge.ts
721
721
  import { WebSocketServer, WebSocket } from "ws";
722
722
  function createWsBridge(port) {
@@ -1061,6 +1061,76 @@ function consumeReviewResult(cwd) {
1061
1061
  }
1062
1062
  }
1063
1063
 
1064
+ // packages/core/src/server-file.ts
1065
+ import fs3 from "fs";
1066
+ import path5 from "path";
1067
+ import os from "os";
1068
+ function serverDir() {
1069
+ return path5.join(os.homedir(), ".diffprism");
1070
+ }
1071
+ function serverFilePath() {
1072
+ return path5.join(serverDir(), "server.json");
1073
+ }
1074
+ function isPidAlive2(pid) {
1075
+ try {
1076
+ process.kill(pid, 0);
1077
+ return true;
1078
+ } catch {
1079
+ return false;
1080
+ }
1081
+ }
1082
+ function writeServerFile(info) {
1083
+ const dir = serverDir();
1084
+ if (!fs3.existsSync(dir)) {
1085
+ fs3.mkdirSync(dir, { recursive: true });
1086
+ }
1087
+ fs3.writeFileSync(serverFilePath(), JSON.stringify(info, null, 2) + "\n");
1088
+ }
1089
+ function readServerFile() {
1090
+ const filePath = serverFilePath();
1091
+ if (!fs3.existsSync(filePath)) {
1092
+ return null;
1093
+ }
1094
+ try {
1095
+ const raw = fs3.readFileSync(filePath, "utf-8");
1096
+ const info = JSON.parse(raw);
1097
+ if (!isPidAlive2(info.pid)) {
1098
+ fs3.unlinkSync(filePath);
1099
+ return null;
1100
+ }
1101
+ return info;
1102
+ } catch {
1103
+ return null;
1104
+ }
1105
+ }
1106
+ function removeServerFile() {
1107
+ try {
1108
+ const filePath = serverFilePath();
1109
+ if (fs3.existsSync(filePath)) {
1110
+ fs3.unlinkSync(filePath);
1111
+ }
1112
+ } catch {
1113
+ }
1114
+ }
1115
+ async function isServerAlive() {
1116
+ const info = readServerFile();
1117
+ if (!info) {
1118
+ return null;
1119
+ }
1120
+ try {
1121
+ const response = await fetch(`http://localhost:${info.httpPort}/api/status`, {
1122
+ signal: AbortSignal.timeout(2e3)
1123
+ });
1124
+ if (response.ok) {
1125
+ return info;
1126
+ }
1127
+ return null;
1128
+ } catch {
1129
+ removeServerFile();
1130
+ return null;
1131
+ }
1132
+ }
1133
+
1064
1134
  // packages/core/src/watch.ts
1065
1135
  import { createHash } from "crypto";
1066
1136
  import getPort2 from "get-port";
@@ -1375,78 +1445,6 @@ import { randomUUID } from "crypto";
1375
1445
  import getPort3 from "get-port";
1376
1446
  import open3 from "open";
1377
1447
  import { WebSocketServer as WebSocketServer3, WebSocket as WebSocket3 } from "ws";
1378
-
1379
- // packages/core/src/server-file.ts
1380
- import fs3 from "fs";
1381
- import path5 from "path";
1382
- import os from "os";
1383
- function serverDir() {
1384
- return path5.join(os.homedir(), ".diffprism");
1385
- }
1386
- function serverFilePath() {
1387
- return path5.join(serverDir(), "server.json");
1388
- }
1389
- function isPidAlive2(pid) {
1390
- try {
1391
- process.kill(pid, 0);
1392
- return true;
1393
- } catch {
1394
- return false;
1395
- }
1396
- }
1397
- function writeServerFile(info) {
1398
- const dir = serverDir();
1399
- if (!fs3.existsSync(dir)) {
1400
- fs3.mkdirSync(dir, { recursive: true });
1401
- }
1402
- fs3.writeFileSync(serverFilePath(), JSON.stringify(info, null, 2) + "\n");
1403
- }
1404
- function readServerFile() {
1405
- const filePath = serverFilePath();
1406
- if (!fs3.existsSync(filePath)) {
1407
- return null;
1408
- }
1409
- try {
1410
- const raw = fs3.readFileSync(filePath, "utf-8");
1411
- const info = JSON.parse(raw);
1412
- if (!isPidAlive2(info.pid)) {
1413
- fs3.unlinkSync(filePath);
1414
- return null;
1415
- }
1416
- return info;
1417
- } catch {
1418
- return null;
1419
- }
1420
- }
1421
- function removeServerFile() {
1422
- try {
1423
- const filePath = serverFilePath();
1424
- if (fs3.existsSync(filePath)) {
1425
- fs3.unlinkSync(filePath);
1426
- }
1427
- } catch {
1428
- }
1429
- }
1430
- async function isServerAlive() {
1431
- const info = readServerFile();
1432
- if (!info) {
1433
- return null;
1434
- }
1435
- try {
1436
- const response = await fetch(`http://localhost:${info.httpPort}/api/status`, {
1437
- signal: AbortSignal.timeout(2e3)
1438
- });
1439
- if (response.ok) {
1440
- return info;
1441
- }
1442
- return null;
1443
- } catch {
1444
- removeServerFile();
1445
- return null;
1446
- }
1447
- }
1448
-
1449
- // packages/core/src/global-server.ts
1450
1448
  var sessions2 = /* @__PURE__ */ new Map();
1451
1449
  var clientSessions = /* @__PURE__ */ new Map();
1452
1450
  function toSummary(session) {
@@ -1699,6 +1697,24 @@ async function startGlobalServer(options = {}) {
1699
1697
  };
1700
1698
  ws.send(JSON.stringify(msg));
1701
1699
  }
1700
+ } else {
1701
+ const summaries = Array.from(sessions2.values()).map(toSummary);
1702
+ const msg = {
1703
+ type: "session:list",
1704
+ payload: summaries
1705
+ };
1706
+ ws.send(JSON.stringify(msg));
1707
+ if (summaries.length === 1) {
1708
+ const session = sessions2.get(summaries[0].id);
1709
+ if (session) {
1710
+ clientSessions.set(ws, session.id);
1711
+ session.status = "in_review";
1712
+ ws.send(JSON.stringify({
1713
+ type: "review:init",
1714
+ payload: session.payload
1715
+ }));
1716
+ }
1717
+ }
1702
1718
  }
1703
1719
  ws.on("message", (data) => {
1704
1720
  try {
@@ -1712,6 +1728,16 @@ async function startGlobalServer(options = {}) {
1712
1728
  session.status = "submitted";
1713
1729
  }
1714
1730
  }
1731
+ } else if (msg.type === "session:select") {
1732
+ const session = sessions2.get(msg.payload.sessionId);
1733
+ if (session) {
1734
+ clientSessions.set(ws, session.id);
1735
+ session.status = "in_review";
1736
+ ws.send(JSON.stringify({
1737
+ type: "review:init",
1738
+ payload: session.payload
1739
+ }));
1740
+ }
1715
1741
  }
1716
1742
  } catch {
1717
1743
  }
@@ -1769,6 +1795,9 @@ Waiting for reviews...
1769
1795
  }
1770
1796
 
1771
1797
  export {
1798
+ getCurrentBranch,
1799
+ getDiff,
1800
+ analyze,
1772
1801
  startReview,
1773
1802
  readWatchFile,
1774
1803
  readReviewResult,