@kyubiware/commit-mint 0.5.6 → 0.6.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,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.5.6",
31
+ version: "0.6.1",
32
32
  description: "🌿 A commit tool that actually handles hook failures",
33
33
  type: "module",
34
34
  bin: { "cmint": "./dist/cli.mjs" },
@@ -47,7 +47,8 @@ var package_default = {
47
47
  "release:patch": "bash scripts/release.sh patch",
48
48
  "release:minor": "bash scripts/release.sh minor",
49
49
  "release:major": "bash scripts/release.sh major",
50
- "prepublishOnly": "npm run build"
50
+ "prepublishOnly": "npm run build",
51
+ "publish:cmint": "cd packages/cmint && npm publish"
51
52
  },
52
53
  keywords: [
53
54
  "git",
@@ -88,13 +89,30 @@ var package_default = {
88
89
  //#endregion
89
90
  //#region src/utils/debug.ts
90
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
+ }
91
101
  function setDebug(value) {
92
102
  enabled = value;
93
103
  }
104
+ function writeSessionHeader() {
105
+ if (sessionWritten) return;
106
+ ensureLogDir();
107
+ writeFileSync(logFile, `--- session ${(/* @__PURE__ */ new Date()).toISOString()} ---\n`, "utf8");
108
+ sessionWritten = true;
109
+ }
94
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");
95
114
  if (!enabled) return;
96
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
97
- console.error(dim(`[debug ${timestamp}]`), ...args);
115
+ console.error(dim(prefix), ...args);
98
116
  }
99
117
  //#endregion
100
118
  //#region src/services/provider.ts
@@ -174,69 +192,178 @@ function createProvider(options) {
174
192
  };
175
193
  }
176
194
  //#endregion
177
- //#region src/services/config.ts
178
- const CONFIG_PATH = join(os.homedir(), ".commit-mint");
179
- const defaults = {
180
- provider: "groq",
181
- model: "openai/gpt-oss-20b",
182
- locale: "en",
183
- "max-length": "100",
184
- type: "",
185
- timeout: "10000"
186
- };
187
- async function readConfig() {
188
- debug("readConfig: loading from %s", CONFIG_PATH);
189
- try {
190
- const raw = await readFile(CONFIG_PATH, "utf8");
191
- const parsed = ini.parse(raw);
192
- const merged = {
193
- ...defaults,
194
- ...parsed
195
- };
196
- debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
197
- return merged;
198
- } catch {
199
- debug("readConfig: no config file, using defaults");
200
- 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++;
201
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");
202
265
  }
203
- async function writeConfig(updates) {
204
- const existing = await readConfig();
205
- Object.assign(existing, updates);
206
- 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;
207
270
  }
208
- async function setConfigValue(key, value) {
209
- 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");
210
277
  }
211
- async function getProviderApiKey(provider) {
212
- const envVar = PROVIDER_ENV_KEYS[provider];
213
- if (envVar) {
214
- const envValue = process.env[envVar];
215
- if (envValue) {
216
- debug("getProviderApiKey(%s): found in env", provider);
217
- 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;
218
346
  }
219
347
  }
220
- const config = await readConfig();
221
- const configKey = PROVIDER_ENV_KEYS[provider];
222
- if (configKey && config[configKey]) {
223
- debug("getProviderApiKey(%s): found in config", provider);
224
- 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);
225
366
  }
226
- debug("getProviderApiKey(%s): not found", provider);
227
- throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
228
- }
229
- /** Check if a model name is the default for a provider OTHER than the given one. */
230
- function isOtherProviderDefault(model, provider) {
231
- for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
232
- return false;
233
- }
234
- function getModelForProvider(config, provider, defaultModel) {
235
- const providerModel = config[`model_${provider}`];
236
- if (providerModel) return providerModel;
237
- const globalModel = config.model;
238
- if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
239
- return defaultModel;
240
367
  }
241
368
  //#endregion
242
369
  //#region src/services/hooks.ts
@@ -472,651 +599,466 @@ function findMeaningfulCommand(command) {
472
599
  return segments[segments.length - 1] || command;
473
600
  }
474
601
  //#endregion
