@kyubiware/commit-mint 0.6.0 → 0.6.2

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,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli, command } from "cleye";
3
- import * as p from "@clack/prompts";
4
- import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
5
- import { bold, cyan, dim, green, red, yellow } from "kolorist";
6
- import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import Groq from "groq-sdk";
4
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
5
  import os from "node:os";
8
6
  import { extname, join } from "node:path";
9
- import ini from "ini";
10
- import Groq from "groq-sdk";
7
+ import { bold, cyan, dim, green, red, yellow } from "kolorist";
8
+ import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
11
9
  import { execa } from "execa";
12
- import { createHash } from "node:crypto";
13
- import { readFileSync } from "node:fs";
14
10
  import picomatch from "picomatch";
11
+ import ini from "ini";
12
+ import { createHash } from "node:crypto";
13
+ import * as p from "@clack/prompts";
14
+ import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
15
15
  import { spawn } from "node:child_process";
16
16
  //#region \0rolldown/runtime.js
17
17
  var __defProp = Object.defineProperty;
@@ -28,7 +28,7 @@ var __exportAll = (all, no_symbols) => {
28
28
  //#region package.json
29
29
  var package_default = {
30
30
  name: "@kyubiware/commit-mint",
31
- version: "0.6.0",
31
+ version: "0.6.2",
32
32
  description: "🌿 A commit tool that actually handles hook failures",
33
33
  type: "module",
34
34
  bin: { "cmint": "./dist/cli.mjs" },
@@ -39,7 +39,7 @@ var package_default = {
39
39
  "dev:auto": "tsx src/cli.ts -a",
40
40
  "dev:debug": "tsx src/cli.ts --debug",
41
41
  "lint": "biome check .",
42
- "lint:fix": "biome check --fix .",
42
+ "lint:fix": "biome check --write --unsafe .",
43
43
  "typecheck": "tsc --noEmit",
44
44
  "test": "vitest run",
45
45
  "test:coverage": "vitest run --coverage",
@@ -89,13 +89,30 @@ var package_default = {
89
89
  //#endregion
90
90
  //#region src/utils/debug.ts
91
91
  let enabled = false;
92
+ let dirEnsured = false;
93
+ let sessionWritten = false;
94
+ let logFile = join(os.homedir(), ".cache", "commit-mint", "debug.log");
95
+ const LOG_DIR = join(os.homedir(), ".cache", "commit-mint");
96
+ function ensureLogDir() {
97
+ if (dirEnsured) return;
98
+ mkdirSync(LOG_DIR, { recursive: true });
99
+ dirEnsured = true;
100
+ }
92
101
  function setDebug(value) {
93
102
  enabled = value;
94
103
  }
104
+ function writeSessionHeader() {
105
+ if (sessionWritten) return;
106
+ ensureLogDir();
107
+ writeFileSync(logFile, `--- session ${(/* @__PURE__ */ new Date()).toISOString()} ---\n`, "utf8");
108
+ sessionWritten = true;
109
+ }
95
110
  function debug(...args) {
111
+ const prefix = `[debug ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 23)}]`;
112
+ ensureLogDir();
113
+ appendFileSync(logFile, `${prefix} ${args.map(String).join(" ")}\n`, "utf8");
96
114
  if (!enabled) return;
97
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
98
- console.error(dim(`[debug ${timestamp}]`), ...args);
115
+ console.error(dim(prefix), ...args);
99
116
  }
100
117
  //#endregion
101
118
  //#region src/services/provider.ts
@@ -175,69 +192,178 @@ function createProvider(options) {
175
192
  };
176
193
  }
177
194
  //#endregion
178
- //#region src/services/config.ts
179
- const CONFIG_PATH = join(os.homedir(), ".commit-mint");
180
- const defaults = {
181
- provider: "groq",
182
- model: "openai/gpt-oss-20b",
183
- locale: "en",
184
- "max-length": "100",
185
- type: "",
186
- timeout: "10000"
187
- };
188
- async function readConfig() {
189
- debug("readConfig: loading from %s", CONFIG_PATH);
190
- try {
191
- const raw = await readFile(CONFIG_PATH, "utf8");
192
- const parsed = ini.parse(raw);
193
- const merged = {
194
- ...defaults,
195
- ...parsed
196
- };
197
- debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
198
- return merged;
199
- } catch {
200
- debug("readConfig: no config file, using defaults");
201
- return { ...defaults };
195
+ //#region src/services/ai.ts
196
+ const MAX_DIFF_CHARS = 2e4;
197
+ function mapGroqError(error, providerLabel) {
198
+ const label = providerLabel ?? "Groq";
199
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
200
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
201
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
202
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
203
+ if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
204
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
205
+ }
206
+ const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
207
+ function stripThinkTags(text) {
208
+ return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
209
+ }
210
+ function deriveMessageFromReasoning(reasoning) {
211
+ const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
212
+ if (match) return match[0].trim();
213
+ const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
214
+ return first ? first.trim() : null;
215
+ }
216
+ function stripContextLines(diff) {
217
+ return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
218
+ }
219
+ function compressDiff(diff) {
220
+ if (diff.length <= MAX_DIFF_CHARS) return diff;
221
+ let result = stripContextLines(diff);
222
+ if (result.length <= MAX_DIFF_CHARS) return result;
223
+ result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
224
+ return fd.split(/(?=\n@@)/).map((part, idx) => {
225
+ if (idx === 0) return part;
226
+ const lines = part.split("\n");
227
+ return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
228
+ }).join("");
229
+ }).join("");
230
+ if (result.length <= MAX_DIFF_CHARS) return result;
231
+ return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
232
+ const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
233
+ return match && match[1] === match[2] ? `${match[1]} | changed` : "";
234
+ }).filter(Boolean).join("\n")}`;
235
+ }
236
+ function buildStatSummary(diff) {
237
+ const files = [];
238
+ let currentFile = "";
239
+ let adds = 0;
240
+ let dels = 0;
241
+ for (const line of diff.split("\n")) {
242
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
243
+ if (match) {
244
+ if (currentFile) files.push({
245
+ name: currentFile,
246
+ adds,
247
+ dels
248
+ });
249
+ currentFile = match[1];
250
+ adds = 0;
251
+ dels = 0;
252
+ } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
253
+ else if (line.startsWith("-") && !line.startsWith("---")) dels++;
202
254
  }
255
+ if (currentFile) files.push({
256
+ name: currentFile,
257
+ adds,
258
+ dels
259
+ });
260
+ const totalAdds = files.reduce((s, f) => s + f.adds, 0);
261
+ const totalDels = files.reduce((s, f) => s + f.dels, 0);
262
+ const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
263
+ lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
264
+ return lines.join("\n");
203
265
  }
204
- async function writeConfig(updates) {
205
- const existing = await readConfig();
206
- Object.assign(existing, updates);
207
- await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
266
+ function buildSystemPrompt(type) {
267
+ 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.";
268
+ if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
269
+ return prompt;
208
270
  }
209
- async function setConfigValue(key, value) {
210
- await writeConfig({ [key]: value });
271
+ function buildUserPrompt(diff, hint, statSummary) {
272
+ const parts = [];
273
+ if (hint) parts.push(`Context: ${hint}`);
274
+ if (statSummary) parts.push(`Change summary:\n${statSummary}`);
275
+ parts.push(`Generate a conventional commit for:\n\n${diff}`);
276
+ return parts.join("\n\n");
211
277
  }
212
- async function getProviderApiKey(provider) {
213
- const envVar = PROVIDER_ENV_KEYS[provider];
214
- if (envVar) {
215
- const envValue = process.env[envVar];
216
- if (envValue) {
217
- debug("getProviderApiKey(%s): found in env", provider);
218
- return envValue;
278
+ function isValidConventionalCommit(message) {
279
+ return CONVENTIONAL_COMMIT_REGEX.test(message);
280
+ }
281
+ function extractContentText(content) {
282
+ if (content == null) return "";
283
+ if (typeof content === "string") return content.trim();
284
+ if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
285
+ return "";
286
+ }
287
+ async function generateCommitMessage(diff, options) {
288
+ const timeoutMs = options.timeout ?? 6e4;
289
+ debug("Timeout: %d ms", timeoutMs);
290
+ const { client, model } = createProvider({
291
+ provider: options.provider ?? "groq",
292
+ apiKey: options.apiKey,
293
+ modelOverride: options.model,
294
+ timeout: timeoutMs,
295
+ baseURLOverride: options.proxy
296
+ });
297
+ debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
298
+ const compressedDiff = compressDiff(diff);
299
+ const statSummary = buildStatSummary(diff);
300
+ const systemPrompt = buildSystemPrompt(options.type);
301
+ const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
302
+ debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
303
+ debug("Stat summary:\n%s", statSummary);
304
+ debug("User prompt length: %d chars", userPrompt.length);
305
+ async function callAI(strictSystemPrompt) {
306
+ const callStart = Date.now();
307
+ debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
308
+ try {
309
+ const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
310
+ const isGroq = (options.provider ?? "groq") === "groq";
311
+ const completion = await client.chat.completions.create({
312
+ messages: [{
313
+ role: "system",
314
+ content: strictSystemPrompt ?? systemPrompt
315
+ }, {
316
+ role: "user",
317
+ content: userPrompt
318
+ }],
319
+ model,
320
+ temperature: .3,
321
+ ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
322
+ ...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
323
+ });
324
+ const elapsed = Date.now() - callStart;
325
+ const rawContent = completion.choices[0]?.message?.content;
326
+ const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
327
+ 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);
328
+ debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
329
+ if (!content) {
330
+ const reasoning = completion.choices[0]?.message?.reasoning;
331
+ debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
332
+ if (reasoning) {
333
+ const derived = deriveMessageFromReasoning(reasoning);
334
+ if (derived) {
335
+ debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
336
+ return stripThinkTags(derived);
337
+ }
338
+ debug("callAI: could not derive message from reasoning");
339
+ }
340
+ throw new Error("AI returned an empty commit message");
341
+ }
342
+ return content;
343
+ } catch (error) {
344
+ debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
345
+ throw error;
219
346
  }
220
347
  }
221
- const config = await readConfig();
222
- const configKey = PROVIDER_ENV_KEYS[provider];
223
- if (configKey && config[configKey]) {
224
- debug("getProviderApiKey(%s): found in config", provider);
225
- return config[configKey];
348
+ try {
349
+ const totalStart = Date.now();
350
+ let message = await callAI();
351
+ debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
352
+ if (!isValidConventionalCommit(message)) {
353
+ debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
354
+ 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.");
355
+ debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
356
+ if (isValidConventionalCommit(retryMessage)) {
357
+ debug("Retry produced valid conventional commit");
358
+ message = retryMessage;
359
+ } else debug("Retry also failed validation, using original message");
360
+ }
361
+ debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
362
+ return message;
363
+ } catch (error) {
364
+ debug("AI error: %s", error instanceof Error ? error.message : String(error));
365
+ throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
226
366
  }
227
- debug("getProviderApiKey(%s): not found", provider);
228
- throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
229
- }
230
- /** Check if a model name is the default for a provider OTHER than the given one. */
231
- function isOtherProviderDefault(model, provider) {
232
- for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
233
- return false;
234
- }
235
- function getModelForProvider(config, provider, defaultModel) {
236
- const providerModel = config[`model_${provider}`];
237
- if (providerModel) return providerModel;
238
- const globalModel = config.model;
239
- if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
240
- return defaultModel;
241
367
  }
242
368
  //#endregion
243
369
  //#region src/services/hooks.ts
@@ -473,439 +599,6 @@ function findMeaningfulCommand(command) {
473
599
  return segments[segments.length - 1] || command;
474
600
  }
475
601
  //#endregion
476
- //#region src/services/hook-progress.ts
477
- const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
478
- function createStderrParser() {
479
- let buffer = "";
480
- return (chunk) => {
481
- buffer += chunk;
482
- const steps = [];
483
- const lines = buffer.split("\n");
484
- buffer = lines.pop() ?? "";
485
- for (const line of lines) {
486
- const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
487
- if (!match) continue;
488
- const status = match[1].toLowerCase();
489
- const command = match[2].trim();
490
- if (isLintStagedMeta(command)) continue;
491
- const tool = extractToolName(command) ?? command;
492
- steps.push({
493
- status,
494
- command,
495
- tool
496
- });
497
- }
498
- return steps;
499
- };
500
- }
501
- function createProgressHandler(s) {
502
- return (step) => {
503
- if (step.status === "started") s.message(step.command);
504
- else if (step.status === "failed") s.message(step.command);
505
- };
506
- }
507
- //#endregion
508
- //#region src/services/git.ts
509
- var git_exports = /* @__PURE__ */ __exportAll({
510
- KnownError: () => KnownError,
511
- assertGitRepo: () => assertGitRepo,
512
- attemptCommit: () => attemptCommit,
513
- attemptCommitNoVerify: () => attemptCommitNoVerify,
514
- getChangedFiles: () => getChangedFiles,
515
- getDefaultExcludes: () => getDefaultExcludes,
516
- getHead: () => getHead,
517
- getRepoRoot: () => getRepoRoot,
518
- getStagedDiff: () => getStagedDiff,
519
- getStatusShort: () => getStatusShort,
520
- resetStaging: () => resetStaging,
521
- stageAll: () => stageAll,
522
- stageFiles: () => stageFiles
523
- });
524
- var KnownError = class extends Error {};
525
- async function assertGitRepo() {
526
- debug("assertGitRepo");
527
- const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
528
- if (failed) throw new KnownError("The current directory must be a Git repository!");
529
- }
530
- async function getRepoRoot() {
531
- const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
532
- debug("getRepoRoot:", stdout.trim());
533
- return stdout.trim();
534
- }
535
- const DEFAULT_EXCLUDES = [
536
- "package-lock.json",
537
- "node_modules/**",
538
- "dist/**",
539
- "build/**",
540
- ".next/**",
541
- "coverage/**",
542
- "*.log",
543
- "*.min.js",
544
- "*.min.css",
545
- "*.lock",
546
- ".DS_Store"
547
- ];
548
- function getDefaultExcludes() {
549
- return [...DEFAULT_EXCLUDES];
550
- }
551
- async function getStagedDiff(exclude) {
552
- const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
553
- const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
554
- const { stdout: allFiles } = await execa("git", [
555
- "diff",
556
- "--cached",
557
- "--name-only"
558
- ]);
559
- if (!allFiles) {
560
- debug("getStagedDiff: no staged files");
561
- return null;
562
- }
563
- const { stdout: files } = await execa("git", [
564
- "diff",
565
- "--cached",
566
- "--name-only",
567
- ...defaultExcludeArgs,
568
- ...excludeArgs
569
- ]);
570
- if (!files) {
571
- const excludedFiles = allFiles.split("\n").filter(Boolean);
572
- debug("getStagedDiff: all files excluded:", excludedFiles);
573
- return { excludedFiles };
574
- }
575
- const { stdout: diff } = await execa("git", [
576
- "diff",
577
- "--cached",
578
- "--diff-algorithm=minimal",
579
- ...defaultExcludeArgs,
580
- ...excludeArgs
581
- ]);
582
- debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
583
- return {
584
- files: files.split("\n").filter(Boolean),
585
- diff
586
- };
587
- }
588
- async function stageAll() {
589
- debug("stageAll: git add -A");
590
- await execa("git", ["add", "-A"]);
591
- }
592
- async function resetStaging() {
593
- debug("resetStaging: git reset HEAD");
594
- await execa("git", ["reset", "HEAD"]);
595
- }
596
- async function getHead() {
597
- const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
598
- return stdout.trim();
599
- }
600
- async function getStatusShort() {
601
- const { stdout } = await execa("git", ["status", "--short"]);
602
- return stdout.trim();
603
- }
604
- async function getChangedFiles() {
605
- const { stdout } = await execa("git", ["status", "--short"]);
606
- if (!stdout.trim()) return [];
607
- const files = stdout.split("\n").filter(Boolean).map((line) => {
608
- const indexStatus = line[0];
609
- return {
610
- status: line.slice(0, 2).trim(),
611
- path: line.slice(3),
612
- staged: indexStatus !== " " && indexStatus !== "?"
613
- };
614
- });
615
- debug("getChangedFiles:", files.length, "files");
616
- return files;
617
- }
618
- async function stageFiles(paths) {
619
- debug("stageFiles:", paths);
620
- await execa("git", ["add", ...paths]);
621
- }
622
- async function attemptCommit(message, extraArgs = [], onProgress) {
623
- debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
624
- try {
625
- const subprocess = execa("git", [
626
- "commit",
627
- "-m",
628
- message,
629
- ...extraArgs
630
- ]);
631
- const stderrChunks = [];
632
- const parser = onProgress ? createStderrParser() : null;
633
- subprocess.stderr?.on("data", (chunk) => {
634
- const text = chunk.toString();
635
- stderrChunks.push(text);
636
- if (parser && onProgress) for (const step of parser(text)) onProgress(step);
637
- });
638
- await subprocess;
639
- debug("attemptCommit: success");
640
- return {
641
- ok: true,
642
- stderr: stderrChunks.join("")
643
- };
644
- } catch (error) {
645
- const e = error;
646
- debug("attemptCommit: failed —", e.message?.slice(0, 200));
647
- return {
648
- ok: false,
649
- error: e.message,
650
- stderr: typeof e.stderr === "string" ? e.stderr : ""
651
- };
652
- }
653
- }
654
- async function attemptCommitNoVerify(message, onProgress) {
655
- debug("attemptCommitNoVerify:", message);
656
- return attemptCommit(message, ["--no-verify"], onProgress);
657
- }
658
- //#endregion
659
- //#region src/ui/review-message.ts
660
- async function reviewCommitMessage(message) {
661
- const { select, text } = await import("@clack/prompts");
662
- while (true) {
663
- const review = await select({
664
- message: `Review commit message:\n\n ${bold(message)}\n`,
665
- options: [
666
- {
667
- label: "Use as-is",
668
- value: "use"
669
- },
670
- {
671
- label: "Edit",
672
- value: "edit"
673
- },
674
- {
675
- label: "Cancel",
676
- value: "cancel"
677
- }
678
- ]
679
- });
680
- if (isCancel(review) || review === "cancel") {
681
- debug("User cancelled at review step");
682
- return null;
683
- }
684
- if (review === "use") {
685
- debug("User accepted message");
686
- return message;
687
- }
688
- if (review === "edit") {
689
- debug("User chose to edit message");
690
- const edited = await text({
691
- message: "Edit commit message:",
692
- initialValue: message,
693
- validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
694
- });
695
- if (isCancel(edited)) continue;
696
- message = String(edited).trim();
697
- debug("Edited message:", message);
698
- }
699
- }
700
- }
701
- //#endregion
702
- //#region src/utils/cache.ts
703
- const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
704
- function repoHash(repoPath) {
705
- return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
706
- }
707
- function cachePath(repoPath) {
708
- return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
709
- }
710
- async function saveCachedCommit(repoPath, message) {
711
- await mkdir(CACHE_DIR, { recursive: true });
712
- const data = {
713
- message,
714
- timestamp: Date.now(),
715
- repoPath
716
- };
717
- const path = cachePath(repoPath);
718
- debug("saveCachedCommit: saving to %s", path);
719
- await writeFile(path, JSON.stringify(data, null, 2), "utf8");
720
- }
721
- async function loadCachedCommit(repoPath) {
722
- const path = cachePath(repoPath);
723
- debug("loadCachedCommit: loading from %s", path);
724
- try {
725
- const raw = await readFile(path, "utf8");
726
- const data = JSON.parse(raw);
727
- debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
728
- return data;
729
- } catch {
730
- debug("loadCachedCommit: no cached commit found");
731
- return null;
732
- }
733
- }
734
- //#endregion
735
- //#region src/services/ai.ts
736
- const MAX_DIFF_CHARS = 2e4;
737
- function mapGroqError(error, providerLabel) {
738
- const label = providerLabel ?? "Groq";
739
- if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
740
- if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
741
- if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
742
- if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
743
- if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
744
- return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
745
- }
746
- const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
747
- function stripThinkTags(text) {
748
- return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
749
- }
750
- function deriveMessageFromReasoning(reasoning) {
751
- const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
752
- if (match) return match[0].trim();
753
- const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
754
- return first ? first.trim() : null;
755
- }
756
- function stripContextLines(diff) {
757
- return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
758
- }
759
- function compressDiff(diff) {
760
- if (diff.length <= MAX_DIFF_CHARS) return diff;
761
- let result = stripContextLines(diff);
762
- if (result.length <= MAX_DIFF_CHARS) return result;
763
- result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
764
- return fd.split(/(?=\n@@)/).map((part, idx) => {
765
- if (idx === 0) return part;
766
- const lines = part.split("\n");
767
- return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
768
- }).join("");
769
- }).join("");
770
- if (result.length <= MAX_DIFF_CHARS) return result;
771
- return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
772
- const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
773
- return match && match[1] === match[2] ? `${match[1]} | changed` : "";
774
- }).filter(Boolean).join("\n")}`;
775
- }
776
- function buildStatSummary(diff) {
777
- const files = [];
778
- let currentFile = "";
779
- let adds = 0;
780
- let dels = 0;
781
- for (const line of diff.split("\n")) {
782
- const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
783
- if (match) {
784
- if (currentFile) files.push({
785
- name: currentFile,
786
- adds,
787
- dels
788
- });
789
- currentFile = match[1];
790
- adds = 0;
791
- dels = 0;
792
- } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
793
- else if (line.startsWith("-") && !line.startsWith("---")) dels++;
794
- }
795
- if (currentFile) files.push({
796
- name: currentFile,
797
- adds,
798
- dels
799
- });
800
- const totalAdds = files.reduce((s, f) => s + f.adds, 0);
801
- const totalDels = files.reduce((s, f) => s + f.dels, 0);
802
- const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
803
- lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
804
- return lines.join("\n");
805
- }
806
- function buildSystemPrompt(type) {
807
- 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.";
808
- if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
809
- return prompt;
810
- }
811
- function buildUserPrompt(diff, hint, statSummary) {
812
- const parts = [];
813
- if (hint) parts.push(`Context: ${hint}`);
814
- if (statSummary) parts.push(`Change summary:\n${statSummary}`);
815
- parts.push(`Generate a conventional commit for:\n\n${diff}`);
816
- return parts.join("\n\n");
817
- }
818
- function isValidConventionalCommit(message) {
819
- return CONVENTIONAL_COMMIT_REGEX.test(message);
820
- }
821
- function extractContentText(content) {
822
- if (content == null) return "";
823
- if (typeof content === "string") return content.trim();
824
- if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
825
- return "";
826
- }
827
- async function generateCommitMessage(diff, options) {
828
- const timeoutMs = options.timeout ?? 6e4;
829
- debug("Timeout: %d ms", timeoutMs);
830
- const { client, model } = createProvider({
831
- provider: options.provider ?? "groq",
832
- apiKey: options.apiKey,
833
- modelOverride: options.model,
834
- timeout: timeoutMs,
835
- baseURLOverride: options.proxy
836
- });
837
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
838
- const compressedDiff = compressDiff(diff);
839
- const statSummary = buildStatSummary(diff);
840
- const systemPrompt = buildSystemPrompt(options.type);
841
- const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
842
- debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
843
- debug("Stat summary:\n%s", statSummary);
844
- debug("User prompt length: %d chars", userPrompt.length);
845
- async function callAI(strictSystemPrompt) {
846
- const callStart = Date.now();
847
- debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
848
- try {
849
- const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
850
- const isGroq = (options.provider ?? "groq") === "groq";
851
- const completion = await client.chat.completions.create({
852
- messages: [{
853
- role: "system",
854
- content: strictSystemPrompt ?? systemPrompt
855
- }, {
856
- role: "user",
857
- content: userPrompt
858
- }],
859
- model,
860
- temperature: .3,
861
- ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
862
- ...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
863
- });
864
- const elapsed = Date.now() - callStart;
865
- const rawContent = completion.choices[0]?.message?.content;
866
- const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
867
- 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);
868
- debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
869
- if (!content) {
870
- const reasoning = completion.choices[0]?.message?.reasoning;
871
- debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
872
- if (reasoning) {
873
- const derived = deriveMessageFromReasoning(reasoning);
874
- if (derived) {
875
- debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
876
- return stripThinkTags(derived);
877
- }
878
- debug("callAI: could not derive message from reasoning");
879
- }
880
- throw new Error("AI returned an empty commit message");
881
- }
882
- return content;
883
- } catch (error) {
884
- debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
885
- throw error;
886
- }
887
- }
888
- try {
889
- const totalStart = Date.now();
890
- let message = await callAI();
891
- debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
892
- if (!isValidConventionalCommit(message)) {
893
- debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
894
- 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.");
895
- debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
896
- if (isValidConventionalCommit(retryMessage)) {
897
- debug("Retry produced valid conventional commit");
898
- message = retryMessage;
899
- } else debug("Retry also failed validation, using original message");
900
- }
901
- debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
902
- return message;
903
- } catch (error) {
904
- debug("AI error: %s", error instanceof Error ? error.message : String(error));
905
- throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
906
- }
907
- }
908
- //#endregion
909
602
  //#region src/services/checks.ts
