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

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 +500 -247
  3. package/package.json +8 -3
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # RMCode CLI
2
+
3
+ Run Review My Code from your terminal.
4
+
5
+ ```bash
6
+ npm install -g @review-my-code/rmcode
7
+ rmcode login
8
+ export RMC_API_KEY=rmc_...
9
+ rmcode --staged
10
+ ```
11
+
12
+ The CLI sends the selected diff, file, or stdin content to RMCode for analysis.
13
+ Anonymous CLI reviews are disabled by default, so set `RMC_API_KEY` before
14
+ running a review.
15
+
16
+ ## Common Commands
17
+
18
+ ```bash
19
+ rmcode # review branch diff from merge base
20
+ rmcode --staged # review staged changes
21
+ rmcode --unstaged # review unstaged changes
22
+ rmcode --all # review uncommitted tracked changes and safe text files
23
+ rmcode src/auth.ts # review one file
24
+ git diff main | rmcode --lang typescript
25
+ rmcode --json --fail-on-findings
26
+ ```
27
+
28
+ Use `RMC_API_URL` to point at a non-production API and `RMC_APP_URL` to point
29
+ `rmcode login` at a non-production web app.
package/dist/cli.js CHANGED
@@ -6,7 +6,13 @@ const fs_1 = require("fs");
6
6
  const path_1 = require("path");
7
7
  const readline_1 = require("readline");
8
8
  const API_URL = process.env.RMC_API_URL || "https://review-my-code.com";
9
- const VERSION = "0.1.0";
9
+ const APP_URL = process.env.RMC_APP_URL || "https://review-my-code.com";
10
+ const VERSION = "0.1.1";
11
+ const FREE_PLAN_CREDITS_PER_MONTH = 30;
12
+ const REQUEST_TIMEOUT_MS = 290_000;
13
+ const POLL_TIMEOUT_MS = 10 * 60_000;
14
+ const POLL_INTERVAL_MS = 2_000;
15
+ const MAX_UNTRACKED_FILE_BYTES = 512 * 1024;
10
16
  // ── Colors (no dependencies) ──────────────────────────────────────────
11
17
  const c = {
12
18
  reset: "\x1b[0m",
@@ -15,15 +21,12 @@ const c = {
15
21
  red: "\x1b[31m",
16
22
  green: "\x1b[32m",
17
23
  yellow: "\x1b[33m",
18
- blue: "\x1b[34m",
19
- magenta: "\x1b[35m",
20
24
  cyan: "\x1b[36m",
21
25
  gray: "\x1b[90m",
22
26
  white: "\x1b[97m",
23
27
  bgRed: "\x1b[41m",
24
28
  bgYellow: "\x1b[43m",
25
29
  bgGreen: "\x1b[42m",
26
- bgBlue: "\x1b[44m",
27
30
  };
28
31
  const isCI = process.env.CI === "true";
29
32
  const noColor = process.env.NO_COLOR === "1" || !process.stdout.isTTY;
@@ -44,63 +47,198 @@ function severityBadge(severity) {
44
47
  return style(` ${severity.toUpperCase()} `, c.dim);
45
48
  }
46
49
  // ── Git helpers ───────────────────────────────────────────────────────
47
- function gitDiff(staged) {
50
+ function git(args) {
51
+ return (0, child_process_1.execFileSync)("git", args, {
52
+ encoding: "utf-8",
53
+ maxBuffer: 10 * 1024 * 1024,
54
+ }).trim();
55
+ }
56
+ function isGitRepo() {
48
57
  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;
58
+ git(["rev-parse", "--is-inside-work-tree"]);
59
+ return true;
52
60
  }
53
61
  catch {
54
- return null;
62
+ return false;
55
63
  }
56
64
  }
57
- function gitStagedFiles() {
65
+ function gitWorktreeRoot() {
66
+ return git(["rev-parse", "--show-toplevel"]);
67
+ }
68
+ function mergeBase() {
69
+ const refs = [];
58
70
  try {
59
- return (0, child_process_1.execSync)("git diff --cached --name-only", { encoding: "utf-8" }).trim().split("\n").filter(Boolean);
71
+ refs.push(git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]));
60
72
  }
61
73
  catch {
62
- return [];
74
+ // Remote HEAD is not available.
75
+ }
76
+ for (const ref of [
77
+ ...refs,
78
+ "origin/main",
79
+ "origin/master",
80
+ "main",
81
+ "master",
82
+ ]) {
83
+ try {
84
+ git(["rev-parse", "--verify", ref]);
85
+ return git(["merge-base", ref, "HEAD"]);
86
+ }
87
+ catch {
88
+ // Try the next common default branch ref.
89
+ }
63
90
  }
91
+ 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
92
  }
