built-diff 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.
Files changed (2) hide show
  1. package/dist/index.js +321 -0
  2. package/package.json +30 -0
package/dist/index.js ADDED
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { spawn } from "child_process";
5
+ import { mkdirSync, writeFileSync, rmSync } from "fs";
6
+ import { tmpdir } from "os";
7
+ import { join, dirname } from "path";
8
+ import { serve } from "@hono/node-server";
9
+
10
+ // src/server.ts
11
+ import { Hono } from "hono";
12
+ import { streamSSE } from "hono/streaming";
13
+ import { serveStatic } from "@hono/node-server/serve-static";
14
+
15
+ // src/comments.ts
16
+ var commentStore = [];
17
+ var nextCommentId = 1;
18
+ var getComments = () => {
19
+ return commentStore;
20
+ };
21
+ var addComment = (data) => {
22
+ const comment = {
23
+ id: String(nextCommentId++),
24
+ filePath: data.filePath,
25
+ lineNumber: data.lineNumber,
26
+ side: data.side,
27
+ text: data.text,
28
+ timestamp: Date.now(),
29
+ delivered: false
30
+ };
31
+ commentStore.push(comment);
32
+ return comment;
33
+ };
34
+ var updateComment = (id, text) => {
35
+ const comment = commentStore.find((c) => c.id === id);
36
+ if (!comment)
37
+ return null;
38
+ comment.text = text;
39
+ comment.delivered = false;
40
+ return comment;
41
+ };
42
+ var deleteComment = (id) => {
43
+ const index = commentStore.findIndex((c) => c.id === id);
44
+ if (index === -1)
45
+ return false;
46
+ commentStore.splice(index, 1);
47
+ return true;
48
+ };
49
+ var getPendingComments = () => {
50
+ const pendingComments = commentStore.filter((comment) => !comment.delivered);
51
+ for (const comment of pendingComments) {
52
+ comment.delivered = true;
53
+ }
54
+ return pendingComments;
55
+ };
56
+
57
+ // src/git.ts
58
+ import { execSync } from "child_process";
59
+ var execGit = (command) => {
60
+ return execSync(command, { encoding: "utf-8" }).trim();
61
+ };
62
+ var getHeadSha = () => {
63
+ return execGit("git rev-parse HEAD");
64
+ };
65
+ var getBranch = () => {
66
+ return execGit("git rev-parse --abbrev-ref HEAD");
67
+ };
68
+ var isDirty = () => {
69
+ const statusOutput = execGit("git status --porcelain");
70
+ return statusOutput.length > 0;
71
+ };
72
+ var getDiff = (baseline) => {
73
+ try {
74
+ execGit("git add --intent-to-add -A");
75
+ const diffOutput = execGit(`git diff --no-ext-diff --no-color ${baseline}`);
76
+ execGit("git reset -q");
77
+ return diffOutput;
78
+ } catch {
79
+ return "";
80
+ }
81
+ };
82
+ var getStatus = () => {
83
+ return {
84
+ branch: getBranch(),
85
+ dirty: isDirty()
86
+ };
87
+ };
88
+ var commitAll = (message) => {
89
+ try {
90
+ execGit("git add -A");
91
+ execGit(`git commit -m ${JSON.stringify(message)}`);
92
+ return { success: true };
93
+ } catch (error) {
94
+ return { success: false, error: String(error) };
95
+ }
96
+ };
97
+ var pushToRemote = () => {
98
+ try {
99
+ execGit("git push");
100
+ return { success: true };
101
+ } catch (error) {
102
+ return { success: false, error: String(error) };
103
+ }
104
+ };
105
+
106
+ // src/constants.ts
107
+ var SSE_KEEPALIVE_INTERVAL_MS = 15000;
108
+
109
+ // src/server.ts
110
+ var createApp = (baseline, staticDir) => {
111
+ const app = new Hono;
112
+ const connectedClients = new Set;
113
+ const broadcast = (event, data = "{}") => {
114
+ for (const sendToClient of connectedClients) {
115
+ sendToClient(event, data);
116
+ }
117
+ };
118
+ app.get("/api/comments", (context) => {
119
+ return context.json(getComments());
120
+ });
121
+ app.post("/api/comments", async (context) => {
122
+ const body = await context.req.json();
123
+ const comment = addComment(body);
124
+ broadcast("refresh");
125
+ return context.json(comment);
126
+ });
127
+ app.patch("/api/comments/:id", async (context) => {
128
+ const { text } = await context.req.json();
129
+ const comment = updateComment(context.req.param("id"), text);
130
+ if (!comment)
131
+ return context.json({ error: "Not found" }, 404);
132
+ broadcast("refresh");
133
+ return context.json(comment);
134
+ });
135
+ app.delete("/api/comments/:id", (context) => {
136
+ const deleted = deleteComment(context.req.param("id"));
137
+ if (!deleted)
138
+ return context.json({ error: "Not found" }, 404);
139
+ broadcast("refresh");
140
+ return context.json({ success: true });
141
+ });
142
+ app.get("/api/git/diff", (context) => {
143
+ const diffOutput = getDiff(baseline);
144
+ return context.json({ diff: diffOutput });
145
+ });
146
+ app.get("/api/git/status", (context) => {
147
+ return context.json(getStatus());
148
+ });
149
+ app.post("/api/git/commit", async (context) => {
150
+ const { message } = await context.req.json();
151
+ const result = commitAll(message);
152
+ if (result.success)
153
+ broadcast("refresh");
154
+ return context.json(result);
155
+ });
156
+ app.post("/api/git/push", (context) => {
157
+ const result = pushToRemote();
158
+ return context.json(result);
159
+ });
160
+ app.get("/api/events", (context) => {
161
+ return streamSSE(context, async (stream) => {
162
+ const sendToClient = (event, data) => {
163
+ stream.writeSSE({ event, data }).catch(() => {});
164
+ };
165
+ connectedClients.add(sendToClient);
166
+ await stream.writeSSE({ event: "connected", data: "{}" });
167
+ const keepAliveTimer = setInterval(() => {
168
+ stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {
169
+ clearInterval(keepAliveTimer);
170
+ });
171
+ }, SSE_KEEPALIVE_INTERVAL_MS);
172
+ stream.onAbort(() => {
173
+ connectedClients.delete(sendToClient);
174
+ clearInterval(keepAliveTimer);
175
+ });
176
+ await new Promise(() => {});
177
+ });
178
+ });
179
+ app.post("/api/hook/pre-tool-use", async (context) => {
180
+ const pendingComments = getPendingComments();
181
+ if (pendingComments.length === 0) {
182
+ return context.json({});
183
+ }
184
+ const formattedComments = pendingComments.map((comment) => `[Review comment on ${comment.filePath}:${comment.lineNumber}] ${comment.text}`).join(`
185
+ `);
186
+ return context.json({
187
+ additionalContext: `The user has left review comments on your changes. Please address them:
188
+ ${formattedComments}`
189
+ });
190
+ });
191
+ app.post("/api/hook/post-tool-use", (context) => {
192
+ broadcast("refresh");
193
+ return context.json({});
194
+ });
195
+ app.use("/*", serveStatic({
196
+ root: staticDir
197
+ }));
198
+ app.get("/*", serveStatic({ root: staticDir, path: "index.html" }));
199
+ return { app, broadcast };
200
+ };
201
+
202
+ // src/index.ts
203
+ import { createRequire } from "module";
204
+ import { fileURLToPath } from "url";
205
+ var currentDir = dirname(fileURLToPath(import.meta.url));
206
+ var cliArgs = process.argv.slice(2);
207
+ var baselineCommit = getHeadSha();
208
+ console.log(`built-diff: baseline ${baselineCommit.slice(0, 8)}`);
209
+ var staticDir;
210
+ try {
211
+ const requireFromModule = createRequire(import.meta.url);
212
+ const webPackagePath = requireFromModule.resolve("@built-diff/web/package.json");
213
+ staticDir = join(dirname(webPackagePath), "dist");
214
+ } catch {
215
+ staticDir = join(currentDir, "static");
216
+ }
217
+ var { app } = createApp(baselineCommit, staticDir);
218
+ var cleanupTempDir = (tempDir) => {
219
+ try {
220
+ rmSync(tempDir, { recursive: true, force: true });
221
+ } catch {}
222
+ };
223
+ var getBrowserOpenCommand = () => {
224
+ if (process.platform === "darwin")
225
+ return "open";
226
+ if (process.platform === "win32")
227
+ return "start";
228
+ return "xdg-open";
229
+ };
230
+ var server = serve({ fetch: app.fetch, port: 0 }, (info) => {
231
+ const serverPort = info.port;
232
+ console.log(`built-diff: server on http://localhost:${serverPort}`);
233
+ const tempDir = join(tmpdir(), `built-diff-${process.pid}`);
234
+ mkdirSync(tempDir, { recursive: true });
235
+ let mcpServerEntryPath;
236
+ try {
237
+ const requireFromModule = createRequire(import.meta.url);
238
+ mcpServerEntryPath = requireFromModule.resolve("@built-diff/mcp-server");
239
+ } catch {
240
+ mcpServerEntryPath = join(currentDir, "..", "..", "mcp-server", "dist", "index.js");
241
+ }
242
+ const mcpConfig = {
243
+ mcpServers: {
244
+ "built-diff": {
245
+ command: "node",
246
+ args: [mcpServerEntryPath],
247
+ env: {
248
+ BUILT_DIFF_PORT: String(serverPort)
249
+ }
250
+ }
251
+ }
252
+ };
253
+ const mcpConfigPath = join(tempDir, "mcp.json");
254
+ writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
255
+ const hookBaseUrl = `http://localhost:${serverPort}`;
256
+ const settings = {
257
+ hooks: {
258
+ PreToolUse: [
259
+ {
260
+ matcher: ".*",
261
+ hooks: [
262
+ {
263
+ type: "command",
264
+ command: `curl -s -X POST ${hookBaseUrl}/api/hook/pre-tool-use`
265
+ }
266
+ ]
267
+ }
268
+ ],
269
+ PostToolUse: [
270
+ {
271
+ matcher: "Edit|Write|MultiEdit",
272
+ hooks: [
273
+ {
274
+ type: "command",
275
+ command: `curl -s -X POST ${hookBaseUrl}/api/hook/post-tool-use`
276
+ }
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ };
282
+ const settingsPath = join(tempDir, "settings.json");
283
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
284
+ spawn(getBrowserOpenCommand(), [`http://localhost:${serverPort}`], {
285
+ stdio: "ignore",
286
+ detached: true
287
+ }).unref();
288
+ const claudeArgs = [
289
+ "--mcp-config",
290
+ mcpConfigPath,
291
+ "--settings",
292
+ settingsPath,
293
+ "--append-system-prompt",
294
+ "You have a built-diff MCP server connected. Use the check_review tool periodically to see if the user has left inline review comments on your code changes in the web UI. Address any comments you find.",
295
+ ...cliArgs
296
+ ];
297
+ const claudeProcess = spawn("claude", claudeArgs, {
298
+ stdio: "inherit",
299
+ env: {
300
+ ...process.env,
301
+ BUILT_DIFF_PORT: String(serverPort)
302
+ }
303
+ });
304
+ claudeProcess.on("error", (spawnError) => {
305
+ console.error("built-diff: failed to start claude:", spawnError.message);
306
+ console.error("Make sure the 'claude' CLI is installed and in your PATH.");
307
+ cleanupTempDir(tempDir);
308
+ process.exit(1);
309
+ });
310
+ claudeProcess.on("exit", (exitCode) => {
311
+ cleanupTempDir(tempDir);
312
+ server.close();
313
+ process.exit(exitCode ?? 0);
314
+ });
315
+ process.on("SIGINT", () => {
316
+ claudeProcess.kill("SIGINT");
317
+ });
318
+ process.on("SIGTERM", () => {
319
+ claudeProcess.kill("SIGTERM");
320
+ });
321
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "built-diff",
3
+ "version": "0.1.0",
4
+ "description": "Review Claude Code changes in a web UI with inline comments",
5
+ "bin": {
6
+ "built-diff": "dist/index.js"
7
+ },
8
+ "type": "module",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "dependencies": {
13
+ "@hono/node-server": "^1.14.0",
14
+ "hono": "^4.7.0",
15
+ "@built-diff/mcp-server": "0.1.0",
16
+ "@built-diff/web": "0.1.0"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "claude-code",
21
+ "diff",
22
+ "code-review"
23
+ ],
24
+ "license": "MIT",
25
+ "scripts": {
26
+ "build": "bun build src/index.ts --outdir dist --target node --packages external",
27
+ "dev": "bun run src/dev-server.ts",
28
+ "dev:claude": "bun run src/index.ts"
29
+ }
30
+ }