910
603
  /** Config file names, checked in priority order (matches lint-staged naming conventions) */
911
604
  const CONFIG_FILES = [
@@ -1094,30 +787,278 @@ async function runAllChecks(repoRoot, stagedFiles, timeout) {
1094
787
  debug("runAllChecks: no config found, skipping checks");
1095
788
  return {
1096
789
  ok: true,
1097
- results: []
790
+ results: []
791
+ };
792
+ }
793
+ const config = await loadConfig(repoRoot);
794
+ debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
795
+ const results = [];
796
+ for (const [glob, commands] of Object.entries(config)) {
797
+ const matchedFiles = matchFiles(glob, stagedFiles);
798
+ if (matchedFiles.length === 0) {
799
+ debug("runAllChecks: no files matched pattern '%s'", glob);
800
+ continue;
801
+ }
802
+ debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
803
+ if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot)) return {
804
+ ok: false,
805
+ results
806
+ };
807
+ }
808
+ const ok = results.every((r) => r.ok);
809
+ debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
810
+ return {
811
+ ok,
812
+ results
813
+ };
814
+ }
815
+ //#endregion
816
+ //#region src/services/config.ts
817
+ const CONFIG_PATH = join(os.homedir(), ".commit-mint");
818
+ const defaults = {
819
+ provider: "groq",
820
+ model: "openai/gpt-oss-20b",
821
+ locale: "en",
822
+ "max-length": "100",
823
+ type: "",
824
+ timeout: "10000"
825
+ };
826
+ async function readConfig() {
827
+ debug("readConfig: loading from %s", CONFIG_PATH);
828
+ try {
829
+ const raw = await readFile(CONFIG_PATH, "utf8");
830
+ const parsed = ini.parse(raw);
831
+ const merged = {
832
+ ...defaults,
833
+ ...parsed
834
+ };
835
+ debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
836
+ return merged;
837
+ } catch {
838
+ debug("readConfig: no config file, using defaults");
839
+ return { ...defaults };
840
+ }
841
+ }
842
+ async function writeConfig(updates) {
843
+ const existing = await readConfig();
844
+ Object.assign(existing, updates);
845
+ await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
846
+ }
847
+ async function setConfigValue(key, value) {
848
+ await writeConfig({ [key]: value });
849
+ }
850
+ async function getProviderApiKey(provider) {
851
+ const envVar = PROVIDER_ENV_KEYS[provider];
852
+ if (envVar) {
853
+ const envValue = process.env[envVar];
854
+ if (envValue) {
855
+ debug("getProviderApiKey(%s): found in env", provider);
856
+ return envValue;
857
+ }
858
+ }
859
+ const config = await readConfig();
860
+ const configKey = PROVIDER_ENV_KEYS[provider];
861
+ if (configKey && config[configKey]) {
862
+ debug("getProviderApiKey(%s): found in config", provider);
863
+ return config[configKey];
864
+ }
865
+ debug("getProviderApiKey(%s): not found", provider);
866
+ throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
867
+ }
868
+ /** Check if a model name is the default for a provider OTHER than the given one. */
869
+ function isOtherProviderDefault(model, provider) {
870
+ for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
871
+ return false;
872
+ }
873
+ function getModelForProvider(config, provider, defaultModel) {
874
+ const providerModel = config[`model_${provider}`];
875
+ if (providerModel) return providerModel;
876
+ const globalModel = config.model;
877
+ if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
878
+ return defaultModel;
879
+ }
880
+ //#endregion
881
+ //#region src/services/hook-progress.ts
882
+ const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
883
+ function createStderrParser() {
884
+ let buffer = "";
885
+ return (chunk) => {
886
+ buffer += chunk;
887
+ const steps = [];
888
+ const lines = buffer.split("\n");
889
+ buffer = lines.pop() ?? "";
890
+ for (const line of lines) {
891
+ const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
892
+ if (!match) continue;
893
+ const status = match[1].toLowerCase();
894
+ const command = match[2].trim();
895
+ if (isLintStagedMeta(command)) continue;
896
+ const tool = extractToolName(command) ?? command;
897
+ steps.push({
898
+ status,
899
+ command,
900
+ tool
901
+ });
902
+ }
903
+ return steps;
904
+ };
905
+ }
906
+ function createProgressHandler(s) {
907
+ return (step) => {
908
+ if (step.status === "started") s.message(step.command);
909
+ else if (step.status === "failed") s.message(step.command);
910
+ };
911
+ }
912
+ //#endregion
913
+ //#region src/services/git.ts
914
+ var git_exports = /* @__PURE__ */ __exportAll({
915
+ KnownError: () => KnownError,
916
+ assertGitRepo: () => assertGitRepo,
917
+ attemptCommit: () => attemptCommit,
918
+ attemptCommitNoVerify: () => attemptCommitNoVerify,
919
+ getChangedFiles: () => getChangedFiles,
920
+ getDefaultExcludes: () => getDefaultExcludes,
921
+ getHead: () => getHead,
922
+ getRepoRoot: () => getRepoRoot,
923
+ getStagedDiff: () => getStagedDiff,
924
+ getStatusShort: () => getStatusShort,
925
+ resetStaging: () => resetStaging,
926
+ stageAll: () => stageAll,
927
+ stageFiles: () => stageFiles
928
+ });
929
+ var KnownError = class extends Error {};
930
+ async function assertGitRepo() {
931
+ debug("assertGitRepo");
932
+ const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
933
+ if (failed) throw new KnownError("The current directory must be a Git repository!");
934
+ }
935
+ async function getRepoRoot() {
936
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
937
+ debug("getRepoRoot:", stdout.trim());
938
+ return stdout.trim();
939
+ }
940
+ const DEFAULT_EXCLUDES = [
941
+ "package-lock.json",
942
+ "node_modules/**",
943
+ "dist/**",
944
+ "build/**",
945
+ ".next/**",
946
+ "coverage/**",
947
+ "*.log",
948
+ "*.min.js",
949
+ "*.min.css",
950
+ "*.lock",
951
+ ".DS_Store"
952
+ ];
953
+ function getDefaultExcludes() {
954
+ return [...DEFAULT_EXCLUDES];
955
+ }
956
+ async function getStagedDiff(exclude) {
957
+ const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
958
+ const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
959
+ const { stdout: allFiles } = await execa("git", [
960
+ "diff",
961
+ "--cached",
962
+ "--name-only"
963
+ ]);
964
+ if (!allFiles) {
965
+ debug("getStagedDiff: no staged files");
966
+ return null;
967
+ }
968
+ const { stdout: files } = await execa("git", [
969
+ "diff",
970
+ "--cached",
971
+ "--name-only",
972
+ ...defaultExcludeArgs,
973
+ ...excludeArgs
974
+ ]);
975
+ if (!files) {
976
+ const excludedFiles = allFiles.split("\n").filter(Boolean);
977
+ debug("getStagedDiff: all files excluded:", excludedFiles);
978
+ return { excludedFiles };
979
+ }
980
+ const { stdout: diff } = await execa("git", [
981
+ "diff",
982
+ "--cached",
983
+ "--diff-algorithm=minimal",
984
+ ...defaultExcludeArgs,
985
+ ...excludeArgs
986
+ ]);
987
+ debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
988
+ return {
989
+ files: files.split("\n").filter(Boolean),
990
+ diff
991
+ };
992
+ }
993
+ async function stageAll() {
994
+ debug("stageAll: git add -A");
995
+ await execa("git", ["add", "-A"]);
996
+ }
997
+ async function resetStaging() {
998
+ debug("resetStaging: git reset HEAD");
999
+ await execa("git", ["reset", "HEAD"]);
1000
+ }
1001
+ async function getHead() {
1002
+ const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
1003
+ return stdout.trim();
1004
+ }
1005
+ async function getStatusShort() {
1006
+ const { stdout } = await execa("git", ["status", "--short"]);
1007
+ return stdout.trim();
1008
+ }
1009
+ async function getChangedFiles() {
1010
+ const { stdout } = await execa("git", ["status", "--short"]);
1011
+ if (!stdout.trim()) return [];
1012
+ const files = stdout.split("\n").filter(Boolean).map((line) => {
1013
+ const indexStatus = line[0];
1014
+ return {
1015
+ status: line.slice(0, 2).trim(),
1016
+ path: line.slice(3),
1017
+ staged: indexStatus !== " " && indexStatus !== "?"
1018
+ };
1019
+ });
1020
+ debug("getChangedFiles:", files.length, "files");
1021
+ return files;
1022
+ }
1023
+ async function stageFiles(paths) {
1024
+ debug("stageFiles:", paths);
1025
+ await execa("git", ["add", ...paths]);
1026
+ }
1027
+ async function attemptCommit(message, extraArgs = [], onProgress) {
1028
+ debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
1029
+ try {
1030
+ const subprocess = execa("git", [
1031
+ "commit",
1032
+ "-m",
1033
+ message,
1034
+ ...extraArgs
1035
+ ]);
1036
+ const stderrChunks = [];
1037
+ const parser = onProgress ? createStderrParser() : null;
1038
+ subprocess.stderr?.on("data", (chunk) => {
1039
+ const text = chunk.toString();
1040
+ stderrChunks.push(text);
1041
+ if (parser && onProgress) for (const step of parser(text)) onProgress(step);
1042
+ });
1043
+ await subprocess;
1044
+ debug("attemptCommit: success");
1045
+ return {
1046
+ ok: true,
1047
+ stderr: stderrChunks.join("")
1098
1048
  };
1099
- }
1100
- const config = await loadConfig(repoRoot);
1101
- debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
1102
- const results = [];
1103
- for (const [glob, commands] of Object.entries(config)) {
1104
- const matchedFiles = matchFiles(glob, stagedFiles);
1105
- if (matchedFiles.length === 0) {
1106
- debug("runAllChecks: no files matched pattern '%s'", glob);
1107
- continue;
1108
- }
1109
- debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
1110
- if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot)) return {
1049
+ } catch (error) {
1050
+ const e = error;
1051
+ debug("attemptCommit: failed ", e.message?.slice(0, 200));
1052
+ return {
1111
1053
  ok: false,
1112
- results
1054
+ error: e.message,
1055
+ stderr: typeof e.stderr === "string" ? e.stderr : ""
1113
1056
  };
1114
1057
  }
