claude-review-hub 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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/bin/claude-review-hub.mjs +86 -0
  4. package/logo.svg +1 -0
  5. package/package.json +62 -0
  6. package/scripts/dev.mjs +163 -0
  7. package/scripts/install-skill.mjs +74 -0
  8. package/scripts/uninstall.mjs +84 -0
  9. package/server/dist/agent.js +292 -0
  10. package/server/dist/agent.js.map +1 -0
  11. package/server/dist/azure/actions.js +182 -0
  12. package/server/dist/azure/actions.js.map +1 -0
  13. package/server/dist/azure/detect.js +92 -0
  14. package/server/dist/azure/detect.js.map +1 -0
  15. package/server/dist/azure/rest.js +54 -0
  16. package/server/dist/azure/rest.js.map +1 -0
  17. package/server/dist/chat.js +51 -0
  18. package/server/dist/chat.js.map +1 -0
  19. package/server/dist/config.js +137 -0
  20. package/server/dist/config.js.map +1 -0
  21. package/server/dist/db.js +140 -0
  22. package/server/dist/db.js.map +1 -0
  23. package/server/dist/diff/split.js +98 -0
  24. package/server/dist/diff/split.js.map +1 -0
  25. package/server/dist/events.js +82 -0
  26. package/server/dist/events.js.map +1 -0
  27. package/server/dist/git/diff.js +36 -0
  28. package/server/dist/git/diff.js.map +1 -0
  29. package/server/dist/git/worktree.js +79 -0
  30. package/server/dist/git/worktree.js.map +1 -0
  31. package/server/dist/index.js +116 -0
  32. package/server/dist/index.js.map +1 -0
  33. package/server/dist/inspect.js +144 -0
  34. package/server/dist/inspect.js.map +1 -0
  35. package/server/dist/mcp.js +106 -0
  36. package/server/dist/mcp.js.map +1 -0
  37. package/server/dist/paths.js +35 -0
  38. package/server/dist/paths.js.map +1 -0
  39. package/server/dist/permissions.js +99 -0
  40. package/server/dist/permissions.js.map +1 -0
  41. package/server/dist/pkg.js +25 -0
  42. package/server/dist/pkg.js.map +1 -0
  43. package/server/dist/providers/azure.js +203 -0
  44. package/server/dist/providers/azure.js.map +1 -0
  45. package/server/dist/providers/github.js +234 -0
  46. package/server/dist/providers/github.js.map +1 -0
  47. package/server/dist/providers/index.js +166 -0
  48. package/server/dist/providers/index.js.map +1 -0
  49. package/server/dist/providers/types.js +2 -0
  50. package/server/dist/providers/types.js.map +1 -0
  51. package/server/dist/repos.js +55 -0
  52. package/server/dist/repos.js.map +1 -0
  53. package/server/dist/review/actions.js +439 -0
  54. package/server/dist/review/actions.js.map +1 -0
  55. package/server/dist/review/findings.js +103 -0
  56. package/server/dist/review/findings.js.map +1 -0
  57. package/server/dist/review/profiles.js +117 -0
  58. package/server/dist/review/profiles.js.map +1 -0
  59. package/server/dist/review/prompt.js +23 -0
  60. package/server/dist/review/prompt.js.map +1 -0
  61. package/server/dist/review/promptStore.js +52 -0
  62. package/server/dist/review/promptStore.js.map +1 -0
  63. package/server/dist/review/run.js +153 -0
  64. package/server/dist/review/run.js.map +1 -0
  65. package/server/dist/review/schema.js +58 -0
  66. package/server/dist/review/schema.js.map +1 -0
  67. package/server/dist/review/tasks.js +18 -0
  68. package/server/dist/review/tasks.js.map +1 -0
  69. package/server/dist/routes.js +223 -0
  70. package/server/dist/routes.js.map +1 -0
  71. package/server/dist/sessions.js +226 -0
  72. package/server/dist/sessions.js.map +1 -0
  73. package/server/dist/skill.js +37 -0
  74. package/server/dist/skill.js.map +1 -0
  75. package/skill/azure-pr-master/SKILL.md +259 -0
  76. package/skill/azure-pr-master/split-diff.mjs +130 -0
  77. package/skill/github-pr-master/SKILL.md +298 -0
  78. package/skill/github-pr-master/split-diff.mjs +130 -0
  79. package/web/dist/assets/index-D55AjoTk.js +359 -0
  80. package/web/dist/assets/index-DWLH0g20.css +1 -0
  81. package/web/dist/favicon.svg +1 -0
  82. package/web/dist/index.html +20 -0
  83. package/web/dist/logo-small.svg +1 -0
  84. package/web/dist/logo.svg +1 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 goldynlabs
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,244 @@
1
+ <div align="center">
2
+
3
+ <img src="logo.svg" alt="" width="96" height="96" />
4
+
5
+ # Claude Review Hub
6
+
7
+ **Pull request review that actually reads the code, in a dashboard you control.**
8
+
9
+ [![npm](https://img.shields.io/npm/v/claude-review-hub?color=d77757)](https://www.npmjs.com/package/claude-review-hub)
10
+ [![license](https://img.shields.io/badge/license-MIT-d77757)](LICENSE)
11
+ [![node](https://img.shields.io/badge/node-%3E%3D20-d77757)](https://nodejs.org)
12
+
13
+ </div>
14
+
15
+ Point it at a repo, paste a PR number, and Claude Code reviews it the way a
16
+ senior engineer would: it clones the branch into a worktree, reads the diff
17
+ against the real codebase, and comes back with findings that cite evidence.
18
+ You decide what gets posted.
19
+
20
+ ```console
21
+ $ npx claude-review-hub "C:/Source code/your-repo"
22
+
23
+ ✳️ Claude Review Hub
24
+ project : C:/Source code/your-repo
25
+ skill : azure-pr-master installed
26
+ skill : github-pr-master installed
27
+ host : Azure DevOps
28
+ org : your-org
29
+ url : http://localhost:4319
30
+
31
+ Keep this terminal open. Press Ctrl+C to stop the dashboard.
32
+ ```
33
+
34
+ ---
35
+
36
+ ## 😵 The problem
37
+
38
+ Reviewing a pull request properly is expensive, so it mostly does not happen.
39
+
40
+ - **The diff lies.** A three-line change is safe or catastrophic depending on
41
+ who calls it, and the PR page will not tell you which. Finding out means
42
+ checking out the branch and grepping the repo.
43
+ - **Bot reviewers are noise.** They read the diff and nothing else, so they
44
+ flag style and miss the tenant id that stopped being filtered. After a week
45
+ of that, nobody reads their comments.
46
+ - **The terminal is not a record.** You can ask an agent to review a PR in a
47
+ chat window today, but tomorrow the session is gone, the reasoning is gone,
48
+ and nothing connects what it said to what you posted.
49
+ - **You cannot trust what you cannot see.** An agent with write access to your
50
+ pull requests is a liability unless you know the exact words it is about to
51
+ send, before it sends them.
52
+
53
+ ## ✨ What Claude Review Hub does about it
54
+
55
+ **It reads the repository, not just the patch.** Every PR gets a detached git
56
+ worktree of the actual branch. Claude opens the files, follows the call sites,
57
+ and reads your `CLAUDE.md` and conventions, because the tool installs into your
58
+ project and runs from its root.
59
+
60
+ **Findings come with evidence and a confidence score.** Not "consider
61
+ refactoring this". A finding names the file and line, states the input or state
62
+ that produces the wrong result, and cites what it checked.
63
+
64
+ **You can argue with it.** Disagree with a finding? Hit **Challenge**, type
65
+ your counter-argument, and an adversarial re-check runs against it. The new
66
+ verdict is appended, never overwritten, so the disagreement stays on the
67
+ record.
68
+
69
+ **Nothing is sent unseen.** Every control that sends a prompt to the agent
70
+ opens one confirmation showing the exact prompt, with a note box to aim it and
71
+ an Edit control to rewrite it for that one request. Anything that writes to the
72
+ pull request is marked before you click.
73
+
74
+ **It stays yours.** Sessions, findings, verdicts and the full event log live in
75
+ the repo you reviewed. Nothing to host, no extra account, no analytics: the
76
+ dashboard runs on localhost for as long as your terminal is open, and the only
77
+ thing that leaves the machine is what Claude Code and your own CLIs already
78
+ send.
79
+
80
+ ---
81
+
82
+ ## ⚡ Quick start
83
+
84
+ ```bash
85
+ # From inside the repo you want to review
86
+ cd your-repo
87
+ npx claude-review-hub
88
+
89
+ # Or point it at one
90
+ npx claude-review-hub "C:/Source code/your-repo"
91
+ ```
92
+
93
+ That single command installs the review skills into the project, hides the
94
+ tool's own files from git through `.git/info/exclude` (your `.gitignore` is
95
+ never touched, so the repo shows no modified file), and opens the dashboard on
96
+ <http://localhost:4319>.
97
+
98
+ It is idempotent. Run it again after an update and it refreshes the skills in
99
+ place. Running it in a second repo does not clash: a port already in use is
100
+ skipped and the banner names the one it took.
101
+
102
+ ### 🧰 Requirements
103
+
104
+ | | |
105
+ |---|---|
106
+ | Node | 20 or newer |
107
+ | Claude Code | Signed in on this machine, with a plan that allows Sonnet and Opus |
108
+ | Azure DevOps | `az login`, plus `az extension add --name azure-devops` |
109
+ | GitHub | `gh auth login` |
110
+ | Repos | A local checkout of every repository whose PRs you review |
111
+
112
+ Install only the CLI for the host you use. Which host a repo is on is read from
113
+ its git remote, so having both CLIs is never ambiguous: the sidebar names the
114
+ host in play, and every button about a pull request drives that host's CLI.
115
+ With one CLI installed the dashboard shows one account and the agent is told
116
+ about one host; the other is listed as not installed rather than as broken.
117
+
118
+ ---
119
+
120
+ ## 🔁 The loop
121
+
122
+ ```
123
+ paste PR ids → worktree + diff → findings
124
+
125
+ post / reply / approve ← challenge
126
+
127
+ the chat box reaches every one of those steps
128
+ ```
129
+
130
+ **1. Start a session.** The PR box is free text. Ids, full URLs, or a sentence:
131
+ `4821 4830 focus on the migration path`. Anything that is not a PR id
132
+ becomes context for the run. One session can span several PRs and several
133
+ repositories.
134
+
135
+ **2. Pick what "review" means.** Criteria are configured in the tool, not
136
+ buried in a prompt file. A **profile** holds review dimensions (correctness,
137
+ security, blast radius, project rules, or your own), extra context, include and
138
+ exclude globs, and a severity floor. The shipped profiles are a starting point;
139
+ Settings is where you make them yours.
140
+
141
+ **3. Read the findings.** Each one carries severity, confidence, file and line,
142
+ and the evidence behind it. Dismiss what you do not want. Challenge what you
143
+ doubt.
144
+
145
+ **4. Act on the PR.** Post a single finding or all of them, reply to a thread,
146
+ set a thread's state, approve, reject, wait for the author, or merge. Every one
147
+ of those opens the confirmation first.
148
+
149
+ **5. Ask for anything else.** The chat box drives the same operations the
150
+ buttons do, in the same session, landing in the same event log. The buttons are
151
+ the shortcuts; the chat box is the whole surface.
152
+
153
+ Existing PR comments are deliberately **not** fed into the review pass, so the
154
+ first read is unbiased. They enter Claude's context only when you reply to a
155
+ thread or ask about one.
156
+
157
+ ---
158
+
159
+ ## 🔧 Under the hood
160
+
161
+ The dashboard is not a wrapper around a chatbot. The server keeps the record;
162
+ the agent does the work with your own CLIs.
163
+
164
+ - **A detached worktree per PR**, built from the local checkout you already
165
+ have. Nothing is downloaded twice, and your working tree is never touched.
166
+ - **The server produces the diff**, splits it per file, and hands it over.
167
+ Claude spends its context reading code, not reconstructing patches.
168
+ - **One definition of every operation.** A reply typed in chat and one clicked
169
+ on a thread card are the same function call, and both land in the append-only
170
+ event log that the UI is drawn from. Close the browser mid-review and reopen
171
+ it: the session is exactly where you left it.
172
+ - **Prompt templates are editable for good.** Settings > Prompts lists every
173
+ action and lets its wording be replaced, which reaches the hover preview, the
174
+ confirmation, the docs tab and the agent together. The built-in wording can
175
+ always be restored.
176
+
177
+ ### 📁 What it writes to your project
178
+
179
+ ```
180
+ <project>/
181
+ .claude/skills/azure-pr-master/ the Azure DevOps procedure, refreshed on start
182
+ .claude/skills/github-pr-master/ the GitHub procedure, refreshed on start
183
+ .review-tool/ hidden from git, never committed
184
+ config/settings.json
185
+ config/profiles.json your review criteria
186
+ config/prompts.json your prompt overrides
187
+ data/review.db sessions, PRs, findings, evidence, verdicts
188
+ data/sessions/<id>/events.jsonl append-only log, the UI's source of truth
189
+ temp/worktrees/<repo>-pr<id>/
190
+ temp/diffs/<repo>-pr<id>/
191
+ ```
192
+
193
+ Everything lives in the repo it reviewed, so nothing is lost when npm clears
194
+ its npx cache.
195
+
196
+ ---
197
+
198
+ ## ⚙️ Settings worth knowing
199
+
200
+ | Setting | Default | Why |
201
+ |---|---|---|
202
+ | `allowCodeEdits` | off | Review is read-only. Turn it on for auto-fix or opening a PR |
203
+ | `permissionMode` | auto | `ask` confirms every write and shell command the agent attempts. Separate from your own clicks, which are always confirmed |
204
+ | `models` | Sonnet to review, Opus to challenge and chat | The broad sweep is wide, the adversarial pass is hard |
205
+ | `worktreeTtlHours` | 72 | Worktrees stay browsable, then are pruned on start |
206
+ | `sessionLanguage` | en | What Claude writes into the dashboard. The prompts it is sent stay English |
207
+ | `pullRequestLanguage` | en | What Claude writes into the pull request |
208
+
209
+ ## ⌨️ Commands
210
+
211
+ | Command | What it does |
212
+ |---|---|
213
+ | `npx claude-review-hub` | Install what the repo needs, then open the dashboard |
214
+ | `npx claude-review-hub <repo>` | Same, for a repo other than the current directory |
215
+ | `npx claude-review-hub install` | Only install the skills and the git exclude entries |
216
+ | `npx claude-review-hub --port 4320` | Prefer a specific port |
217
+ | `npx claude-review-hub uninstall` | Remove the skills and the git exclude entries |
218
+ | `npx claude-review-hub uninstall --purge` | Also delete `.review-tool/`: sessions, findings and worktrees |
219
+
220
+ `Ctrl+C` stops the dashboard, and closing the terminal stops it too.
221
+
222
+ ---
223
+
224
+ ## 🛠️ Working on the tool
225
+
226
+ `npx` runs the built output, so a source change only reaches it after a
227
+ rebuild. To run the sources instead, against a real repo:
228
+
229
+ ```bash
230
+ git clone https://github.com/goldynlabs/claude-review-hub
231
+ cd claude-review-hub
232
+ npm install
233
+ npm run dev -- "C:/Source code/your-repo" # the repo is remembered
234
+ npm run dev # later runs reuse it
235
+ ```
236
+
237
+ `server/` and `web/` reload on save. `skill/` needs a restart, because a
238
+ project gets its copy of the skills at start-up. Ports are chosen at start-up
239
+ and printed, so several instances can run at once. `npm run typecheck` before
240
+ opening a PR.
241
+
242
+ ## 📄 License
243
+
244
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { installSkill, isGitRepo } from "../scripts/install-skill.mjs";
6
+ import { uninstallTool } from "../scripts/uninstall.mjs";
7
+
8
+ const here = path.dirname(fileURLToPath(import.meta.url));
9
+ const serverEntry = path.join(here, "..", "server", "dist", "index.js");
10
+
11
+ const args = process.argv.slice(2);
12
+ const COMMANDS = ["install", "uninstall", "version"];
13
+ const command = COMMANDS.includes(args[0]) ? args[0] : "start";
14
+
15
+ if (args.includes("-h") || args.includes("--help")) {
16
+ console.log(`
17
+ claude-review-hub [command] [repo path] [options]
18
+
19
+ The repo path is where the review happens. It defaults to the current
20
+ directory, so inside a repo 'claude-review-hub' on its own is enough.
21
+
22
+ (no command) Open the dashboard for that repo
23
+ install Only install the skill and the git exclude entries
24
+ uninstall Remove them again (add --purge to delete .review-tool/ as
25
+ well: sessions, findings and review worktrees)
26
+ version Print the tool version
27
+
28
+ --port <n> Preferred port (default 4319). If it is taken, the next
29
+ free one is used.
30
+
31
+ Examples:
32
+ npx claude-review-hub "C:\\Source code\\your-repo"
33
+ cd your-repo && npx claude-review-hub
34
+ `);
35
+ process.exit(0);
36
+ }
37
+
38
+ function flag(name) {
39
+ const index = args.indexOf(name);
40
+ return index === -1 ? undefined : args[index + 1];
41
+ }
42
+
43
+ /** The repo to review: the first bare argument that is not a flag's value. */
44
+ const positional = args.find(
45
+ (arg, index) => !arg.startsWith("-") && !COMMANDS.includes(arg) && !args[index - 1]?.startsWith("-"),
46
+ );
47
+ const target = path.resolve(flag("--project") ?? positional ?? process.cwd());
48
+
49
+ if (command === "version") {
50
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
51
+ console.log(pkg.version);
52
+ process.exit(0);
53
+ }
54
+
55
+ if (!isGitRepo(target)) {
56
+ console.error(`\n ${target} is not a git repository.\n Pass the repo to review: npx claude-review-hub <path>\n`);
57
+ process.exit(1);
58
+ }
59
+
60
+ console.log(`\n ${path.basename(target)}`);
61
+
62
+ if (command === "uninstall") {
63
+ uninstallTool(target, { purge: args.includes("--purge") });
64
+ console.log("");
65
+ process.exit(0);
66
+ }
67
+
68
+ // One command does the whole thing: the skill is (re)installed on every start,
69
+ // so an updated tool never leaves a stale skill behind in a project.
70
+ installSkill(target);
71
+
72
+ if (command === "install") {
73
+ console.log(`\n Ready. Run 'npx claude-review-hub' in that repo to open the dashboard.\n`);
74
+ process.exit(0);
75
+ }
76
+
77
+ if (!fs.existsSync(serverEntry)) {
78
+ console.error(`\n The tool has not been built yet.\n Run 'npm run build' in ${path.join(here, "..")}\n`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const port = flag("--port");
83
+ if (port) process.env.PORT = port;
84
+ process.env.REVIEW_TOOL_PROJECT = target;
85
+
86
+ await import(new URL(`file://${serverEntry.replace(/\\/g, "/")}`));
package/logo.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="480px" height="480px" baseProfile="basic"><rect width="36" height="24" x="6" y="18" fill="#d77757"/><rect width="3" height="9" x="9" y="39" fill="#d77757"/><rect width="3" height="9" x="15" y="39" fill="#d77757"/><rect width="3" height="9" x="30" y="39" fill="#d77757"/><rect width="3" height="9" x="36" y="39" fill="#d77757"/><rect width="7.5" height="6" y="33" fill="#d77757"/><rect width="7.5" height="6" x="40.5" y="33" fill="#d77757"/><rect width="9" height="9" x="12" y="24"/><rect width="9" height="9" x="27" y="24"/><rect width="3" height="6" x="22.5" y="22.5" transform="rotate(90 24 25.5)"/><rect width="3" height="7" x="37" y="22" transform="rotate(90 38.5 25.5)"/><rect width="3" height="7" x="8" y="22" transform="rotate(90 9.5 25.5)"/></svg>
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "claude-review-hub",
3
+ "version": "0.1.0",
4
+ "description": "A local dashboard that reviews pull requests with Claude Code",
5
+ "license": "MIT",
6
+ "author": "goldynlabs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/goldynlabs/claude-review-hub.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/goldynlabs/claude-review-hub/issues"
13
+ },
14
+ "homepage": "https://github.com/goldynlabs/claude-review-hub#readme",
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "keywords": [
20
+ "claude",
21
+ "code-review",
22
+ "pull-request",
23
+ "azure-devops",
24
+ "github",
25
+ "dashboard"
26
+ ],
27
+ "workspaces": [
28
+ "server",
29
+ "web"
30
+ ],
31
+ "bin": {
32
+ "claude-review-hub": "bin/claude-review-hub.mjs"
33
+ },
34
+ "files": [
35
+ "LICENSE",
36
+ "logo.svg",
37
+ "bin",
38
+ "scripts",
39
+ "skill",
40
+ "server/dist",
41
+ "web/dist"
42
+ ],
43
+ "scripts": {
44
+ "dev": "node scripts/dev.mjs",
45
+ "dev:server": "tsx watch server/src/index.ts",
46
+ "dev:web": "npm -w web run dev",
47
+ "build": "npm -w server run build && npm -w web run build",
48
+ "typecheck": "npm -w server run typecheck && npm -w web run typecheck",
49
+ "prepare": "npm run build"
50
+ },
51
+ "dependencies": {
52
+ "@anthropic-ai/claude-agent-sdk": "^0.3.272",
53
+ "better-sqlite3": "^11.7.0",
54
+ "cors": "^2.8.5",
55
+ "express": "^4.21.2",
56
+ "nanoid": "^5.0.9",
57
+ "zod": "^4.1.13"
58
+ },
59
+ "devDependencies": {
60
+ "tsx": "^4.19.2"
61
+ }
62
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Development launcher.
4
+ *
5
+ * `claude-review-hub` runs the built output, so every source change would need a
6
+ * build and a restart. This runs the sources instead: the server under
7
+ * `tsx watch`, the dashboard under Vite, both pointed at a real repo to
8
+ * review. Ports are chosen at start-up and handed to Vite, so a claude-review-hub
9
+ * already running on 4319 cannot silently steal the proxy target.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import net from "node:net";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { installSkill, isGitRepo } from "./install-skill.mjs";
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const root = path.join(here, "..");
20
+ // Whatever repo was last developed against; plain `npm run dev` reuses it.
21
+ const lastFile = path.join(root, ".dev-project");
22
+
23
+ const args = process.argv.slice(2);
24
+
25
+ if (args.includes("-h") || args.includes("--help")) {
26
+ console.log(`
27
+ npm run dev [-- <repo path>] [--api-port <n>] [--web-port <n>]
28
+
29
+ Runs the tool from source, with hot reload, against a repo to review.
30
+ The repo is remembered, so later runs can be just 'npm run dev'.
31
+
32
+ Examples:
33
+ npm run dev -- "C:\\Source code\\your-repo"
34
+ npm run dev
35
+ `);
36
+ process.exit(0);
37
+ }
38
+
39
+ function flag(name) {
40
+ const index = args.indexOf(name);
41
+ return index === -1 ? undefined : args[index + 1];
42
+ }
43
+
44
+ /** The repo path, given bare: anything that is not a flag or a flag's value. */
45
+ const positional = args.find((arg, index) => !arg.startsWith("-") && !args[index - 1]?.startsWith("-"));
46
+ const remembered = fs.existsSync(lastFile) ? fs.readFileSync(lastFile, "utf8").trim() : "";
47
+ const chosen = flag("--project") ?? positional ?? remembered;
48
+ const target = chosen ? path.resolve(chosen) : "";
49
+
50
+ if (!chosen) {
51
+ console.error(`
52
+ Say which repo to review, once:
53
+
54
+ npm run dev -- "C:\\Source code\\your-repo"
55
+
56
+ It is remembered after that, so later runs are just 'npm run dev'.
57
+ `);
58
+ process.exit(1);
59
+ }
60
+
61
+ if (!isGitRepo(target)) {
62
+ console.error(`\n ${target} is not a git repository.\n`);
63
+ process.exit(1);
64
+ }
65
+
66
+ fs.writeFileSync(lastFile, target);
67
+
68
+ /**
69
+ * Asked by connecting, not by binding: Windows lets a wildcard bind succeed
70
+ * while another process holds the same port on a specific address, so a bind
71
+ * probe calls ports free that Express or Vite then fails to take.
72
+ */
73
+ function answers(port, host) {
74
+ return new Promise((resolve) => {
75
+ const socket = net.connect({ port, host });
76
+ const done = (result) => {
77
+ socket.destroy();
78
+ resolve(result);
79
+ };
80
+ socket.setTimeout(500);
81
+ socket.once("connect", () => done(true));
82
+ socket.once("timeout", () => done(false));
83
+ socket.once("error", () => done(false));
84
+ });
85
+ }
86
+
87
+ /** The dev server must own the port Vite proxies to, so it is picked up front. */
88
+ async function free(from, { skip = [], attempts = 20 } = {}) {
89
+ for (let candidate = from; candidate < from + attempts; candidate += 1) {
90
+ // `skip` holds ports already handed to a child that has not bound yet;
91
+ // without it both halves can be told to use the same one.
92
+ if (skip.includes(candidate)) continue;
93
+ // Both families: Express listens on IPv4 and IPv6, Vite resolves localhost
94
+ // to ::1 first on Windows.
95
+ if (!(await answers(candidate, "127.0.0.1")) && !(await answers(candidate, "::1"))) return candidate;
96
+ }
97
+ throw new Error(`No free port from ${from}.`);
98
+ }
99
+
100
+ const apiPort = Number(flag("--api-port")) || (await free(4319));
101
+ const webPort = Number(flag("--web-port")) || (await free(4318, { skip: [apiPort] }));
102
+
103
+ // Same as a real start: the repo gets the current skill before anything runs.
104
+ installSkill(target, { quiet: true });
105
+
106
+ console.log(`
107
+ Claude Review Hub (dev, hot reload)
108
+ project : ${target}
109
+ api : http://localhost:${apiPort}
110
+ open : http://localhost:${webPort}
111
+
112
+ Editing server/ or web/ reloads by itself. Editing skill/ needs a restart.
113
+ Ctrl+C stops both.
114
+ `);
115
+
116
+ const env = {
117
+ ...process.env,
118
+ REVIEW_TOOL_PROJECT: target,
119
+ PORT: String(apiPort),
120
+ // Read by web/vite.config.ts, so the proxy always finds the server above.
121
+ API_PORT: String(apiPort),
122
+ // The proxy target is fixed now, so the server must not drift off this port.
123
+ REVIEW_TOOL_STRICT_PORT: "1",
124
+ };
125
+
126
+ // The local entry points are run by node directly. Going through npx and a
127
+ // shell would leave the real processes as grandchildren, and on Windows those
128
+ // survive a kill and keep holding the ports.
129
+ const bin = (...parts) => path.join(root, "node_modules", ...parts);
130
+ const children = [
131
+ spawn(process.execPath, [bin("tsx", "dist", "cli.mjs"), "watch", "server/src/index.ts"], {
132
+ cwd: root,
133
+ env,
134
+ stdio: "inherit",
135
+ }),
136
+ spawn(process.execPath, [bin("vite", "bin", "vite.js"), "--port", String(webPort), "--strictPort"], {
137
+ cwd: path.join(root, "web"),
138
+ env,
139
+ stdio: "inherit",
140
+ }),
141
+ ];
142
+
143
+ let stopping = false;
144
+ const stopAll = () => {
145
+ if (stopping) return;
146
+ stopping = true;
147
+ for (const child of children) {
148
+ if (!child.pid) continue;
149
+ // tsx runs the server in a child of its own, so the whole tree has to go;
150
+ // a survivor would hold the port and quietly serve stale code later.
151
+ if (process.platform === "win32") {
152
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: true });
153
+ } else {
154
+ child.kill();
155
+ }
156
+ }
157
+ setTimeout(() => process.exit(0), 1500).unref();
158
+ };
159
+
160
+ process.on("SIGINT", stopAll);
161
+ process.on("SIGTERM", stopAll);
162
+ // One half dying is not a working dashboard, so the other goes with it.
163
+ for (const child of children) child.on("exit", stopAll);
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url));
6
+ const skillRoot = path.join(here, "..", "skill");
7
+
8
+ /** One skill per host the tool supports, discovered rather than listed twice. */
9
+ export const SKILL_NAMES = fs
10
+ .readdirSync(skillRoot, { withFileTypes: true })
11
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(skillRoot, entry.name, "SKILL.md")))
12
+ .map((entry) => entry.name);
13
+
14
+ /** What the tool adds to a repo, and what it must therefore keep out of git. */
15
+ export const EXCLUDED_PATHS = [".review-tool/", ...SKILL_NAMES.map((name) => `.claude/skills/${name}/`)];
16
+
17
+ /**
18
+ * Puts the forked per-host skills in the target repo and keeps the tool's own
19
+ * state out of that repo's history. Idempotent: safe to run on every start.
20
+ */
21
+ export function installSkill(target, { quiet = false } = {}) {
22
+ const log = (message) => !quiet && console.log(message);
23
+ const done = { skill: false, excluded: false };
24
+
25
+ for (const name of SKILL_NAMES) {
26
+ const skillTarget = path.join(target, ".claude", "skills", name);
27
+ const sourceFile = path.join(skillRoot, name, "SKILL.md");
28
+ const targetFile = path.join(skillTarget, "SKILL.md");
29
+ const current = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, "utf8") : null;
30
+ const latest = fs.readFileSync(sourceFile, "utf8");
31
+
32
+ if (current !== latest) {
33
+ fs.mkdirSync(path.dirname(skillTarget), { recursive: true });
34
+ fs.cpSync(path.join(skillRoot, name), skillTarget, { recursive: true });
35
+ log(` skill ${current === null ? "installed" : "updated"} -> .claude/skills/${name}`);
36
+ done.skill = true;
37
+ }
38
+ }
39
+
40
+ // .git/info/exclude, not .gitignore: that file is tracked, and a work repo
41
+ // must not show a modified file just because the tool was started in it.
42
+ const exclude = excludeFile(target);
43
+ if (exclude) {
44
+ const existing = fs.existsSync(exclude) ? fs.readFileSync(exclude, "utf8") : "";
45
+ const present = new Set(existing.split(/\r?\n/).map((line) => line.trim()));
46
+ const missing = EXCLUDED_PATHS.filter((line) => !present.has(line));
47
+ if (missing.length) {
48
+ fs.mkdirSync(path.dirname(exclude), { recursive: true });
49
+ const prefix = !existing || existing.endsWith("\n") ? "" : "\n";
50
+ fs.appendFileSync(exclude, `${prefix}${missing.join("\n")}\n`);
51
+ log(" ignored locally, in .git/info/exclude (your .gitignore is untouched)");
52
+ done.excluded = true;
53
+ }
54
+ }
55
+
56
+ return done;
57
+ }
58
+
59
+ export function isGitRepo(target) {
60
+ return fs.existsSync(path.join(target, ".git"));
61
+ }
62
+
63
+ /**
64
+ * Resolves .git/info/exclude, following the gitdir pointer when the repo is
65
+ * itself a worktree (there .git is a file, not a directory).
66
+ */
67
+ export function excludeFile(target) {
68
+ const dotGit = path.join(target, ".git");
69
+ if (!fs.existsSync(dotGit)) return null;
70
+ if (fs.statSync(dotGit).isDirectory()) return path.join(dotGit, "info", "exclude");
71
+ const pointer = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
72
+ if (!pointer) return null;
73
+ return path.join(path.resolve(target, pointer[1].trim()), "info", "exclude");
74
+ }