65
- function gitUnstagedFiles() {
93
+ function diffFromMergeBase() {
94
+ const base = mergeBase();
95
+ const diff = git(["diff", `${base}...HEAD`]);
96
+ const files = git(["diff", `${base}...HEAD`, "--name-only"])
97
+ .split("\n")
98
+ .filter(Boolean);
99
+ return { diff, files };
100
+ }
101
+ function diffStaged() {
102
+ const diff = git(["diff", "--cached"]);
103
+ const files = git(["diff", "--cached", "--name-only"])
104
+ .split("\n")
105
+ .filter(Boolean);
106
+ return { diff, files };
107
+ }
108
+ function diffUnstaged() {
109
+ const diff = git(["diff"]);
110
+ const files = git(["diff", "--name-only"]).split("\n").filter(Boolean);
111
+ return { diff, files };
112
+ }
113
+ function diffAll() {
114
+ const root = gitWorktreeRoot();
115
+ const trackedDiff = git(["diff", "HEAD"]);
116
+ const trackedFiles = git(["diff", "HEAD", "--name-only"])
117
+ .split("\n")
118
+ .filter(Boolean);
119
+ const untrackedFiles = git(["ls-files", "--others", "--exclude-standard"])
120
+ .split("\n")
121
+ .filter(Boolean);
122
+ const untrackedDiffs = untrackedFiles
123
+ .map((file) => pseudoDiffForNewFile(file, root))
124
+ .filter(Boolean);
125
+ const diff = [trackedDiff, ...untrackedDiffs].filter(Boolean).join("\n\n");
126
+ const files = [...trackedFiles, ...untrackedFiles];
127
+ return { diff, files };
128
+ }
129
+ function pseudoDiffForNewFile(file, worktreeRoot) {
66
130
  try {
67
- return (0, child_process_1.execSync)("git diff --name-only", { encoding: "utf-8" }).trim().split("\n").filter(Boolean);
131
+ if ((0, path_1.isAbsolute)(file))
132
+ return "";
133
+ const stat = (0, fs_1.lstatSync)(file);
134
+ if (!stat.isFile() || stat.isSymbolicLink())
135
+ return "";
136
+ if (stat.size > MAX_UNTRACKED_FILE_BYTES)
137
+ return "";
138
+ const realRoot = (0, fs_1.realpathSync)(worktreeRoot);
139
+ const realFile = (0, fs_1.realpathSync)(file);
140
+ const rel = (0, path_1.relative)(realRoot, realFile);
141
+ if (rel.startsWith("..") || (0, path_1.isAbsolute)(rel))
142
+ return "";
143
+ const contents = (0, fs_1.readFileSync)(realFile, "utf-8");
144
+ if (contents.includes("\0"))
145
+ return "";
146
+ const added = contents
147
+ .split("\n")
148
+ .map((line) => `+${line}`)
149
+ .join("\n");
150
+ 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}`;
68
151
  }
69
152
  catch {
70
- return [];
153
+ return "";
71
154
  }
72
155
  }
73
- function isGitRepo() {
156
+ function gitRepoName() {
74
157
  try {
75
- (0, child_process_1.execSync)("git rev-parse --is-inside-work-tree", { encoding: "utf-8", stdio: "pipe" });
76
- return true;
158
+ const remote = git(["remote", "get-url", "origin"]);
159
+ const m = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
160
+ return m ? m[1] : null;
77
161
  }
78
162
  catch {
79
- return false;
163
+ return null;
80
164
  }
81
165
  }
82
- function gitRepoName() {
166
+ function currentBranch() {
83
167
  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;
168
+ return git(["branch", "--show-current"]);
95
169
  }
96
170
  catch {
97
- return null;
171
+ return "unknown";
172
+ }
173
+ }
174
+ function filesFromDiff(diff) {
175
+ const files = new Set();
176
+ const re = /^diff --git a\/.+ b\/(.+)$/gm;
177
+ let match;
178
+ while ((match = re.exec(diff)) !== null) {
179
+ files.add(match[1]);
180
+ }
181
+ return [...files];
182
+ }
183
+ function looksLikeUnifiedDiff(value) {
184
+ return value.startsWith("diff --git ") || /\n@@ -\d/.test(value);
185
+ }
186
+ function hasPipedStdin() {
187
+ if (process.stdin.isTTY)
188
+ return false;
189
+ try {
190
+ const stat = (0, fs_1.fstatSync)(0);
191
+ return stat.isFIFO() || stat.isFile() || stat.isSocket();
192
+ }
193
+ catch {
194
+ return false;
98
195
  }
99
196
  }
100
197
  const API_KEY = process.env.RMC_API_KEY || null;
198
+ async function fetchReviewJson(url, init) {
199
+ const controller = new AbortController();
200
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
201
+ try {
202
+ const resp = await fetch(url, { ...init, signal: controller.signal });
203
+ const text = await resp.text();
204
+ let body;
205
+ try {
206
+ body = text ? JSON.parse(text) : { success: false };
207
+ }
208
+ catch {
209
+ body = {
210
+ success: false,
211
+ error: `RMCode API returned a non-JSON response (${resp.status}).`,
212
+ code: "RMC-5000",
213
+ };
214
+ }
215
+ if (!resp.ok && body.success !== false) {
216
+ body = {
217
+ ...body,
218
+ success: false,
219
+ error: body.error || `RMCode API request failed (${resp.status}).`,
220
+ };
221
+ }
222
+ return { status: resp.status, body };
223
+ }
224
+ catch (error) {
225
+ const message = error instanceof Error && error.name === "AbortError"
226
+ ? "RMCode API request timed out."
227
+ : error instanceof Error
228
+ ? error.message
229
+ : "RMCode API request failed.";
230
+ return {
231
+ status: 0,
232
+ body: { success: false, error: message, code: "RMC-5000" },
233
+ };
234
+ }
235
+ finally {
236
+ clearTimeout(timeout);
237
+ }
238
+ }
101
239
  async function callAuthenticatedAPI(code, language, type, files) {
102
240
  const repo = gitRepoName();
103
- const resp = await fetch(`${API_URL}/api/reviews/cli`, {
241
+ const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
104
242
  method: "POST",
105
243
  headers: {
106
244
  "Content-Type": "application/json",
@@ -108,54 +246,89 @@ async function callAuthenticatedAPI(code, language, type, files) {
108
246
  },
109
247
  body: JSON.stringify({ type, value: code, language, files, repo }),
110
248
  });
111
- return resp.json();
249
+ if (status === 202 && body.data?.accessToken) {
250
+ return pollForResults(`${API_URL}/api/reviews/cli/${body.data.accessToken}`, body.meta, { "X-API-Key": API_KEY });
251
+ }
252
+ return body;
112
253
  }
113
- async function callAnonymousAPI(code, language) {
254
+ async function callAnonymousAPI(code, language, type, files) {
114
255
  const repo = gitRepoName();
115
- const resp = await fetch(`${API_URL}/api/reviews/anonymous`, {
256
+ const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/anonymous`, {
116
257
  method: "POST",
117
258
  headers: { "Content-Type": "application/json" },
118
- body: JSON.stringify({ type: "snippet", value: code, language, repo }),
259
+ body: JSON.stringify({ type, value: code, language, files, repo }),
119
260
  });
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);
261
+ if (status === 202 && body.data?.accessToken) {
262
+ return pollForResults(`${API_URL}/api/reviews/anonymous/${body.data.accessToken}`, body.meta);
124
263
  }