1115
- const ok = results.every((r) => r.ok);
1116
- debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
1117
- return {
1118
- ok,
1119
- results
1120
- };
1058
+ }
1059
+ async function attemptCommitNoVerify(message, onProgress) {
1060
+ debug("attemptCommitNoVerify:", message);
1061
+ return attemptCommit(message, ["--no-verify"], onProgress);
1121
1062
  }
1122
1063
  //#endregion
1123
1064
  //#region src/services/grouping.ts
@@ -1201,8 +1142,36 @@ function buildGroupingUserPrompt(summary) {
1201
1142
  summary
1202
1143
  ].join("\n");
1203
1144
  }
1145
+ function buildRetryGroupingPrompt() {
1146
+ return [
1147
+ "PREVIOUS ATTEMPT FAILED: You grouped all files into a single group.",
1148
+ "",
1149
+ "You MUST split the files into at least 2 groups based on what changed and why.",
1150
+ "",
1151
+ "Look for these natural split points:",
1152
+ "- Source code vs tests",
1153
+ "- Different features or modules (e.g., different directories)",
1154
+ "- New files vs modified files vs deleted files",
1155
+ "- Configuration changes vs code changes",
1156
+ "- Documentation vs implementation",
1157
+ "",
1158
+ "If unsure, err on the side of MORE groups, not fewer.",
1159
+ "",
1160
+ "Output format: JSON array of objects with keys 'name', 'description', 'files'.",
1161
+ "name: short label (3-5 words)",
1162
+ "description: 1-2 sentences explaining what this group changes",
1163
+ "files: array of exact file paths from the input",
1164
+ "",
1165
+ "Output ONLY valid JSON. No markdown fences, no explanation."
1166
+ ].join("\n");
1167
+ }
1204
1168
  function parseGroupingResponse(content) {
1205
- const jsonText = content.replace(/^```json\s*/, "").replace(/\s*```$/, "").trim();
1169
+ let cleaned = content.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
1170
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
1171
+ const start = cleaned.indexOf("[");
1172
+ const end = cleaned.lastIndexOf("]");
1173
+ if (start === -1 || end === -1 || end <= start) throw new Error("AI response did not contain a JSON array");
1174
+ const jsonText = cleaned.slice(start, end + 1);
1206
1175
  const parsed = JSON.parse(jsonText);
1207
1176
  if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
1208
1177
  const rawGroups = [];
@@ -1236,27 +1205,17 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1236
1205
  baseURLOverride: proxy
1237
1206
  });
