korekt-cli 0.11.2 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korekt-cli",
3
- "version": "0.11.2",
3
+ "version": "0.13.1",
4
4
  "description": "AI-powered code review CLI - Keep your kode korekt",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -44,13 +44,3 @@ export function getApiEndpoint() {
44
44
  export function setApiEndpoint(endpoint) {
45
45
  config.set('apiEndpoint', endpoint);
46
46
  }
47
-
48
- /**
49
- * Get all configuration
50
- */
51
- export function getConfig() {
52
- return {
53
- apiKey: getApiKey(),
54
- apiEndpoint: getApiEndpoint(),
55
- };
56
- }
package/src/formatter.js CHANGED
@@ -76,7 +76,7 @@ function toAbsolutePath(filePath) {
76
76
  * @param {Object} data - The API response data
77
77
  */
78
78
  export function formatReviewOutput(data) {
79
- const { review, summary, change_classification: changeClassification } = data.data;
79
+ const { review, summary, change_classification: changeClassification, model } = data.data;
80
80
 
81
81
  console.log(chalk.bold.blue('🤖 Automated Code Review Results\n'));
82
82
 
@@ -162,4 +162,9 @@ export function formatReviewOutput(data) {
162
162
  console.log(); // Add a blank line for spacing
163
163
  });
164
164
  }
165
+
166
+ // Footer with model info
167
+ if (model) {
168
+ console.log(chalk.gray(`Analyzed with ${model}`));
169
+ }
165
170
  }
package/src/index.js CHANGED
@@ -72,6 +72,75 @@ async function confirmAction(message) {
72
72
  });
73
73
  }
74
74
 
