codeant-cli 0.3.7 → 0.3.8

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.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ import {
11
11
  renderDone,
12
12
  } from '../components/SecretsUI.js';
13
13
 
14
- export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
14
+ export default function Secrets({ scanType = 'all', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
15
15
  const { exit } = useApp();
16
16
  const [status, setStatus] = useState('initializing');
17
17
  const [secrets, setSecrets] = useState([]);
@@ -20,14 +20,6 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
20
20
  const [scanMeta, setScanMeta] = useState(null);
21
21
  const [startTime] = useState(() => Date.now());
22
22
 
23
- const shouldFailOn = (confidenceScore) => {
24
- const score = confidenceScore?.toUpperCase();
25
- if (score === 'FALSE_POSITIVE') return false;
26
- if (failOn === 'HIGH') return score === 'HIGH';
27
- if (failOn === 'MEDIUM') return score === 'HIGH' || score === 'MEDIUM';
28
- return true;
29
- };
30
-
31
23
  useEffect(() => {
32
24
  let cancelled = false;
33
25
 
@@ -81,10 +73,10 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
81
73
 
82
74
  useEffect(() => {
83
75
  if (status === 'done') {
84
- const hasBlockingSecrets = secrets.some(file =>
85
- file.secrets.some(secret => shouldFailOn(secret.confidence_score))
76
+ const hasSecrets = secrets.some(file =>
77
+ file.secrets.length > 0
86
78
  );
87
- if (hasBlockingSecrets) {
79
+ if (hasSecrets) {
88
80
  setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
89
81
  } else {
90
82
  setTimeout(() => exit(), 100);
@@ -101,7 +93,7 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
101
93
  if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
102
94
  if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
103
95
  if (status === 'error') return renderError(error);
104
- if (status === 'done') return renderDone(secrets, failOn, shouldFailOn, startTime, fileCount, scanMeta);
96
+ if (status === 'done') return renderDone(secrets, startTime, fileCount, scanMeta);
105
97
 
106
98
  return null;
107
99
  }
@@ -7,19 +7,8 @@ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧',
7
7
  const DIVIDER = '─'.repeat(55);
8
8
  const STEPS = ['Init', 'Fetch', 'Scan'];
9
9
 
10
- const CONFIDENCE_COLORS = {
11
- HIGH: 'red',
12
- MEDIUM: 'yellow',
13
- LOW: 'cyan',
14
- FALSE_POSITIVE: 'gray',
15
- };
16
-
17
10
  // ── Helpers ──────────────────────────────────────────────────────────────────
18
11
 
19
- function getConfidenceColor(score) {
20
- return CONFIDENCE_COLORS[score?.toUpperCase()] || 'gray';
21
- }
22
-
23
12
  function padEnd(str, len) {
24
13
  return str + ' '.repeat(Math.max(0, len - str.length));
25
14
  }
@@ -211,22 +200,13 @@ export function renderNotLoggedIn() {
211
200
  );
212
201
  }
213
202
 
214
- export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount, meta) {
203
+ export function renderDone(secrets, startTime, fileCount, meta) {
215
204
  const allSecrets = secrets.flatMap(file =>
216
205
  file.secrets.map(s => ({ ...s, file_path: file.file_path }))
217
206
  );
218
207
 
219
- const blockingSecrets = allSecrets.filter(s => shouldFailOn(s.confidence_score));
220
- const hasBlocking = blockingSecrets.length > 0;
221
208
  const elapsed = startTime ? formatElapsed(startTime) : null;
222
209
 
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
210
  // Group by file
231
211
  const grouped = {};
232
212
  allSecrets.forEach(s => {
@@ -269,9 +249,6 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
269
249
  ));
270
250
 
271
251
  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
252
  const isLast = si === fileSecrets.length - 1;
276
253
  const branch = isLast ? '└─' : '├─';
277
254
  const lineStr = secret.line_number ? `L${secret.line_number}` : '';
@@ -279,12 +256,8 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
279
256
  els.push(React.createElement(
280
257
  Box, { key: `s-${fi}-${si}` },
281
258
  React.createElement(Text, { color: 'gray' }, ` ${branch} `),
282
- React.createElement(Text, {
283
- color: confColor,
284
- bold: isBlocking,
285
- }, padEnd(conf, 16)),
286
259
  React.createElement(Text, { color: 'gray' }, padEnd(lineStr, 6)),
287
- React.createElement(Text, { color: isBlocking ? 'white' : 'gray', dimColor: !isBlocking }, secret.type || ''),
260
+ React.createElement(Text, { color: 'white' }, secret.type || ''),
288
261
  ));
289
262
  });
290
263
 
@@ -330,28 +303,17 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
330
303
 
331
304
  // Secret stats line
332
305
  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
306
  els.push(React.createElement(
341
- Text, { key: 'stats', color: 'gray' }, stats.join(' · '),
307
+ Text, { key: 'stats', color: 'gray' },
308
+ `${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`,
342
309
  ));
343
310
  }
344
311
 
345
312
  // Status line
346
- if (hasBlocking) {
313
+ if (allSecrets.length > 0) {
347
314
  els.push(React.createElement(
348
315
  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',
316
+ `✗ ${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found — remove before committing`,
355
317
  ));
356
318
  } else {
357
319
  els.push(React.createElement(
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import GetBaseUrl from './commands/getBaseUrl.js';
10
10
  import Login from './commands/login.js';
11
11
  import Logout from './commands/logout.js';
12
12
  import Review from './commands/review.js';
13
+ import { runReviewHeadless } from './reviewHeadless.js';
13
14
  import Welcome from './components/Welcome.js';
14
15
  import * as scm from './scm/index.js';
15
16
  import { setConfigValue } from './utils/config.js';
@@ -58,7 +59,6 @@ program
58
59
  .option('--last-n-commits <n>', 'Scan last n commits (max 5)', parseInt)
59
60
  .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
60
61
  .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
61
- .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
62
62
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
63
63
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
64
64
  .action((options) => {
@@ -95,9 +95,7 @@ program
95
95
  ? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
96
96
  : [];
97
97
 
98
- const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
99
-
100
- render(React.createElement(Secrets, { scanType, failOn, include, exclude, lastNCommits, baseBranch, baseCommit }));
98
+ render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit }));
101
99
  });
102
100
 
103
101
  program
@@ -114,7 +112,8 @@ program
114
112
  .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
115
113
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
116
114
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
117
- .action((options) => {
115
+ .option('--headless', 'Output clean JSON with no spinners (for agents and CI)')
116
+ .action(async (options) => {
118
117
  let scanType = 'all';
119
118
  let lastNCommits = 1;
120
119
  let baseBranch = null;
@@ -150,7 +149,23 @@ program
150
149
 
151
150
  const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
152
151
 
153
- render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
152
+ if (options.headless) {
153
+ const result = await runReviewHeadless({
154
+ workspacePath: process.cwd(),
155
+ scanType,
156
+ lastNCommits,
157
+ include,
158
+ exclude,
159
+ baseBranch,
160
+ baseCommit,
161
+ onProgress: (msg) => console.error(`[progress] ${msg}`),
162
+ onFilesReady: (files, meta) => console.error(`[files] Reviewing ${files.length} file(s)`),
163
+ });
164
+ console.log(JSON.stringify(result, null, 2));
165
+ process.exit(result.error ? 1 : 0);
166
+ } else {
167
+ render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
168
+ }
154
169
  });
155
170
 
156
171
  program
@@ -126,9 +126,9 @@ export async function runReviewHeadless(options = {}) {
126
126
 
127
127
  onProgress(`Analyzing ${perFileRequests.length} file${perFileRequests.length !== 1 ? 's' : ''} in parallel...`);
128
128
 
129
- // ── Per-file agent turn loops (parallel) ────────────────────────────
129
+ // ── Per-file agent turn loops (parallel, fault-tolerant) ─────────────
130
130
  const perFileResults = await Promise.all(
131
- perFileRequests.map((fileReq) => {
131
+ perFileRequests.map(async (fileReq) => {
132
132
  const filename = fileReq._filename;
133
133
  delete fileReq._filename;
134
134
 
@@ -138,7 +138,15 @@ export async function runReviewHeadless(options = {}) {
138
138
  }
139
139
  delete fileReq.file_contents;
140
140
 
141
- return runTurnLoop(fileReq, gitRoot, false);
141
+ try {
142
+ onProgress(`Reviewing ${filename}...`);
143
+ const result = await runTurnLoop(fileReq, gitRoot, false);
144
+ onProgress(`Done reviewing ${filename}`);
145
+ return result;
146
+ } catch (err) {
147
+ console.error(`[error] Failed to review ${filename}: ${err.message}`);
148
+ return { finalMessage: null, finalOutput: null };
149
+ }
142
150
  })
143
151
  );
144
152
 
@@ -149,20 +157,26 @@ export async function runReviewHeadless(options = {}) {
149
157
  output: perFileResults[i].finalOutput,
150
158
  })).filter(r => r.output?.code_suggestions?.length > 0);
151
159
 
152
- // ── Per-file reflector loops (parallel) ──────────────────────────────
153
- onProgress('Running reflector...');
160
+ onProgress(`${perFileWithSuggestions.length} file(s) have suggestions, running reflector...`);
161
+
162
+ // ── Per-file reflector loops (parallel, fault-tolerant) ──────────────
154
163
  const reflectorResults = await Promise.all(
155
- perFileWithSuggestions.map(({ diff_content, suggestions }) =>
156
- runTurnLoop(
157
- {
158
- diff_content,
159
- prompt_template_name: 'reflector',
160
- extra_variables: { suggestion_str: suggestions },
161
- },
162
- gitRoot,
163
- true
164
- )
165
- )
164
+ perFileWithSuggestions.map(async ({ diff_content, suggestions }, i) => {
165
+ try {
166
+ return await runTurnLoop(
167
+ {
168
+ diff_content,
169
+ prompt_template_name: 'reflector',
170
+ extra_variables: { suggestion_str: suggestions },
171
+ },
172
+ gitRoot,
173
+ true
174
+ );
175
+ } catch (err) {
176
+ console.error(`[error] Reflector failed for file ${i}: ${err.message}`);
177
+ return { finalMessage: null, finalOutput: null };
178
+ }
179
+ })
166
180
  );
167
181
 
168
182
  // ── Parse results ───────────────────────────────────────────────────
@@ -23,6 +23,11 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => {
23
23
 
24
24
  try {
25
25
  const response = await fetch(url, options);
26
+
27
+ if (response.status === 403) {
28
+ throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.');
29
+ }
30
+
26
31
  const data = await response.json();
27
32
 
28
33
  if (!response.ok) {
@@ -3,32 +3,29 @@ import { readFileSync, writeFileSync, chmodSync, existsSync, unlinkSync, mkdirSy
3
3
  import path from 'path';
4
4
 
5
5
  const HOOK_MARKER = '# codeant-push-protection';
6
+ const HOOK_MARKER_END = '# end-codeant-push-protection';
6
7
 
7
8
  /**
8
9
  * Build the full pre-push hook script (with shebang).
9
- * @param {string} failOn - Severity threshold (e.g. "HIGH", "CRITICAL")
10
10
  */
11
- function buildHookScript(failOn = 'HIGH') {
11
+ function buildHookScript() {
12
12
  return `#!/bin/sh
13
- ${buildHookBlock(failOn)}
13
+ ${buildHookBlock()}
14
14
  `;
15
15
  }
16
16
 
17
17
  /**
18
18
  * Build just the CodeAnt block (no shebang), used when appending to an existing hook.
19
- * @param {string} failOn - Severity threshold
20
19
  */
21
- function buildHookBlock(failOn = 'HIGH') {
20
+ function buildHookBlock() {
22
21
  return `${HOOK_MARKER}
23
22
  # Auto-installed by CodeAnt AI — blocks pushes containing secrets.
24
23
  # To disable: delete this hook or run "codeant push-protection disable"
25
24
  command -v codeant >/dev/null 2>&1 || exit 0
26
- codeant secrets --committed --fail-on ${failOn}
25
+ codeant secrets --committed
27
26
  ${HOOK_MARKER_END}`;
28
27
  }
29
28
 
30
- const HOOK_MARKER_END = '# end-codeant-push-protection';
31
-
32
29
  /**
33
30
  * Replace the CodeAnt block in a hook file with new content (or remove it).
34
31
  */
@@ -37,7 +34,6 @@ function replaceCodeAntBlock(fileContent, newBlock) {
37
34
  let endIdx = fileContent.indexOf(HOOK_MARKER_END);
38
35
  if (startIdx === -1) return fileContent;
39
36
  if (endIdx === -1) {
40
- // Legacy hook without end marker — remove from start marker to EOF
41
37
  endIdx = fileContent.length;
42
38
  } else {
43
39
  endIdx += HOOK_MARKER_END.length;
@@ -85,13 +81,9 @@ function getHooksDir(gitRoot) {
85
81
  * Install a pre-push hook that runs secret scanning before push.
86
82
  *
87
83
  * @param {string} workspacePath - Path to the git repository
88
- * @param {object} [options]
89
- * @param {string} [options.failOn="HIGH"] - Severity threshold
90
84
  * @returns {{ installed: boolean, hookPath: string|null, message: string }}
91
85
  */
92
- export function installPushProtectionHook(workspacePath, options = {}) {
93
- const { failOn = 'HIGH' } = options;
94
-
86
+ export function installPushProtectionHook(workspacePath) {
95
87
  const gitRoot = findGitRoot(workspacePath);
96
88
  if (!gitRoot) {
97
89
  return { installed: false, hookPath: null, message: 'Not a git repository' };
@@ -108,19 +100,24 @@ export function installPushProtectionHook(workspacePath, options = {}) {
108
100
  const existing = readFileSync(hookPath, 'utf-8');
109
101
  if (existing.includes(HOOK_MARKER)) {
110
102
  // Replace only our block, preserve everything else
111
- const updated = replaceCodeAntBlock(existing, buildHookBlock(failOn));
103
+ const updated = replaceCodeAntBlock(existing, buildHookBlock());
112
104
  writeFileSync(hookPath, updated, 'utf-8');
113
105
  chmodSync(hookPath, 0o755);
114
106
  return { installed: true, hookPath, message: 'Hook updated' };
115
107
  }
116
- // There's a user-managed hook — append our block (no duplicate shebang)
117
- const appended = existing.trimEnd() + '\n\n' + buildHookBlock(failOn) + '\n';
108
+ // There's a user-managed hook — append only for shell hooks to avoid breaking non-shell scripts
109
+ const firstLine = existing.split('\n', 1)[0] || '';
110
+ const isShellHook = firstLine.startsWith('#!') ? /\/(ba|z|k)?sh(\s|$)/.test(firstLine) : true;
111
+ if (!isShellHook) {
112
+ return { installed: false, hookPath, message: 'Existing pre-push hook is non-shell; cannot append CodeAnt block safely' };
113
+ }
114
+ const appended = existing.trimEnd() + '\n\n' + buildHookBlock() + '\n';
118
115
  writeFileSync(hookPath, appended, 'utf-8');
119
116
  chmodSync(hookPath, 0o755);
120
117
  return { installed: true, hookPath, message: 'Hook appended to existing pre-push' };
121
118
  }
122
119
 
123
- writeFileSync(hookPath, buildHookScript(failOn), 'utf-8');
120
+ writeFileSync(hookPath, buildHookScript(), 'utf-8');
124
121
  chmodSync(hookPath, 0o755);
125
122
  return { installed: true, hookPath, message: 'Hook installed' };
126
123
  }
@@ -152,7 +149,6 @@ export function removePushProtectionHook(workspacePath) {
152
149
  // Remove our block (between start and end markers)
153
150
  const remaining = replaceCodeAntBlock(content, '').trim();
154
151
  if (!remaining || remaining === '#!/bin/sh') {
155
- // Nothing left — delete the file
156
152
  unlinkSync(hookPath);
157
153
  return { removed: true, message: 'Hook removed' };
158
154
  }
@@ -185,7 +185,6 @@ export function scanFile(filePath, content) {
185
185
  type: rule.id,
186
186
  description: rule.description,
187
187
  line_number: lineNum,
188
- confidence_score: rule.entropy > 0 ? 'HIGH' : 'MEDIUM',
189
188
  secret_snippet: maskSecret(secret),
190
189
  });
191
190
  }