1238
1207
  try {
1239
- const completion = await client.chat.completions.create({
1240
- messages: [{
1241
- role: "system",
1242
- content: systemPrompt
1243
- }, {
1244
- role: "user",
1245
- content: userPrompt
1246
- }],
1247
- model: resolvedModel,
1248
- temperature: .3,
1249
- max_tokens: 2048
1250
- });
1251
- const rawContent = completion.choices[0]?.message?.content;
1252
- const content = typeof rawContent === "string" ? rawContent.trim() : "";
1253
- debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
1254
- debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
1255
- if (!content) throw new Error("AI returned an empty grouping response");
1256
- const rawGroups = parseGroupingResponse(content);
1208
+ let rawGroups = await callGroupingAI(client, resolvedModel, systemPrompt, userPrompt);
1257
1209
  debug("generateGroups: parsed %d raw groups", rawGroups.length);
1258
- const validated = validateGroups(rawGroups, included);
1210
+ let validated = validateGroups(rawGroups, included);
1259
1211
  debug("generateGroups: %d validated groups", validated.length);
1212
+ if (isLowQualityGrouping(validated, included)) {
1213
+ debug("generateGroups: low quality result, retrying with stricter prompt");
1214
+ rawGroups = await callGroupingAI(client, resolvedModel, buildRetryGroupingPrompt(), userPrompt);
1215
+ debug("generateGroups retry: parsed %d raw groups", rawGroups.length);
1216
+ validated = validateGroups(rawGroups, included);
1217
+ debug("generateGroups retry: %d validated groups", validated.length);
1218
+ }
1260
1219
  return {
1261
1220
  groups: validated,
1262
1221
  excluded
@@ -1266,6 +1225,33 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1266
1225
  throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
1267
1226
  }
1268
1227
  }