125
- return result;
264
+ return body;
126
265
  }
127
- async function pollForResults(accessToken, initialMeta) {
128
- const deadline = Date.now() + 60_000;
266
+ async function pollForResults(url, initialMeta, headers) {
267
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
129
268
  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;
269
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
270
+ const { status, body } = await fetchReviewJson(url, { headers });
271
+ if (status >= 400 || body.success === false) {
272
+ if (initialMeta && !body.meta)
273
+ body.meta = initialMeta;
274
+ return body;
275
+ }
276
+ if (body.data?.status === "completed" || body.data?.status === "failed") {
277
+ if (initialMeta && !body.meta)
278
+ body.meta = initialMeta;
279
+ return body;
137
280
  }
138
281
  }
139
- return { success: false, error: "Review timed out after 60 seconds." };
282
+ return { success: false, error: "Review timed out after 10 minutes." };
140
283
  }
141
284
  function callAPI(code, language, type = "snippet", files = []) {
142
- if (API_KEY) {
285
+ if (API_KEY)
143
286
  return callAuthenticatedAPI(code, language, type, files);
144
- }
145
- return callAnonymousAPI(code, language);
287
+ return Promise.resolve({
288
+ success: false,
289
+ error: "API key required. Run `rmcode login`, then set RMC_API_KEY before sending code for review.",
290
+ code: "RMC-1002",
291
+ meta: {
292
+ plan: null,
293
+ reviewer: "scout",
294
+ message: "Anonymous CLI reviews are disabled so your credits stay tied to your RMCode account.",
295
+ },
296
+ });
146
297
  }
147
298
  // ── Language detection ────────────────────────────────────────────────
