cli-changescribe 0.1.3 → 0.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # changescribe
2
2
 
3
- CLI to generate Conventional Commit messages and PR summaries using Groq.
3
+ CLI to generate Conventional Commit messages and PR summaries using Cerebras or Groq.
4
4
 
5
5
  ## Install
6
6
 
@@ -32,9 +32,14 @@ yarn add cli-changescribe
32
32
  Create a `.env.local` file in the repo where you run the CLI:
33
33
 
34
34
  ```bash
35
+ # Pick one (Cerebras is preferred for higher throughput)
36
+ CEREBRAS_API_KEY="your-key-here"
37
+ # or
35
38
  GROQ_API_KEY="your-key-here"
36
39
  ```
37
40
 
41
+ Provider priority: if both keys are set, Cerebras is used.
42
+
38
43
  If your repo uses `pnpm` or `yarn`, make sure you install `cli-changescribe`
39
44
  with the same package manager so the correct lockfile is updated (Vercel uses
40
45
  `frozen-lockfile` by default).
@@ -42,7 +47,7 @@ with the same package manager so the correct lockfile is updated (Vercel uses
42
47
  ### Setup process (recommended)
43
48
 
44
49
  1. Install `cli-changescribe` (global or per repo).
45
- 2. Add `.env.local` with `GROQ_API_KEY`.
50
+ 2. Add `.env.local` with `CEREBRAS_API_KEY` or `GROQ_API_KEY`.
46
51
  3. Run `npx changescribe init` to add npm scripts.
47
52
  4. If you plan to use `--create-pr`, install and auth GitHub CLI: `gh auth login`.
48
53
  5. Run a dry run to validate:
@@ -55,8 +60,8 @@ Optional environment variables for PR summaries:
55
60
  - `PR_SUMMARY_OUT` (default: `.pr-summaries/PR_SUMMARY.md`)
56
61
  - `PR_SUMMARY_LIMIT` (default: `400`)
57
62
  - `PR_SUMMARY_ISSUE` (default: empty)
58
- - `GROQ_PR_MODEL` (default: `openai/gpt-oss-120b`)
59
- - `GROQ_MODEL` (fallback)
63
+ - `CHANGESCRIBE_MODEL` (override model name for any provider)
64
+ - `GROQ_PR_MODEL` / `GROQ_MODEL` (legacy overrides, still supported)
60
65
 
61
66
  ## Usage
62
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-changescribe",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for generating commit messages and PR summaries",
5
5
  "bin": {
6
6
  "changescribe": "bin/changescribe.js"
@@ -19,7 +19,9 @@
19
19
  "pr",
20
20
  "summary",
21
21
  "changelog",
22
- "groq"
22
+ "groq",
23
+ "cerebras",
24
+ "openai"
23
25
  ],
24
26
  "license": "MIT",
25
27
  "engines": {
@@ -27,6 +29,6 @@
27
29
  },
28
30
  "dependencies": {
29
31
  "dotenv": "^17.2.1",
30
- "groq-sdk": "^0.8.0"
32
+ "openai": "^4.104.0"
31
33
  }
32
- }
34
+ }
package/src/commit.js CHANGED
@@ -3,7 +3,7 @@ const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
5
  const { config } = require('dotenv');
6
- const Groq = require('groq-sdk').default;
6
+ const { createClient } = require('./provider');
7
7
 
8
8
  // Load environment variables from .env.local
9
9
  config({ path: '.env.local' });
