@kyubiware/commit-mint 0.2.1 → 0.3.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/dist/cli.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli, command } from "cleye";
3
3
  import * as p from "@clack/prompts";
4
- import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
4
+ import { intro, isCancel, log, note, outro, select, spinner } from "@clack/prompts";
5
5
  import { bold, cyan, dim, green, red, yellow } from "kolorist";
6
- import Groq from "groq-sdk";
7
- import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
8
7
  import os from "node:os";
9
8
  import { join } from "node:path";
10
9
  import ini from "ini";
11
10
  import { execa } from "execa";
12
11
  import { spawn } from "node:child_process";
12
+ import Groq from "groq-sdk";
13
13
  import { createHash } from "node:crypto";
14
14
  //#region \0rolldown/runtime.js
15
15
  var __defProp = Object.defineProperty;
@@ -26,7 +26,7 @@ var __exportAll = (all, no_symbols) => {
26
26
  //#region package.json
27
27
  var package_default = {
28
28
  name: "@kyubiware/commit-mint",
29
- version: "0.2.1",
29
+ version: "0.3.1",
30
30
  description: "A commit tool that actually handles hook failures",
31
31
  type: "module",
32
32
  bin: { "cmint": "./dist/cli.mjs" },
@@ -66,7 +66,7 @@ var package_default = {
66
66
  "url": "https://github.com/kyubiware/commit-mint.git"
67
67
  },
68
68
  dependencies: {
69
- "@clack/prompts": "^0.9.1",
69
+ "@clack/prompts": "^1.4.0",
70
70
  "cleye": "^1.3.4",
71
71
  "execa": "^9.6.0",
72
72
  "groq-sdk": "^0.32.0",
@@ -97,171 +97,6 @@ function debug(...args) {
97
97
  console.error(dim(`[debug ${timestamp}]`), ...args);
98
98
  }
99
99
  //#endregion
100
- //#region src/services/ai.ts
101
- const MAX_DIFF_CHARS = 2e4;
102
- const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
103
- function stripThinkTags(text) {
104
- return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
105
- }
106
- function deriveMessageFromReasoning(reasoning) {
107
- const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
108
- if (match) return match[0].trim();
109
- const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
110
- return first ? first.trim() : null;
111
- }
112
- function stripContextLines(diff) {
113
- return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
114
- }
115
- function compressDiff(diff) {
116
- if (diff.length <= MAX_DIFF_CHARS) return diff;
117
- let result = stripContextLines(diff);
118
- if (result.length <= MAX_DIFF_CHARS) return result;
119
- result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
120
- return fd.split(/(?=\n@@)/).map((part, idx) => {
121
- if (idx === 0) return part;
122
- const lines = part.split("\n");
123
- return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
124
- }).join("");
125
- }).join("");
126
- if (result.length <= MAX_DIFF_CHARS) return result;
127
- return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
128
- const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
129
- return match && match[1] === match[2] ? `${match[1]} | changed` : "";
130
- }).filter(Boolean).join("\n")}`;
131
- }
132
- function buildStatSummary(diff) {
133
- const files = [];
134
- let currentFile = "";
135
- let adds = 0;
136
- let dels = 0;
137
- for (const line of diff.split("\n")) {
138
- const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
139
- if (match) {
140
- if (currentFile) files.push({
141
- name: currentFile,
142
- adds,
143
- dels
144
- });
145
- currentFile = match[1];
146
- adds = 0;
147
- dels = 0;
148
- } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
149
- else if (line.startsWith("-") && !line.startsWith("---")) dels++;
150
- }
151
- if (currentFile) files.push({
152
- name: currentFile,
153
- adds,
154
- dels
155
- });
156
- const totalAdds = files.reduce((s, f) => s + f.adds, 0);
157
- const totalDels = files.reduce((s, f) => s + f.dels, 0);
158
- const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
159
- lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
160
- return lines.join("\n");
161
- }
162
- function buildSystemPrompt(type) {
163
- let prompt = "You are a commit message generator. Follow the Conventional Commits specification.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.\nFormat: type(scope): description\nUse imperative mood, lowercase, no trailing period.\nOutput ONLY the commit message, no markdown fences, no explanation.";
164
- if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
165
- return prompt;
166
- }
167
- function buildUserPrompt(diff, hint, statSummary) {
168
- const parts = [];
169
- if (hint) parts.push(`Context: ${hint}`);
170
- if (statSummary) parts.push(`Change summary:\n${statSummary}`);
171
- parts.push(`Generate a conventional commit for:\n\n${diff}`);
172
- return parts.join("\n\n");
173
- }
174
- function isValidConventionalCommit(message) {
175
- return CONVENTIONAL_COMMIT_REGEX.test(message);
176
- }
177
- function extractContentText(content) {
178
- if (content == null) return "";
179
- if (typeof content === "string") return content.trim();
180
- if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
181
- return "";
182
- }
183
- async function generateCommitMessage(diff, options) {
184
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
185
- const timeoutMs = options.timeout ?? 6e4;
186
- debug("Timeout: %d ms", timeoutMs);
187
- const client = new Groq({
188
- apiKey: options.apiKey,
189
- timeout: timeoutMs
190
- });
191
- const compressedDiff = compressDiff(diff);
192
- const statSummary = buildStatSummary(diff);
193
- const systemPrompt = buildSystemPrompt(options.type);
194
- const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
195
- debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
196
- debug("Stat summary:\n%s", statSummary);
197
- debug("User prompt length: %d chars", userPrompt.length);
198
- async function callAI(strictSystemPrompt) {
199
- const callStart = Date.now();
200
- debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
201
- try {
202
- const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
203
- const completion = await client.chat.completions.create({
204
- messages: [{
205
- role: "system",
206
- content: strictSystemPrompt ?? systemPrompt
207
- }, {
208
- role: "user",
209
- content: userPrompt
210
- }],
211
- model: options.model ?? "openai/gpt-oss-20b",
212
- temperature: .3,
213
- ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
214
- reasoning_format: "parsed"
215
- });
216
- const elapsed = Date.now() - callStart;
217
- const rawContent = completion.choices[0]?.message?.content;
218
- const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
219
- debug("callAI response (%d ms): choices=%d, finishReason=%s, contentLen=%d, rawType=%s", elapsed, completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length, typeof rawContent);
220
- debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
221
- if (!content) {
222
- const reasoning = completion.choices[0]?.message?.reasoning;
223
- debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
224
- if (reasoning) {
225
- const derived = deriveMessageFromReasoning(reasoning);
226
- if (derived) {
227
- debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
228
- return stripThinkTags(derived);
229
- }
230
- debug("callAI: could not derive message from reasoning");
231
- }
232
- throw new Error("AI returned an empty commit message");
233
- }
234
- return content;
235
- } catch (error) {
236
- debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
237
- throw error;
238
- }
239
- }
240
- try {
241
- const totalStart = Date.now();
242
- let message = await callAI();
243
- debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
244
- if (!isValidConventionalCommit(message)) {
245
- debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
246
- const retryMessage = await callAI("You MUST output ONLY a valid conventional commit message. Format: type(scope): description. If you output anything else your response will be rejected.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.");
247
- debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
248
- if (isValidConventionalCommit(retryMessage)) {
249
- debug("Retry produced valid conventional commit");
250
- message = retryMessage;
251
- } else debug("Retry also failed validation, using original message");
252
- }
253
- debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
254
- return message;
255
- } catch (error) {
256
- debug("AI error: %s", error instanceof Error ? error.message : String(error));
257
- if (error instanceof Groq.AuthenticationError) throw new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
258
- if (error instanceof Groq.RateLimitError) throw new Error("Rate limited by Groq. Please wait and try again.");
259
- if (error instanceof Groq.APIConnectionTimeoutError) throw new Error("Request timed out. Check your network or try a smaller diff.");
260
- if (error instanceof Groq.APIError) throw new Error(`Groq API error: ${error.message}`);
261
- throw new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
262
- }
263
- }
264
- //#endregion
265
100
  //#region src/services/config.ts
266
101
  const CONFIG_PATH = join(os.homedir(), ".commit-mint");
267
102
  const defaults = {
@@ -325,6 +160,7 @@ var git_exports = /* @__PURE__ */ __exportAll({
325
160
  getRepoRoot: () => getRepoRoot,
326
161
  getStagedDiff: () => getStagedDiff,
327
162
  getStatusShort: () => getStatusShort,
163
+ resetStaging: () => resetStaging,
328
164
  stageAll: () => stageAll,
329
165
  stageFiles: () => stageFiles
330
166
  });
@@ -396,6 +232,10 @@ async function stageAll() {
396
232
  debug("stageAll: git add -A");
397
233
  await execa("git", ["add", "-A"]);
398
234
  }
235
+ async function resetStaging() {
236
+ debug("resetStaging: git reset HEAD");
237
+ await execa("git", ["reset", "HEAD"]);
238
+ }
399
239
  async function getHead() {
400
240
  const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
401
241
  return stdout.trim();
@@ -407,10 +247,14 @@ async function getStatusShort() {
407
247
  async function getChangedFiles() {
408
248
  const { stdout } = await execa("git", ["status", "--short"]);
409
249
  if (!stdout.trim()) return [];
410
- const files = stdout.split("\n").filter(Boolean).map((line) => ({
411
- status: line.slice(0, 2).trim(),
412
- path: line.slice(3)
413
- }));
250
+ const files = stdout.split("\n").filter(Boolean).map((line) => {
251
+ const indexStatus = line[0];
252
+ return {
253
+ status: line.slice(0, 2).trim(),
254
+ path: line.slice(3),
255
+ staged: indexStatus !== " " && indexStatus !== "?"
256
+ };
257
+ });
414
258
  debug("getChangedFiles:", files.length, "files");
415
259
  return files;
416
260
  }
@@ -589,6 +433,50 @@ function extractToolName(command) {
589
433
  return first;
590
434
  }
591
435
  //#endregion
436
+ //#region src/services/lint-staged.ts
437
+ const CONFIG_FILES = [
438
+ ".lintstagedrc",
439
+ ".lintstagedrc.json",
440
+ ".lintstagedrc.yaml",
441
+ ".lintstagedrc.yml",
442
+ ".lintstagedrc.mjs",
443
+ ".lintstagedrc.cjs",
444
+ "lint-staged.config.mjs",
445
+ "lint-staged.config.cjs",
446
+ "lint-staged.config.js"
447
+ ];
448
+ async function hasLintStagedConfig(repoRoot) {
449
+ debug("hasLintStagedConfig: checking in %s", repoRoot);
450
+ for (const file of CONFIG_FILES) {
451
+ const path = join(repoRoot, file);
452
+ try {
453
+ await access(path, constants.F_OK);
454
+ debug("hasLintStagedConfig: found %s", file);
455
+ return true;
456
+ } catch {}
457
+ }
458
+ const packageJsonPath = join(repoRoot, "package.json");
459
+ try {
460
+ const raw = await readFile(packageJsonPath, "utf8");
461
+ if ("lint-staged" in JSON.parse(raw)) {
462
+ debug("hasLintStagedConfig: found lint-staged in package.json");
463
+ return true;
464
+ }
465
+ } catch {}
466
+ debug("hasLintStagedConfig: no config found");
467
+ return false;
468
+ }
469
+ async function runLintStaged() {
470
+ debug("runLintStaged: starting npx lint-staged");
471
+ const { failed, stdout, stderr } = await execa("npx", ["lint-staged"], { reject: false });
472
+ debug("runLintStaged: finished, failed=%s", failed);
473
+ return {
474
+ ok: !failed,
475
+ stdout,
476
+ stderr
477
+ };
478
+ }
479
+ //#endregion
592
480
  //#region src/services/clipboard.ts
593
481
  async function copyToClipboard(content) {
594
482
  for (const [cmd, args] of [
@@ -629,7 +517,7 @@ async function copyToClipboard(content) {
629
517
  }
630
518
  //#endregion
631
519
  //#region src/ui/menu.ts
632
- async function showStagingMenu(files) {
520
+ async function showStagingMenu(files, hasLintStaged) {
633
521
  debug("showStagingMenu: %d files", files.length);
634
522
  const statusLabel = (status) => {
635
523
  switch (status) {
@@ -640,14 +528,37 @@ async function showStagingMenu(files) {
640
528
  default: return dim(status);
641
529
  }
642
530
  };
531
+ const sorted = [...files].sort((a, b) => {
532
+ if (a.staged !== b.staged) return a.staged ? -1 : 1;
533
+ return a.path.localeCompare(b.path);
534
+ });
535
+ const stagedFiles = sorted.filter((f) => f.staged);
536
+ const unstagedFiles = sorted.filter((f) => !f.staged);
537
+ const lines = [];
538
+ if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
539
+ if (unstagedFiles.length > 0) {
540
+ if (lines.length > 0) lines.push("");
541
+ lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
542
+ }
543
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
643
544
  const choice = await p.select({
644
545
  message: "Stage files for commit:",
645
546
  options: [
547
+ {
548
+ label: "Auto-group into commits",
549
+ value: "autogroup",
550
+ hint: "LLM groups files into logical commits"
551
+ },
646
552
  {
647
553
  label: "Stage all files",
648
554
  value: "all",
649
555
  hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
650
556
  },
557
+ ...hasLintStaged ? [{
558
+ label: "Run lint-staged checks",
559
+ value: "lint-staged",
560
+ hint: "Pre-flight checks on all changed files"
561
+ }] : [],
651
562
  {
652
563
  label: "Select files...",
653
564
  value: "select"
@@ -659,13 +570,15 @@ async function showStagingMenu(files) {
659
570
  ]
660
571
  });
661
572
  if (p.isCancel(choice) || choice === "cancel") return null;
573
+ if (choice === "autogroup") return "autogroup";
574
+ if (choice === "lint-staged") return "lint-staged";
662
575
  if (choice === "all") return {
663
576
  files: files.map((f) => f.path),
664
577
  all: true
665
578
  };
666
579
  const selected = await p.multiselect({
667
580
  message: "Select files to stage:",
668
- options: files.map((f) => ({
581
+ options: sorted.map((f) => ({
669
582
  label: `${statusLabel(f.status)} ${f.path}`,
670
583
  value: f.path
671
584
  })),
@@ -718,8 +631,7 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
718
631
  if (p.isCancel(choice)) {
719
632
  debug("showRecoveryMenu: user cancelled");
720
633
  p.outro(yellow("Cancelled. Message cached for --retry."));
721
- process.exit(1);
722
- return;
634
+ return "cancelled";
723
635
  }
724
636
  debug("showRecoveryMenu: user chose %s", choice);
725
637
  switch (choice) {
@@ -731,14 +643,18 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
731
643
  continue;
732
644
  case "skip":
733
645
  p.log.info(yellow("Committing with --no-verify..."));
734
- if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
735
- else p.outro(red("Commit failed even with --no-verify."));
736
- return;
646
+ if (await onSkipHooks(message)) {
647
+ p.outro(green("Committed (hooks skipped)."));
648
+ return "committed";
649
+ } else {
650
+ p.outro(red("Commit failed even with --no-verify."));
651
+ return "failed";
652
+ }
737
653
  case "restage":
738
654
  p.log.info(cyan("Re-staging and retrying..."));
739
655
  if (await onRestage()) {
740
656
  p.outro(green("Committed successfully."));
741
- return;
657
+ return "committed";
742
658
  }
743
659
  showNote = true;
744
660
  continue;
@@ -746,81 +662,849 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
746
662
  const edited = await p.text({
747
663
  message: "Edit commit message:",
748
664
  initialValue: message,
749
- validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
665
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
750
666
  });
751
667
  if (p.isCancel(edited)) {
752
668
  p.outro(yellow("Cancelled. Message cached for --retry."));
753
- process.exit(1);
754
- return;
669
+ return "cancelled";
670
+ }
671
+ if (await onRetry()) {
672
+ p.outro(green("Committed successfully."));
673
+ return "committed";
674
+ } else {
675
+ p.outro(red("Commit failed again."));
676
+ return "failed";
755
677
  }
756
- if (await onRetry()) p.outro(green("Committed successfully."));
757
- else p.outro(red("Commit failed again."));
758
- return;
759
678
  }
760
679
  case "cancel":
761
680
  p.outro(dim("Message cached for --retry."));
762
- process.exit(1);
763
- return;
681
+ return "cancelled";
764
682
  }
765
683
  }
766
684
  }
767
685
  //#endregion
768
- //#region src/utils/cache.ts
769
- const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
770
- function repoHash(repoPath) {
771
- return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
686
+ //#region src/services/ai.ts
687
+ const MAX_DIFF_CHARS = 2e4;
688
+ function mapGroqError$1(error) {
689
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
690
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
691
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
692
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
693
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
772
694
  }
773
- function cachePath(repoPath) {
774
- return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
695
+ const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
696
+ function stripThinkTags(text) {
697
+ return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
775
698
  }
776
- async function saveCachedCommit(repoPath, message) {
777
- await mkdir(CACHE_DIR, { recursive: true });
778
- const data = {
779
- message,
780
- timestamp: Date.now(),
781
- repoPath
782
- };
783
- const path = cachePath(repoPath);
784
- debug("saveCachedCommit: saving to %s", path);
785
- await writeFile(path, JSON.stringify(data, null, 2), "utf8");
699
+ function deriveMessageFromReasoning(reasoning) {
700
+ const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
701
+ if (match) return match[0].trim();
702
+ const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
703
+ return first ? first.trim() : null;
786
704
  }
787
- async function loadCachedCommit(repoPath) {
788
- const path = cachePath(repoPath);
789
- debug("loadCachedCommit: loading from %s", path);
790
- try {
791
- const raw = await readFile(path, "utf8");
792
- const data = JSON.parse(raw);
793
- debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
794
- return data;
795
- } catch {
796
- debug("loadCachedCommit: no cached commit found");
797
- return null;
798
- }
705
+ function stripContextLines(diff) {
706
+ return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
799
707
  }
800
- //#endregion
801
- //#region src/commands/commit.ts
802
- async function commitCommand(flags) {
803
- debug("commitCommand called", { flags });
804
- await assertGitRepo();
805
- if (flags.retry) {
806
- debug("Entering retry mode");
807
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
808
- const repoRoot = await getRepoRoot();
809
- debug("Repo root:", repoRoot);
810
- const cached = await loadCachedCommit(repoRoot);
811
- if (!cached) {
812
- debug("No cached commit found");
813
- outro(red("No cached commit message found. Run cmint without --retry first."));
814
- process.exit(1);
815
- }
816
- debug("Loaded cached message:", cached.message);
817
- intro("commit-mint — retry");
818
- const s = spinner();
819
- s.start("Retrying commit...");
820
- const result = await attemptCommit(cached.message);
821
- s.stop("Attempted commit");
822
- debug("Retry commit result:", result);
823
- if (result.ok) {
708
+ function compressDiff(diff) {
709
+ if (diff.length <= MAX_DIFF_CHARS) return diff;
710
+ let result = stripContextLines(diff);
711
+ if (result.length <= MAX_DIFF_CHARS) return result;
712
+ result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
713
+ return fd.split(/(?=\n@@)/).map((part, idx) => {
714
+ if (idx === 0) return part;
715
+ const lines = part.split("\n");
716
+ return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
717
+ }).join("");
718
+ }).join("");
719
+ if (result.length <= MAX_DIFF_CHARS) return result;
720
+ return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
721
+ const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
722
+ return match && match[1] === match[2] ? `${match[1]} | changed` : "";
723
+ }).filter(Boolean).join("\n")}`;
724
+ }
725
+ function buildStatSummary(diff) {
726
+ const files = [];
727
+ let currentFile = "";
728
+ let adds = 0;
729
+ let dels = 0;
730
+ for (const line of diff.split("\n")) {
731
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
732
+ if (match) {
733
+ if (currentFile) files.push({
734
+ name: currentFile,
735
+ adds,
736
+ dels
737
+ });
738
+ currentFile = match[1];
739
+ adds = 0;
740
+ dels = 0;
741
+ } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
742
+ else if (line.startsWith("-") && !line.startsWith("---")) dels++;
743
+ }
744
+ if (currentFile) files.push({
745
+ name: currentFile,
746
+ adds,
747
+ dels
748
+ });
749
+ const totalAdds = files.reduce((s, f) => s + f.adds, 0);
750
+ const totalDels = files.reduce((s, f) => s + f.dels, 0);
751
+ const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
752
+ lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
753
+ return lines.join("\n");
754
+ }
755
+ function buildSystemPrompt(type) {
756
+ let prompt = "You are a commit message generator. Follow the Conventional Commits specification.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.\nFormat: type(scope): description\nUse imperative mood, lowercase, no trailing period.\nOutput ONLY the commit message, no markdown fences, no explanation.";
757
+ if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
758
+ return prompt;
759
+ }
760
+ function buildUserPrompt(diff, hint, statSummary) {
761
+ const parts = [];
762
+ if (hint) parts.push(`Context: ${hint}`);
763
+ if (statSummary) parts.push(`Change summary:\n${statSummary}`);
764
+ parts.push(`Generate a conventional commit for:\n\n${diff}`);
765
+ return parts.join("\n\n");
766
+ }
767
+ function isValidConventionalCommit(message) {
768
+ return CONVENTIONAL_COMMIT_REGEX.test(message);
769
+ }
770
+ function extractContentText(content) {
771
+ if (content == null) return "";
772
+ if (typeof content === "string") return content.trim();
773
+ if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
774
+ return "";
775
+ }
776
+ async function generateCommitMessage(diff, options) {
777
+ debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
778
+ const timeoutMs = options.timeout ?? 6e4;
779
+ debug("Timeout: %d ms", timeoutMs);
780
+ const client = new Groq({
781
+ apiKey: options.apiKey,
782
+ timeout: timeoutMs
783
+ });
784
+ const compressedDiff = compressDiff(diff);
785
+ const statSummary = buildStatSummary(diff);
786
+ const systemPrompt = buildSystemPrompt(options.type);
787
+ const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
788
+ debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
789
+ debug("Stat summary:\n%s", statSummary);
790
+ debug("User prompt length: %d chars", userPrompt.length);
791
+ async function callAI(strictSystemPrompt) {
792
+ const callStart = Date.now();
793
+ debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
794
+ try {
795
+ const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
796
+ const completion = await client.chat.completions.create({
797
+ messages: [{
798
+ role: "system",
799
+ content: strictSystemPrompt ?? systemPrompt
800
+ }, {
801
+ role: "user",
802
+ content: userPrompt
803
+ }],
804
+ model: options.model ?? "openai/gpt-oss-20b",
805
+ temperature: .3,
806
+ ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
807
+ reasoning_format: "parsed"
808
+ });
809
+ const elapsed = Date.now() - callStart;
810
+ const rawContent = completion.choices[0]?.message?.content;
811
+ const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
812
+ debug("callAI response (%d ms): choices=%d, finishReason=%s, contentLen=%d, rawType=%s", elapsed, completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length, typeof rawContent);
813
+ debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
814
+ if (!content) {
815
+ const reasoning = completion.choices[0]?.message?.reasoning;
816
+ debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
817
+ if (reasoning) {
818
+ const derived = deriveMessageFromReasoning(reasoning);
819
+ if (derived) {
820
+ debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
821
+ return stripThinkTags(derived);
822
+ }
823
+ debug("callAI: could not derive message from reasoning");
824
+ }
825
+ throw new Error("AI returned an empty commit message");
826
+ }
827
+ return content;
828
+ } catch (error) {
829
+ debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
830
+ throw error;
831
+ }
832
+ }
833
+ try {
834
+ const totalStart = Date.now();
835
+ let message = await callAI();
836
+ debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
837
+ if (!isValidConventionalCommit(message)) {
838
+ debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
839
+ const retryMessage = await callAI("You MUST output ONLY a valid conventional commit message. Format: type(scope): description. If you output anything else your response will be rejected.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.");
840
+ debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
841
+ if (isValidConventionalCommit(retryMessage)) {
842
+ debug("Retry produced valid conventional commit");
843
+ message = retryMessage;
844
+ } else debug("Retry also failed validation, using original message");
845
+ }
846
+ debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
847
+ return message;
848
+ } catch (error) {
849
+ debug("AI error: %s", error instanceof Error ? error.message : String(error));
850
+ throw mapGroqError$1(error);
851
+ }
852
+ }
853
+ //#endregion
854
+ //#region src/services/review-ai.ts
855
+ function buildReviewSystemPrompt() {
856
+ return [
857
+ "You are an expert code reviewer. Review the following staged git diff.",
858
+ "",
859
+ "Focus on finding:",
860
+ "1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
861
+ "2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
862
+ "3. **Performance problems** — unnecessary work, large allocations in hot paths",
863
+ "4. **Code quality** — readability, maintainability, error handling gaps",
864
+ "5. **Edge cases** — missing boundary checks, empty states, error states",
865
+ "",
866
+ "For each issue found, use this format:",
867
+ "- SEVERITY: [critical|major|minor|suggestion]",
868
+ "- LOCATION: <file-path>:<line-number>",
869
+ "- ISSUE: <description>",
870
+ "- FIX: <suggested resolution>",
871
+ "",
872
+ "Separate issues with a blank line.",
873
+ "",
874
+ "If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
875
+ "",
876
+ "Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
877
+ ].join("\n");
878
+ }
879
+ function buildReviewPrompt(diff, files, statSummary) {
880
+ const parts = [];
881
+ parts.push(`Review the following staged changes (${files.length} files):`);
882
+ parts.push("");
883
+ parts.push(statSummary);
884
+ parts.push("");
885
+ parts.push("```diff");
886
+ parts.push(diff);
887
+ parts.push("```");
888
+ return parts.join("\n");
889
+ }
890
+ async function generateCodeReview(diff, files, options) {
891
+ debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
892
+ const timeoutMs = options.timeout ?? 6e4;
893
+ const client = new Groq({
894
+ apiKey: options.apiKey,
895
+ timeout: timeoutMs
896
+ });
897
+ const compressedDiff = compressDiff(diff);
898
+ const statSummary = buildStatSummary(diff);
899
+ const systemPrompt = buildReviewSystemPrompt();
900
+ const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
901
+ debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
902
+ try {
903
+ const completion = await client.chat.completions.create({
904
+ messages: [{
905
+ role: "system",
906
+ content: systemPrompt
907
+ }, {
908
+ role: "user",
909
+ content: userPrompt
910
+ }],
911
+ model: options.model ?? "openai/gpt-oss-20b",
912
+ temperature: .3,
913
+ max_tokens: 4096
914
+ });
915
+ const rawContent = completion.choices[0]?.message?.content;
916
+ const content = extractContentText(rawContent);
917
+ debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
918
+ if (!content) {
919
+ const reasoning = completion.choices[0]?.message?.reasoning;
920
+ if (reasoning) {
921
+ const derived = deriveMessageFromReasoning(reasoning);
922
+ if (derived) {
923
+ debug("generateCodeReview: derived from reasoning");
924
+ return derived;
925
+ }
926
+ }
927
+ return "NO_ISSUES_FOUND";
928
+ }
929
+ return content;
930
+ } catch (error) {
931
+ debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
932
+ throw mapGroqError$1(error);
933
+ }
934
+ }
935
+ //#endregion
936
+ //#region src/commands/review.ts
937
+ async function reviewCommand() {
938
+ debug("reviewCommand called");
939
+ await assertGitRepo();
940
+ const s = spinner();
941
+ s.start("Staging all changes...");
942
+ await stageAll();
943
+ s.stop("Changes staged");
944
+ const diffResult = await getStagedDiff();
945
+ if (!diffResult) {
946
+ outro(dim("No changes to review."));
947
+ return;
948
+ }
949
+ if ("excludedFiles" in diffResult) {
950
+ outro(dim("Staged files are all excluded from review."));
951
+ return;
952
+ }
953
+ intro("commit-mint — code review");
954
+ log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
955
+ const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
956
+ if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
957
+ note(report, red(bold("Review findings")));
958
+ await offerClipboardCopy(report);
959
+ } else outro(green("No issues found. Looks good!"));
960
+ }
961
+ async function offerClipboardCopy(report) {
962
+ const shouldCopy = await select({
963
+ message: "Copy review report to clipboard?",
964
+ options: [{
965
+ label: "Yes, copy to clipboard",
966
+ value: "yes"
967
+ }, {
968
+ label: "No",
969
+ value: "no"
970
+ }]
971
+ });
972
+ if (isCancel(shouldCopy) || shouldCopy === "no") {
973
+ outro(dim("Done."));
974
+ return;
975
+ }
976
+ if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
977
+ else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
978
+ }
979
+ async function isOpenCodeAvailable() {
980
+ try {
981
+ const { exitCode } = await import("execa").then((m) => m.execa("which", ["opencode"], { reject: false }));
982
+ return exitCode === 0;
983
+ } catch {
984
+ return false;
985
+ }
986
+ }
987
+ async function reviewWithGroq(diff, files) {
988
+ const s = spinner();
989
+ s.start("Reviewing with Groq...");
990
+ try {
991
+ const config = await readConfig();
992
+ const report = await generateCodeReview(diff, files, {
993
+ apiKey: await getApiKey(),
994
+ model: config.model,
995
+ timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0
996
+ });
997
+ s.stop("Review complete");
998
+ return report;
999
+ } catch (err) {
1000
+ s.stop(red("Review failed."));
1001
+ debug("reviewWithGroq error:", err instanceof Error ? err.message : String(err));
1002
+ throw err;
1003
+ }
1004
+ }
1005
+ async function reviewWithOpenCode(diff, files) {
1006
+ const s = spinner();
1007
+ s.start("Running OpenCode review...");
1008
+ try {
1009
+ const repoRoot = await getRepoRoot();
1010
+ const prompt = [
1011
+ "Review the staged changes in this git repository.",
1012
+ "Analyze the code diff for bugs, security issues, performance problems,",
1013
+ "code quality issues, and missing edge cases.",
1014
+ "",
1015
+ `Files changed (${files.length}):`,
1016
+ ...files.map((f) => ` - ${f}`),
1017
+ "",
1018
+ "```diff",
1019
+ diff.slice(0, 15e3),
1020
+ "```",
1021
+ "",
1022
+ "Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
1023
+ "If no issues found, respond with: NO_ISSUES_FOUND"
1024
+ ].join("\n");
1025
+ const { stdout } = await import("execa").then((m) => m.execa("opencode", [
1026
+ "run",
1027
+ prompt,
1028
+ "--dir",
1029
+ repoRoot
1030
+ ], {
1031
+ timeout: 12e4,
1032
+ reject: false
1033
+ }));
1034
+ s.stop("Review complete");
1035
+ return stdout || "OpenCode review completed but no output captured.";
1036
+ } catch (err) {
1037
+ s.stop(red("OpenCode review failed."));
1038
+ debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
1039
+ throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
1040
+ }
1041
+ }
1042
+ //#endregion
1043
+ //#region src/ui/review-message.ts
1044
+ async function runCodeReview() {
1045
+ const diffResult = await getStagedDiff();
1046
+ if (!diffResult || "excludedFiles" in diffResult) {
1047
+ outro(dim("No staged changes to review."));
1048
+ return;
1049
+ }
1050
+ const opencodeAvailable = await isOpenCodeAvailable();
1051
+ const s = spinner();
1052
+ s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
1053
+ try {
1054
+ const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1055
+ s.stop("Review complete");
1056
+ await showReviewResults(report);
1057
+ } catch (err) {
1058
+ s.stop(red("Review failed."));
1059
+ debug("Code review error:", err instanceof Error ? err.message : String(err));
1060
+ outro(red(err instanceof Error ? err.message : String(err)));
1061
+ }
1062
+ }
1063
+ async function showReviewResults(report) {
1064
+ const { note: clackNote, select: clackSelect } = await import("@clack/prompts");
1065
+ if (!(report !== "NO_ISSUES_FOUND" && report.trim().length > 0)) {
1066
+ log.info(green("No issues found."));
1067
+ return;
1068
+ }
1069
+ clackNote(report, red(bold("Review findings")));
1070
+ const shouldCopy = await clackSelect({
1071
+ message: "Copy review report to clipboard?",
1072
+ options: [{
1073
+ label: "Yes, copy to clipboard",
1074
+ value: "yes"
1075
+ }, {
1076
+ label: "No",
1077
+ value: "no"
1078
+ }]
1079
+ });
1080
+ if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
1081
+ if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
1082
+ else log.warn(red("Failed to copy to clipboard."));
1083
+ }
1084
+ async function reviewCommitMessage(message) {
1085
+ const { select, text } = await import("@clack/prompts");
1086
+ while (true) {
1087
+ const review = await select({
1088
+ message: `Review commit message:\n\n ${bold(message)}\n`,
1089
+ options: [
1090
+ {
1091
+ label: "Use as-is",
1092
+ value: "use"
1093
+ },
1094
+ {
1095
+ label: "Edit",
1096
+ value: "edit"
1097
+ },
1098
+ {
1099
+ label: "Review with OpenCode",
1100
+ value: "review"
1101
+ },
1102
+ {
1103
+ label: "Cancel",
1104
+ value: "cancel"
1105
+ }
1106
+ ]
1107
+ });
1108
+ if (isCancel(review) || review === "cancel") {
1109
+ debug("User cancelled at review step");
1110
+ return null;
1111
+ }
1112
+ if (review === "use") {
1113
+ debug("User accepted message");
1114
+ return message;
1115
+ }
1116
+ if (review === "edit") {
1117
+ debug("User chose to edit message");
1118
+ const edited = await text({
1119
+ message: "Edit commit message:",
1120
+ initialValue: message,
1121
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
1122
+ });
1123
+ if (isCancel(edited)) continue;
1124
+ message = String(edited).trim();
1125
+ debug("Edited message:", message);
1126
+ continue;
1127
+ }
1128
+ if (review === "review") {
1129
+ debug("User chose to review code");
1130
+ await runCodeReview();
1131
+ }
1132
+ }
1133
+ }
1134
+ //#endregion
1135
+ //#region src/utils/cache.ts
1136
+ const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
1137
+ function repoHash(repoPath) {
1138
+ return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
1139
+ }
1140
+ function cachePath(repoPath) {
1141
+ return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
1142
+ }
1143
+ async function saveCachedCommit(repoPath, message) {
1144
+ await mkdir(CACHE_DIR, { recursive: true });
1145
+ const data = {
1146
+ message,
1147
+ timestamp: Date.now(),
1148
+ repoPath
1149
+ };
1150
+ const path = cachePath(repoPath);
1151
+ debug("saveCachedCommit: saving to %s", path);
1152
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
1153
+ }
1154
+ async function loadCachedCommit(repoPath) {
1155
+ const path = cachePath(repoPath);
1156
+ debug("loadCachedCommit: loading from %s", path);
1157
+ try {
1158
+ const raw = await readFile(path, "utf8");
1159
+ const data = JSON.parse(raw);
1160
+ debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
1161
+ return data;
1162
+ } catch {
1163
+ debug("loadCachedCommit: no cached commit found");
1164
+ return null;
1165
+ }
1166
+ }
1167
+ //#endregion
1168
+ //#region src/services/grouping.ts
1169
+ function mapGroqError(error) {
1170
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
1171
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
1172
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network and try again.");
1173
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
1174
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
1175
+ }
1176
+ function matchesExcludePattern(filePath, pattern) {
1177
+ if (pattern === filePath) return true;
1178
+ if (pattern.endsWith("/**")) {
1179
+ const prefix = pattern.slice(0, -3);
1180
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
1181
+ }
1182
+ if (pattern.startsWith("*.")) {
1183
+ const suffix = pattern.slice(1);
1184
+ return filePath.endsWith(suffix);
1185
+ }
1186
+ return false;
1187
+ }
1188
+ /** Lockfiles that should be kept when their companion manifest is present */
1189
+ const LOCKFILE_COMPANIONS = {
1190
+ "package-lock.json": "package.json",
1191
+ "pnpm-lock.yaml": "package.json",
1192
+ "yarn.lock": "package.json"
1193
+ };
1194
+ function filterExcludedFiles(files) {
1195
+ const patterns = getDefaultExcludes();
1196
+ const included = [];
1197
+ const excluded = [];
1198
+ const filePaths = new Set(files.map((f) => f.path));
1199
+ for (const file of files) if (patterns.some((pattern) => matchesExcludePattern(file.path, pattern))) excluded.push(file);
1200
+ else included.push(file);
1201
+ const stillExcluded = [];
1202
+ for (const file of excluded) {
1203
+ const companion = LOCKFILE_COMPANIONS[file.path];
1204
+ if (companion && filePaths.has(companion)) included.push(file);
1205
+ else stillExcluded.push(file.path);
1206
+ }
1207
+ debug("filterExcludedFiles: %d included, %d excluded", included.length, stillExcluded.length);
1208
+ return {
1209
+ included,
1210
+ excluded: stillExcluded
1211
+ };
1212
+ }
1213
+ function statusIndicator(status) {
1214
+ switch (status) {
1215
+ case "M": return "modified";
1216
+ case "A": return "added";
1217
+ case "D": return "deleted";
1218
+ case "R": return "renamed";
1219
+ case "C": return "copied";
1220
+ case "?": return "untracked";
1221
+ default: return "changed";
1222
+ }
1223
+ }
1224
+ function buildFileSummary(files) {
1225
+ return files.map((f) => `${f.path} (${statusIndicator(f.status)})`).join("\n");
1226
+ }
1227
+ function buildGroupingSystemPrompt() {
1228
+ return [
1229
+ "You are analyzing changed files in a git repository. Group them into logical commits based on what changed and why. Each group should be a coherent unit of work.",
1230
+ "",
1231
+ "Rules:",
1232
+ "- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes', 'Test updates')",
1233
+ "- Keep related files together (e.g., a component + its test, a model + its migration)",
1234
+ "- Do not split a single logical change across multiple groups",
1235
+ "- If a file does not clearly belong to any group, include it anyway — do not omit files",
1236
+ "",
1237
+ "Output format: JSON array of objects with keys 'name', 'description', 'files'.",
1238
+ "name: short label (3-5 words)",
1239
+ "description: 1-2 sentences explaining what this group changes",
1240
+ "files: array of exact file paths from the input",
1241
+ "",
1242
+ "Output ONLY valid JSON. No markdown fences, no explanation."
1243
+ ].join("\n");
1244
+ }
1245
+ function buildGroupingUserPrompt(summary) {
1246
+ return [
1247
+ "Group the following changed files into logical commits:",
1248
+ "",
1249
+ summary
1250
+ ].join("\n");
1251
+ }
1252
+ function parseGroupingResponse(content) {
1253
+ const jsonText = content.replace(/^```json\s*/, "").replace(/\s*```$/, "").trim();
1254
+ const parsed = JSON.parse(jsonText);
1255
+ if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
1256
+ const rawGroups = [];
1257
+ for (const item of parsed) if (typeof item === "object" && item !== null && "name" in item && "description" in item && "files" in item && Array.isArray(item.files)) rawGroups.push({
1258
+ name: String(item.name),
1259
+ description: String(item.description),
1260
+ files: item.files.filter((f) => typeof f === "string")
1261
+ });
1262
+ return rawGroups;
1263
+ }
1264
+ async function generateGroups(files, apiKey, model, timeout) {
1265
+ debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
1266
+ const { included, excluded } = filterExcludedFiles(files);
1267
+ if (included.length === 0) {
1268
+ debug("generateGroups: no files to group after exclusion");
1269
+ return {
1270
+ groups: [],
1271
+ excluded
1272
+ };
1273
+ }
1274
+ const summary = buildFileSummary(included);
1275
+ const systemPrompt = buildGroupingSystemPrompt();
1276
+ const userPrompt = buildGroupingUserPrompt(summary);
1277
+ debug("File summary:\n%s", summary);
1278
+ debug("User prompt length: %d chars", userPrompt.length);
1279
+ const client = new Groq({
1280
+ apiKey,
1281
+ timeout: timeout ?? 6e4
1282
+ });
1283
+ try {
1284
+ const completion = await client.chat.completions.create({
1285
+ messages: [{
1286
+ role: "system",
1287
+ content: systemPrompt
1288
+ }, {
1289
+ role: "user",
1290
+ content: userPrompt
1291
+ }],
1292
+ model: model ?? "openai/gpt-oss-20b",
1293
+ temperature: .3,
1294
+ max_tokens: 2048
1295
+ });
1296
+ const rawContent = completion.choices[0]?.message?.content;
1297
+ const content = typeof rawContent === "string" ? rawContent.trim() : "";
1298
+ debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
1299
+ debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
1300
+ if (!content) throw new Error("AI returned an empty grouping response");
1301
+ const rawGroups = parseGroupingResponse(content);
1302
+ debug("generateGroups: parsed %d raw groups", rawGroups.length);
1303
+ const validated = validateGroups(rawGroups, included);
1304
+ debug("generateGroups: %d validated groups", validated.length);
1305
+ return {
1306
+ groups: validated,
1307
+ excluded
1308
+ };
1309
+ } catch (error) {
1310
+ debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
1311
+ throw mapGroqError(error);
1312
+ }
1313
+ }
1314
+ function validateGroups(groups, allFiles) {
1315
+ const seen = /* @__PURE__ */ new Set();
1316
+ const validated = [];
1317
+ for (const group of groups) {
1318
+ const uniqueFiles = group.files.filter((f) => {
1319
+ if (seen.has(f)) return false;
1320
+ seen.add(f);
1321
+ return true;
1322
+ });
1323
+ if (uniqueFiles.length > 0) validated.push({
1324
+ name: group.name,
1325
+ description: group.description,
1326
+ files: uniqueFiles
1327
+ });
1328
+ }
1329
+ const ungrouped = allFiles.filter((f) => !seen.has(f.path));
1330
+ if (ungrouped.length > 0) {
1331
+ debug("validateGroups: %d ungrouped files added to 'Other changes'", ungrouped.length);
1332
+ validated.push({
1333
+ name: "Other changes",
1334
+ description: "Miscellaneous changes that did not fit into other groups",
1335
+ files: ungrouped.map((f) => f.path)
1336
+ });
1337
+ }
1338
+ return validated;
1339
+ }
1340
+ //#endregion
1341
+ //#region src/ui/grouping.ts
1342
+ async function showGroupingConfirmation(groups, excluded) {
1343
+ debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1344
+ const lines = [];
1345
+ for (const group of groups) {
1346
+ lines.push(bold(group.name));
1347
+ lines.push(` ${dim(group.description)}`);
1348
+ lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1349
+ for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
1350
+ lines.push("");
1351
+ }
1352
+ if (excluded.length > 0) {
1353
+ lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1354
+ for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
1355
+ }
1356
+ p.note(lines.join("\n"), "Proposed commit groups");
1357
+ const choice = await p.select({
1358
+ message: "Proceed with these groupings?",
1359
+ options: [{
1360
+ label: "Yes, commit all groups",
1361
+ value: "yes"
1362
+ }, {
1363
+ label: "No, cancel",
1364
+ value: "no"
1365
+ }]
1366
+ });
1367
+ if (p.isCancel(choice) || choice === "no") {
1368
+ debug("showGroupingConfirmation: user cancelled");
1369
+ return false;
1370
+ }
1371
+ debug("showGroupingConfirmation: user confirmed");
1372
+ return true;
1373
+ }
1374
+ function showGroupProgress(current, total, groupName) {
1375
+ p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1376
+ }
1377
+ //#endregion
1378
+ //#region src/commands/auto-group.ts
1379
+ async function runAutoGroupFlow(changedFiles, flags) {
1380
+ const { included, excluded } = filterExcludedFiles(changedFiles);
1381
+ try {
1382
+ await getApiKey();
1383
+ debug("API key found");
1384
+ } catch {
1385
+ debug("No API key found, prompting user");
1386
+ const { text: promptText } = await import("@clack/prompts");
1387
+ const key = await promptText({
1388
+ message: "Enter your Groq API key:",
1389
+ placeholder: "gsk_...",
1390
+ validate: (v) => v?.trim() ? void 0 : "API key is required"
1391
+ });
1392
+ if (isCancel(key)) {
1393
+ outro(dim("Cancelled."));
1394
+ return "cancelled";
1395
+ }
1396
+ await setConfigValue("GROQ_API_KEY", String(key).trim());
1397
+ debug("API key saved to config");
1398
+ }
1399
+ const s = spinner();
1400
+ s.start("Analyzing files...");
1401
+ const config = await readConfig();
1402
+ const validatedGroups = validateGroups((await generateGroups(included, await getApiKey(), config.model, config.timeout ? parseInt(config.timeout, 10) : void 0)).groups, included);
1403
+ s.stop("Files analyzed");
1404
+ if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1405
+ outro(dim("Cancelled."));
1406
+ return "cancelled";
1407
+ }
1408
+ for (let i = 0; i < validatedGroups.length; i++) {
1409
+ const group = validatedGroups[i];
1410
+ showGroupProgress(i + 1, validatedGroups.length, group.name);
1411
+ await resetStaging();
1412
+ await stageFiles(group.files);
1413
+ const diffResult = await getStagedDiff();
1414
+ if (!diffResult || "excludedFiles" in diffResult) {
1415
+ log.warn(red(`No changes found for group "${group.name}" — skipping.`));
1416
+ continue;
1417
+ }
1418
+ s.start("Generating commit message...");
1419
+ let message;
1420
+ try {
1421
+ message = await generateMessage(diffResult.diff, flags.hint);
1422
+ } catch (err) {
1423
+ s.stop(red("Failed to generate message."));
1424
+ outro(red(err instanceof Error ? err.message : String(err)));
1425
+ return "cancelled";
1426
+ }
1427
+ s.stop("Message generated");
1428
+ const reviewed = await reviewCommitMessage(message);
1429
+ if (reviewed === null) {
1430
+ outro(dim("Cancelled."));
1431
+ return "cancelled";
1432
+ }
1433
+ message = reviewed;
1434
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1435
+ await saveCachedCommit(await getRepoRoot(), message);
1436
+ s.start("Committing...");
1437
+ const headBefore = await getHead();
1438
+ const commitResult = await attemptCommit(message);
1439
+ const headAfter = await getHead();
1440
+ if (commitResult.ok || headBefore !== headAfter) {
1441
+ s.stop("Committed successfully.");
1442
+ const checks = parseToolChecks(commitResult.stderr ?? "");
1443
+ if (checks.length > 0) {
1444
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1445
+ log.info(lines.join("\n"));
1446
+ }
1447
+ continue;
1448
+ }
1449
+ s.stop("Commit failed.");
1450
+ const recoveryResult = await showRecoveryMenu(parseHookErrors(commitResult.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1451
+ await stageFiles(group.files);
1452
+ return (await attemptCommit(message)).ok;
1453
+ }, message, commitResult.stderr ?? "");
1454
+ if (recoveryResult === "committed") {
1455
+ if (i < validatedGroups.length - 1) continue;
1456
+ return "committed";
1457
+ }
1458
+ return recoveryResult;
1459
+ }
1460
+ outro(green("All groups committed."));
1461
+ return "committed";
1462
+ }
1463
+ async function generateMessage(diff, hint) {
1464
+ const config = await readConfig();
1465
+ const apiKey = await getApiKey();
1466
+ debug("Generating message with model:", config.model, "type:", config.type);
1467
+ return generateCommitMessage(diff, {
1468
+ apiKey,
1469
+ model: config.model,
1470
+ type: config.type,
1471
+ timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1472
+ hint
1473
+ });
1474
+ }
1475
+ function buildExcludedFilesMessage(files) {
1476
+ const excludes = getDefaultExcludes();
1477
+ const isLockfile = (f) => excludes.some((pattern) => {
1478
+ if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
1479
+ return false;
1480
+ });
1481
+ if (files.every(isLockfile)) return "chore: update lockfile";
1482
+ return "chore: update generated files";
1483
+ }
1484
+ //#endregion
1485
+ //#region src/commands/commit.ts
1486
+ async function commitCommand(flags) {
1487
+ debug("commitCommand called", { flags });
1488
+ await assertGitRepo();
1489
+ if (flags.retry) {
1490
+ debug("Entering retry mode");
1491
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1492
+ const repoRoot = await getRepoRoot();
1493
+ debug("Repo root:", repoRoot);
1494
+ const cached = await loadCachedCommit(repoRoot);
1495
+ if (!cached) {
1496
+ debug("No cached commit found");
1497
+ outro(red("No cached commit message found. Run cmint without --retry first."));
1498
+ process.exit(1);
1499
+ }
1500
+ debug("Loaded cached message:", cached.message);
1501
+ intro("commit-mint — retry");
1502
+ const s = spinner();
1503
+ s.start("Retrying commit...");
1504
+ const result = await attemptCommit(cached.message);
1505
+ s.stop("Attempted commit");
1506
+ debug("Retry commit result:", result);
1507
+ if (result.ok) {
824
1508
  const checks = parseToolChecks(result.stderr ?? "");
825
1509
  if (checks.length > 0) {
826
1510
  const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
@@ -830,12 +1514,12 @@ async function commitCommand(flags) {
830
1514
  } else {
831
1515
  const errors = parseHookErrors(result.stderr ?? "");
832
1516
  debug("Hook errors on retry:", errors.length);
833
- await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1517
+ if (await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
834
1518
  await stageAll();
835
1519
  return (await attemptCommit(cached.message)).ok;
836
- }, cached.message, result.stderr ?? "");
1520
+ }, cached.message, result.stderr ?? "") === "cancelled") process.exit(1);
1521
+ return;
837
1522
  }
