cli-changescribe 0.1.2 → 0.2.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
@@ -1,11 +1,30 @@
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
 
7
+ Pick the install command that matches your repo's package manager:
8
+
7
9
  ```bash
10
+ # npm
8
11
  npm install -g cli-changescribe
12
+ # or in a repo
13
+ npm install cli-changescribe
14
+ ```
15
+
16
+ ```bash
17
+ # pnpm
18
+ pnpm add -g cli-changescribe
19
+ # or in a repo
20
+ pnpm add cli-changescribe
21
+ ```
22
+
23
+ ```bash
24
+ # yarn
25
+ yarn global add cli-changescribe
26
+ # or in a repo
27
+ yarn add cli-changescribe
9
28
  ```
10
29
 
11
30
  ## Setup
@@ -13,13 +32,22 @@ npm install -g cli-changescribe
13
32
  Create a `.env.local` file in the repo where you run the CLI:
14
33
 
15
34
  ```bash
35
+ # Pick one (Cerebras is preferred for higher throughput)
36
+ CEREBRAS_API_KEY="your-key-here"
37
+ # or
16
38
  GROQ_API_KEY="your-key-here"
17
39
  ```
18
40
 
41
+ Provider priority: if both keys are set, Cerebras is used.
42
+
43
+ If your repo uses `pnpm` or `yarn`, make sure you install `cli-changescribe`
44
+ with the same package manager so the correct lockfile is updated (Vercel uses
45
+ `frozen-lockfile` by default).
46
+
19
47
  ### Setup process (recommended)
20
48
 
21
49
  1. Install `cli-changescribe` (global or per repo).
22
- 2. Add `.env.local` with `GROQ_API_KEY`.
50
+ 2. Add `.env.local` with `CEREBRAS_API_KEY` or `GROQ_API_KEY`.
23
51
  3. Run `npx changescribe init` to add npm scripts.
24
52
  4. If you plan to use `--create-pr`, install and auth GitHub CLI: `gh auth login`.
25
53
  5. Run a dry run to validate:
@@ -32,8 +60,8 @@ Optional environment variables for PR summaries:
32
60
  - `PR_SUMMARY_OUT` (default: `.pr-summaries/PR_SUMMARY.md`)
33
61
  - `PR_SUMMARY_LIMIT` (default: `400`)
34
62
  - `PR_SUMMARY_ISSUE` (default: empty)
35
- - `GROQ_PR_MODEL` (default: `openai/gpt-oss-120b`)
36
- - `GROQ_MODEL` (fallback)
63
+ - `CHANGESCRIBE_MODEL` (override model name for any provider)
64
+ - `GROQ_PR_MODEL` / `GROQ_MODEL` (legacy overrides, still supported)
37
65
 
38
66
  ## Usage
39
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-changescribe",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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/init.js CHANGED
@@ -42,6 +42,18 @@ function runInit(cwd = process.cwd()) {
42
42
  process.exit(1);
43
43
  }
44
44
 
45
+ const pnpmLock = path.join(cwd, 'pnpm-lock.yaml');
46
+ const yarnLock = path.join(cwd, 'yarn.lock');
47
+ if (fs.existsSync(pnpmLock)) {
48
+ console.warn(
49
+ '⚠️ pnpm-lock.yaml detected. Use pnpm to install/update dependencies so the lockfile stays in sync.'
50
+ );
51
+ } else if (fs.existsSync(yarnLock)) {
52
+ console.warn(
53
+ '⚠️ yarn.lock detected. Use yarn to install/update dependencies so the lockfile stays in sync.'
54
+ );
55
+ }
56
+
45
57
  const pkg = readPackageJson(packagePath);
46
58
  const added = ensureScripts(pkg);
47
59
  writePackageJson(packagePath, pkg);
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,44 @@ function runGit(command) {
75
74
  }
76
75
  }
77
76
 