148
299
  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",
300
+ ".ts": "typescript",
301
+ ".tsx": "typescript",
302
+ ".js": "javascript",
303
+ ".jsx": "javascript",
304
+ ".py": "python",
305
+ ".java": "java",
306
+ ".go": "go",
307
+ ".rs": "rust",
308
+ ".c": "c",
309
+ ".cpp": "cpp",
310
+ ".h": "c",
311
+ ".cs": "csharp",
312
+ ".rb": "ruby",
313
+ ".php": "php",
314
+ ".swift": "swift",
315
+ ".kt": "kotlin",
316
+ ".scala": "scala",
317
+ ".lua": "lua",
318
+ ".dart": "dart",
319
+ ".v": "verilog",
320
+ ".sv": "verilog",
321
+ ".vhd": "vhdl",
322
+ ".sh": "bash",
323
+ ".zig": "zig",
324
+ ".sol": "solidity",
325
+ ".jl": "julia",
326
+ ".r": "r",
327
+ ".ex": "elixir",
328
+ ".erl": "erlang",
329
+ ".hs": "haskell",
330
+ ".ml": "ocaml",
331
+ ".fs": "fsharp",
159
332
  };
160
333
  function detectLanguage(files) {
161
334
  for (const f of files) {
@@ -166,33 +339,30 @@ function detectLanguage(files) {
166
339
  return "javascript";
167
340
  }
168
341
  // ── Output formatting ─────────────────────────────────────────────────
169
- function printFindings(findings, jsonMode) {
170
- if (jsonMode) {
171
- console.log(JSON.stringify(findings, null, 2));
172
- return;
173
- }
342
+ function printFindings(findings) {
174
343
  if (findings.length === 0) {
175
344
  console.log(`\n ${style("No issues found.", c.green, c.bold)} Your code looks good.\n`);
176
345
  return;
177
346
  }
178
347
  const counts = {};
179
- for (const f of findings) {
348
+ for (const f of findings)
180
349
  counts[f.severity] = (counts[f.severity] || 0) + 1;
181
- }
182
- const summary = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(" · ");
350
+ const summary = Object.entries(counts)
351
+ .map(([k, v]) => `${v} ${k}`)
352
+ .join(" · ");
183
353
  console.log(`\n ${style(`${findings.length} finding${findings.length !== 1 ? "s" : ""}`, c.bold, c.white)} · ${summary}\n`);
184
354
  for (const f of findings) {
185
- const location = f.filePath ? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}` : "";
355
+ const location = f.filePath
356
+ ? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}`
357
+ : "";
186
358
  console.log(` ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
187
359
  if (location)
188
360
  console.log(` ${style("at", c.dim)} ${location}`);
189
361
  console.log(` ${style(f.explanation, c.gray)}`);
190
- if (f.codeContext) {
362
+ if (f.codeContext)
191
363
  console.log(` ${style(f.codeContext, c.dim)}`);
192
- }
193
- if (f.suggestedCode) {
364
+ if (f.suggestedCode)
194
365
  console.log(` ${style("fix:", c.green)} ${style(f.suggestedCode, c.green)}`);
195
- }
196
366
  console.log();
197
367
  }
198
368
  }
@@ -203,30 +373,33 @@ function printMeta(meta) {
203
373
  if (meta.reviewer)
204
374
  parts.push(meta.reviewer.charAt(0).toUpperCase() + meta.reviewer.slice(1));
205
375
  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
- }
376
+ const label = meta.plan.charAt(0).toUpperCase() + meta.plan.slice(1);
377
+ parts.push(meta.creditsRemaining != null
378
+ ? `${label} (${meta.creditsRemaining} cr left)`
379
+ : label);
213
380
  }
214
- if (meta.quotaUsed) {
215
- parts.push(`Free review (${meta.quotaUsed} today)`);
216
- }
217
- if (parts.length > 0) {
381
+ if (parts.length > 0)
218
382
  console.log(` ${style(parts.join(" · "), c.dim)}`);
219
- }
220
- if (meta.message) {
383
+ if (meta.message)
221
384
  console.log(` ${style(meta.message, c.yellow)}`);
222
- }
385
+ }
386
+ function printJsonResponse(result) {
387
+ console.log(JSON.stringify(result, null, 2));
388
+ }
389
+ function hasBlockingFindings(result) {
390
+ const findings = result.data?.findings?.findings || [];
391
+ return (findings.length > 0 || result.data?.findings?.verdict === "request_changes");
223
392
  }
224
393
  // ── Spinner ───────────────────────────────────────────────────────────
225
394
  function createSpinner(msg) {
226
395
  if (noColor || isCI) {
227
396
  process.stderr.write(` ${msg}...\n`);
228
- return { stop: (final) => { if (final)
229
- process.stderr.write(` ${final}\n`); } };
397
+ return {
398
+ stop: (final) => {
399
+ if (final)
400
+ process.stderr.write(` ${final}\n`);
401
+ },
402
+ };
230
403
  }
231
404
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
232
405
  let i = 0;
@@ -240,104 +413,30 @@ function createSpinner(msg) {
240
413
  },
241
414
  };
242
415
  }
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})`);
261
- 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);
275
- }
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})`);
416
+ // ── Review runner ─────────────────────────────────────────────────────
417
+ async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language) {
418
+ const lang = language || detectLanguage(files);
419
+ const spinner = createSpinner(`Reviewing ${label} (${files.length} file${files.length !== 1 ? "s" : ""}, ${lang})`);
293
420
  try {
294
- const result = await callAPI(content, lang, "file", [filePath]);
295
- spinner.stop(`Reviewed ${(0, path_1.basename)(filePath)}`);
296
- if (!result.success) {
297
- console.error(style(` ${result.error || "Review failed."}`, c.red));
298
- if (result.meta?.message) {
299
- console.log(` ${style(result.meta.message, c.yellow)}`);
300
- }
301
- process.exit(1);
302
- }
303
- if (!jsonMode)
304
- printMeta(result.meta);
305
- if (result.data?.findings?.findings) {
306
- printFindings(result.data.findings.findings, jsonMode);
421
+ const result = await callAPI(content, lang, type, files);
422
+ spinner.stop(`Reviewed ${label}`);
423
+ if (jsonMode) {
424
+ printJsonResponse(result);
425
+ if (!result.success)
426
+ process.exit(1);
427
+ if (failOnFindings && hasBlockingFindings(result))
428
+ process.exit(2);
429
+ return;
307
430
  }
308
- }
309
- catch (err) {
310
- spinner.stop("Review failed");
311
- console.error(style(` ${err.message}`, c.red));
312
- process.exit(1);
313
- }
314
- }
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
431
  if (!result.success) {
330
432
  console.error(style(` ${result.error || "Review failed."}`, c.red));
331
- if (result.meta?.message) {
433
+ if (result.meta?.message)
332
434
  console.log(` ${style(result.meta.message, c.yellow)}`);
333
- }
334
435
  process.exit(1);
335
436
  }
336
- if (!jsonMode)
337
- printMeta(result.meta);
338
- if (result.data?.findings?.findings) {
339
- printFindings(result.data.findings.findings, jsonMode);
340
- }
437
+ printMeta(result.meta);
438
+ if (result.data?.findings?.findings)
439
+ printFindings(result.data.findings.findings);
341
440
  }
342
441
  catch (err) {
343
442
  spinner.stop("Review failed");
@@ -345,48 +444,71 @@ async function reviewStdin(lang, jsonMode) {
345
444
  process.exit(1);
346
445
  }
347
446
  }
348
- // ── Help & Version ────────────────────────────────────────────────────
447
+ // ── Help ──────────────────────────────────────────────────────────────
349
448
  function printHelp() {
350
449
  console.log(`
