git-ai-review 2.2.1 → 2.3.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 CHANGED
@@ -68,7 +68,7 @@ Recommended: install multiple for resilient fallback behavior.
68
68
 
69
69
  When an API token env var is set, the network preflight check for that reviewer is skipped — this is useful in CI environments or when interactive OAuth login is not available.
70
70
 
71
- Default fallback chain: Codex → Copilot → Claude. If a provider was unavailable on the previous run, it is rotated to the end of the chain on the next attempt (state stored in `.git/ai-review-last-unavailable`, cleared on success). If all are unavailable, review fails.
71
+ Default fallback chain: Codex → Copilot → Claude. If a provider was unavailable on the previous run, it is rotated to the end of the chain on the next attempt (state stored in `.git/ai-review-last-unavailable`). The rotation persists until the unavailable provider is retried and found available again, preventing repeated preflight failures. If all are unavailable, review fails.
72
72
 
73
73
  When a reviewer is explicitly selected via `--claude`, `--codex`, or `--copilot`, the preflight check is skipped entirely — the CLI tool handles its own authentication.
74
74
 
@@ -185,6 +185,19 @@ Use `--verbose` to print:
185
185
 
186
186
  This is useful for debugging prompt behavior and model integration issues.
187
187
 
188
+ ## Token usage
189
+
190
+ After each review, token usage is printed to stdout when available:
191
+
192
+ ```
193
+ Tokens: input: 1234, output: 567
194
+ ```
195
+
196
+ - **Input tokens**: tokens sent to the model (your prompt, diff, schema, AGENTS.md context).
197
+ - **Output tokens**: tokens generated by the model (the review JSON response).
198
+
199
+ For Claude, usage is reliably extracted from the CLI's JSON envelope. For Codex and Copilot, usage is extracted on a best-effort basis from CLI output and may not always be available.
200
+
188
201
  ## Example setup in a target repo
189
202
 