1228
+ async function callGroupingAI(client, model, systemPrompt, userPrompt) {
1229
+ const completion = await client.chat.completions.create({
1230
+ messages: [{
1231
+ role: "system",
1232
+ content: systemPrompt
1233
+ }, {
1234
+ role: "user",
1235
+ content: userPrompt
1236
+ }],
1237
+ model,
1238
+ temperature: .3,
1239
+ max_tokens: 2048
1240
+ });
1241
+ const rawContent = completion.choices[0]?.message?.content;
1242
+ const content = typeof rawContent === "string" ? rawContent.trim() : "";
1243
+ debug("callGroupingAI response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
1244
+ debug("callGroupingAI raw content: %s", content.slice(0, 500) || "(empty)");
1245
+ if (!content) throw new Error("AI returned an empty grouping response");
1246
+ return parseGroupingResponse(content);
1247
+ }
1248
+ /** Minimum file count where a single-group result is considered low quality */
1249
+ const MIN_FILES_FOR_QUALITY_CHECK = 5;
1250
+ function isLowQualityGrouping(groups, allFiles) {
1251
+ if (groups.length === 0) return false;
1252
+ if (allFiles.length < MIN_FILES_FOR_QUALITY_CHECK) return false;
1253
+ return groups.length === 1;
1254
+ }
1269
1255
  function validateGroups(groups, allFiles) {
1270
1256
  const validPaths = new Set(allFiles.map((f) => f.path));
1271
1257
  const seen = /* @__PURE__ */ new Set();
@@ -1294,6 +1280,51 @@ function validateGroups(groups, allFiles) {
1294
1280
  }
1295
1281
  return validated;
1296
1282
  }
1283
+ const EXIT_CODES = {
1284
+ SUCCESS: 0,
1285
+ GENERIC: 1,
1286
+ NO_CHANGES: 2,
1287
+ GIT: 3,
1288
+ AI: 4,
1289
+ CHECK: 5,
1290
+ HOOK: 6
1291
+ };
1292
+ function writeAgentResult(result) {
1293
+ process.stdout.write(`${JSON.stringify(result)}\n`);
1294
+ }
1295
+ //#endregion
1296
+ //#region src/utils/cache.ts
1297
+ const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
1298
+ function repoHash(repoPath) {
1299
+ return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
1300
+ }
1301
+ function cachePath(repoPath) {
1302
+ return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
1303
+ }
1304
+ async function saveCachedCommit(repoPath, message) {
1305
+ await mkdir(CACHE_DIR, { recursive: true });
1306
+ const data = {
1307
+ message,
1308
+ timestamp: Date.now(),
1309
+ repoPath
1310
+ };
1311
+ const path = cachePath(repoPath);
1312
+ debug("saveCachedCommit: saving to %s", path);
1313
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
1314
+ }
1315
+ async function loadCachedCommit(repoPath) {
1316
+ const path = cachePath(repoPath);
1317
+ debug("loadCachedCommit: loading from %s", path);
1318
+ try {
1319
+ const raw = await readFile(path, "utf8");
1320
+ const data = JSON.parse(raw);
1321
+ debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
1322
+ return data;
1323
+ } catch {
1324
+ debug("loadCachedCommit: no cached commit found");
1325
+ return null;
1326
+ }
1327
+ }
1297
1328
  //#endregion
1298
1329
  //#region src/services/clipboard.ts
1299
1330
  /** Milliseconds to wait after stdin closes for quick exit failures. */
@@ -1678,6 +1709,49 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
1678
1709
  }