75
+ /**
76
+ * Handle skipped response from API (when integration mode prevents CLI review)
77
+ * @param {Object} response - Axios response object
78
+ * @param {Object} options - Command options (json flag, etc.)
79
+ * @param {Object} spinner - Ora spinner instance
80
+ * @returns {boolean} - True if response was skipped, false otherwise
81
+ */
82
+ export function handleSkippedResponse(response, options, spinner) {
83
+ if (response.data.skipped) {
84
+ spinner.info(response.data.message || 'Review skipped.');
85
+ if (options.json) {
86
+ output(JSON.stringify(response.data, null, 2));
87
+ }
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Available Gemini models for code review
95
+ */
96
+ const GEMINI_MODELS = [
97
+ { value: 'gemini-2.5-pro', label: 'gemini-2.5-pro (high quality)' },
98
+ { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash (avoid, the worst quality)' },
99
+ { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview (experimental - the best model)' },
100
+ {
101
+ value: 'gemini-3-flash-preview',
102
+ label: 'gemini-3-flash-preview (experimental - the most efficient model)',
103
+ },
104
+ ];
105
+
106
+ /**
107
+ * Prompt user to select a Gemini model
108
+ * @returns {Promise<string>} - Selected model name
109
+ * @throws {Error} - If not running in interactive terminal
110
+ */
111
+ async function selectModel() {
112
+ // Check if running in interactive terminal
113
+ if (!process.stdin.isTTY) {
114
+ log(chalk.red('Error: --model flag requires a value in non-interactive environments.'));
115
+ log(chalk.gray('Example: kk review --model=gemini-2.5-pro'));
116
+ process.exit(1);
117
+ }
118
+
119
+ const rl = readline.createInterface({
120
+ input: process.stdin,
121
+ output: process.stderr,
122
+ });
123
+
124
+ log(chalk.yellow('\nSelect Gemini model:\n'));
125
+ GEMINI_MODELS.forEach((model, index) => {
126
+ log(` ${index + 1}. ${model.label}`);
127
+ });
128
+ log('');
129
+
130
+ return new Promise((resolvePromise) => {
131
+ rl.question(chalk.bold('Enter number (1-4): '), (answer) => {
132
+ rl.close();
133
+ const num = parseInt(answer, 10);
134
+ if (num >= 1 && num <= GEMINI_MODELS.length) {
135
+ resolvePromise(GEMINI_MODELS[num - 1].value);
136
+ } else {
137
+ log(chalk.red('Invalid selection. Please run the command again and enter 1-4.'));
138
+ process.exit(1);
139
+ }
140
+ });
141
+ });
142
+ }
143
+
75
144
  /**
76
145
  * Run the CI integration script to post comments
77
146
  * @param {string} provider - CI provider (github, azure, bitbucket)
@@ -128,6 +197,8 @@ program
128
197
  Examples:
129
198
  $ kk review Review committed changes (auto-detect base)
130
199
  $ kk review main Review changes against main branch
200
+ $ kk review --model Select Gemini model interactively
201
+ $ kk review -m gemini-2.5-pro Use specific model
131
202
  $ kk stg --dry-run Preview staged changes review
132
203
  $ kk diff Review unstaged changes
133
204
  $ kk review main --json Output raw JSON (for CI/CD integration)
@@ -137,6 +208,7 @@ Common Options:
137
208
  --dry-run Show payload without sending to API
138
209
  --json Output raw API response as JSON
139
210
  --comment Post review results as PR comments
211
+ -m, --model [name] Gemini model (picker if no value)
140
212
 
141
213
  Configuration:
142
214
  $ kk config --key YOUR_KEY
@@ -159,6 +231,7 @@ program
159
231
  .option('--json', 'Output raw API response as JSON')
160
232
  .option('--comment', 'Post review results as PR comments (auto-detects CI provider)')
161
233
  .option('--post-ticket', 'Post review results to linked ticket (e.g., JIRA)')
234
+ .option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
162
235
  .action(async (targetBranch, options) => {
163
236
  const reviewTarget = targetBranch ? `against '${targetBranch}'` : '(auto-detecting fork point)';
164
237
 
@@ -179,6 +252,15 @@ program
179
252
  process.exit(1);
180
253
  }
181
254
 
255
+ // Handle model selection: if --model without value, show picker
256
+ let selectedModel = null;
257
+ if (options.model === true) {
258
+ selectedModel = await selectModel();
259
+ log(chalk.green(`Using model: ${selectedModel}\n`));
260
+ } else if (typeof options.model === 'string') {
261
+ selectedModel = options.model;
262
+ }
263
+
182
264
  // Fetch file rules config from API
183
265
  const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
184
266
 
@@ -206,6 +288,11 @@ program
206
288
  changed_files: payload.changed_files.map((file) => truncateFileData(file)),
207
289
  };
208
290
 
291
+ // Add model if specified
292
+ if (selectedModel) {
293
+ displayPayload.model = selectedModel;
294
+ }
295
+
209
296
  log(JSON.stringify(displayPayload, null, 2));
210
297
  log(chalk.gray('\n💡 Run without --dry-run to send to API'));
211
298
  log(chalk.gray('💡 Diffs and content are truncated in dry-run for readability'));
@@ -255,6 +342,11 @@ program
255
342
  payload.post_to_ticket = true;
256
343
  }
257
344
 
345
+ // Add model if specified
346
+ if (selectedModel) {
347
+ payload.model = selectedModel;
348
+ }
349
+
258
350
  try {
259
351
  const response = await axios.post(apiEndpoint, payload, {
260
352
  headers: {
@@ -265,6 +357,12 @@ program
265
357
 
266
358
  clearInterval(timer);
267
359
  const elapsed = Math.floor((Date.now() - startTime) / 1000);
360
+
361
+ // Handle skipped response (integration mode prevents CLI review)
362
+ if (handleSkippedResponse(response, options, spinner)) {
363
+ return;
364
+ }
365
+
268
366
  spinner.succeed(`Review completed in ${elapsed}s!`);
269
367
 
270
368
  // Handle --comment flag: post results to PR
@@ -328,6 +426,7 @@ program
328
426
  .description('Review staged changes (git diff --cached)')
329
427
  .option('--dry-run', 'Show payload without sending to API')
330
428
  .option('--json', 'Output raw API response as JSON')
429
+ .option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
331
430
  .action(async (options) => {
332
431
  log(chalk.blue.bold('🚀 Reviewing staged changes...'));
333
432
  await reviewUncommitted('staged', options);
@@ -339,6 +438,7 @@ program
339
438
  .description('Review unstaged changes (git diff)')
340
439
  .option('--dry-run', 'Show payload without sending to API')
341
440
  .option('--json', 'Output raw API response as JSON')
441
+ .option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
342
442
  .action(async (options) => {
343
443
  log(chalk.blue.bold('🚀 Reviewing unstaged changes...'));
344
444
  await reviewUncommitted('unstaged', options);
@@ -359,6 +459,15 @@ async function reviewUncommitted(mode, options) {
359
459
  process.exit(1);
360
460
  }
361
461
 
462
+ // Handle model selection: if --model without value, show picker
463
+ let selectedModel = null;
464
+ if (options.model === true) {
465
+ selectedModel = await selectModel();
466
+ log(chalk.green(`Using model: ${selectedModel}\n`));
467
+ } else if (typeof options.model === 'string') {
468
+ selectedModel = options.model;
469
+ }
470
+
362
471
  // Fetch file rules config from API
363
472
  const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
364
473
 
@@ -378,6 +487,11 @@ async function reviewUncommitted(mode, options) {
378
487
  changed_files: payload.changed_files.map((file) => truncateFileData(file)),
379
488
  };
380
489
 
490
+ // Add model if specified
491
+ if (selectedModel) {
492
+ displayPayload.model = selectedModel;
493
+ }
494
+
381
495
  log(JSON.stringify(displayPayload, null, 2));
382
496
  log(chalk.gray('\n💡 Run without --dry-run to send to API'));
383
497
  log(chalk.gray('💡 Diffs and content are truncated in dry-run for readability'));
@@ -410,6 +524,11 @@ async function reviewUncommitted(mode, options) {
410
524
  }
411
525
  }
412
526
 
527
+ // Add model if specified
528
+ if (selectedModel) {
529
+ payload.model = selectedModel;
530
+ }
531
+
413
532
  const spinner = ora('Submitting review to the AI...').start();
414
533
  const startTime = Date.now();
415
534
 
@@ -428,6 +547,12 @@ async function reviewUncommitted(mode, options) {
428
547
 
429
548
  clearInterval(timer);
430
549
  const elapsed = Math.floor((Date.now() - startTime) / 1000);
550
+
551
+ // Handle skipped response (integration mode prevents CLI review)
552
+ if (handleSkippedResponse(response, options, spinner)) {
553
+ return;
554
+ }
555
+
431
556
  spinner.succeed(`Review completed in ${elapsed}s!`);
432
557
 
433
558
  // Output results to stdout
package/src/index.test.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { truncateFileData, formatErrorOutput, detectCIProvider, getPrUrl } from './index.js';
2
+ import {
3
+ truncateFileData,
4
+ formatErrorOutput,
5
+ detectCIProvider,
6
+ getPrUrl,
7
+ handleSkippedResponse,
8
+ } from './index.js';
3
9
 
4
10
  describe('CLI JSON output mode', () => {
5
11
  let stdoutSpy;
@@ -469,6 +475,194 @@ describe('--comment flag behavior', () => {
469
475
  });
470
476
  });
471
477
 
478
+ describe('--model flag behavior', () => {
479
+ describe('model selection logic', () => {
480
+ it('should treat --model without value as true (picker mode)', () => {
481
+ const options = { model: true };
482
+
483
+ // When --model is used without a value, Commander sets it to true
484
+ const shouldShowPicker = options.model === true;
485
+ const hasDirectValue = typeof options.model === 'string';
486
+
487
+ expect(shouldShowPicker).toBe(true);
488
+ expect(hasDirectValue).toBe(false);
489
+ });
490
+
491
+ it('should treat --model=value as string (direct mode)', () => {
492
+ const options = { model: 'gemini-2.5-flash' };
493
+
494
+ const shouldShowPicker = options.model === true;
495
+ const hasDirectValue = typeof options.model === 'string';
496
+
497
+ expect(shouldShowPicker).toBe(false);
498
+ expect(hasDirectValue).toBe(true);
499
+ expect(options.model).toBe('gemini-2.5-flash');
500
+ });
501
+
502
+ it('should not include model when flag is not used', () => {
503
+ const options = {};
504
+
505
+ const shouldIncludeModel = options.model !== undefined;
506
+
507
+ expect(shouldIncludeModel).toBe(false);
508
+ });
509
+ });
510
+
511
+ describe('payload model field', () => {
512
+ it('should add model to payload when specified', () => {
513
+ const payload = {
514
+ repo_url: 'https://github.com/user/repo',
515
+ changed_files: [],
516
+ };
517
+
518
+ const selectedModel = 'gemini-2.5-flash';
519
+ if (selectedModel) {
520
+ payload.model = selectedModel;
521
+ }
522
+
523
+ expect(payload.model).toBe('gemini-2.5-flash');
524
+ });
525
+
526
+ it('should not add model to payload when not specified', () => {
527
+ const payload = {
528
+ repo_url: 'https://github.com/user/repo',
529
+ changed_files: [],
530
+ };
531
+
532
+ const selectedModel = null;
533
+ if (selectedModel) {
534
+ payload.model = selectedModel;
535
+ }
536
+
537
+ expect(payload.model).toBeUndefined();
538
+ });
539
+ });
540
+
541
+ describe('available models', () => {
542
+ it('should have valid model values', () => {
543
+ const validModels = [
544
+ 'gemini-2.5-pro',
545
+ 'gemini-2.5-flash',
546
+ 'gemini-3-pro-preview',
547
+ 'gemini-3-flash-preview',
548
+ ];
549
+
550
+ // Test that all expected models are valid Gemini model names
551
+ validModels.forEach((model) => {
552
+ expect(model).toMatch(/^gemini-/);
553
+ });
554
+ });
555
+ });
556
+ });
557
+
558
+ describe('skipped response handling', () => {
559
+ let stdoutSpy;
560
+
561
+ beforeEach(() => {
562
+ stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
563
+ });
564
+
565
+ afterEach(() => {
566
+ vi.restoreAllMocks();
567
+ });
568
+
569
+ it('should return true and call spinner.info when response is skipped', () => {
570
+ const response = {
571
+ data: {
572
+ skipped: true,
573
+ reason: 'webhook_mode_active',
574
+ message: 'Review skipped due to webhook mode.',
575
+ },
576
+ };
577
+ const options = { json: false };
578
+ const spinner = { info: vi.fn() };
579
+
580
+ const result = handleSkippedResponse(response, options, spinner);
581
+
582
+ expect(result).toBe(true);
583
+ expect(spinner.info).toHaveBeenCalledWith('Review skipped due to webhook mode.');
584
+ });
585
+
586
+ it('should return false when response is not skipped', () => {
587
+ const response = {
588
+ data: {
589
+ review: { issues: [], praises: [] },
590
+ summary: { total_issues: 0 },
591
+ },
592
+ };
593
+ const options = { json: false };
594
+ const spinner = { info: vi.fn() };
595
+
596
+ const result = handleSkippedResponse(response, options, spinner);
597
+
598
+ expect(result).toBe(false);
599
+ expect(spinner.info).not.toHaveBeenCalled();
600
+ });
601
+
602
+ it('should output JSON to stdout when json option is true and response is skipped', () => {
603
+ const response = {
604
+ data: {
605
+ skipped: true,
606
+ reason: 'local_reviews_disabled',
607
+ message: 'Local reviews disabled.',
608
+ },
609
+ };
610
+ const options = { json: true };
611
+ const spinner = { info: vi.fn() };
612
+
613
+ handleSkippedResponse(response, options, spinner);
614
+
615
+ expect(stdoutSpy).toHaveBeenCalledWith(JSON.stringify(response.data, null, 2) + '\n');
616
+ });
617
+
618
+ it('should not output JSON when json option is false', () => {
619
+ const response = {
620
+ data: {
621
+ skipped: true,
622
+ reason: 'reviews_disabled',
623
+ message: 'Reviews disabled.',
624
+ },
625
+ };
626
+ const options = { json: false };
627
+ const spinner = { info: vi.fn() };
628
+
629
+ handleSkippedResponse(response, options, spinner);
630
+
631
+ expect(stdoutSpy).not.toHaveBeenCalled();
632
+ });
633
+
634
+ it('should use default message when response message is empty', () => {
635
+ const response = {
636
+ data: {
637
+ skipped: true,
638
+ reason: 'webhook_mode_active',
639
+ },
640
+ };
641
+ const options = { json: false };
642
+ const spinner = { info: vi.fn() };
643
+
644
+ handleSkippedResponse(response, options, spinner);
645
+
646
+ expect(spinner.info).toHaveBeenCalledWith('Review skipped.');
647
+ });
648
+
649
+ it('should handle all skip reason types', () => {
650
+ const reasons = ['webhook_mode_active', 'reviews_disabled', 'local_reviews_disabled'];
651
+ const spinner = { info: vi.fn() };
652
+
653
+ reasons.forEach((reason) => {
654
+ const response = {
655
+ data: { skipped: true, reason, message: `Skipped: ${reason}` },
656
+ };
657
+
658
+ const result = handleSkippedResponse(response, { json: false }, spinner);
659
+ expect(result).toBe(true);
660
+ });
661
+
662
+ expect(spinner.info).toHaveBeenCalledTimes(3);
663
+ });
664
+ });
665
+
472
666
  describe('getPrUrl', () => {
473
667
  const originalEnv = process.env;
474
668