190
203
  ```bash
package/dist/review.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ParsedReview, ReviewReport, ReviewRunnerResult } from './types.js';
1
+ import type { ParsedReview, ReviewReport, ReviewRunnerResult, TokenUsage } from './types.js';
2
2
  export type ReviewerName = 'codex' | 'copilot' | 'claude';
3
3
  export declare function buildFallbackOrder(lastUnavailable: string | null): ReviewerName[];
4
4
  export declare function readLastUnavailable(cwd: string): string | null;
@@ -36,6 +36,8 @@ export interface ResolveBinaryOptions {
36
36
  }
37
37
  export declare function resolveBinary(envName: string, candidates: string[], options?: ResolveBinaryOptions): string | null;
38
38
  export declare function buildPrompt(diff: string, cwd?: string, diffLabel?: string): string;
39
+ export declare function extractTokenUsage(usage: unknown): TokenUsage | undefined;
40
+ export declare function extractTokenUsageFromText(text: string): TokenUsage | undefined;
39
41
  export declare function parseSubagentOutput(raw: string): ParsedReview;
40
42
  export declare function buildSpawnOptions(cwd: string, env: Record<string, string>, osPlatform?: NodeJS.Platform): {
41
43
  cwd: string;
package/dist/review.js CHANGED
@@ -183,6 +183,24 @@ function validateParsedReview(parsed) {
183
183
  }
184
184
  return parsed;
185
185
  }
186
+ export function extractTokenUsage(usage) {
187
+ if (!usage || typeof usage !== 'object')
188
+ return undefined;
189
+ const u = usage;
190
+ const input = typeof u.input_tokens === 'number' ? u.input_tokens : undefined;
191
+ const output = typeof u.output_tokens === 'number' ? u.output_tokens : undefined;
192
+ if (input === undefined && output === undefined)
193
+ return undefined;
194
+ return { input_tokens: input, output_tokens: output };
195
+ }
196
+ export function extractTokenUsageFromText(text) {
197
+ // Find a JSON object that contains both input_tokens and output_tokens as siblings
198
+ const objectPattern = /\{[^{}]*"input_tokens"\s*:\s*(\d+)[^{}]*"output_tokens"\s*:\s*(\d+)[^{}]*\}/;
199
+ const match = text.match(objectPattern);
200
+ if (!match)
201
+ return undefined;
202
+ return { input_tokens: Number(match[1]), output_tokens: Number(match[2]) };
203
+ }
186
204
  export function parseSubagentOutput(raw) {
187
205
  const trimmed = String(raw || '').trim();
188
206
  if (!trimmed) {
@@ -296,16 +314,18 @@ async function runClaude(prompt, verbose, skipPreflight = false) {
296
314
  return { available: false };
297
315
  }
298
316
  let reviewPayload = trimmed;
317
+ let usage;
299
318
  try {
300
319
  const envelope = JSON.parse(trimmed);
301
320
  if (envelope && typeof envelope === 'object' && typeof envelope.result === 'string') {
302
321
  reviewPayload = envelope.result;
322
+ usage = extractTokenUsage(envelope.usage);
303
323
  }
304
324
  }
305
325
  catch {
306
326
  // not an envelope — use raw output
307
327
  }
308
- return { available: true, result: parseSubagentOutput(reviewPayload) };
328
+ return { available: true, result: parseSubagentOutput(reviewPayload), usage };
309
329
  }
310
330
  catch (error) {
311
331
  const message = error instanceof Error ? error.message : String(error);
@@ -360,7 +380,8 @@ function runCodex(prompt, verbose, skipPreflight = false) {
360
380
  console.error(`Codex: execution failed${err ? `: ${err}` : ''} — skipping.`);
361
381
  return { available: false };
362
382
  }
363
- return { available: true, result: parseSubagentOutput(readFileSync(resultPath, 'utf8')) };
383
+ const usage = extractTokenUsageFromText(run.stdout || '');
384
+ return { available: true, result: parseSubagentOutput(readFileSync(resultPath, 'utf8')), usage };
364
385
  }
365
386
  catch (error) {
366
387
  const message = error instanceof Error ? error.message : String(error);
@@ -405,7 +426,8 @@ function runCopilot(prompt, verbose, skipPreflight = false) {
405
426
  console.error('Copilot: empty response — skipping.');
406
427
  return { available: false };
407
428
  }
408
- return { available: true, result: parseSubagentOutput(stdout) };
429
+ const usage = extractTokenUsageFromText(run.stderr || '');
430
+ return { available: true, result: parseSubagentOutput(stdout), usage };
409
431
  }
410
432
  catch (error) {
411
433
  const message = error instanceof Error ? error.message : String(error);
@@ -441,7 +463,7 @@ export function evaluateResults(claude, codex, copilot) {
441
463
  }
442
464
  return { pass: true, reason: `AI review approved by: ${passed.join(', ')}.` };
443
465
  }
444
- function printReview(model, result) {
466
+ function printReview(model, result, usage) {
445
467
  const label = result.status === 'fail'
446
468
  ? `${model} review FAILED`
447
469
  : result.findings.length > 0
@@ -454,6 +476,15 @@ function printReview(model, result) {
454
476
  console.log(`- [${finding.severity}] ${finding.title} (${at})`);
455
477
  console.log(` ${finding.details}`);
456
478
  }
479
+ if (usage) {
480
+ const parts = [];
481
+ if (usage.input_tokens !== undefined)
482
+ parts.push(`input: ${usage.input_tokens}`);
483
+ if (usage.output_tokens !== undefined)
484
+ parts.push(`output: ${usage.output_tokens}`);
485
+ if (parts.length > 0)
486
+ console.log(`Tokens: ${parts.join(', ')}`);
487
+ }
457
488
  }
458
489
  export async function runReview(cwd = process.cwd(), options = {}, deps = {}) {
459
490
  const verbose = options.verbose === true;
@@ -531,7 +562,7 @@ export async function runReview(cwd = process.cwd(), options = {}, deps = {}) {
531
562
  if (firstUnavailable) {
532
563
  writeLastUnavailable(cwd, firstUnavailable);
533
564
  }
534
- else {
565
+ else if (!lastUnavailable || !DEFAULT_FALLBACK_ORDER.includes(lastUnavailable) || results[lastUnavailable].available) {
535
566
  clearLastUnavailable(cwd);
536
567
  }
537
568
  }
@@ -544,19 +575,19 @@ export async function runReview(cwd = process.cwd(), options = {}, deps = {}) {
544
575
  runtimeDeps.writeReport(reportPath, report);
545
576
  runtimeDeps.log(`AI review raw report saved: ${reportPath}`);
546
577
  if (claude.available) {
547
- printReview('Claude', claude.result);
578
+ printReview('Claude', claude.result, claude.usage);
548
579
  }
549
580
  else {
550
581
  console.log('\nClaude: unavailable — skipped.');
551
582
  }
552
583
  if (codex.available) {
553
- printReview('Codex', codex.result);
584
+ printReview('Codex', codex.result, codex.usage);
554
585
  }
555
586
  else {
556
587
  console.log('\nCodex: unavailable — skipped.');
557
588
  }
558
589
  if (copilot.available) {
559
- printReview('Copilot', copilot.result);
590
+ printReview('Copilot', copilot.result, copilot.usage);
560
591
  }
561
592
  else {
562
593
  console.log('\nCopilot: unavailable — skipped.');
package/dist/types.d.ts CHANGED
@@ -10,11 +10,16 @@ export interface ParsedReview {
10
10
  summary: string;
11
11
  findings: ReviewFinding[];
12
12
  }
13
+ export interface TokenUsage {
14
+ input_tokens?: number;
15
+ output_tokens?: number;
16
+ }
13
17
  export type ReviewRunnerResult = {
14
18
  available: false;
15
19
  } | {
16
20
  available: true;
17
21
  result: ParsedReview;
22
+ usage?: TokenUsage;
18
23
  };
19
24
  export interface ReviewReport {
20
25
  claude: ParsedReview | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ai-review",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Review your git diff with local Codex CLI, Copilot CLI, or Claude CLI — run manually in one command or automatically as a git hook",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",