838
- return;
839
1523
  }
840
1524
  intro("commit-mint");
841
1525
  const status = await getStatusShort();
@@ -844,7 +1528,7 @@ async function commitCommand(flags) {
844
1528
  outro(dim("Nothing to commit."));
845
1529
  return;
846
1530
  }
847
- const changedFiles = await getChangedFiles();
1531
+ let changedFiles = await getChangedFiles();
848
1532
  debug("Changed files:", changedFiles.length);
849
1533
  const s = spinner();
850
1534
  try {
@@ -857,14 +1541,48 @@ async function commitCommand(flags) {
857
1541
  await stageFiles([changedFiles[0].path]);
858
1542
  s.stop("File staged");
859
1543
  } else {
860
- const stagingResult = await showStagingMenu(changedFiles);
861
- if (!stagingResult) {
862
- outro(dim("Cancelled."));
863
- return;
1544
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1545
+ const lintStagedAvailable = await hasLintStagedConfig(await getRepoRoot());
1546
+ debug("lint-staged available:", lintStagedAvailable);
1547
+ let stagingResult = null;
1548
+ let filesToStage = [];
1549
+ let stageAllFlag = false;
1550
+ while (true) {
1551
+ stagingResult = await showStagingMenu(changedFiles, lintStagedAvailable);
1552
+ if (stagingResult === "autogroup") {
1553
+ if (flags.message) {
1554
+ outro(red("--message flag is not compatible with auto-group mode."));
1555
+ return;
1556
+ }
1557
+ if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
1558
+ return;
1559
+ }
1560
+ if (stagingResult === "lint-staged") {
1561
+ await stageAll();
1562
+ const lsSpinner = spinner();
1563
+ lsSpinner.start("Running lint-staged checks...");
1564
+ const lsResult = await runLintStaged();
1565
+ if (lsResult.ok) {
1566
+ lsSpinner.stop("All lint-staged checks passed");
1567
+ if (lsResult.stdout.trim()) log.info(dim(lsResult.stdout.trim()));
1568
+ } else {
1569
+ lsSpinner.stop("Lint-staged checks failed");
1570
+ log.info(lsResult.stderr?.trim() || lsResult.stdout?.trim() || "Unknown error");
1571
+ }
1572
+ changedFiles = await getChangedFiles();
1573
+ continue;
1574
+ }
1575
+ if (!stagingResult) {
1576
+ outro(dim("Cancelled."));
1577
+ return;
1578
+ }
1579
+ filesToStage = stagingResult.files;
1580
+ stageAllFlag = stagingResult.all;
1581
+ break;
864
1582
  }
865
- s.start(`Staging ${stagingResult.files.length} file${stagingResult.files.length !== 1 ? "s" : ""}...`);
866
- if (stagingResult.all) await stageAll();
867
- else await stageFiles(stagingResult.files);
1583
+ s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
1584
+ if (stageAllFlag) await stageAll();
1585
+ else await stageFiles(filesToStage);
868
1586
  s.stop("Files staged");
869
1587
  }
870
1588
  } catch (err) {
@@ -896,10 +1614,10 @@ async function commitCommand(flags) {
896
1614
  return;
897
1615
  }
898
1616
  s.stop("Commit failed.");
899
- await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1617
+ if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
900
1618
  await stageAll();
901
1619
  return (await attemptCommit(message)).ok;
902
- }, message, result.stderr ?? "");
1620
+ }, message, result.stderr ?? "") === "cancelled") process.exit(1);
903
1621
  return;