1679
1710
  }
1680
1711
  //#endregion
1712
+ //#region src/ui/review-message.ts
1713
+ async function reviewCommitMessage(message) {
1714
+ const { select, text } = await import("@clack/prompts");
1715
+ while (true) {
1716
+ const review = await select({
1717
+ message: `Review commit message:\n\n ${bold(message)}\n`,
1718
+ options: [
1719
+ {
1720
+ label: "Use as-is",
1721
+ value: "use"
1722
+ },
1723
+ {
1724
+ label: "Edit",
1725
+ value: "edit"
1726
+ },
1727
+ {
1728
+ label: "Cancel",
1729
+ value: "cancel"
1730
+ }
1731
+ ]
1732
+ });
1733
+ if (isCancel(review) || review === "cancel") {
1734
+ debug("User cancelled at review step");
1735
+ return null;
1736
+ }
1737
+ if (review === "use") {
1738
+ debug("User accepted message");
1739
+ return message;
1740
+ }
1741
+ if (review === "edit") {
1742
+ debug("User chose to edit message");
1743
+ const edited = await text({
1744
+ message: "Edit commit message:",
1745
+ initialValue: message,
1746
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
1747
+ });
1748
+ if (isCancel(edited)) continue;
1749
+ message = String(edited).trim();
1750
+ debug("Edited message:", message);
1751
+ }
1752
+ }
1753
+ }
1754
+ //#endregion
1681
1755
  //#region src/commands/auto-group.ts