77
+ // ---------------------------------------------------------------------------
78
+ // Diff enrichment
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function getCommitDiffInfo(sha, title) {
82
+ // Skip diff for merge commits — they produce combined diffs that are noisy
83
+ if (title.toLowerCase().startsWith('merge')) {
84
+ return { stat: '(merge commit)', diff: '' };
85
+ }
86
+ try {
87
+ const stat = execSync(`git show ${sha} --stat --format=""`, {
88
+ encoding: 'utf8',
89
+ maxBuffer: LARGE_BUFFER_SIZE,
90
+ }).trim();
91
+
92
+ let diff = '';
93
+ try {
94
+ diff = execSync(`git show ${sha} -U3 --format="" --diff-filter=ACMRT`, {
95
+ encoding: 'utf8',
96
+ maxBuffer: LARGE_BUFFER_SIZE,
97
+ });
98
+ if (diff.length > DIFF_PER_COMMIT_CHARS) {
99
+ diff = `${diff.slice(0, DIFF_PER_COMMIT_CHARS)}\n...[truncated]...`;
100
+ }
101
+ } catch {
102
+ diff = '(diff unavailable)';
103
+ }
104
+
105
+ return { stat, diff };
106
+ } catch {
107
+ return { stat: '(unavailable)', diff: '' };
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Commit collection
113
+ // ---------------------------------------------------------------------------
114
+
78
115
  function collectCommits(baseRef, limit) {
79
116
  const range = `${baseRef}..HEAD`;
80
117
  let rawLog = '';
@@ -101,7 +138,7 @@ function collectCommits(baseRef, limit) {
101
138
  .map((entry) => {
102
139
  const [sha = '', title = '', bodyRaw = ''] = entry.split('\x1f');
103
140
  const body = bodyRaw.trim().slice(0, BODY_TRUNCATION);
104
- return { sha: sha.trim(), title: title.trim(), body };
141
+ return { sha: sha.trim(), title: title.trim(), body, stat: '', diff: '' };
105
142
  });
106
143
 
107
144
  if (Number.isFinite(limit) && limit > 0 && commits.length > limit) {
@@ -111,6 +148,16 @@ function collectCommits(baseRef, limit) {
111
148
  return commits;
112
149
  }
113
150
 
151
+ function enrichCommitsWithDiffs(commits) {
152
+ step('Enriching commits with diff context...');
153
+ for (const commit of commits) {
154
+ const info = getCommitDiffInfo(commit.sha, commit.title);
155
+ commit.stat = info.stat;
156
+ commit.diff = info.diff;
157
+ }
158
+ success(`Enriched ${commits.length} commits with diffs`);
159
+ }
160
+
114
161
  function tryFetchBase(baseBranch) {
115
162
  try {
116
163
  execSync(`git fetch origin ${baseBranch}`, {
@@ -146,6 +193,10 @@ function resolveBaseRef(baseBranch) {
146
193
  }
147
194
  }
148
195
 
196
+ // ---------------------------------------------------------------------------
197
+ // Chunking
198
+ // ---------------------------------------------------------------------------
199
+
149
200
  function chunkCommits(commits, maxChars) {
150
201
  const chunks = [];
151
202
  let current = [];
@@ -170,24 +221,40 @@ function chunkCommits(commits, maxChars) {
170
221
  }
171
222
 
172
223
  function serializeCommit(commit) {
173
- return `${commit.sha}\n${commit.title}\n${commit.body}\n---\n`;
224
+ const parts = [commit.sha, commit.title, commit.body];
225
+ if (commit.stat) {
226
+ parts.push(commit.stat);
227
+ }
228
+ if (commit.diff) {
229
+ parts.push(commit.diff);
230
+ }
231
+ parts.push('---');
232
+ return `${parts.join('\n')}\n`;
174
233
  }
175
234
 
176
- async function createCompletionSafe(groq, messages, model, maxTokens) {
235
+ // ---------------------------------------------------------------------------
236
+ // LLM helpers
237
+ // ---------------------------------------------------------------------------
238
+
239
+ async function createCompletionSafe(client, messages, model, maxTokens) {
177
240
  try {
178
- return await groq.chat.completions.create({
241
+ return await client.chat.completions.create({
179
242
  messages,
180
243
  model,
181
244
  temperature: 0.3,
182
245
  max_tokens: maxTokens,
183
246
  });
184
247
  } catch (error) {
185
- fail('Groq API error while creating completion');
248
+ fail('LLM API error while creating completion');
186
249
  step(formatError(error));
187
250
  process.exit(1);
188
251
  }
189
252
  }
190
253
 
254
+ // ---------------------------------------------------------------------------
255
+ // Prompt builders
256
+ // ---------------------------------------------------------------------------
257
+
191
258
  function buildPass1Messages(commits, branch, base) {
192
259
  const titles = commits
193
260
  .map((commit) => `- ${commit.title || '(no title)'}`)
@@ -216,21 +283,28 @@ function buildPass1Messages(commits, branch, base) {
216
283
 
217
284
  function buildPass2Messages(commitsChunk) {
218
285
  const body = commitsChunk
219
- .map((commit) =>
220
- [
286
+ .map((commit) => {
287
+ const parts = [
221
288
  `SHA: ${commit.sha}`,
222
289
  `Title: ${commit.title || '(no title)'}`,
223
290
  `Body:\n${commit.body || '(no body)'}`,
224
- '---',
225
- ].join('\n')
226
- )
291
+ ];
292
+ if (commit.stat) {
293
+ parts.push(`Files changed:\n${commit.stat}`);
294
+ }
295
+ if (commit.diff) {
296
+ parts.push(`Diff:\n${commit.diff}`);
297
+ }
298
+ parts.push('---');
299
+ return parts.join('\n');
300
+ })
227
301
  .join('\n');
228
302
 
229
303
  return [
230
304
  {
231
305
  role: 'system',
232
306
  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.',
307
+ '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.',
234
308
  },
235
309
  {
236
310
  role: 'user',
@@ -239,9 +313,9 @@ function buildPass2Messages(commitsChunk) {
239
313
  body,
240
314
  '',
241
315
  '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.',
316
+ '- Title-aligned bullet: what changed + why (use the diff to be specific about files and code patterns).',
317
+ '- Risk or test note if visible in the diff.',
318
+ 'Keep outputs brief; do not restate bodies verbatim.',
245
319
  ].join('\n'),
246
320
  },
247
321
  ];
@@ -286,7 +360,9 @@ function buildPass3Messages(
286
360
  'Rules:',
287
361
  '- If the issue is unknown, write: "Related: (not provided)".',
288
362
  '- If testing is unknown, write: "Testing: (not provided)".',
289
- '- Prefer bullets. Be thorough but not rambly.',
363
+ '- 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.',
364
+ '- Be thorough and specific. Reference actual file names, functions, and architectural changes.',
365
+ '- Prefer bullets.',
290
366
  '',
291
367
  `Issue hint: ${issue || '(not provided)'}`,
292
368
  ].join('\n'),
@@ -333,7 +409,9 @@ function buildReleaseMessages(
333
409
  '',
334
410
  'Rules:',
335
411
  '- If unknown, write: "Unknown".',
336
- '- Prefer bullets. Be thorough but not rambly.',
412
+ '- Every commit must appear as its own bullet. Do not group commits under catch-all labels like "miscellaneous".',
413
+ '- Be thorough and specific. Reference actual changes from the commit summaries.',
414
+ '- Prefer bullets.',
337
415
  '',
338
416
  `Issue hint: ${issue || '(not provided)'}`,
339
417
  ].join('\n'),
@@ -341,6 +419,10 @@ function buildReleaseMessages(
341
419
  ];
342
420
  }
343
421
 
422
+ // ---------------------------------------------------------------------------
423
+ // Utilities
424
+ // ---------------------------------------------------------------------------
425
+
344
426
  function formatError(error) {
345
427
  const plain = {};
346
428
  for (const key of Object.getOwnPropertyNames(error)) {
@@ -438,6 +520,10 @@ function isUnknownSummary(summary, mode) {
438
520
  return unknownCount === headings.length;
439
521
  }
440
522
 
523
+ // ---------------------------------------------------------------------------
524
+ // GitHub helpers
525
+ // ---------------------------------------------------------------------------
526
+
441
527
  function checkGhCli() {
442
528
  try {
443
529
  execSync('gh --version', { encoding: 'utf8', stdio: 'ignore' });
@@ -461,7 +547,6 @@ function checkUncommittedChanges() {
461
547
 
462
548
  function checkExistingPr(base, head) {
463
549
  try {
464
- // Check for open PRs from head to base
465
550
  const result = spawnSync(
466
551
  'gh',
467
552
  [
@@ -483,14 +568,12 @@ function checkExistingPr(base, head) {
483
568
  );
484
569
 
485
570
  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
571
  return null;
488
572
  }
489
573
 
490
574
  const prs = JSON.parse(result.stdout || '[]');
491
575
  return prs.length > 0 ? prs[0] : null;
492
576
  } catch {
493
- // If parsing fails or command fails, assume no PR exists
494
577
  return null;
495
578
  }
496
579
  }
@@ -508,33 +591,27 @@ function hasNpmScript(scriptName, cwd = process.cwd()) {
508
591
 
509
592
  function extractPrTitle(summary, mode) {
510
593
  const targetSection = mode === 'release' ? 'release summary' : 'what change';
511
- // Try to extract a title from the summary
512
594
  const lines = summary.split('\n');
513
595
  let inChangesSection = false;
514
596
 
515
597
  for (const line of lines) {
516
598
  const trimmed = line.trim();
517
599
 
518
- // Detect the target section
519
600
  if (trimmed.toLowerCase().includes(targetSection)) {
520
601
  inChangesSection = true;
521
602
  continue;
522
603
  }
523
604
 
524
- // If we hit another section heading, stop looking
525
605
  if (inChangesSection && trimmed && trimmed.endsWith('?')) {
526
606
  break;
527
607
  }
528
608
 
529
- // If we're in the changes section, look for first bullet point
530
609
  if (inChangesSection && trimmed.startsWith('-')) {
531
- // Extract bullet content, clean it up
532
610
  const bullet = trimmed.slice(1).trim();
533
- // Remove markdown formatting and truncate
534
611
  const clean = bullet
535
- .replace(/`([^`]+)`/g, '$1') // Remove backticks
536
- .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
537
- .replace(/\*([^*]+)\*/g, '$1') // Remove italic
612
+ .replace(/`([^`]+)`/g, '$1')
613
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
614
+ .replace(/\*([^*]+)\*/g, '$1')
538
615
  .slice(0, 100);
539
616
  if (clean.length > 10) {
540
617
  return clean;
@@ -542,13 +619,11 @@ function extractPrTitle(summary, mode) {
542
619
  }
543
620
  }
544
621
 
545
- // Fallback: use first commit title or branch name
546
622
  return null;
547
623
  }
548
624
 
549
625
  function createPrWithGh(base, branch, title, body) {
550
626
  try {
551
- // Ensure branch is pushed first
552
627
  step('Pushing branch to remote...');
553
628
  try {
554
629
  execSync(`git push -u origin ${branch}`, {
@@ -557,7 +632,6 @@ function createPrWithGh(base, branch, title, body) {
557
632
  });
558
633
  success('Branch pushed to remote');
559
634
  } catch (error) {
560
- // Branch might already be pushed, check if it exists
561
635
  const remoteBranches = execSync('git branch -r', {
562
636
  encoding: 'utf8',
563
637
  });
@@ -568,11 +642,9 @@ function createPrWithGh(base, branch, title, body) {
568
642
  }
569
643
  }
570
644
 
571
- // Create PR using gh CLI
572
645
  step('Creating PR with GitHub CLI...');
573
646
  const prTitle = title || branch;
574
647
 
575
- // Write body to temp file to avoid shell escaping issues
576
648
  const bodyFile = path.join(
577
649
  os.tmpdir(),
578
650
  `pr-body-${Date.now().toString()}.md`
@@ -592,13 +664,11 @@ function createPrWithGh(base, branch, title, body) {
592
664
  bodyFile,
593
665
  ];
594
666
 
595
- // Add issue reference if provided via environment
596
667
  const issueEnv = process.env.PR_SUMMARY_ISSUE || '';
597
668
  if (issueEnv) {
598
669
  ghArgs.push('--issue', issueEnv);
599
670
  }
600
671
 
601
- // Use spawnSync for better argument handling
602
672
  const result = spawnSync('gh', ghArgs, {
603
673
  encoding: 'utf8',
604
674
  stdio: 'pipe',
@@ -616,7 +686,6 @@ function createPrWithGh(base, branch, title, body) {
616
686
 
617
687
  const prUrl = result.stdout.trim();
618
688
 
619
- // Cleanup temp file
620
689
  try {
621
690
  fs.unlinkSync(bodyFile);
622
691
  } catch {
@@ -675,6 +744,10 @@ function updatePrWithGh(prNumber, title, body) {
675
744
  }
676
745
  }
677
746
 
747
+ // ---------------------------------------------------------------------------
748
+ // CLI
749
+ // ---------------------------------------------------------------------------
750
+
678
751
  function parseArgs(argv) {
679
752
  const args = {
680
753
  base: DEFAULT_BASE,
@@ -716,21 +789,31 @@ function parseArgs(argv) {
716
789
  return args;
717
790
  }
718
791
 
792
+ // ---------------------------------------------------------------------------
793
+ // Main
794
+ // ---------------------------------------------------------------------------
795
+
719
796
  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"');
797
+ const providerInfo = createClient();
798
+ if (!providerInfo) {
799
+ fail('No API key found. Set CEREBRAS_API_KEY or GROQ_API_KEY in .env.local');
723
800
  process.exit(1);
724
801
  }
725
802
 
803
+ const { client, provider, defaultModel } = providerInfo;
804
+ const model =
805
+ process.env.CHANGESCRIBE_MODEL ||
806
+ process.env.GROQ_PR_MODEL ||
807
+ process.env.GROQ_MODEL ||
808
+ defaultModel;
809
+
726
810
  const args = parseArgs(argv);
727
811
  const branch = runGit('git branch --show-current').trim();
728
812
  const mode =
729
813
  args.mode ||
730
814
  (branch === 'staging' && args.base === 'main' ? 'release' : 'feature');
731
- const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
732
815
 
733
- process.stdout.write(`${banner(branch, args.base)}\n`);
816
+ process.stdout.write(`${banner(branch, args.base, provider)}\n`);
734
817
 
735
818
  const didFetch = tryFetchBase(args.base);
736
819
  if (!didFetch) {
@@ -753,6 +836,8 @@ async function main(argv) {
753
836
  step(`Issue: ${args.issue || '(not provided)'}`);
754
837
  step(`Create PR: ${args.createPr ? 'yes' : 'no'}`);
755
838
  step(`Mode: ${mode}`);
839
+ step(`Provider: ${provider}`);
840
+ step(`Model: ${model}`);
756
841
  return;
757
842
  }
758
843
 
@@ -765,7 +850,6 @@ async function main(argv) {
765
850
 
766
851
  // Safety checks before creating PR
767
852
  if (args.createPr) {
768
- // Run format as a last-minute verification step
769
853
  if (args.skipFormat) {
770
854
  warn('Skipping format step (flagged)');
771
855
  } else if (!hasNpmScript('format')) {
@@ -780,7 +864,6 @@ async function main(argv) {
780
864
  }
781
865
  }
782
866
 
783
- // Run tests as an extra verification step
784
867
  step('Running npm test before PR creation...');
785
868
  try {
786
869
  execSync('npm test', { encoding: 'utf8', stdio: 'inherit' });
@@ -789,7 +872,6 @@ async function main(argv) {
789
872
  process.exit(1);
790
873
  }
791
874
 
792
- // Run build as a final verification step
793
875
  step('Running npm run build before PR creation...');
794
876
  try {
795
877
  execSync('npm run build', { encoding: 'utf8', stdio: 'inherit' });
@@ -798,14 +880,12 @@ async function main(argv) {
798
880
  process.exit(1);
799
881
  }
800
882
 
801
- // Check for uncommitted changes after formatting
802
883
  if (checkUncommittedChanges()) {
803
884
  fail('You have uncommitted changes; please commit them first.');
804
885
  step('Run: git add . && git commit -m "your message"');
805
886
  process.exit(1);
806
887
  }
807
888
 
808
- // Check for existing open PR
809
889
  const existingPr = checkExistingPr(args.base, branch);
810
890
  if (existingPr) {
811
891
  warn(`Found existing PR #${existingPr.number}: ${existingPr.title}`);
@@ -814,16 +894,16 @@ async function main(argv) {
814
894
 
815
895
  step(`Collecting ${commits.length} commits from ${baseRef}..HEAD`);
816
896
 
897
+ // Enrich commits with diff context for better LLM understanding
898
+ enrichCommitsWithDiffs(commits);
899
+
900
+ // Pass 1: 5Cs snapshot from titles only
817
901
  const pass1Messages = buildPass1Messages(commits, branch, baseRef);
818
- const pass1 = await createCompletionSafe(
819
- groq,
820
- pass1Messages,
821
- DEFAULT_MODEL,
822
- 2048
823
- );
902
+ const pass1 = await createCompletionSafe(client, pass1Messages, model, 2048);
824
903
  const pass1Text = pass1?.choices?.[0]?.message?.content?.trim() || '';
825
904
  success('Pass 1 complete (5Cs snapshot)');
826
905
 
906
+ // Pass 2: per-commit condensation with diff context
827
907
  const chunks = chunkCommits(commits, CHUNK_SIZE_CHARS);
828
908
  step(`Pass 2 across ${chunks.length} chunk(s)`);
829
909
  const pass2Outputs = [];
@@ -831,10 +911,10 @@ async function main(argv) {
831
911
  const messages = buildPass2Messages(chunk);
832
912
  // biome-ignore lint/nursery/noAwaitInLoop: sequential LLM calls to avoid rate limits and keep output order predictable
833
913
  const completion = await createCompletionSafe(
834
- groq,
914
+ client,
835
915
  messages,
836
- DEFAULT_MODEL,
837
- 2048
916
+ model,
917
+ 4096
838
918
  );
839
919
  const chunkText = completion?.choices?.[0]?.message?.content?.trim();
840
920
  if (chunkText) {
@@ -843,6 +923,7 @@ async function main(argv) {
843
923
  }
844
924
  success('Pass 2 complete (per-commit condensation)');
845
925
 
926
+ // Pass 3: synthesize PR summary
846
927
  const pass3Messages =
847
928
  mode === 'release'
848
929
  ? buildReleaseMessages(
@@ -861,12 +942,7 @@ async function main(argv) {
861
942
  pass1Text,
862
943
  formatCommitTitles(commits, 40)
863
944
  );
864
- const pass3 = await createCompletionSafe(
865
- groq,
866
- pass3Messages,
867
- DEFAULT_MODEL,
868
- 2048
869
- );
945
+ const pass3 = await createCompletionSafe(client, pass3Messages, model, 4096);
870
946
  let finalSummary = pass3?.choices?.[0]?.message?.content?.trim() || '';
871
947
  if (isUnknownSummary(finalSummary, mode)) {
872
948
  warn('Pass 3 summary returned Unknown; retrying with fallback context...');
@@ -889,10 +965,10 @@ async function main(argv) {
889
965
  formatCommitTitles(commits, 80)
890
966
  );
891
967
  const retry = await createCompletionSafe(
892
- groq,
968
+ client,
893
969
  retryMessages,
894
- DEFAULT_MODEL,
895
- 2048
970
+ model,
971
+ 4096
896
972
  );
897
973
  finalSummary =
898
974
  retry?.choices?.[0]?.message?.content?.trim() || finalSummary;
@@ -921,7 +997,6 @@ async function main(argv) {
921
997
  ? args.out
922
998
  : path.join(process.cwd(), args.out);
923
999
 
924
- // Ensure output directory exists
925
1000
  const outDir = path.dirname(resolvedOut);
926
1001
  if (!fs.existsSync(outDir)) {
927
1002
  fs.mkdirSync(outDir, { recursive: true });
@@ -930,7 +1005,6 @@ async function main(argv) {
930
1005
  fs.writeFileSync(resolvedOut, fullOutput, 'utf8');
931
1006
  success(`PR summary written to ${resolvedOut}`);
932
1007
 
933
- // Write a slim, PR-ready version without appendices to avoid GH body limits
934
1008
  const finalOutPath = path.join(
935
1009
  path.dirname(resolvedOut),
936
1010
  `${path.basename(resolvedOut, path.extname(resolvedOut))}.final.md`
@@ -938,7 +1012,6 @@ async function main(argv) {
938
1012
  fs.writeFileSync(finalOutPath, prBlock, 'utf8');
939
1013
  success(`PR-ready (slim) summary written to ${finalOutPath}`);
940
1014
 
941
- // Also stash an autosave in tmp for debugging
942
1015
  const tmpPath = path.join(
943
1016
  os.tmpdir(),
944
1017
  `pr-summary-${Date.now().toString()}-${path.basename(resolvedOut)}`
@@ -946,7 +1019,6 @@ async function main(argv) {
946
1019
  fs.writeFileSync(tmpPath, fullOutput, 'utf8');
947
1020
  warn(`Backup copy saved to ${tmpPath}`);
948
1021
 
949
- // Create PR if requested
950
1022
  if (args.createPr) {
951
1023
  const prTitle = extractPrTitle(finalSummary, mode) || branch;
952
1024
  try {
@@ -957,7 +1029,6 @@ async function main(argv) {
957
1029
  createPrWithGh(args.base, branch, prTitle, finalSummary);
958
1030
  }
959
1031
  } catch (_error) {
960
- // Error already logged in createPrWithGh
961
1032
  process.exit(1);
962
1033
  }
963
1034
  }
@@ -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 };