@review-my-code/rmcode 0.1.0-alpha.1 → 0.1.2

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 (3) hide show
  1. package/README.md +29 -0
  2. package/dist/cli.js +887 -254
  3. package/package.json +8 -3
package/dist/cli.js CHANGED
@@ -2,11 +2,33 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const child_process_1 = require("child_process");
5
+ const crypto_1 = require("crypto");
5
6
  const fs_1 = require("fs");
7
+ const os_1 = require("os");
6
8
  const path_1 = require("path");
7
9
  const readline_1 = require("readline");
8
10
  const API_URL = process.env.RMC_API_URL || "https://review-my-code.com";
9
- const VERSION = "0.1.0";
11
+ const APP_URL = process.env.RMC_APP_URL || "https://review-my-code.com";
12
+ const VERSION = "0.1.2";
13
+ const FREE_PLAN_CREDITS_PER_MONTH = 30;
14
+ const REQUEST_TIMEOUT_MS = 290_000;
15
+ const POLL_TIMEOUT_MS = 10 * 60_000;
16
+ // Backed-off polling keeps long reviews well under the backend's per-IP
17
+ // rate limit (100 requests / 15 min).
18
+ const POLL_INITIAL_INTERVAL_MS = 3_000;
19
+ const POLL_MAX_INTERVAL_MS = 15_000;
20
+ const POLL_BACKOFF_FACTOR = 1.5;
21
+ const MAX_UNTRACKED_FILE_BYTES = 512 * 1024;
22
+ const EXTRACTOR_TIMEOUT_MS = 120_000;
23
+ const EXTRACTOR_DOWNLOAD_TIMEOUT_MS = 60_000;
24
+ const EXTRACTOR_INFO_TIMEOUT_MS = 15_000;
25
+ // Mirrors the GitHub App workflow template's "Install extractor runtime
26
+ // dependencies" step and REPOMAP_MAX_CHARS env.
27
+ const EXTRACTOR_RUNTIME_DEPS = [
28
+ "web-tree-sitter@0.20.8",
29
+ "tree-sitter-wasms@0.1.13",
30
+ ];
31
+ const EXTRACTOR_REPOMAP_MAX_CHARS = "80000";
10
32
  // ── Colors (no dependencies) ──────────────────────────────────────────
