grepper-dev 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +725 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @grepper/cli
|
|
2
|
+
|
|
3
|
+
Local code review powered by AI. Review your code changes before pushing, right from your terminal.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @grepper/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use directly with npx:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @grepper/cli review
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
1. Get your API key from the [Grepper dashboard](https://grepper.dev/dashboard/settings)
|
|
20
|
+
2. Login with your key:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
grepper login
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### Review staged changes (default)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
grepper review
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Review all uncommitted changes
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
grepper review --all
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Review changes against a branch
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
grepper review --base main
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Review specific files
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
grepper review src/api.ts src/utils.ts
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
| Option | Description |
|
|
55
|
+
|--------|-------------|
|
|
56
|
+
| `-s, --staged` | Review staged changes only |
|
|
57
|
+
| `-a, --all` | Review all uncommitted changes |
|
|
58
|
+
| `-b, --base <branch>` | Compare against a branch |
|
|
59
|
+
| `-m, --mode <mode>` | Review mode: `full`, `quick`, or `security` |
|
|
60
|
+
| `-f, --format <format>` | Output: `pretty`, `json`, or `markdown` |
|
|
61
|
+
| `--fail-on-critical` | Exit with code 1 if critical issues found |
|
|
62
|
+
|
|
63
|
+
## Review Modes
|
|
64
|
+
|
|
65
|
+
- **full** (default) - Comprehensive review covering all aspects
|
|
66
|
+
- **quick** - Fast review focusing on obvious issues
|
|
67
|
+
- **security** - Security-focused review
|
|
68
|
+
|
|
69
|
+
## Output Formats
|
|
70
|
+
|
|
71
|
+
- **pretty** (default) - Colored terminal output
|
|
72
|
+
- **json** - Machine-readable JSON for CI/automation
|
|
73
|
+
- **markdown** - Markdown for documentation or PRs
|
|
74
|
+
|
|
75
|
+
## CI Integration
|
|
76
|
+
|
|
77
|
+
Use in CI pipelines to catch issues before merge:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
- name: Review code
|
|
81
|
+
run: npx @grepper/cli review --base main --format json --fail-on-critical
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
Create a `.grepper.json` in your project root:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mode": "full",
|
|
91
|
+
"ignore": ["*.test.ts", "dist/**"]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The CLI also reads `CLAUDE.md` and `agents.md` for project-specific context.
|
|
96
|
+
|
|
97
|
+
## Commands
|
|
98
|
+
|
|
99
|
+
| Command | Description |
|
|
100
|
+
|---------|-------------|
|
|
101
|
+
| `grepper login` | Authenticate with your API key |
|
|
102
|
+
| `grepper logout` | Clear stored credentials |
|
|
103
|
+
| `grepper whoami` | Show current user and organization |
|
|
104
|
+
| `grepper review` | Review code changes |
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import updateNotifier from "update-notifier";
|
|
6
|
+
|
|
7
|
+
// src/commands/login.ts
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
|
|
10
|
+
// src/lib/auth.ts
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
var CONFIG_DIR = join(homedir(), ".grepper");
|
|
15
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
16
|
+
var DEFAULT_API_URL = "https://grepper.convex.site";
|
|
17
|
+
function ensureConfigDir() {
|
|
18
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
19
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function loadConfig() {
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(CONFIG_FILE)) {
|
|
25
|
+
const data = readFileSync(CONFIG_FILE, "utf-8");
|
|
26
|
+
return JSON.parse(data);
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
function saveConfig(config) {
|
|
33
|
+
ensureConfigDir();
|
|
34
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
|
35
|
+
mode: 384
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function getApiKey() {
|
|
39
|
+
const envKey = process.env.GREPPER_API_KEY;
|
|
40
|
+
if (envKey) {
|
|
41
|
+
return envKey;
|
|
42
|
+
}
|
|
43
|
+
const config = loadConfig();
|
|
44
|
+
return config.apiKey;
|
|
45
|
+
}
|
|
46
|
+
function getApiUrl() {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
return config.apiUrl ?? process.env.GREPPER_API_URL ?? DEFAULT_API_URL;
|
|
49
|
+
}
|
|
50
|
+
function setApiKey(apiKey) {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
config.apiKey = apiKey;
|
|
53
|
+
saveConfig(config);
|
|
54
|
+
}
|
|
55
|
+
function clearCredentials() {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
config.apiKey = void 0;
|
|
58
|
+
saveConfig(config);
|
|
59
|
+
}
|
|
60
|
+
function isLoggedIn() {
|
|
61
|
+
return !!getApiKey();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/lib/api.ts
|
|
65
|
+
var REVIEW_TIMEOUT_MS = 12e4;
|
|
66
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
67
|
+
var ApiError = class extends Error {
|
|
68
|
+
statusCode;
|
|
69
|
+
constructor(message, statusCode) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "ApiError";
|
|
72
|
+
this.statusCode = statusCode;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
function getHeaders() {
|
|
76
|
+
const apiKey = getApiKey();
|
|
77
|
+
if (!apiKey) {
|
|
78
|
+
throw new ApiError("Not logged in. Run `grepper login` first.", 401);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
Authorization: `Bearer ${apiKey}`,
|
|
82
|
+
"Content-Type": "application/json"
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function whoami() {
|
|
86
|
+
const apiUrl = getApiUrl();
|
|
87
|
+
const response = await fetch(`${apiUrl}/cli/whoami`, {
|
|
88
|
+
method: "GET",
|
|
89
|
+
headers: getHeaders(),
|
|
90
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const error = await response.json().catch(() => ({}));
|
|
94
|
+
throw new ApiError(
|
|
95
|
+
error.error ?? `API error: ${response.status}`,
|
|
96
|
+
response.status
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return response.json();
|
|
100
|
+
}
|
|
101
|
+
async function runReview(options) {
|
|
102
|
+
const apiUrl = getApiUrl();
|
|
103
|
+
const response = await fetch(`${apiUrl}/cli/review`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: getHeaders(),
|
|
106
|
+
body: JSON.stringify(options),
|
|
107
|
+
signal: AbortSignal.timeout(REVIEW_TIMEOUT_MS)
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const error = await response.json().catch(() => ({}));
|
|
111
|
+
throw new ApiError(
|
|
112
|
+
error.error ?? `API error: ${response.status}`,
|
|
113
|
+
response.status
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return response.json();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/commands/login.ts
|
|
120
|
+
function promptApiKey() {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const { stdin, stdout } = process;
|
|
123
|
+
stdout.write("Enter your API key: ");
|
|
124
|
+
const wasRaw = stdin.isRaw;
|
|
125
|
+
if (stdin.isTTY) {
|
|
126
|
+
stdin.setRawMode(true);
|
|
127
|
+
}
|
|
128
|
+
stdin.resume();
|
|
129
|
+
stdin.setEncoding("utf-8");
|
|
130
|
+
let input = "";
|
|
131
|
+
const onData = (char) => {
|
|
132
|
+
if (char === "\n" || char === "\r" || char === "") {
|
|
133
|
+
stdout.write("\n");
|
|
134
|
+
stdin.removeListener("data", onData);
|
|
135
|
+
stdin.pause();
|
|
136
|
+
if (stdin.isTTY && wasRaw !== void 0) {
|
|
137
|
+
stdin.setRawMode(wasRaw);
|
|
138
|
+
}
|
|
139
|
+
resolve(input.trim());
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (char === "\x7F" || char === "\b") {
|
|
143
|
+
if (input.length > 0) {
|
|
144
|
+
input = input.slice(0, -1);
|
|
145
|
+
stdout.write("\b \b");
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (char === "") {
|
|
150
|
+
stdout.write("\n");
|
|
151
|
+
process.exit(130);
|
|
152
|
+
}
|
|
153
|
+
input += char;
|
|
154
|
+
stdout.write("*");
|
|
155
|
+
};
|
|
156
|
+
stdin.on("data", onData);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async function loginCommand() {
|
|
160
|
+
console.log(chalk.bold("\n\u{1F511} Grepper Login\n"));
|
|
161
|
+
console.log("Get your API key from the Grepper dashboard:");
|
|
162
|
+
console.log(chalk.dim("https://grepper.dev/dashboard/settings\n"));
|
|
163
|
+
const apiKey = await promptApiKey();
|
|
164
|
+
if (!apiKey) {
|
|
165
|
+
console.error(chalk.red("No API key provided."));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
if (!apiKey.startsWith("grp_")) {
|
|
169
|
+
console.error(
|
|
170
|
+
chalk.red('Invalid API key format. Keys should start with "grp_".')
|
|
171
|
+
);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
setApiKey(apiKey);
|
|
176
|
+
const info = await whoami();
|
|
177
|
+
console.log(chalk.green("\n\u2713 Logged in successfully!\n"));
|
|
178
|
+
console.log(` User: ${info.user.name} (${info.user.email})`);
|
|
179
|
+
console.log(` Org: ${info.org.name} (${info.org.slug})`);
|
|
180
|
+
console.log(chalk.dim("\n Credentials saved to ~/.grepper/config.json"));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
setApiKey("");
|
|
183
|
+
if (error instanceof TypeError || error instanceof Error && error.message.includes("fetch")) {
|
|
184
|
+
console.error(
|
|
185
|
+
chalk.red(
|
|
186
|
+
"\nNetwork error: Could not connect to Grepper. Check your internet connection."
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
console.error(
|
|
192
|
+
chalk.red(
|
|
193
|
+
`
|
|
194
|
+
Failed to validate API key: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/commands/logout.ts
|
|
202
|
+
import chalk2 from "chalk";
|
|
203
|
+
function logoutCommand() {
|
|
204
|
+
if (!isLoggedIn()) {
|
|
205
|
+
console.error(chalk2.yellow("You are not logged in."));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
clearCredentials();
|
|
209
|
+
console.log(chalk2.green("\u2713 Logged out successfully."));
|
|
210
|
+
console.log(chalk2.dim(" Credentials removed from ~/.grepper/config.json"));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/commands/review.ts
|
|
214
|
+
import chalk4 from "chalk";
|
|
215
|
+
import ora from "ora";
|
|
216
|
+
|
|
217
|
+
// src/lib/context.ts
|
|
218
|
+
import { execFileSync, execSync } from "child_process";
|
|
219
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
220
|
+
import { join as join2 } from "path";
|
|
221
|
+
var MAX_DIFF_SIZE = 100 * 1024;
|
|
222
|
+
var SSH_REMOTE_REGEX = /^git@[^:]+:([^/]+\/[^/]+?)(?:\.git)?$/;
|
|
223
|
+
var GIT_SUFFIX_REGEX = /\.git$/;
|
|
224
|
+
function parseRepoFullName(remoteUrl) {
|
|
225
|
+
const sshMatch = remoteUrl.match(SSH_REMOTE_REGEX);
|
|
226
|
+
if (sshMatch?.[1]) {
|
|
227
|
+
return sshMatch[1];
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const url = new URL(remoteUrl);
|
|
231
|
+
const parts = url.pathname.replace(GIT_SUFFIX_REGEX, "").split("/").filter(Boolean);
|
|
232
|
+
if (parts.length >= 2) {
|
|
233
|
+
return `${parts[0]}/${parts[1]}`;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
function getRepoFullName() {
|
|
240
|
+
try {
|
|
241
|
+
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
242
|
+
encoding: "utf-8",
|
|
243
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
244
|
+
}).trim();
|
|
245
|
+
return parseRepoFullName(remoteUrl);
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function getProjectRoot() {
|
|
251
|
+
try {
|
|
252
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
253
|
+
encoding: "utf-8",
|
|
254
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
255
|
+
}).trim();
|
|
256
|
+
return root;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function hasStagedChanges() {
|
|
262
|
+
try {
|
|
263
|
+
execSync("git diff --cached --quiet", {
|
|
264
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
265
|
+
});
|
|
266
|
+
return false;
|
|
267
|
+
} catch {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function hasUnstagedChanges() {
|
|
272
|
+
try {
|
|
273
|
+
execSync("git diff --quiet", {
|
|
274
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
275
|
+
});
|
|
276
|
+
return false;
|
|
277
|
+
} catch {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function getGitDiff(options = {}) {
|
|
282
|
+
const args = ["diff"];
|
|
283
|
+
if (options.base) {
|
|
284
|
+
args.push(`${options.base}...HEAD`);
|
|
285
|
+
} else if (options.staged) {
|
|
286
|
+
args.push("--cached");
|
|
287
|
+
} else if (options.all) {
|
|
288
|
+
args.push("HEAD");
|
|
289
|
+
}
|
|
290
|
+
if (options.files?.length) {
|
|
291
|
+
args.push("--");
|
|
292
|
+
args.push(...options.files);
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const diff = execFileSync("git", args, {
|
|
296
|
+
encoding: "utf-8",
|
|
297
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
298
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
299
|
+
});
|
|
300
|
+
if (diff.length > MAX_DIFF_SIZE) {
|
|
301
|
+
return {
|
|
302
|
+
diff: diff.slice(0, MAX_DIFF_SIZE),
|
|
303
|
+
truncated: true
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { diff, truncated: false };
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error instanceof Error && "stdout" in error) {
|
|
309
|
+
const stdout = error.stdout ?? "";
|
|
310
|
+
if (stdout.length > MAX_DIFF_SIZE) {
|
|
311
|
+
return {
|
|
312
|
+
diff: stdout.slice(0, MAX_DIFF_SIZE),
|
|
313
|
+
truncated: true
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return { diff: stdout, truncated: false };
|
|
317
|
+
}
|
|
318
|
+
return { diff: "", truncated: false };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function readClaudeMd(projectRoot) {
|
|
322
|
+
const claudeDir = join2(projectRoot, ".claude", "CLAUDE.md");
|
|
323
|
+
if (existsSync2(claudeDir)) {
|
|
324
|
+
return readFileSync2(claudeDir, "utf-8");
|
|
325
|
+
}
|
|
326
|
+
const claudeRoot = join2(projectRoot, "CLAUDE.md");
|
|
327
|
+
if (existsSync2(claudeRoot)) {
|
|
328
|
+
return readFileSync2(claudeRoot, "utf-8");
|
|
329
|
+
}
|
|
330
|
+
return void 0;
|
|
331
|
+
}
|
|
332
|
+
function readAgentsMd(projectRoot) {
|
|
333
|
+
const agentsPath = join2(projectRoot, "agents.md");
|
|
334
|
+
if (existsSync2(agentsPath)) {
|
|
335
|
+
return readFileSync2(agentsPath, "utf-8");
|
|
336
|
+
}
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
339
|
+
function readLocalConfig(projectRoot) {
|
|
340
|
+
const configPath = join2(projectRoot, ".grepper.json");
|
|
341
|
+
if (existsSync2(configPath)) {
|
|
342
|
+
try {
|
|
343
|
+
const data = readFileSync2(configPath, "utf-8");
|
|
344
|
+
return JSON.parse(data);
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return void 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/lib/output.ts
|
|
352
|
+
import chalk3 from "chalk";
|
|
353
|
+
var SEVERITY_ICONS = {
|
|
354
|
+
critical: "\u{1F534}",
|
|
355
|
+
warning: "\u{1F7E1}",
|
|
356
|
+
suggestion: "\u{1F535}",
|
|
357
|
+
nitpick: "\u26AA",
|
|
358
|
+
praise: "\u{1F7E2}"
|
|
359
|
+
};
|
|
360
|
+
var SEVERITY_COLORS = {
|
|
361
|
+
critical: chalk3.red,
|
|
362
|
+
warning: chalk3.yellow,
|
|
363
|
+
suggestion: chalk3.blue,
|
|
364
|
+
nitpick: chalk3.gray,
|
|
365
|
+
praise: chalk3.green
|
|
366
|
+
};
|
|
367
|
+
function formatFinding(finding) {
|
|
368
|
+
const icon = SEVERITY_ICONS[finding.severity];
|
|
369
|
+
const color = SEVERITY_COLORS[finding.severity];
|
|
370
|
+
const lines = [];
|
|
371
|
+
const lineRange = finding.endLine && finding.endLine !== finding.startLine ? `${finding.startLine}-${finding.endLine}` : String(finding.startLine);
|
|
372
|
+
lines.push(
|
|
373
|
+
`${icon} ${color.bold(finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1))}: ${finding.title}`
|
|
374
|
+
);
|
|
375
|
+
lines.push(chalk3.dim(` ${finding.file}:${lineRange}`));
|
|
376
|
+
lines.push(` ${finding.message}`);
|
|
377
|
+
if (finding.suggestion) {
|
|
378
|
+
lines.push("");
|
|
379
|
+
lines.push(chalk3.dim(" Suggested fix:"));
|
|
380
|
+
const suggestionLines = finding.suggestion.split("\n");
|
|
381
|
+
for (const line of suggestionLines) {
|
|
382
|
+
if (line.startsWith("+")) {
|
|
383
|
+
lines.push(chalk3.green(` ${line}`));
|
|
384
|
+
} else if (line.startsWith("-")) {
|
|
385
|
+
lines.push(chalk3.red(` ${line}`));
|
|
386
|
+
} else {
|
|
387
|
+
lines.push(chalk3.dim(` ${line}`));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return lines.join("\n");
|
|
392
|
+
}
|
|
393
|
+
function formatPretty(output) {
|
|
394
|
+
const lines = [];
|
|
395
|
+
lines.push(
|
|
396
|
+
`
|
|
397
|
+
${chalk3.bold("\u{1F4CB} Reviewed")} ${output.reviewedFiles.length} file${output.reviewedFiles.length === 1 ? "" : "s"}`
|
|
398
|
+
);
|
|
399
|
+
if (output.truncated) {
|
|
400
|
+
lines.push(chalk3.yellow("\u26A0\uFE0F Diff was truncated due to size limits"));
|
|
401
|
+
}
|
|
402
|
+
lines.push("");
|
|
403
|
+
const critical = output.findings.filter((f) => f.severity === "critical");
|
|
404
|
+
const warning = output.findings.filter((f) => f.severity === "warning");
|
|
405
|
+
const suggestion = output.findings.filter((f) => f.severity === "suggestion");
|
|
406
|
+
const nitpick = output.findings.filter((f) => f.severity === "nitpick");
|
|
407
|
+
const praise = output.findings.filter((f) => f.severity === "praise");
|
|
408
|
+
const allFindings = [
|
|
409
|
+
...critical,
|
|
410
|
+
...warning,
|
|
411
|
+
...suggestion,
|
|
412
|
+
...nitpick,
|
|
413
|
+
...praise
|
|
414
|
+
];
|
|
415
|
+
if (allFindings.length === 0) {
|
|
416
|
+
lines.push(chalk3.green("\u2728 No issues found!"));
|
|
417
|
+
} else {
|
|
418
|
+
for (const finding of allFindings) {
|
|
419
|
+
lines.push(formatFinding(finding));
|
|
420
|
+
lines.push("");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
lines.push("\u2500".repeat(40));
|
|
424
|
+
const counts = [];
|
|
425
|
+
if (critical.length > 0) {
|
|
426
|
+
counts.push(chalk3.red(`${critical.length} critical`));
|
|
427
|
+
}
|
|
428
|
+
if (warning.length > 0) {
|
|
429
|
+
counts.push(chalk3.yellow(`${warning.length} warning`));
|
|
430
|
+
}
|
|
431
|
+
if (suggestion.length > 0) {
|
|
432
|
+
counts.push(chalk3.blue(`${suggestion.length} suggestion`));
|
|
433
|
+
}
|
|
434
|
+
if (nitpick.length > 0) {
|
|
435
|
+
counts.push(chalk3.gray(`${nitpick.length} nitpick`));
|
|
436
|
+
}
|
|
437
|
+
if (praise.length > 0) {
|
|
438
|
+
counts.push(chalk3.green(`${praise.length} praise`));
|
|
439
|
+
}
|
|
440
|
+
if (counts.length > 0) {
|
|
441
|
+
lines.push(`Summary: ${counts.join(", ")}`);
|
|
442
|
+
}
|
|
443
|
+
if (output.summary) {
|
|
444
|
+
lines.push(chalk3.dim(output.summary));
|
|
445
|
+
}
|
|
446
|
+
return lines.join("\n");
|
|
447
|
+
}
|
|
448
|
+
function formatJson(output) {
|
|
449
|
+
return JSON.stringify(output, null, 2);
|
|
450
|
+
}
|
|
451
|
+
function formatFindingMarkdown(finding) {
|
|
452
|
+
const lines = [];
|
|
453
|
+
const lineRange = finding.endLine && finding.endLine !== finding.startLine ? `${finding.startLine}-${finding.endLine}` : String(finding.startLine);
|
|
454
|
+
lines.push(`#### ${finding.title}`);
|
|
455
|
+
lines.push(`\u{1F4CD} \`${finding.file}:${lineRange}\`
|
|
456
|
+
`);
|
|
457
|
+
lines.push(`${finding.message}
|
|
458
|
+
`);
|
|
459
|
+
if (finding.suggestion) {
|
|
460
|
+
lines.push("**Suggested fix:**");
|
|
461
|
+
lines.push("```diff");
|
|
462
|
+
lines.push(finding.suggestion);
|
|
463
|
+
lines.push("```\n");
|
|
464
|
+
}
|
|
465
|
+
return lines;
|
|
466
|
+
}
|
|
467
|
+
function groupBySeverity(findings) {
|
|
468
|
+
const bySeverity = /* @__PURE__ */ new Map();
|
|
469
|
+
for (const finding of findings) {
|
|
470
|
+
const list = bySeverity.get(finding.severity) ?? [];
|
|
471
|
+
list.push(finding);
|
|
472
|
+
bySeverity.set(finding.severity, list);
|
|
473
|
+
}
|
|
474
|
+
return bySeverity;
|
|
475
|
+
}
|
|
476
|
+
var SEVERITY_ORDER = [
|
|
477
|
+
"critical",
|
|
478
|
+
"warning",
|
|
479
|
+
"suggestion",
|
|
480
|
+
"nitpick",
|
|
481
|
+
"praise"
|
|
482
|
+
];
|
|
483
|
+
function formatMarkdown(output) {
|
|
484
|
+
const lines = [];
|
|
485
|
+
lines.push("## Code Review Results\n");
|
|
486
|
+
if (output.truncated) {
|
|
487
|
+
lines.push(
|
|
488
|
+
"> \u26A0\uFE0F **Note:** Diff was truncated due to size limits. Some files may not have been reviewed.\n"
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (output.findings.length === 0) {
|
|
492
|
+
lines.push("\u2728 No issues found!\n");
|
|
493
|
+
} else {
|
|
494
|
+
const bySeverity = groupBySeverity(output.findings);
|
|
495
|
+
for (const severity of SEVERITY_ORDER) {
|
|
496
|
+
const findings = bySeverity.get(severity);
|
|
497
|
+
if (!findings?.length) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const icon = SEVERITY_ICONS[severity];
|
|
501
|
+
const title = severity.charAt(0).toUpperCase() + severity.slice(1);
|
|
502
|
+
lines.push(`### ${icon} ${title} (${findings.length})
|
|
503
|
+
`);
|
|
504
|
+
for (const finding of findings) {
|
|
505
|
+
lines.push(...formatFindingMarkdown(finding));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
lines.push("---\n");
|
|
510
|
+
lines.push(`**Summary:** ${output.summary}`);
|
|
511
|
+
lines.push(`
|
|
512
|
+
**Files reviewed:** ${output.reviewedFiles.join(", ")}`);
|
|
513
|
+
return lines.join("\n");
|
|
514
|
+
}
|
|
515
|
+
function formatOutput(output, format) {
|
|
516
|
+
switch (format) {
|
|
517
|
+
case "json":
|
|
518
|
+
return formatJson(output);
|
|
519
|
+
case "markdown":
|
|
520
|
+
return formatMarkdown(output);
|
|
521
|
+
default:
|
|
522
|
+
return formatPretty(output);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/commands/review.ts
|
|
527
|
+
function determineDiffOptions(options) {
|
|
528
|
+
if (options.files?.length) {
|
|
529
|
+
return { files: options.files };
|
|
530
|
+
}
|
|
531
|
+
if (options.base) {
|
|
532
|
+
return { base: options.base };
|
|
533
|
+
}
|
|
534
|
+
if (options.staged) {
|
|
535
|
+
return { staged: true };
|
|
536
|
+
}
|
|
537
|
+
if (options.all) {
|
|
538
|
+
return { all: true };
|
|
539
|
+
}
|
|
540
|
+
if (hasStagedChanges()) {
|
|
541
|
+
console.error(chalk4.dim("Reviewing staged changes..."));
|
|
542
|
+
return { staged: true };
|
|
543
|
+
}
|
|
544
|
+
if (hasUnstagedChanges()) {
|
|
545
|
+
console.error(chalk4.dim("Reviewing unstaged changes..."));
|
|
546
|
+
return {};
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
function handleReviewError(error) {
|
|
551
|
+
if (error instanceof ApiError) {
|
|
552
|
+
console.error(chalk4.red(`API Error: ${error.message}`));
|
|
553
|
+
process.exit(2);
|
|
554
|
+
}
|
|
555
|
+
if (error instanceof TypeError || error instanceof Error && error.message.includes("fetch")) {
|
|
556
|
+
console.error(
|
|
557
|
+
chalk4.red(
|
|
558
|
+
"Network error: Could not connect to Grepper. Check your internet connection."
|
|
559
|
+
)
|
|
560
|
+
);
|
|
561
|
+
process.exit(2);
|
|
562
|
+
}
|
|
563
|
+
console.error(
|
|
564
|
+
chalk4.red(
|
|
565
|
+
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
566
|
+
)
|
|
567
|
+
);
|
|
568
|
+
process.exit(2);
|
|
569
|
+
}
|
|
570
|
+
function showContextInfo(context) {
|
|
571
|
+
console.error(chalk4.dim(`Mode: ${context.mode}`));
|
|
572
|
+
if (context.repoFullName) {
|
|
573
|
+
console.error(chalk4.dim(`Repository: ${context.repoFullName}`));
|
|
574
|
+
}
|
|
575
|
+
if (context.claudeMd) {
|
|
576
|
+
console.error(chalk4.dim("Found CLAUDE.md"));
|
|
577
|
+
}
|
|
578
|
+
if (context.agentsMd) {
|
|
579
|
+
console.error(chalk4.dim("Found agents.md"));
|
|
580
|
+
}
|
|
581
|
+
if (context.truncated) {
|
|
582
|
+
console.error(
|
|
583
|
+
chalk4.yellow("Diff truncated to 100KB. Some files may not be reviewed.")
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function showRepoStatus(repoFullName, repoConnected, skillCount) {
|
|
588
|
+
if (repoConnected) {
|
|
589
|
+
console.error(
|
|
590
|
+
chalk4.dim(`Repository: ${repoFullName} (${skillCount ?? 0} skills)`)
|
|
591
|
+
);
|
|
592
|
+
} else {
|
|
593
|
+
console.error(
|
|
594
|
+
chalk4.dim(
|
|
595
|
+
"Tip: Connect this repo at grepper.dev/dashboard for repo-specific review rules"
|
|
596
|
+
)
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function reviewCommand(options) {
|
|
601
|
+
if (!isLoggedIn()) {
|
|
602
|
+
console.error(chalk4.yellow("Not logged in. Run `grepper login` first."));
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
const projectRoot = getProjectRoot();
|
|
606
|
+
if (!projectRoot) {
|
|
607
|
+
console.error(chalk4.red("Not in a git repository."));
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
const diffOptions = determineDiffOptions(options);
|
|
611
|
+
if (diffOptions === null) {
|
|
612
|
+
console.error(chalk4.yellow("No changes to review."));
|
|
613
|
+
console.error(
|
|
614
|
+
chalk4.dim(
|
|
615
|
+
"Stage some changes with `git add` or use `--base main` to compare against a branch."
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
process.exit(0);
|
|
619
|
+
}
|
|
620
|
+
const { diff, truncated } = getGitDiff(diffOptions);
|
|
621
|
+
if (!diff.trim()) {
|
|
622
|
+
console.error(chalk4.yellow("No changes to review."));
|
|
623
|
+
process.exit(0);
|
|
624
|
+
}
|
|
625
|
+
const repoFullName = getRepoFullName();
|
|
626
|
+
const claudeMd = readClaudeMd(projectRoot);
|
|
627
|
+
const agentsMd = readAgentsMd(projectRoot);
|
|
628
|
+
const localConfig = readLocalConfig(projectRoot);
|
|
629
|
+
const mode = options.mode ?? localConfig?.mode ?? "full";
|
|
630
|
+
const format = options.format ?? "pretty";
|
|
631
|
+
if (format === "pretty") {
|
|
632
|
+
showContextInfo({ mode, repoFullName, claudeMd, agentsMd, truncated });
|
|
633
|
+
}
|
|
634
|
+
const spinner = format === "pretty" ? ora("Analyzing diff...").start() : null;
|
|
635
|
+
try {
|
|
636
|
+
const output = await runReview({
|
|
637
|
+
diff,
|
|
638
|
+
mode,
|
|
639
|
+
claudeMd,
|
|
640
|
+
agentsMd,
|
|
641
|
+
repoFullName: repoFullName ?? void 0
|
|
642
|
+
});
|
|
643
|
+
if (truncated) {
|
|
644
|
+
output.truncated = true;
|
|
645
|
+
}
|
|
646
|
+
spinner?.stop();
|
|
647
|
+
console.log(formatOutput(output, format));
|
|
648
|
+
if (format === "pretty" && repoFullName) {
|
|
649
|
+
showRepoStatus(repoFullName, output.repoConnected, output.skillCount);
|
|
650
|
+
}
|
|
651
|
+
if (options.failOnCritical) {
|
|
652
|
+
const hasCritical = output.findings.some(
|
|
653
|
+
(f) => f.severity === "critical"
|
|
654
|
+
);
|
|
655
|
+
if (hasCritical) {
|
|
656
|
+
process.exit(1);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
} catch (error) {
|
|
660
|
+
spinner?.stop();
|
|
661
|
+
handleReviewError(error);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/commands/whoami.ts
|
|
666
|
+
import chalk5 from "chalk";
|
|
667
|
+
async function whoamiCommand() {
|
|
668
|
+
if (!isLoggedIn()) {
|
|
669
|
+
console.error(chalk5.yellow("Not logged in. Run `grepper login` first."));
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const info = await whoami();
|
|
674
|
+
console.log(chalk5.bold("\n\u{1F4CB} Current Session\n"));
|
|
675
|
+
console.log(` User: ${info.user.name} (${info.user.email})`);
|
|
676
|
+
console.log(` Org: ${info.org.name} (${info.org.slug})`);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (error instanceof TypeError || error instanceof Error && error.message.includes("fetch")) {
|
|
679
|
+
console.error(
|
|
680
|
+
chalk5.red(
|
|
681
|
+
"Network error: Could not connect to Grepper. Check your internet connection."
|
|
682
|
+
)
|
|
683
|
+
);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
console.error(
|
|
687
|
+
chalk5.red(
|
|
688
|
+
`Failed to get user info: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
689
|
+
)
|
|
690
|
+
);
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/index.ts
|
|
696
|
+
updateNotifier({
|
|
697
|
+
pkg: { name: "grepper-dev", version: "0.2.0" }
|
|
698
|
+
}).notify();
|
|
699
|
+
var program = new Command();
|
|
700
|
+
program.name("grepper").description("Local code review powered by AI").version("0.2.0");
|
|
701
|
+
program.command("login").description("Log in with your Grepper API key").action(async () => {
|
|
702
|
+
await loginCommand();
|
|
703
|
+
});
|
|
704
|
+
program.command("logout").description("Log out and clear credentials").action(() => {
|
|
705
|
+
logoutCommand();
|
|
706
|
+
});
|
|
707
|
+
program.command("whoami").description("Show current user and organization").action(async () => {
|
|
708
|
+
await whoamiCommand();
|
|
709
|
+
});
|
|
710
|
+
program.command("review", { isDefault: true }).description("Review code changes").option("-s, --staged", "Review staged changes only").option("-a, --all", "Review all uncommitted changes (staged + unstaged)").option("-b, --base <branch>", "Review changes against a branch (e.g., main)").addOption(
|
|
711
|
+
new Option("-m, --mode <mode>", "Review mode").choices(["full", "quick", "security"]).default("full")
|
|
712
|
+
).addOption(
|
|
713
|
+
new Option("-f, --format <format>", "Output format").choices(["pretty", "json", "markdown"]).default("pretty")
|
|
714
|
+
).option("--fail-on-critical", "Exit with code 1 if critical issues found").argument("[files...]", "Specific files to review").action(async (files, opts) => {
|
|
715
|
+
await reviewCommand({
|
|
716
|
+
staged: opts.staged,
|
|
717
|
+
all: opts.all,
|
|
718
|
+
base: opts.base,
|
|
719
|
+
files: files.length > 0 ? files : void 0,
|
|
720
|
+
mode: opts.mode,
|
|
721
|
+
format: opts.format,
|
|
722
|
+
failOnCritical: opts.failOnCritical
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "grepper-dev",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Grepper CLI - Local code review powered by AI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"grepper": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/avr00/grepper.git",
|
|
16
|
+
"directory": "packages/cli"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://grepper.dev",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/avr00/grepper/issues"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"code-review",
|
|
24
|
+
"ai",
|
|
25
|
+
"cli",
|
|
26
|
+
"linter",
|
|
27
|
+
"static-analysis",
|
|
28
|
+
"developer-tools"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"chalk": "^5.4.1",
|
|
35
|
+
"commander": "^14.0.0",
|
|
36
|
+
"ora": "^9.3.0",
|
|
37
|
+
"update-notifier": "^7.3.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.3.0",
|
|
41
|
+
"@types/update-notifier": "^6.0.8",
|
|
42
|
+
"tsup": "^8.5.0",
|
|
43
|
+
"typescript": "^5",
|
|
44
|
+
"@grepper/config": "0.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup",
|
|
48
|
+
"dev": "tsup --watch",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|