351
- ${style("rmcode", c.bold, c.green)} — AI code review from your terminal
450
+ ${style("rmcode", c.bold, c.green)} ${style(`v${VERSION}`, c.dim)} — AI code review from your terminal
352
451
 
353
- ${style("USAGE", c.bold)}
452
+ ${style("COMMANDS", c.bold)}
354
453
 
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
454
+ ${style("rmcode", c.cyan)} Review changes from merge base (default)
455
+ ${style("rmcode --staged", c.cyan)} Review staged changes only
456
+ ${style("rmcode --unstaged", c.cyan)} Review unstaged changes only
457
+ ${style("rmcode --all", c.cyan)} Review all uncommitted changes (staged + unstaged)
458
+ ${style("rmcode <file>", c.cyan)} Review a single local file
359
459
  ${style("rmcode login", c.cyan)} Set up your API key
360
- ${style("rmcode install", c.cyan)} Install the GitHub App for PR reviews
460
+ ${style("rmcode install", c.cyan)} Install the GitHub App for automatic PR reviews
461
+ ${style("rmcode help", c.cyan)} Show this help
361
462
 
362
463
  ${style("OPTIONS", c.bold)}
363
464
 
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
465
+ --json Output the full API response as JSON (for CI/agents)
466
+ --fail-on-findings Exit 2 when JSON output contains findings
467
+ --lang <language> Language hint for stdin input
368
468
  --help, -h Show this help
369
469
  --version, -v Show version
370
470
 