@@ -193,18 +193,19 @@ function getFileType(filename) {
193
193
  async function generateCommitMessage(argv) {
194
194
  try {
195
195
  const isDryRun = argv.includes('--dry-run');
196
- // Check if GROQ_API_KEY is set
197
- if (!process.env.GROQ_API_KEY || process.env.GROQ_API_KEY === '') {
198
- console.error('❌ GROQ_API_KEY environment variable is required');
199
- console.log('💡 Get your API key from: https://console.groq.com/keys');
200
- console.log('💡 Set it in .env.local file: GROQ_API_KEY="your-key-here"');
196
+
197
+ const providerInfo = createClient();
198
+ if (!providerInfo) {
199
+ console.error(' No API key found');
200
+ console.log('💡 Set CEREBRAS_API_KEY or GROQ_API_KEY in .env.local');
201
201
  process.exit(1);
202
202
  }
203
203
 
204
- // Initialize Groq client
205
- const groq = new Groq({
206
- apiKey: process.env.GROQ_API_KEY,
207
- });
204
+ const { client, provider, defaultModel } = providerInfo;
205
+ const model =
206
+ process.env.CHANGESCRIBE_MODEL ||
207
+ process.env.GROQ_MODEL ||
208
+ defaultModel;
208
209
 
209
210
  // Get comprehensive analysis of all changes
210
211
  let changeAnalysis;
@@ -220,13 +221,14 @@ async function generateCommitMessage(argv) {
220
221
  return;
221
222
  }
222
223
 
223
- console.log('🤖 Generating commit message with AI...');
224
+ console.log(`🤖 Generating commit message with AI (${provider})...`);
224
225
 
225
- // Generate commit message using Groq with comprehensive analysis
226
+ // Generate commit message using LLM with comprehensive analysis
226
227
  const completion = await createCompletionSafe(
227
- groq,
228
+ client,
228
229
  buildChatMessages(changeAnalysis),
229
- 'openai/gpt-oss-120b'
230
+ model,
231
+ provider
230
232
  );
231
233
 
232
234
  const rawContent = completion?.choices?.[0]?.message?.content
@@ -249,9 +251,10 @@ async function generateCommitMessage(argv) {
249
251
  const violations = findCommitViolations(built);
250
252
  if (violations.length > 0) {
251
253
  const repairCompletion = await createCompletionSafe(
252
- groq,
254
+ client,
253
255
  buildRepairMessages(rawContent || '', reasoning || '', built, violations),
254
- 'openai/gpt-oss-120b'
256
+ model,
257
+ provider
255
258
  );
256
259
  const repairedContent = repairCompletion?.choices?.[0]?.message?.content
257
260
  ?.trim()
@@ -324,17 +327,21 @@ async function generateCommitMessage(argv) {
324
327
  }
325
328
  }
326
329
 
327
- async function createCompletionSafe(groq, messages, model) {
330
+ async function createCompletionSafe(client, messages, model, provider) {
328
331
  try {
329
- return await groq.chat.completions.create({
332
+ const params = {
330
333
  messages,
331
334
  model,
332
335
  temperature: 0.3,
333
336
  max_tokens: 16_384,
334
- reasoning_effort: 'high',
335
- });
337
+ };
338
+ // reasoning_effort is a Groq-specific parameter; omit for other providers
339
+ if (provider === 'groq') {
340
+ params.reasoning_effort = 'high';
341
+ }
342
+ return await client.chat.completions.create(params);
336
343
  } catch (error) {
337
- console.error('❌ Groq API error while creating completion');
344
+ console.error('❌ LLM API error while creating completion');
338
345
  console.error(formatError(error));
339
346
  process.exit(1);
340
347
  }
package/src/pr-summary.js CHANGED
@@ -3,14 +3,12 @@ const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
5
  const { config } = require('dotenv');
6
- const Groq = require('groq-sdk').default;
6
+ const { createClient } = require('./provider');
7
7
 
8
8
  config({ path: '.env.local' });
9
9
 
10
10
  const DEFAULT_BASE = process.env.PR_SUMMARY_BASE || 'main';
11
11
  const DEFAULT_OUT = process.env.PR_SUMMARY_OUT || '.pr-summaries/PR_SUMMARY.md';
12
- const DEFAULT_MODEL =
13
- process.env.GROQ_PR_MODEL || process.env.GROQ_MODEL || 'openai/gpt-oss-120b';
14
12
  const DEFAULT_LIMIT = Number.parseInt(
15
13
  process.env.PR_SUMMARY_LIMIT || '400',
16
14
  10
@@ -18,7 +16,8 @@ const DEFAULT_LIMIT = Number.parseInt(
18
16
  const DEFAULT_ISSUE = process.env.PR_SUMMARY_ISSUE || '';
19
17
  const LARGE_BUFFER_SIZE = 10 * 1024 * 1024;
20
18
  const BODY_TRUNCATION = 4000;
21
- const CHUNK_SIZE_CHARS = 8000;
19
+ const CHUNK_SIZE_CHARS = 20000;
20
+ const DIFF_PER_COMMIT_CHARS = 3000;
22
21
  const NEWLINE_SPLIT_RE = /\r?\n/;
23
22
 
24
23
  const ui = {
@@ -38,13 +37,13 @@ function paint(text, color) {
38
37
  return `${color}${text}${ui.reset}`;
39
38
  }
40
39
 
41
- function banner(branch, base) {
40
+ function banner(branch, base, providerName) {
42
41
  const title = paint('PR SYNTHESIZER', ui.magenta);
43
42
  const line = paint('═'.repeat(36), ui.purple);
44
43
  const meta = `${paint('branch', ui.cyan)} ${branch} ${paint(
45
44
  'base',
46
45
  ui.cyan
47
- )} ${base}`;
46
+ )} ${base} ${paint('provider', ui.cyan)} ${providerName}`;
48
47
  return `${line}\n${title}\n${meta}\n${line}`;
49
48
  }
50
49
 
@@ -75,6 +74,50 @@ function runGit(command) {
75
74
  }
76
75
  }
77
76
 
77
+ // ---------------------------------------------------------------------------
78
+ // Diff enrichment
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function getCommitDiffInfo(sha, title) {
82
+ const isMerge = title.toLowerCase().startsWith('merge');
83
+
84
+ try {
85
+ // For merge commits, diff against the first parent to see what the merge introduced.
86
+ // For normal commits, use git show which diffs against the single parent.
87
+ const statCmd = isMerge
88
+ ? `git diff ${sha}^1..${sha} --stat`
89
+ : `git show ${sha} --stat --format=""`;
90
+ const stat = execSync(statCmd, {
91
+ encoding: 'utf8',
92
+ maxBuffer: LARGE_BUFFER_SIZE,
93
+ }).trim();
94
+
95
+ let diff = '';
96
+ try {
97
+ const diffCmd = isMerge
98
+ ? `git diff ${sha}^1..${sha} -U3 --diff-filter=ACMRT`
99
+ : `git show ${sha} -U3 --format="" --diff-filter=ACMRT`;
100
+ diff = execSync(diffCmd, {
101
+ encoding: 'utf8',
102
+ maxBuffer: LARGE_BUFFER_SIZE,
103
+ });
104
+ if (diff.length > DIFF_PER_COMMIT_CHARS) {
105
+ diff = `${diff.slice(0, DIFF_PER_COMMIT_CHARS)}\n...[truncated]...`;
106
+ }
107
+ } catch {
108
+ diff = '(diff unavailable)';
109
+ }
110
+
111
+ return { stat, diff };
112
+ } catch {
113
+ return { stat: '(unavailable)', diff: '' };
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Commit collection
119
+ // ---------------------------------------------------------------------------
120
+
78
121
  function collectCommits(baseRef, limit) {
79
122
  const range = `${baseRef}..HEAD`;
80
123
  let rawLog = '';
@@ -101,7 +144,7 @@ function collectCommits(baseRef, limit) {
101
144
  .map((entry) => {
102
145
  const [sha = '', title = '', bodyRaw = ''] = entry.split('\x1f');
103
146
  const body = bodyRaw.trim().slice(0, BODY_TRUNCATION);
104
- return { sha: sha.trim(), title: title.trim(), body };
147
+ return { sha: sha.trim(), title: title.trim(), body, stat: '', diff: '' };
105
148
  });
106
149
 
107
150
  if (Number.isFinite(limit) && limit > 0 && commits.length > limit) {
@@ -111,6 +154,16 @@ function collectCommits(baseRef, limit) {
111
154
  return commits;
112
155
  }
113
156
 
157
+ function enrichCommitsWithDiffs(commits) {
158
+ step('Enriching commits with diff context...');
159
+ for (const commit of commits) {
160
+ const info = getCommitDiffInfo(commit.sha, commit.title);
161
+ commit.stat = info.stat;
162
+ commit.diff = info.diff;
163
+ }
164
+ success(`Enriched ${commits.length} commits with diffs`);
165
+ }
166
+
114
167
  function tryFetchBase(baseBranch) {
115
168
  try {
116
169
  execSync(`git fetch origin ${baseBranch}`, {
@@ -146,6 +199,10 @@ function resolveBaseRef(baseBranch) {
146
199
  }
147
200
  }
148
201
 
202
+ // ---------------------------------------------------------------------------
203
+ // Chunking
204
+ // ---------------------------------------------------------------------------
205
+
149
206
  function chunkCommits(commits, maxChars) {
150
207
  const chunks = [];
151
208
  let current = [];
@@ -170,24 +227,40 @@ function chunkCommits(commits, maxChars) {
170
227
  }
171
228
 
172
229
  function serializeCommit(commit) {
173
- return `${commit.sha}\n${commit.title}\n${commit.body}\n---\n`;
230
+ const parts = [commit.sha, commit.title, commit.body];
231
+ if (commit.stat) {
232
+ parts.push(commit.stat);
233
+ }
234
+ if (commit.diff) {
235
+ parts.push(commit.diff);
236
+ }
237
+ parts.push('---');
238
+ return `${parts.join('\n')}\n`;
174
239
  }
175
240
 
176
- async function createCompletionSafe(groq, messages, model, maxTokens) {
241
+ // ---------------------------------------------------------------------------
242
+ // LLM helpers
243
+ // ---------------------------------------------------------------------------
244
+
245
+ async function createCompletionSafe(client, messages, model, maxTokens) {
177
246
  try {
178
- return await groq.chat.completions.create({
247
+ return await client.chat.completions.create({
179
248
  messages,
180
249
  model,
181
250
  temperature: 0.3,
182
251
  max_tokens: maxTokens,
183
252
  });
184
253
  } catch (error) {
185
- fail('Groq API error while creating completion');
254
+ fail('LLM API error while creating completion');
186
255
  step(formatError(error));
187
256
  process.exit(1);
188
257
  }
189
258
  }
190
259
 
260
+ // ---------------------------------------------------------------------------
261
+ // Prompt builders
262
+ // ---------------------------------------------------------------------------
263
+
191
264
  function buildPass1Messages(commits, branch, base) {
192
265
  const titles = commits
193
266
  .map((commit) => `- ${commit.title || '(no title)'}`)
@@ -216,21 +289,28 @@ function buildPass1Messages(commits, branch, base) {
216
289
 
217
290
  function buildPass2Messages(commitsChunk) {
218
291
  const body = commitsChunk
219
- .map((commit) =>
220
- [
292
+ .map((commit) => {
293
+ const parts = [
221
294
  `SHA: ${commit.sha}`,
222
295
  `Title: ${commit.title || '(no title)'}`,
223
296
  `Body:\n${commit.body || '(no body)'}`,
224
- '---',
225
- ].join('\n')
226
- )
297
+ ];
298
+ if (commit.stat) {
299
+ parts.push(`Files changed:\n${commit.stat}`);
300
+ }
301
+ if (commit.diff) {
302
+ parts.push(`Diff:\n${commit.diff}`);
303
+ }
304
+ parts.push('---');
305
+ return parts.join('\n');
306
+ })
227
307
  .join('\n');
228
308
 
229
309
  return [
230
310
  {
231
311
  role: 'system',
232
312
  content:
233
- 'You are producing compact, high-signal summaries per commit: 2-3 bullets each (change, rationale, risk/test note). Flag any breaking changes or migrations.',
313
+ 'You are producing compact, high-signal summaries per commit. Use the diff and file stats to understand exactly what changed in the code. Produce 2-3 bullets each (change, rationale, risk/test note). Flag any breaking changes or migrations. IMPORTANT: Only reference technologies, frameworks, and patterns that are explicitly visible in the diff or file names. Do not infer or guess technologies that are not shown (e.g., do not mention GraphQL unless you see .graphql files or GraphQL client imports in the diff).',
234
314
  },
235
315
  {
236
316
  role: 'user',
@@ -239,9 +319,9 @@ function buildPass2Messages(commitsChunk) {
239
319
  body,
240
320
  '',
241
321
  'Return for each commit:',
242
- '- Title-aligned bullet: what changed + why.',
243
- '- Risk or test note if visible.',
244
- 'Keep outputs brief; do not restate bodies.',
322
+ '- Title-aligned bullet: what changed + why (use the diff to be specific about files and code patterns).',
323
+ '- Risk or test note if visible in the diff.',
324
+ 'Keep outputs brief; do not restate bodies verbatim.',
245
325
  ].join('\n'),
246
326
  },
247
327
  ];
@@ -259,7 +339,7 @@ function buildPass3Messages(
259
339
  {
260
340
  role: 'system',
261
341
  content:
262
- 'You write PR summaries that are easy to review. Be concise, specific, and action-oriented. Do not include markdown fences.',
342
+ 'You write PR summaries that are easy to review. Be concise, specific, and action-oriented. Do not include markdown fences. Only reference technologies and patterns that appear in the commit summaries provided. Never fabricate or assume technologies not explicitly mentioned (e.g., do not mention GraphQL, Apollo, Relay, or similar unless the summaries explicitly reference them).',
263
343
  },
264
344
  {
265
345
  role: 'user',
@@ -286,7 +366,9 @@ function buildPass3Messages(
286
366
  'Rules:',
287
367
  '- If the issue is unknown, write: "Related: (not provided)".',
288
368
  '- If testing is unknown, write: "Testing: (not provided)".',
289
- '- Prefer bullets. Be thorough but not rambly.',
369
+ '- Every commit must appear as its own bullet under "What change does this PR add?". Do not group commits under "miscellaneous" or similar catch-all labels.',
370
+ '- Be thorough and specific. Reference actual file names, functions, and architectural changes.',
371
+ '- Prefer bullets.',
290
372
  '',
291
373
  `Issue hint: ${issue || '(not provided)'}`,
292
374
  ].join('\n'),
@@ -306,7 +388,7 @@ function buildReleaseMessages(
306
388
  {
307
389
  role: 'system',
308
390
  content:
309
- 'You write release PR summaries for QA to production. Be concise, concrete, and action-oriented. Do not include markdown fences.',
391
+ 'You write release PR summaries for QA to production. Be concise, concrete, and action-oriented. Do not include markdown fences. Only reference technologies and patterns that appear in the commit summaries provided. Never fabricate or assume technologies not explicitly mentioned.',
310
392
  },
311
393
  {
312
394
  role: 'user',
@@ -333,7 +415,9 @@ function buildReleaseMessages(
333
415
  '',
334
416
  'Rules:',
335
417
  '- If unknown, write: "Unknown".',
336
- '- Prefer bullets. Be thorough but not rambly.',
418
+ '- Every commit must appear as its own bullet. Do not group commits under catch-all labels like "miscellaneous".',
419
+ '- Be thorough and specific. Reference actual changes from the commit summaries.',
420
+ '- Prefer bullets.',
337
421
  '',
338
422
  `Issue hint: ${issue || '(not provided)'}`,
339
423
  ].join('\n'),
@@ -341,6 +425,10 @@ function buildReleaseMessages(
341
425
  ];
342
426
  }
343
427
 
428
+ // ---------------------------------------------------------------------------
429
+ // Utilities
430
+ // ---------------------------------------------------------------------------
431
+
344
432
  function formatError(error) {
345
433
  const plain = {};
346
434
  for (const key of Object.getOwnPropertyNames(error)) {
@@ -438,6 +526,10 @@ function isUnknownSummary(summary, mode) {
438
526
  return unknownCount === headings.length;
439
527
  }
440
528
 
529
+ // ---------------------------------------------------------------------------
530
+ // GitHub helpers
531
+ // ---------------------------------------------------------------------------
532
+
441
533
  function checkGhCli() {
442
534
  try {
443
535
  execSync('gh --version', { encoding: 'utf8', stdio: 'ignore' });
@@ -461,7 +553,6 @@ function checkUncommittedChanges() {
461
553
 
462
554
  function checkExistingPr(base, head) {
463
555
  try {
464
- // Check for open PRs from head to base
465
556
  const result = spawnSync(
466
557
  'gh',
467
558
  [
@@ -483,14 +574,12 @@ function checkExistingPr(base, head) {
483
574
  );
484
575
 
485
576
  if (result.error || result.status !== 0) {
486
- // If gh command fails, assume no PR exists (might be auth issue, but we'll catch that later)
487
577
  return null;
488
578
  }
489
579
 
490
580
  const prs = JSON.parse(result.stdout || '[]');
491
581
  return prs.length > 0 ? prs[0] : null;
492
582
  } catch {
493
- // If parsing fails or command fails, assume no PR exists
494
583
  return null;
495
584
  }
496
585
  }
@@ -508,33 +597,27 @@ function hasNpmScript(scriptName, cwd = process.cwd()) {
508
597
 
509
598
  function extractPrTitle(summary, mode) {
510
599
  const targetSection = mode === 'release' ? 'release summary' : 'what change';
511
- // Try to extract a title from the summary
512
600
  const lines = summary.split('\n');
513
601
  let inChangesSection = false;
514
602
 
515
603
  for (const line of lines) {
516
604
  const trimmed = line.trim();
517
605
 
518
- // Detect the target section
519
606
  if (trimmed.toLowerCase().includes(targetSection)) {
520
607
  inChangesSection = true;
521
608
  continue;
522
609
  }
523
610
 
524
- // If we hit another section heading, stop looking
525
611
  if (inChangesSection && trimmed && trimmed.endsWith('?')) {
526
612
  break;
527
613
  }
528
614
 
529
- // If we're in the changes section, look for first bullet point
530
615
  if (inChangesSection && trimmed.startsWith('-')) {
531
- // Extract bullet content, clean it up
532
616
  const bullet = trimmed.slice(1).trim();
533
- // Remove markdown formatting and truncate
534
617
  const clean = bullet
535
- .replace(/`([^`]+)`/g, '$1') // Remove backticks
536
- .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
537
- .replace(/\*([^*]+)\*/g, '$1') // Remove italic
618
+ .replace(/`([^`]+)`/g, '$1')
619
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
620
+ .replace(/\*([^*]+)\*/g, '$1')
538
621
  .slice(0, 100);
539
622
  if (clean.length > 10) {
540
623
  return clean;
@@ -542,13 +625,11 @@ function extractPrTitle(summary, mode) {
542
625
  }
543
626
  }
544
627
 
545
- // Fallback: use first commit title or branch name
546
628
  return null;
547
629
  }
548
630
 
549
631
  function createPrWithGh(base, branch, title, body) {
550
632
  try {
551
- // Ensure branch is pushed first
552
633
  step('Pushing branch to remote...');
553
634
  try {
554
635
  execSync(`git push -u origin ${branch}`, {
@@ -557,7 +638,6 @@ function createPrWithGh(base, branch, title, body) {
557
638
  });
558
639
  success('Branch pushed to remote');
559
640
  } catch (error) {
560
- // Branch might already be pushed, check if it exists
561
641
  const remoteBranches = execSync('git branch -r', {
562
642
  encoding: 'utf8',
563
643
  });
@@ -568,11 +648,9 @@ function createPrWithGh(base, branch, title, body) {
568
648
  }
569
649
  }
570
650
 
571
- // Create PR using gh CLI
572
651
  step('Creating PR with GitHub CLI...');
573
652
  const prTitle = title || branch;
574
653
 
575
- // Write body to temp file to avoid shell escaping issues
576
654
  const bodyFile = path.join(
577
655
  os.tmpdir(),
578
656
  `pr-body-${Date.now().toString()}.md`
@@ -592,13 +670,11 @@ function createPrWithGh(base, branch, title, body) {
592
670
  bodyFile,
593
671
  ];
594
672
 
595
- // Add issue reference if provided via environment
596
673
  const issueEnv = process.env.PR_SUMMARY_ISSUE || '';
597
674
  if (issueEnv) {
598
675
  ghArgs.push('--issue', issueEnv);
599
676
  }
600
677
 
601
- // Use spawnSync for better argument handling
602
678
  const result = spawnSync('gh', ghArgs, {
603
679
  encoding: 'utf8',
604
680
  stdio: 'pipe',
@@ -616,7 +692,6 @@ function createPrWithGh(base, branch, title, body) {
616
692
 
617
693
  const prUrl = result.stdout.trim();
618
694
 
619
- // Cleanup temp file
620
695
  try {
621
696
  fs.unlinkSync(bodyFile);
622
697
  } catch {
@@ -675,6 +750,10 @@ function updatePrWithGh(prNumber, title, body) {
675
750
  }
676
751
  }
677
752
 
753
+ // ---------------------------------------------------------------------------
754
+ // CLI
755
+ // ---------------------------------------------------------------------------
756
+
678
757
  function parseArgs(argv) {
679
758
  const args = {
680
759
  base: DEFAULT_BASE,
@@ -716,21 +795,31 @@ function parseArgs(argv) {
716
795
  return args;
717
796
  }
718
797
 
798
+ // ---------------------------------------------------------------------------
799
+ // Main
800
+ // ---------------------------------------------------------------------------
801
+
719
802
  async function main(argv) {
720
- if (!process.env.GROQ_API_KEY || process.env.GROQ_API_KEY === '') {
721
- fail('GROQ_API_KEY environment variable is required');
722
- step('Set it in .env.local: GROQ_API_KEY="your-key-here"');
803
+ const providerInfo = createClient();
804
+ if (!providerInfo) {
805
+ fail('No API key found. Set CEREBRAS_API_KEY or GROQ_API_KEY in .env.local');
723
806
  process.exit(1);
724
807
  }
725
808
 
809
+ const { client, provider, defaultModel } = providerInfo;
810
+ const model =
811
+ process.env.CHANGESCRIBE_MODEL ||
812
+ process.env.GROQ_PR_MODEL ||
813
+ process.env.GROQ_MODEL ||
814
+ defaultModel;
815
+
726
816
  const args = parseArgs(argv);
727
817
  const branch = runGit('git branch --show-current').trim();
728
818
  const mode =
729
819
  args.mode ||
730
820
  (branch === 'staging' && args.base === 'main' ? 'release' : 'feature');
731
- const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
732
821
 
733
- process.stdout.write(`${banner(branch, args.base)}\n`);
822
+ process.stdout.write(`${banner(branch, args.base, provider)}\n`);
734
823
 
735
824
  const didFetch = tryFetchBase(args.base);
736
825
  if (!didFetch) {
@@ -753,6 +842,8 @@ async function main(argv) {
753
842
  step(`Issue: ${args.issue || '(not provided)'}`);
754
843
  step(`Create PR: ${args.createPr ? 'yes' : 'no'}`);
755
844
  step(`Mode: ${mode}`);
845
+ step(`Provider: ${provider}`);
846
+ step(`Model: ${model}`);
756
847
  return;
757
848
  }
758
849
 
@@ -765,7 +856,6 @@ async function main(argv) {
765
856
 
766
857
  // Safety checks before creating PR
767
858
  if (args.createPr) {
768
- // Run format as a last-minute verification step
769
859
  if (args.skipFormat) {
770
860
  warn('Skipping format step (flagged)');
771
861
  } else if (!hasNpmScript('format')) {
@@ -780,7 +870,6 @@ async function main(argv) {
780
870
  }
781
871
  }
782
872
 
783
- // Run tests as an extra verification step
784
873
  step('Running npm test before PR creation...');
785
874
  try {
786
875
  execSync('npm test', { encoding: 'utf8', stdio: 'inherit' });
@@ -789,7 +878,6 @@ async function main(argv) {
789
878
  process.exit(1);
790
879
  }
791
880
 
792
- // Run build as a final verification step
793
881
  step('Running npm run build before PR creation...');
794
882
  try {
795
883
  execSync('npm run build', { encoding: 'utf8', stdio: 'inherit' });
@@ -798,14 +886,12 @@ async function main(argv) {
798
886
  process.exit(1);
799
887
  }
800
888
 
801
- // Check for uncommitted changes after formatting
802
889
  if (checkUncommittedChanges()) {
803
890
  fail('You have uncommitted changes; please commit them first.');
804
891
  step('Run: git add . && git commit -m "your message"');
805
892
  process.exit(1);
806
893
  }
807
894
 
808
- // Check for existing open PR
809
895
  const existingPr = checkExistingPr(args.base, branch);
810
896
  if (existingPr) {
811
897
  warn(`Found existing PR #${existingPr.number}: ${existingPr.title}`);
@@ -814,16 +900,16 @@ async function main(argv) {
814
900
 
815
901
  step(`Collecting ${commits.length} commits from ${baseRef}..HEAD`);
816
902
 
903
+ // Enrich commits with diff context for better LLM understanding
904
+ enrichCommitsWithDiffs(commits);
905
+
906
+ // Pass 1: 5Cs snapshot from titles only
817
907
  const pass1Messages = buildPass1Messages(commits, branch, baseRef);
818
- const pass1 = await createCompletionSafe(
819
- groq,
820
- pass1Messages,
821
- DEFAULT_MODEL,
822
- 2048
823
- );
908
+ const pass1 = await createCompletionSafe(client, pass1Messages, model, 2048);
824
909
  const pass1Text = pass1?.choices?.[0]?.message?.content?.trim() || '';
825
910
  success('Pass 1 complete (5Cs snapshot)');
826
911
 
912
+ // Pass 2: per-commit condensation with diff context
827
913
  const chunks = chunkCommits(commits, CHUNK_SIZE_CHARS);
828
914
  step(`Pass 2 across ${chunks.length} chunk(s)`);
829
915
  const pass2Outputs = [];
@@ -831,10 +917,10 @@ async function main(argv) {
831
917
  const messages = buildPass2Messages(chunk);
832
918
  // biome-ignore lint/nursery/noAwaitInLoop: sequential LLM calls to avoid rate limits and keep output order predictable
833
919
  const completion = await createCompletionSafe(
834
- groq,
920
+ client,
835
921
  messages,
836
- DEFAULT_MODEL,
837
- 2048
922
+ model,
923
+ 4096
838
924
  );
839
925
  const chunkText = completion?.choices?.[0]?.message?.content?.trim();
840
926
  if (chunkText) {
@@ -843,6 +929,7 @@ async function main(argv) {
843
929
  }
844
930
  success('Pass 2 complete (per-commit condensation)');
845
931
 
932
+ // Pass 3: synthesize PR summary
846
933
  const pass3Messages =
847
934
  mode === 'release'
848
935
  ? buildReleaseMessages(
@@ -861,12 +948,7 @@ async function main(argv) {
861
948
  pass1Text,
862
949
  formatCommitTitles(commits, 40)
863
950
  );
864
- const pass3 = await createCompletionSafe(
865
- groq,
866
- pass3Messages,
867
- DEFAULT_MODEL,
868
- 2048
869
- );
951
+ const pass3 = await createCompletionSafe(client, pass3Messages, model, 4096);
870
952
  let finalSummary = pass3?.choices?.[0]?.message?.content?.trim() || '';
871
953
  if (isUnknownSummary(finalSummary, mode)) {
872
954
  warn('Pass 3 summary returned Unknown; retrying with fallback context...');
@@ -889,10 +971,10 @@ async function main(argv) {
889
971
  formatCommitTitles(commits, 80)
890
972
  );
891
973
  const retry = await createCompletionSafe(
892
- groq,
974
+ client,
893
975
  retryMessages,
894
- DEFAULT_MODEL,
895
- 2048
976
+ model,
977
+ 4096
896
978
  );
897
979
  finalSummary =
898
980
  retry?.choices?.[0]?.message?.content?.trim() || finalSummary;
@@ -921,7 +1003,6 @@ async function main(argv) {
921
1003
  ? args.out
922
1004
  : path.join(process.cwd(), args.out);
923
1005
 
924
- // Ensure output directory exists
925
1006
  const outDir = path.dirname(resolvedOut);
926
1007
  if (!fs.existsSync(outDir)) {
927
1008
  fs.mkdirSync(outDir, { recursive: true });
@@ -930,7 +1011,6 @@ async function main(argv) {
930
1011
  fs.writeFileSync(resolvedOut, fullOutput, 'utf8');
931
1012
  success(`PR summary written to ${resolvedOut}`);
932
1013
 
933
- // Write a slim, PR-ready version without appendices to avoid GH body limits
934
1014
  const finalOutPath = path.join(
935
1015
  path.dirname(resolvedOut),
936
1016
  `${path.basename(resolvedOut, path.extname(resolvedOut))}.final.md`
@@ -938,7 +1018,6 @@ async function main(argv) {
938
1018
  fs.writeFileSync(finalOutPath, prBlock, 'utf8');
939
1019
  success(`PR-ready (slim) summary written to ${finalOutPath}`);
940
1020
 
941
- // Also stash an autosave in tmp for debugging
942
1021
  const tmpPath = path.join(
943
1022
  os.tmpdir(),
944
1023
  `pr-summary-${Date.now().toString()}-${path.basename(resolvedOut)}`
@@ -946,7 +1025,6 @@ async function main(argv) {
946
1025
  fs.writeFileSync(tmpPath, fullOutput, 'utf8');
947
1026
  warn(`Backup copy saved to ${tmpPath}`);
948
1027
 
949
- // Create PR if requested
950
1028
  if (args.createPr) {
951
1029
  const prTitle = extractPrTitle(finalSummary, mode) || branch;
952
1030
  try {
@@ -957,7 +1035,6 @@ async function main(argv) {
957
1035
  createPrWithGh(args.base, branch, prTitle, finalSummary);
958
1036
  }
959
1037
  } catch (_error) {
960
- // Error already logged in createPrWithGh
961
1038
  process.exit(1);
962
1039
  }
963
1040
  }
@@ -0,0 +1,42 @@
1
+ const OpenAI = require('openai');
2
+
3
+ /**
4
+ * Create an LLM client that works with Cerebras or Groq.
5
+ *
6
+ * Priority:
7
+ * 1. CEREBRAS_API_KEY → Cerebras (64K TPM, 1M TPD)
8
+ * 2. GROQ_API_KEY → Groq (fallback)
9
+ *
10
+ * Both providers expose an OpenAI-compatible API so we use the
11
+ * `openai` SDK for both, swapping only baseURL and model name.
12
+ */
13
+ function createClient() {
14
+ const cerebrasKey = process.env.CEREBRAS_API_KEY;
15
+ const groqKey = process.env.GROQ_API_KEY;
16
+
17
+ if (cerebrasKey) {
18
+ return {
19
+ client: new OpenAI({
20
+ apiKey: cerebrasKey,
21
+ baseURL: 'https://api.cerebras.ai/v1',
22
+ }),
23
+ provider: 'cerebras',
24
+ defaultModel: 'gpt-oss-120b',
25
+ };
26
+ }
27
+
28
+ if (groqKey) {
29
+ return {
30
+ client: new OpenAI({
31
+ apiKey: groqKey,
32
+ baseURL: 'https://api.groq.com/openai/v1',
33
+ }),
34
+ provider: 'groq',
35
+ defaultModel: 'openai/gpt-oss-120b',
36
+ };
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ module.exports = { createClient };