1682
1756
  async function runAutoGroupFlow(changedFiles, flags) {
1683
1757
  const { included, excluded } = filterExcludedFiles(changedFiles);
@@ -1855,6 +1929,245 @@ function buildExcludedFilesMessage(files) {
1855
1929
  return "chore: update generated files";
1856
1930
  }
1857
1931
  //#endregion
1932
+ //#region src/commands/agent.ts
1933
+ /**
1934
+ * Wrapper around getHead() that returns "" on fresh repos with no commits.
1935
+ * `git rev-parse HEAD` fails with exit 128 on a brand-new repo, which would
1936
+ * crash the agent flow before the first commit can be made.
1937
+ */
1938
+ async function safeGetHead() {
1939
+ try {
1940
+ return await getHead();
1941
+ } catch {
1942
+ return "";
1943
+ }
1944
+ }
1945
+ /**
1946
+ * Headless agent command — orchestrates the entire commit flow without any TUI
1947
+ * interaction. Emits structured JSON results to stdout, one per line. Returns
1948
+ * control to the caller with `process.exitCode` set to one of the 7 documented
1949
+ * exit codes (0=success, 1=generic, 2=no_changes, 3=git, 4=ai, 5=check, 6=hook).
1950
+ */
1951
+ async function agentCommand(flags) {
1952
+ debug("agentCommand called", { flags });
1953
+ if (flags.retry) {
1954
+ process.exitCode = EXIT_CODES.GENERIC;
1955
+ writeAgentResult({
1956
+ status: "failure",
1957
+ commits: [],
1958
+ errors: ["--agent is not compatible with --retry"]
1959
+ });
1960
+ return;
1961
+ }
1962
+ try {
1963
+ await assertGitRepo();
1964
+ } catch (err) {
1965
+ process.exitCode = EXIT_CODES.GIT;
1966
+ writeAgentResult({
1967
+ status: "failure",
1968
+ commits: [],
1969
+ errors: [err instanceof Error ? err.message : String(err)]
1970
+ });
1971
+ return;
1972
+ }
1973
+ const status = await getStatusShort();
1974
+ debug("Git status:", status || "(empty)");
1975
+ if (!status) {
1976
+ process.exitCode = EXIT_CODES.NO_CHANGES;
1977
+ writeAgentResult({
1978
+ status: "no_changes",
1979
+ commits: []
1980
+ });
1981
+ return;
1982
+ }
1983
+ const changedFiles = await getChangedFiles();
1984
+ debug("Changed files:", changedFiles.length);
1985
+ await stageFiles(changedFiles.map((f) => f.path));
1986
+ const diffResult = await getStagedDiff();
1987
+ if (!diffResult) {
1988
+ process.exitCode = EXIT_CODES.NO_CHANGES;
1989
+ writeAgentResult({
1990
+ status: "no_changes",
1991
+ commits: []
1992
+ });
1993
+ return;
1994
+ }
1995
+ if ("excludedFiles" in diffResult) {
1996
+ debug("All staged files are excluded:", diffResult.excludedFiles);
1997
+ const message = buildExcludedFilesMessage(diffResult.excludedFiles);
1998
+ const headBefore = await safeGetHead();
1999
+ const result = await attemptCommit(message);
2000
+ const headAfter = await safeGetHead();
2001
+ if (result.ok || headBefore !== headAfter) {
2002
+ process.exitCode = EXIT_CODES.SUCCESS;
2003
+ writeAgentResult({
2004
+ status: "success",
2005
+ commits: [{
2006
+ message,
2007
+ hash: headAfter,
2008
+ files: diffResult.excludedFiles
2009
+ }]
2010
+ });
2011
+ } else {
2012
+ process.exitCode = EXIT_CODES.HOOK;
2013
+ writeAgentResult({
2014
+ status: "failure",
2015
+ commits: [],
2016
+ errors: parseHookErrors(result.stderr ?? "").map((e) => `[${e.tool}] ${e.message}`)
2017
+ });
2018
+ }
2019
+ return;
2020
+ }
2021
+ if (flags.message) {
2022
+ debug("Using provided message:", flags.message);
2023
+ const headBefore = await safeGetHead();
2024
+ const result = await attemptCommit(flags.message);
2025
+ const headAfter = await safeGetHead();
2026
+ if (result.ok || headBefore !== headAfter) {
2027
+ process.exitCode = EXIT_CODES.SUCCESS;
2028
+ writeAgentResult({
2029
+ status: "success",
2030
+ commits: [{
2031
+ message: flags.message,
2032
+ hash: headAfter,
2033
+ files: diffResult.files
2034
+ }]
2035
+ });
2036
+ } else {
2037
+ process.exitCode = EXIT_CODES.HOOK;
2038
+ writeAgentResult({
2039
+ status: "failure",
2040
+ commits: [],
2041
+ errors: parseHookErrors(result.stderr ?? "").map((e) => `[${e.tool}] ${e.message}`)
2042
+ });
2043
+ }
2044
+ return;
2045
+ }
2046
+ if (!flags.noCheck) {
2047
+ const repoRoot = await getRepoRoot();
2048
+ if (await detectConfig(repoRoot)) {
2049
+ debug("Running user checks on changed files...");
2050
+ const checkResults = await runAllChecks(repoRoot, changedFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
2051
+ if (!checkResults.ok) {
2052
+ const errorMessages = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).filter(Boolean);
2053
+ const parsed = parseCheckErrors(errorMessages.join("\n\n"));
2054
+ const errors = parsed.length > 0 ? parsed.map((e) => `[${e.tool}] ${e.message}`) : errorMessages;
2055
+ process.exitCode = EXIT_CODES.CHECK;
2056
+ writeAgentResult({
2057
+ status: "failure",
2058
+ commits: [],
2059
+ errors
2060
+ });
2061
+ return;
2062
+ }
2063
+ }
2064
+ }
2065
+ const { included, excluded } = filterExcludedFiles(changedFiles);
2066
+ debug("Auto-group: %d included, %d excluded", included.length, excluded.length);
2067
+ if (excluded.length > 0) {
2068
+ const message = buildExcludedFilesMessage(excluded);
2069
+ debug("Committing %d excluded files:", excluded.length, excluded);
2070
+ await resetStaging();
2071
+ await stageFiles(excluded);
2072
+ const headBefore = await safeGetHead();
2073
+ const result = await attemptCommit(message);
2074
+ const headAfter = await safeGetHead();
2075
+ if (!result.ok && headBefore === headAfter) debug("Excluded files commit failed, continuing without them");
2076
+ }
2077
+ if (included.length === 0) {
2078
+ process.exitCode = EXIT_CODES.SUCCESS;
2079
+ writeAgentResult({
2080
+ status: "success",
2081
+ commits: []
2082
+ });
2083
+ return;
2084
+ }
2085
+ const config = await readConfig();
2086
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
2087
+ let apiKey;
2088
+ try {
2089
+ apiKey = await getProviderApiKey(provider);
2090
+ } catch {
2091
+ process.exitCode = EXIT_CODES.AI;
2092
+ writeAgentResult({
2093
+ status: "failure",
2094
+ commits: [],
2095
+ errors: [`No API key found for ${provider}. Set ${PROVIDER_ENV_KEYS[provider]} env var or run 'cmint config'`]
2096
+ });
2097
+ return;
2098
+ }
2099
+ const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
2100
+ const timeout = config.timeout ? parseInt(config.timeout, 10) : void 0;
2101
+ let groups;
2102
+ try {
2103
+ groups = validateGroups((await generateGroups(included, apiKey, model, timeout, provider, config.proxy)).groups, included);
2104
+ } catch (err) {
2105
+ process.exitCode = EXIT_CODES.AI;
2106
+ writeAgentResult({
2107
+ status: "failure",
2108
+ commits: [],
2109
+ errors: [err instanceof Error ? err.message : String(err)]
2110
+ });
2111
+ return;
2112
+ }
2113
+ const commits = [];
2114
+ for (const group of groups) {
2115
+ debug("Processing group %d/%d: %s", commits.length + 1, groups.length, group.name);
2116
+ await resetStaging();
2117
+ await stageFiles(group.files);
2118
+ const groupDiff = await getStagedDiff();
2119
+ if (!groupDiff || "excludedFiles" in groupDiff) {
2120
+ debug(`Skipping group "${group.name}" — no diff`);
2121
+ continue;
2122
+ }
2123
+ let message;
2124
+ try {
2125
+ message = await generateCommitMessage(groupDiff.diff, {
2126
+ apiKey,
2127
+ model,
2128
+ type: config.type,
2129
+ timeout,
2130
+ hint: flags.hint,
2131
+ provider,
2132
+ proxy: config.proxy
2133
+ });
2134
+ } catch (err) {
2135
+ process.exitCode = EXIT_CODES.AI;
2136
+ writeAgentResult({
2137
+ status: "failure",
2138
+ commits,
2139
+ errors: [err instanceof Error ? err.message : String(err)]
2140
+ });
2141
+ return;
2142
+ }
2143
+ await saveCachedCommit(await getRepoRoot(), message);
2144
+ const headBefore = await safeGetHead();
2145
+ const result = await attemptCommit(message);
2146
+ const headAfter = await safeGetHead();
2147
+ if (result.ok || headBefore !== headAfter) {
2148
+ commits.push({
2149
+ message,
2150
+ hash: headAfter,
2151
+ files: group.files,
2152
+ groupName: group.name
2153
+ });
2154
+ continue;
2155
+ }
2156
+ process.exitCode = EXIT_CODES.HOOK;
2157
+ writeAgentResult({
2158
+ status: "failure",
2159
+ commits,
2160
+ errors: parseHookErrors(result.stderr ?? "").map((e) => `[${e.tool}] ${e.message}`)
2161
+ });
2162
+ return;
2163
+ }
2164
+ process.exitCode = EXIT_CODES.SUCCESS;
2165
+ writeAgentResult({
2166
+ status: "success",
2167
+ commits
2168
+ });
2169
+ }
2170
+ //#endregion
1858
2171
  //#region src/commands/commit-utils.ts
1859
2172
  /** Shared recovery menu factory — avoids repeating the same callback set */
1860
2173
  function makeRecoveryCallbacks(message) {
@@ -2265,7 +2578,8 @@ async function runPreCommitChecks(changedFiles, noCheck) {
2265
2578
  });
2266
2579
  if (menuResult === "cancelled") process.exit(1);
2267
2580
  if (menuResult === "retried") {
2268
- debug("Re-running checks after retry...");
2581
+ debug("Re-staging files and re-running checks after retry...");
2582
+ await stageAll();
2269
2583
  const ckSpinner = spinner();
2270
2584
  ckSpinner.start("Running checks...");
2271
2585
  checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
@@ -2612,6 +2926,40 @@ async function configCommand() {
2612
2926
  }
2613
2927
  }
2614
2928
  //#endregion
2929
+ //#region src/commands/logs.ts
2930
+ const LOG_PATH = join(os.homedir(), ".cache", "commit-mint", "debug.log");
2931
+ const SESSION_SEPARATOR = /^--- session .+ ---$/;
2932
+ async function logsCommand(flags) {
2933
+ let content;
2934
+ try {
2935
+ content = await readFile(LOG_PATH, "utf8");
2936
+ } catch (err) {
2937
+ if (err.code === "ENOENT") {
2938
+ console.error("No debug logs found. Run cmint with any command first.");
2939
+ process.exit(1);
2940
+ }
2941
+ throw err;
2942
+ }
2943
+ if (content.trim() === "") {
2944
+ console.error("No debug logs found. Run cmint with any command first.");
2945
+ process.exit(1);
2946
+ }
2947
+ const allLines = content.split("\n");
2948
+ let lastSessionIndex = -1;
2949
+ for (let i = allLines.length - 1; i >= 0; i--) if (SESSION_SEPARATOR.test(allLines[i])) {
2950
+ lastSessionIndex = i;
2951
+ break;
2952
+ }
2953
+ const sessionLines = lastSessionIndex === -1 ? allLines : allLines.slice(lastSessionIndex + 1);
2954
+ const filtered = sessionLines.filter((line) => line.length > 0 || sessionLines.indexOf(line) === 0);
2955
+ if (filtered.length === 0) {
2956
+ console.error("No debug logs found. Run cmint with any command first.");
2957
+ process.exit(1);
2958
+ }
2959
+ const lines = flags.lines !== void 0 && flags.lines > 0 ? filtered.slice(-flags.lines) : filtered;
2960
+ for (const line of lines) console.log(line);
2961
+ }
2962
+ //#endregion
2615
2963
  //#region src/cli.ts
2616
2964
  const { version } = package_default;
2617
2965
  cli({
@@ -2652,14 +3000,31 @@ cli({
2652
3000
  description: "Skip user-defined pre-commit checks",
2653
3001
  alias: "N",
2654
3002
  default: false
3003
+ },
3004
+ agent: {
3005
+ type: Boolean,
3006
+ description: "AI agent mode: non-interactive auto-group with JSON output",
3007
+ default: false
2655
3008
  }
2656
3009
  },
2657
- commands: [command({ name: "config" }, async () => {
3010
+ commands: [command({
3011
+ name: "logs",
3012
+ description: "Show debug logs from the last cmint run",
3013
+ flags: { lines: {
3014
+ type: Number,
3015
+ description: "Number of lines to show from the end",
3016
+ alias: "n"
3017
+ } }
3018
+ }, async (argv) => {
3019
+ await logsCommand(argv.flags);
3020
+ }), command({ name: "config" }, async () => {
2658
3021
  await configCommand();
2659
3022
  })]
2660
3023
  }, (argv) => {
3024
+ writeSessionHeader();
2661
3025
  setDebug(argv.flags.debug);
2662
- commitCommand(argv.flags);
3026
+ if (argv.flags.agent) agentCommand(argv.flags);
3027
+ else commitCommand(argv.flags);
2663
3028
  });
2664
3029
  //#endregion
2665
3030
  export {};