471
+ ${style("MODES", c.bold)}
472
+
473
+ ${style("Default", c.white)} Diff from merge base to HEAD — what your PR would contain
474
+ ${style("--staged", c.white)} Only changes in the staging area (git add)
475
+ ${style("--unstaged", c.white)} Only working directory changes not yet staged
476
+ ${style("--all", c.white)} Everything not yet committed, including untracked text files
477
+ ${style("file", c.white)} A single local file
478
+ ${style("stdin", c.white)} Pipe any diff or code: ${style("git diff main | rmcode", c.dim)}
479
+
371
480
  ${style("EXAMPLES", c.bold)}
372
481
 
373
- ${style("# Review your work before committing", c.dim)}
482
+ ${style("# Review your branch before opening a PR", c.dim)}
483
+ rmcode
484
+
485
+ ${style("# Review only what you're about to commit", c.dim)}
374
486
  rmcode --staged
375
487
 
376
- ${style("# Let an AI agent review its own code", c.dim)}
377
- rmcode --staged --json
488
+ ${style("# Review a specific range", c.dim)}
489
+ git diff abc123..def456 | rmcode --lang typescript
378
490
 
379
- ${style("# Review a file directly", c.dim)}
380
- rmcode src/auth/login.ts
491
+ ${style("# Review one file", c.dim)}
492
+ rmcode src/auth.ts
381
493
 
382
- ${style("# Pipe from another command", c.dim)}
383
- git diff main | rmcode --lang typescript
494
+ ${style("# JSON output for CI pipeline", c.dim)}
495
+ rmcode --json
384
496
 
385
- ${style("# Set up authenticated reviews (30/month free)", c.dim)}
497
+ ${style(`# Set up authenticated reviews (${FREE_PLAN_CREDITS_PER_MONTH} credits/month free)`, c.dim)}
386
498
  rmcode login
387
499
 
388
- ${style("# Auto-review every PR on GitHub", c.dim)}
389
- rmcode install
500
+ ${style("ENVIRONMENT", c.bold)}
501
+
502
+ RMC_API_KEY API key for authenticated reviews (get one: rmcode login)
503
+ RMC_API_URL API endpoint (default: https://review-my-code.com)
504
+ RMC_APP_URL App URL for login (default: https://review-my-code.com)
505
+
506
+ ${style("PRIVACY", c.bold)}
507
+
508
+ CLI reviews run locally to collect your diff/file/stdin content, then send that
509
+ review content to RMCode's backend and model providers for analysis. For
510
+ automatic PR reviews, install the GitHub App; that path uses the repository's
511
+ GitHub Actions runner to produce review context.
390
512
 
391
513
  ${style("https://review-my-code.com", c.dim)}
392
514
  `);
@@ -394,28 +516,59 @@ function printHelp() {
394
516
  // ── Version check ─────────────────────────────────────────────────────
395
517
  async function checkForUpdate() {
396
518
  try {
397
- const resp = await fetch("https://registry.npmjs.org/rmcode/latest", {
519
+ const resp = await fetch("https://registry.npmjs.org/@review-my-code/rmcode/latest", {
398
520
  signal: AbortSignal.timeout(3000),
399
521
  });
400
522
  if (!resp.ok)
401
523
  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));
524
+ const data = (await resp.json());
525
+ if (data.version && data.version !== VERSION) {
526
+ console.log(style(`\n Update available: ${VERSION} ${data.version}`, c.yellow));
527
+ console.log(style(` Run: npm install -g @review-my-code/rmcode\n`, c.dim));
407
528
  }
408
529
  }
409
530
  catch {
410
- // Silent fail — don't block the review
531
+ /* silent */
411
532
  }
412
533
  }
534
+ function parseArgs(args) {
535
+ const flags = new Set();
536
+ const options = {};
537
+ const positional = [];
538
+ for (let i = 0; i < args.length; i++) {
539
+ const arg = args[i];
540
+ if (arg === "--lang") {
541
+ options.lang = args[i + 1];
542
+ i++;
543
+ }
544
+ else if (arg.startsWith("-")) {
545
+ flags.add(arg);
546
+ }
547
+ else {
548
+ positional.push(arg);
549
+ }
550
+ }
551
+ return { flags, options, positional };
552
+ }
553
+ function unknownFlags(flags) {
554
+ const known = new Set([
555
+ "--all",
556
+ "--fail-on-findings",
557
+ "--help",
558
+ "-h",
559
+ "--json",
560
+ "--staged",
561
+ "--unstaged",
562
+ "--version",
563
+ "-v",
564
+ ]);
565
+ return [...flags].filter((flag) => !known.has(flag));
566
+ }
413
567
  // ── Main ──────────────────────────────────────────────────────────────
414
568
  async function main() {
415
569
  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")) {
570
+ const { flags, options, positional } = parseArgs(args);
571
+ if (flags.has("--help") || flags.has("-h") || positional[0] === "help") {
419
572
  printHelp();
420
573
  return;
421
574
  }
@@ -423,65 +576,165 @@ async function main() {
423
576
  console.log(`rmcode v${VERSION}`);
424
577
  return;
425
578
  }
426
- const jsonMode = flags.has("--json");
427
- const staged = flags.has("--staged");
428
- // rmc install
579
+ const invalidFlags = unknownFlags(flags);
580
+ if (invalidFlags.length > 0) {
581
+ console.error(style(`\n Unknown option: ${invalidFlags[0]}`, c.red));
582
+ console.error(style(` Run: rmcode help\n`, c.dim));
583
+ process.exit(1);
584
+ }
429
585
  if (positional[0] === "install") {
430
586
  console.log(`\n ${style("Install the GitHub App for automatic PR reviews:", c.bold)}\n`);
431
587
  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`);
588
+ console.log(` ${style("Install from the same GitHub account linked in RMCode so PR reviews share your plan credits.", c.dim)}`);
589
+ console.log(` ${style("The app uses a GitHub Actions workflow in your repo for context extraction.", c.dim)}`);
590
+ console.log(` ${style("If that workflow cannot be created or dispatched, the PR check fails until setup is fixed.", c.dim)}\n`);
433
591
  return;
434
592
  }