11
33
  const c = {
12
34
  reset: "\x1b[0m",
@@ -15,15 +37,12 @@ const c = {
15
37
  red: "\x1b[31m",
16
38
  green: "\x1b[32m",
17
39
  yellow: "\x1b[33m",
18
- blue: "\x1b[34m",
19
- magenta: "\x1b[35m",
20
40
  cyan: "\x1b[36m",
21
41
  gray: "\x1b[90m",
22
42
  white: "\x1b[97m",
23
43
  bgRed: "\x1b[41m",
24
44
  bgYellow: "\x1b[43m",
25
45
  bgGreen: "\x1b[42m",
26
- bgBlue: "\x1b[44m",
27
46
  };
28
47
  const isCI = process.env.CI === "true";
29
48
  const noColor = process.env.NO_COLOR === "1" || !process.stdout.isTTY;
@@ -32,6 +51,19 @@ function style(text, ...codes) {
32
51
  return text;
33
52
  return codes.join("") + text + c.reset;
34
53
  }
54
+ // ── Owl header ────────────────────────────────────────────────────────
55
+ //
56
+ // Brand header shown once at the start of review runs (stderr, so piped
57
+ // stdout stays parseable) and in --help. Hidden in --json mode, non-TTY,
58
+ // NO_COLOR, and CI via the noColor/isCI guards.
59
+ function owlHeader() {
60
+ const owl = (part) => style(part, c.dim, c.gray);
61
+ return [
62
+ ` ${owl("{o,o}")}`,
63
+ ` ${owl("/)__)")} ${style("rmcode", c.bold, c.white)} ${style(`v${VERSION} — AI code review`, c.dim)}`,
64
+ ` ${owl('" "')} ${style("─────────────────────────────────", c.dim)}`,
65
+ ].join("\n");
66
+ }
35
67
  // ── Severity styling ──────────────────────────────────────────────────
36
68
  function severityBadge(severity) {
37
69
  const s = severity.toLowerCase();
@@ -44,118 +76,526 @@ function severityBadge(severity) {
44
76
  return style(` ${severity.toUpperCase()} `, c.dim);
45
77
  }
46
78
  // ── Git helpers ───────────────────────────────────────────────────────
47
- function gitDiff(staged) {
79
+ function git(args) {
80
+ return (0, child_process_1.execFileSync)("git", args, {
81
+ encoding: "utf-8",
82
+ maxBuffer: 10 * 1024 * 1024,
83
+ }).trim();
84
+ }
85
+ function gitQuiet(args) {
86
+ return (0, child_process_1.execFileSync)("git", args, {
87
+ encoding: "utf-8",
88
+ maxBuffer: 10 * 1024 * 1024,
89
+ stdio: ["ignore", "pipe", "ignore"],
90
+ }).trim();
91
+ }
92
+ function isGitRepo() {
48
93
  try {
49
- const flag = staged ? "--cached" : "";
50
- const diff = (0, child_process_1.execSync)(`git diff ${flag}`, { encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
51
- return diff.trim() || null;
94
+ git(["rev-parse", "--is-inside-work-tree"]);
95
+ return true;
52
96
  }
53
97
  catch {
54
- return null;
98
+ return false;
55
99
  }
56
100
  }
57
- function gitStagedFiles() {
101
+ function gitWorktreeRoot() {
102
+ return git(["rev-parse", "--show-toplevel"]);
103
+ }
104
+ function mergeBase() {
105
+ const refs = [];
58
106
  try {
59
- return (0, child_process_1.execSync)("git diff --cached --name-only", { encoding: "utf-8" }).trim().split("\n").filter(Boolean);
107
+ refs.push(git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]));
60
108
  }
61
109
  catch {
62
- return [];
110
+ // Remote HEAD is not available.
63
111
  }
112
+ for (const ref of [
113
+ ...refs,
114
+ "origin/main",
115
+ "origin/master",
116
+ "main",
117
+ "master",
118
+ ]) {
119
+ try {
120
+ git(["rev-parse", "--verify", ref]);
121
+ return git(["merge-base", ref, "HEAD"]);
122
+ }
123
+ catch {
124
+ // Try the next common default branch ref.
125
+ }
126
+ }
127
+ throw new Error("Could not determine a base branch. Set an upstream branch, or run rmcode --staged, rmcode --all, or pipe an explicit git diff.");
64
128
  }
65
- function gitUnstagedFiles() {
129
+ function headCommit() {
66
130
  try {
67
- return (0, child_process_1.execSync)("git diff --name-only", { encoding: "utf-8" }).trim().split("\n").filter(Boolean);
131
+ return git(["rev-parse", "--verify", "HEAD"]);
68
132
  }
69
133
  catch {
70
- return [];
134
+ return undefined;
71
135
  }
72
136
  }
73
- function isGitRepo() {
137
+ function diffFromMergeBase() {
138
+ const base = mergeBase();
139
+ const diff = git(["diff", `${base}...HEAD`]);
140
+ const files = git(["diff", `${base}...HEAD`, "--name-only"])
141
+ .split("\n")
142
+ .filter(Boolean);
143
+ return { diff, files, baseRef: base };
144
+ }
145
+ function diffStaged() {
146
+ const diff = git(["diff", "--cached"]);
147
+ const files = git(["diff", "--cached", "--name-only"])
148
+ .split("\n")
149
+ .filter(Boolean);
150
+ return { diff, files, baseRef: headCommit() };
151
+ }
152
+ function diffUnstaged() {
153
+ const diff = git(["diff"]);
154
+ const files = git(["diff", "--name-only"]).split("\n").filter(Boolean);
155
+ return { diff, files, baseRef: headCommit() };
156
+ }
157
+ function diffAll() {
158
+ const root = gitWorktreeRoot();
159
+ const trackedDiff = git(["diff", "HEAD"]);
160
+ const trackedFiles = git(["diff", "HEAD", "--name-only"])
161
+ .split("\n")
162
+ .filter(Boolean);
163
+ const untrackedFiles = git(["ls-files", "--others", "--exclude-standard"])
164
+ .split("\n")
165
+ .filter(Boolean);
166
+ const untrackedDiffs = untrackedFiles
167
+ .map((file) => pseudoDiffForNewFile(file, root))
168
+ .filter(Boolean);
169
+ const diff = [trackedDiff, ...untrackedDiffs].filter(Boolean).join("\n\n");
170
+ const files = [...trackedFiles, ...untrackedFiles];
171
+ return { diff, files, baseRef: headCommit() };
172
+ }
173
+ function pseudoDiffForNewFile(file, worktreeRoot) {
74
174
  try {
75
- (0, child_process_1.execSync)("git rev-parse --is-inside-work-tree", { encoding: "utf-8", stdio: "pipe" });
76
- return true;
175
+ if ((0, path_1.isAbsolute)(file))
176
+ return "";
177
+ const stat = (0, fs_1.lstatSync)(file);
178
+ if (!stat.isFile() || stat.isSymbolicLink())
179
+ return "";
180
+ if (stat.size > MAX_UNTRACKED_FILE_BYTES)
181
+ return "";
182
+ const realRoot = (0, fs_1.realpathSync)(worktreeRoot);
183
+ const realFile = (0, fs_1.realpathSync)(file);
184
+ const rel = (0, path_1.relative)(realRoot, realFile);
185
+ if (rel.startsWith("..") || (0, path_1.isAbsolute)(rel))
186
+ return "";
187
+ const contents = (0, fs_1.readFileSync)(realFile, "utf-8");
188
+ if (contents.includes("\0"))
189
+ return "";
190
+ const added = contents
191
+ .split("\n")
192
+ .map((line) => `+${line}`)
193
+ .join("\n");
194
+ return `diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${contents.split("\n").length} @@\n${added}`;
77
195
  }
78
196
  catch {
79
- return false;
197
+ return "";
80
198
  }
81
199
  }
82
200
  function gitRepoName() {
83
201
  try {
84
- const remote = (0, child_process_1.execSync)("git remote get-url origin", {
85
- encoding: "utf-8",
86
- stdio: ["pipe", "pipe", "pipe"],
87
- }).trim();
88
- const httpsMatch = remote.match(/github\.com\/([^/]+\/[^/.]+)/);
89
- if (httpsMatch)
90
- return httpsMatch[1];
91
- const sshMatch = remote.match(/github\.com:([^/]+\/[^/.]+)/);
92
- if (sshMatch)
93
- return sshMatch[1];
94
- return null;
202
+ const remote = git(["remote", "get-url", "origin"]);
203
+ const m = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
204
+ return m ? m[1] : null;
95
205
  }
96
206
  catch {
97
207
  return null;
98
208
  }
99
209
  }
210
+ function currentBranch() {
211
+ try {
212
+ return git(["branch", "--show-current"]);
213
+ }
214
+ catch {
215
+ return "unknown";
216
+ }
217
+ }
218
+ function filesFromDiff(diff) {
219
+ const files = new Set();
220
+ const re = /^diff --git a\/.+ b\/(.+)$/gm;
221
+ let match;
222
+ while ((match = re.exec(diff)) !== null) {
223
+ files.add(match[1]);
224
+ }
225
+ return [...files];
226
+ }
227
+ function looksLikeUnifiedDiff(value) {
228
+ return value.startsWith("diff --git ") || /\n@@ -\d/.test(value);
229
+ }
230
+ function hasPipedStdin() {
231
+ if (process.stdin.isTTY)
232
+ return false;
233
+ try {
234
+ const stat = (0, fs_1.fstatSync)(0);
235
+ return stat.isFIFO() || stat.isFile() || stat.isSocket();
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
100
241
  const API_KEY = process.env.RMC_API_KEY || null;
101
- async function callAuthenticatedAPI(code, language, type, files) {
242
+ async function fetchReviewJson(url, init) {
243
+ const controller = new AbortController();
244
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
245
+ try {
246
+ const resp = await fetch(url, { ...init, signal: controller.signal });
247
+ const text = await resp.text();
248
+ let body;
249
+ try {
250
+ body = text ? JSON.parse(text) : { success: false };
251
+ }
252
+ catch {
253
+ body = {
254
+ success: false,
255
+ error: `RMCode API returned a non-JSON response (${resp.status}).`,
256
+ code: "RMC-5000",
257
+ };
258
+ }
259
+ if (!resp.ok && body.success !== false) {
260
+ body = {
261
+ ...body,
262
+ success: false,
263
+ error: body.error || `RMCode API request failed (${resp.status}).`,
264
+ };
265
+ }
266
+ return { status: resp.status, body };
267
+ }
268
+ catch (error) {
269
+ const message = error instanceof Error && error.name === "AbortError"
270
+ ? "RMCode API request timed out."
271
+ : error instanceof Error
272
+ ? error.message
273
+ : "RMCode API request failed.";
274
+ return {
275
+ status: 0,
276
+ body: { success: false, error: message, code: "RMC-5000" },
277
+ };
278
+ }
279
+ finally {
280
+ clearTimeout(timeout);
281
+ }
282
+ }
283
+ async function callAuthenticatedAPI(code, language, type, files, bundle, progress) {
102
284
  const repo = gitRepoName();
103
- const resp = await fetch(`${API_URL}/api/reviews/cli`, {
285
+ const payload = {
286
+ type,
287
+ value: code,
288
+ language,
289
+ files,
290
+ repo,
291
+ };
292
+ if (bundle)
293
+ payload.bundle = bundle;
294
+ let { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
104
295
  method: "POST",
105
296
  headers: {
106
297
  "Content-Type": "application/json",
107
298
  "X-API-Key": API_KEY,
108
299
  },
109
- body: JSON.stringify({ type, value: code, language, files, repo }),
300
+ body: JSON.stringify(payload),
110
301
  });
111
- return resp.json();
302
+ // If the API rejects the attached context bundle (too large or invalid),
303
+ // degrade to the legacy diff-only request instead of failing the review.
304
+ if (bundle && (status === 413 || status === 400)) {
305
+ const notice = style(" Repository context was rejected by the API — reviewing without it.", c.dim);
306
+ if (progress)
307
+ progress.note(notice);
308
+ else
309
+ console.error(notice);
310
+ delete payload.bundle;
311
+ ({ status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
312
+ method: "POST",
313
+ headers: {
314
+ "Content-Type": "application/json",
315
+ "X-API-Key": API_KEY,
316
+ },
317
+ body: JSON.stringify(payload),
318
+ }));
319
+ }
320
+ if (status === 202 && body.data?.accessToken) {
321
+ progress?.update("Reviewing (queued)");
322
+ return pollForResults(`${API_URL}/api/reviews/cli/${body.data.accessToken}`, body.meta, { "X-API-Key": API_KEY }, progress);
323
+ }
324
+ return body;
112
325
  }
113
- async function callAnonymousAPI(code, language) {
326
+ async function callAnonymousAPI(code, language, type, files, progress) {
114
327
  const repo = gitRepoName();
115
- const resp = await fetch(`${API_URL}/api/reviews/anonymous`, {
328
+ const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/anonymous`, {
116
329
  method: "POST",
117
330
  headers: { "Content-Type": "application/json" },
118
- body: JSON.stringify({ type: "snippet", value: code, language, repo }),
331
+ body: JSON.stringify({ type, value: code, language, files, repo }),
119
332
  });
120
- const result = (await resp.json());
121
- // Handle 202 polling
122
- if (resp.status === 202 && result.data?.accessToken) {
123
- return pollForResults(result.data.accessToken, result.meta);
333
+ if (status === 202 && body.data?.accessToken) {
334
+ progress?.update("Reviewing (queued)");
335
+ return pollForResults(`${API_URL}/api/reviews/anonymous/${body.data.accessToken}`, body.meta, undefined, progress);
124
336
  }
125
- return result;
337
+ return body;
126
338
  }
127
- async function pollForResults(accessToken, initialMeta) {
128
- const deadline = Date.now() + 60_000;
339
+ async function pollForResults(url, initialMeta, headers, progress) {
340
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
341
+ let interval = POLL_INITIAL_INTERVAL_MS;
342
+ let reviewing = false;
129
343
  while (Date.now() < deadline) {
130
- await new Promise((r) => setTimeout(r, 2000));
131
- const resp = await fetch(`${API_URL}/api/reviews/anonymous/${accessToken}`);
132
- const result = (await resp.json());
133
- if (result.data?.status === "completed" || result.data?.status === "failed") {
134
- if (initialMeta && !result.meta)
135
- result.meta = initialMeta;
136
- return result;
344
+ await new Promise((r) => setTimeout(r, interval));
345
+ interval = Math.min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL_MS);
346
+ const { status, body } = await fetchReviewJson(url, { headers });
347
+ if (status === 429 || body.error?.includes("Too many requests")) {
348
+ // Rate limiting is transient — retry at the backed-off interval.
349
+ continue;
350
+ }
351
+ if (status >= 400 || body.success === false) {
352
+ if (initialMeta && !body.meta)
353
+ body.meta = initialMeta;
354
+ return body;
355
+ }
356
+ if (body.data?.status === "completed" || body.data?.status === "failed") {
357
+ if (initialMeta && !body.meta)
358
+ body.meta = initialMeta;
359
+ return body;
360
+ }
361
+ // The poll body carries no real queue state ("pending" covers both
362
+ // queued and running), so flip to the active phase once the first
363
+ // poll answers — unless the API explicitly reports "queued".
364
+ if (progress && !reviewing && body.data?.status !== "queued") {
365
+ reviewing = true;
366
+ progress.update("Reviewing");
367
+ }
368
+ }
369
+ return { success: false, error: "Review timed out after 10 minutes." };
370
+ }
371
+ function callAPI(code, language, type = "snippet", files = [], bundle, progress) {
372
+ if (API_KEY)
373
+ return callAuthenticatedAPI(code, language, type, files, bundle, progress);
374
+ return Promise.resolve({
375
+ success: false,
376
+ error: "API key required. Run `rmcode login`, then set RMC_API_KEY before sending code for review.",
377
+ code: "RMC-1002",
378
+ meta: {
379
+ plan: null,
380
+ reviewer: "scout",
381
+ message: "Anonymous CLI reviews are disabled so your credits stay tied to your RMCode account.",
382
+ },
383
+ });
384
+ }
385
+ class ExtractorIntegrityError extends Error {
386
+ }
387
+ function sha256Hex(data) {
388
+ return (0, crypto_1.createHash)("sha256").update(data).digest("hex");
389
+ }
390
+ async function fetchExtractorInfo() {
391
+ try {
392
+ const resp = await fetch(`${API_URL}/api/reviews/cli/extractor`, {
393
+ headers: { "X-API-Key": API_KEY },
394
+ signal: AbortSignal.timeout(EXTRACTOR_INFO_TIMEOUT_MS),
395
+ });
396
+ if (!resp.ok)
397
+ return null;
398
+ const body = (await resp.json());
399
+ const data = body.data;
400
+ if (!data?.version || !data.sha256 || !data.url)
401
+ return null;
402
+ return {
403
+ version: data.version,
404
+ sha256: data.sha256.toLowerCase(),
405
+ url: data.url,
406
+ tier: data.tier,
407
+ };
408
+ }
409
+ catch {
410
+ return null;
411
+ }
412
+ }
413
+ /**
414
+ * Download (or reuse a cached copy of) the pinned extractor and install its
415
+ * runtime dependencies next to it, mirroring the GitHub App workflow.
416
+ * Throws ExtractorIntegrityError on checksum mismatch — never run an
417
+ * artifact that fails verification.
418
+ */
419
+ async function ensureExtractor(info) {
420
+ const cacheDir = (0, path_1.join)((0, os_1.tmpdir)(), "rmcode-extractor", info.version);
421
+ (0, fs_1.mkdirSync)(cacheDir, { recursive: true });
422
+ const extractorPath = (0, path_1.join)(cacheDir, "extractor.mjs");
423
+ let verified = false;
424
+ if ((0, fs_1.existsSync)(extractorPath)) {
425
+ verified = sha256Hex((0, fs_1.readFileSync)(extractorPath)) === info.sha256;
426
+ }
427
+ if (!verified) {
428
+ const resp = await fetch(info.url, {
429
+ signal: AbortSignal.timeout(EXTRACTOR_DOWNLOAD_TIMEOUT_MS),
430
+ });
431
+ if (!resp.ok) {
432
+ throw new Error(`Extractor download failed (${resp.status}).`);
137
433
  }
434
+ const data = Buffer.from(await resp.arrayBuffer());
435
+ if (sha256Hex(data) !== info.sha256) {
436
+ throw new ExtractorIntegrityError(`Extractor checksum mismatch for ${info.url} — refusing to run it. Expected sha256 ${info.sha256}.`);
437
+ }
438
+ (0, fs_1.writeFileSync)(extractorPath, data);
138
439
  }
139
- return { success: false, error: "Review timed out after 60 seconds." };
440
+ if (!(0, fs_1.existsSync)((0, path_1.join)(cacheDir, "node_modules", "web-tree-sitter"))) {
441
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
442
+ const init = (0, child_process_1.spawnSync)(npm, ["init", "-y"], {
443
+ cwd: cacheDir,
444
+ stdio: "ignore",
445
+ timeout: 30_000,
446
+ });
447
+ if (init.status !== 0) {
448
+ throw new Error("Could not initialize extractor dependency directory.");
449
+ }
450
+ const install = (0, child_process_1.spawnSync)(npm, [
451
+ "install",
452
+ "--no-save",
453
+ "--legacy-peer-deps",
454
+ "--no-audit",
455
+ "--no-fund",
456
+ ...EXTRACTOR_RUNTIME_DEPS,
457
+ ], { cwd: cacheDir, stdio: "ignore", timeout: EXTRACTOR_TIMEOUT_MS });
458
+ if (install.status !== 0) {
459
+ throw new Error("Could not install extractor runtime dependencies.");
460
+ }
461
+ }
462
+ return extractorPath;
140
463
  }
141
- function callAPI(code, language, type = "snippet", files = []) {
142
- if (API_KEY) {
143
- return callAuthenticatedAPI(code, language, type, files);
464
+ /**
465
+ * Run the pinned extractor against a temporary detached worktree at the
466
+ * diff's base state and return the ContextBundle, or null when extraction
467
+ * is unavailable. Throws ExtractorIntegrityError on checksum mismatch.
468
+ */
469
+ async function extractContextBundle(diff, baseRef) {
470
+ const info = await fetchExtractorInfo();
471
+ if (!info)
472
+ return null;
473
+ const extractorPath = await ensureExtractor(info);
474
+ const runDir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "rmcode-run-"));
475
+ const diffPath = (0, path_1.join)(runDir, "pr.diff");
476
+ const bundlePath = (0, path_1.join)(runDir, "bundle.json");
477
+ const baseDir = (0, path_1.join)(runDir, "base");
478
+ let worktreeAdded = false;
479
+ try {
480
+ try {
481
+ gitQuiet(["worktree", "add", "--detach", baseDir, baseRef]);
482
+ worktreeAdded = true;
483
+ }
484
+ catch {
485
+ // Without a base-state worktree the extractor would see the wrong
486
+ // tree (e.g. uncommitted or post-PR files), so degrade to diff-only
487
+ // instead of extracting from the live checkout.
488
+ return null;
489
+ }
490
+ (0, fs_1.writeFileSync)(diffPath, diff);
491
+ const result = (0, child_process_1.spawnSync)(process.execPath, [
492
+ extractorPath,
493
+ "--pr=0",
494
+ `--diff-file=${diffPath}`,
495
+ `--output-bundle=${bundlePath}`,
496
+ `--source-repo=${process.env.RMC_SOURCE_REPO || gitRepoName() || "local/repo"}`,
497
+ `--tier=${info.tier || "free"}`,
498
+ ], {
499
+ cwd: baseDir,
500
+ stdio: "ignore",
501
+ timeout: EXTRACTOR_TIMEOUT_MS,
502
+ env: {
503
+ ...process.env,
504
+ REPOMAP_MAX_CHARS: EXTRACTOR_REPOMAP_MAX_CHARS,
505
+ RMCODE_EXTRACTOR_VERSION: info.version,
506
+ RMCODE_EXTRACTOR_SHA256: info.sha256,
507
+ },
508
+ });
509
+ if (result.status !== 0 || !(0, fs_1.existsSync)(bundlePath))
510
+ return null;
511
+ const bundle = JSON.parse((0, fs_1.readFileSync)(bundlePath, "utf-8"));
512
+ if (typeof bundle.diff !== "string" ||
513
+ typeof bundle.repomapContext !== "string" ||
514
+ typeof bundle.enrichedContext !== "string" ||
515
+ !Array.isArray(bundle.changedFiles)) {
516
+ return null;
517
+ }
518
+ return bundle;
144
519
  }
145
- return callAnonymousAPI(code, language);
520
+ catch {
521
+ return null;
522
+ }
523
+ finally {
524
+ if (worktreeAdded) {
525
+ try {
526
+ gitQuiet(["worktree", "remove", "--force", baseDir]);
527
+ }
528
+ catch {
529
+ (0, fs_1.rmSync)(baseDir, { recursive: true, force: true });
530
+ try {
531
+ gitQuiet(["worktree", "prune"]);
532
+ }
533
+ catch {
534
+ // Stale worktree metadata only; git prunes it on its own later.
535
+ }
536
+ }
537
+ }
538
+ (0, fs_1.rmSync)(runDir, { recursive: true, force: true });
539
+ }
540
+ }
541
+ /**
542
+ * Best-effort context extraction for git-based diff reviews. Returns null
543
+ * (after printing a one-line notice) when the review must run diff-only.
544
+ * Reports progress on the shared review spinner so the run renders as one
545
+ * phase-aware line. Exits the process on extractor checksum mismatch.
546
+ */
547
+ async function maybeExtractContext(diff, spinner, baseRef) {
548
+ if (!API_KEY || !baseRef || !isGitRepo())
549
+ return null;
550
+ try {
551
+ const bundle = await extractContextBundle(diff, baseRef);
552
+ if (bundle)
553
+ return bundle;
554
+ }
555
+ catch (err) {
556
+ if (err instanceof ExtractorIntegrityError) {
557
+ spinner.stop("Context extraction aborted");
558
+ console.error(style(` ${err.message}`, c.red));
559
+ process.exit(1);
560
+ }
561
+ }
562
+ spinner.note(style(" Running review without repository context.", c.dim));
563
+ return null;
146
564
  }
147
565
  // ── Language detection ────────────────────────────────────────────────
148
566
  const EXT_TO_LANG = {
149
- ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript",
150
- ".py": "python", ".java": "java", ".go": "go", ".rs": "rust",
151
- ".c": "c", ".cpp": "cpp", ".h": "c", ".cs": "csharp",
152
- ".rb": "ruby", ".php": "php", ".swift": "swift", ".kt": "kotlin",
153
- ".scala": "scala", ".lua": "lua", ".dart": "dart",
154
- ".v": "verilog", ".sv": "verilog", ".vhd": "vhdl",
155
- ".glsl": "glsl", ".hlsl": "hlsl", ".wgsl": "wgsl",
156
- ".jl": "julia", ".r": "r", ".ex": "elixir", ".erl": "erlang",
157
- ".hs": "haskell", ".ml": "ocaml", ".fs": "fsharp",
158
- ".sh": "bash", ".zig": "zig", ".sol": "solidity",
567
+ ".ts": "typescript",
568
+ ".tsx": "typescript",
569
+ ".js": "javascript",
570
+ ".jsx": "javascript",
571
+ ".py": "python",
572
+ ".java": "java",
573
+ ".go": "go",
574
+ ".rs": "rust",
575
+ ".c": "c",
576
+ ".cpp": "cpp",
577
+ ".h": "c",
578
+ ".cs": "csharp",
579
+ ".rb": "ruby",
580
+ ".php": "php",
581
+ ".swift": "swift",
582
+ ".kt": "kotlin",
583
+ ".scala": "scala",
584
+ ".lua": "lua",
585
+ ".dart": "dart",
586
+ ".v": "verilog",
587
+ ".sv": "verilog",
588
+ ".vhd": "vhdl",
589
+ ".sh": "bash",
590
+ ".zig": "zig",
591
+ ".sol": "solidity",
592
+ ".jl": "julia",
593
+ ".r": "r",
594
+ ".ex": "elixir",
595
+ ".erl": "erlang",
596
+ ".hs": "haskell",
597
+ ".ml": "ocaml",
598
+ ".fs": "fsharp",
159
599
  };
160
600
  function detectLanguage(files) {
161
601
  for (const f of files) {
@@ -166,35 +606,100 @@ function detectLanguage(files) {
166
606
  return "javascript";
167
607
  }
168
608
  // ── Output formatting ─────────────────────────────────────────────────
169
- function printFindings(findings, jsonMode) {
170
- if (jsonMode) {
171
- console.log(JSON.stringify(findings, null, 2));
172
- return;
609
+ function severityRank(severity) {
610
+ const s = severity.toLowerCase();
611
+ if (s === "critical" || s === "security")
612
+ return 0;
613
+ if (s === "bug" || s === "high")
614
+ return 1;
615
+ if (s === "perf" || s === "medium")
616
+ return 2;
617
+ return 3;
618
+ }
619
+ function wrapText(text, width) {
620
+ const lines = [];
621
+ for (const paragraph of text.split("\n")) {
622
+ if (!paragraph.trim()) {
623
+ lines.push("");
624
+ continue;
625
+ }
626
+ let line = "";
627
+ for (const word of paragraph.split(/\s+/)) {
628
+ if (!line)
629
+ line = word;
630
+ else if (line.length + 1 + word.length <= width)
631
+ line += ` ${word}`;
632
+ else {
633
+ lines.push(line);
634
+ line = word;
635
+ }
636
+ }
637
+ if (line)
638
+ lines.push(line);
173
639
  }
640
+ return lines;
641
+ }
642
+ function verdictFooter(findingCount, startedAt) {
643
+ const rule = noColor ? "--" : style("──", c.dim);
644
+ const mark = findingCount > 0 ? style("✗", c.red, c.bold) : style("✓", c.green, c.bold);
645
+ const verdict = findingCount > 0
646
+ ? `changes requested · ${findingCount} finding${findingCount !== 1 ? "s" : ""}`
647
+ : "approved · no blocking findings";
648
+ const elapsed = startedAt != null
649
+ ? ` · ${formatElapsed(Date.now() - startedAt, true)}`
650
+ : "";
651
+ return ` ${rule} ${mark} ${verdict}${elapsed} ${rule}`;
652
+ }
653
+ function printFindings(findings, startedAt) {
174
654
  if (findings.length === 0) {
175
655
  console.log(`\n ${style("No issues found.", c.green, c.bold)} Your code looks good.\n`);
656
+ console.log(`${verdictFooter(0, startedAt)}\n`);
176
657
  return;
177
658
  }
178
659
  const counts = {};
179
- for (const f of findings) {
660
+ for (const f of findings)
180
661
  counts[f.severity] = (counts[f.severity] || 0) + 1;
181
- }
182
- const summary = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(" · ");
662
+ const summary = Object.entries(counts)
663
+ .map(([k, v]) => `${v} ${k}`)
664
+ .join(" · ");
183
665
  console.log(`\n ${style(`${findings.length} finding${findings.length !== 1 ? "s" : ""}`, c.bold, c.white)} · ${summary}\n`);
184
- for (const f of findings) {
185
- const location = f.filePath ? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}` : "";
186
- console.log(` ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
666
+ const sorted = [...findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
667
+ const numWidth = String(sorted.length).length;
668
+ // Each body line is indented by " " + number column + " " + gutter bar
669
+ // + " " = numWidth + 5 chars. Clamp to a sane floor so narrow terminals
670
+ // never produce a negative width for wrap/repeat.
671
+ const wrapWidth = Math.max(20, Math.min(process.stdout.columns || 80, 100) - (numWidth + 5));
672
+ const pad = " ".repeat(numWidth);
673
+ const bar = noColor ? "|" : style("▌", c.dim);
674
+ const fixPad = " ".repeat("fix: ".length);
675
+ sorted.forEach((f, idx) => {
676
+ const num = String(idx + 1).padStart(numWidth);
677
+ const location = f.filePath
678
+ ? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}`
679
+ : "";
680
+ console.log(` ${style(num, c.bold, c.white)} ${bar} ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
187
681
  if (location)
188
- console.log(` ${style("at", c.dim)} ${location}`);
189
- console.log(` ${style(f.explanation, c.gray)}`);
682
+ console.log(` ${pad} ${bar} ${style("at", c.dim)} ${location}`);
683
+ for (const line of wrapText(f.explanation, wrapWidth)) {
684
+ console.log(` ${pad} ${bar} ${style(line, c.gray)}`);
685
+ }
190
686
  if (f.codeContext) {
191
- console.log(` ${style(f.codeContext, c.dim)}`);
687
+ for (const line of f.codeContext.split("\n")) {
688
+ console.log(` ${pad} ${bar} ${style(line, c.dim)}`);
689
+ }
192
690
  }
193
691
  if (f.suggestedCode) {
194
- console.log(` ${style("fix:", c.green)} ${style(f.suggestedCode, c.green)}`);
692
+ const [first, ...rest] = f.suggestedCode.split("\n");
693
+ console.log(` ${pad} ${bar} ${style("fix:", c.green)} ${style(first, c.green)}`);
694
+ for (const line of rest) {
695
+ console.log(` ${pad} ${bar} ${fixPad}${style(line, c.green)}`);
696
+ }
195
697
  }
196
- console.log();
197
- }
698
+ if (idx < sorted.length - 1) {
699
+ console.log(` ${noColor ? "-".repeat(wrapWidth) : style("─".repeat(wrapWidth), c.dim)}`);
700
+ }
701
+ });
702
+ console.log(`\n${verdictFooter(sorted.length, startedAt)}\n`);
198
703
  }
199
704
  function printMeta(meta) {
200
705
  if (!meta)
@@ -203,108 +708,108 @@ function printMeta(meta) {
203
708
  if (meta.reviewer)
204
709
  parts.push(meta.reviewer.charAt(0).toUpperCase() + meta.reviewer.slice(1));
205
710
  if (meta.plan) {
206
- const planLabel = meta.plan.charAt(0).toUpperCase() + meta.plan.slice(1);
207
- if (meta.creditsRemaining != null) {
208
- parts.push(`${planLabel} (${meta.creditsRemaining} credits remaining)`);
209
- }
210
- else {
211
- parts.push(planLabel);
212
- }
213
- }
214
- if (meta.quotaUsed) {
215
- parts.push(`Free review (${meta.quotaUsed} today)`);
711
+ const label = meta.plan.charAt(0).toUpperCase() + meta.plan.slice(1);
712
+ parts.push(meta.creditsRemaining != null
713
+ ? `${label} (${meta.creditsRemaining} cr left)`
714
+ : label);
216
715
  }
217
- if (parts.length > 0) {
716
+ if (parts.length > 0)
218
717
  console.log(` ${style(parts.join(" · "), c.dim)}`);
219
- }
220
- if (meta.message) {
718
+ if (meta.message)
221
719
  console.log(` ${style(meta.message, c.yellow)}`);
222
- }
223
720
  }
224
- // ── Spinner ───────────────────────────────────────────────────────────
721
+ function printJsonResponse(result) {
722
+ console.log(JSON.stringify(result, null, 2));
723
+ }
724
+ function hasBlockingFindings(result) {
725
+ const findings = result.data?.findings?.findings || [];
726
+ return (findings.length > 0 || result.data?.findings?.verdict === "request_changes");
727
+ }
728
+ function formatElapsed(ms, compact = false) {
729
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
730
+ const minutes = Math.floor(totalSeconds / 60);
731
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
732
+ if (compact)
733
+ return minutes > 0 ? `${minutes}m${seconds}s` : `${totalSeconds % 60}s`;
734
+ return `${minutes}m ${seconds}s`;
735
+ }
225
736
  function createSpinner(msg) {
226
737
  if (noColor || isCI) {
738
+ // Plain fallback: print each phase transition once, no timer spam.
739
+ let lastMsg = msg;
227
740
  process.stderr.write(` ${msg}...\n`);
228
- return { stop: (final) => { if (final)
229
- process.stderr.write(` ${final}\n`); } };
741
+ return {
742
+ update(nextMsg) {
743
+ if (nextMsg === lastMsg)
744
+ return;
745
+ lastMsg = nextMsg;
746
+ process.stderr.write(` ${nextMsg}...\n`);
747
+ },
748
+ note(line) {
749
+ process.stderr.write(`${line}\n`);
750
+ },
751
+ stop(final) {
752
+ if (final)
753
+ process.stderr.write(` ${final}\n`);
754
+ },
755
+ };
230
756
  }
231
757
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
758
+ const startedAt = Date.now();
759
+ let currentMsg = msg;
232
760
  let i = 0;
233
761
  const interval = setInterval(() => {
234
- process.stderr.write(`\r ${style(frames[i++ % frames.length], c.green)} ${msg}`);
762
+ process.stderr.write(`\r ${style(frames[i++ % frames.length], c.green)} ${currentMsg}… ${style(formatElapsed(Date.now() - startedAt), c.dim)}\x1b[K`);
235
763
  }, 80);
236
764
  return {
765
+ update(nextMsg) {
766
+ currentMsg = nextMsg;
767
+ },
768
+ note(line) {
769
+ process.stderr.write(`\r\x1b[K${line}\n`);
770
+ },
237
771
  stop(finalMsg) {
238
772
  clearInterval(interval);
239
- process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg || msg}${"".padEnd(20)}\n`);
773
+ process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg || currentMsg}\x1b[K\n`);
240
774
  },
241
775
  };
242
776
  }
243
- // ── Commands ──────────────────────────────────────────────────────────
244
- async function reviewDiff(staged, jsonMode) {
245
- if (!isGitRepo()) {
246
- console.error(style(" Not a git repository.", c.red));
247
- process.exit(1);
248
- }
249
- const files = staged ? gitStagedFiles() : gitUnstagedFiles();
250
- const diff = gitDiff(staged);
251
- if (!diff) {
252
- const which = staged ? "staged" : "unstaged";
253
- console.error(style(` No ${which} changes found.`, c.yellow));
254
- if (!staged && gitStagedFiles().length > 0) {
255
- console.error(style(" Try: rmcode --staged", c.dim));
256
- }
257
- process.exit(0);
258
- }
259
- const lang = detectLanguage(files);
260
- const spinner = createSpinner(`Reviewing ${files.length} file${files.length !== 1 ? "s" : ""} (${lang})`);
777
+ // ── Review runner ─────────────────────────────────────────────────────
778
+ async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language, baseRef) {
779
+ const startedAt = Date.now();
780
+ const lang = language || detectLanguage(files);
781
+ const uploadMsg = `Uploading review request (${files.length} file${files.length !== 1 ? "s" : ""}, ${lang})`;
782
+ // Reach parity with the GitHub App path: run the pinned extractor against
783
+ // the diff's base state so the review sees the same repository context.
784
+ // One spinner carries the whole run through its phases. The guard here
785
+ // mirrors maybeExtractContext's own early return.
786
+ const willExtract = type === "diff" && Boolean(API_KEY) && Boolean(baseRef) && isGitRepo();
787
+ const spinner = createSpinner(willExtract ? "Extracting repository context" : uploadMsg);
788
+ const bundle = willExtract
789
+ ? await maybeExtractContext(content, spinner, baseRef)
790
+ : null;
791
+ if (willExtract)
792
+ spinner.update(uploadMsg);
261
793
  try {
262
- const result = await callAPI(diff, lang, "diff", files);
263
- spinner.stop(`Reviewed ${files.length} file${files.length !== 1 ? "s" : ""}`);
264
- if (!result.success) {
265
- console.error(style(` ${result.error || "Review failed."}`, c.red));
266
- if (result.meta?.message) {
267
- console.log(` ${style(result.meta.message, c.yellow)}`);
268
- }
269
- process.exit(1);
270
- }
271
- if (!jsonMode)
272
- printMeta(result.meta);
273
- if (result.data?.findings?.findings) {
274
- printFindings(result.data.findings.findings, jsonMode);
794
+ const result = await callAPI(content, lang, type, files, bundle, spinner);
795
+ spinner.stop(`Reviewed ${label}`);
796
+ if (jsonMode) {
797
+ printJsonResponse(result);
798
+ if (!result.success)
799
+ process.exit(1);
800
+ if (failOnFindings && hasBlockingFindings(result))
801
+ process.exit(2);
802
+ return;
275
803
  }
276
- }
277
- catch (err) {
278
- spinner.stop("Review failed");
279
- console.error(style(` ${err.message}`, c.red));
280
- process.exit(1);
281
- }
282
- }
283
- async function reviewFile(filePath, jsonMode) {
284
- const resolved = (0, path_1.resolve)(filePath);
285
- if (!(0, fs_1.existsSync)(resolved)) {
286
- console.error(style(` File not found: ${filePath}`, c.red));
287
- process.exit(1);
288
- }
289
- const content = (0, fs_1.readFileSync)(resolved, "utf-8");
290
- const ext = (0, path_1.extname)(filePath).toLowerCase();
291
- const lang = EXT_TO_LANG[ext] || "javascript";
292
- const spinner = createSpinner(`Reviewing ${(0, path_1.basename)(filePath)} (${lang})`);
293
- try {
294
- const result = await callAPI(content, lang, "file", [filePath]);
295
- spinner.stop(`Reviewed ${(0, path_1.basename)(filePath)}`);
296
804
  if (!result.success) {
297
805
  console.error(style(` ${result.error || "Review failed."}`, c.red));
298
- if (result.meta?.message) {
806
+ if (result.meta?.message)
299
807
  console.log(` ${style(result.meta.message, c.yellow)}`);
300
- }
301
808
  process.exit(1);
302
809
  }
303
- if (!jsonMode)
304
- printMeta(result.meta);
305
- if (result.data?.findings?.findings) {
306
- printFindings(result.data.findings.findings, jsonMode);
307
- }
810
+ if (result.data?.findings?.findings)
811
+ printFindings(result.data.findings.findings, startedAt);
812
+ printMeta(result.meta);
308
813
  }
309
814
  catch (err) {
310
815
  spinner.stop("Review failed");
@@ -312,81 +817,74 @@ async function reviewFile(filePath, jsonMode) {
312
817
  process.exit(1);
313
818
  }
314
819
  }
315
- async function reviewStdin(lang, jsonMode) {
316
- const rl = (0, readline_1.createInterface)({ input: process.stdin });
317
- const lines = [];
318
- for await (const line of rl)
319
- lines.push(line);
320
- const code = lines.join("\n");
321
- if (!code.trim()) {
322
- console.error(style(" No input received on stdin.", c.red));
323
- process.exit(1);
324
- }
325
- const spinner = createSpinner(`Reviewing stdin (${lang})`);
326
- try {
327
- const result = await callAPI(code, lang, "snippet", []);
328
- spinner.stop("Reviewed stdin");
329
- if (!result.success) {
330
- console.error(style(` ${result.error || "Review failed."}`, c.red));
331
- if (result.meta?.message) {
332
- console.log(` ${style(result.meta.message, c.yellow)}`);
333
- }
334
- process.exit(1);
335
- }
336
- if (!jsonMode)
337
- printMeta(result.meta);
338
- if (result.data?.findings?.findings) {
339
- printFindings(result.data.findings.findings, jsonMode);
340
- }
341
- }
342
- catch (err) {
343
- spinner.stop("Review failed");
344
- console.error(style(` ${err.message}`, c.red));
345
- process.exit(1);
346
- }
347
- }
348
- // ── Help & Version ────────────────────────────────────────────────────
820
+ // ── Help ──────────────────────────────────────────────────────────────
349
821
  function printHelp() {
822
+ const header = !noColor && !isCI
823
+ ? owlHeader()
824
+ : ` ${style("rmcode", c.bold, c.green)} ${style(`v${VERSION}`, c.dim)} — AI code review from your terminal`;
350
825
  console.log(`
351
- ${style("rmcode", c.bold, c.green)} — AI code review from your terminal
826
+ ${header}
352
827
 
353
- ${style("USAGE", c.bold)}
828
+ ${style("COMMANDS", c.bold)}
354
829
 
355
- ${style("rmcode", c.cyan)} Review unstaged changes
356
- ${style("rmcode --staged", c.cyan)} Review staged changes
357
- ${style("rmcode file.ts", c.cyan)} Review a specific file
358
- ${style("cat file.ts | rmcode", c.cyan)} Review from stdin
830
+ ${style("rmcode", c.cyan)} Review changes from merge base (default)
831
+ ${style("rmcode --staged", c.cyan)} Review staged changes only
832
+ ${style("rmcode --unstaged", c.cyan)} Review unstaged changes only
833
+ ${style("rmcode --all", c.cyan)} Review all uncommitted changes (staged + unstaged)
834
+ ${style("rmcode <file>", c.cyan)} Review a single local file
359
835
  ${style("rmcode login", c.cyan)} Set up your API key
360
- ${style("rmcode install", c.cyan)} Install the GitHub App for PR reviews
836
+ ${style("rmcode install", c.cyan)} Install the GitHub App for automatic PR reviews
837
+ ${style("rmcode help", c.cyan)} Show this help
361
838
 
362
839
  ${style("OPTIONS", c.bold)}
363
840
 
364
- --staged Review staged (git add) changes only
365
- --deep Deep multi-pass review (coming soon)
366
- --json Output findings as JSON (for agents/CI)
367
- --lang <language> Language hint for stdin input
841
+ --json Output the full API response as JSON (for CI/agents)
842
+ --fail-on-findings Exit 2 when JSON output contains findings
843
+ --lang <language> Language hint for stdin input
368
844
  --help, -h Show this help
369
845
  --version, -v Show version
370
846
 
847
+ ${style("MODES", c.bold)}
848
+
849
+ ${style("Default", c.white)} Diff from merge base to HEAD — what your PR would contain
850
+ ${style("--staged", c.white)} Only changes in the staging area (git add)
851
+ ${style("--unstaged", c.white)} Only working directory changes not yet staged
852
+ ${style("--all", c.white)} Everything not yet committed, including untracked text files
853
+ ${style("file", c.white)} A single local file
854
+ ${style("stdin", c.white)} Pipe any diff or code: ${style("git diff main | rmcode", c.dim)}
855
+
371
856
  ${style("EXAMPLES", c.bold)}
372
857
 
373
- ${style("# Review your work before committing", c.dim)}
858
+ ${style("# Review your branch before opening a PR", c.dim)}
859
+ rmcode
860
+
861
+ ${style("# Review only what you're about to commit", c.dim)}
374
862
  rmcode --staged
375
863
 
376
- ${style("# Let an AI agent review its own code", c.dim)}
377
- rmcode --staged --json
864
+ ${style("# Review a specific range", c.dim)}
865
+ git diff abc123..def456 | rmcode --lang typescript
378
866
 
379
- ${style("# Review a file directly", c.dim)}
380
- rmcode src/auth/login.ts
867
+ ${style("# Review one file", c.dim)}
868
+ rmcode src/auth.ts
381
869
 
382
- ${style("# Pipe from another command", c.dim)}
383
- git diff main | rmcode --lang typescript
870
+ ${style("# JSON output for CI pipeline", c.dim)}
871
+ rmcode --json
384
872
 
385
- ${style("# Set up authenticated reviews (30/month free)", c.dim)}
873
+ ${style(`# Set up authenticated reviews (${FREE_PLAN_CREDITS_PER_MONTH} credits/month free)`, c.dim)}
386
874
  rmcode login
387
875
 
388
- ${style("# Auto-review every PR on GitHub", c.dim)}
389
- rmcode install
876
+ ${style("ENVIRONMENT", c.bold)}
877
+
878
+ RMC_API_KEY API key for authenticated reviews (get one: rmcode login)
879
+ RMC_API_URL API endpoint (default: https://review-my-code.com)
880
+ RMC_APP_URL App URL for login (default: https://review-my-code.com)
881
+
882
+ ${style("PRIVACY", c.bold)}
883
+
884
+ CLI reviews run locally to collect your diff/file/stdin content, then send that
885
+ review content to RMCode's backend and model providers for analysis. For
886
+ automatic PR reviews, install the GitHub App; that path uses the repository's
887
+ GitHub Actions runner to produce review context.
390
888
 
391
889
  ${style("https://review-my-code.com", c.dim)}
392
890
  `);
@@ -394,28 +892,59 @@ function printHelp() {
394
892
  // ── Version check ─────────────────────────────────────────────────────
395
893
  async function checkForUpdate() {
396
894
  try {
397
- const resp = await fetch("https://registry.npmjs.org/rmcode/latest", {
895
+ const resp = await fetch("https://registry.npmjs.org/@review-my-code/rmcode/latest", {
398
896
  signal: AbortSignal.timeout(3000),
399
897
  });
400
898
  if (!resp.ok)
401
899
  return;
402
- const data = await resp.json();
403
- const latest = data.version;
404
- if (latest && latest !== VERSION) {
405
- console.log(style(`\n Update available: ${VERSION} ${latest}`, c.yellow));
406
- console.log(style(` Run: npm install -g rmcode\n`, c.dim));
900
+ const data = (await resp.json());
901
+ if (data.version && data.version !== VERSION) {
902
+ console.log(style(`\n Update available: ${VERSION} ${data.version}`, c.yellow));
903
+ console.log(style(` Run: npm install -g @review-my-code/rmcode\n`, c.dim));
407
904
  }
408
905
  }
409
906
  catch {
410
- // Silent fail — don't block the review
907
+ /* silent */
411
908
  }
412
909
  }
910
+ function parseArgs(args) {
911
+ const flags = new Set();
912
+ const options = {};
913
+ const positional = [];
914
+ for (let i = 0; i < args.length; i++) {
915
+ const arg = args[i];
916
+ if (arg === "--lang") {
917
+ options.lang = args[i + 1];
918
+ i++;
919
+ }
920
+ else if (arg.startsWith("-")) {
921
+ flags.add(arg);
922
+ }
923
+ else {
924
+ positional.push(arg);
925
+ }
926
+ }
927
+ return { flags, options, positional };
928
+ }
929
+ function unknownFlags(flags) {
930
+ const known = new Set([
931
+ "--all",
932
+ "--fail-on-findings",
933
+ "--help",
934
+ "-h",
935
+ "--json",
936
+ "--staged",
937
+ "--unstaged",
938
+ "--version",
939
+ "-v",
940
+ ]);
941
+ return [...flags].filter((flag) => !known.has(flag));
942
+ }
413
943
  // ── Main ──────────────────────────────────────────────────────────────
414
944
  async function main() {
415
945
  const args = process.argv.slice(2);
416
- const flags = new Set(args.filter((a) => a.startsWith("-")));
417
- const positional = args.filter((a) => !a.startsWith("-"));
418
- if (flags.has("--help") || flags.has("-h")) {
946
+ const { flags, options, positional } = parseArgs(args);
947
+ if (flags.has("--help") || flags.has("-h") || positional[0] === "help") {
419
948
  printHelp();
420
949
  return;
421
950
  }
@@ -423,65 +952,169 @@ async function main() {
423
952
  console.log(`rmcode v${VERSION}`);
424
953
  return;
425
954
  }
426
- const jsonMode = flags.has("--json");
427
- const staged = flags.has("--staged");
428
- // rmc install
955
+ const invalidFlags = unknownFlags(flags);
956
+ if (invalidFlags.length > 0) {
957
+ console.error(style(`\n Unknown option: ${invalidFlags[0]}`, c.red));
958
+ console.error(style(` Run: rmcode help\n`, c.dim));
959
+ process.exit(1);
960
+ }
429
961
  if (positional[0] === "install") {
430
962
  console.log(`\n ${style("Install the GitHub App for automatic PR reviews:", c.bold)}\n`);
431
963
  console.log(` ${style("https://github.com/apps/rmcode-ai", c.cyan, c.bold)}\n`);
432
- console.log(` ${style("Once installed, RMCode AI reviews every PR automatically.", c.dim)}\n`);
964
+ console.log(` ${style("Install from the same GitHub account linked in RMCode so PR reviews share your plan credits.", c.dim)}`);
965
+ console.log(` ${style("The app uses a GitHub Actions workflow in your repo for context extraction.", c.dim)}`);
966
+ console.log(` ${style("If that workflow cannot be created or dispatched, the PR check fails until setup is fixed.", c.dim)}\n`);
433
967
  return;
434
968
  }
435
- // rmc login
436
969
  if (positional[0] === "login") {
437
- const url = `${API_URL}/api-keys`;
970
+ const url = `${APP_URL}/api-keys`;
438
971
  console.log(`\n ${style("Opening your browser to create an API key...", c.bold)}\n`);
439
972
  try {
440
973
  const openCmd = process.platform === "darwin"
441
974
  ? "open"
442
975
  : process.platform === "win32"
443
- ? "start"
976
+ ? "cmd"
444
977
  : "xdg-open";
445
- (0, child_process_1.execSync)(`${openCmd} ${url}`, { stdio: "ignore" });
978
+ const openArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
979
+ (0, child_process_1.execFileSync)(openCmd, openArgs, { stdio: "ignore" });
446
980
  }
447
981
  catch {
448
- console.log(` ${style("Could not open browser. Visit this URL:", c.yellow)}`);
982
+ console.log(` ${style("Could not open browser. Visit:", c.yellow)}`);
449
983
  }
450
984
  console.log(` ${style(url, c.cyan, c.bold)}\n`);
451
985
  console.log(` ${style("Then set your API key:", c.dim)}`);
452
- console.log(` ${style("export RMC_API_KEY=rmc_k_...", c.green)}\n`);
986
+ console.log(` ${style("export RMC_API_KEY=rmc_...", c.green)}\n`);
453
987
  return;
454
988
  }
455
- // --deep stub
456
- if (flags.has("--deep")) {
457
- console.log(`\n ${style("Deep review is coming soon.", c.yellow)}`);
458
- console.log(` ${style("Scout review is available now — just run: rmcode --staged", c.dim)}\n`);
459
- return;
989
+ const jsonMode = flags.has("--json");
990
+ const failOnFindings = flags.has("--fail-on-findings");
991
+ if (positional.length > 1) {
992
+ console.error(style(`\n Unknown command: ${positional[0]}`, c.red));
993
+ console.error(style(` Run: rmcode help\n`, c.dim));
994
+ process.exit(1);
460
995
  }
461
- console.log(`\n ${style("rmcode", c.green, c.bold)} ${style(`v${VERSION}`, c.dim)}`);
462
- // rmc file.ts
463
- if (positional.length > 0 && (0, fs_1.existsSync)((0, path_1.resolve)(positional[0]))) {
464
- await reviewFile(positional[0], jsonMode);
465
- return;
996
+ // Brand header for review runs — stderr so any stdout consumers stay
997
+ // safe; hidden in --json, non-TTY, NO_COLOR, and CI.
998
+ if (!jsonMode && !noColor && !isCI) {
999
+ process.stderr.write(`\n${owlHeader()}\n\n`);
466
1000
  }
467
1001
  // Piped stdin
468
- if (!process.stdin.isTTY) {
469
- const langIdx = args.indexOf("--lang");
470
- const lang = langIdx >= 0 && args[langIdx + 1] ? args[langIdx + 1] : "javascript";
471
- await reviewStdin(lang, jsonMode);
1002
+ if (hasPipedStdin()) {
1003
+ const lang = options.lang || "javascript";
1004
+ const rl = (0, readline_1.createInterface)({ input: process.stdin });
1005
+ const lines = [];
1006
+ for await (const line of rl)
1007
+ lines.push(line);
1008
+ const code = lines.join("\n");
1009
+ if (!code.trim()) {
1010
+ console.error(style(" No input received on stdin.", c.red));
1011
+ process.exit(1);
1012
+ }
1013
+ const startedAt = Date.now();
1014
+ const spinner = createSpinner(`Uploading review request (stdin, ${lang})`);
1015
+ try {
1016
+ const stdinType = looksLikeUnifiedDiff(code) ? "diff" : "snippet";
1017
+ const stdinFiles = stdinType === "diff" ? filesFromDiff(code) : [];
1018
+ const result = await callAPI(code, lang, stdinType, stdinFiles, null, spinner);
1019
+ spinner.stop("Reviewed stdin");
1020
+ if (jsonMode) {
1021
+ printJsonResponse(result);
1022
+ if (!result.success)
1023
+ process.exit(1);
1024
+ if (failOnFindings && hasBlockingFindings(result))
1025
+ process.exit(2);
1026
+ return;
1027
+ }
1028
+ if (!result.success) {
1029
+ console.error(style(` ${result.error || "Review failed."}`, c.red));
1030
+ process.exit(1);
1031
+ }
1032
+ if (result.data?.findings?.findings)
1033
+ printFindings(result.data.findings.findings, startedAt);
1034
+ printMeta(result.meta);
1035
+ }
1036
+ catch (err) {
1037
+ spinner.stop("Review failed");
1038
+ console.error(style(` ${err.message}`, c.red));
1039
+ process.exit(1);
1040
+ }
1041
+ return;
1042
+ }
1043
+ if (positional.length === 1) {
1044
+ const file = positional[0];
1045
+ let code;
1046
+ try {
1047
+ code = (0, fs_1.readFileSync)(file, "utf-8");
1048
+ }
1049
+ catch (err) {
1050
+ console.error(style(` Could not read ${file}: ${err.message}`, c.red));
1051
+ process.exit(1);
1052
+ }
1053
+ if (!code.trim()) {
1054
+ console.error(style(` ${file} is empty.`, c.yellow));
1055
+ process.exit(0);
1056
+ }
1057
+ await runReview(code, [file], file, jsonMode, failOnFindings, "file", detectLanguage([file]));
1058
+ if (!jsonMode)
1059
+ await checkForUpdate();
472
1060
  return;
473
1061
  }
474
- // Default: review git diff
475
- await reviewDiff(staged, jsonMode);
1062
+ // Git modes
1063
+ if (!isGitRepo()) {
1064
+ console.error(style(" Not a git repository. Run from inside a git repo.", c.red));
1065
+ process.exit(1);
1066
+ }
1067
+ const branch = currentBranch();
1068
+ let diff;
1069
+ let files;
1070
+ let baseRef;
1071
+ let label;
1072
+ if (flags.has("--staged")) {
1073
+ ({ diff, files, baseRef } = diffStaged());
1074
+ label = "staged changes";
1075
+ if (!diff) {
1076
+ console.error(style(" No staged changes. Stage files with: git add <files>", c.yellow));
1077
+ process.exit(0);
1078
+ }
1079
+ }
1080
+ else if (flags.has("--unstaged")) {
1081
+ ({ diff, files, baseRef } = diffUnstaged());
1082
+ label = "unstaged changes";
1083
+ if (!diff) {
1084
+ console.error(style(" No unstaged changes.", c.yellow));
1085
+ process.exit(0);
1086
+ }
1087
+ }
1088
+ else if (flags.has("--all")) {
1089
+ ({ diff, files, baseRef } = diffAll());
1090
+ label = "all uncommitted changes";
1091
+ if (!diff) {
1092
+ console.error(style(" No uncommitted changes.", c.yellow));
1093
+ process.exit(0);
1094
+ }
1095
+ }
1096
+ else {
1097
+ ({ diff, files, baseRef } = diffFromMergeBase());
1098
+ label = `branch ${style(branch, c.cyan)} vs base`;
1099
+ if (!diff) {
1100
+ console.error(style(" No changes from merge base. Your branch matches main.", c.yellow));
1101
+ process.exit(0);
1102
+ }
1103
+ }
1104
+ await runReview(diff, files, label, jsonMode, failOnFindings, "diff", undefined, baseRef);
476
1105
  if (!jsonMode && !API_KEY) {
477
- console.log(style(" Get 30 reviews/month free: rmcode login", c.dim));
1106
+ console.log(style(` Get ${FREE_PLAN_CREDITS_PER_MONTH} credits/month free: rmcode login`, c.dim));
478
1107
  console.log(style(" Auto-review every PR: rmcode install", c.dim));
479
1108
  console.log();
480
1109
  }
481
- // Non-blocking update check after review completes
482
- await checkForUpdate();
1110
+ if (!jsonMode)
1111
+ await checkForUpdate();
483
1112
  }
484
- main().catch((err) => {
1113
+ main()
1114
+ .then(() => {
1115
+ process.exit(process.exitCode ?? 0);
1116
+ })
1117
+ .catch((err) => {
485
1118
  console.error(style(` Fatal: ${err.message}`, c.red));
486
1119
  process.exit(1);
487
1120
  });