475
- //#region src/services/hook-progress.ts
476
- const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
477
- function createStderrParser() {
478
- let buffer = "";
479
- return (chunk) => {
480
- buffer += chunk;
481
- const steps = [];
482
- const lines = buffer.split("\n");
483
- buffer = lines.pop() ?? "";
484
- for (const line of lines) {
485
- const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
486
- if (!match) continue;
487
- const status = match[1].toLowerCase();
488
- const command = match[2].trim();
489
- if (isLintStagedMeta(command)) continue;
490
- const tool = extractToolName(command) ?? command;
491
- steps.push({
492
- status,
493
- command,
494
- tool
495
- });
496
- }
497
- return steps;
498
- };
499
- }
500
- function createProgressHandler(s) {
501
- return (step) => {
502
- if (step.status === "started") s.message(step.command);
503
- else if (step.status === "failed") s.message(step.command);
504
- };
505
- }
506
- //#endregion
507
- //#region src/services/git.ts
508
- var git_exports = /* @__PURE__ */ __exportAll({
509
- KnownError: () => KnownError,
510
- assertGitRepo: () => assertGitRepo,
511
- attemptCommit: () => attemptCommit,
512
- attemptCommitNoVerify: () => attemptCommitNoVerify,
513
- getChangedFiles: () => getChangedFiles,
514
- getDefaultExcludes: () => getDefaultExcludes,
515
- getHead: () => getHead,
516
- getRepoRoot: () => getRepoRoot,
517
- getStagedDiff: () => getStagedDiff,
518
- getStatusShort: () => getStatusShort,
519
- resetStaging: () => resetStaging,
520
- stageAll: () => stageAll,
521
- stageFiles: () => stageFiles
522
- });
523
- var KnownError = class extends Error {};
524
- async function assertGitRepo() {
525
- debug("assertGitRepo");
526
- const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
527
- if (failed) throw new KnownError("The current directory must be a Git repository!");
528
- }
529
- async function getRepoRoot() {
530
- const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
531
- debug("getRepoRoot:", stdout.trim());
532
- return stdout.trim();
533
- }
534
- const DEFAULT_EXCLUDES = [
535
- "package-lock.json",
536
- "node_modules/**",
537
- "dist/**",
538
- "build/**",
539
- ".next/**",
540
- "coverage/**",
541
- "*.log",
542
- "*.min.js",
543
- "*.min.css",
544
- "*.lock",
545
- ".DS_Store"
602
+ //#region src/services/checks.ts
603
+ /** Config file names, checked in priority order (matches lint-staged naming conventions) */
604
+ const CONFIG_FILES = [
605
+ ".cmintrc",
606
+ ".cmintrc.json",
607
+ ".cmintrc.mjs",
608
+ ".cmintrc.mts",
609
+ ".cmintrc.js",
610
+ ".cmintrc.ts",
611
+ ".cmintrc.cjs",
612
+ ".cmintrc.cts",
613
+ "cmint.config.mjs",
614
+ "cmint.config.mts",
615
+ "cmint.config.js",
616
+ "cmint.config.ts",
617
+ "cmint.config.cjs",
618
+ "cmint.config.cts"
546
619
  ];
547
- function getDefaultExcludes() {
548
- return [...DEFAULT_EXCLUDES];
549
- }
550
- async function getStagedDiff(exclude) {
551
- const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
552
- const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
553
- const { stdout: allFiles } = await execa("git", [
554
- "diff",
555
- "--cached",
556
- "--name-only"
557
- ]);
558
- if (!allFiles) {
559
- debug("getStagedDiff: no staged files");
560
- return null;
561
- }
562
- const { stdout: files } = await execa("git", [
563
- "diff",
564
- "--cached",
565
- "--name-only",
566
- ...defaultExcludeArgs,
567
- ...excludeArgs
568
- ]);
569
- if (!files) {
570
- const excludedFiles = allFiles.split("\n").filter(Boolean);
571
- debug("getStagedDiff: all files excluded:", excludedFiles);
572
- return { excludedFiles };
573
- }
574
- const { stdout: diff } = await execa("git", [
575
- "diff",
576
- "--cached",
577
- "--diff-algorithm=minimal",
578
- ...defaultExcludeArgs,
579
- ...excludeArgs
580
- ]);
581
- debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
582
- return {
583
- files: files.split("\n").filter(Boolean),
584
- diff
585
- };
586
- }
587
- async function stageAll() {
588
- debug("stageAll: git add -A");
589
- await execa("git", ["add", "-A"]);
590
- }
591
- async function resetStaging() {
592
- debug("resetStaging: git reset HEAD");
593
- await execa("git", ["reset", "HEAD"]);
594
- }
595
- async function getHead() {
596
- const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
597
- return stdout.trim();
598
- }
599
- async function getStatusShort() {
600
- const { stdout } = await execa("git", ["status", "--short"]);
601
- return stdout.trim();
602
- }
603
- async function getChangedFiles() {
604
- const { stdout } = await execa("git", ["status", "--short"]);
605
- if (!stdout.trim()) return [];
606
- const files = stdout.split("\n").filter(Boolean).map((line) => {
607
- const indexStatus = line[0];
608
- return {
609
- status: line.slice(0, 2).trim(),
610
- path: line.slice(3),
611
- staged: indexStatus !== " " && indexStatus !== "?"
612
- };
613
- });
614
- debug("getChangedFiles:", files.length, "files");
615
- return files;
620
+ /**
621
+ * Detect whether the repo has a cmint config file.
622
+ * Returns the config file path, or null if none found.
623
+ */
624
+ async function detectConfig(repoRoot) {
625
+ debug("detectConfig: checking for config in %s", repoRoot);
626
+ for (const name of CONFIG_FILES) try {
627
+ await access(join(repoRoot, name), constants.R_OK);
628
+ debug("detectConfig: found %s", name);
629
+ return join(repoRoot, name);
630
+ } catch {}
631
+ debug("detectConfig: no config file found");
632
+ return null;
616
633
  }
617
- async function stageFiles(paths) {
618
- debug("stageFiles:", paths);
619
- await execa("git", ["add", ...paths]);
634
+ /**
635
+ * Load and validate the cmint config from a repo root.
636
+ * Throws if the loaded value is missing or not a non-null object.
637
+ */
638
+ async function loadConfig(repoRoot) {
639
+ const configPath = await detectConfig(repoRoot);
640
+ if (!configPath) throw new Error("No cmint config file found");
641
+ debug("loadConfig: loading %s", configPath);
642
+ const ext = extname(configPath);
643
+ const isJSON = ext === ".json";
644
+ const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
645
+ let config;
646
+ if (isJSON) {
647
+ const raw = readFileSync(configPath, "utf-8");
648
+ config = JSON.parse(raw);
649
+ } else if (needsJiti) {
650
+ const { createJiti } = await import("jiti");
651
+ const mod = await createJiti(import.meta.url, {}).import(configPath);
652
+ config = mod.default ?? mod;
653
+ } else config = (await import(configPath)).default;
654
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
655
+ debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
656
+ return config;
620
657
  }
621
- async function attemptCommit(message, extraArgs = [], onProgress) {
622
- debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
658
+ /**
659
+ * Run a shell command and capture its output.
660
+ * Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
661
+ * Handles ENOENT (command not found) and timeout errors gracefully.
662
+ */
663
+ async function runCommand(command, timeout, repoRoot) {
664
+ debug("runCommand: %s (timeout: %dms)", command, timeout);
665
+ const tool = extractToolName(command) ?? command.split(" ")[0];
623
666
  try {
624
- const subprocess = execa("git", [
625
- "commit",
626
- "-m",
627
- message,
628
- ...extraArgs
629
- ]);
630
- const stderrChunks = [];
631
- const parser = onProgress ? createStderrParser() : null;
632
- subprocess.stderr?.on("data", (chunk) => {
633
- const text = chunk.toString();
634
- stderrChunks.push(text);
635
- if (parser && onProgress) for (const step of parser(text)) onProgress(step);
667
+ const result = await execa(command, {
668
+ shell: true,
669
+ reject: false,
670
+ timeout,
671
+ all: true,
672
+ preferLocal: true,
673
+ ...repoRoot ? { localDir: repoRoot } : {}
636
674
  });
637
- await subprocess;
638
- debug("attemptCommit: success");
675
+ const ok = !result.failed;
676
+ debug("runCommand: %s — ok=%s", tool, ok);
639
677
  return {
640
- ok: true,
641
- stderr: stderrChunks.join("")
678
+ ok,
679
+ tool,
680
+ command,
681
+ stdout: result.stdout ?? "",
682
+ stderr: result.stderr ?? "",
683
+ files: []
642
684
  };
643
- } catch (error) {
644
- const e = error;
645
- debug("attemptCommit: failed —", e.message?.slice(0, 200));
685
+ } catch (err) {
686
+ const msg = err instanceof Error ? err.message : String(err);
687
+ const isTimedOut = msg.toLowerCase().includes("timed out");
688
+ const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
689
+ debug("runCommand: %s — error: %s", tool, msg);
646
690
  return {
647
691
  ok: false,
648
- error: e.message,
649
- stderr: typeof e.stderr === "string" ? e.stderr : ""
692
+ tool,
693
+ command,
694
+ stdout: "",
695
+ stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
696
+ files: []
650
697
  };
651
698
  }
652
699
  }
653
- async function attemptCommitNoVerify(message, onProgress) {
654
- debug("attemptCommitNoVerify:", message);
655
- return attemptCommit(message, ["--no-verify"], onProgress);
700
+ /**
701
+ * Filter a list of file paths by a picomatch glob pattern.
702
+ * When the pattern contains no `/`, files are matched at any depth (matchBase).
703
+ * Dotfiles are included (dot: true).
704
+ */
705
+ function matchFiles(pattern, files) {
706
+ if (!pattern) return [];
707
+ const matchBase = !pattern.includes("/");
708
+ const isMatch = picomatch(pattern, {
709
+ dot: true,
710
+ posixSlashes: true,
711
+ strictBrackets: true
712
+ });
713
+ return files.filter((f) => {
714
+ const parts = f.split("/");
715
+ return isMatch(matchBase ? parts[parts.length - 1] : f);
716
+ });
656
717
  }
657
- //#endregion
658
- //#region src/ui/review-message.ts
659
- async function reviewCommitMessage(message) {
660
- const { select, text } = await import("@clack/prompts");
661
- while (true) {
662
- const review = await select({
663
- message: `Review commit message:\n\n ${bold(message)}\n`,
664
- options: [
665
- {
666
- label: "Use as-is",
667
- value: "use"
668
- },
669
- {
670
- label: "Edit",
671
- value: "edit"
672
- },
673
- {
674
- label: "Cancel",
675
- value: "cancel"
676
- }
677
- ]
718
+ /**
719
+ * Build a shell command string from a base command and a list of file paths.
720
+ * File paths containing spaces are wrapped in double quotes.
721
+ * If no files are provided, the base command is returned as-is.
722
+ */
723
+ function buildCommand(command, files) {
724
+ if (files.length === 0) return command;
725
+ return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
726
+ }
727
+ /**
728
+ * Call a function command with matched files and normalize the result to ResolvedCommand[].
729
+ */
730
+ function resolveFunction(fn, matchedFiles) {
731
+ const resolved = fn(matchedFiles);
732
+ return (Array.isArray(resolved) ? resolved : [resolved]).map((command) => ({
733
+ command,
734
+ fromFunction: true
735
+ }));
736
+ }
737
+ /**
738
+ * Resolve config commands for a glob entry into an array of resolved commands.
739
+ * Function commands are called with matched filenames; string commands are kept as-is.
740
+ * Each resolved entry tracks whether it came from a function (for file-append behavior).
741
+ */
742
+ function resolveCommands(commands, matchedFiles) {
743
+ if (typeof commands === "function") return resolveFunction(commands, matchedFiles);
744
+ if (Array.isArray(commands)) {
745
+ const result = [];
746
+ for (const cmd of commands) if (typeof cmd === "function") result.push(...resolveFunction(cmd, matchedFiles));
747
+ else result.push({
748
+ command: cmd,
749
+ fromFunction: false
678
750
  });
679
- if (isCancel(review) || review === "cancel") {
680
- debug("User cancelled at review step");
681
- return null;
682
- }
683
- if (review === "use") {
684
- debug("User accepted message");
685
- return message;
686
- }
687
- if (review === "edit") {
688
- debug("User chose to edit message");
689
- const edited = await text({
690
- message: "Edit commit message:",
691
- initialValue: message,
692
- validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
693
- });
694
- if (isCancel(edited)) continue;
695
- message = String(edited).trim();
696
- debug("Edited message:", message);
697
- }
751
+ return result;
698
752
  }
753
+ return [{
754
+ command: commands,
755
+ fromFunction: false
756
+ }];
699
757
  }
700
- //#endregion
701
- //#region src/utils/cache.ts
702
- const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
703
- function repoHash(repoPath) {
704
- return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
705
- }
706
- function cachePath(repoPath) {
707
- return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
758
+ /**
759
+ * Run resolved commands for a single glob entry, appending results.
760
+ * Function-originated commands run as-is; string commands get matched files appended.
761
+ * Returns false if any command fails (for fail-fast signaling).
762
+ */
763
+ async function runCommandsForGlob(cmds, matchedFiles, timeout, results, repoRoot) {
764
+ for (const { command, fromFunction } of cmds) {
765
+ const fullCommand = fromFunction ? command : buildCommand(command, matchedFiles);
766
+ debug("runCommandsForGlob: running '%s'", fullCommand);
767
+ const result = await runCommand(fullCommand, timeout, repoRoot);
768
+ results.push({
769
+ ...result,
770
+ files: matchedFiles
771
+ });
772
+ if (!result.ok) {
773
+ debug("runCommandsForGlob: check failed, stopping (fail-fast)");
774
+ return false;
775
+ }
776
+ }
777
+ return true;
708
778
  }
709
- async function saveCachedCommit(repoPath, message) {
710
- await mkdir(CACHE_DIR, { recursive: true });
711
- const data = {
712
- message,
713
- timestamp: Date.now(),
714
- repoPath
779
+ /**
780
+ * Run all user-defined checks from .cmintrc against staged files.
781
+ * Returns a no-op result when no config exists.
782
+ * Fail-fast: stops on first error.
783
+ */
784
+ async function runAllChecks(repoRoot, stagedFiles, timeout) {
785
+ debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
786
+ if (!await detectConfig(repoRoot)) {
787
+ debug("runAllChecks: no config found, skipping checks");
788
+ return {
789
+ ok: true,
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
715
813
  };
716
- const path = cachePath(repoPath);
717
- debug("saveCachedCommit: saving to %s", path);
718
- await writeFile(path, JSON.stringify(data, null, 2), "utf8");
719
814
  }
720
- async function loadCachedCommit(repoPath) {
721
- const path = cachePath(repoPath);
722
- debug("loadCachedCommit: loading from %s", path);
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);
723
828
  try {
724
- const raw = await readFile(path, "utf8");
725
- const data = JSON.parse(raw);
726
- debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
727
- return data;
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;
728
837
  } catch {
729
- debug("loadCachedCommit: no cached commit found");
730
- return null;
838
+ debug("readConfig: no config file, using defaults");
839
+ return { ...defaults };
731
840
  }
732
841
  }
733
- //#endregion
734
- //#region src/services/ai.ts
735
- const MAX_DIFF_CHARS = 2e4;
736
- function mapGroqError(error, providerLabel) {
737
- const label = providerLabel ?? "Groq";
738
- if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
739
- if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
740
- if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
741
- if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
742
- if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
743
- return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
842
+ async function writeConfig(updates) {
843
+ const existing = await readConfig();
844
+ Object.assign(existing, updates);
845
+ await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
744
846
  }
745
- const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
746
- function stripThinkTags(text) {
747
- return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
847
+ async function setConfigValue(key, value) {
848
+ await writeConfig({ [key]: value });
748
849
  }
749
- function deriveMessageFromReasoning(reasoning) {
750
- const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
751
- if (match) return match[0].trim();
752
- const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
753
- return first ? first.trim() : null;
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>\``);
754
867
  }
755
- function stripContextLines(diff) {
756
- return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
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;
757
872
  }
758
- function compressDiff(diff) {
759
- if (diff.length <= MAX_DIFF_CHARS) return diff;
760
- let result = stripContextLines(diff);
761
- if (result.length <= MAX_DIFF_CHARS) return result;
762
- result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
763
- return fd.split(/(?=\n@@)/).map((part, idx) => {
764
- if (idx === 0) return part;
765
- const lines = part.split("\n");
766
- return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
767
- }).join("");
768
- }).join("");
769
- if (result.length <= MAX_DIFF_CHARS) return result;
770
- return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
771
- const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
772
- return match && match[1] === match[2] ? `${match[1]} | changed` : "";
773
- }).filter(Boolean).join("\n")}`;
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;
774
879
  }
775
- function buildStatSummary(diff) {
776
- const files = [];
777
- let currentFile = "";
778
- let adds = 0;
779
- let dels = 0;
780
- for (const line of diff.split("\n")) {
781
- const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
782
- if (match) {
783
- if (currentFile) files.push({
784
- name: currentFile,
785
- adds,
786
- dels
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
787
901
  });
788
- currentFile = match[1];
789
- adds = 0;
790
- dels = 0;
791
- } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
792
- else if (line.startsWith("-") && !line.startsWith("---")) dels++;
793
- }
794
- if (currentFile) files.push({
795
- name: currentFile,
796
- adds,
797
- dels
798
- });
799
- const totalAdds = files.reduce((s, f) => s + f.adds, 0);
800
- const totalDels = files.reduce((s, f) => s + f.dels, 0);
801
- const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
802
- lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
803
- return lines.join("\n");
902
+ }
903
+ return steps;
904
+ };
804
905
  }
805
- function buildSystemPrompt(type) {
806
- 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.";
807
- if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
808
- return prompt;
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
+ };
809
911
  }
810
- function buildUserPrompt(diff, hint, statSummary) {
811
- const parts = [];
812
- if (hint) parts.push(`Context: ${hint}`);
813
- if (statSummary) parts.push(`Change summary:\n${statSummary}`);
814
- parts.push(`Generate a conventional commit for:\n\n${diff}`);
815
- return parts.join("\n\n");
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!");
816
934
  }
817
- function isValidConventionalCommit(message) {
818
- return CONVENTIONAL_COMMIT_REGEX.test(message);
935
+ async function getRepoRoot() {
936
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
937
+ debug("getRepoRoot:", stdout.trim());
938
+ return stdout.trim();
819
939
  }
820
- function extractContentText(content) {
821
- if (content == null) return "";
822
- if (typeof content === "string") return content.trim();
823
- if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
824
- return "";
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];
825
955
  }
826
- async function generateCommitMessage(diff, options) {
827
- const timeoutMs = options.timeout ?? 6e4;
828
- debug("Timeout: %d ms", timeoutMs);
829
- const { client, model } = createProvider({
830
- provider: options.provider ?? "groq",
831
- apiKey: options.apiKey,
832
- modelOverride: options.model,
833
- timeout: timeoutMs,
834
- baseURLOverride: options.proxy
835
- });
836
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
837
- const compressedDiff = compressDiff(diff);
838
- const statSummary = buildStatSummary(diff);
839
- const systemPrompt = buildSystemPrompt(options.type);
840
- const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
841
- debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
842
- debug("Stat summary:\n%s", statSummary);
843
- debug("User prompt length: %d chars", userPrompt.length);
844
- async function callAI(strictSystemPrompt) {
845
- const callStart = Date.now();
846
- debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
847
- try {
848
- const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
849
- const isGroq = (options.provider ?? "groq") === "groq";
850
- const completion = await client.chat.completions.create({
851
- messages: [{
852
- role: "system",
853
- content: strictSystemPrompt ?? systemPrompt
854
- }, {
855
- role: "user",
856
- content: userPrompt
857
- }],
858
- model,
859
- temperature: .3,
860
- ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
861
- ...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
862
- });
863
- const elapsed = Date.now() - callStart;
864
- const rawContent = completion.choices[0]?.message?.content;
865
- const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
866
- 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);
867
- debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
868
- if (!content) {
869
- const reasoning = completion.choices[0]?.message?.reasoning;
870
- debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
871
- if (reasoning) {
872
- const derived = deriveMessageFromReasoning(reasoning);
873
- if (derived) {
874
- debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
875
- return stripThinkTags(derived);
876
- }
877
- debug("callAI: could not derive message from reasoning");
878
- }
879
- throw new Error("AI returned an empty commit message");
880
- }
881
- return content;
882
- } catch (error) {
883
- debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
884
- throw error;
885
- }
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;
886
967
  }
887
- try {
888
- const totalStart = Date.now();
889
- let message = await callAI();
890
- debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
891
- if (!isValidConventionalCommit(message)) {
892
- debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
893
- 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.");
894
- debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
895
- if (isValidConventionalCommit(retryMessage)) {
896
- debug("Retry produced valid conventional commit");
897
- message = retryMessage;
898
- } else debug("Retry also failed validation, using original message");
899
- }
900
- debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
901
- return message;
902
- } catch (error) {
903
- debug("AI error: %s", error instanceof Error ? error.message : String(error));
904
- throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
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 };
905
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
+ };
906
992
  }
907
- //#endregion
908
- //#region src/services/checks.ts
909
- /** Config file names, checked in priority order (matches lint-staged naming conventions) */
910
- const CONFIG_FILES = [
911
- ".cmintrc",
912
- ".cmintrc.json",
913
- ".cmintrc.mjs",
914
- ".cmintrc.mts",
915
- ".cmintrc.js",
916
- ".cmintrc.ts",
917
- ".cmintrc.cjs",
918
- ".cmintrc.cts",
919
- "cmint.config.mjs",
920
- "cmint.config.mts",
921
- "cmint.config.js",
922
- "cmint.config.ts",
923
- "cmint.config.cjs",
924
- "cmint.config.cts"
925
- ];
926
- /**
927
- * Detect whether the repo has a cmint config file.
928
- * Returns the config file path, or null if none found.
929
- */
930
- async function detectConfig(repoRoot) {
931
- debug("detectConfig: checking for config in %s", repoRoot);
932
- for (const name of CONFIG_FILES) try {
933
- await access(join(repoRoot, name), constants.R_OK);
934
- debug("detectConfig: found %s", name);
935
- return join(repoRoot, name);
936
- } catch {}
937
- debug("detectConfig: no config file found");
938
- return null;
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"]);
939
1000
  }
940
- /**
941
- * Load and validate the cmint config from a repo root.
942
- * Throws if the loaded value is missing or not a non-null object.
943
- */
944
- async function loadConfig(repoRoot) {
945
- const configPath = await detectConfig(repoRoot);
946
- if (!configPath) throw new Error("No cmint config file found");
947
- debug("loadConfig: loading %s", configPath);
948
- const ext = extname(configPath);
949
- const isJSON = ext === ".json";
950
- const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
951
- let config;
952
- if (isJSON) {
953
- const raw = readFileSync(configPath, "utf-8");
954
- config = JSON.parse(raw);
955
- } else if (needsJiti) {
956
- const { createJiti } = await import("jiti");
957
- const mod = await createJiti(import.meta.url, {}).import(configPath);
958
- config = mod.default ?? mod;
959
- } else config = (await import(configPath)).default;
960
- if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
961
- debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
962
- return config;
1001
+ async function getHead() {
1002
+ const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
1003
+ return stdout.trim();
963
1004
  }
964
- /**
965
- * Run a shell command and capture its output.
966
- * Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
967
- * Handles ENOENT (command not found) and timeout errors gracefully.
968
- */
969
- async function runCommand(command, timeout, repoRoot) {
970
- debug("runCommand: %s (timeout: %dms)", command, timeout);
971
- const tool = extractToolName(command) ?? command.split(" ")[0];
972
- try {
973
- const result = await execa(command, {
974
- shell: true,
975
- reject: false,
976
- timeout,
977
- all: true,
978
- preferLocal: true,
979
- ...repoRoot ? { localDir: repoRoot } : {}
980
- });
981
- const ok = !result.failed;
982
- debug("runCommand: %s — ok=%s", tool, ok);
983
- return {
984
- ok,
985
- tool,
986
- command,
987
- stdout: result.stdout ?? "",
988
- stderr: result.stderr ?? "",
989
- files: []
990
- };
991
- } catch (err) {
992
- const msg = err instanceof Error ? err.message : String(err);
993
- const isTimedOut = msg.toLowerCase().includes("timed out");
994
- const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
995
- debug("runCommand: %s — error: %s", tool, msg);
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];
996
1014
  return {
997
- ok: false,
998
- tool,
999
- command,
1000
- stdout: "",
1001
- stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
1002
- files: []
1015
+ status: line.slice(0, 2).trim(),
1016
+ path: line.slice(3),
1017
+ staged: indexStatus !== " " && indexStatus !== "?"
1003
1018
  };
1004
- }
1005
- }
1006
- /**
1007
- * Filter a list of file paths by a picomatch glob pattern.
1008
- * When the pattern contains no `/`, files are matched at any depth (matchBase).
1009
- * Dotfiles are included (dot: true).
1010
- */
1011
- function matchFiles(pattern, files) {
1012
- if (!pattern) return [];
1013
- const matchBase = !pattern.includes("/");
1014
- const isMatch = picomatch(pattern, {
1015
- dot: true,
1016
- posixSlashes: true,
1017
- strictBrackets: true
1018
- });
1019
- return files.filter((f) => {
1020
- const parts = f.split("/");
1021
- return isMatch(matchBase ? parts[parts.length - 1] : f);
1022
1019
  });
1020
+ debug("getChangedFiles:", files.length, "files");
1021
+ return files;
1023
1022
  }
1024
- /**
1025
- * Build a shell command string from a base command and a list of file paths.
1026
- * File paths containing spaces are wrapped in double quotes.
1027
- * If no files are provided, the base command is returned as-is.
1028
- */
1029
- function buildCommand(command, files) {
1030
- if (files.length === 0) return command;
1031
- return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
1032
- }
1033
- /**
1034
- * Call a function command with matched files and normalize the result to ResolvedCommand[].
1035
- */
1036
- function resolveFunction(fn, matchedFiles) {
1037
- const resolved = fn(matchedFiles);
1038
- return (Array.isArray(resolved) ? resolved : [resolved]).map((command) => ({
1039
- command,
1040
- fromFunction: true
1041
- }));
1042
- }
1043
- /**
1044
- * Resolve config commands for a glob entry into an array of resolved commands.
1045
- * Function commands are called with matched filenames; string commands are kept as-is.
1046
- * Each resolved entry tracks whether it came from a function (for file-append behavior).
1047
- */
1048
- function resolveCommands(commands, matchedFiles) {
1049
- if (typeof commands === "function") return resolveFunction(commands, matchedFiles);
1050
- if (Array.isArray(commands)) {
1051
- const result = [];
1052
- for (const cmd of commands) if (typeof cmd === "function") result.push(...resolveFunction(cmd, matchedFiles));
1053
- else result.push({
1054
- command: cmd,
1055
- fromFunction: false
1056
- });
1057
- return result;
1058
- }
1059
- return [{
1060
- command: commands,
1061
- fromFunction: false
1062
- }];
1023
+ async function stageFiles(paths) {
1024
+ debug("stageFiles:", paths);
1025
+ await execa("git", ["add", ...paths]);
1063
1026
  }
1064
- /**
1065
- * Run resolved commands for a single glob entry, appending results.
1066
- * Function-originated commands run as-is; string commands get matched files appended.
1067
- * Returns false if any command fails (for fail-fast signaling).
1068
- */
1069
- async function runCommandsForGlob(cmds, matchedFiles, timeout, results, repoRoot) {
1070
- for (const { command, fromFunction } of cmds) {
1071
- const fullCommand = fromFunction ? command : buildCommand(command, matchedFiles);
1072
- debug("runCommandsForGlob: running '%s'", fullCommand);
1073
- const result = await runCommand(fullCommand, timeout, repoRoot);
1074
- results.push({
1075
- ...result,
1076
- files: matchedFiles
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);
1077
1042
  });
1078
- if (!result.ok) {
1079
- debug("runCommandsForGlob: check failed, stopping (fail-fast)");
1080
- return false;
1081
- }
1082
- }
1083
- return true;
1084
- }
1085
- /**
1086
- * Run all user-defined checks from .cmintrc against staged files.
1087
- * Returns a no-op result when no config exists.
1088
- * Fail-fast: stops on first error.
1089
- */
1090
- async function runAllChecks(repoRoot, stagedFiles, timeout) {
1091
- debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
1092
- if (!await detectConfig(repoRoot)) {
1093
- debug("runAllChecks: no config found, skipping checks");
1043
+ await subprocess;
1044
+ debug("attemptCommit: success");
1094
1045
  return {
1095
1046
  ok: true,
1096
- results: []
1047
+ stderr: stderrChunks.join("")
1097
1048
  };
1098
- }
1099
- const config = await loadConfig(repoRoot);
1100
- debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
1101
- const results = [];
1102
- for (const [glob, commands] of Object.entries(config)) {
1103
- const matchedFiles = matchFiles(glob, stagedFiles);
1104
- if (matchedFiles.length === 0) {
1105
- debug("runAllChecks: no files matched pattern '%s'", glob);
1106
- continue;
1107
- }
1108
- debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
1109
- 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 {
1110
1053
  ok: false,
1111
- results
1054
+ error: e.message,
1055
+ stderr: typeof e.stderr === "string" ? e.stderr : ""
1112
1056
  };
1113
1057
  }
1114
- const ok = results.every((r) => r.ok);
1115
- debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
1116
- return {
1117
- ok,
1118
- results
1119
- };
1058
+ }
1059
+ async function attemptCommitNoVerify(message, onProgress) {
1060
+ debug("attemptCommitNoVerify:", message);
1061
+ return attemptCommit(message, ["--no-verify"], onProgress);
1120
1062
  }
1121
1063
  //#endregion
1122
1064
  //#region src/services/grouping.ts
@@ -1200,8 +1142,36 @@ function buildGroupingUserPrompt(summary) {
1200
1142
  summary
1201
1143
  ].join("\n");
1202
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
+ }
1203
1168
  function parseGroupingResponse(content) {
1204
- 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);
1205
1175
  const parsed = JSON.parse(jsonText);
1206
1176
  if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
1207
1177
  const rawGroups = [];
@@ -1235,27 +1205,17 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1235
1205
  baseURLOverride: proxy
1236
1206
  });
1237
1207
  try {
1238
- const completion = await client.chat.completions.create({
1239
- messages: [{
1240
- role: "system",
1241
- content: systemPrompt
1242
- }, {
1243
- role: "user",
1244
- content: userPrompt
1245
- }],
1246
- model: resolvedModel,
1247
- temperature: .3,
1248
- max_tokens: 2048
1249
- });
1250
- const rawContent = completion.choices[0]?.message?.content;
1251
- const content = typeof rawContent === "string" ? rawContent.trim() : "";
1252
- debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
1253
- debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
1254
- if (!content) throw new Error("AI returned an empty grouping response");
1255
- const rawGroups = parseGroupingResponse(content);
1208
+ let rawGroups = await callGroupingAI(client, resolvedModel, systemPrompt, userPrompt);
1256
1209
  debug("generateGroups: parsed %d raw groups", rawGroups.length);
1257
- const validated = validateGroups(rawGroups, included);
1210
+ let validated = validateGroups(rawGroups, included);
1258
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
+ }
1259
1219
  return {
1260
1220
  groups: validated,
1261
1221
  excluded
@@ -1265,6 +1225,33 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1265
1225
  throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
1266
1226
  }
1267
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
+ }
1268
1255
  function validateGroups(groups, allFiles) {
1269
1256
  const validPaths = new Set(allFiles.map((f) => f.path));
1270
1257
  const seen = /* @__PURE__ */ new Set();
@@ -1291,7 +1278,52 @@ function validateGroups(groups, allFiles) {
1291
1278
  files: ungrouped.map((f) => f.path)
1292
1279
  });
1293
1280
  }
1294
- return validated;
1281
+ return validated;
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
+ }
1295
1327
  }
1296
1328
  //#endregion
1297
1329
  //#region src/services/clipboard.ts
@@ -1374,8 +1406,10 @@ function tryCopy(cmd, args, content) {
1374
1406
  //#endregion
1375
1407
  //#region src/ui/check-failure-menu.ts
1376
1408
  const MAX_TSC_DIAGNOSTICS = 3;
1409
+ const MAX_ESLINT_DIAGNOSTICS = 3;
1377
1410
  const MAX_SUMMARY_LINE_LENGTH = 120;
1378
1411
  const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1412
+ const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1379
1413
  function formatCheckFailureSummary(errors) {
1380
1414
  if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1381
1415
  return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
@@ -1385,6 +1419,10 @@ function formatCheckErrorSummary(error) {
1385
1419
  const diagnostics = extractTscDiagnostics(error.raw || error.message);
1386
1420
  if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1387
1421
  }
1422
+ if (error.tool === "eslint") {
1423
+ const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1424
+ if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1425
+ }
1388
1426
  const message = firstMeaningfulLine(error.message || error.raw);
1389
1427
  return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1390
1428
  }
@@ -1408,6 +1446,36 @@ function formatTscSummary(diagnostics) {
1408
1446
  if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1409
1447
  return lines.join("\n");
1410
1448
  }
1449
+ function extractEslintDiagnostics(raw) {
1450
+ const diagnostics = [];
1451
+ const lines = raw.split("\n");
1452
+ let currentFile = "";
1453
+ for (const line of lines) {
1454
+ if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
1455
+ currentFile = line.trim();
1456
+ continue;
1457
+ }
1458
+ const match = ESLINT_ERROR_LINE.exec(line);
1459
+ if (match) diagnostics.push({
1460
+ file: currentFile || "unknown",
1461
+ line: match[1] ?? "",
1462
+ column: match[2] ?? "",
1463
+ severity: match[3] ?? "",
1464
+ message: (match[4] ?? "").trim(),
1465
+ rule: match[5] ?? ""
1466
+ });
1467
+ }
1468
+ return diagnostics;
1469
+ }
1470
+ function formatEslintSummary(diagnostics) {
1471
+ const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
1472
+ const hidden = diagnostics.length - visible.length;
1473
+ const count = diagnostics.length;
1474
+ const noun = count === 1 ? "problem" : "problems";
1475
+ const lines = [` ${red("•")} [eslint] ${count} ESLint ${noun}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule} — ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1476
+ if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
1477
+ return lines.join("\n");
1478
+ }
1411
1479
  function firstMeaningfulLine(message) {
1412
1480
  return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
1413
1481
  }
@@ -1641,6 +1709,49 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
1641
1709
  }
1642
1710
  }
1643
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
1644
1755
  //#region src/commands/auto-group.ts
1645
1756
  async function runAutoGroupFlow(changedFiles, flags) {
1646
1757
  const { included, excluded } = filterExcludedFiles(changedFiles);
@@ -1818,6 +1929,245 @@ function buildExcludedFilesMessage(files) {
1818
1929
  return "chore: update generated files";
1819
1930
  }
1820
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
1821
2171
  //#region src/commands/commit-utils.ts
1822
2172
  /** Shared recovery menu factory — avoids repeating the same callback set */
1823
2173
  function makeRecoveryCallbacks(message) {
@@ -1873,6 +2223,199 @@ async function handleRetry() {
1873
2223
  else process.exit(1);
1874
2224
  }
1875
2225
  //#endregion
2226
+ //#region src/commands/setup.ts
2227
+ /** Marker files for each tool. First match wins per tool. */
2228
+ const TOOL_MARKERS = {
2229
+ biome: ["biome.json", "biome.jsonc"],
2230
+ eslint: [
2231
+ "eslint.config.js",
2232
+ "eslint.config.mjs",
2233
+ "eslint.config.ts",
2234
+ "eslint.config.cjs",
2235
+ ".eslintrc.js",
2236
+ ".eslintrc.cjs",
2237
+ ".eslintrc.json",
2238
+ ".eslintrc.yml",
2239
+ ".eslintrc.yaml",
2240
+ ".eslintrc"
2241
+ ],
2242
+ typescript: ["tsconfig.json"],
2243
+ vitest: [
2244
+ "vitest.config.js",
2245
+ "vitest.config.mts",
2246
+ "vitest.config.ts",
2247
+ "vitest.config.mjs"
2248
+ ]
2249
+ };
2250
+ /** Indent for generated config — matches biome.json `indentStyle: "tab"`. */
2251
+ const TAB = " ";
2252
+ async function exists(path) {
2253
+ try {
2254
+ await access(path, constants.R_OK);
2255
+ return true;
2256
+ } catch {
2257
+ return false;
2258
+ }
2259
+ }
2260
+ /**
2261
+ * Scan a directory for marker files that indicate which tools the project uses.
2262
+ * Returns a map of tool name to detected status. Order within each tool's list
2263
+ * is priority order (first match wins).
2264
+ */
2265
+ async function detectTools(cwd) {
2266
+ const result = {
2267
+ biome: false,
2268
+ eslint: false,
2269
+ typescript: false,
2270
+ vitest: false
2271
+ };
2272
+ for (const [tool, files] of Object.entries(TOOL_MARKERS)) for (const file of files) if (await exists(join(cwd, file))) {
2273
+ result[tool] = true;
2274
+ debug("setup: detected %s via %s", tool, file);
2275
+ break;
2276
+ }
2277
+ debug("setup: detection result %o", result);
2278
+ return result;
2279
+ }
2280
+ /**
2281
+ * Build the string content of a .cmintrc file from a detection result.
2282
+ * Returns tabs-indented TS/JS object literal with trailing commas. Biome is
2283
+ * preferred when both biome and eslint are present — overlapping globs would
2284
+ * cause both tools to run on the same files, which is wasteful and noisy.
2285
+ */
2286
+ function buildCmintrcContent(tools) {
2287
+ const entries = [];
2288
+ if (tools.biome || tools.eslint) {
2289
+ const cmd = tools.biome ? "biome check --write --no-errors-on-unmatched --error-on-warnings" : "eslint --fix";
2290
+ const ext = tools.biome ? "{js,ts,json}" : "{js,ts}";
2291
+ entries.push(`${TAB}"*.${ext}": "${cmd}",`);
2292
+ }
2293
+ const tsChecks = [];
2294
+ if (tools.typescript) tsChecks.push("tsc --noEmit");
2295
+ if (tools.vitest) tsChecks.push("vitest run --passWithNoTests");
2296
+ if (tsChecks.length > 0) {
2297
+ const body = tsChecks.map((c) => `"${c}"`).join(", ");
2298
+ const fn = tsChecks.length === 1 ? `() => ${body}` : `() => [${body}]`;
2299
+ entries.push(`${TAB}"*.ts": ${fn},`);
2300
+ }
2301
+ if (entries.length === 0) return `export default {\n};\n`;
2302
+ return `export default {\n${entries.join("\n")}\n};\n`;
2303
+ }
2304
+ /** Choose the file extension based on whether the project uses TypeScript. */
2305
+ function pickFileName(tools) {
2306
+ return tools.typescript ? ".cmintrc.ts" : ".cmintrc";
2307
+ }
2308
+ function formatDetection(tools) {
2309
+ return Object.entries(tools).map(([tool, found]) => ` ${found ? green("✓") : dim("✗")} ${tool}`).join("\n");
2310
+ }
2311
+ /**
2312
+ * Interactive setup for `.cmintrc`. Detects biome/eslint/typescript/vitest in
2313
+ * the given directory, previews the generated config, and writes the file
2314
+ * after confirmation. Refuses to overwrite without explicit consent. Defaults
2315
+ * to `process.cwd()` when called from the `cmint config` menu; the preflight
2316
+ * caller passes the repo root explicitly.
2317
+ */
2318
+ async function setupCmintrcCommand(cwd = process.cwd()) {
2319
+ debug("setupCmintrcCommand: starting in %s", cwd);
2320
+ const tools = await detectTools(cwd);
2321
+ p.log.info(`Detected tools in ${bold(cwd)}:`);
2322
+ p.log.message(formatDetection(tools));
2323
+ if (!Object.values(tools).some(Boolean)) p.log.warn("No recognized tools found. Writing an empty config to fill in manually.");
2324
+ else if (tools.biome && tools.eslint) p.log.warn(yellow("Both biome and eslint detected — using biome (remove this line to switch)."));
2325
+ const fileName = pickFileName(tools);
2326
+ const filePath = join(cwd, fileName);
2327
+ if (await exists(filePath)) {
2328
+ const overwrite = await p.confirm({ message: `${fileName} already exists. Overwrite?` });
2329
+ if (p.isCancel(overwrite) || !overwrite) {
2330
+ p.log.info(dim("Cancelled — existing file left untouched."));
2331
+ return;
2332
+ }
2333
+ }
2334
+ const content = buildCmintrcContent(tools);
2335
+ p.log.info(dim(`\nPreview of ${fileName}:`));
2336
+ p.log.message(dim(content));
2337
+ const confirm = await p.confirm({ message: `Write ${fileName}?` });
2338
+ if (p.isCancel(confirm) || !confirm) {
2339
+ p.log.info(dim("Cancelled."));
2340
+ return;
2341
+ }
2342
+ await writeFile(filePath, content, "utf-8");
2343
+ debug("setupCmintrcCommand: wrote %s", filePath);
2344
+ p.log.success(green(`Wrote ${fileName}`));
2345
+ }
2346
+ /** Project-local marker file that suppresses the preflight prompt forever. */
2347
+ const SKIP_SETUP_MARKER = ".cmint-skip-setup";
2348
+ /** True if at least one of biome/eslint/typescript/vitest is present. */
2349
+ function isAutoConfigurable(tools) {
2350
+ return Object.values(tools).some(Boolean);
2351
+ }
2352
+ /** True if the skip-setup marker exists in `cwd`. */
2353
+ async function hasSkipSetupMarker(cwd) {
2354
+ return exists(join(cwd, SKIP_SETUP_MARKER));
2355
+ }
2356
+ /** Write the skip-setup marker to `cwd`. The file is empty by design. */
2357
+ async function writeSkipSetupMarker(cwd) {
2358
+ const filePath = join(cwd, SKIP_SETUP_MARKER);
2359
+ await writeFile(filePath, "", "utf-8");
2360
+ debug("preflight: wrote skip-setup marker to %s", filePath);
2361
+ }
2362
+ /**
2363
+ * One-shot prompt run at the start of `cmint`. Skips silently if the user
2364
+ * already has a `.cmintrc` or has previously opted out (`.cmint-skip-setup`).
2365
+ * If the project is auto-configurable, asks the user whether to run setup
2366
+ * now. Choices: `yes` runs the standard setup flow; `no` proceeds without
2367
+ * setup and re-prompts next time; `never` writes a marker to suppress the
2368
+ * prompt for this project forever.
2369
+ */
2370
+ async function runPreflightSetupPrompt(cwd) {
2371
+ debug("preflight: checking %s", cwd);
2372
+ if (await hasSkipSetupMarker(cwd)) {
2373
+ debug("preflight: skip-setup marker present, skipping prompt");
2374
+ return;
2375
+ }
2376
+ const existingConfig = await detectConfig(cwd);
2377
+ if (existingConfig) {
2378
+ debug("preflight: .cmintrc present at %s, skipping prompt", existingConfig);
2379
+ return;
2380
+ }
2381
+ if (!isAutoConfigurable(await detectTools(cwd))) {
2382
+ debug("preflight: project not auto-configurable, skipping prompt");
2383
+ return;
2384
+ }
2385
+ const choice = await p.select({
2386
+ message: "No .cmintrc found. Run setup to create one from detected tools?",
2387
+ options: [
2388
+ {
2389
+ label: "Yes, set up .cmintrc",
2390
+ value: "yes"
2391
+ },
2392
+ {
2393
+ label: "No, skip for now",
2394
+ value: "no"
2395
+ },
2396
+ {
2397
+ label: "No, don't ask again",
2398
+ value: "never"
2399
+ }
2400
+ ]
2401
+ });
2402
+ if (p.isCancel(choice)) {
2403
+ debug("preflight: user cancelled prompt");
2404
+ return;
2405
+ }
2406
+ if (choice === "never") {
2407
+ await writeSkipSetupMarker(cwd);
2408
+ p.log.info(dim(`Won't ask again. Delete ${SKIP_SETUP_MARKER} to re-enable.`));
2409
+ return;
2410
+ }
2411
+ if (choice === "no") {
2412
+ p.log.info(dim("Skipping .cmintrc setup."));
2413
+ return;
2414
+ }
2415
+ debug("preflight: user chose yes, running setup");
2416
+ await setupCmintrcCommand(cwd);
2417
+ }
2418
+ //#endregion
1876
2419
  //#region src/ui/staging-menu.ts
1877
2420
  async function showStagingMenu(files, hasChecks) {
1878
2421
  debug("showStagingMenu: %d files", files.length);
@@ -2035,7 +2578,8 @@ async function runPreCommitChecks(changedFiles, noCheck) {
2035
2578
  });
2036
2579
  if (menuResult === "cancelled") process.exit(1);
2037
2580
  if (menuResult === "retried") {
2038
- debug("Re-running checks after retry...");
2581
+ debug("Re-staging files and re-running checks after retry...");
2582
+ await stageAll();
2039
2583
  const ckSpinner = spinner();
2040
2584
  ckSpinner.start("Running checks...");
2041
2585
  checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
@@ -2058,6 +2602,8 @@ async function commitCommand(flags) {
2058
2602
  debug("commitCommand called", { flags });
2059
2603
  await assertGitRepo();
2060
2604
  if (flags.retry) return handleRetry();
2605
+ const repoRoot = await getRepoRoot();
2606
+ await runPreflightSetupPrompt(repoRoot);
2061
2607
  intro("🌿 commit-mint");
2062
2608
  const status = await getStatusShort();
2063
2609
  debug("Git status:", status || "(empty)");
@@ -2104,7 +2650,7 @@ async function commitCommand(flags) {
2104
2650
  debug("All staged files are excluded:", diffResult.excludedFiles);
2105
2651
  const message = buildExcludedFilesMessage(diffResult.excludedFiles);
2106
2652
  log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
2107
- await saveCachedCommit(await getRepoRoot(), message);
2653
+ await saveCachedCommit(repoRoot, message);
2108
2654
  s.start("Running pre-commit hooks...");
2109
2655
  const result = await commitWithRecovery(message, s, await getHead());
2110
2656
  if (result === "committed") {
@@ -2163,7 +2709,6 @@ async function commitCommand(flags) {
2163
2709
  return;
2164
2710
  }
2165
2711
  message = reviewed;
2166
- const repoRoot = await getRepoRoot();
2167
2712
  await saveCachedCommit(repoRoot, message);
2168
2713
  debug("Message cached for repo:", repoRoot);
2169
2714
  s.start("Running pre-commit hooks...");
@@ -2347,13 +2892,20 @@ async function configCommand() {
2347
2892
  p.note(buildConfigDisplay(config), "commit-mint config");
2348
2893
  const action = await p.select({
2349
2894
  message: "What would you like to do?",
2350
- options: [{
2351
- label: "Edit settings",
2352
- value: "edit"
2353
- }, {
2354
- label: "Done",
2355
- value: "done"
2356
- }]
2895
+ options: [
2896
+ {
2897
+ label: "Edit settings",
2898
+ value: "edit"
2899
+ },
2900
+ {
2901
+ label: "Setup .cmintrc",
2902
+ value: "setup"
2903
+ },
2904
+ {
2905
+ label: "Done",
2906
+ value: "done"
2907
+ }
2908
+ ]
2357
2909
  });
2358
2910
  if (p.isCancel(action)) {
2359
2911
  debug("configCommand: cancelled at main menu");
@@ -2365,10 +2917,49 @@ async function configCommand() {
2365
2917
  p.outro("Config saved.");
2366
2918
  return;
2367
2919
  }
2920
+ if (action === "setup") {
2921
+ debug("configCommand: starting .cmintrc setup");
2922
+ await setupCmintrcCommand();
2923
+ continue;
2924
+ }
2368
2925
  await editSettingsLoop(config);
2369
2926
  }
2370
2927
  }
2371
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
2372
2963
  //#region src/cli.ts
2373
2964
  const { version } = package_default;
2374
2965
  cli({
@@ -2409,14 +3000,31 @@ cli({
2409
3000
  description: "Skip user-defined pre-commit checks",
2410
3001
  alias: "N",
2411
3002
  default: false
3003
+ },
3004
+ agent: {
3005
+ type: Boolean,
3006
+ description: "AI agent mode: non-interactive auto-group with JSON output",
3007
+ default: false
2412
3008
  }
2413
3009
  },
2414
- 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 () => {
2415
3021
  await configCommand();
2416
3022
  })]
2417
3023
  }, (argv) => {
3024
+ writeSessionHeader();
2418
3025
  setDebug(argv.flags.debug);
2419
- commitCommand(argv.flags);
3026
+ if (argv.flags.agent) agentCommand(argv.flags);
3027
+ else commitCommand(argv.flags);
2420
3028
  });
2421
3029
  //#endregion
2422
3030
  export {};