codeant-cli 0.2.3 → 0.2.5

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": "codeant-cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,34 +1,42 @@
1
- import React, { useState, useEffect } from 'react';
2
- import { Text, Box, useApp } from 'ink';
1
+ import { useState, useEffect } from 'react';
2
+ import { useApp } from 'ink';
3
3
  import { getConfigValue } from '../utils/config.js';
4
4
  import { fetchApi } from '../utils/fetchApi.js';
5
5
  import SecretsApiHelper from '../utils/secretsApiHelper.js';
6
-
7
- export default function Secrets({ scanType = 'staged-only', failOn = 'HIGH', include = [], exclude = [], lastNCommits = 1 }) {
6
+ import {
7
+ renderInitializing,
8
+ renderFetchingDiff,
9
+ renderScanning,
10
+ renderNoFiles,
11
+ renderError,
12
+ renderNotLoggedIn,
13
+ renderDone,
14
+ } from '../components/SecretsUI.js';
15
+
16
+ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
8
17
  const { exit } = useApp();
9
18
  const [status, setStatus] = useState('initializing');
10
19
  const [secrets, setSecrets] = useState([]);
11
20
  const [error, setError] = useState(null);
12
21
  const [fileCount, setFileCount] = useState(0);
22
+ const [scanMeta, setScanMeta] = useState(null);
23
+ const [startTime] = useState(() => Date.now());
13
24
 
14
25
  const apiKey = getConfigValue('apiKey');
15
26
 
16
27
  // Handle not logged in state
17
28
  useEffect(() => {
18
29
  if (!apiKey) {
30
+ if (process.stdin.isTTY) {
31
+ return;
32
+ }
19
33
  const id = setTimeout(() => exit(new Error('Not logged in')), 0);
20
34
  return () => clearTimeout(id);
21
35
  }
22
36
  }, [apiKey, exit]);
23
37
 
24
- // Check if logged in
25
38
  if (!apiKey) {
26
- return React.createElement(
27
- Box,
28
- { flexDirection: 'column', padding: 1 },
29
- React.createElement(Text, { color: 'red' }, '✗ Not logged in.'),
30
- React.createElement(Text, { color: 'gray' }, 'Run "codeant login" to authenticate.')
31
- );
39
+ return renderNotLoggedIn();
32
40
  }
33
41
 
34
42
  // Helper to check if a secret should cause failure based on failOn level
@@ -46,21 +54,30 @@ export default function Secrets({ scanType = 'staged-only', failOn = 'HIGH', inc
46
54
  async function scanSecrets() {
47
55
  try {
48
56
  if (cancelled) return;
49
- setStatus('scanning');
57
+ setStatus('fetching_diff');
50
58
 
51
- // Initialize git helper and get staged files
59
+ // Initialize git helper and get files
52
60
  const helper = new SecretsApiHelper(process.cwd());
53
61
  await helper.init();
54
62
 
55
63
  if (cancelled) return;
56
- const requestBody = await helper.buildSecretsApiRequest(scanType, include, exclude, { lastNCommits });
64
+ const requestBody = await helper.buildSecretsApiRequest(scanType, include, exclude, { lastNCommits, baseBranch, baseCommit });
57
65
  if (cancelled) return;
58
66
 
67
+ const meta = requestBody._meta || null;
68
+ setScanMeta(meta);
69
+
70
+ // Strip _meta before sending to API
71
+ delete requestBody._meta;
72
+
59
73
  if (requestBody.files.length === 0) {
60
74
  if (!cancelled) setStatus('no_files');
61
75
  return;
62
76
  }
63
77
 
78
+ setFileCount(requestBody.files.length);
79
+ setStatus('scanning');
80
+
64
81
  // Call the secrets detection API
65
82
  const response = await fetchApi(
66
83
  '/extension/pr-review/secrets-detection',
@@ -93,7 +110,7 @@ export default function Secrets({ scanType = 'staged-only', failOn = 'HIGH', inc
93
110
  return () => {
94
111
  cancelled = true;
95
112
  };
96
- }, [scanType, include, exclude, lastNCommits]);
113
+ }, [scanType, include, exclude, lastNCommits, baseBranch, baseCommit]);
97
114
 
98
115
  // Handle exit after status changes
99
116
  useEffect(() => {
@@ -118,151 +135,14 @@ export default function Secrets({ scanType = 'staged-only', failOn = 'HIGH', inc
118
135
  }
119
136
  }, [status, secrets]);
120
137
 
121
- // Render: Initializing
122
- if (status === 'initializing') {
123
- return React.createElement(
124
- Box,
125
- { flexDirection: 'column', padding: 1 },
126
- React.createElement(Text, { color: 'cyan' }, 'Initializing...')
127
- );
128
- }
129
-
130
- // Render: Scanning
131
- if (status === 'scanning') {
132
- return React.createElement(
133
- Box,
134
- { flexDirection: 'column', padding: 1 },
135
- React.createElement(Text, { color: 'cyan' }, `Scanning ${fileCount} file(s) for secrets...`)
136
- );
137
- }
138
-
139
- // Render: No files to scan
140
- if (status === 'no_files') {
141
- const noFilesMessages = {
142
- 'staged-only': 'Stage some changes first with "git add".',
143
- 'branch-diff': 'No changes found compared to the base branch.',
144
- 'uncommitted': 'No uncommitted changes found.',
145
- 'last-commit': 'No files found in the last commit.'
146
- };
147
-
148
- return React.createElement(
149
- Box,
150
- { flexDirection: 'column', padding: 1 },
151
- React.createElement(Text, { color: 'yellow' }, 'No files to scan.'),
152
- React.createElement(Text, { color: 'gray' }, noFilesMessages[scanType] || 'No files found.')
153
- );
154
- }
155
-
156
- // Render: Error
157
- if (status === 'error') {
158
- return React.createElement(
159
- Box,
160
- { flexDirection: 'column', padding: 1 },
161
- React.createElement(Text, { color: 'red' }, '✗ Error: ', error)
162
- );
163
- }
164
-
165
- // Render: Done with secrets found
166
- if (status === 'done' && secrets.length > 0) {
167
- const allSecrets = secrets.flatMap(file =>
168
- file.secrets.map(s => ({ ...s, file_path: file.file_path }))
169
- );
170
-
171
- const blockingSecrets = allSecrets.filter(s => shouldFailOn(s.confidence_score));
172
- const falsePositives = allSecrets.filter(s => !shouldFailOn(s.confidence_score));
173
-
174
- const hasBlockingSecrets = blockingSecrets.length > 0;
138
+ // ── Renders ──────────────────────────────────────────────────────────────
175
139
 
176
- // Group by file for display
177
- const groupByFile = (secretsList) => {
178
- const grouped = {};
179
- secretsList.forEach(s => {
180
- if (!grouped[s.file_path]) grouped[s.file_path] = [];
181
- grouped[s.file_path].push(s);
182
- });
183
- return grouped;
184
- };
185
-
186
- const blockingByFile = groupByFile(blockingSecrets);
187
- const falsePositivesByFile = groupByFile(falsePositives);
188
-
189
- const elements = [
190
- // Header
191
- React.createElement(
192
- Text,
193
- { key: 'header', color: hasBlockingSecrets ? 'red' : 'yellow', bold: true },
194
- hasBlockingSecrets
195
- ? `✗ ${blockingSecrets.length} secret(s) found!`
196
- : `⚠ ${falsePositives.length} potential secret(s) found (ignored)`
197
- ),
198
- React.createElement(Text, { key: 'spacer1' }, '')
199
- ];
200
-
201
- // Blocking secrets
202
- if (blockingSecrets.length > 0) {
203
- Object.entries(blockingByFile).forEach(([filePath, fileSecrets]) => {
204
- elements.push(
205
- React.createElement(Text, { key: `file-${filePath}`, color: 'yellow', bold: true }, filePath)
206
- );
207
- fileSecrets.forEach((secret, idx) => {
208
- elements.push(
209
- React.createElement(
210
- Text,
211
- { key: `secret-${filePath}-${idx}`, color: 'red' },
212
- ` Line ${secret.line_number}: ${secret.type} (${secret.confidence_score})`
213
- )
214
- );
215
- });
216
- });
217
- }
218
-
219
- // False positives / ignored
220
- if (falsePositives.length > 0) {
221
- if (blockingSecrets.length > 0) {
222
- elements.push(React.createElement(Text, { key: 'spacer2' }, ''));
223
- }
224
- elements.push(
225
- React.createElement(Text, { key: 'fp-header', color: 'gray' }, 'Ignored (false positives):')
226
- );
227
- Object.entries(falsePositivesByFile).forEach(([filePath, fileSecrets]) => {
228
- elements.push(
229
- React.createElement(Text, { key: `fp-file-${filePath}`, color: 'gray' }, ` ${filePath}`)
230
- );
231
- fileSecrets.forEach((secret, idx) => {
232
- elements.push(
233
- React.createElement(
234
- Text,
235
- { key: `fp-secret-${filePath}-${idx}`, color: 'gray', dimColor: true },
236
- ` Line ${secret.line_number}: ${secret.type} (${secret.confidence_score})`
237
- )
238
- );
239
- });
240
- });
241
- }
242
-
243
- elements.push(React.createElement(Text, { key: 'spacer3' }, ''));
244
-
245
- if (hasBlockingSecrets) {
246
- elements.push(
247
- React.createElement(Text, { key: 'footer', color: 'gray' }, 'Remove secrets before committing.')
248
- );
249
- } else {
250
- elements.push(
251
- React.createElement(Text, { key: 'footer', color: 'green' }, '✓ Commit allowed (only false positives found)')
252
- );
253
- }
254
-
255
- return React.createElement(
256
- Box,
257
- { flexDirection: 'column', padding: 1 },
258
- ...elements
259
- );
260
- }
140
+ if (status === 'initializing') return renderInitializing(startTime);
141
+ if (status === 'fetching_diff') return renderFetchingDiff(startTime);
142
+ if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
143
+ if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
144
+ if (status === 'error') return renderError(error);
145
+ if (status === 'done') return renderDone(secrets, failOn, shouldFailOn, startTime, fileCount, scanMeta);
261
146
 
262
- // Render: Done with no secrets
263
- return React.createElement(
264
- Box,
265
- { flexDirection: 'column', padding: 1 },
266
- React.createElement(Text, { color: 'green' }, '✓ No secrets found')
267
- );
147
+ return null;
268
148
  }
@@ -0,0 +1,366 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ // ── Constants ────────────────────────────────────────────────────────────────
5
+
6
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ const DIVIDER = '─'.repeat(55);
8
+ const STEPS = ['Init', 'Fetch', 'Scan'];
9
+
10
+ const CONFIDENCE_COLORS = {
11
+ HIGH: 'red',
12
+ MEDIUM: 'yellow',
13
+ LOW: 'cyan',
14
+ FALSE_POSITIVE: 'gray',
15
+ };
16
+
17
+ // ── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ function getConfidenceColor(score) {
20
+ return CONFIDENCE_COLORS[score?.toUpperCase()] || 'gray';
21
+ }
22
+
23
+ function padEnd(str, len) {
24
+ return str + ' '.repeat(Math.max(0, len - str.length));
25
+ }
26
+
27
+ function formatElapsed(startTime) {
28
+ return ((Date.now() - startTime) / 1000).toFixed(1) + 's';
29
+ }
30
+
31
+ // ── Animated Components ──────────────────────────────────────────────────────
32
+
33
+ function Spinner({ color = 'cyan' }) {
34
+ const [frame, setFrame] = useState(0);
35
+ useEffect(() => {
36
+ const timer = setInterval(() => {
37
+ setFrame(f => (f + 1) % SPINNER_FRAMES.length);
38
+ }, 80);
39
+ return () => clearInterval(timer);
40
+ }, []);
41
+ return React.createElement(Text, { color }, SPINNER_FRAMES[frame]);
42
+ }
43
+
44
+ function ElapsedTime({ startTime }) {
45
+ const [, setTick] = useState(0);
46
+ useEffect(() => {
47
+ const timer = setInterval(() => setTick(t => t + 1), 100);
48
+ return () => clearInterval(timer);
49
+ }, []);
50
+ return React.createElement(Text, { color: 'gray' }, formatElapsed(startTime));
51
+ }
52
+
53
+ function StepProgress({ currentStep }) {
54
+ const items = STEPS.map((step, i) => {
55
+ if (i < currentStep) {
56
+ return React.createElement(Text, { key: step, color: 'green' }, `✓ ${step}`);
57
+ } else if (i === currentStep) {
58
+ return React.createElement(
59
+ Box, { key: step },
60
+ React.createElement(Spinner, { color: 'cyan' }),
61
+ React.createElement(Text, { color: 'cyan' }, ` ${step}`),
62
+ );
63
+ } else {
64
+ return React.createElement(Text, { key: step, color: 'gray', dimColor: true }, `· ${step}`);
65
+ }
66
+ });
67
+ return React.createElement(Box, { gap: 2 }, ...items);
68
+ }
69
+
70
+ // ── Loading Screen ───────────────────────────────────────────────────────────
71
+
72
+ function SecretsLoading({ message, step, startTime, fileCount, meta }) {
73
+ const els = [
74
+ React.createElement(
75
+ Box, { key: 'status', gap: 1 },
76
+ React.createElement(Spinner, {}),
77
+ React.createElement(Text, { color: 'white', bold: true }, message),
78
+ startTime != null && React.createElement(ElapsedTime, { startTime }),
79
+ ),
80
+ React.createElement(
81
+ Box, { key: 'steps', marginTop: 1 },
82
+ React.createElement(StepProgress, { currentStep: step }),
83
+ ),
84
+ ];
85
+
86
+ // Show file list and coverage info when we have metadata (during scan step)
87
+ if (meta && step >= 2) {
88
+ const scanned = meta.scanned_files || [];
89
+ const total = meta.total_changed || 0;
90
+ const skipped = meta.skipped || [];
91
+
92
+ els.push(React.createElement(Text, { key: 'meta-sp' }, ''));
93
+ els.push(React.createElement(
94
+ Text, { key: 'meta-hdr', color: 'gray', dimColor: true }, DIVIDER,
95
+ ));
96
+
97
+ // Coverage line
98
+ if (total > 0) {
99
+ const coverageColor = scanned.length === total ? 'green' : 'yellow';
100
+ els.push(React.createElement(
101
+ Text, { key: 'coverage', color: coverageColor },
102
+ `Scanning ${scanned.length} of ${total} changed file${total !== 1 ? 's' : ''}`,
103
+ ));
104
+ }
105
+
106
+ // File list
107
+ if (scanned.length > 0) {
108
+ els.push(React.createElement(Text, { key: 'files-sp' }, ''));
109
+ scanned.forEach((file, i) => {
110
+ const isLast = i === scanned.length - 1;
111
+ const branch = isLast ? '└─' : '├─';
112
+ els.push(React.createElement(
113
+ Text, { key: `file-${i}`, color: 'white' },
114
+ ` ${branch} ${file}`,
115
+ ));
116
+ });
117
+ }
118
+
119
+ // Skipped summary
120
+ if (skipped.length > 0) {
121
+ els.push(React.createElement(Text, { key: 'skip-sp' }, ''));
122
+ const reasons = {};
123
+ skipped.forEach(s => {
124
+ const r = s.reason.split(' (')[0];
125
+ reasons[r] = (reasons[r] || 0) + 1;
126
+ });
127
+ const parts = Object.entries(reasons).map(([r, n]) => `${n} ${r}`);
128
+ els.push(React.createElement(
129
+ Text, { key: 'skipped', color: 'gray', dimColor: true },
130
+ `Skipped: ${parts.join(' · ')}`,
131
+ ));
132
+ }
133
+ }
134
+
135
+ return React.createElement(
136
+ Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els,
137
+ );
138
+ }
139
+
140
+ // ── Public Render Functions ──────────────────────────────────────────────────
141
+
142
+ export function renderInitializing(startTime) {
143
+ return React.createElement(SecretsLoading, {
144
+ message: 'Initializing...',
145
+ step: 0,
146
+ startTime,
147
+ });
148
+ }
149
+
150
+ export function renderFetchingDiff(startTime) {
151
+ return React.createElement(SecretsLoading, {
152
+ message: 'Fetching changes...',
153
+ step: 1,
154
+ startTime,
155
+ });
156
+ }
157
+
158
+ export function renderScanning(startTime, fileCount, meta) {
159
+ return React.createElement(SecretsLoading, {
160
+ message: 'Scanning for secrets...',
161
+ step: 2,
162
+ startTime,
163
+ fileCount,
164
+ meta,
165
+ });
166
+ }
167
+
168
+ export function renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit) {
169
+ const hints = {
170
+ 'staged-only': 'Stage some changes first with "git add".',
171
+ 'branch-diff': 'No changes found compared to the base branch.',
172
+ 'uncommitted': 'No uncommitted changes found.',
173
+ 'committed': 'No unpushed commits found.',
174
+ 'all': 'No changes found between your branch and the base.',
175
+ 'last-commit': 'No files found in the last commit.',
176
+ 'last-n-commits': `No changes found in the last ${lastNCommits} commit(s).`,
177
+ 'base-branch': `No changes found compared to ${baseBranch}.`,
178
+ 'base-commit': `No changes found compared to commit ${baseCommit}.`,
179
+ };
180
+ return React.createElement(
181
+ Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 },
182
+ React.createElement(Text, { color: 'cyan', bold: true }, 'CodeAnt Secrets'),
183
+ React.createElement(Text, { color: 'gray', dimColor: true }, DIVIDER),
184
+ React.createElement(Text, {}, ''),
185
+ React.createElement(Text, { color: 'yellow' }, ' No changes to scan.'),
186
+ React.createElement(Text, { color: 'gray' }, ` ${hints[scanType] || 'No files found.'}`),
187
+ React.createElement(Text, {}, ''),
188
+ );
189
+ }
190
+
191
+ export function renderError(error) {
192
+ return React.createElement(
193
+ Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 },
194
+ React.createElement(Text, { color: 'cyan', bold: true }, 'CodeAnt Secrets'),
195
+ React.createElement(Text, { color: 'gray', dimColor: true }, DIVIDER),
196
+ React.createElement(Text, {}, ''),
197
+ React.createElement(Text, { color: 'red', bold: true }, ` ✗ ${error}`),
198
+ React.createElement(Text, {}, ''),
199
+ );
200
+ }
201
+
202
+ export function renderNotLoggedIn() {
203
+ return React.createElement(
204
+ Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 },
205
+ React.createElement(Text, { color: 'cyan', bold: true }, 'CodeAnt Secrets'),
206
+ React.createElement(Text, { color: 'gray', dimColor: true }, DIVIDER),
207
+ React.createElement(Text, {}, ''),
208
+ React.createElement(Text, { color: 'red', bold: true }, ' ✗ Not logged in'),
209
+ React.createElement(Text, { color: 'gray' }, ' Run "codeant login" to authenticate.'),
210
+ React.createElement(Text, {}, ''),
211
+ );
212
+ }
213
+
214
+ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount, meta) {
215
+ const allSecrets = secrets.flatMap(file =>
216
+ file.secrets.map(s => ({ ...s, file_path: file.file_path }))
217
+ );
218
+
219
+ const blockingSecrets = allSecrets.filter(s => shouldFailOn(s.confidence_score));
220
+ const hasBlocking = blockingSecrets.length > 0;
221
+ const elapsed = startTime ? formatElapsed(startTime) : null;
222
+
223
+ // Count by confidence
224
+ const counts = {};
225
+ allSecrets.forEach(s => {
226
+ const conf = s.confidence_score?.toUpperCase() || 'UNKNOWN';
227
+ counts[conf] = (counts[conf] || 0) + 1;
228
+ });
229
+
230
+ // Group by file
231
+ const grouped = {};
232
+ allSecrets.forEach(s => {
233
+ const file = s.file_path || 'Unknown';
234
+ (grouped[file] ??= []).push(s);
235
+ });
236
+ const sortedFiles = Object.keys(grouped).sort();
237
+ sortedFiles.forEach(f =>
238
+ grouped[f].sort((a, b) => (a.line_number || 0) - (b.line_number || 0))
239
+ );
240
+
241
+ const els = [];
242
+
243
+ // ── Header ──
244
+ els.push(React.createElement(
245
+ Box, { key: 'hdr', gap: 1 },
246
+ React.createElement(Text, { color: 'cyan', bold: true }, 'CodeAnt Secrets'),
247
+ elapsed && React.createElement(Text, { color: 'gray' }, elapsed),
248
+ ));
249
+ els.push(React.createElement(
250
+ Text, { key: 'div1', color: 'gray', dimColor: true }, DIVIDER,
251
+ ));
252
+
253
+ if (allSecrets.length === 0) {
254
+ // ── Clean pass ──
255
+ els.push(React.createElement(Text, { key: 'sp' }, ''));
256
+ els.push(React.createElement(
257
+ Text, { key: 'ok', color: 'green', bold: true }, ' ✓ No secrets found',
258
+ ));
259
+ els.push(React.createElement(Text, { key: 'sp2' }, ''));
260
+ } else {
261
+ // ── Secrets grouped by file ──
262
+ els.push(React.createElement(Text, { key: 'sp1' }, ''));
263
+
264
+ sortedFiles.forEach((file, fi) => {
265
+ const fileSecrets = grouped[file];
266
+
267
+ els.push(React.createElement(
268
+ Text, { key: `f-${fi}`, color: 'white', bold: true }, ` ${file}`,
269
+ ));
270
+
271
+ fileSecrets.forEach((secret, si) => {
272
+ const conf = secret.confidence_score?.toUpperCase() || 'UNKNOWN';
273
+ const confColor = getConfidenceColor(conf);
274
+ const isBlocking = shouldFailOn(secret.confidence_score);
275
+ const isLast = si === fileSecrets.length - 1;
276
+ const branch = isLast ? '└─' : '├─';
277
+ const lineStr = secret.line_number ? `L${secret.line_number}` : '';
278
+
279
+ els.push(React.createElement(
280
+ Box, { key: `s-${fi}-${si}` },
281
+ React.createElement(Text, { color: 'gray' }, ` ${branch} `),
282
+ React.createElement(Text, {
283
+ color: confColor,
284
+ bold: isBlocking,
285
+ }, padEnd(conf, 16)),
286
+ React.createElement(Text, { color: 'gray' }, padEnd(lineStr, 6)),
287
+ React.createElement(Text, { color: isBlocking ? 'white' : 'gray', dimColor: !isBlocking }, secret.type || ''),
288
+ ));
289
+ });
290
+
291
+ els.push(React.createElement(Text, { key: `g-${fi}` }, ''));
292
+ });
293
+ }
294
+
295
+ // ── Footer ──
296
+ els.push(React.createElement(
297
+ Text, { key: 'div2', color: 'gray', dimColor: true }, DIVIDER,
298
+ ));
299
+
300
+ // Coverage line
301
+ const totalChanged = meta?.total_changed || 0;
302
+ const scannedCount = meta?.scanned_files?.length || fileCount;
303
+ if (totalChanged > 0) {
304
+ const coverageColor = scannedCount === totalChanged ? 'green' : 'yellow';
305
+ els.push(React.createElement(
306
+ Text, { key: 'coverage', color: coverageColor },
307
+ `Scanned ${scannedCount} of ${totalChanged} changed file${totalChanged !== 1 ? 's' : ''}`,
308
+ ));
309
+ } else if (fileCount) {
310
+ els.push(React.createElement(
311
+ Text, { key: 'coverage', color: 'gray' },
312
+ `${fileCount} file${fileCount !== 1 ? 's' : ''} scanned`,
313
+ ));
314
+ }
315
+
316
+ // Skipped files summary
317
+ const skipped = meta?.skipped || [];
318
+ if (skipped.length > 0) {
319
+ const reasons = {};
320
+ skipped.forEach(s => {
321
+ const r = s.reason.split(' (')[0];
322
+ reasons[r] = (reasons[r] || 0) + 1;
323
+ });
324
+ const parts = Object.entries(reasons).map(([r, n]) => `${n} ${r}`);
325
+ els.push(React.createElement(
326
+ Text, { key: 'skipped', color: 'gray', dimColor: true },
327
+ `Skipped: ${parts.join(' · ')}`,
328
+ ));
329
+ }
330
+
331
+ // Secret stats line
332
+ if (allSecrets.length > 0) {
333
+ const stats = [`${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`];
334
+ for (const conf of ['HIGH', 'MEDIUM', 'LOW', 'FALSE_POSITIVE']) {
335
+ if (counts[conf]) {
336
+ const label = conf === 'FALSE_POSITIVE' ? 'false positive' : conf.toLowerCase();
337
+ stats.push(`${counts[conf]} ${label}`);
338
+ }
339
+ }
340
+ els.push(React.createElement(
341
+ Text, { key: 'stats', color: 'gray' }, stats.join(' · '),
342
+ ));
343
+ }
344
+
345
+ // Status line
346
+ if (hasBlocking) {
347
+ els.push(React.createElement(
348
+ Text, { key: 'status', color: 'red', bold: true },
349
+ `✗ ${blockingSecrets.length} blocking secret${blockingSecrets.length !== 1 ? 's' : ''} — remove before committing`,
350
+ ));
351
+ } else if (allSecrets.length > 0) {
352
+ els.push(React.createElement(
353
+ Text, { key: 'status', color: 'green', bold: true },
354
+ '✓ No blocking secrets found',
355
+ ));
356
+ } else {
357
+ els.push(React.createElement(
358
+ Text, { key: 'status', color: 'green', bold: true },
359
+ '✓ All clear',
360
+ ));
361
+ }
362
+
363
+ return React.createElement(
364
+ Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els,
365
+ );
366
+ }