435
- // rmc login
436
593
  if (positional[0] === "login") {
437
- const url = `${API_URL}/api-keys`;
594
+ const url = `${APP_URL}/api-keys`;
438
595
  console.log(`\n ${style("Opening your browser to create an API key...", c.bold)}\n`);
439
596
  try {
440
597
  const openCmd = process.platform === "darwin"
441
598
  ? "open"
442
599
  : process.platform === "win32"
443
- ? "start"
600
+ ? "cmd"
444
601
  : "xdg-open";
445
- (0, child_process_1.execSync)(`${openCmd} ${url}`, { stdio: "ignore" });
602
+ const openArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
603
+ (0, child_process_1.execFileSync)(openCmd, openArgs, { stdio: "ignore" });
446
604
  }
447
605
  catch {
448
- console.log(` ${style("Could not open browser. Visit this URL:", c.yellow)}`);
606
+ console.log(` ${style("Could not open browser. Visit:", c.yellow)}`);
449
607
  }
450
608
  console.log(` ${style(url, c.cyan, c.bold)}\n`);
451
609
  console.log(` ${style("Then set your API key:", c.dim)}`);
452
- console.log(` ${style("export RMC_API_KEY=rmc_k_...", c.green)}\n`);
610
+ console.log(` ${style("export RMC_API_KEY=rmc_...", c.green)}\n`);
453
611
  return;
454
612
  }
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;
613
+ const jsonMode = flags.has("--json");
614
+ const failOnFindings = flags.has("--fail-on-findings");
615
+ if (positional.length > 1) {
616
+ console.error(style(`\n Unknown command: ${positional[0]}`, c.red));
617
+ console.error(style(` Run: rmcode help\n`, c.dim));
618
+ process.exit(1);
460
619
  }
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;
620
+ if (!jsonMode) {
621
+ console.log(`\n ${style("rmcode", c.green, c.bold)} ${style(`v${VERSION}`, c.dim)}`);
466
622
  }
467
623
  // 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);
624
+ if (hasPipedStdin()) {
625
+ const lang = options.lang || "javascript";
626
+ const rl = (0, readline_1.createInterface)({ input: process.stdin });
627
+ const lines = [];
628
+ for await (const line of rl)
629
+ lines.push(line);
630
+ const code = lines.join("\n");
631
+ if (!code.trim()) {
632
+ console.error(style(" No input received on stdin.", c.red));
633
+ process.exit(1);
634
+ }
635
+ const spinner = createSpinner(`Reviewing stdin (${lang})`);
636
+ try {
637
+ const stdinType = looksLikeUnifiedDiff(code) ? "diff" : "snippet";
638
+ const stdinFiles = stdinType === "diff" ? filesFromDiff(code) : [];
639
+ const result = await callAPI(code, lang, stdinType, stdinFiles);
640
+ spinner.stop("Reviewed stdin");
641
+ if (jsonMode) {
642
+ printJsonResponse(result);
643
+ if (!result.success)
644
+ process.exit(1);
645
+ if (failOnFindings && hasBlockingFindings(result))
646
+ process.exit(2);
647
+ return;
648
+ }
649
+ if (!result.success) {
650
+ console.error(style(` ${result.error || "Review failed."}`, c.red));
651
+ process.exit(1);
652
+ }
653
+ printMeta(result.meta);
654
+ if (result.data?.findings?.findings)
655
+ printFindings(result.data.findings.findings);
656
+ }
657
+ catch (err) {
658
+ spinner.stop("Review failed");
659
+ console.error(style(` ${err.message}`, c.red));
660
+ process.exit(1);
661
+ }
472
662
  return;
473
663
  }