904
1622
  }
905
1623
  debug("Staged files:", diffResult.files);
@@ -919,7 +1637,7 @@ async function commitCommand(flags) {
919
1637
  const key = await promptText({
920
1638
  message: "Enter your Groq API key:",
921
1639
  placeholder: "gsk_...",
922
- validate: (v) => v.trim() ? void 0 : "API key is required"
1640
+ validate: (v) => v?.trim() ? void 0 : "API key is required"
923
1641
  });
924
1642
  if (isCancel(key)) {
925
1643
  outro(dim("Cancelled."));
@@ -942,43 +1660,12 @@ async function commitCommand(flags) {
942
1660
  }
943
1661
  s.stop("Message generated");
944
1662
  }
945
- const { select, text } = await import("@clack/prompts");
946
- const review = await select({
947
- message: `Review commit message:\n\n ${bold(message)}\n`,
948
- options: [
949
- {
950
- label: "Use as-is",
951
- value: "use"
952
- },
953
- {
954
- label: "Edit",
955
- value: "edit"
956
- },
957
- {
958
- label: "Cancel",
959
- value: "cancel"
960
- }
961
- ]
962
- });
963
- if (isCancel(review) || review === "cancel") {
964
- debug("User cancelled at review step");
1663
+ const reviewed = await reviewCommitMessage(message);
1664
+ if (reviewed === null) {
965
1665
  outro(dim("Cancelled."));
966
1666
  return;
967
1667
  }
968
- if (review === "edit") {
969
- debug("User chose to edit message");
970
- const edited = await text({
971
- message: "Edit commit message:",
972
- initialValue: message,
973
- validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
974
- });
975
- if (isCancel(edited)) {
976
- outro(dim("Cancelled."));
977
- return;
978
- }
979
- message = String(edited).trim();
980
- debug("Edited message:", message);
981
- }
1668
+ message = reviewed;
982
1669
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
983
1670
  const repoRoot = await getRepoRoot();
984
1671
  await saveCachedCommit(repoRoot, message);
@@ -1004,35 +1691,14 @@ async function commitCommand(flags) {
1004
1691
  debug("Commit failed, showing recovery menu");
1005
1692
  const errors = parseHookErrors(result.stderr ?? "");
1006
1693
  debug("Parsed hook errors:", errors.length, "errors");
1007
- await showRecoveryMenu(errors, async () => {
1694
+ if (await showRecoveryMenu(errors, async () => {
1008
1695
  return (await attemptCommit(message)).ok;
1009
1696
  }, async (msg) => {
1010
1697
  return (await attemptCommitNoVerify(msg)).ok;
1011
1698
  }, async () => {
1012
1699
  await stageAll();
1013
1700
  return (await attemptCommit(message)).ok;
1014
- }, message, result.stderr ?? "");
1015
- }
1016
- async function generateMessage(diff, hint) {
1017
- const config = await readConfig();
1018
- const apiKey = await getApiKey();
1019
- debug("Generating message with model:", config.model, "type:", config.type);
1020
- return generateCommitMessage(diff, {
1021
- apiKey,
1022
- model: config.model,
1023
- type: config.type,
1024
- timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1025
- hint
1026
- });
1027
- }
1028
- function buildExcludedFilesMessage(files) {
1029
- const excludes = getDefaultExcludes();
1030
- const isLockfile = (f) => excludes.some((pattern) => {
1031
- if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
1032
- return false;
1033
- });
1034
- if (files.every(isLockfile)) return "chore: update lockfile";
1035
- return "chore: update generated files";
1701
+ }, message, result.stderr ?? "") === "cancelled") process.exit(1);
1036
1702
  }
1037
1703
  //#endregion
1038
1704
  //#region src/commands/config.ts
@@ -1090,6 +1756,12 @@ cli({
1090
1756
  description: "Add context hint for AI commit message generation",
1091
1757
  alias: "H"
1092
1758
  },
1759
+ review: {
1760
+ type: Boolean,
1761
+ description: "Review staged changes with a coding model",
1762
+ alias: "R",
1763
+ default: false
1764
+ },
1093
1765
  debug: {
1094
1766
  type: Boolean,
1095
1767
  description: "Enable debug output",
@@ -1100,7 +1772,8 @@ cli({
1100
1772
  commands: [configCommand]
1101
1773
  }, (argv) => {
1102
1774
  setDebug(argv.flags.debug);
1103
- commitCommand(argv.flags);
1775
+ if (argv.flags.review) reviewCommand();
1776
+ else commitCommand(argv.flags);
1104
1777
  });
1105
1778
  //#endregion
1106
1779
  export {};