codeant-cli 0.5.10 → 0.5.12

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 CHANGED
@@ -16,8 +16,11 @@ For installation, authentication, and self-hosted setup, see the [CodeAnt CLI se
16
16
  # Login to CodeAnt
17
17
  codeant login
18
18
 
19
- # Scan staged files for secrets
19
+ # Scan your changes for secrets
20
20
  codeant secrets
21
+
22
+ # Review your changes
23
+ codeant review
21
24
  ```
22
25
 
23
26
  ## Usage
@@ -56,10 +59,14 @@ codeant secrets [options]
56
59
 
57
60
  | Option | Description |
58
61
  |--------|-------------|
59
- | `--staged` | Scan only staged files (default) |
60
- | `--all` | Scan all changed files compared to base branch |
61
- | `--uncommitted` | Scan all uncommitted changes |
62
+ | `--all` | Scan committed (unpushed) + uncommitted changes, including untracked files (default) |
63
+ | `--committed` | Scan only unpushed commits |
64
+ | `--uncommitted` | Scan staged + unstaged + untracked changes |
65
+ | `--staged` | Scan only staged files |
62
66
  | `--last-commit` | Scan files from the last commit |
67
+ | `--last-n-commits <n>` | Scan files from the last n commits (max 5) |
68
+ | `--base <branch>` | Scan committed + uncommitted + untracked changes since the merge-base with `<branch>` |
69
+ | `--base-commit <commit>` | Scan committed + uncommitted + untracked changes since `<commit>` |
63
70
  | `--fail-on <level>` | Fail only on HIGH, MEDIUM, or all (default: HIGH) |
64
71
  | `--include <patterns>` | Comma-separated glob patterns to include files |
65
72
  | `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
@@ -67,11 +74,11 @@ codeant secrets [options]
67
74
  **Examples:**
68
75
 
69
76
  ```bash
70
- # Scan staged files (default)
77
+ # Scan all changed files (default)
71
78
  codeant secrets
72
79
 
73
- # Scan all changed files
74
- codeant secrets --all
80
+ # Scan only staged files
81
+ codeant secrets --staged
75
82
 
76
83
  # Scan last commit
77
84
  codeant secrets --last-commit
@@ -108,6 +115,46 @@ Use `--include` and `--exclude` with glob patterns to filter files:
108
115
  - `MEDIUM` - Medium confidence, may need review
109
116
  - `FALSE_POSITIVE` - Detected but likely not a real secret (always ignored)
110
117
 
118
+ #### `review`
119
+
120
+ Run an AI code review on your local changes.
121
+
122
+ ```bash
123
+ codeant review [options]
124
+ ```
125
+
126
+ **Options:**
127
+
128
+ | Option | Description |
129
+ |--------|-------------|
130
+ | `--all` | Review committed (unpushed) + uncommitted changes, including untracked files (default) |
131
+ | `--committed` | Review only unpushed commits |
132
+ | `--uncommitted` | Review staged + unstaged + untracked changes |
133
+ | `--staged` | Review only staged files |
134
+ | `--last-commit` | Review the last commit |
135
+ | `--last-n-commits <n>` | Review the last n commits (max 5) |
136
+ | `--base <branch>` | Review committed + uncommitted + untracked changes since the merge-base with `<branch>` |
137
+ | `--base-commit <commit>` | Review committed + uncommitted + untracked changes since `<commit>` |
138
+ | `--fail-on <level>` | Fail on issues at or above BLOCKER, CRITICAL, MAJOR, MINOR, or INFO (default: CRITICAL) |
139
+ | `--include <patterns>` | Comma-separated glob patterns to include files |
140
+ | `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
141
+ | `--headless` | Print JSON with no spinners (for agents and CI) |
142
+
143
+ `--base` and `--base-commit` take precedence over the other scope flags. They compare the base against your working tree, so staged, unstaged, and untracked files are reviewed along with commits. Files ignored by `.gitignore` are never reviewed. The same applies to `secrets`.
144
+
145
+ **Examples:**
146
+
147
+ ```bash
148
+ # Review everything on your branch since it left main, including uncommitted and new files
149
+ codeant review --base main
150
+
151
+ # Review only uncommitted work
152
+ codeant review --uncommitted
153
+
154
+ # CI: fail on MAJOR or worse, JSON output
155
+ codeant review --base main --fail-on MAJOR --headless
156
+ ```
157
+
111
158
  #### `set-base-url <url>`
112
159
 
113
160
  Set a custom API base URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "repository": {
@@ -12,6 +12,7 @@ export default function Login() {
12
12
  const [status, setStatus] = useState('opening');
13
13
  const [error, setError] = useState(null);
14
14
  const [loginUrl, setLoginUrl] = useState('');
15
+ const [pollResults, setPollResults] = useState([]);
15
16
 
16
17
  useEffect(() => {
17
18
  // Check if already logged in
@@ -25,9 +26,11 @@ export default function Login() {
25
26
  const token = `cli___${randomUUID()}`;
26
27
  const baseUrl = getBaseUrl();
27
28
  const pollUrl = `${baseUrl}/extension/login/status?apiKey=${token}`;
29
+ const displayPollUrl = `${baseUrl}/extension/login/status?apiKey=[redacted]`;
28
30
 
29
31
  let intervalId;
30
32
  let timeoutId;
33
+ let pollCount = 0;
31
34
 
32
35
  (async () => {
33
36
  let dashboardUrl;
@@ -54,11 +57,14 @@ export default function Login() {
54
57
 
55
58
  // Poll for login status
56
59
  intervalId = setInterval(async () => {
60
+ const attempt = ++pollCount;
61
+ let response;
57
62
  try {
58
- const response = await fetch(pollUrl);
63
+ response = await fetch(pollUrl);
59
64
  const data = await response.json();
65
+ setPollResults((results) => [...results, `Check ${attempt}: GET ${displayPollUrl} → HTTP ${response.status}, status: ${data?.status ?? 'missing'}`]);
60
66
 
61
- if (data.status === 'yes') {
67
+ if (data?.status === 'yes') {
62
68
  clearInterval(intervalId);
63
69
  clearTimeout(timeoutId);
64
70
 
@@ -69,7 +75,8 @@ export default function Login() {
69
75
  setTimeout(() => exit(), 100);
70
76
  }
71
77
  } catch (err) {
72
- // Silently continue polling
78
+ const code = err.cause?.code || err.code || err.name;
79
+ setPollResults((results) => [...results, `Check ${attempt}: GET ${displayPollUrl} → ${response ? `HTTP ${response.status}, ` : ''}error: ${code}`]);
73
80
  }
74
81
  }, POLL_INTERVAL);
75
82
 
@@ -89,6 +96,10 @@ export default function Login() {
89
96
  };
90
97
  }, []);
91
98
 
99
+ const pollLines = pollResults.map((result, index) =>
100
+ React.createElement(Text, { color: 'gray', key: index }, result)
101
+ );
102
+
92
103
  if (status === 'already_logged_in') {
93
104
  return React.createElement(
94
105
  Box,
@@ -112,7 +123,8 @@ export default function Login() {
112
123
  { flexDirection: 'column', padding: 1 },
113
124
  React.createElement(Text, { color: 'cyan' }, `Waiting for login. Open this URL in your browser: ${loginUrl}`),
114
125
  React.createElement(Text, { color: 'gray' }, 'Complete the login in your browser.'),
115
- React.createElement(Text, { color: 'gray' }, 'Checking every 10 seconds. Timeout in 10 minutes.')
126
+ React.createElement(Text, { color: 'gray' }, 'Checking every 10 seconds. Timeout in 10 minutes.'),
127
+ ...pollLines
116
128
  );
117
129
  }
118
130
 
@@ -120,7 +132,8 @@ export default function Login() {
120
132
  return React.createElement(
121
133
  Box,
122
134
  { flexDirection: 'column', padding: 1 },
123
- React.createElement(Text, { color: 'green' }, '✓ Login successful!')
135
+ React.createElement(Text, { color: 'green' }, '✓ Login successful!'),
136
+ ...pollLines
124
137
  );
125
138
  }
126
139
 
@@ -128,7 +141,8 @@ export default function Login() {
128
141
  return React.createElement(
129
142
  Box,
130
143
  { flexDirection: 'column', padding: 1 },
131
- React.createElement(Text, { color: 'red' }, '✗ ', error)
144
+ React.createElement(Text, { color: 'red' }, '✗ ', error),
145
+ ...pollLines
132
146
  );
133
147
  }
134
148
 
package/src/index.js CHANGED
@@ -72,8 +72,8 @@ program
72
72
  .option('--staged', 'Scan only staged files')
73
73
  .option('--last-commit', 'Scan last commit')
74
74
  .option('--last-n-commits <n>', 'Scan last n commits (max 5)', toCount)
75
- .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
76
- .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
75
+ .option('--base <branch>', 'Scan committed + uncommitted + untracked changes since the merge-base with a branch (e.g. --base develop). Takes precedence over the other scope flags')
76
+ .option('--base-commit <commit>', 'Scan committed + uncommitted + untracked changes since a commit (e.g. --base-commit HEAD~3). Takes precedence over the other scope flags')
77
77
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
78
78
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
79
79
  .option('--hook', 'Running from pre-commit hook (enables bypass prompt)')
@@ -83,6 +83,8 @@ program
83
83
  let baseBranch = null;
84
84
  let baseCommit = null;
85
85
 
86
+ // Precedence: --base > --base-commit > other scope flags. The base modes already diff
87
+ // against the working tree, so they include uncommitted and untracked changes too.
86
88
  if (options.base) {
87
89
  scanType = 'base-branch';
88
90
  baseBranch = options.base;
@@ -123,8 +125,8 @@ program
123
125
  .option('--staged', 'Review only staged files')
124
126
  .option('--last-commit', 'Review last commit')
125
127
  .option('--last-n-commits <n>', 'Review last n commits (max 5)', toCount)
126
- .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
127
- .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
128
+ .option('--base <branch>', 'Review committed + uncommitted + untracked changes since the merge-base with a branch (e.g. --base develop). Takes precedence over the other scope flags')
129
+ .option('--base-commit <commit>', 'Review committed + uncommitted + untracked changes since a commit (e.g. --base-commit HEAD~3). Takes precedence over the other scope flags')
128
130
  .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
129
131
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
130
132
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
@@ -135,6 +137,8 @@ program
135
137
  let baseBranch = null;
136
138
  let baseCommit = null;
137
139
 
140
+ // Precedence: --base > --base-commit > other scope flags. The base modes already diff
141
+ // against the working tree, so they include uncommitted and untracked changes too.
138
142
  if (options.base) {
139
143
  scanType = 'base-branch';
140
144
  baseBranch = options.base;
package/src/mcp/server.js CHANGED
@@ -639,8 +639,8 @@ export function createMcpServer() {
639
639
  .optional()
640
640
  .describe('Review scope. Default "uncommitted".'),
641
641
  lastNCommits: z.number().int().positive().max(5).optional(),
642
- baseBranch: z.string().optional(),
643
- baseCommit: z.string().optional(),
642
+ baseBranch: z.string().optional().describe('Branch for scope "base-branch". Reviews committed, uncommitted and untracked changes since the merge-base with this branch.'),
643
+ baseCommit: z.string().optional().describe('Commit for scope "base-commit". Reviews committed, uncommitted and untracked changes since this commit.'),
644
644
  include: z.array(z.string()).optional().describe('Glob patterns to include.'),
645
645
  exclude: z.array(z.string()).optional().describe('Glob patterns to exclude.'),
646
646
  },
@@ -460,41 +460,50 @@ class GitDiffHelper {
460
460
 
461
461
  // Handle untracked files by generating synthetic "new file" diffs since git diff can't see them
462
462
  for (const file of expandedUntrackedFiles) {
463
- try {
464
- const absolutePath = path.resolve(this.gitRoot, file);
465
- const content = await fs.readFile(absolutePath, 'utf8');
466
- const lines = content.split('\n');
467
-
468
- let patchStr = `diff --git a/${file} b/${file}\n`;
469
- patchStr += `new file mode 100644\n`;
470
- patchStr += `index 0000000..${this._generateRandomHash()}\n`;
471
- patchStr += `--- /dev/null\n`;
472
- patchStr += `+++ b/${file}\n`;
473
- patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
474
- lines.forEach(line => { patchStr += `+${line}\n`; });
475
-
476
- diffs.push({
477
- base_file_str: '',
478
- head_file_str: content,
479
- patch_str: patchStr,
480
- filename_str: file,
481
- edit_type_str: 'ADDED',
482
- old_filename_str: null,
483
- num_plus_lines_str: String(lines.length),
484
- num_minus_lines_str: '0',
485
- tokens_str: String(content.split(/\s+/).length),
486
- start_line_str: '1',
487
- end_line_str: String(lines.length),
488
- });
489
- } catch (error) {
490
- // Skip files that can't be read
491
- }
463
+ const diff = await this._buildUntrackedFileDiff(file);
464
+ if (diff) diffs.push(diff);
492
465
  }
493
466
 
494
467
  this.baseCommit = originalBaseCommit;
495
468
  return diffs;
496
469
  }
497
470
 
471
+ /**
472
+ * Build a synthetic "new file" diff for an untracked file, which git diff can't see.
473
+ * Returns null if the file can't be read.
474
+ */
475
+ async _buildUntrackedFileDiff(file) {
476
+ try {
477
+ const absolutePath = path.resolve(this.gitRoot, file);
478
+ const content = await fs.readFile(absolutePath, 'utf8');
479
+ const lines = content.split('\n');
480
+
481
+ let patchStr = `diff --git a/${file} b/${file}\n`;
482
+ patchStr += `new file mode 100644\n`;
483
+ patchStr += `index 0000000..${this._generateRandomHash()}\n`;
484
+ patchStr += `--- /dev/null\n`;
485
+ patchStr += `+++ b/${file}\n`;
486
+ patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
487
+ lines.forEach(line => { patchStr += `+${line}\n`; });
488
+
489
+ return {
490
+ base_file_str: '',
491
+ head_file_str: content,
492
+ patch_str: patchStr,
493
+ filename_str: file,
494
+ edit_type_str: 'ADDED',
495
+ old_filename_str: null,
496
+ num_plus_lines_str: String(lines.length),
497
+ num_minus_lines_str: '0',
498
+ tokens_str: String(content.split(/\s+/).length),
499
+ start_line_str: '1',
500
+ end_line_str: String(lines.length),
501
+ };
502
+ } catch (error) {
503
+ return null;
504
+ }
505
+ }
506
+
498
507
  async _getStagedChanges() {
499
508
  const { stdout: stagedFiles } = await execAsync(
500
509
  'git diff --name-only --cached HEAD',
@@ -525,7 +534,8 @@ class GitDiffHelper {
525
534
 
526
535
  /**
527
536
  * Get diff against a specific base branch.
528
- * Finds the merge-base between the given branch and HEAD, then diffs from there.
537
+ * Finds the merge-base between the given branch and HEAD, then diffs it against the
538
+ * working tree (committed + staged + unstaged + untracked).
529
539
  */
530
540
  async _getBaseBranchDiff(branch) {
531
541
  if (!this.gitRoot) {
@@ -561,32 +571,12 @@ class GitDiffHelper {
561
571
  base = resolvedRef;
562
572
  }
563
573
 
564
- const { stdout: changedFiles } = await execAsync(
565
- `git diff --name-only ${base} HEAD`,
566
- { cwd: this.gitRoot }
567
- );
568
-
569
- const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
570
- if (files.length === 0) return [];
571
-
572
- const originalBaseCommit = this.baseCommit;
573
- try {
574
- this.baseCommit = base;
575
- this._headRef = 'HEAD';
576
-
577
- const diffs = await Promise.all(
578
- files.map(fp => this.getDiffInfoForFile(fp))
579
- );
580
- return diffs.flat();
581
- } finally {
582
- this.baseCommit = originalBaseCommit;
583
- this._headRef = null;
584
- }
574
+ return this._getWorkingTreeDiffAgainst(base);
585
575
  }
586
576
 
587
577
  /**
588
578
  * Get diff against a specific base commit.
589
- * Diffs from the given commit to HEAD.
579
+ * Diffs from the given commit to the working tree (committed + staged + unstaged + untracked).
590
580
  */
591
581
  async _getBaseCommitDiff(commit) {
592
582
  if (!this.gitRoot) {
@@ -605,23 +595,43 @@ class GitDiffHelper {
605
595
  throw new Error(`Could not resolve commit: ${commit}`);
606
596
  }
607
597
 
598
+ return this._getWorkingTreeDiffAgainst(resolvedCommit);
599
+ }
600
+
601
+ /**
602
+ * Diff a base commit against the working tree: commits since base, staged and unstaged
603
+ * changes (including staged new files), plus untracked files not ignored by .gitignore.
604
+ * Content is read from disk so it matches the patch, which getFileDiff also takes against
605
+ * the working tree.
606
+ */
607
+ async _getWorkingTreeDiffAgainst(base) {
608
+ // No HEAD argument: compares base to the working tree, which covers tracked files that
609
+ // were committed, staged or edited since base
608
610
  const { stdout: changedFiles } = await execAsync(
609
- `git diff --name-only ${resolvedCommit} HEAD`,
611
+ `git diff --name-only ${base}`,
612
+ { cwd: this.gitRoot }
613
+ );
614
+ const { stdout: untrackedStr } = await execAsync(
615
+ 'git ls-files --others --exclude-standard',
610
616
  { cwd: this.gitRoot }
611
617
  );
612
618
 
613
619
  const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
614
- if (files.length === 0) return [];
620
+ const untrackedFiles = untrackedStr.split('\n').map(f => f.trim()).filter(Boolean);
621
+ if (files.length === 0 && untrackedFiles.length === 0) return [];
615
622
 
616
623
  const originalBaseCommit = this.baseCommit;
617
624
  try {
618
- this.baseCommit = resolvedCommit;
619
- this._headRef = 'HEAD';
625
+ this.baseCommit = base;
626
+ this._headRef = null;
620
627
 
621
628
  const diffs = await Promise.all(
622
629
  files.map(fp => this.getDiffInfoForFile(fp))
623
630
  );
624
- return diffs.flat();
631
+ const untrackedDiffs = await Promise.all(
632
+ untrackedFiles.map(fp => this._buildUntrackedFileDiff(fp))
633
+ );
634
+ return [...diffs.flat(), ...untrackedDiffs.filter(Boolean)];
625
635
  } finally {
626
636
  this.baseCommit = originalBaseCommit;
627
637
  this._headRef = null;
@@ -762,35 +772,8 @@ class GitDiffHelper {
762
772
 
763
773
  // Handle untracked files with synthetic diffs
764
774
  for (const file of expandedUntrackedFiles) {
765
- try {
766
- const absolutePath = path.resolve(this.gitRoot, file);
767
- const content = await fs.readFile(absolutePath, 'utf8');
768
- const lines = content.split('\n');
769
-
770
- let patchStr = `diff --git a/${file} b/${file}\n`;
771
- patchStr += `new file mode 100644\n`;
772
- patchStr += `index 0000000..${this._generateRandomHash()}\n`;
773
- patchStr += `--- /dev/null\n`;
774
- patchStr += `+++ b/${file}\n`;
775
- patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
776
- lines.forEach(line => { patchStr += `+${line}\n`; });
777
-
778
- diffs.push({
779
- base_file_str: '',
780
- head_file_str: content,
781
- patch_str: patchStr,
782
- filename_str: file,
783
- edit_type_str: 'ADDED',
784
- old_filename_str: null,
785
- num_plus_lines_str: String(lines.length),
786
- num_minus_lines_str: '0',
787
- tokens_str: String(content.split(/\s+/).length),
788
- start_line_str: '1',
789
- end_line_str: String(lines.length),
790
- });
791
- } catch (err) {
792
- // skip
793
- }
775
+ const diff = await this._buildUntrackedFileDiff(file);
776
+ if (diff) diffs.push(diff);
794
777
  }
795
778
  } finally {
796
779
  this.baseCommit = originalBaseCommit;