474
- // Default: review git diff
475
- await reviewDiff(staged, jsonMode);
664
+ if (positional.length === 1) {
665
+ const file = positional[0];
666
+ let code;
667
+ try {
668
+ code = (0, fs_1.readFileSync)(file, "utf-8");
669
+ }
670
+ catch (err) {
671
+ console.error(style(` Could not read ${file}: ${err.message}`, c.red));
672
+ process.exit(1);
673
+ }
674
+ if (!code.trim()) {
675
+ console.error(style(` ${file} is empty.`, c.yellow));
676
+ process.exit(0);
677
+ }
678
+ await runReview(code, [file], file, jsonMode, failOnFindings, "file", detectLanguage([file]));
679
+ if (!jsonMode)
680
+ await checkForUpdate();
681
+ return;
682
+ }
683
+ // Git modes
684
+ if (!isGitRepo()) {
685
+ console.error(style(" Not a git repository. Run from inside a git repo.", c.red));
686
+ process.exit(1);
687
+ }
688
+ const branch = currentBranch();
689
+ let diff;
690
+ let files;
691
+ let label;
692
+ if (flags.has("--staged")) {
693
+ ({ diff, files } = diffStaged());
694
+ label = "staged changes";
695
+ if (!diff) {
696
+ console.error(style(" No staged changes. Stage files with: git add <files>", c.yellow));
697
+ process.exit(0);
698
+ }
699
+ }
700
+ else if (flags.has("--unstaged")) {
701
+ ({ diff, files } = diffUnstaged());
702
+ label = "unstaged changes";
703
+ if (!diff) {
704
+ console.error(style(" No unstaged changes.", c.yellow));
705
+ process.exit(0);
706
+ }
707
+ }
708
+ else if (flags.has("--all")) {
709
+ ({ diff, files } = diffAll());
710
+ label = "all uncommitted changes";
711
+ if (!diff) {
712
+ console.error(style(" No uncommitted changes.", c.yellow));
713
+ process.exit(0);
714
+ }
715
+ }
716
+ else {
717
+ ({ diff, files } = diffFromMergeBase());
718
+ label = `branch ${style(branch, c.cyan)} vs base`;
719
+ if (!diff) {
720
+ console.error(style(" No changes from merge base. Your branch matches main.", c.yellow));
721
+ process.exit(0);
722
+ }
723
+ }
724
+ await runReview(diff, files, label, jsonMode, failOnFindings);
476
725
  if (!jsonMode && !API_KEY) {
477
- console.log(style(" Get 30 reviews/month free: rmcode login", c.dim));
726
+ console.log(style(` Get ${FREE_PLAN_CREDITS_PER_MONTH} credits/month free: rmcode login`, c.dim));
478
727
  console.log(style(" Auto-review every PR: rmcode install", c.dim));
479
728
  console.log();
480
729
  }
481
- // Non-blocking update check after review completes
482
- await checkForUpdate();
730
+ if (!jsonMode)
731
+ await checkForUpdate();
483
732
  }
484
- main().catch((err) => {
733
+ main()
734
+ .then(() => {
735
+ process.exit(process.exitCode ?? 0);
736
+ })
737
+ .catch((err) => {
485
738
  console.error(style(` Fatal: ${err.message}`, c.red));
486
739
  process.exit(1);
487
740
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@review-my-code/rmcode",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.1",
4
4
  "description": "AI code review from your terminal. Catches logic errors, null risks, security holes, and broken error handling.",
5
5
  "keywords": [
6
6
  "code-review",
@@ -17,7 +17,8 @@
17
17
  "rmc": "dist/cli.js"
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "README.md"
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc",
@@ -34,9 +35,13 @@
34
35
  "node": ">=18"
35
36
  },
36
37
  "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
37
41
  "repository": {
38
42
  "type": "git",
39
- "url": "https://github.com/review-my-code/review-my-code"
43
+ "url": "git+https://github.com/review-my-code/review-my-code.git",
44
+ "directory": "cli"
40
45
  },
41
46
  "homepage": "https://review-my-code.com",
42
47
  "author": "Alex Yang"